DEV Community

Emery Lin
Emery Lin

Posted on

Don't Merge on Green: A Fixture Contract, a Pre-Push Hook, and a Merge Packet

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
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Install the pre-push hook and hash script.
  2. Make merge-packet a required check on the protected branch.
  3. Upload merge-packet.json on every PR.
  4. Optionally fill brief from a model hosted wherever you already have free capacity.
  5. Merge only when merge_ready is 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)