Tech Story
As a developer, I want every GitHub Release to automatically include a formatted markdown changelog — generated from conventional commits between the previous tag and the new release — so that release notes are always accurate, consistent, and require no manual effort.
ELI5 Context
What are conventional commits? This project uses commit messages that follow a standard format: `type: description` (e.g. `feat: add OAuth support`, `fix: correct token expiry`, `chore: update deps`). Because the format is consistent, a tool can read all commits between two version tags and automatically group them into sections: Features, Bug Fixes, Performance, etc.
What is `softprops/action-gh-release`? A GitHub Action that creates (or updates) a GitHub Release — sets the title, attaches the generated markdown as the release body, and optionally uploads files as release assets. It's what wires the changelog generator output into the GitHub Releases UI.
What does the output look like? A markdown file that gets attached to the GitHub Release, e.g.:
```markdown
v0.2.0 — 2026-05-10
Features
Bug Fixes
Chores
Technical Elaboration
Tool: `git-cliff`
Use git-cliff — a fast, configurable changelog generator that reads conventional commits and renders them via a template. It is a single static binary, requires no Node dependency, and is available as a GitHub Action.
Why git-cliff over alternatives?
auto-changelog and conventional-changelog-cli require Node and extra config
- GitHub's built-in auto-generated release notes are generic and don't group by type
git-cliff reads a cliff.toml config file committed to the repo — the format is version-controlled and consistent
New file: cliff.toml (repo root)
```toml
[changelog]
header = ""
body = """
{{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}
{% for group, commits in commits | group_by(attribute="group") %}
{{ group }}
{% for commit in commits %}
- {{ commit.message | upper_first }} {% if commit.github.pr_number %}(#{{ commit.github.pr_number }}){% endif %}
{% endfor %}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^test", group = "Testing" },
{ message = "^chore|^ci|^build", group = "Chores" },
{ message = "^docs", group = "Documentation" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
```
Integration into `.github/workflows/release.yml` (issue #90)
The `create-release` job (Job 4 in #90) runs after `deploy-production` succeeds:
```yaml
-
name: Generate release notes
uses: orhun/git-cliff-action@v3
id: cliff
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: RELEASE_NOTES.md
GITHUB_REPO: ${{ github.repository }}
-
name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_VERSION }} # e.g. v0.2.0
name: ${{ env.RELEASE_VERSION }}
body_path: RELEASE_NOTES.md
files: RELEASE_NOTES.md
make_latest: true
```
Standalone manual workflow: `.github/workflows/release-notes.yml`
A separate `workflow_dispatch` workflow that can be triggered manually to regenerate release notes for any existing tag — useful for fixing a release body without re-deploying:
```yaml
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to regenerate notes for (e.g. v0.1.0)'
required: true
```
Full changelog: `CHANGELOG.md` (repo root)
In addition to per-release notes on the GitHub Release, `git-cliff` can generate a cumulative `CHANGELOG.md` covering all versions. The `create-release` job commits this file back to main after each release so it stays up to date.
```yaml
-
name: Update CHANGELOG.md
uses: orhun/git-cliff-action@v3
with:
config: cliff.toml
args: --output CHANGELOG.md
-
name: Commit CHANGELOG.md
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git diff --staged --quiet || git commit -m "chore: update CHANGELOG.md for ${{ env.RELEASE_VERSION }}"
git push origin main
```
`docs/cicd.md` additions (from #90)
Add a Release Notes section explaining:
- How `git-cliff` reads conventional commits to build the changelog
- The `cliff.toml` config and how to customise groupings
- How to manually regenerate notes for a past release
- Why `CHANGELOG.md` is committed to main (single source of truth for all versions)
Definition of Done
Dependencies
Tech Story
As a developer, I want every GitHub Release to automatically include a formatted markdown changelog — generated from conventional commits between the previous tag and the new release — so that release notes are always accurate, consistent, and require no manual effort.
ELI5 Context
What are conventional commits? This project uses commit messages that follow a standard format: `type: description` (e.g. `feat: add OAuth support`, `fix: correct token expiry`, `chore: update deps`). Because the format is consistent, a tool can read all commits between two version tags and automatically group them into sections: Features, Bug Fixes, Performance, etc.
What is `softprops/action-gh-release`? A GitHub Action that creates (or updates) a GitHub Release — sets the title, attaches the generated markdown as the release body, and optionally uploads files as release assets. It's what wires the changelog generator output into the GitHub Releases UI.
What does the output look like? A markdown file that gets attached to the GitHub Release, e.g.:
```markdown
v0.2.0 — 2026-05-10
Features
Bug Fixes
Chores
```
Technical Elaboration
Tool: `git-cliff`
Use
git-cliff— a fast, configurable changelog generator that reads conventional commits and renders them via a template. It is a single static binary, requires no Node dependency, and is available as a GitHub Action.Why git-cliff over alternatives?
auto-changelogandconventional-changelog-clirequire Node and extra configgit-cliffreads acliff.tomlconfig file committed to the repo — the format is version-controlled and consistentNew file:
cliff.toml(repo root)```toml
[changelog]
header = ""
body = """
{{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}
{% for group, commits in commits | group_by(attribute="group") %}
{{ group }}
{% for commit in commits %}
{% endfor %}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^test", group = "Testing" },
{ message = "^chore|^ci|^build", group = "Chores" },
{ message = "^docs", group = "Documentation" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
```
Integration into `.github/workflows/release.yml` (issue #90)
The `create-release` job (Job 4 in #90) runs after `deploy-production` succeeds:
```yaml
name: Generate release notes
uses: orhun/git-cliff-action@v3
id: cliff
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: RELEASE_NOTES.md
GITHUB_REPO: ${{ github.repository }}
name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_VERSION }} # e.g. v0.2.0
name: ${{ env.RELEASE_VERSION }}
body_path: RELEASE_NOTES.md
files: RELEASE_NOTES.md
make_latest: true
```
Standalone manual workflow: `.github/workflows/release-notes.yml`
A separate `workflow_dispatch` workflow that can be triggered manually to regenerate release notes for any existing tag — useful for fixing a release body without re-deploying:
```yaml
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag to regenerate notes for (e.g. v0.1.0)'
required: true
```
Full changelog: `CHANGELOG.md` (repo root)
In addition to per-release notes on the GitHub Release, `git-cliff` can generate a cumulative `CHANGELOG.md` covering all versions. The `create-release` job commits this file back to main after each release so it stays up to date.
```yaml
name: Update CHANGELOG.md
uses: orhun/git-cliff-action@v3
with:
config: cliff.toml
args: --output CHANGELOG.md
name: Commit CHANGELOG.md
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git diff --staged --quiet || git commit -m "chore: update CHANGELOG.md for ${{ env.RELEASE_VERSION }}"
git push origin main
```
`docs/cicd.md` additions (from #90)
Add a Release Notes section explaining:
Definition of Done
Dependencies