A release rerun that creates a second draft, uploads a binary from a different commit, or fails because the first draft already exists can turn a five-minute fix into an incident. The dangerous part is not the failed command; it is the moment a reviewer can no longer tell whether the attached asset is the one CI tested.
GitHub Actions can handle the repetitive release work: build a distributable, calculate a checksum, generate release notes, upload assets, and create a draft release. It should not silently decide that v2.4.0 is the right version, that generated notes accurately describe a breaking change, or that a bad production release should be rolled back.
Make the release tag the handoff between judgment and automation
The most useful release boundary is an immutable tag pointing at an approved commit. A protected pull request approves the code and version file; a release manager creates v2.4.0 at the merged commit; automation builds from that exact tag target. The workflow must not infer release intent merely because someone clicked Run workflow on a branch.
This matters because workflow_dispatch can run against a selected ref. Checking that VERSION contains 2.4.0 does not prove the selected ref is your protected release branch, nor that v2.4.0 points at the commit being built.
| Step | Automation owns | Human owns |
|---|---|---|
| Version proposal | Validate tag syntax and compare it with VERSION |
Approve the version bump in a protected pull request |
| Release build | Build, test, package, checksum, and upload assets | Decide when to create the release tag |
| Draft release | Generate notes and attach provenance metadata | Review customer-facing notes and migration guidance |
| Publication | Change a validated draft from draft to published | Approve the production environment and publication timing |
| Rollback | Run diagnostic or rollback commands chosen by a runbook | Decide whether to yank, revert, or ship a corrective version |
The key decision rule is simple: automate a step when the same inputs should always produce the same output. Keep it human-owned when it changes customer compatibility, production exposure, or the meaning of a version number.
Configure the repository controls before writing YAML
A workflow-level approval is only useful when the repository configuration supports it. Start with a protected main branch and, if you use them, protected release/* branches. Require pull requests, require the checks that build and test the package, and require an approving reviewer for changes to VERSION, release scripts, and workflow files.
Create a GitHub Environment named production and configure required reviewers. For small teams, use two maintainers and enable the setting that prevents a person from approving their own deployment. The publish job, not the draft-creation job, references this environment.
- Set the organization or repository default
GITHUB_TOKENpermission to read-only where possible. - Grant
contents: writeonly to the workflow job that creates or edits a release. - Limit who can trigger manual workflows to maintainers who may initiate releases.
- Limit who has repository permissions capable of editing or publishing releases.
- Use tag protection or repository rulesets so ordinary contributors cannot create a production-looking
v*tag.
There is an important limitation: an Environment approval protects a GitHub Actions job, not every action in the GitHub web UI. A user with sufficient repository permission may be able to publish a draft release manually, without running the environment-gated publish workflow. Treat “manual publishing is forbidden” as both a permissions policy and a documented operating rule; do not claim the environment gate alone enforces it.
GitHub documents environment protection rules and required reviewers in its Environments documentation. For token scope, use GitHub’s automatic token authentication guidance rather than assuming a workflow token has write access.
Validate the tag, source ref, and prerelease intent explicitly
A practical policy for a SemVer-style project is: tags must match vMAJOR.MINOR.PATCH, optionally followed by a prerelease suffix such as -rc.1. The tag must already exist, its peeled commit must equal the selected workflow commit, and the suffix must agree with the prerelease input.
The following validation belongs at the beginning of the preparation workflow. It permits runs only from main or a release/* branch, then proves that the pre-created tag names the exact selected commit.
- name: Validate release inputs and source
env:
TAG: ${{ inputs.tag }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
case "$GITHUB_REF" in
refs/heads/main|refs/heads/release/*) ;;
*) echo "Release preparation must run from main or release/*"; exit 1 ;;
esac
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then
echo "Invalid tag format: $TAG"; exit 1
fi
git fetch --force --tags
git show-ref --verify --quiet "refs/tags/$TAG" || {
echo "Create the reviewed tag before preparing a release"; exit 1
}
TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
test "$TAG_COMMIT" = "$GITHUB_SHA" || {
echo "Tag points to $TAG_COMMIT, but this run is building $GITHUB_SHA"; exit 1
}
test "v$(tr -d '[:space:]' < VERSION)" = "$TAG" || {
echo "VERSION does not match $TAG"; exit 1
}
if [[ "$TAG" == *-* ]] && [[ "$PRERELEASE" != "true" ]]; then
echo "Prerelease tag requires prerelease=true"; exit 1
fi
if [[ "$TAG" != *-* ]] && [[ "$PRERELEASE" != "false" ]]; then
echo "Stable tag requires prerelease=false"; exit 1
fi
This check deliberately requires a tag before automation starts. That is not extra ceremony; it makes the version-approval event visible, reviewable, and separate from a workflow button click.
Create an idempotent draft, not a fragile one-shot release
A common failure mode is calling gh release create on every rerun. The first run creates a draft; the second exits with “release already exists” before it can repair an interrupted upload or publish the existing draft. Define recovery behavior instead: a rerun either validates and reuses the existing draft, or stops because its metadata does not match the new build.
The preparation workflow should build from the validated commit, package an asset, and calculate a SHA-256 digest. Put both the commit SHA and digest in the draft body. A reviewer can then compare the release page, the workflow run, and the downloaded file without guessing which build produced it.
- name: Build, package, and checksum
run: |
set -euo pipefail
make test
make dist
tar -C dist -czf "mytool-${TAG}.tar.gz" .
sha256sum "mytool-${TAG}.tar.gz" | tee SHA256SUMS
- name: Generate release notes
run: |
gh api --method POST \
"repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="$TAG" \
-f target_commitish="$GITHUB_SHA" \
--jq .body > GENERATED_NOTES.md
- name: Create or verify draft release
run: |
set -euo pipefail
ASSET="mytool-${TAG}.tar.gz"
SHA="$(cut -d ' ' -f1 SHA256SUMS)"
cat GENERATED_NOTES.md > RELEASE_NOTES.md
printf '\n\nBuild commit: `%s`\nSHA-256: `%s`\n' \
"$GITHUB_SHA" "$SHA" >> RELEASE_NOTES.md
if gh release view "$TAG" --json isDraft,body,targetCommitish > release.json 2>/dev/null; then
jq -e '.isDraft == true' release.json >/dev/null
jq -e --arg sha "$GITHUB_SHA" '.body | contains($sha)' release.json >/dev/null
jq -e --arg sum "$SHA" '.body | contains($sum)' release.json >/dev/null
gh release download "$TAG" --pattern "$ASSET" --dir existing
echo "$SHA existing/$ASSET" | sha256sum --check
echo "Existing draft is valid; do not overwrite reviewed notes or assets."
else
gh release create "$TAG" "$ASSET" SHA256SUMS \
--verify-tag --draft --title "$TAG" --notes-file RELEASE_NOTES.md
fi
Generated notes are a starting point, not approval. GitHub’s release-notes API can generate notes from repository history; its behavior and parameters are documented in the Generate release notes API reference.
Publish in a second workflow after a protected approval
Use a second manually triggered workflow for publication. It takes only a tag, repeats the source-ref and tag-to-commit validation, downloads the draft asset, recomputes its checksum, and checks that the digest appears in the draft body. Only then should an environment-gated job publish it.
name: Publish release
on:
workflow_dispatch:
inputs:
tag:
description: Existing reviewed draft tag
required: true
type: string
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify draft asset and publish
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
git fetch --force --tags
test "$(git rev-list -n 1 "$TAG")" = "$GITHUB_SHA"
gh release view "$TAG" --json isDraft,body > release.json
jq -e '.isDraft == true' release.json >/dev/null
gh release download "$TAG" --pattern "mytool-${TAG}.tar.gz" --dir release
SHA="$(sha256sum "release/mytool-${TAG}.tar.gz" | cut -d ' ' -f1)"
jq -e --arg sum "$SHA" '.body | contains($sum)' release.json >/dev/null
gh release edit "$TAG" --draft=false
For packages distributed beyond GitHub, add an artifact attestation or a signing step appropriate to your ecosystem. A checksum proves that the reviewer downloaded the same bytes attached to the release; a signature or attestation adds a stronger statement about how and where those bytes were built.
Keep rollback decisions outside the happy-path pipeline
Do not add an “automatic rollback” branch that publishes a new release whenever a monitoring threshold changes. A rollback can break migrations, invalidate customer data, or require ecosystem-specific actions such as yanking a package. Automation can collect evidence and execute an approved command, but a person should choose the remedy.
A short release runbook should specify three distinct actions: unpublish or mark a release as pre-release where your distribution channel permits it, revert the deployment to a known-good artifact, and create a corrective version rather than moving an existing immutable tag. Never retag v2.4.0 to a different commit to make a release page look fixed.
Set this up this week
- Create a
productionEnvironment with required reviewers and blocked self-review. - Protect
main, protect release branches if used, and restrict creation ofv*tags. - Require a reviewed pull request for
VERSION, workflow, and release-script changes. - Implement a prepare workflow that validates the tag target, creates one draft, and records a commit SHA plus SHA-256 digest.
- Implement a separate publish workflow that verifies the existing draft asset before the environment approval publishes it.
- Run one intentional retry in a test repository. Confirm that it validates the prior draft rather than creating a duplicate or overwriting reviewed notes.
That retry test is the useful standard. A release workflow is not mature because it works once; it is mature when an interrupted run can be resumed without losing the link between the approved commit, tested artifact, reviewed draft, and published release.