The mandatory merge gate for every repository, defined once here and pinned
to @main everywhere else. Fix a bug or add a rule in this repo and it is
live in every caller on its next run — no per-repo change, ever.
It is the server-side twin of the git hooks in devcontainer-template
(.githooks/commit-msg, the content checks of git-guard.sh). Those run on
a developer's machine and are skipped by git commit --no-verify. This runs
on GitHub, against commits that already exist. There is nothing to skip.
| Check | Scope | Fails when |
|---|---|---|
| Identity | every commit reachable from the branch (default) | a commit's author or committer is not one of the authors accounts. Opt-in: an empty list disables it |
| AI attribution | every commit reachable from the branch (default) | any commit's message, author or committer carries an attribution: Co-authored-by: naming an assistant, a "Generated by/with …" banner, the 🤖 footer, an noreply@anthropic.com identity, "AI-assisted", a Plan: footer |
| Format | the commits this push/PR introduces (merges skipped) | a subject is not type(scope)!: subject with a conventional type |
| Secrets | lines this push/PR adds, outside test/fixture paths | a line looks like a credential (AWS key, GitHub token, private-key header, a quoted password assignment, …) |
| Agent artefacts | every path tracked in the tree at the head | an agent's output is committed: .claude/logs/, .claude/plans/, .claude/.credentials.json, settings.local.json, .aider.chat.history.md, .specstory/history/, session/cache/lock files. An agent's configuration is source and never matches |
Default attribution patterns match the shape of an attribution, not the
mention of a vendor. Measured on devcontainer-template's real history, a
bare .claude/ rule matches 25 of the last 60 legitimate commits. Keyword
rules exist but are opt-in (strict: true).
The artefact check applies the same discipline to paths: match the shape of the artefact, and check it against real history before making it a default.
Deliberately not here: lint, build, test. Every repository's own CI
already runs those on GitHub, so --no-verify never bypassed them.
| Output | |
|---|---|
passed | "true" when the gate found nothing, "false" otherwise |
report | the verdict as markdown — the same text the job summary carries, capped at 60 kB |
report is what the stub posts on the pull request. The gate runs in a step
that always exits 0 and a separate step carries the verdict, because a
composite step that fails takes its outputs down with it — and the one job
that needs the report is the one reacting to a failure.
authors takes GitHub logins, not addresses. Each expands to that
account's noreply address with or without the numeric id prefix, so both
kodflow@users.noreply.github.com and 133899878+kodflow@users.noreply.github.com
pass. A personal address — florent@… — does not: the fleet's history carries
the owning account, and only that.
Two families are accepted on top of the list, because refusing them would fail
commits no human can re-author: GitHub's own merge committer
(noreply@github.com, every squash and merge done through the web UI or the
API) and app accounts (…[bot]@users.noreply.github.com — dependabot,
github-actions).
The check reads the author and the committer. Rewriting only the author half is exactly how a wrong identity survives a sloppy fixup.
One tainted commit anywhere in the ancestry fails the check — on this PR and on every future one — until the repository's history is rewritten. That is the policy: a repo either has a clean history or it does not merge. The gate reports and never rewrites; see Rewriting history.
Set history: range to only check the commits a push/PR introduces.
An agent's configuration is source. .claude/agents/, .claude/commands/,
.claude/skills/, .mcp.json, .cursor/rules/, .clinerules — someone
authored those, reviewed them, and wants them shared with the next person to
clone the repository. Refusing them would be refusing the work.
What has no business in a repository is the exhaust: the session log, the chat transcript, the plan file, the lock, the cache, the personal override. Nobody reads it, nobody reviews it, it conflicts on every merge, and it carries whatever the session happened to touch.
The line is drawn at named runtime directories — never at an extension, never at a keyword. Two measurements on the fleet say why:
.devcontainer/images/.claude/agents/routing-table.jsonlis tracked in 4 repositories, and it is authored routing configuration. A rule reading "a.jsonlunder an agent directory is a log" would refuse source..claude/sessions/.gitkeepis tracked in 14. The directory is shipped empty on purpose by the devcontainer image, and the placeholder is what makes it exist.sessions/is therefore not matched at all.
One entry earns its place for a different reason. .claude/.credentials.json
is Claude Code's OAuth token store, and the secrets check does not save you
from it: that check reads only the lines a push adds, so a credentials file
committed once is never looked at again — and a sk-ant-oat01-… token carries
hyphens where the sk-[a-zA-Z0-9] pattern expects none. Here it stays red for
as long as the file is tracked.
Not matched, by decision rather than by omission: an agent's configuration
— .claude/agents/, commands/, skills/, docs/, scripts/,
settings.json, .mcp.json, .cursorrules, .cursor/rules/, .clinerules,
.aider.conf.yml — plus the two measured cases above, editor configuration
(.vscode/, .idea/, .zed/), .devcontainer/, and markdown instructions
(CLAUDE.md, AGENTS.md, GEMINI.md). Nothing is matched because it sits
under an agent directory — the directory is legitimate; only the listed
children are not. A log dropped inside
.devcontainer/images/.claude/ is still a log, though: no parent grants
immunity, or "put it under .devcontainer/" becomes the way around the rule.
The check reads the tree at the head, not the range. A check scoped to what a change adds would call every later pull request clean while the artefact sat in the trunk. The gate stays red until it is gone.
It can afford that scope because gone is cheap, and this is the one place the gate refuses something without demanding a rewrite:
git rm -r --cached .claude/logs &&echo'.claude/logs/'>> .gitignore
git commit -m "chore: untrack the session logs"The files stay on your machine; the repository stops carrying them. Nothing
walks the ancestry, so that single commit ends it — unlike an attribution,
which lives in a commit and needs scripts/rewrite-history.sh.
.github/workflows/post-commit.yml in the target repo (stub/post-commit.yml here):
name: post-commiton:
pull_request:
types: [opened, synchronize, reopened]push:
branches: [main, master]permissions:
contents: readjobs:
post-commit:
runs-on: ubuntu-latesttimeout-minutes: 10steps:
- uses: kodflow/post-commit@mainwith:
authors: kodflowThe job must be named post-commit: that is the status the ruleset
requires. @main is deliberate — pinning a SHA freezes a repo out of every
future fix. Don't pin it, don't copy the logic in.
| Input | Default | |
|---|---|---|
authors | (empty) | GitHub logins allowed to author and commit, space or comma separated. Empty disables the check |
history | full | full scans every ancestor; range only the introduced commits |
strict | false | also apply scripts/patterns-strict.txt (vendor keywords, .claude/ paths) |
format | true | conventional-commit subjects |
secrets | true | credential scan on added lines |
agent_files | true | refuse an agent's session exhaust tracked in the tree; its configuration is never matched |
agent_files_allow | (empty) | paths exempt from that check, space or comma separated. A bare entry exempts the whole subtree: .claude/logs covers everything under it |
token | github.token | checkout token |
scripts/enforce.sh (or the enforce workflow with a FLEET_TOKEN PAT)
puts, on every repository:
- the stub above — opening a PR where it is missing;
- a repository ruleset named
post-commiton the default branch:- required status check
post-commit, accepted only from GitHub Actions (nobody can post a look-alike status with a PAT); - no branch deletion, no non-fast-forward push — the force-push protection the local hook used to provide, now server-side;
- bypass: repository admins only, always.
- required status check
Because a required status that is never reported blocks the merge, deleting or renaming the stub blocks every PR — the gate cannot be removed from below, only by an admin editing the ruleset.
scripts/enforce.sh owner/repo # dry-run: shows what would change
scripts/enforce.sh --apply owner/repo # do it
scripts/enforce.sh --apply --all # every non-fork, non-archived repo you ownIdempotent: re-running never duplicates a PR or a ruleset.
Private repositories in a Free organisation have neither rulesets nor branch protection ("Upgrade to GitHub Pro or make this repository public"). The check still runs and goes red; nothing stops the merge.
enforce.shreports these asunavailable:plan, and--auditcounts them as advisory rather than as gaps — a limit GitHub imposes is not the same defect as a ruleset nobody created, and scoring them together buries the gaps that can actually be closed.What is left there is visibility, so the gate spends it: on a failed run the pull request gets a comment naming every violation and saying plainly that nothing will stop this merge, with the two ways out (paid plan, or make the repository public). One comment per pull request, rewritten in place. The same comment on a repository that does carry the ruleset says so instead — the reader never has to guess whether the red status has teeth.
A repository admin bypasses the ruleset. That is intended.
The gate sees commits after they exist. A direct push to the trunk without a passing
post-commitstatus is rejected by the ruleset — but the commit was made, locally, by someone who then has to fix it.
scripts/rewrite-history.sh owner/repo is a dry run: it mirrors the
repo into a temp dir, rewrites it there, verifies the result with the gate
and prints exactly what changed — commits rewritten, identities remapped,
subjects a human must reword, vendor mentions left in ordinary prose, open
PRs that will need a rebase. Nothing reaches GitHub.
It remaps every author and committer outside --authors (default: the
authenticated gh login), not just the AI agents — a personal address is the
same leak as an assistant's trailer, and the identity check refuses both. The
replacement is that account's GitHub noreply address, never a private one:
mapping onto a private address would leave the history failing the very gate
the rewrite exists to satisfy.
Signed repositories. git-filter-repo drops GPG signatures, so a repo whose ruleset carries
required_signaturescomes out of a rewrite with an entirely unverified history and can no longer merge anything. Today that iskodflow/terraform-provider-n8nandsupervizio/agent— decide what to do with the rule before rewriting either.
--execute force-pushes every branch and tag of the rewritten mirror. After
that, every commit SHA from the first tainted one onward is different (and
loses its GPG signature — a git-filter-repo limitation), same-repo PR
branches are rewritten along with the trunk, fork-based PRs break, every
clone must be re-cloned, and there is no undo.
GitHub keeps the old commits reachable by URL for a while; only support can
purge them. Run it one repository at a time, on purpose.
action.yml the action (composite): checkout → resolve → gate
scripts/post-commit.sh the gate
scripts/patterns.txt default forbidden patterns (attribution-shaped)
scripts/patterns-strict.txt opt-in keyword patterns
scripts/agent-paths.txt agent RUNTIME paths (logs, transcripts, caches); config kept
scripts/enforce.sh fleet: stub PR + ruleset, idempotent
scripts/rewrite-history.sh history scrub (messages + identities), dry-run by default
stub/post-commit.yml the file installed in each repo
stub/pr-body.md the PR body enforce.sh uses
tests/run.sh behaviour tests against real throwaway repos
.github/workflows/ci.yml shellcheck + tests + YAML + dogfood (uses: ./)
.github/workflows/enforce.yml fleet enforcement from GitHub (needs FLEET_TOKEN)
Edit scripts/patterns.txt for a rule about what a commit says, or
scripts/agent-paths.txt for one about what it tracks. Add a case to
tests/run.sh, open a PR — this repo gates itself with the version under
review. Once on main, the rule is live everywhere on the next run.