DEV Community

Emery Lin
Emery Lin

Posted on

Give the Merge Queue a SHA-Bound Ticket

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

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

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

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

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

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

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

Reproducible test plan

Run these as ordinary unit tests. Mock ticket dicts. Do not hit the network.

  1. SHA mismatch: ticket sha is aaa, current SHA is bbb. Expect sha-mismatch and exit 1.
  2. Owned fixture: touched_paths includes tests/fixtures/billing/invoice.json and fixtures.owners maps that prefix. Expect no unowned-fixtures.
  3. Unmapped fixture: tests/fixtures/tmp/dump.json with an agent ticket. Expect a closed gate.
  4. Workflow path: agent ticket with .github/workflows/ci.yml. Expect agent-touched-workflow.
  5. Flake cap: human ticket with flake_reruns=2, flake_cap=1. Expect flake-budget-exceeded. Agent ticket with flake_reruns=1 must fail even faster.
  6. Path allowlist: agent ticket touching infra/terraform/main.tf. Expect path-not-allowed unless 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)
Enter fullscreen mode Exit fullscreen mode

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

  1. A human or an agent produces a commit.
  2. pre-push mints .git/merge-ticket.json from the diff and fixtures.owners.
  3. Push is refused if the ticket already fails locally.
  4. CI checks out the SHA, recomputes the ticket, and publishes the artifact.
  5. lint and unit may run in parallel, but the queue still requires ticket-verify.
  6. The merge queue admits the SHA only when required_contexts match exactly and the artifact SHA equals HEAD.
  7. 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)