A green job is a boolean. Merge eligibility is a contract. If an agent-authored diff can reach main because unit went green, you skipped fixture ownership, the flake cap, and the required-context list. Write a SHA-bound merge ticket locally, refuse a missing or stale ticket in CI, and only then enqueue.
You already know the failure mode. The PR is green. The queue is quiet. Then a fixture nobody owned moves, a flaky test is rerun twice, and a workflow file changes under a check that never reads YAML. The ticket exists to make those facts visible before merge, not after rollback.
What the ticket is for
The ticket is not another status-check name. It is a small JSON document bound to the commit SHA you intend to merge. CI recomputes the same fields from the diff. If they diverge, the job fails closed.
You keep promotion boring. Local hooks mint the ticket. CI verifies it. The merge queue reads the verified artifact, not a recycled success bit from a renamed job.
Decision table: mergeable or not
Use this table as the contract. Adapt the paths to your repo. Do not treat a blank cell as allow.
| Signal | Human PR | Agent-authored PR | Fail closed when |
|---|---|---|---|
| Ticket missing or SHA mismatch | block | block | always |
Diff touches .github/workflows/
|
require CODEOWNERS | block | agent + workflow path |
| Diff touches unowned fixtures | require review | block unless fixtures-ok
|
unmapped fixture path |
| Flake reruns on this SHA | cap 1 | cap 0 | reruns exceed cap |
| Required contexts | exact name set | exact name set plus ticket-verify
|
any missing or renamed |
| Retry / rebase storms | concurrency group | concurrency group, max 1 retry | second running job for SHA |
That table is the policy. The code below only encodes it. If a row does not match your risk, change the table first, then the script.
1. Define a tiny ticket document
Keep it greppable in logs. Do not commit it. A committed ticket goes stale on the next amend.
{
"sha": "REPLACE_WITH_GIT_SHA",
"agent_authored": true,
"allowed_path_prefixes": ["src/", "tests/unit/"],
"touched_paths": ["src/billing.py", "tests/unit/test_billing.py"],
"unowned_fixtures": [],
"workflow_touched": false,
"flake_reruns": 0,
"flake_cap": 0,
"required_contexts": ["lint", "unit", "ticket-verify"],
"retry_count": 0,
"max_retries": 1
}
Store it as .git/merge-ticket.json on the laptop and as a CI artifact named merge-ticket.json. The queue should demand the artifact SHA, not the conversation.
2. Map fixtures so unmapped paths fail closed
You need an ownership file you can grep. Start smaller than you think.
# fixtures.owners
# prefix<space>team
tests/fixtures/billing/ billing-team
tests/fixtures/auth/ auth-team
tests/fixtures/shared/ platform
Walk one example. tests/fixtures/billing/invoice.json is owned. tests/fixtures/tmp/dump.json is not. An agent diff that adds the dump is a merge block, not a review comment.
Unmapped fixture paths are not probably fine. They are a closed gate. Agent diffs should not expand fixtures.owners without a human review of the map itself.
3. Mint the ticket in a pre-push hook
The hook is the first gate. If the ticket cannot be built, you do not push.
#!/usr/bin/env bash
# .git/hooks/pre-push — example template, not a production dump
set -euo pipefail
SHA="$(git rev-parse HEAD)"
AGENT=0
if git log -1 --format=%B | grep -qiE '(^Co-authored-by:.*agent)|\[agent\]'; then
AGENT=1
fi
python3 scripts/merge_ticket.py mint \
--sha "$SHA" \
--base origin/main \
--owners fixtures.owners \
--agent-authored "$AGENT" \
--out .git/merge-ticket.json
python3 scripts/merge_ticket.py verify --ticket .git/merge-ticket.json
Install it explicitly. A hook that is not executable is not a control.
chmod +x scripts/merge_ticket.py .git/hooks/pre-push
git fetch origin main
Fetch before you mint. origin/main...HEAD is a lie if you never updated origin/main.
4. Recompute the ticket in CI
CI must recompute, not trust an uploaded JSON. Recompute from git diff --name-only origin/main...HEAD. Compare. Fail closed on drift.
Pin the job name. If someone renames ticket-verify to ticket_verify to dodge branch protection, the ticket's required_contexts list no longer matches. That is the point.
# .github/workflows/merge-ticket.yml — example template
name: merge-ticket
on:
pull_request:
merge_group:
concurrency:
group: ticket-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: true
jobs:
ticket-verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Recompute and verify ticket
env:
AGENT: ${{ contains(join(github.event.pull_request.labels.*.name, ','), 'agent') }}
FLAKE_RERUNS: ${{ vars.FLAKE_RERUNS || '0' }}
run: |
git fetch origin main --depth=1 || git fetch origin main
python3 scripts/merge_ticket.py mint \
--sha "${GITHUB_SHA}" \
--base origin/main \
--owners fixtures.owners \
--agent-authored "$AGENT" \
--flake-reruns "${FLAKE_RERUNS}" \
--out merge-ticket.json
python3 scripts/merge_ticket.py verify --ticket merge-ticket.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: merge-ticket
path: merge-ticket.json
Required contexts stay exact: lint, unit, ticket-verify. The merge queue admits the SHA only when those names exist and the artifact SHA equals HEAD.
5. Put the flake cap on the SHA, not the PR
Reruns attach to the commit, not the conversation. A new push gets a new SHA and a new budget. An agent retry of the same SHA gets none.
flake_cap(human) = 1
flake_cap(agent) = 0
If unit is flaky, you spend the human budget or you fix the test. You do not let an agent click re-run until the queue is full. Treat a missing rerun sidecar as zero. Missing must not mean unlimited.
Worked example: mint and verify
The script below is a compact example you can drop into scripts/merge_ticket.py and extend. It is a proposal. It is not a claim that it ran on your default branch.
#!/usr/bin/env python3
"""SHA-bound merge ticket. Example only; unexecuted here."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
DEFAULT_ALLOWED = ("src/", "tests/unit/")
WORKFLOW_PREFIX = ".github/workflows/"
FIXTURE_HINTS = ("tests/fixtures/", "testdata/")
def git_output(args: list[str]) -> str:
return subprocess.check_output(args, text=True).strip()
def diff_names(base: str) -> list[str]:
out = git_output(["git", "diff", "--name-only", f"{base}...HEAD"])
return [line for line in out.splitlines() if line]
def load_owners(path: Path) -> list[tuple[str, str]]:
rows: list[tuple[str, str]] = []
if not path.exists():
return rows
for raw in path.read_text().splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
prefix, team = line.split()
rows.append((prefix, team))
return rows
def unowned_fixtures(paths: list[str], owners: list[tuple[str, str]]) -> list[str]:
bad: list[str] = []
for p in paths:
if not p.startswith(FIXTURE_HINTS):
continue
if not any(p.startswith(prefix) for prefix, _ in owners):
bad.append(p)
return bad
def truthy(value: str | bool) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def build_ticket(args: argparse.Namespace) -> dict:
touched = diff_names(args.base)
owners = load_owners(Path(args.owners))
agent = truthy(args.agent_authored)
return {
"sha": args.sha or git_output(["git", "rev-parse", "HEAD"]),
"agent_authored": agent,
"allowed_path_prefixes": list(DEFAULT_ALLOWED),
"touched_paths": touched,
"unowned_fixtures": unowned_fixtures(touched, owners),
"workflow_touched": any(p.startswith(WORKFLOW_PREFIX) for p in touched),
"flake_reruns": int(args.flake_reruns),
"flake_cap": 0 if agent else 1,
"required_contexts": ["lint", "unit", "ticket-verify"],
"retry_count": int(args.retry_count),
"max_retries": 1,
}
def violations(ticket: dict, current_sha: str) -> list[str]:
problems: list[str] = []
if ticket["sha"] != current_sha:
problems.append("sha-mismatch")
if ticket["unowned_fixtures"]:
problems.append("unowned-fixtures:" + ",".join(ticket["unowned_fixtures"]))
if ticket["agent_authored"] and ticket["workflow_touched"]:
problems.append("agent-touched-workflow")
if int(ticket["flake_reruns"]) > int(ticket["flake_cap"]):
problems.append("flake-budget-exceeded")
if int(ticket["retry_count"]) > int(ticket["max_retries"]):
problems.append("retry-storm")
skip_prefixes = FIXTURE_HINTS + (WORKFLOW_PREFIX,)
for p in ticket["touched_paths"]:
if not ticket["agent_authored"]:
break
if p.startswith(skip_prefixes):
continue
allowed = ticket["allowed_path_prefixes"]
if not any(p.startswith(a) for a in allowed):
problems.append(f"path-not-allowed:{p}")
return problems
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--sha", default="")
parser.add_argument("--base", default="origin/main")
parser.add_argument("--owners", default="fixtures.owners")
parser.add_argument("--agent-authored", default="0")
parser.add_argument("--flake-reruns", default="0")
parser.add_argument("--retry-count", default="0")
parser.add_argument("--out", default=".git/merge-ticket.json")
parser.add_argument("--ticket", default=".git/merge-ticket.json")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("mint")
sub.add_parser("verify")
args = parser.parse_args()
current_sha = args.sha or git_output(["git", "rev-parse", "HEAD"])
if args.cmd == "mint":
ticket = build_ticket(args)
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(ticket, indent=2) + "\n")
print(args.out)
return 0
ticket = json.loads(Path(args.ticket).read_text())
problems = violations(ticket, current_sha)
if problems:
print("ticket failed:", file=sys.stderr)
for item in problems:
print(f" - {item}", file=sys.stderr)
return 1
print("ticket ok", ticket["sha"])
return 0
if __name__ == "__main__":
raise SystemExit(main())
Reproducible test plan
Run these as ordinary unit tests. Mock ticket dicts. Do not hit the network.
- SHA mismatch: ticket
shaisaaa, current SHA isbbb. Expectsha-mismatchand exit 1. - Owned fixture:
touched_pathsincludestests/fixtures/billing/invoice.jsonandfixtures.ownersmaps that prefix. Expect nounowned-fixtures. - Unmapped fixture:
tests/fixtures/tmp/dump.jsonwith an agent ticket. Expect a closed gate. - Workflow path: agent ticket with
.github/workflows/ci.yml. Expectagent-touched-workflow. - Flake cap: human ticket with
flake_reruns=2,flake_cap=1. Expectflake-budget-exceeded. Agent ticket withflake_reruns=1must fail even faster. - Path allowlist: agent ticket touching
infra/terraform/main.tf. Expectpath-not-allowedunless you changed the table.
A minimal pytest sketch for step 3:
from merge_ticket import violations
def test_unmapped_fixture_fails_closed():
ticket = {
"sha": "abc",
"agent_authored": True,
"allowed_path_prefixes": ["src/", "tests/unit/"],
"touched_paths": ["tests/fixtures/tmp/dump.json"],
"unowned_fixtures": ["tests/fixtures/tmp/dump.json"],
"workflow_touched": False,
"flake_reruns": 0,
"flake_cap": 0,
"required_contexts": ["lint", "unit", "ticket-verify"],
"retry_count": 0,
"max_retries": 1,
}
problems = violations(ticket, "abc")
assert any(p.startswith("unowned-fixtures:") for p in problems)
If you cannot run that assertion locally, do not enable the merge queue path. The ticket is only useful when the verifier is in the required set.
Green-to-merge path
- A human or an agent produces a commit.
-
pre-pushmints.git/merge-ticket.jsonfrom the diff andfixtures.owners. - Push is refused if the ticket already fails locally.
- CI checks out the SHA, recomputes the ticket, and publishes the artifact.
-
lintandunitmay run in parallel, but the queue still requiresticket-verify. - The merge queue admits the SHA only when
required_contextsmatch exactly and the artifact SHA equalsHEAD. - A flake rerun is a new ticket field, not a silent click. Agent SHAs get a zero cap.
Skip a step and you are back to merging a boolean.
Where a model belongs — and where it does not
Some teams want a natural-language check: does this PR description match the touched paths? That is not merge authority. It is a linter on the ticket's story.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you run that classifier at all, run it on a redacted path list with no secrets, no GITHUB_TOKEN, and no deploy keys. MonkeyCode's free model access and free server option are enough for that isolated step: the model returns yes or no on a path/intent mismatch, and ticket-verify still owns the fail-closed decision. Do not let the model rename required contexts or increment the flake cap.
Keep the prompt boring. Feed the touched paths, the allow prefixes, and the PR title. Ask for a single token: MATCH or DRIFT. Parse only those two strings. Anything else is a failed classifier, not a merge.
Limitations
The ticket is only as strong as CI's ability to recompute it. If ticket-verify can be skipped, renamed, or run on a fork workflow you do not control, the file is theater.
A model classifier can be wrong. Treat it as an extra signal, never as the owner map. Inference on a free server does not see your private fixtures unless you paste them. Do not paste them.
git diff origin/main...HEAD is wrong if main moved and you did not fetch. Shallow clones will lie about names. The workflow uses fetch-depth: 0 for a reason.
This does not replace code review, CODEOWNERS, or secret scanning. It serializes merge eligibility. That is all.
Branch protection that matches .*verify.* will accept a renamed job. Pin the exact context string. If your queue cannot require an exact check name, fix the queue before you add JSON.
Who should not use this
Do not add a merge ticket if you are a solo repo with no agents, no fixtures, and one required check. The ceremony will cost more than the risk.
Do not use it to auto-merge agent PRs. The point is to block, not to accelerate.
Do not point a model at the full tree, .env, or Actions secrets to explain the diff. The classifier belongs on a redacted path list.
If you cannot list fixture owners, stop. An empty fixtures.owners plus fail-closed means every fixture change blocks. That is safer than a silent allow, and it will also halt the team until you fill the map.
Start with the map, then pin the name
Start with fixtures.owners and a verifier that fails closed. Then pin ticket-verify. Only after that should you care whether a model helped write the PR body or classify the path list.
If you run the classifier on a free model and a free server, keep it read-only on the diff and leave merge authority in CI.
Top comments (0)