Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The ci-ok gate and the title-edit fast path

ci-ok is the single required status check on master. Everything else is aggregated into it, so adding or removing a job never means touching branch protection settings. The two heavy jobs, quality and codeql, reach it through needs; the cheap pull-request metadata checks (pr-title and the two agent-session-url checks) run as steps inside it (see What runs inside ci-ok). This page records the one place that aggregation is subtle: the fast path for a pull-request title edit, and the rule that keeps it honest.

What runs inside ci-ok

ci-ok needs [quality, codeql] and runs on every event (if: always()). Its steps, in order:

StepRuns on
checkoutpull_request, workflow_dispatch, merge_group
assert the gate already ran for this commitpull_request with action edited (the fast path)
pr-titlepull_request
no agent session urls (pr body)pull_request, workflow_dispatch, merge_group
no agent session urls (commits, dispatch/queue)workflow_dispatch, merge_group
assert every required check succeeded (verdict)every event, under always()

The checkout takes fetch-depth: 1 on a pull request, where only the script is read, and full history on a dispatch or queue run, where the commit-range step walks the range locally. The expression is github.event_name == 'pull_request' && 1 || 0. That form is safe because its middle operand, 1, is truthy. The reverse, cond && 0 || 1, would always yield 1, since 0 is falsy.

Every check step is guarded by !cancelled() && <its event guard>, never by always(). One failing check does not hide the ones after it, and a run that the concurrency group cancelled spends nothing on checks nobody will read. Only the verdict step runs under always(). In a cancelled run every other step skips, and a verdict that skipped with them would leave the job, which is the one required check, green over nothing.

The verdict reads every job result and step outcome through env: and reports every broken rule before it exits, not just the first one:

ResultMust be
quality, codeqlsuccess; on an edited run, success or skipped
assert the gate already ransuccess on an edited run
pr-titlenever failure; success on any pull_request run
no agent session urls (pr body)success on every event except push, where it is skipped
no agent session urls (commits, dispatch/queue)never failure; success on workflow_dispatch and merge_group
pr-title skipped on a workflow_dispatch runallowed, with a ::warning:: that the title went unvalidated (see below)

