diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000..da6e175 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,40 @@ +name: Release Notes + +on: + workflow_dispatch: + inputs: + tag: + description: Existing tag to regenerate release notes for (for example v0.2.0) + required: true + type: string + +permissions: + contents: write + +jobs: + regenerate-release-notes: + name: Regenerate Release Notes + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate release notes + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --tag "${{ inputs.tag }}" --strip header --output RELEASE_NOTES.md + env: + GITHUB_REPO: ${{ github.repository }} + OUTPUT: RELEASE_NOTES.md + + - name: Update GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag }} + name: ${{ inputs.tag }} + body_path: RELEASE_NOTES.md + files: RELEASE_NOTES.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 457e326..771ab87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -344,20 +344,68 @@ jobs: git push origin "${VERSION}" fi - - name: Prepare release notes + - name: Generate release notes + uses: orhun/git-cliff-action@v4 + env: + GITHUB_REPO: ${{ github.repository }} + OUTPUT: RELEASE_NOTES.md + with: + config: cliff.toml + args: --tag "${{ needs.build-and-push.outputs.version }}" --strip header --output RELEASE_NOTES.md + + - name: Update CHANGELOG.md + uses: orhun/git-cliff-action@v4 + env: + GITHUB_REPO: ${{ github.repository }} + OUTPUT: CHANGELOG.md + with: + config: cliff.toml + args: --output CHANGELOG.md + + - name: Persist generated CHANGELOG.md + run: cp CHANGELOG.md "${RUNNER_TEMP}/CHANGELOG.md" + + - name: Checkout main for changelog update + uses: actions/checkout@v4 + with: + ref: main + path: changelog-main + fetch-depth: 0 + + - name: Commit CHANGELOG.md to main + working-directory: changelog-main env: VERSION: ${{ needs.build-and-push.outputs.version }} run: | - cat < release-notes.md - Release ${VERSION} + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + for attempt in 1 2 3; do + git fetch origin main + git checkout -B changelog-sync origin/main + cp "${RUNNER_TEMP}/CHANGELOG.md" CHANGELOG.md + git add CHANGELOG.md + + if git diff --cached --quiet; then + echo "CHANGELOG.md is already up to date" + exit 0 + fi + + git commit -m "chore: update CHANGELOG.md for ${VERSION}" + if git push origin HEAD:main; then + exit 0 + fi - TODO: Replace with generated release notes from issue #124. - EOF + git reset --hard origin/main + sleep 2 + done + + echo "Failed to update CHANGELOG.md on main after 3 attempts" >&2 + exit 1 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.build-and-push.outputs.version }} name: ${{ needs.build-and-push.outputs.version }} - body_path: release-notes.md - files: release-notes.md + body_path: RELEASE_NOTES.md + files: RELEASE_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f72ce50 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to this project are documented in this file. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..40ee304 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,38 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project are documented in this file. +""" +body = """ +{% if version -%} +## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} +## Unreleased +{% endif %} + +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group }} +{% for commit in commits %} +- {{ commit.message | split(pat=": ") | last | upper_first }}{% if commit.github.pr_number %} (#{{ commit.github.pr_number }}){% endif %} +{% endfor %} +{% endfor %} +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+([.-].*)?$" + +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^test", group = "Testing" }, + { message = "^docs", group = "Documentation" }, + { message = "^(chore|ci|build)", group = "Chores" }, +] diff --git a/docs/cicd.md b/docs/cicd.md index ab29b2d..5e03352 100644 --- a/docs/cicd.md +++ b/docs/cicd.md @@ -11,7 +11,7 @@ release/v0.2.0 push -> deploy staging automatically -> wait for production approval -> deploy production - -> create git tag and GitHub Release + -> create git tag, CHANGELOG.md update, and GitHub Release ``` ## GitHub Environments @@ -66,7 +66,6 @@ bash infra/scripts/deploy.sh ## Notes -- The workflow currently writes a placeholder release notes file and should be upgraded with the release-notes generation from issue `#124`. - The release workflow now runs its own backend/frontend validation before image build and deploy, so release branches are gated inside the same workflow that ships them. - Release runs are serialized per release branch with a workflow-level concurrency group so repeated pushes or reruns on the same release branch queue behind the in-flight run instead of canceling it mid-deploy. - The shared staging and production deploy jobs also use a global `station-deploy` concurrency group so different release branches cannot race each other on the same VPS or image promotion path. @@ -76,3 +75,14 @@ bash infra/scripts/deploy.sh - Health-check polling bounds each `curl` attempt with explicit connect and total timeouts so a single hung request cannot stall the full deploy window. - Release validation runs against `postgres:16-alpine` so the test database matches the same Postgres major version used by staging and production compose stacks. - The frontend runtime derives the API host from the current hostname by default (`station.drdnt.org -> api.drdnt.org`, `staging.station.drdnt.org -> staging.api.drdnt.org`), while still allowing `VITE_API_URL` to override that mapping when needed. Unknown non-localhost hosts fall back to the same hostname on port `3001`, which keeps preview and LAN-accessed environments functional without baking a frontend-only localhost default. + +## Release Notes + +Release notes are generated from conventional commits with `git-cliff`: + +- `cliff.toml` defines the changelog groups and rendering template. +- The release workflow generates `RELEASE_NOTES.md` for the current tag and uses it as the GitHub Release body and downloadable asset. +- The same release job regenerates the cumulative root `CHANGELOG.md` and commits it back to `main`. +- `.github/workflows/release-notes.yml` exists as a manual `workflow_dispatch` escape hatch for regenerating the release body for an existing tag without re-running the full deploy. + +If you need to change how commits are grouped, edit `cliff.toml` and keep the group names aligned with the conventional commit types used in this repository. diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 771b444..dfeea9f 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -311,9 +311,12 @@ test('infra scripts are executable on disk', () => { test('release workflow and CI branch rules are configured', () => { const releaseWorkflow = readInfraFile('../.github/workflows/release.yml'); + const releaseNotesWorkflow = readInfraFile('../.github/workflows/release-notes.yml'); const backendCiWorkflow = readInfraFile('../.github/workflows/backend-ci.yml'); const frontendCiWorkflow = readInfraFile('../.github/workflows/frontend-ci.yml'); const cicdDoc = readInfraFile('../docs/cicd.md'); + const cliffConfig = readInfraFile('../cliff.toml'); + const changelog = readInfraFile('../CHANGELOG.md'); assert.match(releaseWorkflow, /branches:\s*\n\s*- 'release\/\*\*'/); assert.match(releaseWorkflow, /deploy-staging/); @@ -321,7 +324,37 @@ test('release workflow and CI branch rules are configured', () => { assert.match(releaseWorkflow, /deploy-production/); assert.match(releaseWorkflow, /environment: production/); assert.match(releaseWorkflow, /softprops\/action-gh-release@v2/); + assert.match(releaseWorkflow, /orhun\/git-cliff-action@v4/); + assert.match(releaseWorkflow, /args: --tag "\$\{\{ needs\.build-and-push\.outputs\.version \}\}" --strip header --output RELEASE_NOTES\.md/); + assert.match(releaseWorkflow, /args: --output CHANGELOG\.md/); + assert.match(releaseWorkflow, /Persist generated CHANGELOG\.md/); + assert.match(releaseWorkflow, /Checkout main for changelog update/); + assert.match(releaseWorkflow, /path: changelog-main/); + assert.match(releaseWorkflow, /working-directory: changelog-main/); + assert.match(releaseWorkflow, /cp CHANGELOG\.md "\$\{RUNNER_TEMP\}\/CHANGELOG\.md"/); + assert.match(releaseWorkflow, /git checkout -B changelog-sync origin\/main/); + assert.match(releaseWorkflow, /for attempt in 1 2 3; do/); + assert.match(releaseWorkflow, /git push origin HEAD:main/); assert.match(releaseWorkflow, /Wait for production health[\s\S]*Promote images to latest/); + assert.match(releaseWorkflow, /Create git tag[\s\S]*Generate release notes[\s\S]*Update CHANGELOG\.md[\s\S]*Persist generated CHANGELOG\.md[\s\S]*Checkout main for changelog update[\s\S]*Commit CHANGELOG\.md to main[\s\S]*Create GitHub Release/); + + assert.match(releaseNotesWorkflow, /workflow_dispatch:/); + assert.match(releaseNotesWorkflow, /description: Existing tag to regenerate release notes/); + assert.match(releaseNotesWorkflow, /orhun\/git-cliff-action@v4/); + assert.match(releaseNotesWorkflow, /softprops\/action-gh-release@v2/); + assert.match(releaseNotesWorkflow, /tag_name: \$\{\{ inputs\.tag \}\}/); + + assert.match(cliffConfig, /\[changelog\]/); + assert.match(cliffConfig, /### \{\{ group \}\}/); + assert.match(cliffConfig, /Features/); + assert.match(cliffConfig, /Bug Fixes/); + assert.match(cliffConfig, /Performance/); + assert.match(cliffConfig, /Refactoring/); + assert.match(cliffConfig, /Testing/); + assert.match(cliffConfig, /Documentation/); + assert.match(cliffConfig, /Chores/); + assert.match(cliffConfig, /\^v\[0-9\]\+\\\\\.\[0-9\]\+\\\\\.\[0-9\]\+\(\[\.-\]\.\*\)\?\$/); + assert.match(changelog, /^# Changelog$/m); assert.doesNotMatch( backendCiWorkflow, @@ -345,6 +378,11 @@ test('release workflow and CI branch rules are configured', () => { assert.match(cicdDoc, /VPS_KNOWN_HOSTS/); assert.match(cicdDoc, /staging-up\.sh/); assert.match(cicdDoc, /station-staging/); + assert.match(cicdDoc, /## Release Notes/); + assert.match(cicdDoc, /git-cliff/); + assert.match(cicdDoc, /RELEASE_NOTES\.md/); + assert.match(cicdDoc, /CHANGELOG\.md/); + assert.match(cicdDoc, /release-notes\.yml/); assert.match(cicdDoc, /release workflow now runs its own backend\/frontend validation before image build and deploy/); assert.match(cicdDoc, /Release runs are serialized per release branch/); assert.match(cicdDoc, /global `station-deploy` concurrency group/);