Skip to content

Tech Story: Automated release notes generation on every GitHub Release #124

@GitAddRemote

Description

@GitAddRemote

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

  • `cliff.toml` committed to repo root with conventional commit groupings (Features, Bug Fixes, Performance, Refactoring, Testing, Chores, Documentation)
  • `git-cliff` runs in the `create-release` job of `release.yml` (Tech Story: GitHub Actions CI/CD — release-tag SSH deploy with graceful restart #90) and generates `RELEASE_NOTES.md`
  • GitHub Release body is populated from `RELEASE_NOTES.md` (not GitHub's default auto-notes)
  • `RELEASE_NOTES.md` attached as a downloadable release asset
  • `CHANGELOG.md` generated and committed to `main` after every release
  • Manual `release-notes.yml` workflow exists for regenerating notes on demand
  • `docs/cicd.md` documents the release notes process and how to customise `cliff.toml`
  • Tested against v0.1.0 tag: `git cliff --latest` produces accurate notes for all 8 closed issues

Dependencies

Metadata

Metadata

Assignees

Labels

configConfiguration and feature flagstech-storyTechnical implementation story

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions