Development Task Notes

Verify Git Commit Signatures in Cloud Mac CI

Verify Git Commit Signatures in Cloud Mac CI

Once code is pushed to a cloud Mac, CI typically checks it out, builds it, and runs tests immediately. The problem is that the author name and email in a Git commit are only editable text: they indicate who the commit claims to be from, but they do not prove that the developer actually signed the object. A more reliable approach is to sign commits with SSH keys, then have CI use a controlled list of public keys to verify the entire commit range from the merge baseline to the current commit.

This gate does not replace code review or determine whether the code is safe. It addresses a narrower but important question: whether the Git objects entering the build pipeline are intact, whether their signatures are valid, and whether the signing keys belong to people currently authorized to submit code.

Define the Verification Trust Boundary

Commit verification involves at least three layers of checks:

  1. Whether the Git object content matches its signature.
  2. Whether the signing public key appears in the allowed signers file.
  3. Whether that identity was still authorized to submit code when the commit was created.

The first layer is enforced by cryptographic signatures, the second by a repository-maintained list, and the third still depends on the team’s access revocation process. Simply running git log --show-signature and seeing “Good signature” is not enough, because a valid but unauthorized key can also produce a valid signature.

Email addresses are for display and notifications, public keys are for verification, and the allowed signers file is for authorization. Do not treat them as the same thing.

Keep the list in a separate directory where changes are tightly controlled, such as .ci/trusted_signers. Changes to the list itself should require additional review so that a contributor cannot add their own public key and approve their code in the same change.

Configure Git SSH Commit Signing

Git 2.34 and later can sign directly with SSH keys. Developers should first configure the signing format and public key path locally:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true

This configuration points to the public key file; the actual signature is created with the corresponding private key. After creating a commit, confirm that the object contains a signature:

git cat-file commit HEAD | sed -n '/^gpgsig /,/^[^ ]/p'

Each line in the allowed signers file contains an identity, optional constraints, and a public key. Use stable team identifiers rather than display names that may change frequently:

ci-release namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
ios-team namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5BBBB...

After CI checks out the repository, point Git to this file:

git config --local gpg.format ssh
git config --local gpg.ssh.allowedSignersFile .ci/trusted_signers
git verify-commit HEAD

Never store private keys in the repository. CI only needs public keys for verification; private keys are required only in environments that create signed commits or tags.

Gate the Entire Commit Range

Verifying only HEAD is a common mistake. Malicious or unauthorized changes can be hidden in an earlier commit and then obscured by a final signed commit. The gate must inspect every commit after the trusted baseline.

#!/bin/bash
set -euo pipefail

base="${MERGE_BASE_SHA:?missing MERGE_BASE_SHA}"
head="${HEAD_SHA:?missing HEAD_SHA}"

git cat-file -e "${base}^{commit}"
git cat-file -e "${head}^{commit}"

count=0
while IFS= read -r commit; do
  git verify-commit "$commit"
  count=$((count + 1))
done < <(git rev-list --reverse "${base}..${head}")

printf 'verified_commits=%s\n' "$count"

MERGE_BASE_SHA should be calculated by CI from the target branch and the branch being merged, or supplied through a trusted mechanism. It must not simply be set to HEAD~1. For merge commits, also confirm that the baseline selection matches the team’s policy; otherwise, objects introduced through the second parent may be missed.

Gate output should record commit hashes and the stage at which a failure occurred, but it should not copy entire commit messages or environment variables into public logs. Stopping the build on failure is easier to audit than continuing to produce an artifact whose origin has not been verified.

Handle Shallow Clones, Tags, and Key Rotation

Shallow clones often cause false failures. If the baseline object is not available locally, rev-list cannot determine the complete range. Fetch the required target-branch history before verification, and use git cat-file -e to explicitly confirm that the baseline exists. If the baseline cannot be found, do not fall back to verifying only HEAD.

Scenario Correct handling Shortcut to avoid
Baseline missing from a shallow clone Fetch the required history and recalculate the range Verify only the final commit
Release tag Use a signed annotated tag and run git verify-tag Check only the tag name
Transition between old and new keys Keep both public keys temporarily Replace the key immediately and break historical jobs
Compromised key Remove it immediately and review the range it signed Change only the displayed identity
Departing team member Remove their allowed signer entry Disable only their routine login access

Decide in advance whether historical commits should continue to pass after a key is removed. The simplest strict mode is to verify against the current list, which is appropriate for merge gates. If historical releases must remain verifiable over the long term, preserve trust records with validity intervals and archive the release tag, commit hash, and version of the list used at the time.

Preflight Checks and Troubleshooting

Before making the gate block merges, run it in an observation period that records failures while still preventing unsigned releases from proceeding. During this period, focus on the following checks:

When troubleshooting a failure, first use git verify-commit --raw <hash> to distinguish among an unsigned object, a damaged signature, and a public key missing from the allowed list. Then inspect the repository-level settings for gpg.format and gpg.ssh.allowedSignersFile, along with the namespace, key type, and line endings in the list. This separates identity authorization issues from incomplete Git history instead of repeatedly re-signing the same commit.

Once per-commit verification, tag verification, and review of list changes are all integrated into the pipeline, the build system gains an auditable chain of origin. It cannot prove that the code is defect-free, but it can clearly show which objects were signed by which authorized keys and why unverified objects were prevented from entering subsequent build stages.

Frequently asked questions

Why is a correct author email not enough to trust a commit?

The author name and email are ordinary text fields that anyone creating a commit can set. A valid signature proves possession of a private key, while the allowed signers file determines whether that key belongs to an approved identity.

Is verifying only the branch tip sufficient?

No. Every commit introduced after the trusted base can change the resulting source tree, so the gate should run git verify-commit across the complete range. Signed release tags should also pass git verify-tag.

How can a signing key be rotated without breaking every build?

Add the new public key to the versioned allowed signers file first, accept both keys during a short transition, verify commits made with the new key, and then remove the old key.

Dedicated Development Environment

Configure a Cloud Mac for Your Next Development Task

Choose Oak M4, Oak M4 Plus, or Oak M4 Pro, then configure a rental plan across five physical nodes based on your team's location.

Configure a Cloud Mac