From e4f5ee9b40caf13e6c068cb6ce9ea2ed2d940ff9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 16:40:32 -0700 Subject: [PATCH 1/2] Add a git-hygiene rule for signing identity during a history rewrite A history rewrite that re-signs commits (e.g. filter-repo to strip PII) preserves each commit's author but makes the runner the signer, so a blanket re-sign stamps the maintainer's key over bot-authored commits (dependabot[bot], github-actions[bot]) - a signature that does not match the author, which GitHub marks unknown_key/unverified and a require-signed-commits ruleset rejects. This has recurred three times, and AGENTS.md only stated the principle in passing ("you cannot sign another contributor's commits for them") without an operational rule. Add an explicit two-gate rule to AGENTS.md "Git and Commit Rules": scope the rewrite to only the commits that must change (by default your own, re-signed with your key), and if a commit that must change is not yours, re-identify its author/committer to the signing identity before re-signing so signature and author agree. Cross-reference it from the brownfield re-signing note in docs/repo-config-carry.md. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 1 + docs/repo-config-carry.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2e2933e3..c3e1d24f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her - **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. - **Commit under the committing account's own GitHub `noreply` identity - never a private, personal, or invented address.** The `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit (above) - GitHub issues these in a `username@users.noreply.github.com` or `ID+username@users.noreply.github.com` form, and for this single-maintainer fleet it is the owner's `ptr727@users.noreply.github.com`. Do not set `user.name`/`user.email` to a fabricated persona, bot name, or product name, and do not commit under whatever identity the environment happens to carry: verify `git config --get user.email` is that GitHub `noreply` address before committing, and fix it if not. A wrong identity is not cosmetic - a private email trips GitHub's email-privacy push protection (GH007), and an unrecognized or invented author pollutes history. Identity is separate from signing: a wrong author does not by itself fail the signature rule, but the ad-hoc identities that produce it are typically also unsigned, which the signing rule above then rejects on push. - **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. +- **A history rewrite includes only the commits that must change, and re-identifies any commit it rewrites that is not yours.** Filtering history (`git filter-repo` / `filter-branch`, e.g. to strip PII) makes whoever runs it the committer and signer of every commit it touches, while preserving the original `author`. Re-signing a bot-authored commit (`dependabot[bot]`, `github-actions[bot]`) therefore leaves your key over the bot's identity - a signature that does not match the author, which GitHub marks `unknown_key`/unverified and a require-signed-commits ruleset rejects. Two gates keep signature and identity aligned. **First, scope the rewrite to only the commits that must be modified** - by default those are your own, re-signed with your key, and a commit that does not need changing is kept out of the rewrite so its identity and signature are never touched. **Second, if a commit that must change is not yours, re-map its `author` and `committer` to the signing identity before re-signing** so the rewritten commit is yours and its signature matches its author - the original bot attribution is deliberately given up as the cost of having to rewrite it. Never leave a maintainer signature over a bot author. Verify after the rewrite: `git log --format='%an%x09%GS'` must show no commit whose author and signer disagree. - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. ## Branching Model diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index e463a1de..33f9374a 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -50,7 +50,7 @@ done ## Brownfield Migration (Maintainer Only) -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits authored by a bot (`dependabot[bot]`, `github-actions[bot]`), re-identify each to the signing identity before re-signing rather than leaving your key over the bot's identity (see the history-rewrite rule in [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules]). From 2dc22739d7c0adbf7870c2f092b972a94f888e51 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 22 Jul 2026 16:47:55 -0700 Subject: [PATCH 2/2] Address Copilot round 1: center the rule on committer identity, not author GitHub verifies a commit signature against the committer identity, not the author, so the rule was inaccurate and internally inconsistent (it claimed the rewrite makes the runner the committer, then keyed on author-matching). Re-center it: a your-key signature over a commit still committed by a bot or GitHub web-flow is what GitHub marks unknown_key/unverified, so the second gate sets the committer (and author, since the content changed) to the signing identity. Fix the repo-config-carry cross-reference the same way, and replace the fragile author/signer log check with git log --show-signature. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- docs/repo-config-carry.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3e1d24f..1ae694d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ A state-changing GitHub call is the highest-blast-radius thing an agent does her - **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. - **Commit under the committing account's own GitHub `noreply` identity - never a private, personal, or invented address.** The `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit (above) - GitHub issues these in a `username@users.noreply.github.com` or `ID+username@users.noreply.github.com` form, and for this single-maintainer fleet it is the owner's `ptr727@users.noreply.github.com`. Do not set `user.name`/`user.email` to a fabricated persona, bot name, or product name, and do not commit under whatever identity the environment happens to carry: verify `git config --get user.email` is that GitHub `noreply` address before committing, and fix it if not. A wrong identity is not cosmetic - a private email trips GitHub's email-privacy push protection (GH007), and an unrecognized or invented author pollutes history. Identity is separate from signing: a wrong author does not by itself fail the signature rule, but the ad-hoc identities that produce it are typically also unsigned, which the signing rule above then rejects on push. - **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. -- **A history rewrite includes only the commits that must change, and re-identifies any commit it rewrites that is not yours.** Filtering history (`git filter-repo` / `filter-branch`, e.g. to strip PII) makes whoever runs it the committer and signer of every commit it touches, while preserving the original `author`. Re-signing a bot-authored commit (`dependabot[bot]`, `github-actions[bot]`) therefore leaves your key over the bot's identity - a signature that does not match the author, which GitHub marks `unknown_key`/unverified and a require-signed-commits ruleset rejects. Two gates keep signature and identity aligned. **First, scope the rewrite to only the commits that must be modified** - by default those are your own, re-signed with your key, and a commit that does not need changing is kept out of the rewrite so its identity and signature are never touched. **Second, if a commit that must change is not yours, re-map its `author` and `committer` to the signing identity before re-signing** so the rewritten commit is yours and its signature matches its author - the original bot attribution is deliberately given up as the cost of having to rewrite it. Never leave a maintainer signature over a bot author. Verify after the rewrite: `git log --format='%an%x09%GS'` must show no commit whose author and signer disagree. +- **A history rewrite includes only the commits that must change, and re-identifies any commit it rewrites that is not yours.** Filtering history (`git filter-repo` / `filter-branch`, e.g. to strip PII) rewrites the touched commits and you re-sign them with your key, while the tooling preserves each commit's original `author` and `committer` unless told otherwise. GitHub verifies a signature against the commit's `committer` identity, so a your-key signature over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or by GitHub's web-flow does not match its committer and is marked `unknown_key`/unverified, which a require-signed-commits ruleset then rejects. Two gates keep committer and signature aligned. **First, scope the rewrite to only the commits that must be modified** - by default those are your own, whose committer is already your identity, and a commit that does not need changing is kept out of the rewrite so its identity and signature are never touched. **Second, if a commit that must change is not yours, set its `committer` to the signing identity before re-signing** (and its `author` too, since a rewrite that alters the content should not keep attributing it to the bot), so the committer GitHub verifies matches your key - the original bot attribution is deliberately given up as the cost of having to rewrite it. Never leave your signature over a commit committed by another identity. Verify after the rewrite that every rewritten commit is signed and committed under your identity (`git log --show-signature`). - **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. ## Branching Model diff --git a/docs/repo-config-carry.md b/docs/repo-config-carry.md index 33f9374a..cfff12b5 100644 --- a/docs/repo-config-carry.md +++ b/docs/repo-config-carry.md @@ -50,7 +50,7 @@ done ## Brownfield Migration (Maintainer Only) -`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits authored by a bot (`dependabot[bot]`, `github-actions[bot]`), re-identify each to the signing identity before re-signing rather than leaving your key over the bot's identity (see the history-rewrite rule in [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules]). +`Require signed commits` rejects any pre-existing unsigned commit, so the first `develop -> main` release on a repo with unsigned history is blocked. Re-signing that history is a non-fast-forward that the `Block force pushes` rule rejects, **and the admin bypass does not cover `git push --force`**. Completing it requires temporarily disabling the ruleset and a maintainer force-push. This is a one-time, maintainer-performed migration that deliberately uses the force-push [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules] forbids agents from running - **an agent must never execute it - surface it to the maintainer**. Greenfield repos where signing is live before the first commit never hit this. When the rewrite touches commits committed under a bot or web-flow identity (`dependabot[bot]`, `github-actions[bot]`), set each commit's committer to the signing identity before re-signing so the committer GitHub verifies matches your key, rather than leaving your key over another identity's commit (see the history-rewrite rule in [AGENTS.md "Git and Commit Rules"][agents-git-and-commit-rules]).