Signed Tags as a Release Gate
A deploy script that refuses to promote anything but a tag with a valid, trusted signature, tested against a legitimate release, a signature from an untrusted key, and an unsigned tag.

Signed Tags as a Release Gate
Part 6 of 8 in the Beneath the Porcelain series: what you can build, break, and repair using nothing but git plumbing.
Every gate this series has built so far answers the question "is this code okay?" — a lint pass, a syntax check, a filename pattern. None of them answer a different, equally important question: "did the person who is supposed to authorize a release actually authorize this one?" A main branch that anyone with push access can move is not a release process; it is a shared pointer with good intentions. Promoting to production should depend on something that cannot be forged by an ordinary commit, and that a specific, deliberate action produced.
Git has had this primitive since 2005 in the form of GPG-signed tags, and since git 2.34 (2021) in the arguably more approachable form of SSH-signed tags, using the same keys most engineers already have for SSH access. Either way, the mechanism is identical: a tag object carries a cryptographic signature over its content, git verify-tag checks that signature against a set of keys you explicitly trust, and a deploy script that only promotes tags passing that check has a release gate that does not depend on branch protection rules living in a third-party platform's settings page.
Signing a Tag With an SSH Key
If GPG is not already part of your workflow — it is one more piece of infrastructure to install, configure, and keep keys current in — git's SSH signing mode uses a plain SSH keypair instead:
$ ssh-keygen -t ed25519 -f release_signer -N "" -C "release-signer"
$ git config gpg.format ssh
$ git config user.signingkey ./release_signerSigning a tag is then the same -s flag as GPG signing always used:
$ git tag -s -m "release: v1.0.0" v1.0.0A lightweight tag — the kind git tag v1.0.0 creates with no message — is just a named pointer to a commit, with no tag object at all. -s (or -a for an unsigned annotated tag) is what actually creates a real tag object with a message, a tagger, and, with -s, a signature over all of it. This distinction matters enough that git enforces it at verification time:
$ git tag v1.2.0-unsigned
$ git verify-tag v1.2.0-unsigned
error: v1.2.0-unsigned: cannot verify a non-tag object of type commit.There is nothing to verify because a lightweight tag has no signature to check in the first place — it is a ref pointing directly at a commit, not a signed object pointing at one. If your release process depends on verification, lightweight tags need to be rejected outright, not treated as "unsigned but otherwise fine."
Verification Needs a List of Who You Trust
A signature being cryptographically valid and a signature being from someone you trust are two different checks, and it is worth seeing them fail independently before wiring anything into a deploy script. SSH signature verification reads an allowed signers file: a list mapping identities to public keys, in the same format sshd uses for authorized keys but with a principal name prefixed.
$ echo "release@ci ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBwl..." > allowed_signers
$ git config gpg.ssh.allowedSignersFile ./allowed_signers
$ git tag -v v1.0.0
Good "git" signature for release@ci with ED25519 key SHA256:0gdviAedqpQrk+It2qo/c4X9wAaxmbAKM2RdXryTWWU
object 84e391ef8cfd6d6e50dc00552d4ed099d38b27c3
type commit
tag v1.0.0
tagger ...Sign a second tag with a completely different, unregistered key, and the shape of the failure is informative rather than just a flat rejection:
$ git tag -s -m "release: v1.1.0" v1.1.0 # signed with rogue_signer
$ git tag -v v1.1.0
Good "git" signature with ED25519 key SHA256:HgLmAOJdRtfq1VPJ69E/KSZj+neTeI9pcLfPtHEgkOw
No principal matched."Good signature" is still printed — the math checks out; the tag really was signed by whoever holds that private key. "No principal matched" is the second, independent check failing: that key simply is not in allowed_signers, so git has no identity to attribute it to and, critically, no basis for trusting it. git tag -v exits non-zero in this case despite reporting a "good" signature, which is exactly the behavior a deploy script needs: cryptographic validity is necessary but not sufficient, and the exit code — not the presence of the word "Good" in the output — is what you should actually branch on.
Wiring Verification Into an Actual Promote Script
A promote.sh that only checks out a tag after verify-tag succeeds turns this from a manual habit into an enforced gate:
#!/usr/bin/env bash
set -euo pipefail
# promote.sh <repo> <tag> <deploy-target>
REPO="$1"; TAG="$2"; TARGET="$3"
echo "promote: verifying signature on tag $TAG"
if ! git -C "$REPO" verify-tag "$TAG" 2>&1; then
echo "REFUSED: tag $TAG is not a valid, trusted signature. Not promoting." >&2
exit 1
fi
echo "promote: signature OK, checking out $TAG into $TARGET"
mkdir -p "$TARGET"
git -C "$REPO" --work-tree="$TARGET" checkout -f "$TAG" -- .
echo "promote: deployed $TAG"The trusted release goes through cleanly:
$ ./promote.sh repo v1.0.0 deploy
promote: verifying signature on tag v1.0.0
Good "git" signature for release@ci with ED25519 key SHA256:0gdvi...
promote: signature OK, checking out v1.0.0 into deploy
promote: deployed v1.0.0The tag signed by a key that is not on the allowed list is refused, and — this is the detail worth actually checking rather than assuming — the deploy target is genuinely untouched, not partially overwritten:
$ ./promote.sh repo v1.1.0 deploy
promote: verifying signature on tag v1.1.0
Good "git" signature with ED25519 key SHA256:HgLmAOJ...
No principal matched.
REFUSED: tag v1.1.0 is not a valid, trusted signature. Not promoting.
$ cat deploy/app.txt
app v1The checkout line never ran, because the script exits on the verify-tag failure before reaching it. That ordering — verify first, touch the filesystem second, and only if verification actually succeeded — is the entire security property this gate provides. Reverse the order, or run the checkout unconditionally and gate only the "announce success" step, and you have a script that deploys unsigned code and merely lies about whether it was authorized to.
Enforcing It at the Server, Not Just in a Script
promote.sh only protects a deploy if someone actually runs it, which means the gate still depends on process discipline: nothing stops a tag from being pushed and someone deploying it by hand, verify-tag skipped entirely. Pushing the same check into a pre-receive hook — Part 3's mechanism, applied to a new kind of ref — makes verification a property of the push itself, for any tag matching a chosen pattern:
#!/usr/bin/env bash
set -euo pipefail
zero="0000000000000000000000000000000000000000"
while read -r oldrev newrev refname; do
case "$refname" in
refs/tags/release-*) ;;
*) continue ;;
esac
[ "$newrev" = "$zero" ] && continue
tag="${refname#refs/tags/}"
echo "pre-receive: $tag matches a protected release tag pattern, verifying signature"
if ! git verify-tag "$newrev" 2>&1; then
echo "REJECTED: $tag is not signed by a trusted key. Refusing the push." >&2
exit 1
fi
echo "pre-receive: $tag signature OK"
doneThe one detail that will cost real debugging time if you miss it: that hook verifies "$newrev" — the object's own SHA — not "$tag", the ref name. Try it the more obvious way and it fails for a reason that has nothing to do with the signature:
$ git push origin release-v1.0.0
remote: pre-receive: release-v1.0.0 matches a protected release tag pattern, verifying signature
remote: error: tag 'release-v1.0.0' not found.
remote: REJECTED: release-v1.0.0 is not signed by a trusted key. Refusing the push.pre-receive runs before any ref is written — that is the whole property this series has relied on since Part 3 — which means refs/tags/release-v1.0.0 genuinely does not exist yet at the moment the hook runs, even though the underlying tag object was already unpacked into the object database as part of the same push. git verify-tag needs something it can resolve, and a ref that has not been created yet cannot be resolved by name. The SHA can, because objects arrive before refs move; the split between "objects are already here" and "refs are not yet updated" is exactly the same split Part 1 relied on when it checked out a commit by SHA before any ref pointed at it. Once that substitution is made, an unsigned or untrusted tag is rejected server-side, with no script anyone has to remember to run:
$ git push origin release-v1.0.0
remote: pre-receive: release-v1.0.0 matches a protected release tag pattern, verifying signature
remote: error: 8810777...: cannot verify a non-tag object of type commit.
remote: REJECTED: release-v1.0.0 is not signed by a trusted key. Refusing the push.
! [remote rejected] release-v1.0.0 -> release-v1.0.0 (pre-receive hook declined)And a properly signed one passes straight through, verified before the tag ref is ever visible to anyone who might clone it:
$ git push origin release-v1.0.0
remote: pre-receive: release-v1.0.0 matches a protected release tag pattern, verifying signature
remote: Good "git" signature for release@ci with ED25519 key SHA256:NLIege...
remote: pre-receive: release-v1.0.0 signature OK
* [new tag] release-v1.0.0 -> release-v1.0.0What This Gate Does and Does Not Protect Against
Signed tags answer "did someone holding a trusted key deliberately mark this commit as a release," which is a narrower and more useful question than it might first appear. It is worth being precise about the boundary:
It does not vet the commit's contents. A trusted key can sign a tag pointing at a commit nobody reviewed, containing a bug, or (as Part 7 covers) a leaked secret. Signing is an attestation of who authorized this to ship, not a correctness proof. Combine it with the testing gate from Part 3 if you want both properties enforced together — which is exactly what Part 8's capstone does.
It is only as strong as key custody. allowed_signers is a trust boundary you control entirely, which is the whole point, but that also means a leaked signing key is a leaked ability to author trusted releases, with no third-party platform to revoke it for you. Rotating a compromised key means removing it from allowed_signers on every machine that verifies, immediately — there is no central authority to do that on your behalf.
It says nothing about when something was signed relative to when it was reviewed. A tag can be signed the instant a commit is pushed, or a year after, by whoever still holds the key. If your process needs "signed only after review," that has to be a separate, enforced step: restricting which identities are even allowed to push a release-* tag in the first place, so the signature and the review gate are tied to the same trusted actor rather than assumed to line up.
Server-side verification and a manually-run promote script check the same signature at two different, complementary points. promote.sh verifies at the moment someone chooses to deploy; the pre-receive hook verifies at the moment the tag is pushed, closing the gap where a tag could exist, unverified, for however long it takes someone to remember to run the script. Neither replaces the other — a pre-receive rejection stops an untrusted tag from ever existing on the server; promote.sh still needs to run to check that verification is what gated the actual deploy, since a tag existing on the server does not by itself mean anything was promoted.
Takeaways
"Good signature" and "trusted signature" are two different checks, and only the second one is safe to gate a deploy on. SSH and GPG verification both separate cryptographic validity from identity trust; allowed_signers (or a GPG keyring plus gpg.trustlevel policy) is what turns "signed by someone" into "signed by someone I actually trust," and it is the exit code of verify-tag, not the word "Good" in its output, that reflects both checks together.
Lightweight tags cannot be verified because they are not signable objects at all. Any release process built on signed tags needs to reject lightweight tags outright — git verify-tag already does this for you, loudly, rather than silently treating them as unsigned-but-acceptable.
A pre-receive hook verifying a tag has to resolve it by SHA, not by name. The ref does not exist yet at the point pre-receive runs — that is the entire mechanism this series has used since Part 3 — so git verify-tag "$newrev" works where git verify-tag "$tag" fails with an unrelated-looking "tag not found" error. It is a one-character difference between a hook that works and one that mysteriously never verifies anything.
Verify before you touch the filesystem, not after. A promote script's entire security value lives in the ordering: check the signature, and only on success perform the deploy. Checking it "somewhere in the script" is not the same guarantee as checking it strictly before the first side effect.
Next: Part 7: Rewriting History Safely: filter-repo and Protected Refs. What happens when a secret gets committed anyway, and how to remove it without breaking every downstream clone.
Beneath the Porcelain is an eight-part series by Ajitem Sahasrabuddhe on the git internals that quietly hold together small production systems. Every command shown was run against git 2.50.1 on macOS before being written up; output may differ slightly on other platforms or versions.