A merge rule should wait on one check name you pinned before the pull request opened. Other jobs may hunt noise, draft notes, or retry a shaky test. They do not get to publish that pinned name, rewrite the fixture behind it, or cancel the run the rule is watching.
That split is the whole gate. Rename the check, and the badge is a new label. Keep the name, and you still have to prove the job is read-only and bound to a fixture hash.
Why the badge string is the control
Required status checks match text. GitHub does not match your intention, and it does not match the story in the pull request body. Actions usually shows a check as a workflow name plus a job name, but you should copy the live string from a dry run into the ruleset instead of guessing. Confirm the current behavior in GitHub's workflow syntax docs and status-check docs, then trust the string your repository actually emits.
An agent that can edit .github/workflows can publish a friendly green under a new job name. If your ruleset still lists the old name, the new badge is decoration. If someone updates the ruleset to follow the new name without reviewing the job, the decoration becomes the gate. You close that gap by pinning the name in a policy file and failing the pull request when the workflow and the policy disagree.
This is not a claim about any model family. It is a claim about strings, permissions, and hashes. Coding agents make the gap easier to hit, because a generated workflow edit and a generated test can land in the same pull request. The ruleset still only sees names.
Set the policy before the workflow
Write the contract first. The table is the decision. The files only encode it.
| Decision | Pinned merge check | Noisy lane |
|---|---|---|
| Check name | Exact string copied from a dry run | Any other name |
| Fixture directory | Read a hashed bundle | Scratch only, never uploaded as the bundle |
cancel-in-progress |
false |
true is allowed |
| Token scope | contents: read |
No write scope either |
| Model note | Ignored | Advisory artifact only |
| Listed in the ruleset | Yes | No |
If the noisy lane must be green before merge, it is not a noisy lane. Move that test into the pinned check, or delete it. Do not keep a second required context "temporarily." Temporary required checks are how old names survive.
1. Store the pinned name outside the job
Create .github/merge-policy.json. Review it like production code. A pull request that changes it needs an owner review, even when the rest of the diff is a one-line test.
{
"required_context": "gate/merge-fixture",
"required_job_id": "merge-fixture",
"quarantine_job_id": "flake-explore",
"quarantine_context_prefix": "explore/flake-",
"fixture_bundle": "fixtures/locked"
}
The context value above is an example string, not a name GitHub will invent for you. After a dry run on a non-default branch, compare it with the check name you actually see. If the live name includes the workflow title, put that full string in the ruleset, and keep the job name: field aligned with the portion you control.
2. Give the two jobs different lifetimes
This workflow is a proposal. It has not been run for this article. Pin action versions your organization has already approved before you adopt it.
name: merge-gate
on:
pull_request:
permissions:
contents: read
jobs:
merge-fixture:
name: gate/merge-fixture
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
group: merge-fixture-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Check policy, permissions, and fixture hash
run: python3 scripts/check_merge_gate.py --policy .github/merge-policy.json
- name: Run tests bound to the locked bundle
run: python3 -m unittest discover -s tests/locked -v
flake-explore:
name: explore/flake-note
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
group: flake-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Rerun the noisy suite without failing the pin
run: python3 -m unittest discover -s tests/noisy -v
continue-on-error: true
- name: Write an advisory note
if: always()
run: python3 scripts/flake_note.py --log noisy.log --out flake-note.md
The pinned job cannot be cancelled by a newer push on that pull request. The noisy job can. That difference is intentional. A follow-up commit may abandon an exploration. It must not abandon the run your ruleset is waiting for.
Both jobs stay read-only. A required job that can push is a job that can repair the fixture it is about to trust. Keep contents: write off both lanes even if a later step "only" wants to commit a regenerated cassette.
3. Fail the pull request when the lanes collapse
Save the checker as scripts/check_merge_gate.py. It is a narrow proposal, not a full Actions language parser. It extracts two job blocks by indentation, then refuses the collisions that matter.
#!/usr/bin/env python3
"""Proposal checker. This article does not report an execution result."""
import argparse
import hashlib
import json
import pathlib
import sys
def sha256_tree(root: pathlib.Path) -> str:
files = sorted(p for p in root.rglob("*") if p.is_file())
if not files:
sys.exit(f"empty fixture bundle: {root}")
digest = hashlib.sha256()
for path in files:
rel = path.relative_to(root).as_posix().encode()
digest.update(rel)
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def job_block(workflow: str, job_id: str) -> str:
lines = workflow.splitlines()
start = None
for index, line in enumerate(lines):
if line == f" {job_id}:":
start = index
break
if start is None:
sys.exit(f"missing job id: {job_id}")
block = [lines[start]]
for line in lines[start + 1 :]:
if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"):
break
block.append(line)
return "\n".join(block)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--policy", required=True)
parser.add_argument("--workflow", default=".github/workflows/merge-gate.yml")
args = parser.parse_args()
policy = json.loads(pathlib.Path(args.policy).read_text(encoding="utf-8"))
workflow_path = pathlib.Path(args.workflow)
workflow = workflow_path.read_text(encoding="utf-8")
required = policy["required_context"]
prefix = policy["quarantine_context_prefix"]
if required.startswith(prefix):
sys.exit("required context uses the quarantine prefix")
required_job = job_block(workflow, policy["required_job_id"])
quarantine_job = job_block(workflow, policy["quarantine_job_id"])
if required_job.count(f"name: {required}") != 1:
sys.exit("pinned job must publish the required context once")
if required in quarantine_job:
sys.exit("noisy lane reuses the pinned context")
if "cancel-in-progress: false" not in required_job:
sys.exit("pinned job must refuse cancel-in-progress")
if "contents: read" not in required_job:
sys.exit("pinned job must declare contents: read")
if "contents: write" in required_job:
sys.exit("pinned job must not request contents: write")
digest = sha256_tree(pathlib.Path(policy["fixture_bundle"]))
receipt = {
"required_context": required,
"fixture_sha256": digest,
"workflow_file": workflow_path.as_posix(),
"advisory_note": "not-a-status",
}
pathlib.Path("merge-receipt.json").write_text(
json.dumps(receipt, indent=2) + "\n", encoding="utf-8"
)
print(digest)
if __name__ == "__main__":
main()
Run it before you enable the ruleset entry:
mkdir -p fixtures/locked tests/locked tests/noisy
printf 'id: sample\n' > fixtures/locked/case.json
python3 scripts/check_merge_gate.py --policy .github/merge-policy.json
git status --short
You want a receipt whose fixture_sha256 matches the locked tree, and a worktree diff you can explain. If the checker rewrites merge-receipt.json on every run, commit that file only when the hash changed for a reason you reviewed. An unexpected hash change means the bundle moved. Do not shrug that into a green badge.
The indentation parser will miss a job written with a different layout. That is a reason to keep the workflow boring, not a reason to trust an unparsed file. If your organization already calls a reusable workflow from a locked repository, prefer that pin, and use this script only as a local tripwire.
4. Put model text on the noisy lane only
The advisory writer does not decide merge. It copies a tail of the noisy log so a reviewer can see why the exploration failed. The sample does not call a network.
#!/usr/bin/env python3
"""Advisory note writer. A failing suite must not change this exit code."""
import argparse
import pathlib
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--log", required=True)
parser.add_argument("--out", required=True)
args = parser.parse_args()
source = pathlib.Path(args.log)
text = source.read_text(encoding="utf-8", errors="replace")[-4000:] if source.exists() else ""
pathlib.Path(args.out).write_text(
"Advisory only. Do not add this file's job to the ruleset.\n\n" + text,
encoding="utf-8",
)
if __name__ == "__main__":
main()
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you want a shorter note than a raw log tail, MonkeyCode's free model access can sit behind this writer on the noisy lane. Keep that call out of merge-fixture. The free server option matters for the same boundary: run the draft there so a slow summary cannot occupy, cancel, or impersonate the pinned runner. This article does not name a model, a token quota, a machine size, or a period of free availability. Those details change, and they are not the gate. Read the current product documentation before you treat either option as present.
One use is enough. Try the free model access and the free server option for that advisory note if you want the draft off the required job, then leave the note out of the ruleset.
5. Turn the pin on in this order
Do not enable auto-merge in the same change that introduces the workflow.
- Land the policy, the checker, and the workflow with the ruleset still pointing at your existing required checks.
- Open a dry-run pull request and copy the live check name from the checks list.
gh api repos/OWNER/REPO/commits/SHA/check-runs --jq '.check_runs[].name'is the command shape. Substitute your owner, repository, and commit. - If the live name differs from
required_context, fix the policy in a reviewed change. Do not pick whichever badge is already green in the ruleset UI. - Add only that verified string as a required check. Do not add
explore/flake-note. - On the next pull request, compare
merge-receipt.jsonwith a fresh hash offixtures/locked/at the merge commit.
A reviewer still reads the diff. The receipt is a breadcrumb, not an approval. If step 2 shows two names that both look official, stop and delete the extra job before you touch branch protection.
Who should skip this
Skip the pattern when you have no required checks at all. A local script cannot invent that platform feature. Skip it when one unreviewed credential can both edit workflows and approve merges. The pin is theater in that setup. Skip it when you need a model verdict to be the merge authority. This design refuses that role on purpose.
Also skip the in-repo tripwire if a locked reusable workflow already publishes the required check from a ref the pull request cannot move. Adding a second, weaker parser will only drift. Do not use the noisy lane to hide a product bug you should fix. Isolation is for tests you have already decided are non-blocking. It is not a pardon.
Limits of the artifact
The checker does not evaluate GitHub expressions. A job that concatenates the context name at runtime can slip past a string search. The receipt hash covers file bytes under fixtures/locked/, not the Actions cache, not container images, and not the runner image. continue-on-error: true on the noisy suite means a red exploration can still look quiet in the pull request conversation. That quiet is acceptable only because the job is absent from the ruleset.
Free model access and a free server option are availability claims supplied for this draft. They are not a capacity plan, an SLA, or a measured speedup. No pass rate is reported here because none was measured.
Pin the name you verified. Hash the bundle the pinned job reads. Let every other lane stay optional, including a model note written somewhere else.
Top comments (0)