Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXXIII · Commit and Tag SigningVerifyingSignatures

Verifying signatures — git verify-commit, git verify-tag, --show-signature, and forge UIs

Advanced⏱ ~24 min🧪 Lab requiredgitgpgssh-keygen

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

Not yet marked complete on this device.

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 gpgsig or ssig header).
  • 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:

CodeMeaning
GGood signature (valid + trusted)
BBad signature (signature is invalid)
UGood signature, unknown validity (key not in trust store)
XGood signature but the signature has expired
YGood signature but the key has expired
RGood signature but the key has been revoked
ESignature can’t be checked (missing key, parse error)
NNo 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

  1. 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.
  2. Treat U as 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.
  3. 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.
  4. Use --raw for CI logs. The VALIDSIG line gives the key fingerprint; the audit log should record which fingerprint signed which commit.
  5. Verify the tag before deploying. The deploy step should run git verify-tag $RELEASE_TAG and 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

  1. 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?

  2. 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.

  3. 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.

  4. 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.