DEV Community

Emery Lin
Emery Lin

Posted on

Pin the Check Name, Attest the SHA, Then Allow Merge

A green check is not a merge decision. If an agent can add a similarly named workflow, or if you merge on any success, you will ship a SHA that never passed the gate you think you have. Pin the required check to a workflow digest, make CI write a merge attestation for that exact SHA, and refuse merge until both match.

You do not need a new platform to do this. You need a pin file, a hook that fails closed, and one required check whose name cannot drift.

Why agent diffs make name-only gates fail

Agent-written patches arrive faster than review capacity. That is useful. It is also how a yellow workflow becomes a fake green.

GitHub branch protection matches check names. Names are strings. Strings get copied. If protection says tests and an agent opens .github/workflows/tests-agent.yml with a job named tests, you can get a green mark that is not your harness.

Keep the merge rule boring. One check. One workflow file. One digest. Everything else is noise.

The contract

Three objects, one SHA:

  1. A pin file that names the required check and identifies the workflow that produces it.
  2. A CI job that runs fixtures, records flake retries, and writes merge-attestation.json.
  3. A verifier that blocks push and merge when the attestation is missing, stale, or unbound to HEAD.

The agent owns the diff. CI owns green. The merge path only reads the attestation.

The scripts below are examples you can adapt. They are not a production audit of your GitHub org. Do not paste secrets into the attestation.

1. Pin the required check

Put the pin next to the workflow so a path change is a pin change.

.ci/merge-pin.json:

{
  "required_check_name": "merge-gate",
  "workflow_path": ".github/workflows/merge-gate.yml",
  "max_flake_retries": 1,
  "fixture_glob": "tests/fixtures/**",
  "attestation_path": ".ci/merge-attestation.json"
}
Enter fullscreen mode Exit fullscreen mode

On GitHub, the required check string is not always the name: you put on the workflow. After the first run, copy the exact check name from the branch protection UI into required_check_name. If the UI shows merge-gate / merge-gate, pin that full string. Do not guess.

Hash the workflow in CI and in the hook with the same command. A stored digest that nobody updates is a false sense of safety. Compute it from the file on that SHA:

python3 - <<'PY'
import hashlib, pathlib
p = pathlib.Path(".github/workflows/merge-gate.yml")
print(hashlib.sha256(p.read_bytes()).hexdigest())
PY
Enter fullscreen mode Exit fullscreen mode

If that file moves, the pin is wrong. Fail closed.

2. Fail the push if the pin cannot be proven locally

Install a pre-push hook that refuses the push when HEAD cannot prove the pin. You are not running the full suite on every laptop. You are proving the pin still points at a real file, the check name is stable, and the fixture glob still matches something.

.githooks/pre-push (example):

#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import glob, json, pathlib, sys
pin = json.loads(pathlib.Path(".ci/merge-pin.json").read_text())
wf = pathlib.Path(pin["workflow_path"])
if not wf.is_file():
    print("merge pin points at missing workflow:", wf, file=sys.stderr)
    sys.exit(1)
if pin["required_check_name"] not in {"merge-gate", "merge-gate / merge-gate"}:
    print("required check name drifted:", pin["required_check_name"], file=sys.stderr)
    sys.exit(1)
files = glob.glob(pin["fixture_glob"], recursive=True)
if not files:
    print("fixture glob matched nothing; fail closed", file=sys.stderr)
    sys.exit(1)
print("fixtures pinned:", len(files))
PY
Enter fullscreen mode Exit fullscreen mode

Enable it once:

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

If a contributor bypasses hooks, CI still has to refuse. The hook is a fast local filter, not the merge authority.

3. Make CI write the attestation

The required workflow is the only job allowed to declare green. Give the job the check name you pinned. Run fixtures. Retry at most once if the pin allows it. Then write an attestation bound to GITHUB_SHA.

.github/workflows/merge-gate.yml (example; pin third-party actions by commit SHA in production):

name: merge-gate
on:
  pull_request:
  push:
    branches: [main]
