DEV Community

Emery Lin
Emery Lin

Posted on

Treat Merge as Promotion: Four Stages Between Local Green and main

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.

  1. Local fail-closed, with a receipt that freezes fixtures and paths.
  2. Pull-request checks that re-run tests and recompute the freeze.
  3. A merge queue that isolates one flake retry from a real failure.
  4. 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}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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)