Automated Releases, Changelogs, and Release Notes
Every earlier lesson in this course ends with a working artifact: a tested build, a signed container image, a deployment that passed its checks. This lesson automates what happens the moment that work is ready to ship: cutting a version number, generating a changelog, and publishing release notes without anyone typing them by hand. Done well, release automation turns “did we remember to update the changelog” into a solved problem instead of a recurring chore, and it gives every consumer of your software — teammates, downstream services, end users — a reliable, auditable record of what changed and why.
Overview / How It Works
Release automation sits downstream of continuous integration and upstream of continuous deployment, but it is a distinct concern from both. Continuous integration verifies that a change is safe to merge. Continuous delivery means every merge produces a release-ready artifact, but a human decides when to actually cut and publish it. Continuous deployment removes that human gate entirely and ships automatically. Release automation is the machinery that makes any of these fast and trustworthy: it decides the next version number, assembles a changelog from the commits or pull requests that shipped, tags the commit, and publishes a GitHub Release that downstream systems and humans can consume.
Two ideas make this practical instead of ad hoc. The first is semantic versioning (semver): version numbers follow MAJOR.MINOR.PATCH, where MAJOR increments on breaking changes, MINOR on backward-compatible features, and PATCH on backward-compatible fixes. The second is the Conventional Commits specification: commit messages are prefixed with a type such as feat:, fix:, or a footer containing BREAKING CHANGE:. Because the type is machine-readable, tooling can look at the commits merged since the last release and derive both the correct next version number and a categorized changelog automatically, with no human interpretation required.
Syntax and Workflow Structure
GitHub Actions can drive this in two common shapes. The simpler shape triggers on a pushed tag and asks GitHub to generate release notes from merged pull requests since the previous tag. The more powerful shape runs on every push to your main branch, uses a tool such as release-please to parse conventional commits, and maintains a standing pull request containing the next version bump and changelog; merging that pull request is what actually cuts the release. Both approaches end at the same place — a GitHub Release object with a tag, a title, and release notes — but the second one removes almost all manual judgment from versioning.
A release workflow needs explicit permissions. The default GITHUB_TOKEN is read-only for repository contents unless you opt in, so any job that creates a release, pushes a tag, or opens a pull request must declare permissions: contents: write (and pull-requests: write if it opens PRs) at the workflow or job level. Grant only what the job actually does — a release job does not need packages: write unless it is also publishing a container image, and it never needs administration or workflow-editing scopes.
Examples
Example 1: Tag-Triggered Release with Generated Notes
A maintainer pushes a tag matching a version pattern; the workflow runs the test suite, then asks the GitHub CLI to create a release with generated notes.
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run test suite
run: npm ci && npm test
release:
needs: test
runs-on: ubuntu-latest
steps:
- name: Check out full history
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Create GitHub Release with generated notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--generate-notes
Expected behavior: pushing tag v1.2.0 after this workflow is in place runs the test job first, then creates a GitHub Release titled v1.2.0 whose body lists the merged pull requests since the previous tag, grouped automatically by GitHub’s default categorization.
$ git push origin v1.2.0
...
Release v1.2.0 created: https://github.com/org/repo/releases/tag/v1.2.0
## What's Changed
* feat: add retry logic to the payment worker by @alex in #142
* fix: correct off-by-one error in pagination by @sam in #145
**Full Changelog**: https://github.com/org/repo/compare/v1.1.0...v1.2.0
Example 2: Fully Automated Versioning with release-please
On every push to main, the action inspects commits since the last release tag. If it finds feat: or fix: commits, it opens or updates a pull request containing a version bump and a generated changelog entry. Nothing is tagged yet — merging that pull request is the trigger that creates the tag and the GitHub Release.
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@a02a34c4d625f9ff0985a83c81ea1ee31a6e0432 # v4.1.3
with:
release-type: node
token: ${{ secrets.GITHUB_TOKEN }}
Expected behavior: after a feat: commit merges to main, a pull request titled something like “chore(main): release 1.3.0” appears automatically, containing the version bump and changelog shown below. This gives a human a final look before the changelog becomes public.
## What this PR does
Automated release PR opened by release-please.
### 1.3.0 (2026-08-04)
#### Features
* add CSV export endpoint (#150)
#### Bug Fixes
* correct timezone handling in scheduler (#151)
Merging this pull request will create tag v1.3.0 and publish a GitHub Release.
Example 3: Publishing an Immutable Release Image
In most real pipelines a release is not just a Git tag — it is also a build users can run. On release: published, this workflow builds and pushes an image tagged with the release version, then captures the immutable digest that build step produced.
name: Publish Release Image
on:
release:
types: [published]
permissions:
contents: read
packages: write
jobs:
publish-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push versioned image
id: build
uses: docker/build-push-action@ca877d9245402d1537745e0e356eebaebc23d55 # v6.15.0
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }}
- name: Record immutable digest for deployment
run: |
echo "Deploy target: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
Expected behavior: publishing GitHub Release v1.3.0 builds and pushes ghcr.io/org/repo:v1.3.0, then logs its digest. A tag like v1.3.0 can later be reassigned if someone force-pushes a new image under it; a digest (sha256:...) addresses one exact set of bytes forever. Deployment manifests should reference the digest, not the tag, whenever the deployment must be reproducible and unambiguous.
Step by Step
- Adopt Conventional Commits across the team — enforce it with a commit-lint check in CI so malformed commits fail before merge, not after.
- Choose one of the two workflow shapes above based on how much manual review you want before a version number is decided.
- Add the minimal permissions block and make the release job depend on your existing test and security-scan jobs with
needs:, so nothing is ever released from unverified code. - If you also publish artifacts, chain a
release: publishedtrigger so publishing only happens after the release itself exists. - Do a dry run: push a pre-release tag such as
v0.0.1-testto a fork or test repository and confirm the release, notes, and any downstream publish steps behave as expected before relying on this in production.
Here is the manual side of step 5 — preparing and pushing a release tag from the command line before the workflow above takes over:
git add CHANGELOG.md
git commit -m "chore: prepare release v1.2.0"
git tag -a v1.2.0 -m "Release v1.2.0"
git push origin main
git push origin v1.2.0
Common Mistakes
Mistake 1: Missing the contents: write permission
A workflow that calls gh release create without a permissions block fails with an HTTP 403 from the GitHub API, because the default token grants read-only access to contents on many repositories and organizations.
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Create GitHub Release
run: gh release create "${{ github.ref_name }}" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fails: HTTP 403 - Resource not accessible by integration
The fix is one explicit block, scoped to only the jobs that need it:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Create GitHub Release
run: gh release create "${{ github.ref_name }}" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Mistake 2: Floating action references and mutable image tags
A workflow that writes uses: some-org/release-action@main will silently pull in whatever that action’s maintainer pushes next, including a compromised or unintentionally broken version — a real supply-chain risk, not a theoretical one. Publishing only a mutable tag has the same problem for deployments: the reference can silently point somewhere else later.
steps:
- uses: some-org/release-action@main
- name: Push floating image tag
run: docker push ghcr.io/org/app:latest
# Risk: @main can change without review; ":latest" can be overwritten by any later push
The fix is to pin every third-party action to a full commit SHA, with the human-readable version as a trailing comment, and to record the image digest for anything that must stay reproducible:
steps:
- uses: some-org/release-action@8f2c1e4a9b7d6c5f4e3d2c1b0a9f8e7d6c5b4a39 # v2.4.1
- name: Push versioned, digest-tracked image
id: build
run: docker push ghcr.io/org/app:v1.2.0
- name: Record digest for deployment
run: echo "Use ghcr.io/org/app@${{ steps.build.outputs.digest }} for deploys"
Best Practices
- Use semantic versioning and derive it from Conventional Commits rather than picking numbers by feel.
- Generate the changelog from commits or merged pull requests instead of hand-writing it, and keep a
CHANGELOG.mdin the repository as the durable record. - Set the narrowest
permissions:block each release job actually needs, and never grant write access to jobs triggered by pull requests from forks, since a fork’s workflow file and code are untrusted input. - Gate production-affecting releases behind a protected GitHub environment that requires review, so a merged release PR does not itself deploy without a checkpoint.
- Reference container images by digest, not tag, anywhere a deployment must be exactly reproducible.
- Pin every third-party action to a commit SHA.
- Always sequence release jobs after your test and security jobs with
needs:, so a release can never be cut from code that failed its checks.
Practice Exercises
- Add a tag-triggered release workflow to a sample repository that runs your test suite before calling
gh release createwith generated notes, using the correct minimal permissions block. - Convert the same repository to Conventional Commits and wire up release-please so a release pull request opens automatically after a
feat:orfix:commit lands on main, then merge it and confirm a tag and Release appear. - Extend the release-please workflow so merging the release pull request triggers a
release: publishedjob that builds and pushes a container image tagged with the release version, and logs its immutable digest. - Deliberately remove the
permissions: contents: writeline from a release workflow, push a tag, and read the resulting error; restore the line and confirm the release now succeeds. Repeat the experiment with an unpinned third-party action reference versus a commit-SHA-pinned one, and write one paragraph explaining why the pinned reference is safer for a workflow that has write access to your repository.
Summary
Release automation is the last mile of a CI/CD pipeline: it turns a verified, deployable artifact into a versioned, documented, publicly consumable release without manual bookkeeping. Semantic versioning gives version numbers meaning, Conventional Commits make that meaning machine-derivable, and tools like the GitHub CLI or release-please turn commit history into changelogs and tags automatically. None of this replaces the safety rules from earlier lessons — releases still need minimal permissions, pinned actions, tests and security checks gated with needs:, and immutable digests wherever a deployment must be reproducible. Automating the release step removes toil; it should never remove the checks that make a release trustworthy.
