Skip to main content
RunBook Academy

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

Signing policy and enforcement — branch protection, required signed commits, and the failure modes

Advanced⏱ ~26 mingitssh-keygengpg

What you'll learn

  • Configure branch protection to require signed commits and explain the failure mode when a commit is unsigned
  • Maintain the allowed signers file as a version-controlled, reviewed security artefact
  • Diagnose the failure modes that signing enforcement does not cover — key compromise, offboarded engineers, expired keys
  • Integrate signing policy with CODEOWNERS and required reviewers as a layered control

Prerequisites

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.

Signing configuration is what an engineer does on their workstation; signing policy is what the team enforces on the repository. The two work together: the engineer configures their signing key and turns on automatic signing, and the repository’s branch protection refuses to merge any commit that does not carry a valid signature. A team that has one without the other has a posture without enforcement, or an enforcement without a posture. This lesson walks the policy-side controls and the failure modes that surface when the controls are incomplete.

The three layers of signing policy

A production signing policy has three layers, each defending against a different failure mode:

flowchart TB
    A["engineer workstation\ncommit.gpgsign = true"] --> B["CI verification\ngit log --pretty=%G?"]
    B --> C["branch protection\nrequire signed commits"]
    A -. unsigned commit .-> D["CI step catches"]
    B -. unsigned commit .-> E["branch protection refuses merge"]
  • Layer 1 — engineer workstation. commit.gpgsign true and user.signingkey set so every commit is signed by default. This is the source layer; if it is wrong, every commit is unsigned and the upstream layers have nothing to verify.
  • Layer 2 — CI verification. A pipeline step that runs git log --pretty=%G? <range> and fails on any non-G commit. This catches the engineer who bypassed signing with --no-gpg-sign or whose SSH agent is down (silent failure, see Part XXXIII-03).
  • Layer 3 — branch protection. A forge-side rule that refuses to merge any commit that does not carry a valid signature. This is the last line of defence; even if the CI step is misconfigured, the forge refuses the merge.

A team that has only layer 1 has signing configured but not enforced — an unsigned commit on the default branch is possible. A team that has only layer 3 has enforcement without source — engineers are forced to sign at merge time but the everyday commits are unsigned, and the audit trail is incomplete. All three layers are necessary.

Branch protection requiring signed commits

The forge-side rule:

  • GitHub. Settings → Branches → Branch protection rules → “Require signed commits”. The rule refuses any merge whose commits do not carry a valid signature against a key on the contributor’s GitHub account.
  • GitLab. Settings → Repository → Push rules → “Reject unsigned commits” or, for branch-level control, Settings → Protected branches → “Allowed to push: No one” plus Settings → Repository → Push rules → “reject_unsigned_commits”.
  • Bitbucket. Repository settings → Branch permissions → “Require signed commits” on the default branch.
# Verify the branch protection rule from the command line
gh api repos/OWNER/REPO/branches/main/protection \
    | jq '.required_signatures'
# true

The rule is forge-side and applies to merges, not to direct pushes (which are separately forbidden by the branch protection rules from Part XXXII). An unsigned commit on a PR is rejected at merge time with a clear error message; the engineer must rebase, amend with -S, and push again.

Maintaining the allowed signers file

For SSH signing, the trust database is the gpg.ssh.allowedSigners file. The file must be:

  • Version-controlled in the repository (or in a dedicated dotfiles repository reviewed by the security team).
  • Reviewed via pull request when an entry is added or removed. Adding an entry is a trust assignment; removing an entry is a revocation.
  • Tied to the team’s joiners-movers-leavers process. A new engineer’s entry is added on day one of their employment; a departing engineer’s entry is removed on their last day, not at the next quarterly review.
# .config/git/allowed_signers (format: email <key-type> <key-body>)
alice@corp.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
bob@corp.example.com   ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
carol@corp.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...

The file is the SSH analogue of a GPG trust database. A file that is maintained as a per-engineer local artefact is not a trust database — it is a per-engineer opinion about who to trust. The discipline is to have one file, in one place, reviewed by the team.

For GPG signing, the equivalent is the team keyring — a GPG keyring file that the CI runners import as part of their setup. The team keyring is updated through the same joiners-movers-leavers process.

Key rotation

A signing key has the same operational properties as any other credential: it must be rotated on a schedule, revoked when suspect, and removed from the trust store when its holder leaves the team.

flowchart LR
    A["key created"] --> B["added to trust store\nPR review"]
    B --> C["in use"]
    C --> D["rotation date arrives"]
    D --> E["new key created"]
    E --> F["new key added to trust store"]
    F --> G["old key removed from trust store"]
    G --> H["old key revoked"]
    C --> I["suspect compromise"]
    I --> J["key removed immediately"]
    J --> K["incident response"]

Three rotation scenarios:

  • Scheduled rotation. Every key has an expiry; the schedule is the GPG key expiry or the SSH key rotation cadence. The new key is added to the trust store before the old key expires, and the old key is removed after a grace period.
  • Suspected compromise. A lost laptop, a suspicious login, an external report. The key is removed from the trust store immediately; verification of new commits by the old key fails until the key is re-added (which it should not be).
  • Offboarding. An engineer leaves the team. Their entry is removed from the trust store on their last day, not at the next review. The signed commits they produced before leaving remain valid (the cryptographic check still passes) but new commits cannot be signed by them.

