A green required check is not a merge decision. It is a signal that one job graph finished without a red X. If a pre-push hook was skipped, or a snapshot fixture was regenerated without a reason, you can still ship a lie.
This article walks through a merge packet: a small JSON artifact your CI publishes next to the check. The packet records hook results, fixture drift, and required-job status. A model may write the eight-line brief. It does not get a vote.
Why green still lies
CI dashboards collapse many facts into one glyph. You see green. You click merge. You miss three common failures.
First, someone pushed with --no-verify and skipped the hook that keeps fixture hashes honest. Second, a test helper rewrote golden files because a serializer added a field. Third, a retry job went green on the second attempt and nobody recorded that the first attempt failed.
You do not need a platform rewrite to catch this. You need a contract the merge button cannot ignore.
Cheap code generation makes the second failure more common. When it is easy to regenerate tests, it is easy to regenerate the fixtures those tests pin. The pin becomes a moving target. Treat unexplained fixture diffs as merge blockers, the same way you treat a failed unit job.
What the merge packet contains
Keep the packet boring. One file. One schema. Commit it as a CI artifact, not as a comment that can be edited after the fact.
{
"commit": "REPLACE_WITH_SHA",
"generated_at": "2026-09-03T00:00:00Z",
"hooks": {
"pre_push_fixture_guard": "passed"
},
"fixtures": {
"manifest_path": "tests/fixtures.sha256",
"changed_paths": [],
"unexplained_paths": []
},
"required_jobs": [
{"name": "unit", "conclusion": "success"},
{"name": "contract", "conclusion": "success"}
],
"merge_ready": false,
"brief": null
}
merge_ready is computed by a script you own. Not by a prompt. The brief is optional prose for humans who will not open the JSON.
Step 1: Pin fixtures with a manifest
Pick a directory you already treat as golden data. Recorded HTTP bodies, GraphQL responses, serializer snapshots. Hash them in a stable order.
#!/usr/bin/env bash
set -euo pipefail
# scripts/hash-fixtures.sh
root="${1:-tests/fixtures}"
out="${2:-tests/fixtures.sha256}"
if [[ ! -d "$root" ]]; then
echo "no fixture root: $root" >&2
exit 1
fi
find "$root" -type f -print0 \
| sort -z \
| xargs -0 sha256sum \
> "$out"
echo "wrote $out"
Commit tests/fixtures.sha256. When a PR changes a fixture, the manifest changes too. That is the contract.
Add a note file so a legitimate update is not a silent one.
# tests/FIXTURE_NOTE.md
- path: tests/fixtures/checkout.v2.json
reason: tax field added to order payload in #4821
If the manifest changes and this note does not name the path, the gate fails. You are not asking a model whether the change looks fine. You are asking whether a human named the path.
Step 2: Enforce it in a pre-push hook
Local hooks are easy to skip. Still install one. Most accidental fixture rewrites happen on a laptop, not in CI.
#!/usr/bin/env bash
set -euo pipefail
# .githooks/pre-push
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
scripts/hash-fixtures.sh tests/fixtures "$tmp"
if ! git diff --quiet -- tests/fixtures.sha256; then
echo "tests/fixtures.sha256 is dirty" >&2
exit 1
fi
if ! cmp -s "$tmp" tests/fixtures.sha256; then
echo "fixture files drifted from tests/fixtures.sha256" >&2
echo "update the manifest and tests/FIXTURE_NOTE.md, then commit both" >&2
exit 1
fi
python3 scripts/check_fixture_note.py tests/fixtures.sha256 tests/FIXTURE_NOTE.md
# Optional trailer so CI can see the hook ran. It is not cryptography.
git interpret-trailers --in-place --trailer "Fixture-Guard: passed" "$(git rev-parse --git-path COMMIT_EDITMSG)" 2>/dev/null || true
Point Git at the hook directory once:
git config core.hooksPath .githooks
chmod +x .githooks/pre-push scripts/hash-fixtures.sh scripts/check_fixture_note.py
The checker below is a working sketch. Tighten path matching for your repo. Do not ship the heuristic unchanged into a monorepo with generated paths.
#!/usr/bin/env python3
"""Fail when fixture files change without being named in FIXTURE_NOTE.md."""
import pathlib
import subprocess
import sys
def named_paths(note: str) -> set[str]:
paths = set()
for line in note.splitlines():
line = line.strip()
if line.startswith("- path:"):
paths.add(line.split(":", 1)[1].strip())
return paths
def changed_fixture_files() -> list[str]:
out = subprocess.check_output(
["git", "diff", "--name-only", "HEAD", "--", "tests/fixtures"],
text=True,
)
return [line.strip() for line in out.splitlines() if line.strip()]
def main() -> int:
note_path = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "tests/FIXTURE_NOTE.md")
changed = changed_fixture_files()
if not changed:
return 0
named = named_paths(note_path.read_text() if note_path.exists() else "")
missing = [p for p in changed if p not in named]
if missing:
print("unexplained fixture changes:", file=sys.stderr)
for path in missing:
print(f" {path}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
The failure mode is the point. Unexplained drift should die before it reaches origin.
Step 3: Re-run the same contract in CI
Hooks are advisory if --no-verify exists. CI is the real gate. Mirror the script. Fail the job. Upload the packet even on failure so a reviewer sees why merge is blocked.
# .github/workflows/merge-packet.yml
name: merge-packet
on:
pull_request:
push:
branches: [main]
jobs:
contract:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Hash fixtures and compare
run: |
chmod +x scripts/hash-fixtures.sh scripts/check_fixture_note.py
scripts/hash-fixtures.sh tests/fixtures /tmp/fixtures.sha256
cmp tests/fixtures.sha256 /tmp/fixtures.sha256
python3 scripts/check_fixture_note.py tests/fixtures.sha256 tests/FIXTURE_NOTE.md
- name: Detect skipped-hook signal
id: hooks
run: |
if git log -1 --format=%B | grep -q '^Fixture-Guard: passed$'; then
echo "state=passed" >> "$GITHUB_OUTPUT"
else
echo "state=missing" >> "$GITHUB_OUTPUT"
fi
- name: Collect unexplained paths
id: drift
run: |
paths="$(git diff --name-only HEAD -- tests/fixtures | tr '\n' ',' | sed 's/,$//')"
echo "unexplained=${paths}" >> "$GITHUB_OUTPUT"
- name: Write merge packet
env:
HOOK_STATE: ${{ steps.hooks.outputs.state }}
UNEXPLAINED: ${{ steps.drift.outputs.unexplained }}
run: python3 scripts/write_merge_packet.py
- uses: actions/upload-artifact@v4
if: always()
with:
name: merge-packet
path: merge-packet.json
The skipped-hook signal is imperfect. A commit trailer can be forged. Treat missing as a warning on trusted branches and as a failure on release branches if that matches your threat model. Do not pretend a trailer is an attestation.
Step 4: Compute merge_ready without a model
Keep the vote in code you can grep. The script below is a sketch. Replace the job stub with the Checks API when you need live conclusions from sibling workflows.
#!/usr/bin/env python3
# scripts/write_merge_packet.py
import json
import os
import pathlib
def job_stub():
return [
{"name": "unit", "conclusion": "unknown-until-workflow-aggregates"},
{"name": "contract", "conclusion": "success"},
]
def main() -> None:
unexplained = [p for p in os.environ.get("UNEXPLAINED", "").split(",") if p]
packet = {
"commit": os.environ.get("GITHUB_SHA", "unknown"),
"hooks": {"pre_push_fixture_guard": os.environ.get("HOOK_STATE", "missing")},
"fixtures": {
"manifest_path": "tests/fixtures.sha256",
"unexplained_paths": unexplained,
},
"required_jobs": job_stub(),
"merge_ready": False,
"brief": None,
}
jobs_ok = all(
job["conclusion"] == "success"
for job in packet["required_jobs"]
if job["name"] == "contract"
)
packet["merge_ready"] = jobs_ok and not packet["fixtures"]["unexplained_paths"]
pathlib.Path("merge-packet.json").write_text(json.dumps(packet, indent=2) + "\n")
print("merge_ready=", packet["merge_ready"])
if __name__ == "__main__":
main()
Two rules matter. Unexplained fixture paths force merge_ready false. A missing hook trailer does not, by default, because laptop Git configs vary. If you tighten that, do it on purpose and document it in the branch rules.
Make the contract job required on the protected branch. An optional artifact is not a gate. People will merge around it.
Step 5: Optional brief from a free-tier model
The packet is already useful as JSON. Humans still like eight lines of English. That is the only place a model belongs.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you already use MonkeyCode, its free model access and free server option can host that summarizer so the job does not compete with your test runners. Do not send it the right to flip merge_ready. Send it the packet, the fixture note, and a hard cap on output length.
The following is a proposal. Swap in your real endpoint, auth, and model identifier. Do not treat the URL as product documentation.
#!/usr/bin/env bash
# scripts/summarize-packet.sh
set -euo pipefail
: "${MODEL_BASE_URL:?set MODEL_BASE_URL}"
: "${MODEL_TOKEN:?set MODEL_TOKEN}"
payload="$(python3 - <<'PY'
import json, pathlib
packet = json.loads(pathlib.Path("merge-packet.json").read_text())
note_path = pathlib.Path("tests/FIXTURE_NOTE.md")
note = note_path.read_text() if note_path.exists() else ""
print(json.dumps({
"instruction": (
"Write <=8 lines: what is merge-blocked, which fixtures changed, "
"which jobs are listed. Do not recommend merge. Do not invent jobs."
),
"packet": packet,
"fixture_note": note,
}))
PY
)"
curl -sS -X POST "$MODEL_BASE_URL" \
-H "authorization: bearer $MODEL_TOKEN" \
-H "content-type: application/json" \
--max-time 30 \
--data "$payload" \
-o /tmp/brief.json
python3 - <<'PY'
import json, pathlib
packet = json.loads(pathlib.Path("merge-packet.json").read_text())
raw = pathlib.Path("/tmp/brief.json").read_text()[:2000]
packet["brief"] = raw
pathlib.Path("merge-packet.json").write_text(json.dumps(packet, indent=2) + "\n")
PY
Run this step with if: always() after the contract job so a red fixture gate still gets a readable brief. If the model call fails, leave brief null and keep the JSON. Silence from a summarizer must never fail a merge that the contract already approved. It must never approve a merge the contract rejected.
Decision table
Use this at review time. A reviewer who only reads the brief will ship fixture drift.
| Signal | merge_ready |
What you do |
|---|---|---|
| Fixture hashes match, note unchanged | allowed if jobs green | Review code as usual |
Fixture hashes changed, path named in FIXTURE_NOTE.md
|
allowed if jobs green | Read the note before merge |
| Fixture hashes changed, path not named | false | Block. Do not ask a model to excuse it |
| Contract job red | false | Block |
| Hook trailer missing | warning (default) | Tighten on release branches if you need it |
| Model brief missing | unchanged | Merge on the packet, not the prose |
| Model brief says "looks good" while packet is false | false | Ignore the brief |
Print the table in the PR template if your team reviews on phones. The packet is the source of truth. The brief is a caption.
Limitations
This is not a merge queue. It does not serialize merges or rebase for you. GitHub required checks still need to include the contract job, or someone will merge with the job optional.
The hook trailer is not provenance. --no-verify plus a forged trailer still works. If you need real attestation, use signed commits and protected branch rules, not this sketch.
Hashing a huge fixture tree on every push will hurt. Shard by package, or hash only files Git says changed. Screenshot fixtures are noisy. This pattern fits structured golden files better than pixels.
The summarizer will hallucinate job names if you let it. Cap output. Pass the packet as JSON, not a prose dump of logs. Drop the step entirely if you cannot tolerate a wrong sentence next to a correct merge_ready: false.
Free model access and a free server option do not make the gate cheaper in the sense that matters. The gate is cmp and the note checker. Those should stay on a runner you control. Quotas, model names, and hardware for any vendor tier change. Do not size production capacity from this article.
Who should not use this
Skip it if you already have a merge queue, signed status checks, and fixture review in CODEOWNERS. You would be adding a second dashboard.
Skip it if your goal is auto-merge. This workflow is for slowing the last click, not removing it.
Skip it if fixtures are generated at runtime and never committed. There is no manifest to pin.
Skip it for binary-heavy snapshot suites until you have shard-aware hashing. You will train people to ignore the job.
A green-to-merge path you can actually run
- Install the pre-push hook and hash script.
- Make
merge-packeta required check on the protected branch. - Upload
merge-packet.jsonon every PR. - Optionally fill
brieffrom a model hosted wherever you already have free capacity. - Merge only when
merge_readyis true and a human has read named fixture paths.
The conclusion does not change if you delete the model step. The packet still tells you whether the green check omitted fixture drift. That is the whole point.
If you want the brief off your GitHub-hosted minutes, point MODEL_BASE_URL at free model access you already have. MonkeyCode's free server option is one place that job can live. Keep the vote in your script.
Top comments (0)