Signing and verifying release tags Jump to heading
A release tag is a claim: this specific commit is version 4.2.0. Everything downstream trusts it — the pipeline builds from it, the artefact is named after it, the changelog is generated against it, and an incident six months later is investigated by checking it out. If anyone with push access can create or move that tag unchallenged, the claim is only as strong as the weakest credential in the organisation. Signing turns it into something verifiable. This recipe adds that to Release Tagging & Versioning.
When to use this approach Jump to heading
- You publish artefacts consumers install, so provenance is somebody else’s problem too.
- Compliance requires demonstrating who authorised each release.
- Deployment is triggered by tag creation, making the tag a production control.
- You already sign commits and want the release decision covered by the same trust model — see GPG vs SSH Commit Signing.
- If releases are cut by an automated process, this still applies: the bot gets its own key rather than borrowing a person’s.
Step 1 — Understand what a tag signature covers Jump to heading
Step 2 — Create an annotated signed tag Jump to heading
# Configure signing once (SSH signing shown; GPG works identically)
git config user.signingkey ~/.ssh/id_ed25519.pub
git config gpg.format ssh
git config tag.gpgSign true # every annotated tag is signed by default
# Cut the release tag
git tag -s v4.2.0 -m "Release 4.2.0
Adds streaming settlement export and fixes the refund rounding
regression reported in INC-4712.
Verified against: allowed_signers" What changed: the tag is now a real object containing the tagger identity, the message, and a signature over all of it plus the target commit.
# Inspect the object and its signature
git cat-file -p v4.2.0 | head -8
git verify-tag v4.2.0
# Expect: Good "git" signature for [email protected] with ED25519 key …
# Push the tag explicitly — tags are not pushed by a plain git push
git push origin v4.2.0 Setting tag.gpgSign true matters more than remembering -s. A release cut at the end of a long day is exactly when the flag gets forgotten, and an unsigned release tag is indistinguishable from a signed one until someone tries to verify it.
Step 3 — Verify the tag in the release pipeline Jump to heading
The signature is only useful if something refuses to proceed without it.
# .github/workflows/release.yml
name: release
on:
push:
tags: ['v*']
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags and history are both needed
- name: Verify the release tag signature
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/}"
# An annotated tag object must exist — a lightweight tag cannot be signed
if [ "$(git cat-file -t "$tag")" != "tag" ]; then
echo "::error::$tag is a lightweight tag and carries no signature"
exit 1
fi
git config gpg.ssh.allowedSignersFile .github/allowed_signers
git verify-tag "$tag"
echo "verified $tag"
publish:
needs: verify # nothing is published unless verify passed
runs-on: ubuntu-latest
steps:
- run: ./scripts/publish.sh
What changed: an unsigned tag, or one signed by a key outside the allowed-signers file, fails before any artefact is built or published.
# Rehearse the check locally before relying on it
git config gpg.ssh.allowedSignersFile .github/allowed_signers
git verify-tag v4.2.0 && echo "would pass in CI" SAFETY WARNING — a verification job that runs after the publish job, or in parallel with it, provides no protection whatsoever: by the time it fails the artefact is already downloadable. Express the dependency explicitly so publishing cannot start until verification has succeeded, and check the run graph rather than trusting the file’s ordering.
Step 4 — Prevent a tag from being moved after release Jump to heading
A signature proves who created the tag. It does not stop that tag being deleted and recreated pointing at a different commit — which is the more realistic attack and the more common accident.
# Server-side: refuse tag updates and deletions outright
git config receive.denyDeletes true
# Or express it as a protection rule matching v*
# (platform rulesets: block deletions, block force pushes, restrict who may create) # Locally, refuse to overwrite a tag that already exists
git config advice.forceDeleteBranch true
git tag -s v4.2.0 -m "oops" # expect: fatal: tag 'v4.2.0' already exists Publishing the trust anchor Jump to heading
Verification is only meaningful if the verifier knows which keys to accept, and that list has to reach them somehow. Keep the allowed-signers file in the repository so it is versioned and reviewed like code: a key being added is then a pull request with an author and an approver, rather than a configuration change nobody saw. For external consumers, publish the same list somewhere stable and reference it from the release notes, so someone verifying a downloaded artefact a year from now can still establish what a valid signature looks like. The one arrangement to avoid is a trust anchor that lives only in the pipeline’s configuration, because then the pipeline is both the thing being trusted and the thing defining trust.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is signing the commit enough, or does the tag need signing too? Jump to heading
They answer different questions. A commit signature attests who authored a change; a tag signature attests who declared a particular commit to be a release. Since the release decision is what triggers deployment and publishing, that is the one a pipeline should verify. Signing both is ideal, and if you can only verify one, verify the tag.
Why must the tag be annotated rather than lightweight? Jump to heading
A lightweight tag is just a ref pointing at a commit — there is no tag object, so there is nothing to sign or store a signature in. An annotated tag creates a real object holding the tagger, the date, a message and the signature, which is what verification reads. Signing implies annotation, and any release tag should be annotated regardless.
How does an automated release process sign tags? Jump to heading
With a dedicated bot identity whose key is scoped to signing only and stored in the pipeline’s secret store, never a person’s key. Give it a short expiry and rotate it on a schedule, and record it in the same allowed-signers file consumers verify against, so a signature from the release bot is as checkable as one from a human.
Related Jump to heading
- Release Tagging & Versioning — the parent guide: version calculation, tag conventions and release automation.
- Verifying Signed Commits in GitHub Actions — the commit-level counterpart of the pipeline check.
- Automating Changelog Generation with semantic-release — where the bot identity that signs automated tags comes from.