The discipline is to treat the trust store as a security artefact: changes go through pull request, removals are time-stamped, and the audit log shows who added and removed which entry.

Failure modes the policy does not cover

Signing policy is a narrow but crucial control. Knowing what it does not cover is the difference between a security posture and a false sense of security:

  • Key compromise without detection. A stolen signing key produces valid signatures; the policy cannot distinguish a legitimate signature from a forged one. The remediation is operational: key custody on dedicated machines, HSMs for release keys, monitoring for unexpected signatures.
  • Coerced signing. An engineer under duress produces a valid signature; the policy cannot detect coercion. The remediation is dual-control release ceremonies.
  • Signed merge of unreviewed code. The merge commit is signed; the merged code may have bypassed review. The remediation is branch protection (Part XXXII) requiring reviewers, plus CODEOWNERS (Part XXXI) requiring the right reviewers.
  • A signed tag pointing at an unsigned commit history. The tag is signed; the commits leading up to the tag may be unsigned. The remediation is CI verification (Part XXXIII-05) on the merge-base range before the tag is cut.
  • A signed commit by a former employee with an unrotated key. The key is still in the trust store; the cryptographic check passes; the policy layer must catch the offboarding gap. The remediation is the joiners-movers-leavers process.

Integration with CODEOWNERS and required reviewers

Signing is one of three controls on a production repository. The other two are required reviewers (CODEOWNERS) and required status checks (CI). The three are complementary:

flowchart LR
    A["PR opened"] --> B["CODEOWNERS\nrequired reviewers"]
    B --> C["CI status checks\nincluding signature verification"]
    C --> D["branch protection\nrequire signed commits"]
    D --> E["merge allowed"]
  • CODEOWNERS defends against “the wrong person made the change”. The change must be approved by the team that owns the code.
  • CI status checks defend against “the change does not build, does not pass tests, or is not signed”. The verification step is one of the required checks.
  • Branch protection requiring signed commits defends against “the change was not signed by the contributor”. This is the cryptographic control.

A team that has CODEOWNERS but no signing policy has review but no identity assertion. A team that has signing policy but no CODEOWNERS has identity assertion but no review. The three controls together produce the production posture: a change is authored by a known engineer (signing), reviewed by the right team (CODEOWNERS), and verified by the build (CI status checks).

Production discipline

  1. Layer all three controls. Workstation configuration, CI verification, and branch protection — all three, no exceptions.
  2. Version-control the trust store. Whether GPG keyring or SSH allowedSigners, the trust database is a security artefact and must be reviewed via pull request.
  3. Tie key rotation to the team’s joiners-movers-leavers process. New entries are added on day one; old entries are removed on the last day. The trust store is updated on a known schedule, not ad hoc.
  4. Document the response to a compromised key. The operational playbook for a key compromise should be written down before the compromise happens: remove the key from the trust store, audit recent commits signed by the key, rotate to a new key, notify stakeholders.
  5. Integrate signing with CODEOWNERS and CI. Signing is one of three controls; the policy fails if any one is missing. The PR template should list all three as preconditions for merge.

Cross-course references

  • Git, CI/CD & GitOps — Part XXXII (Protected branches) — the forge-side rules that refuse unsigned commits at the merge boundary.
  • Git, CI/CD & GitOps — Part XXXI (CODEOWNERS) — the review-side controls that signing complements.
  • Git, CI/CD & GitOps — Part XXVI-06 (Signing configuration) — the workstation-side configuration that makes signing the default.
  • Git, CI/CD & GitOps — Part XXXIII-05 (Verifying signatures) — the CI-side verification that catches unsigned commits before they reach branch protection.
  • Linux for Production Sysadmins — Part XII (RepositorySecurity) — the analogous policy for apt/dnf repository signing, including the same trust-store maintenance pattern.

Quiz

Knowledge check · 4 questions

  1. Q1. A team has `commit.gpgsign true` set globally and branch protection requiring signed commits. An engineer commits with `git commit --no-gpg-sign -m 'fix'` and pushes to a PR. What happens?

  2. Q2. A signing key in the SSH allowedSigners file can be safely left in place after the keyholder leaves the team, because the cryptographic check still passes.

  3. Q3. Name the three layers of signing policy and the failure mode that each layer defends against.

  4. Q4. Diagnose a policy gap where signing is enforced but the trust store is out of date, and recommend the operational fix.

    A team has the three layers configured: `commit.gpgsign true` globally, a CI step that runs `git log --pretty=%G?` and fails on non-G, and branch protection requiring signed commits. A contractor leaves the team. Three months later, an attacker uses the contractor's SSH key (which the contractor did not return and which the trust store still lists) to sign and push a commit to a PR. The CI step reports 'G' (good, trusted); branch protection accepts the merge; the commit reaches the default branch. The audit team flags the gap a week later.

Passing score: 75%. Answers are checked in this browser.