Git, CI/CD & GitOpsXXXIII · Commit and Tag SigningVerifyingSignatures
Verifying signatures — git verify-commit, git verify-tag, --show-signature, and forge UIs
What you'll learn
- Verify a commit and a tag with git verify-commit and git verify-tag, and interpret the exit code
- Use git log --show-signature and the %G? format specifier to audit a range of commits
- Distinguish the eight signature status codes G/B/U/X/Y/R/E/N
- Read the GitHub and GitLab UI for signed-commit status and the forge-side trust store
Prerequisites
Practice
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Verification is the consumer side of the signing contract.
Signing attests identity; verification confirms the
attestation. The verifier’s job is to take a commit or tag,
recompute the bytes the signer signed, and check the
signature against the public key. The exit code is the
ground truth: 0 means the cryptographic check passed, non-zero
means it did not. This lesson walks the four commands that
matter for verification — git verify-commit, git verify-tag, git log --show-signature, and git log --pretty=%G? — and the UI integration that surfaces the
result in GitHub and GitLab.
The verifier’s job
A verifier takes three inputs:
- The commit or tag object (the bytes that were signed).
- The signature (extracted from the
gpgsigorssigheader). - The public key (looked up in the verifier’s trust store).
The check is: recompute the canonical bytes of the commit or tag object, apply the public-key decryption to the signature, and compare the recovered hash to the recomputed hash. A match means the signature was produced by the holder of the corresponding private key. A non-match means either the bytes were altered after signing, or the signature was produced by a different key.
flowchart LR
A["commit/tag bytes"] --> B["recompute hash"]
C["signature"] --> D["decrypt with public key"]
D --> E["recovered hash"]
B --> F{"match?"}
E --> F
F -->|yes| G["valid signature"]
F -->|no| H["invalid signature"]
The check is mathematical; it does not consult a trust store.
The trust store — the GPG keyring or the SSH
allowedSigners file — is consulted to interpret the
result: a valid signature by a key in the trust store is a
verified signature; a valid signature by an unknown key is
“good with unknown validity”.
git verify-commit and git verify-tag
The two explicit verifier commands:
# Verify a specific commit
git verify-commit HEAD
# gpg: Good signature from "Alice Engineer <alice@corp.example.com>"
# gpg: WARNING: This key is not certified with a trusted signature!
echo $?
# 0
# Verify a specific tag
git verify-tag v1.0.0
# gpg: Good signature from "Ops <ops@example.com>"
echo $?
# 0
The exit code is 0 for a valid signature, non-zero for an invalid signature. The “WARNING” line is about trust, not validity: GPG distinguishes “the signature was made by this key” (mathematical) from “this key belongs to someone I trust” (web-of-trust). The two are independent.
The --raw flag prints the GPG status messages, including
the VALIDSIG line with the key fingerprint. This is the
form to use in CI:
# Print the key fingerprint that signed the tag
git verify-tag --raw v1.0.0
# [GNUPG:] NEWSIG
# [GNUPG:] GOODSIG <key-id> Ops <ops@example.com>
# [GNUPG:] VALIDSIG <fingerprint> <date> <timestamp>
# [GNUPG:] TRUST_FULLY
The CI step can grep for VALIDSIG to extract the
fingerprint and assert it matches the team’s allowlist.
git log --show-signature
For a range of commits, the --show-signature flag annotates
each commit with the signature status:
# Show signatures for the last 5 commits
git log --show-signature -5
# commit 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# gpg: Good signature from "Alice Engineer <alice@corp.example.com>"
# Author: Alice Engineer <alice@corp.example.com>
# Date: ...
#
# fix: typo in production deploy script
#
# commit 9b4c0e3...
# gpg: Can't check signature: No public key
# Author: Bob Engineer <bob@corp.example.com>
# ...
The output is verbose and human-readable, which is what an
auditor wants but not what a CI step wants. The --pretty
format specifier %G? is the machine-readable form.
The %G? format specifier
%G? prints one letter per commit representing the signature
status. The eight possible values:
| Code | Meaning |
|---|---|
G | Good signature (valid + trusted) |
B | Bad signature (signature is invalid) |
U | Good signature, unknown validity (key not in trust store) |
X | Good signature but the signature has expired |
Y | Good signature but the key has expired |
R | Good signature but the key has been revoked |
E | Signature can’t be checked (missing key, parse error) |
N | No signature |
# One-letter status per commit, for the last 5 commits
git log --pretty='%h %an %G? %s' -5
# 8a3f9d2 Alice Engineer G fix: typo in production deploy script
# 9b4c0e3 Alice Engineer G feat: add canary rollout
# 1c2d3e4 Bob Engineer N docs: update README
# 5e6f7a8 Alice Engineer G chore: bump version
# 7g8h9i0 Bob Engineer E wip: scratch notes
The audit policy is to fail on any code that is not G:
B is an invalid signature (the bytes were tampered with or
the key is wrong); U is a key the verifier does not trust
(default behaviour for GPG unless the key has been marked
trusted); X and Y are expired signatures or keys; R is
a revoked key; E is a missing key; N is no signature at
all.
# Fail the build if any commit in the range has a non-good signature
RANGE="$MAIN_BRANCH..$HEAD_BRANCH"
BAD=$(git log --pretty='%G?' "$RANGE" | grep -v '^G$' | head -1)
if [ -n "$BAD" ]; then
echo "Unsigned or invalid commit in range $RANGE: $BAD"
exit 1
fi
This is the CI step that catches unsigned commits before they reach the default branch.
Forge UI integration
GitHub and GitLab render the signature status of every commit in their web UI:
- Verified (green badge). The signature is valid against a key the user has added to their account on the forge. The forge has looked up the key fingerprint and matched it to the user’s account.
- Unverified (grey badge). The signature is valid cryptographically, but the forge cannot match the key to a user account — either the user has not added the key, or the key does not correspond to the author’s email.
- No verification (no badge). The commit is unsigned.
flowchart LR
A["push signed commit"] --> B["forge checks signature"]
B --> C["key on forge?"]
C -->|yes| D["Verified badge"]
C -->|no| E["Unverified badge"]
A --> F["unsigned commit"]
F --> G["no badge"]
The forge’s badge is a validity claim, not a trust claim (see Part XXXIII-01): the cryptographic check passed, and the forge matched the key to an account, but the trust assignment (whether the account is an authorised contributor) is the team’s policy decision, not the forge’s.
SSH verification on the verifier side
For SSH-signed commits, the verifier checks the signature
against the gpg.ssh.allowedSigners file. The file must
contain an entry for the commit’s author email; otherwise
verification fails with “no principal matched”:
# Verify an SSH-signed commit
git verify-commit HEAD
# Good "ssh" signature for alice@corp.example.com with ED25519 key SHA256:abc...
# Inspect the allowed signers file
cat "$HOME/.config/git/allowed_signers"
# alice@corp.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
# Verify with the file in scope
git -c gpg.ssh.allowedSigners="$HOME/.config/git/allowed_signers" \
verify-commit HEAD
CI verifiers must have the same allowedSigners file
available. The discipline is to version-control the file in
the repository and have the CI step mount it before
verification.
Wiring verification into CI
The production pattern is a CI step that verifies every commit on the merge-base range before the build runs:
# .github/workflows/verify-signatures.yml
name: Verify signatures
on:
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify signatures on the PR range
run: |
RANGE="origin/main..HEAD"
BAD=$(git log --pretty='%h %G? %s' "$RANGE" | grep -v ' G ' || true)
if [ -n "$BAD" ]; then
echo "Unsigned or invalid commits:"
echo "$BAD"
exit 1
fi
The pipeline fails loudly if any commit in the range is unsigned, has a bad signature, or has a key the verifier does not trust. The check is on the range (the PR’s commits) not the entire history, so old unsigned commits on the branch do not block the merge — only new unsigned commits do.
Production discipline
- Wire verification into CI. A local verifier that the engineer runs by hand is not a verifier; an unsigned commit that passes local review and breaks the supply chain is exactly the failure mode CI verification prevents.
- Treat
Uas a failure in CI. A CI step cannot make a trust decision; it should refuse the commit and let the human policy layer (the trust store update) catch up. - Version-control the trust store. Whether GPG keyring
or SSH
allowedSigners, the trust store is the source of truth for trust assignment; it must be reviewed and reproducible. - Use
--rawfor CI logs. TheVALIDSIGline gives the key fingerprint; the audit log should record which fingerprint signed which commit. - Verify the tag before deploying. The deploy step
should run
git verify-tag $RELEASE_TAGand refuse to deploy if the signature is missing or invalid. The tag is the release artefact’s identity; an unsigned or invalid tag is not a release.
Cross-course references
- Git, CI/CD & GitOps — Part XXVI-06 (Signing configuration) — the configuration keys that make signing automatic so that the verifier has something to verify.
- Git, CI/CD & GitOps — Part XXXII (Protected branches) — the branch-protection rules that require signed commits as a precondition for merging, complementing the CI verifier.
- Git, CI/CD & GitOps — Part XXX (Supply chain) — the upstream consumer that pins to signed tags and refuses unsigned artifacts.
- Linux for Production Sysadmins — Part XII (RepositorySecurity) — apt/dnf repository verification, which uses the same cryptographic-validity-plus-trust-store pattern.
Quiz
Knowledge check · 4 questions
Q1. An auditor runs `git verify-tag v1.0.0` and gets exit code 0 with the output 'gpg: Good signature from Alice Engineer' and 'WARNING: This key is not certified with a trusted signature!'. What does this mean?
Q2. For CI verification of a merge request, treating the GPG status code 'U' (good signature, unknown validity) as a failure is the right policy.
Q3. Name the three commands used to verify signatures and the one-letter format specifier that gives the signature status, and explain what the specifier's eight possible values mean.
Q4. Diagnose a CI pipeline that lets an unsigned commit through because the verification step has a bug in its range expression.
A team has a CI step that runs `git log --pretty='%G?' origin/main..HEAD` and fails on any non-G code. The pipeline has been green for weeks. An engineer opens a PR with 5 commits; one of them is unsigned (a `wip:` commit the engineer added during development). The pipeline passes. The unsigned commit is merged to main and signed-tagged for release. The release is later flagged in an audit.
Passing score: 75%. Answers are checked in this browser.