diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..d11de29a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Issue + +Closes #PLEASE_TYPE_ISSUE_NUMBER + +## Type + +type: <-- Add the type of the PR --> + + + +## Summary + +Explain the **motivation** for making this change. What existing problem does the pull request solve? + +## Checklist + +Before submitting a pull request, please ensure that you mark these task. + +- [ ] Ran `npm run dev` and `npm run build` in the repository root and test. +- [ ] If you've fixed a bug or added code that is tested + +## Notes + +Please add here if any other information is required for the reviewer. diff --git a/.github/workflows/cd-production.yml b/.github/workflows/create-release.yml similarity index 95% rename from .github/workflows/cd-production.yml rename to .github/workflows/create-release.yml index 2958a790..6d56da73 100644 --- a/.github/workflows/cd-production.yml +++ b/.github/workflows/create-release.yml @@ -3,7 +3,7 @@ name: Deploy Kaapi to EC2 Production on: push: tags: - - "v*" # Deploy only when tags like v1.0.0, v2.1.0, etc., are created + - "v[0-9]+.[0-9]+.[0-9]+" # Deploy only when tags like v1.0.0, v2.1.0, etc., are created jobs: deploy: diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/deploy-staging.yml similarity index 100% rename from .github/workflows/cd-staging.yml rename to .github/workflows/deploy-staging.yml diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 00000000..8d0b402f --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,249 @@ +name: Pre-release Tag + +on: + pull_request: + types: [closed] + branches: [main] + +concurrency: + group: pre-release + cancel-in-progress: false + +jobs: + pre-release: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + name: Pre-Release + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + + - name: Get PR type from description + id: pr-type + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = context.payload.pull_request.body || ''; + const typeMatch = body.match(/type:\s*(feat|fix|chore|docs|refactor)/i); + + if (!typeMatch) { + core.setOutput('type', 'fix'); + return; + } + + const type = typeMatch[1].toLowerCase(); + core.setOutput('type', type); + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install semantic-release + run: | + npm install -g \ + semantic-release \ + @semantic-release/commit-analyzer \ + @semantic-release/release-notes-generator \ + @semantic-release/github + + - name: Create conventional commit + env: + COMMIT_TYPE: ${{ steps.pr-type.outputs.type }} + run: | + git config user.email "ci@github.com" + git config user.name "GitHub CI" + ORIGINAL_MSG=$(git log -1 --pretty=%s) + git commit --allow-empty -m "${COMMIT_TYPE}: ${ORIGINAL_MSG}" + + - name: Run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release + + - name: Rewrite notes (cumulative, grouped, since last stable) + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, repo, per_page: 100, + }); + const latest = releases + .filter(r => r.prerelease && /-main\.\d+$/.test(r.tag_name)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0]; + if (!latest) { + core.info('No pre-release found to update.'); + return; + } + + const tags = await github.paginate(github.rest.repos.listTags, { + owner, repo, per_page: 100, + }); + const stableRe = /^v(\d+)\.(\d+)\.(\d+)$/; + let stableTag = null; + let stableVer = [-1, -1, -1]; + for (const t of tags) { + const m = stableRe.exec(t.name); + if (m) { + const v = [+m[1], +m[2], +m[3]]; + if (cmp(v, stableVer) > 0) { stableVer = v; stableTag = t.name; } + } + } + + let sinceMs = 0; + if (stableTag) { + const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${stableTag}` }); + let sha = ref.data.object.sha; + if (ref.data.object.type === 'tag') { + const t = await github.rest.git.getTag({ owner, repo, tag_sha: sha }); + sha = t.data.object.sha; + } + const c = await github.rest.repos.getCommit({ owner, repo, ref: sha }); + sinceMs = new Date(c.data.commit.committer.date).getTime(); + } + + const merged = []; + for (let page = 1; page <= 20; page++) { + const { data } = await github.rest.pulls.list({ + owner, repo, state: 'closed', base: 'main', + sort: 'updated', direction: 'desc', per_page: 100, page, + }); + if (!data.length) break; + for (const pr of data) { + if (pr.merged_at && new Date(pr.merged_at).getTime() > sinceMs) merged.push(pr); + } + if (new Date(data[data.length - 1].updated_at).getTime() < sinceMs) break; + } + + if (!merged.length) { + core.info('No merged PRs since last stable; leaving notes as-is.'); + return; + } + + const cats = [ + { title: '๐Ÿš€ Features', types: ['feat'] }, + { title: '๐Ÿ› Fixes', types: ['fix'] }, + { title: '๐Ÿงน Chores', types: ['chore'] }, + { title: '๐Ÿ“š Documentation', types: ['docs'] }, + { title: 'โ™ป๏ธ Refactors', types: ['refactor'] }, + { title: 'Other Changes', types: ['*'] }, + ].map(c => ({ ...c, items: [] })); + + const typeOf = (pr) => { + const m = (pr.body || '').match(/type:\s*(feat|fix|chore|docs|refactor)/i); + return m ? m[1].toLowerCase() : 'fix'; + }; + const seen = new Set(); + for (const pr of merged) { + if (seen.has(pr.number)) continue; + seen.add(pr.number); + const type = typeOf(pr); + const bucket = cats.find(c => c.types.includes(type)) || cats.find(c => c.types.includes('*')); + bucket.items.push(pr); + } + + let body = `## What's Changed\n`; + for (const c of cats) { + if (!c.items.length) continue; + body += `\n### ${c.title}\n`; + for (const pr of c.items) { + body += `* ${pr.title} by @${pr.user.login} in #${pr.number}\n`; + } + } + if (stableTag) { + body += `\n**Full Changelog**: https://github.com/${owner}/${repo}/compare/${stableTag}...${latest.tag_name}\n`; + } + + await github.rest.repos.updateRelease({ + owner, repo, release_id: latest.id, body, + }); + core.info(`Rewrote ${latest.tag_name} notes: ${merged.length} PR(s) since ${stableTag || 'repo start'}.`); + + - name: Remove previous pre-releases + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + + const releases = await github.paginate( + github.rest.repos.listReleases, + { owner, repo, per_page: 100 } + ); + + const preReleases = releases + .filter(r => r.prerelease && /-main\.\d+$/.test(r.tag_name)) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + + const stale = preReleases.slice(1); + + for (const r of stale) { + core.info(`Deleting stale pre-release ${r.tag_name}`); + await github.rest.repos.deleteRelease({ owner, repo, release_id: r.id }); + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `tags/${r.tag_name}`, + }); + } catch (e) { + core.warning(`Could not delete tag ${r.tag_name}: ${e.message}`); + } + } + + - name: Update PR labels + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const labelsToCreate = [ + { name: 'released', color: '0075ca' }, + { name: 'released on @main', color: '0052cc' } + ]; + + for (const label of labelsToCreate) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name + }); + } catch { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color + }); + } + } + + await github.rest.issues.removeAllLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['released', 'released on @main'] + }); diff --git a/.releaserc b/.releaserc new file mode 100644 index 00000000..ea8bbe89 --- /dev/null +++ b/.releaserc @@ -0,0 +1,25 @@ +{ + "branches": [ + "release", + { + "name": "main", + "prerelease": "main" + } + ], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "releaseRules": [ + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "chore", "release": "patch" }, + { "type": "docs", "release": "patch" }, + { "type": "refactor", "release": "patch" } + ] + } + ], + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} diff --git a/app/lib/constants.ts b/app/lib/constants.ts index 97421abd..2735d798 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -10,7 +10,7 @@ import { AnalyticsModality, } from "@/app/lib/types/analytics"; -export const APP_NAME = "Kaapi Konsole"; +export const APP_NAME = "Kaapi Konsole Staging V1"; export const STORAGE_KEYS = { API_KEYS: "kaapi_api_keys",