Skip to content
shellhacks.dev
CI/CD

How to Create Releases and Changelogs in CI With Examples

Automate releases and changelogs in CI from a git tag: trigger on tags, generate notes with gh or git-cliff, attach artifacts and keep it reproducible.

6 min read
Summarize in AI
Illustration for "How to Create Releases and Changelogs in CI With Examples": a terminal session, test results, a git branch graph.

Release day should be a tag and a coffee. Everything else, from the build to the notes to the uploaded files, is repeatable work that a pipeline does the same way every time and a person forgets a step of. This post builds a tag-driven release workflow. It follows versioning in CI and uses the artifacts from image builds. Examples use GitHub Actions with the gh CLI 2.6x and git-cliff 2.x.

The workflow

.github/workflows/release.ymlYAML
name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Build
        run: |
          make dist VERSION="${GITHUB_REF_NAME#v}"
          cd dist && sha256sum * > SHA256SUMS

      - name: Publish
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "$GITHUB_REF_NAME" dist/* \
            --title "$GITHUB_REF_NAME" \
            --generate-notes \
            --verify-tag

${GITHUB_REF_NAME#v} strips the leading v with the shell expansion described in removing a prefix, so the build gets 1.4.0. The checksum file lets users verify downloads with sha256sum -c SHA256SUMS. --generate-notes asks GitHub to summarize merged pull requests and contributors since the previous tag, which works well if pull request titles are good. --verify-tag fails if the tag is not on the remote, a guard against creating a stray tag from the release call.

A changelog from commits

cliff.tomlTOML
[changelog]
header = "# Changelog\n"
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}- {{ commit.message | split(pat="\n") | first }}
{% endfor %}
{% endfor %}
"""

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
  { message = "^feat", group = "Features" },
  { message = "^fix", group = "Fixes" },
  { message = "^perf", group = "Performance" },
  { message = "^docs", skip = true },
]
Shell
git cliff --latest --strip header > RELEASE_NOTES.md
gh release create "$GITHUB_REF_NAME" dist/* --notes-file RELEASE_NOTES.md

git-cliff reads Conventional Commits from the history and groups them. --latest renders only the changes since the previous tag. This produces consistent notes without anyone editing a file by hand, provided commit messages follow the convention; a commit linter in CI helps keep them honest.

Draft first when it matters

Add --draft to create the release unpublished, review the notes and artifacts, then publish by hand. For a public project this one pause is worth it, since a release is visible and hard to retract. Pre-release tags such as v1.5.0-rc.1 can pass --prerelease, chosen with a conditional on the tag name.

Keep it reproducible

  • Build from the tagged commit only, never from the branch head.
  • Pin tool versions, and print them to the log.
  • Do not let the job commit back to the repository; if the changelog must be a file, generate it before tagging.
  • Protect the tag pattern with a repository rule so that only maintainers can create v* tags.

Where this does not apply

If the release must be signed or attested, add that step before publishing, using the provenance tooling described in CI artifacts and provenance. Libraries published to a package registry also need a publish step with its own credentials, preferably through OIDC as in OIDC in CI. Tools such as release-please or semantic-release take the whole flow further, including opening the version bump pull request, at the cost of more convention to follow.

Triggering on a tag and what the job needs to see

A release workflow typically starts when someone pushes a tag that matches a pattern such as v*. The trigger is simple, and the job has to fetch enough history to be useful. A default shallow checkout contains only the tagged commit, so a changelog built from the commits since the previous tag finds nothing, and a version computed with git describe fails. Set the checkout depth to full with fetch-depth: 0, which also fetches the tags. Give the job the permission to create a release, contents: write, and nothing more. Build from the tag itself, never from the branch head at the time the job runs, because a commit can land between the tag and the job, and the artifacts would then not match what the tag names. Make the workflow refuse tags that do not look like a version, and refuse a tag whose commit is not on the main branch, so a stray tag from a feature branch cannot publish a release.

Building the notes from history

The simplest changelog lists the subject lines of commits between the previous tag and the new one, using git log with a range and a short format. It is only as good as the commit messages, and a project with messages such as "fix stuff" gets a changelog to match. Teams that use conventional commit prefixes can group the entries into features, fixes and breaking changes with a script or a tool such as git-cliff, and the structure makes the notes readable by users. Squash merges make the pull request title the message that lands in history, so the title is what matters. Pull request labels are another source, and the hosting service can generate notes from them. Whatever you use, read the result before publishing. Automated notes include internal chores that readers do not care about, and omit the context that makes a change understandable, and a person adding two sentences at the top improves them a great deal.

Drafts, assets and reproducibility

For releases that matter, create the release as a draft, attach the artifacts and checksums, and have a person review and publish it. That adds a pause where a mistake can be caught, and costs a minute. Attach the files that users need and a checksum file computed in the job, and consider signing them or attaching a build provenance attestation so users can verify where they came from. The release should be reproducible from the tag: the same source, the same pinned toolchain and the same build command should give the same artifacts, and the job should record the versions it used. Never move or delete a published tag, since people and automation already depend on it. If a release is bad, publish a new version that fixes it and mark the old release as broken in its notes, since a tag that changes under users destroys the meaning of a version.

Publishing artifacts and checking the release

The workflow should build every artifact in one job, name each with the version, and publish them together, so the release is never half there. For binaries, build for each platform in a matrix and collect the files in a final job that creates the release. Attach a checksum file with the SHA-256 of every artifact, so users can verify downloads, and consider signing it. After publishing, run a short verification: download the release assets in a clean job, check the checksums and run the binary with its version flag, comparing the output with the tag. A job that fails here catches an artifact built from the wrong commit or a version string that was never updated. Update any packaging that follows the release, such as a container image tag, a package manager recipe or the documentation site, from the same version output, and not from a second hand-typed number.

Announce the release where your users look, and link to the notes. A good release page names what changed, what to do when upgrading, and what broke, with the migration steps for any breaking change. Keep a stable link to the latest release, so scripts and documentation can point to it without edits. When a release goes wrong, say so plainly in the notes and in the place users hear about it, name the affected versions, and point to the fix, since silence costs more trust than the mistake did.

Finally, give the project a short document describing how a release is made, with the exact steps for a person to follow when the pipeline fails, because a pipeline that nobody can run by hand is a risk on the day you need a release in a hurry.

See gh release create and git-cliff documentation.

Also inGit
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.