jobs:
  merge-gate:
    name: merge-gate
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      GIT_SHA: ${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - name: Run fixture harness with one recorded retry
        run: |
          set -euo pipefail
          glob=$(python3 -c 'import json; print(json.load(open(".ci/merge-pin.json"))["fixture_glob"])')
          FLAKE_RETRIES_USED=0
          if ! python3 scripts/run_fixtures.py --glob "$glob"; then
            FLAKE_RETRIES_USED=1
            python3 scripts/run_fixtures.py --glob "$glob"
          fi
          echo "FLAKE_RETRIES_USED=$FLAKE_RETRIES_USED" >> "$GITHUB_ENV"
      - name: Write merge attestation
        if: success()
        run: python3 scripts/write_attestation.py
      - name: Verify attestation on this SHA
        if: success()
        run: python3 scripts/verify_attestation.py
      - name: Upload attestation
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: merge-attestation-${{ github.sha }}
          path: .ci/merge-attestation.json
          if-no-files-found: error
Enter fullscreen mode Exit fullscreen mode

scripts/write_attestation.py (example):

#!/usr/bin/env python3
"""Example only. Writes a SHA-bound merge attestation."""
import glob, hashlib, json, os, pathlib, time

pin = json.loads(pathlib.Path(".ci/merge-pin.json").read_text())
wf = pathlib.Path(pin["workflow_path"]).read_bytes()
fixtures = sorted(glob.glob(pin["fixture_glob"], recursive=True))
digest = hashlib.sha256()
for path in fixtures:
    digest.update(pathlib.Path(path).read_bytes())

att = {
    "sha": os.environ["GIT_SHA"],
    "required_check_name": pin["required_check_name"],
    "workflow_path": pin["workflow_path"],
    "workflow_sha256": hashlib.sha256(wf).hexdigest(),
    "fixture_sha256": digest.hexdigest(),
    "fixture_count": len(fixtures),
    "flake_retries_used": int(os.environ.get("FLAKE_RETRIES_USED", "0")),
    "max_flake_retries": pin["max_flake_retries"],
    "written_at_unix": int(time.time()),
}
if att["flake_retries_used"] > att["max_flake_retries"]:
    raise SystemExit("flake retries exceeded pin")
if att["fixture_count"] == 0:
    raise SystemExit("no fixtures hashed; fail closed")
pathlib.Path(pin["attestation_path"]).write_text(json.dumps(att, indent=2) + "\n")
print("wrote", pin["attestation_path"])
Enter fullscreen mode Exit fullscreen mode

Keep scripts/run_fixtures.py deterministic. If a fixture needs network, it does not belong in the merge gate. Record flake retries in the environment the writer reads. A silent rerun that never lands in the attestation is how you launder flakes into greens. If the second run fails, the job fails. No attestation. No merge.

4. Verify before merge

Branch protection should require exactly the pinned check. Then a merge helper downloads the artifact for that SHA and checks the fields.

scripts/verify_attestation.py (example):

#!/usr/bin/env python3
import hashlib, json, os, pathlib, sys

pin = json.loads(pathlib.Path(".ci/merge-pin.json").read_text())
att = json.loads(pathlib.Path(pin["attestation_path"]).read_text())
want_sha = os.environ.get("GIT_SHA", "")
errors = []
if not want_sha:
    errors.append("GIT_SHA is required")
if att.get("sha") != want_sha:
    errors.append(f"attestation sha {att.get('sha')} != GIT_SHA")
if att.get("required_check_name") != pin["required_check_name"]:
    errors.append("check name does not match pin")
wf_hash = hashlib.sha256(pathlib.Path(pin["workflow_path"]).read_bytes()).hexdigest()
if att.get("workflow_sha256") != wf_hash:
    errors.append("workflow digest does not match the file on this SHA")
if int(att.get("flake_retries_used", 99)) > pin["max_flake_retries"]:
    errors.append("flake retries exceed pin")
if errors:
    print("\n".join(errors), file=sys.stderr)
    sys.exit(1)
print("merge attestation ok for", att["sha"])
Enter fullscreen mode Exit fullscreen mode

Call the verifier as the last step of merge-gate as well. If the writer and the verifier disagree, you want that failure on the PR, not at 17:00 on a Friday.

A maintainer helper can stay small:

GIT_SHA=$(git rev-parse HEAD)
# Download the merge-attestation-$GIT_SHA artifact from the merge-gate run, then:
GIT_SHA="$GIT_SHA" python3 scripts/verify_attestation.py && gh pr merge --merge
Enter fullscreen mode Exit fullscreen mode

Do not gh pr merge on a green UI check alone.

Green-to-merge path

Run it in this order every time:

  1. Human or agent opens a diff. The pin files stay out of that diff unless a human is reviewing the gate itself.
  2. Pre-push hook confirms the workflow path, check name, and fixture glob.
  3. merge-gate runs fixtures, records retries, writes the attestation, verifies it, and uploads it.
  4. Branch protection requires only that check.
  5. Merge helper re-reads the attestation for git rev-parse HEAD.
  6. If any field drifts, you do not merge. You fix the pin or the fixtures.

That is the whole path. Chat transcripts are not an input.

Decision table

Signal Merge? Why
merge-gate green, attestation SHA matches HEAD, workflow digest matches pin Yes Gate and evidence agree
Some other check named tests is green No Name is not the pin
Attestation missing on the SHA No Green without evidence
Workflow file changed, digest mismatch No Pin broken; re-review the gate
flake_retries_used greater than the pin No Flakes were laundered
Attestation SHA is the parent commit No Evidence is for a different tree
Hook bypassed, CI still red No Hook is not authority
Pin and workflow edited in the same PR as product code No The gate changed itself

Paste the table into the PR template if people keep asking why merge is blocked.

Where a coding agent belongs on this path

The agent may draft the diff. It may even draft a new fixture. It does not choose the required check name, and it does not write the attestation. If you let the model edit .github/workflows/merge-gate.yml and .ci/merge-pin.json in the same PR, you no longer have a pin. Split those paths onto a review rule, or fail the gate when both change together.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

If you already use a coding assistant to generate the patch, run the same scripts/run_fixtures.py command the gate will run. MonkeyCode's free model access and free server option can host that harness so the agent, your laptop, and CI are not inventing three different test commands. That is the only role it has here: a shared place to execute the fixture command you already pinned. It does not replace branch protection, and it does not get a vote on merge.

Limitations

This does not sign the runner image. A compromised hosted runner, an unpinned third-party action, or a pull_request_target workflow with write permissions can still lie. Pin actions by commit SHA before you treat the attestation as strong evidence.

The attestation is not SLSA provenance. It is a JSON file your own job wrote. That is enough to stop check-name impersonation and unbound greens. It is not enough for a regulated supply-chain audit.

Hooks are optional on the client. Fork PRs may not upload artifacts the way you expect. Secrets must never enter the attestation. Timestamp skew is not a security control.

If your suite is non-deterministic, a retry of one will not save you. Fix the fixtures. The pin's max_flake_retries is a fuse, not a reliability strategy.

Who should skip this

Skip it if you merge to main from a laptop with no branch protection. Skip it if one required check already is a merge queue with a SHA-locked policy you trust. Skip it if the repo has no fixtures worth hashing. Skip it if you wanted a product tour instead of a gate.

You wanted a green-to-merge path. Pin the check, attest the SHA, and let everything else wait.

Top comments (0)