Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/release-notes.yml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 55 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF > 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

All notable changes to this project are documented in this file.
38 changes: 38 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -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" },
]
14 changes: 12 additions & 2 deletions docs/cicd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
38 changes: 38 additions & 0 deletions infra/tests/infrastructure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,17 +311,50 @@ 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/);
assert.match(releaseWorkflow, /environment: staging/);
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,
Expand All @@ -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/);
Expand Down
Loading