DEV Community

Emery Lin
Emery Lin

Posted on

Put the Green-to-Merge Path in the Repo, Then Verify the Receipt

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

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

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

Install it once:

git config core.hooksPath .githooks
chmod +x .githooks/pre-push scripts/hash-fixtures.sh
Enter fullscreen mode Exit fullscreen mode

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

Call it before every agent PR:

chmod +x scripts/merge-dry-run.sh
./scripts/merge-dry-run.sh
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

  1. Add .ci/merge-path.yml with four gates and budgets of 0/0/1/0.
  2. Hash current fixtures into tests/fixtures.lock with ./scripts/hash-fixtures.sh --write.
  3. Point core.hooksPath at .githooks and push a no-op branch to prove the hook fires.
  4. Run ./scripts/merge-dry-run.sh on a known-good commit, then on a commit that edits a fixture without updating the lock. The second run must fail.
  5. Land the workflow. Make merge-receipt the only required check. Open a PR that skips unit and confirm merge stays blocked.
  6. 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)