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_TOKEN permission to read-only where possible.
  • Grant contents: write only 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

  1. Create a production Environment with required reviewers and blocked self-review.
  2. Protect main, protect release branches if used, and restrict creation of v* tags.
  3. Require a reviewed pull request for VERSION, workflow, and release-script changes.
  4. Implement a prepare workflow that validates the tag target, creates one draft, and records a commit SHA plus SHA-256 digest.
  5. Implement a separate publish workflow that verifies the existing draft asset before the environment approval publishes it.
  6. 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.