A green check is a local fact. Merge is a promotion.
If you let an agent-written diff jump from “tests passed on my laptop” to main, you skipped three decisions you still own: what was frozen, what CI re-ran, and what you will revert if the next hour is worse than the last. Copy the path below. Do not copy a feeling.
The conclusion you should keep
You should not merge because a required check is green. You should merge because the patch survived a promotion sequence you can replay.
That sequence has four stages. Each stage writes evidence. The next stage refuses to start without it.
- Local fail-closed, with a receipt that freezes fixtures and paths.
- Pull-request checks that re-run tests and recompute the freeze.
- A merge queue that isolates one flake retry from a real failure.
- A post-merge soak window with a documented revert command.
This is a tutorial. You leave with a receipt schema, a pre-push hook, a GitHub Actions workflow, and a decision table. Treat the scripts as a proposed workflow you can run in a throwaway branch. They are not a claim about your production fleet.
Why one green check is a skipped promotion
CI answers a narrow question: did this SHA fail the jobs you paid to run? Merge answers a wider one: is this SHA allowed to become everyone else’s default?
Those questions diverge when the author is an agent. The agent can rewrite the fixture that made the test pass. It can expand the diff into lockfiles, snapshots, or golden files you never intended to touch. A boolean check will still go green. Your history will not.
So you split the path. Local work is allowed to be optimistic. main is not.
Stage 1 — Fail closed before the network
Run the cheap proofs on the machine that wrote the diff. Do it before git push.
You are not trying to replace CI. You are trying to refuse a class of diffs that CI will rubber-stamp: fixture edits, allowlist escapes, and “I deleted the failing test” patches.
Receipt schema
Keep the receipt small. It is a freeze, not a novel.
{
"schema": "promote-receipt/v1",
"git_sha": "REPLACE_WITH_HEAD_SHA",
"tree_sha": "REPLACE_WITH_TREE_SHA",
"fixture_lock": "sha256:REPLACE",
"paths_changed": ["src/billing/quote.py", "tests/test_quote.py"],
"allowlist_ok": true,
"fixtures_updated": false,
"commands": [
{"name": "unit", "exit": 0},
{"name": "lint", "exit": 0}
]
}
Proposed generation script:
#!/usr/bin/env bash
# scripts/write_receipt.sh — proposed local helper
set -euo pipefail
root="$(git rev-parse --show-toplevel)"
cd "$root"
allow_re='^(src/|tests/test_)'
forbid_re='^(tests/fixtures/|package-lock\.json|uv.lock|poetry.lock)$'
git_sha="$(git rev-parse HEAD)"
tree_sha="$(git rev-parse HEAD^{tree})"
mapfile -t paths < <(git diff --name-only origin/main...HEAD)
allowlist_ok=true
fixtures_updated=false
for p in "${paths[@]}"; do
if [[ "$p" =~ $forbid_re ]]; then
fixtures_updated=true
fi
if [[ ! "$p" =~ $allow_re ]]; then
allowlist_ok=false
fi
done
fixture_lock="sha256:$(find tests/fixtures -type f -print0 2>/dev/null | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')"
python -m pytest -q
python -m ruff check src tests
mkdir -p .ci
python - <<'PY'
import json, os
receipt = {
"schema": "promote-receipt/v1",
"git_sha": os.environ["GIT_SHA"],
"tree_sha": os.environ["TREE_SHA"],
"fixture_lock": os.environ["FIXTURE_LOCK"],
"paths_changed": os.environ["PATHS"].split("\n") if os.environ["PATHS"] else [],
"allowlist_ok": os.environ["ALLOWLIST_OK"] == "true",
"fixtures_updated": os.environ["FIXTURES_UPDATED"] == "true",
"commands": [{"name": "unit", "exit": 0}, {"name": "lint", "exit": 0}],
}
open(".ci/receipt.json", "w", encoding="utf-8").write(json.dumps(receipt, indent=2) + "\n")
PY
Export the env vars before the Python block, or inline them. The point is the freeze, not the shell dialect.
Pre-push hook
#!/usr/bin/env bash
# .git/hooks/pre-push — proposed
set -euo pipefail
./scripts/write_receipt.sh
python - <<'PY'
import json, subprocess, sys
r = json.load(open(".ci/receipt.json", encoding="utf-8"))
msg = subprocess.check_output(["git", "log", "-1", "--format=%B"], text=True)
if r["fixtures_updated"] and "Fixtures-Update: true" not in msg:
sys.stderr.write("fixture or lockfile changed without Fixtures-Update: true\n")
sys.exit(1)
if not r["allowlist_ok"]:
sys.stderr.write("path allowlist failed; split the unrelated files out\n")
sys.exit(1)
PY
You now have a local gate. Push is no longer “the agent said it was fine.”
Drafting the patch can still be cheap. If you sketch the diff in MonkeyCode, use it as a typewriter, not as a merge oracle.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access and a free server option can host that draft loop. They do not sign the receipt, re-run CI, or own the revert.
Stage 2 — Re-run, do not replay
CI must recompute the freeze. A committed receipt is a claim. It is not evidence that the tests passed on GitHub-hosted runners.
Proposed workflow:
# .github/workflows/promote.yml
name: promote
on:
pull_request:
types: [opened, synchronize, reopened]
merge_group:
jobs:
recompute:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install pytest ruff
- name: Verify receipt against this SHA
run: python scripts/verify_receipt.py
- name: Re-run tests
run: |
python -m ruff check src tests
python -m pytest -q --junitxml=junit.xml
- name: Upload junit
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-${{ github.sha }}
path: junit.xml
Proposed verifier:
# scripts/verify_receipt.py — proposed
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
ALLOW = ("src/", "tests/test_")
FORBID = ("tests/fixtures/", "package-lock.json", "uv.lock", "poetry.lock")
def sha_tree(root: Path) -> str:
h = hashlib.sha256()
for p in sorted(root.rglob("*")):
if p.is_file():
h.update(p.as_posix().encode())
h.update(p.read_bytes())
return "sha256:" + h.hexdigest()
def main() -> int:
receipt_path = Path(".ci/receipt.json")
if not receipt_path.exists():
print("missing .ci/receipt.json; run scripts/write_receipt.sh locally", file=sys.stderr)
return 2
r = json.loads(receipt_path.read_text(encoding="utf-8"))
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
if r.get("git_sha") != head:
print(f"receipt sha {r.get('git_sha')} != HEAD {head}", file=sys.stderr)
return 1
base = os.environ.get("GITHUB_BASE_REF", "origin/main")
names = subprocess.check_output(
["git", "diff", "--name-only", f"{base}...HEAD"], text=True
).splitlines()
fixtures_updated = any(n.startswith(FORBID) or n in FORBID for n in names)
allowlist_ok = all(n.startswith(ALLOW) or n == ".ci/receipt.json" for n in names)
msg = subprocess.check_output(["git", "log", "-1", "--format=%B"], text=True)
if fixtures_updated and "Fixtures-Update: true" not in msg:
print("CI freeze: fixtures changed without trailer", file=sys.stderr)
return 1
if not allowlist_ok:
print("CI freeze: path allowlist failed", file=sys.stderr)
return 1
lock = sha_tree(Path("tests/fixtures")) if Path("tests/fixtures").exists() else "sha256:empty"
if r.get("fixture_lock") != lock:
print("fixture lock drifted between laptop and runner", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Notice what this job does not do. It does not trust the local exit codes. It trusts the freeze, then it runs the tests again.
Stage 3 — Queue with one retry, not infinite hope
A merge queue is where flakes pretend to be product bugs. You want one retry for jobs you have already labeled flaky. You do not want a while-loop until green.
Protect main. Require promote / recompute. Turn on a merge queue if your host supports it. Then add a thin classifier so a timeout is not treated like an assertion failure.
# scripts/classify_junit.py — proposed
import sys
import xml.etree.ElementTree as ET
FLAKY_MARKERS = ("socket.timeout", "Temporary failure in name resolution")
def classify(path: str) -> str:
root = ET.parse(path).getroot()
failures = []
for case in root.iter("testcase"):
bad = case.find("failure") or case.find("error")
if bad is None:
continue
text = (bad.get("message") or "") + "\n" + (bad.text or "")
failures.append(text)
if not failures:
return "pass"
if all(any(m in t for m in FLAKY_MARKERS) for t in failures):
return "flake"
return "fail"
if __name__ == "__main__":
print(classify(sys.argv[1]))
Wire it as a second job that only runs on merge_group:
queue-retry:
if: github.event_name == 'merge_group'
needs: recompute
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest
- id: first
continue-on-error: true
run: python -m pytest -q --junitxml=junit.xml
- id: class
if: always()
run: echo "kind=$(python scripts/classify_junit.py junit.xml)" >> "$GITHUB_OUTPUT"
- name: One flake retry
if: steps.class.outputs.kind == 'flake'
run: python -m pytest -q
- name: Real failure blocks the queue
if: steps.class.outputs.kind == 'fail'
run: exit 1
One retry. Then stop. If the second run fails, the SHA does not promote.
Stage 4 — Soak, then keep or revert
Promotion is not finished at merge. You need a window where main can still lose.
Tag the merge. Watch a short, named soak. Keep the revert command in the same runbook as the receipt.
# proposed soak notes, not a production SLO claim
git tag "soak-$(git rev-parse --short HEAD)"
git push origin "soak-$(git rev-parse --short HEAD)"
# if the next deploy or nightly is worse than the parent
git revert --no-edit HEAD
git push origin main
If you lack nightlies, soak is still a decision: “we will look at error rate for this SHA before the next human leaves.” Write that down. A tag without a watcher is jewelry.
Decision table
Use this at review time. Do not negotiate it in the merge comment thread.
| Evidence | Local | PR job | Merge queue | Merge? |
|---|---|---|---|---|
| Unit + lint green, allowlist ok, fixture lock matches | pass | pass | pass | Promote |
Tests green, fixtures changed, no Fixtures-Update: true
|
fail-closed | fail | n/a | Split the PR |
| Tests green, lockfile or snapshot slipped into the diff | fail-closed | fail | n/a | Split the PR |
| Assertion failure on runner | n/a | fail | fail | Fix the code |
| Known network timeout, first queue run only | n/a | pass | one retry | Retry once |
| Same timeout after retry | n/a | pass | fail | Do not merge |
Receipt SHA ≠ GITHUB_SHA
|
n/a | fail | fail | Regenerate receipt |
The table is the merge policy. The workflow is just enforcement.
Limitations, and who should not use this
This path assumes you can name an allowlist. Monorepos that ship lockfiles, generated clients, and screenshot goldens on every change will drown in Fixtures-Update trailers. If that is you, replace the allowlist with CODEOWNERS on generated paths instead of copying this script.
Do not use a laptop receipt as a substitute for CI. Runners differ. Clock skew, case-sensitive filesystems, and missing system libraries will lie to you in the other direction.
Do not use the flake retry as a general “run until green” knob. If you cannot name the timeout string, it is not a flake policy. It is denial.
Tiny personal repos with one job and no agents do not need four stages. A required check and a human look is enough. Teams that already run a vendor merge queue with required re-runs can keep that queue and only steal the fixture freeze.
The draft environment is optional. The promotion path is not. If you already sketch patches on a free server, point write_receipt.sh at that working tree before the first push, then let CI disagree with you in public.
Top comments (0)