GitHub Actions in Production, Part 2: Thirty-Two Lines That Replaced a Job Nobody Was Doing
The shortest workflow I have written returns more than pipelines I spent days on. Tag-triggered GitHub Releases with generated notes, why releasing and deploying are different events, and why I deliberately stopped short of full semantic-release automation.
By Mohammed Mostafa · Published
The best workflow I've written is also the shortest one. Three steps, no conditionals, no error handling:
name: Auto Release on Version Tag
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
generate_release_notes: trueIt has never failed. I've never debugged it. It has never woken anyone up. And it took over the one task in our process that was reliably not getting done.
Introduction#
Most CI/CD writing gravitates toward the complicated stuff, which makes sense because that's where the interesting failures are. But a decent chunk of the value I've gotten out of automation came from workflows shaped like this one. Small, boring, and executed every single time without anyone remembering to do it.
The engineering in here isn't in the YAML. There's barely any YAML. It's in deciding what event should count as "a release," and that decision took longer than writing the file.
Why this workflow exists#
Release notes lose every priority argument they're ever in. They're useful to everybody and urgent to nobody, so they get written when there's spare time, and there's never spare time. What you end up with is a repo where the tag list is the only changelog and v2.4.0 tells you precisely one thing, which is that it came after v2.3.0.
The specific pain that pushed me into building this was support archaeology. Someone reports a bug. You need to know whether the fix is already out. That means finding the commit that fixed it, working out which tag contains it, and then figuring out whether that tag was actually deployed. Step two is git tag --contains <sha>, which is fine if you happen to remember it exists and you're sitting in front of a clone. Useless to a project manager asking in Slack.
The other reason was more conceptual, and I only articulated it later. We had no artifact that said this set of changes is a version we're standing behind. The deploy pipeline runs on every push to live, so production just moves continuously. "Released" wasn't a state anything tracked. It was a vibe.
Those two problems shaped the design. Releases had to be explicit, created by a deliberate human act rather than falling out of every merge. And the notes had to be generated, because anything requiring someone to sit down and write prose was going to get skipped. I knew that because it had already been getting skipped for a year.
Architecture#
developer GitHub Actions
│ │ │
│ git tag v1.4.0 │ │
│ git push --tags ────►│ │
│ │ ref matches "v*.*.*" │
│ │─────────────────────────►│
│ │ │ checkout
│ │ │ diff tag..previous-tag
│ │ │ collect merged PRs
│ │◄─────────────────────────│ POST /releases
│ │ (GITHUB_TOKEN, │
│ │ contents: write) │
│ ◄── Release page ────│ │The decision worth defending is tag-triggered rather than branch-triggered.
Cutting a release on every push to main is common and I think it's usually wrong for a service. It smashes together two questions that aren't the same question: is this code live, and is this a version. For anything continuously deployed those genuinely differ. Code hits production several times a week. Versions get declared much less often, at moments where you want a stable reference point. Before a risky migration. After a feature lands. When a client integration needs something to pin to.
Tags keep those separate. Deployment stays automatic and frequent, versioning stays manual and meaningful.
The cost is that a human has to remember to tag, and that cost is real. I'll come back to it, because the obvious fix isn't one I've taken.
The glob is worth a note. v*.*.* enforces the shape without enforcing the semantics. v1.4.0 matches, v1.4 doesn't, release-4 doesn't. v1.4.0-rc.1 does match, because the pattern isn't anchored at the end, which turned out to be convenient. Prereleases flow through the same path. They just don't get flagged as prereleases, which I'll get to.
Step-by-step explanation#
Two things about the trigger that bite people.
It's a glob, not a regex. * in Actions ref filters doesn't cross / but matches basically everything else, including letters. So v*.*.* will cheerfully match vfoo.bar.baz. If you actually care about SemVer, validate it in a step rather than trusting the filter to do it.
And git push doesn't push tags. You need git push origin v1.4.0 or git push --follow-tags. Everyone hits this exactly once: they tag, they push, nothing happens, and they conclude the workflow is broken. Putting --follow-tags in a written release procedure fixes it permanently.
The permissions block:
permissions:
contents: writeHere the scope is right and necessary. Creating a release writes to repository contents, and since GitHub moved default token permissions to read-only for new repos, dropping this block gets you a 403 from the release API.
That's a nice illustration of something I've come to prefer, actually. Declaring permissions explicitly beats inheriting the default, because the default varies by repo age and by org policy. A workflow that works in one repository can fail in another for reasons that aren't visible in the file. When a setting is required, you're forced to think about it. In the build workflow, where it was optional, it got copy-pasted wrong and sat there.
Then checkout:
- uses: actions/checkout@v4Here's a subtlety I didn't know when I wrote this. generate_release_notes: true is computed server-side by the GitHub API, not locally from git history. GitHub already has the commit graph and the PR associations, so it doesn't need your working tree. The checkout isn't strictly required.
I keep it anyway, for two reasons. The moment you want to attach a build artifact, a changelog file, or a signature, you need the tree, and this is the natural place for that to go. And action-gh-release reads repository context that behaves more predictably with a checkout present. It costs about two seconds. Cutting it to save that would be optimising the wrong axis.
The release step:
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
generate_release_notes: truegithub.ref_name on a tag push gives you the bare tag (v1.4.0) rather than the full ref, which is why it works directly in both fields. Setting tag_name explicitly even though the action can infer it means the workflow still behaves if it ever gets invoked through some other trigger.
generate_release_notes is doing all the work. GitHub walks back from this tag to the previous one, collects the pull requests merged in that range, groups them by label, credits the authors, and adds a "New Contributors" section. The output is genuinely decent.
Interesting implementation details#
The part I find most interesting is that the quality of the output is entirely downstream of your process. The action doesn't parse commits, it reads merged PRs. A team squash-merging with clean titles gets a changelog that reads like someone wrote it. A team merging branches with fix stuff and force-pushing to main gets noise.
Which produces a feedback loop I didn't design and wouldn't have predicted. Sloppy PR titles now show up in a public artifact that people look at, so PR titles got better. Not because anyone made a rule. The automation just made the sloppiness visible.
Two operational things worth knowing before you need them.
Re-running against an existing tag isn't cleanly idempotent. Depending on the inputs you'll either update the existing release or get an error. Not something you want to discover while re-running a release job at 2am.
And deleting a tag then re-pushing it triggers this again and produces a second release for the same version. Since tags are supposed to be immutable references, and something out there may already have pinned to that one, the correct recovery from a bad release is a new patch version. Never a moved tag. I know this because I moved a tag once.
Common mistakes#
Forgetting to push the tag is the universal one, and it's a documentation problem rather than a workflow problem.
Omitting permissions: contents: write gives you a 403 that reads like an authentication failure when it's actually an authorisation failure, which sends people off debugging the wrong thing entirely.
The one that catches more experienced people is assuming fetch-depth: 0 is unnecessary in general. It's unnecessary here, specifically because generation happens server-side. Carry that assumption into a semantic-release or git-cliff setup and you'll get empty changelogs, because those tools read local history and actions/checkout defaults to a depth-1 clone. A good chunk of the "my changelog is empty" issues on those projects trace back to exactly this. It's a landmine sitting one refactor away from this file.
Moving tags produces duplicate releases and breaks anyone pinned to them.
And nothing in this workflow verifies that the tagged commit ever passed CI. You can tag a broken commit and get a beautiful release page for it, which is a hole I've left open.
Last one, which is more of a design opinion: don't couple release creation to deployment. Different concerns, different failure modes. Keeping them in separate workflows means a Docker Hub outage can't stop you cutting a version.
Lessons learned#
The value of automation is frequency times friction, not complexity. This workflow is trivial and it returns more than pipelines I've spent days on, because the task it replaced was high-friction, high-frequency, and boring. That's exactly the profile humans skip. Complex automation replaces work you'd have done carefully anyway. Simple automation replaces work you'd have quietly not done, which is why it wins.
Make the machine's output depend on the human's discipline. Generating notes from PR titles created a loop that improved PR titles. Automation that surfaces the quality of your inputs is worth more than automation that papers over it, and I'd like to find more places to apply that.
Separate "deployed" from "released." For anything continuously deployed those are different states, and collapsing them means you lose the ability to reference a version at all.
Production considerations#
The thing that gives me slight pause is that this is third-party code running with contents: write. softprops/action-gh-release is widely used and well maintained, and it's still someone else's code with write access to my repository. Pin it by commit SHA rather than @v2. Tags are mutable, SHAs aren't. This is the one workflow in the library where I don't think SHA pinning is optional, precisely because the permission is real rather than latent.
There's no verification that the tagged commit is releasable. In a stricter setup I'd want the release job to check the CI status for that SHA before publishing, either via needs: in a combined workflow or by querying the check-runs API and refusing to publish against a red commit. Right now it trusts the human completely.
Prereleases come out undifferentiated. v1.4.0-rc.1 matches the glob and produces a normal release, so anyone watching for stable versions sees it. Detecting the hyphen and setting prerelease: true is a one-liner and prevents a real confusion.
And there are no artifacts attached. For a service that's fine, because the deployable thing is a container image in ECR, not a tarball on a release page. For anything other people consume, a CLI or a library or something self-hosted, an empty release is a lot less useful, and attaching build output plus checksums is the obvious next move.
Improvements#
Pin the action to a SHA, with Dependabot on github-actions so the pin moves through reviewable PRs instead of silently.
Auto-detect prereleases:
- uses: softprops/action-gh-release@<sha>
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}One expression, correctly classifies every SemVer prerelease identifier.
Validate the tag properly, since the glob accepts non-SemVer strings. A short guard rejecting anything that doesn't match ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ catches typos like v1.40 before they become a permanent public release.
Gate on green CI by querying check-runs for the tagged SHA. Closes the hole above.
The one with the best effort-to-output ratio, though, is curating the generated notes with .github/release.yml. GitHub reads that file and uses it to group PRs into sections by label:
changelog:
exclude:
labels: [dependencies, ci]
categories:
- title: Breaking Changes
labels: [breaking]
- title: Features
labels: [feature, enhancement]
- title: Fixes
labels: [bug, fix]That turns a flat list of PR titles into something structured, and it doesn't touch the workflow at all.
Attach artifacts and checksums if anything downstream consumes releases rather than images.
And then there's full automation, which I've deliberately not done. semantic-release or release-please can derive the version from Conventional Commits and remove the manual tagging step entirely. I've read the setup guides twice and backed off both times. Manual tagging is the last human checkpoint in a path that is otherwise fully automatic from merge to production, and I value that checkpoint more than I value saving thirty seconds. Automate the tedious part. Keep the decision.
Next: push-docker-hub.yml, where layer caching saved us minutes per build and a nested expression I never noticed made the whole file invalid.
- For a continuously deployed service, "is this code live" and "is this a version" are different questions. Code reaches production several times a week; versions get declared far less often, at points where you want a stable reference. Tags keep deployment automatic and versioning deliberate.
- Why did my tag push not trigger the release workflow?
- Plain `git push` does not push tags. You need `git push origin v1.4.0` or `git push --follow-tags`. Nearly every team hits this once, tags, pushes, sees nothing happen, and concludes the workflow is broken. Putting --follow-tags in a written release procedure fixes it permanently.
- Does generate_release_notes need fetch-depth: 0 on actions/checkout?
- No. GitHub computes those notes server-side from the commit graph and merged pull requests, so a shallow clone is fine. Tools that generate changelogs locally, such as semantic-release or git-cliff, do need fetch-depth: 0, and most "my changelog is empty" reports trace back to that.
- What happens if you delete and re-push a Git tag?
- The workflow triggers again and creates a second release for the same version, and anything already pinned to that tag now points at different code. Tags are immutable by convention, so the correct recovery from a bad release is a new patch version rather than a moved tag.
- How do you group generated GitHub release notes into sections?
- Add a .github/release.yml file. GitHub reads it and groups pull requests into titled categories by label, and excludes labels such as dependencies or ci. It turns a flat PR list into a structured changelog without changing the workflow at all.