These three checks used to be jobs of their own, pr-title, session-urls and dispatch-commit-urls, each holding a runner for a few seconds of work and often queueing minutes to get one (#2025). As steps they cost no extra runner, and a title or body edit starts one job instead of three. The price is when title feedback arrives. On a run a commit started (opened, synchronize, reopened), pr-title now runs only after quality and codeql finish, about eight minutes in, rather than within seconds. An edited run skips both, so a title fix made after the gate is still checked within seconds. The action fetches the title live rather than reading it from the payload, so a title fixed while the gate is still running is the one that step validates. The body step does the same on a pull request (see The pull_request path reads the live body).

Why the fast path exists

ci.yml triggers on pull_request: [opened, synchronize, reopened, edited]. The edited type is there for a single reason: PR titles are validated as Conventional Commits by ci-ok’s pr-title step, so fixing a bad title has to be able to turn ci-ok green without a new commit. Without an edited trigger, the only way to re-run the title check would be to push an empty commit, which invalidates every review and re-runs a twenty-minute gate for a typo.

The heavy jobs are skipped on that event (if: github.event.action != 'edited' on quality and codeql): a title lives in GitHub’s database, not in the tree, so no test result can change because of it. The tree that was gated is the same tree.

Why that was a hole

Every run of ci.yml writes a check-run named ci-ok against the pull request’s head sha. Branch protection, and anything reading check-runs?check_name=ci-ok, resolves the newest check-run with that name.

An edited run finishes in under a minute, because it skips the gate and runs only the metadata checks. So retitling a PR while the real gate run on the same sha is still in progress produced a newer, successful ci-ok on that sha, and the commit became mergeable before a single test had run. That is not theoretical: on 2026-09-08, #1315 (head 67586128) and #1267 (head cae26a31) were each rebased, pushed and retitled inside a minute, ended up with two ci runs per sha — one edited run green at 17:50:08Z, one real pull_request run still in_progress — and #1315 was squash-merged on the hollow green. The gate went green afterwards, so master survived on luck (#1328).

An earlier round of this (#767) had already made the edited run harmless in one direction, by giving it a per-run concurrency group so it cannot cancel the gate run it is racing. What was left was the other direction: it could still outrank it.

The rule

The edited fast path may report green only when the gate has demonstrably already finished, successfully, on this exact head sha. ci-ok asserts that before it accepts a skipped quality, by listing the other runs of ci.yml on the same head sha (.github/workflows/ci.yml, the assert the gate already ran for this commit step):

  • Any other ci.yml run on this sha that is not completed — queued, in_progress, waiting — fails the step. The commit is not gated yet, and the run that is still going will write its own, newer ci-ok when it finishes, so nothing is lost by refusing here.
  • No completed run on this sha whose gate-ok job succeeded fails the step too. This closes the same hole in its other shape: a retitle over a gate run that failed also used to write a newer green ci-ok.
  • A cancelled run does not count as a pass. It is completed, so it does not block as in-flight, but it carries no verdict — it is treated exactly like a missing run, which is to say the fast path stays red until a real gate run succeeds.
  • A failed API query is never green. Both the run listing and each run’s job listing fail the step when they cannot be read. No answer is not an answer.

gate-ok: what the guard actually asks

The question the fast path needs answered is narrow — did the heavy gate run on this sha, and did it pass — and for a long time it was asked in a way that answered something broader: is there a run on this sha whose overall conclusion is success.

Those differ whenever a run fails on something that is not the gate. A run can pass quality and codeql and still end failure because the PR-body check rejected the body. Under the old question that run counted as no gate at all, and since the opened run is the only one that runs the gate and edited runs never re-run it, nothing could ever make that sha green again — the failure was permanent and unfixable from the PR side (#1522).

So ci.yml has a gate-ok job that records the gate’s verdict by itself:

gate-ok:
  if: github.event.action != 'edited'
  needs: [quality, codeql]
  steps:
    - run: echo "quality and codeql both succeeded on this run"

It is deliberately trivial, and two things about it are load-bearing:

  • No if: always(). With the default condition the job runs only when every job it needs succeeded, so a failed gate leaves gate-ok skipped rather than adding a second red check beside ci-ok. skipped is not success, so the guard reads it correctly either way.
  • It never re-derives quality’s verdict. needs.quality.result is computed by Actions, which already accounts for what continue-on-error covers inside quality.yml (the coverage job, the semver-checks steps of rust build) and for coverage being skipped on every event but pull_request. Any hand-rolled scan of job conclusions would get those wrong.

The guard then walks the completed ci.yml runs on the sha and asks each one whether it has a gate-ok job that concluded success. A cancelled run has no such job, so it still counts as no run at all — #1328 stays closed.

Details that matter if you touch this code:

  • The listing is scoped to ci.yml by filename. A run of docs.yml or ui-e2e.yml on the same sha says nothing about this gate.
  • It is keyed on github.event.pull_request.head.sha, not github.sha. On a pull_request run github.sha is the throwaway merge commit; check-runs and workflow runs hang off the PR head.
  • The current run appears in its own listing and is excluded by run id (github.run_id), never by name — every run of this workflow shares a name.
  • The job carries actions: read for the listing, and the query writes to a file that jq then reads. The step runs under set -o pipefail, where piping into head or grep -q fails the step for the wrong reason when the reader short-circuits; this repo has fixed that bug twice already (#1291, #1306).

ci-ok remains the single required status check. No branch-protection change is needed for any of this.

What you see when it fires

ci-ok goes red on the edited run with gate still running on <sha>, preceded by a ::notice:: spelling out that nothing is wrong with the commit. The verdict step then adds the gate is not proven green on this head sha, which points back at that step and adds nothing new. This is expected and self-healing: when the gate run finishes it writes its own ci-ok on the same sha, which is newer and wins.

Editing a PR body during a long gate run is an ordinary two-step — write the body, then add the follow-up issue numbers once those issues exist — so this red is common. The two branches of the guard are worded to be told apart at a glance, because they mean opposite things:

MessageMeansAction
gate still running on <sha>the gate is fine and unfinishednone; the in-flight run supersedes this
no completed ci run on <sha> recorded a passing gate-ok jobthe gate failed, was cancelled, or never ranpush a fix or re-run the gate

The one case that needs a human is the narrow race where the gate run completes between the listing and the assertion — then the red edited ci-ok is the newest one on a fully gated commit. Re-run the ci-ok job (or edit the title again) and it goes green. Do not merge with an admin bypass instead; the point of the check is that the newest ci-ok is trustworthy.

Why the unfinished case is red rather than grey (#1511)

“The gate has not finished” is not a failure, and GitHub has a conclusion for it — neutral, which renders grey and does not satisfy a required status check. Reporting it that way was proposed and rejected. The reasons are recorded here so it is not re-litigated:

  • A GitHub Actions job cannot conclude neutral. A job ends success, failure, cancelled or skipped; nothing a step does changes that. neutral is reachable only for a check-run posted through the Checks API, which ci-ok is not — it is a job, and its check-run is written by Actions.
  • Posting a second ci-ok check-run through the API would be worse. The job’s own check-run is still written, so the two race, and branch protection reads whichever lands last. The guard exists precisely because the newest ci-ok on a sha must be trustworthy (#1328).
  • Self-cancelling the run to reach cancelled (grey) costs more than it buys. It needs actions: write on ci-ok — the one required status check — purely for a colour, and ci-ok runs if: always(), so a run whose PR-body check genuinely failed would go grey and hide a real finding unless the cancel were conditioned on every other check’s result first.

So the colour stays. What changed instead is the message: the unfinished case now names itself and says no action is needed, rather than reading like the commit is broken. If this becomes painful again, the fix worth considering is not the conclusion but the ordering — the red is only a problem while it is the newest ci-ok on the sha.

The merge queue

master merges through a merge queue (ADR-0033), so ci.yml also triggers on merge_group. (The trigger is inert until the queue is switched on in branch protection — see merge protection on master.) That run checks out a synthetic ref — refs/heads/gh-readonly-queue/master/pr-<n>-<sha> — holding master plus every entry ahead of this one in the queue, and reports the same ci-ok against it. It is the only run that ever sees the tree that will actually exist, which is the whole point: ci-ok on a PR head says nothing about a semantic conflict with something that landed after the branch was cut (#1318).

ci-ok stays the single required status check. The queue asks the repository for its required checks by name, so there is no second name to configure and no second gate to keep in sync.

A merge-group ref must never take the fast path

This is the one rule where the queue and the title-edit fast path meet, and it runs the wrong way by default if nobody thinks about it. The fast path reports green without running the gate, on the strength of an earlier run against the same head sha. A merge-group tree has no earlier run — it was assembled seconds ago and nothing has ever built it — so “the gate already passed here” is not a claim that can be true. A merge-group run that took the fast path would report green having tested nothing, inside the mechanism that exists to stop exactly that.

Today merge_group carries action: checks_requested, so a guard written as github.event.action != 'edited' already lets the gate run. That is an accident of GitHub’s event vocabulary, not a rule. So every guard around the fast path is scoped to the event as well as the action:

guard
quality, codeql, gate-okgithub.event_name != 'pull_request' || github.event.action != 'edited'
ci-ok’s assert the gate already ran step!cancelled() && github.event_name == 'pull_request' && github.event.action == 'edited'
ci-ok’s verdict branch for the skipped gate"${EVENT_NAME}" = "pull_request" and "${EVENT_ACTION}" = "edited"

These are equivalent to the old conditions on every event that exists now. The change is that they cannot stop being equivalent when GitHub adds an event or reuses an action name.

What runs, and what is allowed to skip

Job or stepOn merge_groupWhy
quality, codeql, gate-okrunthe point of the run
ci-ok step no agent session urls (pr body)runs, and must succeeda squash merge writes the body into the commit message, and the queue is what performs the merge
ci-ok step no agent session urls (commits, dispatch/queue)runs, and must succeedthe only thing that reads the commit messages of PRs batched ahead of this one
ci-ok step pr-titleskippedthe payload has no title, and nothing enters the queue without a green ci-ok on the PR, where pr-title did run
static checks step gitleaks (branch history)runs, over base_sha..head_shascans the commits the queue is about to write to master, entries batched ahead of this one included
quality job coverage (informational)skippedinformational and pull_request only; extended.yml runs the same job on master nightly

The two session-url steps resolve their subject differently here, because a queue ref belongs to no pull request head and --pr-for-ref cannot match it:

  • the body is fetched with --pr-for-queue-ref, which parses the PR number out of gh-readonly-queue/<base>/pr-<n>-<sha> and looks it up in the open-PR listing. A queued PR is open until the queue merges it, so not finding it is a hard failure rather than a pass — the same no answer is not an answer rule the rest of this file follows. Like the other modes it honours ROLTER_PULLS_JSON, so it is runnable against a fixture:

    ROLTER_PULLS_JSON=pulls.json bash scripts/check-agent-session-urls.sh \
      --pr-for-queue-ref rolter-ai/rolter gh-readonly-queue/master/pr-1318-deadbeef
    
  • the commits need no lookup at all: the merge_group payload names both endpoints, so the range is base_sha..head_sha through the plain --commit-range mode.

Both of those steps live in ci.yml’s ci-ok job rather than in quality.yml, because resolving a pull request needs a token and quality.yml deliberately takes none (#734).

Two places inside quality.yml did need adjusting, both steps of the static checks job today. migrations append-only diffs against origin/master, and a merge-queue checkout is a synthetic ref with no origin/master fetched — the script’s no such ref; skipping branch would have turned the gate into a silent no-op on exactly the runs that matter. It now takes its base from github.event.merge_group.base_sha, which the payload provides and which is an ancestor of the queue head, falling back to origin/master everywhere else. A called workflow sees the original event, so the expression resolves inside quality.yml without ci.yml having to pass anything in, and no secret is involved.

The other is the gitleaks (branch history) step. On a pull request it reads pull_request.base.sha..head.sha, and a merge_group payload has no pull_request object, so until #1722 the step skipped itself and only the working-tree pass ran. The working-tree pass is the half that matters for the merge gate, since a secret that survives into the merged tree is caught either way. The history pass is the only one that reads commits, though, and on a queue run those are the commits master is about to receive: this entry’s plus every entry batched ahead of it, which no single per-PR run ever scanned as one range. It now takes merge_group.base_sha..merge_group.head_sha from the payload, the same way ci-ok’s commit-range step does, so it needs no API call and no token. base_sha is an ancestor of the queue head, and the static checks job checks out with fetch-depth: 0, so the commit is in the clone.

Concurrency

merge_group runs get a per-run concurrency group, alongside push and edited. A cancelled run is not a passing required check, so cancelling a merge-group run dequeues the PR it was testing and everything batched behind it. GitHub does give each queue entry its own ref, so github.ref alone would usually be unique — but the queue re-forms that ref when an entry ahead of it fails, and the replacement must not shoot down a run that is still reporting. The per-run group makes that impossible rather than unlikely.

Agent session urls

scripts/check-agent-session-urls.sh rejects a coding-agent session or remote-connection url — a claude.ai/code/session… link, or any agent’s own <Name>-Session: git trailer carrying a url — in a commit message or a pull request body. It runs as the no-agent-session-urls prek hook, as the no agent session urls (commits) step of quality.yml’s static checks job over every commit the PR introduces, and as two steps of ci-ok in ci.yml: no agent session urls (pr body) over the PR body, read live from the API, and no agent session urls (commits, dispatch/queue) over the commit range on a dispatched or merge-queue run, where the quality.yml step cannot see one. The links are ephemeral and sometimes private, and a merged commit message can only be corrected with a history rewrite, so the rule has no exceptions.

The part that surprises people is that the offending line is often not one the author wrote. PR-authoring tooling for a coding agent may append a session-scoped footer to the body after the author’s own content — a _Generated by [Claude Code](https://claude.ai/code/session_…)_ line, or the equivalent <Name>-Session: trailer on a commit — so the check fires on text that first became visible when it failed. It is still a real hit: the url is in the body, and the body is what everyone reads.

The convention, for a human or an agent opening a PR:

  1. After creating the PR, read the body back and confirm it carries no session url. Do not assume the body you submitted is the body that was stored.
  2. If the tooling injected one, strip just that line with a direct PATCH /repos/{owner}/{repo}/pulls/{n} (for example gh api -X PATCH repos/rolter-ai/rolter/pulls/<n> -f body=@body.md), not another pass through the authoring tool. The footer is appended on the creation path only, so a direct PATCH does not re-trigger it and the edit sticks. Strip it as soon as you see it: ci-ok reads the live body after the gate, so the run that opened the PR goes green on its own once the line is gone. See Stripping an injected footer below for the red edited run you may see first.
  3. For a commit message, the equivalent is an amend or rebase that drops the trailer before the push — quality.yml re-checks every commit in the range, so a --no-verify push does not get through.

This is a workaround for tooling behaviour outside this repository’s control, not a relaxation of the rule. The detection stays unconditional: never add a regex exception, an allowlist or a special case for “Generated by Claude Code” or any other footer text to the script’s PATTERN. The value of the check is that it has no exceptions — a carve-out keyed on “the footer” is one an agent can talk its way into, and it would let a genuinely private session link ride into history inside a line that merely looks generated.

The workflow_dispatch path checks the body too

ci.yml can be triggered manually, and that trigger is not decoration: the release PR is opened by release-plz with the repo GITHUB_TOKEN, GitHub suppresses downstream events for token-created refs, and so neither push nor pull_request ever fires for it (#1025). ci.yml is dispatched against the release branch to gate it once a maintainer labels the PR release:ready: by release-pr-ready.yml when the label is added, and by release-plz.yml on each later push while it stays (see cutting a release).

The trap is that a dispatched run skips everything guarded by github.event_name == 'pull_request', which once included both the PR-body check and pr-title, while quality and codeql still run, because their guard is on action, which is empty on a dispatch. ci-ok used to accept both skips, so a dispatched run reported green having never looked at the PR body. That made dispatching the cheapest way to clear a red PR, and it is how #1519 and #1521 actually went green (#1523).

So the no agent session urls (pr body) step runs on workflow_dispatch as well. With no pull request in the event payload it resolves one from the API by head ref:

  • an open PR has this ref as its head → its body is checked, exactly as on a pull_request run. This includes the release PR, which is the whole reason the trigger exists.
  • no open PR has this ref → nothing to check, and the step says so with a ::notice:: and succeeds.
  • the API listing still fails after three attempts → the step fails. A flaking query must never resolve to green; the same rule ci-ok’s own run listing follows.

That resolution lives in scripts/check-agent-session-urls.sh --pr-for-ref rather than inline in the workflow, so it can be exercised without a CI run: point ROLTER_PULLS_JSON at a file shaped like the API response and no network call is made.

ROLTER_PULLS_JSON=pulls.json \
  bash scripts/check-agent-session-urls.sh --pr-for-ref rolter-ai/rolter some/branch

Workflow-embedded shell is shell nobody can run, and this logic decides whether a gate reports green.

ci-ok’s verdict was tightened to match: a skipped PR-body check is accepted only on a push build, the one case with no pull request to check. Anywhere else, a skip is a failure rather than a pass.

The commit half of the dispatch path

The body was only one half. quality.yml’s session-urls job — the one that reads the commit messages the PR introduces, today the no agent session urls (commits) step of static checks — took base and head from github.event.pull_request, so a dispatched run skipped it. A skipped job inside a reusable workflow does not fail it, so quality still reported success and ci-ok went green having never read a commit message (#1562).

That is the wrong place to have no verdict twice over: the release PR only ever gets a dispatched run, and before gate-ok a dispatch was how a head sha whose opened run failed its body check got unstuck (#1522). A commit message is also the half that cannot be fixed after the fact — correcting a merged one takes a history rewrite.

ci-ok therefore runs a no agent session urls (commits, dispatch/queue) step on workflow_dispatch and merge_group. On a dispatch it resolves the range through --commit-range-for-ref; on a queue run the payload names both ends (see the merge queue). It lives in ci.yml rather than beside its pull_request twin because quality.yml takes no secrets (#734): every job there scans the checked-out source with pinned public tooling, so the gate behaves identically on dependabot and fork PRs, which receive none. Resolving a pull request needs a token, so it belongs on this side of the line. The pull_request path in quality.yml is unchanged.

Since #2025 that path is a step inside static checks rather than a job, and a skipped step does not fail a job any more than a skipped job fails a workflow. The job’s report step closes that gap for every step it holds: it knows which events each step must run on and fails the job when one of them was skipped there (see the static checks job).

The contract matches the body half: no open PR for the ref is a ::notice:: and a pass, a failed API listing fails the step, and a range whose endpoints are not both in the clone fails rather than silently checking nothing. The ci-ok checkout takes full history on these two events for that reason. The verdict treats a failure here as fatal on any event, and additionally requires success on a dispatched or queued build — a skip is honest only where the step does not apply.

Both --pr-for-ref and --commit-range-for-ref share one lookup helper and one fixture override, so neither is shell that only CI can run:

ROLTER_PULLS_JSON=pulls.json \
  bash scripts/check-agent-session-urls.sh --commit-range-for-ref rolter-ai/rolter some/branch

pr-title still cannot run on a dispatch — the action it uses takes the pull request out of the event payload, and there is no supported way to hand it one. The remaining gap is therefore narrow: two PRs take the dispatch path, and a workflow writes both titles. release-plz titles the release PR, and ui-security-updates.yml gives its PR a fixed title that ui/scripts/security-updates.test.ts checks (see UI security updates). ci-ok emits a ::warning:: naming that the title went unvalidated rather than letting a silent skip imply otherwise.

The pull_request path reads the live body

On a pull_request run the no agent session urls (pr body) step reads the body from the API (GET /repos/{owner}/{repo}/pulls/{n}) through scripts/check-agent-session-urls.sh --pr-number. It does not read ${{ github.event.pull_request.body }}. The PR number is the only field it takes from the payload, and that never changes.

The payload’s copy of the body is frozen when the event fires, and the step runs only after quality and codeql, minutes later. Reading that copy left a hole (#2035). Say a session URL lands in the body while a synchronize run is still running the gate:

  1. The edit starts an edited run. Its body step fails on the new body, and its fast path declines anyway because the gate has not finished. That ci-ok is red.
  2. The synchronize run finishes the gate. Its body step checks the payload, which predates the edit, and passes. That ci-ok is green.
  3. The green one is the newer ci-ok check-run on the head sha, so it is the one branch protection reads. The PR is mergeable with the URL in its body.

The repository’s squash message is currently the commit list (squash_merge_commit_message: COMMIT_MESSAGES), so a merge today does not copy the body onto master. What leaks is the PR body itself, and the rule covers the body as firmly as a commit message. #1327 is the mirror image of this: there an edited run reported green before the gate had run, here a gate run reported green over an edit it never saw.

A live read closes it. The gate run’s body step now reads the body after the gate, so it sees any edit made while the gate ran. An edit made after that starts an edited run, and that run’s newer ci-ok reads the edited body.

Two objections kept the payload until #2025, and neither holds any more:

  • The read would race the strip. While the check was a job of its own it ran seconds after the PR was created, so a live read would usually have seen an injected footer before anyone could remove it. As a step of ci-ok it runs after the gate, and a strip made in the first minutes is already in place.
  • A fork’s read-only token might not be allowed to read the PR. It is. pr-title already fetches the same PR with the same token on every pull_request run (pulls.get in amannn/action-semantic-pull-request, which re-reads the PR because the payload can be stale), and ci-ok holds pull-requests: read. Dependabot PRs, whose token is read-only just as a fork’s is, pass that step today.

The read gets three attempts, five seconds apart, and then fails the step. A response that is not the requested PR, such as another number or something that is not JSON, fails it too. Every lookup in the script goes through the same helper, so the dispatch and merge-queue listings follow the same rule.

scripts/test-agent-session-urls.sh covers this. It extracts the step’s run: block from ci.yml and runs it against a fake gh and a fake sleep. A clean payload body with a dirty live body must fail, a flaking API must be retried, an API that stays down must fail, and the dispatch and merge-queue branches resolve their PR through the same fake. It also fails if the step’s env: names github.event.pull_request.body again. It runs as the pr body lookups against a fake gh step of the static checks job in quality.yml and as the agent-session-urls prek hook:

bash scripts/test-agent-session-urls.sh

Since the live read, the quickest way to green is to strip the footer as soon as you see it:

  1. Read the PR body back after creating the PR.
  2. If a session URL is there, strip that line with a direct PATCH /repos/{owner}/{repo}/pulls/{n}. There is nothing to wait for.
  3. The edited run the strip starts usually ends red with gate still running, since it cannot vouch for a gate that has not finished. Leave it. When the opening run’s gate finishes, its body step reads the stripped body, and its ci-ok, the newer check-run, goes green.

If the strip lands after the opening run’s body step has already read the dirty body, that run ends failure. Its gate still recorded a passing gate-ok, though, and the edited run the strip starts finds it and goes green. No new commit, no dispatch.

That second case used to strand the head sha. The opened run was the only one that ran the heavy gate, and the edited fast path found no successful run to point at. The only ways out were a new commit or a manually dispatched run, neither of them documented, and the latter only working by accident (#1522). gate-ok closed that: the fast path looks for a passing gate, not a passing run.

Before the live read, the order was the other way round. The body step read the payload frozen at opened, so a footer present at that moment failed the opening run whatever happened next, and the advice was to wait for that run to finish before stripping. Stripping early made the edited run decline on the unfinished gate branch and cost a second edit. Seen on #1565, the first PR opened after gate-ok landed (#1566):

RunEventStartedOutcome
35249017068opened16:50:04session-urls, then a job of its own, failed on the frozen body; gate-ok succeeded
35249064201edited16:50:35ci-ok red — gate still running, the strip was too early
35249935368edited16:59:31ci-ok green off the same gate-ok, no new commit

Today the first run’s body step would run after its gate, well after the 16:50:35 strip, and that run would have gone green by itself.

Which red you are looking at is written in the message, and the two mean opposite things:

ci-ok saysMeansDo
gate still running on <sha>a title or body edit landed before the gate finishednothing; the gate run writes a newer ci-ok when it finishes, after reading the title and body live
no completed ci run on <sha> recorded a passing gate-ok jobthe gate actually failed, was cancelled, or never ranfix the commit; no amount of editing helps

One timing still costs a second edit: a strip that lands in the few seconds between the gate run’s body read and the end of its ci-ok. The gate run fails on the body it read, and the edited run declines because the gate run is still in progress. One more edit after the gate run finishes goes green off its gate-ok.

Why not a separate pr-title workflow

The obvious alternative is to drop the edited trigger from ci.yml and give pr-title its own tiny workflow with its own check name, so ci-ok is only ever produced by a full gate run. That removes the possibility of a hollow green rather than detecting it, and it needs no token and no API call.

It was rejected on one point: pr-title would stop being aggregated into ci-ok, so branch protection would have to require two checks, and that is a repository-settings change no pull request can make. Until a human made it, PR titles would be unenforced — a silent regression traded for a loud one. The ci-ok-is-the-only-required-check invariant is load-bearing here, and the API query keeps it. Since #2025, pr-title is a step inside ci-ok rather than a job it needs, which holds the same invariant with no second check to aggregate at all.

Every merge commit keeps its run

The same incident showed a second, smaller gap. ci.yml’s concurrency group used to be shared by every push to master, so a merge landing a minute after another cancelled the earlier one’s run: merge commit 7fd73db6 was cancelled by c6a96378 and master had no completed ci run for it.

Push runs now get a per-run concurrency group, so a master run is never cancelled and never queued behind another. cancel-in-progress: false would not have fixed this — it queues the newer run instead of cancelling the older one, and a third push cancels the pending one, so the middle commit still ends up ungated.

This matters because of the working rule in merge protection on master: the push run is the one that sees the tree everyone else will branch from, and it is how a semantic conflict between two independently-green PRs gets caught. A merge commit with no completed run is a blind spot in exactly the place that rule watches. The cost is more concurrent runs on a busy merge day, which is the cheaper side of the trade.

Pull-request runs are unchanged: pushing again to a PR still cancels the in-flight run for the superseded commit, which is what you want, because nobody will ever merge that sha.

codeql (rust) on a pull request with no Rust change

codeql (rust) is the longest job ci-ok waits on (about 8 min), and about half of all pull requests touch no Rust at all. On pull_request runs its first step, detect rust changes, diffs the merge commit against its first parent (the base) and looks for a *.rs file, a Cargo.toml, Cargo.lock, rust-toolchain.toml or anything under .github/codeql/. With none of those, the leg skips the toolchain setup, cargo fetch, initialize codeql and analyze, writes a notice saying so, and ends success. The other leg, codeql (actions-js-python), always runs.

This is the one diff-based skip in the gate (ADR-0034), and it cannot change a verdict: the codeql job never fails on alerts, so a skipped analysis and a clean one both leave codeql at success. The limits that keep it safe:

  • only pull_request skips. push, merge_group and workflow_dispatch always analyse, so the merge queue runs the full Rust analysis on the tree that is about to land, and every master commit (and so every release tag) keeps a complete one. That queue run is also what catches a pull request that breaks the leg itself, since a change to ci.yml alone does not count as a Rust change;
  • the step fails open. A HEAD that is not a merge commit, a failed diff or a failed grep all mean analyse, and the step is continue-on-error, so an error in it leaves the output unset, which every later guard reads as run.

A skipped PR has no /language:rust analysis of its own, so GitHub’s code scanning summary on it may say a configuration present on master was not found. That is expected and blocks nothing.