Skip to content

Latest commit

History

30 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

post-commit

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.

What it checks

CheckScopeFails when
Identityevery 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 attributionevery 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
Formatthe commits this push/PR introduces (merges skipped)a subject is not type(scope)!: subject with a conventional type
Secretslines this push/PR adds, outside test/fixture pathsa line looks like a credential (AWS key, GitHub token, private-key header, a quoted password assignment, …)
Agent artefactsevery path tracked in the tree at the headan 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.

Outputs

Output
passed"true" when the gate found nothing, "false" otherwise
reportthe 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.

Who is allowed to commit

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.

Full history, on purpose

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.

Agent artefacts: configuration is source, exhaust is not

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.jsonl is tracked in 4 repositories, and it is authored routing configuration. A rule reading "a .jsonl under an agent directory is a log" would refuse source.
  • .claude/sessions/.gitkeep is 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 tree, and a cheap way out

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.

Install

.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: kodflow

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

Inputs

InputDefault
authors(empty)GitHub logins allowed to author and commit, space or comma separated. Empty disables the check
historyfullfull scans every ancestor; range only the introduced commits
strictfalsealso apply scripts/patterns-strict.txt (vendor keywords, .claude/ paths)
formattrueconventional-commit subjects
secretstruecredential scan on added lines
agent_filestruerefuse 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
tokengithub.tokencheckout token

Making it mandatory — and irremovable

scripts/enforce.sh (or the enforce workflow with a FLEET_TOKEN PAT) puts, on every repository:

  1. the stub above — opening a PR where it is missing;
  2. a repository ruleset named post-commit on 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.

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 own

Idempotent: re-running never duplicates a PR or a ruleset.

What GitHub cannot enforce

  • 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.sh reports these as unavailable:plan, and --audit counts 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-commit status is rejected by the ruleset — but the commit was made, locally, by someone who then has to fix it.

Rewriting history

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_signatures comes out of a rewrite with an entirely unverified history and can no longer merge anything. Today that is kodflow/terraform-provider-n8n and supervizio/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.

Layout

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)

Adding a rule

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.

About

Central commit guard: blocks AI attribution and leaked credentials on PRs. One definition, all repos.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages