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:
- A pin file that names the required check and identifies the workflow that produces it.
- A CI job that runs fixtures, records flake retries, and writes
merge-attestation.json. - 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"
}
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
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
Enable it once:
git config core.hooksPath .githooks
chmod +x .githooks/pre-push
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
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"])
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"])
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
Do not gh pr merge on a green UI check alone.
Green-to-merge path
Run it in this order every time:
- Human or agent opens a diff. The pin files stay out of that diff unless a human is reviewing the gate itself.
- Pre-push hook confirms the workflow path, check name, and fixture glob.
-
merge-gateruns fixtures, records retries, writes the attestation, verifies it, and uploads it. - Branch protection requires only that check.
- Merge helper re-reads the attestation for
git rev-parse HEAD. - 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)