A green check is not merge permission. Permission is a receipt that matches a versioned path file in the repo. If the path is only in someone’s head, agent patches will skip a gate and still look done.
You already know the failure mode. A model rewrites a handler, updates a fixture, and your laptop reports green. CI later retries a flaky test, the check turns green again, and the PR merges. The sequence that was supposed to protect main never ran as a sequence. It ran as a pile of jobs.
Encode the path. Dry-run it locally. Let CI sign a receipt. Merge only when those three agree.
What the path file is for
Keep the merge contract next to the code. Not in a wiki. Not in a branch-protection screenshot from last quarter.
The file names the gates, their order, and how many flakes each gate may spend. CI becomes a verifier. You stop arguing about which check “counts.”
# .ci/merge-path.yml
version: 1
fixture_lock: tests/fixtures.lock
allow_merge_on: merge-receipt
required_sequence:
- id: fixture-lock
check_name: fixture-lock
flake_budget: 0
- id: unit
check_name: unit
flake_budget: 0
- id: contract
check_name: contract
flake_budget: 1
- id: merge-receipt
check_name: merge-receipt
flake_budget: 0
Short file. Strict meaning. If a job is not in this list, it cannot unlock merge. If a job is in the list and silent, merge stays closed.
Freeze fixtures before the patch can travel
Agent diffs love to “fix” snapshots. That is how a broken contract becomes a new golden file. You want that change visible as a lock update, not as silent drift.
#!/usr/bin/env bash
# scripts/hash-fixtures.sh
set -euo pipefail
root="$(git rev-parse --show-toplevel)"
lock="$root/tests/fixtures.lock"
tmp="$(mktemp)"
find "$root/tests/fixtures" -type f | sort | while read -r f; do
rel="${f#$root/}"
printf '%s %s\n' "$(git hash-object "$f")" "$rel"
done > "$tmp"
if [[ "${1:-}" == "--write" ]]; then
mv "$tmp" "$lock"
echo "wrote $lock"
exit 0
fi
diff -u "$lock" "$tmp"
rm -f "$tmp"
Run it two ways. --write is a deliberate lock bump. A plain run is a comparison. The hook below refuses a push when the comparison fails.
#!/usr/bin/env bash
# .githooks/pre-push
set -euo pipefail
repo="$(git rev-parse --show-toplevel)"
"$repo/scripts/hash-fixtures.sh"
Install it once:
git config core.hooksPath .githooks
chmod +x .githooks/pre-push scripts/hash-fixtures.sh
You now have a local fail-closed gate. The fixture tree cannot move with the patch unless the lock moves too. That is the first step of the merge path, and it happens before CI spends a minute.
Dry-run the same sequence you will ask CI to sign
Do not wait for GitHub to tell you the order was wrong. Replay the path on the commit you intend to push.
#!/usr/bin/env bash
# scripts/merge-dry-run.sh
set -euo pipefail
repo="$(git rev-parse --show-toplevel)"
cd "$repo"
echo "== fixture-lock =="
./scripts/hash-fixtures.sh
echo "== unit =="
python -m pytest tests/unit -q --maxfail=1
echo "== contract =="
python -m pytest tests/contract -q --maxfail=1
echo "dry-run ok on $(git rev-parse HEAD)"
Call it before every agent PR:
chmod +x scripts/merge-dry-run.sh
./scripts/merge-dry-run.sh
If this script fails, you do not open the PR. Green later in CI will not repair a path you never ran. The dry-run is not a substitute for CI. It is a filter so CI only sees patches that already survived the sequence.
Spend flakes as a budget, then write them down
Retries hide instability when they are infinite. They are usable when they are counted, named, and stored on the receipt.
# scripts/run_with_budget.py
from __future__ import annotations
import json, subprocess, sys, time
from pathlib import Path
def run(cmd: list[str]) -> int:
p = subprocess.run(cmd)
return p.returncode
def main() -> int:
gate, budget = sys.argv[1], int(sys.argv[2])
cmd = sys.argv[3:]
spends = 0
last = 1
attempts = budget + 1
for i in range(attempts):
last = run(cmd)
if last == 0:
break
spends += 1
if spends > budget:
break
time.sleep(min(2 ** i, 8))
Path("receipt.partial.json").write_text(json.dumps({
"gate": gate,
"flake_budget": budget,
"flake_spent": spends if last == 0 else spends,
"ok": last == 0 and spends <= budget,
"command": cmd,
}, indent=2))
return last if last != 0 else (0 if spends <= budget else 1)
if __name__ == "__main__":
raise SystemExit(main())
Unit tests get a budget of zero. Contract tests may spend one retry. The receipt records the spend. A green job that burned three retries against a budget of one is not green for merge. It is a failed gate with a polite color.
CI writes one receipt. Merge reads only that receipt.
The last required check is not “all jobs finished.” It is a document. Head SHA, fixture lock digest, gate order, and flake spends have to match .ci/merge-path.yml.
# scripts/write_receipt.py
from __future__ import annotations
import hashlib, json, os, subprocess, sys
from pathlib import Path
import yaml
def sha() -> str:
return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
def digest(path: str) -> str:
h = hashlib.sha256()
h.update(Path(path).read_bytes())
return h.hexdigest()
def main() -> int:
path = yaml.safe_load(Path(".ci/merge-path.yml").read_text())
partials = [json.loads(p.read_text()) for p in sorted(Path(".ci/partials").glob("*.json"))]
expected = [g["id"] for g in path["required_sequence"] if g["id"] != "merge-receipt"]
seen = [p["gate"] for p in partials]
if seen != expected:
print(f"sequence mismatch: {seen} != {expected}", file=sys.stderr)
return 1
for gate, partial in zip(path["required_sequence"], partials):
if not partial["ok"] or partial["flake_spent"] > gate["flake_budget"]:
print(f"gate failed: {gate['id']}", file=sys.stderr)
return 1
receipt = {
"head_sha": sha(),
"fixture_lock_sha256": digest(path["fixture_lock"]),
"path_version": path["version"],
"gates": partials,
"allow_merge": True,
}
out = Path(os.environ.get("RECEIPT_OUT", "merge-receipt.json"))
out.write_text(json.dumps(receipt, indent=2) + "\n")
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Wire the jobs so order is real, not decorative.
# .github/workflows/merge-path.yml
name: merge-path
on:
pull_request:
push:
branches: [main]
jobs:
fixture-lock:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/hash-fixtures.sh
- run: mkdir -p .ci/partials && python -c "import json,pathlib; pathlib.Path('.ci/partials/01-fixture-lock.json').write_text(json.dumps({'gate':'fixture-lock','flake_budget':0,'flake_spent':0,'ok':True}))"
- uses: actions/upload-artifact@v4
with:
name: partials
path: .ci/partials
unit:
needs: [fixture-lock]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python scripts/run_with_budget.py unit 0 python -m pytest tests/unit -q --maxfail=1
- run: mkdir -p .ci/partials && mv receipt.partial.json .ci/partials/02-unit.json
- uses: actions/upload-artifact@v4
with:
name: partials
path: .ci/partials
contract:
needs: [unit]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python scripts/run_with_budget.py contract 1 python -m pytest tests/contract -q --maxfail=1
- run: mkdir -p .ci/partials && mv receipt.partial.json .ci/partials/03-contract.json
- uses: actions/upload-artifact@v4
with:
name: partials
path: .ci/partials
merge-receipt:
needs: [contract]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: partials
path: .ci/partials
- run: pip install pyyaml
- run: python scripts/write_receipt.py
- uses: actions/upload-artifact@v4
with:
name: merge-receipt
path: merge-receipt.json
Set branch protection to one required check: merge-receipt. Leave the other jobs visible. They feed the receipt. They do not, by themselves, open main.
Decision table you can paste into the PR template
| What you observe | Merge? | Why |
|---|---|---|
| Unit is green, no receipt artifact | No | Sequence never closed |
Receipt head_sha ≠ PR HEAD |
No | Stale or replayed document |
| Fixture files changed, lock did not | No | Contract drift |
| Gate spent more flakes than budget | No | Instability was painted green |
Jobs ran, but not in required_sequence
|
No | Extra color is not a path |
| Receipt matches path, SHA, lock, budgets | Yes | Permission is documented |
Print the table in the PR body. Reviewers stop asking “is it green?” They ask “does the receipt match?”
Where generation happens does not change the path
You can write the patch by hand. You can also draft it in a workspace that offers free model access and a free server option. The merge path does not care which editor produced the diff. It cares whether fixtures stayed locked, whether the dry-run passed, and whether CI signed a matching receipt.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you use MonkeyCode for that draft step, treat it as a source of a patch, not as a substitute for merge-dry-run.sh or the receipt job. Generate, then hash fixtures, then run the path. Skip that order and you are back to a green dot with no permission document.
Limitations
This pattern assumes you can name a short, ordered sequence. A matrix of thirty OS and language jobs does not belong in the receipt as thirty gates. Fold the matrix behind one named gate, or the path file becomes noise.
The hook only protects people who push through Git. A force-push from the GitHub UI, a bot with admin, or a workflow that commits using GITHUB_TOKEN can bypass pre-push. Defend those routes separately, or the lock is theater.
The receipt is only as strong as branch protection. If merge-receipt is not required, the JSON is a log line. If required checks can be skipped by administrators every afternoon, you still have a social process, not a gate.
Flake budgets do not fix flakes. They bound how much instability you will paper over on the way to merge. A contract gate that spends its one retry every day needs a bug, not a larger budget.
Who should not use this
Do not install this on a repo with no fixtures and one smoke test. You will maintain YAML for no gain.
Do not use it as a way to let generated tests rewrite themselves. If the agent changes tests/fixtures and src in the same commit, the lock bump should be a human review item. Auto-writing the lock in CI defeats the hook.
Do not use it when your “CI” is a single vendor check you cannot split. The receipt needs partials. One opaque green square cannot be sequenced.
A working order you can run today
- Add
.ci/merge-path.ymlwith four gates and budgets of0/0/1/0. - Hash current fixtures into
tests/fixtures.lockwith./scripts/hash-fixtures.sh --write. - Point
core.hooksPathat.githooksand push a no-op branch to prove the hook fires. - Run
./scripts/merge-dry-run.shon a known-good commit, then on a commit that edits a fixture without updating the lock. The second run must fail. - Land the workflow. Make
merge-receiptthe only required check. Open a PR that skipsunitand confirm merge stays blocked. - Only then send an agent-authored patch through the same path.
The work that remains after a model drafts code is still engineering work. You name the path. You freeze the fixtures. You count the retries. You merge on a receipt that matches the file in the repo. That is the green-to-merge path. Everything else is a color.
Top comments (0)