Signing release tags from a pipeline Jump to heading

When release tags are signed on a maintainer’s laptop, the release process depends on one person being available, with a working key, on the right machine. It also means the key that vouches for every release lives somewhere that runs a browser, a chat client and a few hundred development dependencies. Moving tag creation into a pipeline fixes both: releases happen whenever the process says they should, and the signing key lives only in a constrained CI environment. The risk moves too — now the pipeline must refuse to tag anything it should not. This page builds the pipeline with that guard in place, within signed commits in CI pipelines.

When to use this approach Jump to heading

  • Releases are cut from a branch by a defined process, not ad hoc.
  • You want release tags signed by an identity users can verify, and published with each release.
  • You are comfortable with a pipeline holding a release key, scoped as in protecting CI signing keys with environment secrets, or with keyless signing.
  • Your build already verifies tags before building artefacts — if not, start with signed tags vs signed commits.

Step 1 — Decide what triggers a release tag Jump to heading

Something must tell the pipeline “release this commit as this version”. Three patterns are common, and each puts a human decision somewhere different.

Three ways to ask the pipeline for a releaseA manual dispatch puts the decision in a person's hands at release time. A merged release pull request records the decision as a reviewed change. Version computed from commit messages removes the decision entirely, which suits frequent small releases.Where the decision isSuitsmanual dispatchperson, with inputsscheduled releasesmerged release PRreviewed difflibraries, changelogscomputed from commitsnobody, by rulecontinuous deliverywhichever you choose, the guard in step 2 still runs
# Manual dispatch with a version input
on:
  workflow_dispatch:
    inputs:
      version: { description: "Version to release, e.g. 2.4.0", required: true }

Step 2 — Guard the commit before tagging it Jump to heading

The pipeline must refuse to tag a commit that is not on the release branch, not green, or not signed by a trusted contributor. This guard is the security boundary; the signature only records that the boundary was passed.

#!/bin/sh
# ci/release-guard.sh <commit> <version>
set -eu
c=$1 v=$2
echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' || { echo "bad version"; exit 1; }
git merge-base --is-ancestor "$c" origin/main      || { echo "not on main"; exit 1; }
! git rev-parse -q --verify "refs/tags/v$v" >/dev/null || { echo "tag exists"; exit 1; }
[ "$(git log -1 --format=%G? "$c")" = G ]          || { echo "commit not signed by trusted key"; exit 1; }
gh api "repos/$GITHUB_REPOSITORY/commits/$c/status" --jq .state | grep -qx success \
                                                   || { echo "checks not green"; exit 1; }
The release guard's decisionsBefore signing, the guard checks the version format, that the commit is on main, that the tag does not already exist, that the commit is signed by a trusted key and that its checks passed. Any failure stops the release before a key is ever loaded.May this commit become a release?all checks passSign and push tagrelease proceedsnot on main / not greenRefusefix and retrytag already existsRefusenever move a releasethe guard runs before the signing key is loaded, so a refused release never touches it

Step 3 — Sign the tag in a job that holds the key Jump to heading

Run the guard and the signing in a job that declares the release environment. Sign with an SSH key loaded into a temporary agent, or keylessly if your verifiers support it.

jobs:
  release:
    runs-on: ubuntu-latest
    environment: release-signing
    permissions: { contents: write }
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: ./ci/release-guard.sh "$GITHUB_SHA" "${{ inputs.version }}"
      - run: ./ci/sign-tag.sh "$GITHUB_SHA" "${{ inputs.version }}"
        env: { SIGNING_KEY: "${{ secrets.RELEASE_SIGNING_KEY }}" }
#!/bin/sh
# ci/sign-tag.sh <commit> <version>
set -eu
eval "$(ssh-agent -s)" >/dev/null; trap 'ssh-agent -k >/dev/null' EXIT
printf '%s\n' "$SIGNING_KEY" | ssh-add - >/dev/null; unset SIGNING_KEY
git config user.name "release-bot"; git config user.email "[email protected]"
git config gpg.format ssh; git config user.signingKey "key::$(ssh-add -L | head -1)"
git tag -s "v$2" "$1" -m "Release $2"

Step 4 — Verify the tag before pushing it Jump to heading

Verify the tag with the same trust file users and your deploy pipeline will use. If it does not verify here, it will not verify anywhere, and it is far better to discover that before the tag is public.

git -c gpg.ssh.allowedSignersFile=trust/release-signers verify-tag "v$VERSION"
git cat-file -p "v$VERSION" | sed -n '1,5p'      # object, type, tag, tagger
git push origin "v$VERSION"
From release request to published tagA maintainer dispatches the workflow with a version. The environment releases the signing key after approval. The job runs the guard, signs the tag, verifies it with the published trust file and only then pushes it, which triggers the build that verifies it again.maintainerrelease jobgitbuild jobdispatch v2.4.0guard: on main, green, signedtag -s, verify-tagpush tagtag push triggers buildverify-tag againverifying before the push means a mis-signed tag never becomes public

Step 5 — Publish what users need to verify it Jump to heading

A signed tag is only useful if users can check it. Publish the release-signing public key and the one-line verification command with every release, and keep the trust file used in step 4 in the repository.

gh release create "v$VERSION" --verify-tag --notes-file RELEASE_NOTES.md
cat >> RELEASE_NOTES.md <<EOF

Verify this release:
    git fetch --tags && git -c gpg.ssh.allowedSignersFile=trust/release-signers verify-tag v$VERSION
EOF

--verify-tag makes the release command fail if the tag does not exist on the remote, which prevents publishing release notes for a tag that never got pushed.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Should the tag be signed by the bot or by the maintainer who approved it? Jump to heading

The bot signs; the approval is recorded by the environment’s deployment history and, if you want it in Git, in the tag message. Getting a maintainer’s personal key into the pipeline would undo the point of moving signing off laptops.

Can I use keyless signing for tags? Jump to heading

Yes, with gitsign or a similar tool; the tag’s signature then names the workflow identity instead of a key. Users verify by checking that identity rather than a public key, as described in keyless commit signing with Sigstore gitsign.

What if a release tag was signed and pushed by mistake? Jump to heading

Do not move it. Publish a new version and mark the mistaken one as withdrawn in its release notes. The reasons and the narrow exceptions are in moving or deleting a published tag.