Skip to content

ci: fleet review lanes show up as commit statuses on the PR head - #1413

Closed
askalf wants to merge 12 commits into
masterfrom
ci/fleet-status
Closed

askalf wants to merge 12 commits into
masterfrom
ci/fleet-status

Conversation

@askalf

@askalf askalf commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

The fleet's review lanes (the Breaker's verification, Redline's gating review, the Second Read) run as tickets on the box. A PR waiting on one looked the same on GitHub as a PR nobody had picked up. On 2026-09-24 dario#1403 sat all morning with green CI and a stale "Changes requested" while its verification waited on a budget-paused seat.

fleet-status.yml posts three commit statuses on the PR head, next to build and test:

status pending green red
fleet/verify waiting on required CI at the head every required check passed at the head, or not required (docs, assets, .github config, bot branch) a required check failed at the head
fleet/review waiting on Redline at the head (says which commit its last verdict was on), or waiting for verification Redline approved the head Redline requested changes at the head
fleet/second-read waiting on the Second Read at the head, or on verification READY at the head, or not gating (non-code PR) NOT READY at the head, with its reason

The rules are the dispatcher's (tools/review-dispatch.sh, runtime/review-lanes.ts in platform): on our own repos verification is the head's required CI (every check the base branch's rules require has passed); a verdict counts only at the head; on code, Redline's deterministic low-risk approval is not a verdict and the Second Read gates too.

It re-runs on every push, label, review and comment, and when CI, CodeQL, the live test or actionlint finishes (workflow_run), since each can move a lane. It runs on a GitHub-hosted runner, sparse-checks-out only scripts/fleet-status.mjs, installs nothing, never runs the PR's code, and its token can only read the PR and write statuses. Fork PRs are skipped; the fleet does not review them.

Not in this PR:

  • The statuses are informational. None is added to the ruleset's required checks, so nothing here can block a merge. Making fleet/verify or fleet/review required is a one-line ruleset change if you want it.
  • It sees what GitHub sees. It says "waiting on the Breaker", not "the Breaker is budget-paused until 00:00Z": the pause lives in the box's database.
  • Only dario for now. The workflow and script are self-contained, so the other public repos can copy both files as they are.

How to test

  • node test/fleet-status.mjs: 73 checks, including the feat(models): opus and opus1m fall back to Opus 5.5; opus5 pins Opus 5 (6.12.0) #1403 morning (an older CHANGES_REQUESTED and NOT READY must not show red at a new head), a stale verification comment, a verifier comment from another login, the deterministic approval on code vs docs, bot branches, and the 140-character description cap.
  • node --test test/all.test.mjs: 242 of 243 pass. The one failure is oauth-detector.mjs, which fails the same way on master in this environment (no detectable CC binary).
  • actionlint v1.7.1 (the CI version): clean. node scripts/preflight.mjs: clean.
  • After merge, this PR's own statuses are the first live check.

Checklist

  • npm run build passes
  • npm test passes (offline regression test, no credentials required)
  • Touches src/? No: .github/, scripts/ and test/ only, so this takes the no-changelog label
  • Links an issue somebody else filed? No
  • Touches proxy.ts, cc-template.ts or streaming? No
  • No new runtime dependencies added
  • No tokens/secrets in code or logs

The fleet's verification, gating review and Second Read run as tickets on
the box, so a PR waiting on one looked the same on GitHub as a PR nobody
had picked up. dario#1403 sat all morning on 2026-09-24 with green CI and
a stale "Changes requested" while its verification waited on a paused seat.

fleet-status.yml posts fleet/verify, fleet/review and fleet/second-read on
the PR head after every push, label, review and comment: pending while a
lane waits, green once it has spoken at the head, red when it said no. It
reads labels, comments and reviews on a GitHub-hosted runner and never
runs the PR's code. scripts/fleet-status.mjs applies the dispatcher's
rules (verify first on code, verdicts count only at the head, the Second
Read gates on code), and test/fleet-status.mjs pins them.
@askalf askalf added the no-changelog PR changes src/ but has nothing user-visible to record; skips the CHANGELOG gate label Sep 24, 2026 — with Claude
@github-actions github-actions Bot added ci GitHub Actions workflow or CI pipeline changes tests Test suite changes labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Live test: ✅ PASS

Head f9c7ea1fac7de88931dc1e33c57f3d9f2ff67d2a on the self-hosted runner: All stages PASS: install/build/test(244/244)/cli/doctor/live-probe(pong)

Stages
install: PASS
build: PASS
test: PASS
cli: PASS
doctor: PASS (exit 1, 18 rows; no credentials, reported cleanly)
live: PASS (pong)

Full workflow run

@askalf askalf added the verified Adversarially verified at this head label Sep 24, 2026
@askalf

askalf commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Verification at d001efd

Base 2ea19eac825a3585016c5f401dd48141cdbe820f (master). The PR adds three new files and touches nothing that exists at base, so the base arm cannot run the test file at all (node test/fleet-status.mjs on a base worktree: ERR_MODULE_NOT_FOUND ... scripts/fleet-status.mjs). Discrimination is by mutants of scripts/fleet-status.mjs instead; the runner is in /agent-output/fp/dario/vfy1413-mutants.mjs.

What I ran at 5a45587 (the PR head)

  • node test/fleet-status.mjs: 37 pass, 0 fail.
  • node scripts/preflight.mjs: preflight: clean. BASE_SHA=2ea19ea node scripts/check-changelog.mjs: no src/ changes.
  • GITHUB_TOKEN=... REPO=askalf/dario PR=1413 node scripts/fleet-status.mjs --dry-run against the live PR: three pending rows naming 5a45587; PR=1403: #1403 is closed; nothing to report.
  • The workflow's own first run on this PR (run 36019923758, job status) posted fleet/verify, fleet/review, fleet/second-read as pending on the head, checked out refs/remotes/pull/1413/merge with sparse-checkout of the one script, both action SHAs resolve to the tagged commits (setup-node v7.0.0, checkout-with-retry v1.1.0). gh pr checks 1413: every job green at 5a45587, actionlint included.

Rules rebuilt against the dispatcher

Compared the script line by line with askalf/platform tools/review-dispatch.sh (jq at 545-553, verified_at_head at 300-308) and apps/forge/src/runtime/review-lanes.ts (latestVerdict, secondReadVerdictAtHead, isDeterministicApproval). Regexes for docs, .github, script extensions, bot authors and bot branches are the same character for character; the 100-file rule, the label-plus-comment pair, the 7..40 hex prefix match, the latest-comment-wins fold, the DISMISSED/COMMENTED exclusion, the last-line-wins SECOND READ: regex and the [ \t\r]*$ tail all match.

Two deliberate simplifications, both towards "pending", neither a hole: the script has no no-verdict-line state (the dispatcher files a "post the line" ticket; here it reads as waiting, which is what the PR is doing), and verifiedAtHead reads the newest verification comment while the dispatcher's tail -1 reads the newest by API order, which is the same thing on a paginated ascending list.

Boundaries the shipped tests did not cover, now on the branch (22 checks, one commit, test file only)

row check name kills
comment at head without the label a comment at head without the label does not count M1
older comment then a comment at head an older comment followed by one at head counts M2
## Verification blocked at <sha> a blocked heading does not count M3
DISMISSED and COMMENTED after an old approval a dismissal and a comment at head leave the older approval as the last verdict M4, M20
CHANGES_REQUESTED after APPROVED at one head changes requested after an approval at the same head is red M5
Redline red at an unverified head changes requested at an unverified head still waits on the Breaker M23
Redline's review carrying SECOND READ: a Redline review carrying the line is not the Second Read M6
two Second Read reviews at head the latest review at head wins M7
lineless review after a verdict at head a later review without the line keeps the verdict M8
verdict at OLD plus lineless at HEAD a verdict at an older head plus a lineless review at this head is none M9
NOT READY with no reason NOT READY with no reason is red without a trailing colon M11
.github/actions/** .github actions are M12
.txt outside docs/ a .txt outside docs is M13
upper-case script extension under .github a .github script keeps its case M14
github-actions[bot] / app/github-actions on bot/ github-actions on bot/ is M15
release-v*, release/, chore/release-v*, receipts-, dependabot/ by askalf askalf on a release, receipts or dependabot branch is M16
bot PR with 100 files a bot PR with 100 files is not code M17
99 docs files 99 docs files are not code M18
CHANGES_REQUESTED at head on a docs PR changes requested on docs is red M19
CHANGES_REQUESTED at OLD on a docs PR changes requested on an older docs head is pending and says where M19, M20
description of exactly 140 exactly 140 characters is kept whole M21
description of 141 141 characters is cut to 137 and an ellipsis M22

No row is a control: each fails under at least one mutant and the file cannot load on base. Two probes I ran and did not commit because no mutant of the shipped regex distinguishes them: CRLF review bodies (text\r\nSECOND READ: READY\r\n reads READY, NOT READY - late\r\n gives reason late) and isBotPr(undefined, 'bot/x') is false. M10 (dropping \r from the READY tail) is killed by nothing on the branch; the dispatcher's own regex carries the same \r, so the row is pinned upstream, not here.

After (d001efd)

node test/fleet-status.mjs
  ...
  59 pass, 0 fail

Mutant run at d001efd (node vfy1413-mutants.mjs <worktree>, file restored after; git status clean):

M1 label not required: 1 fail
M2 first verification comment wins: 2 fail
M3 any ## Verification heading: 2 fail
M4 dismissed and commented reviews are verdicts: 1 fail
M5 first Redline verdict wins: 2 fail
M6 any login carries the SECOND READ line: 1 fail
M7 first Second Read review wins: 1 fail
M8 a lineless later review resets the verdict: 1 fail
M9 Second Read at any head: 2 fail
M10 verdict line without CR: 0 fail (equivalent on this suite, see above)
M11 reason always appended: 1 fail
M12 .github/actions is config: 1 fail
M13 any .txt is docs: 1 fail
M14 script extensions case-sensitive: 1 fail
M15 github-actions is a person: 1 fail
M16 only bot/ and dependabot/ are bot branches: 1 fail
M17 100 files counted before the bot check: 1 fail
M18 99 files is code: 1 fail
M19 docs PRs gated too: 4 fail
M20 older verdict not named: 3 fail
M21 140 characters cut: 1 fail
M22 cut at 138: 2 fail
M23 unverified changes-requested shown: 1 fail

node --test --test-name-pattern=fleet-status test/all.test.mjs: fleet-status.mjs passes through the auto-discovery. scripts/preflight.mjs clean at d001efd; scripts/fleet-status.mjs and the workflow are byte-identical to 5a45587 (git diff 5a45587 HEAD --stat: test file only, +75).

Not run here, and why

  • The workflow on pull_request_review and issue_comment events: it has fired only on synchronize so far (this push will trigger a second one). Both paths take the PR number from github.event.issue.number and the same script; the if: guard for issue_comment was read, not exercised. The first live check is this PR's own statuses moving once the label and this comment land, which the PR body already names as the test.
  • The status POST itself (the script's non-dry-run branch) ran only inside the workflow, not from this container.

Read for behaviour outside the stated change

Nothing else changes: three new files, no edits to existing ones, no runtime dependency, no src/. permissions: {} at the top and statuses: write on the one job. The two em dashes in the diff are inside test fixtures quoting the Second Read's own verdict format (NOT READY — <reason>), which is the dispatcher's literal.

Rules: reads-as-generated=covered(no folded tests; each new check names its mutant) | no-control-cases-in-the-suite=covered(base cannot load the file; every check fails under a named mutant) | mutate-the-rejected-alternatives=covered(M1..M23, M10 equivalent) | ledger-row-needs-its-fixture=covered(each row above has its own check) | comment-cites-its-own-review=unreachable(no added comments cite a review; the header cites the incident, not a gate) | test-comment-density-matches-neighbours=unreachable(new file, header matches test/opus-alias-fallback.mjs) | run-every-ci-step-not-just-the-red-one=covered(preflight, check-changelog, node --test discovery; actionlint via the fork job) | idempotence-test-asserts-only-agreement=unreachable(no repeated-call assertions) | dispatch-arm-boundary-coverage=unreachable(single implementation) | generated-release-note-voice=unreachable(no changelog bullet, no-changelog label)

@askalf

askalf commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Live check after the label and the verification comment landed at d001efd: the labeled event (16:18:25Z) ran the workflow (run 36026433655) and it now reads fleet/verify = Verified at d001efd, the other two = waiting on Redline / the Second Read at d001efd. The issue_comment event (16:18:26Z) started no run: GitHub runs issue_comment workflows from the default branch only, so that trigger is inert until this file is on master. The label event covered it here by one second; after merge, a verification comment alone updates the statuses. Not a defect in the change, but worth knowing while reading the statuses on this PR.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: request changes — public patch narration in source comments fails the first-party generated-writing gate. rule:reads-as-generated

Blocking — generated-writing tell

.github/workflows/fleet-status.yml:1-8

# Shows where a PR stands in the fleet's review lanes as three commit statuses on its head:
# without these a PR waiting on a seat looked the same as one nobody had picked up (dario#1403,
# 2026-09-24). scripts/fleet-status.mjs holds the rules, the dispatcher's, and their tests.

This is patch/incident narration rather than an operational description of the workflow. It puts the review/dispatch history into a public source file and reads as generated prose; first-party strict mode applies the generated-writing gate to new code comments. Keep only comments needed to understand how the workflow operates.

# Posts fleet review-lane commit statuses for pull requests.

I reviewed the workflow's event and permission boundaries, the status classification logic and its head-SHA checks, plus the current-head verification evidence and green CI. The implementation otherwise keeps the token scoped to reads and commit-status writes, does not execute PR code, and the added tests cover the classification edges.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).

Verdict: The status logic is sound and well pinned, but one claim in the PR body is wrong: the workflow does run the PR's own copy of scripts/fleet-status.mjs. Which copy runs depends on the event.

Read at live head 95c4ff1 (the ticket named d001efd; since then the PR has one commit, 451b181, that only trims the workflow header, plus three master merges that don't touch these files). I read the full diff (3 files, +464), run 36072367939's log, the pinned checkout-with-retry@115a640 action.yml, and gh pr checks. All required checks pass, and fleet-status.mjs runs inside npm test (ok 95 - fleet-status.mjs). I could not read runtime/review-lanes.ts or tools/review-dispatch.sh (platform is not visible to this identity), so I did not check the "rules are the dispatcher's" claim against the dispatcher itself.

1. Medium: the body says the workflow "never runs the PR's code", but it runs the PR's copy of the script

.github/workflows/fleet-status.yml:34-38

      - uses: askalf/checkout-with-retry@115a6407547e9711edbc2e915838d495cad9583f  # v1.1.0
        with:
          sparse-checkout: scripts/fleet-status.mjs
          sparse-checkout-cone-mode: false
          persist-credentials: false

No ref: is set, so actions/checkout uses GITHUB_REF. That ref depends on the event:

  • pull_request and pull_request_review: refs/pull/N/merge, which contains the PR's version of the script.
  • issue_comment: the default branch, which contains master's version.

Evidence: run 36072367939 (pull_request, this PR) logged HEAD is now at 572c3d4 Merge 95c4ff1dca6bc6924b4d821a6ba9aed1b25a0c82 into 9137d2f.... It then ran this PR's new scripts/fleet-status.mjs, which is not on master yet, and posted the three fleet/* statuses now on 95c4ff1. This contradicts two lines in the body: "never runs the PR's code" and "After merge, this PR's own statuses are the first live check".

Failure scenario: a later PR changes a rule in scripts/fleet-status.mjs, say the 100-file threshold. On push, its statuses are computed with the PR's rules. When askalf then posts ## Verification at <sha> (an issue_comment), master's rules recompute them. The same head gets two different rule sets depending on which event fired last. I found no security problem: fork PRs are excluded both by the job if and by the script, and a same-repo author can already edit the workflow.

Suggested fix, if the intent is "master's rules, always": pin the ref so every event runs the same file.

      - uses: askalf/checkout-with-retry@115a6407547e9711edbc2e915838d495cad9583f  # v1.1.0
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: scripts/fleet-status.mjs
          sparse-checkout-cone-mode: false
          persist-credentials: false

If running the PR's copy is intended, drop "never runs the PR's code" from the body and note that issue_comment runs master's copy.

2. Low, not blocking: the header says one rule for a lineless later review, and the code and a test do another

scripts/fleet-status.mjs:18

//   - On code, the Second Read gates too: its latest review at the head ends with

scripts/fleet-status.mjs:83-86

    if (r.login !== SECOND_READ_LOGIN || r.commitId !== facts.head) continue;
    let last = null;
    for (const m of (r.body ?? '').matchAll(/^SECOND READ: (READY[ \t\r]*$|NOT READY\b.*)$/gm)) last = m[1];
    if (last === null) continue;

test/fleet-status.mjs:171: check('a later review without the line keeps the verdict', ...)

The comment says the latest review at the head must carry the line. The code skips lineless reviews, so an older line at the same head still counts. Here is a case where the two differ. sprayberry-code/huggingface_hub#3 had a READY at a0ca259 (00:18Z), then a lineless review at that same head (00:25Z), and a fresh READY was then posted at 01:08Z. That sequence fits a pipeline that treats a lineless latest review as "no verdict at head". In that case this script would show a green fleet/second-read while the dispatcher is still waiting, which is the "something kinder" the test header rules out. I can't read review-lanes.ts to say which rule is right. Either way, the comment and the code should agree. If the dispatcher keys on the latest review, the fix is to drop the if (last === null) continue; skip, reset out for every Second Read review at the head, and flip the assertion at test line 171.

Boundaries I rebuilt from the diff

All of these are pinned by a test:

  • isCodePath: .md and images (case-insensitive), docs/*.txt against .txt elsewhere, .gitattributes, .github config against .github/actions|scripts and script extensions (including uppercase .MJS).
  • needsVerify file count: 99 and 100 files, plus the bot-branch 100-file exemption.
  • verifiedAtHead: no label, label without comment, older comment, latest-comment-wins in both orders, another login, findings and blocked headings, 7-char prefix.
  • redlineVerdict: DISMISSED and COMMENTED skipped, order at the same head, deterministic approval on code against docs.
  • secondReadAtHead: other login carrying the line, older head, READY, mostly, last line in a body wins, NOT READY with no reason (no trailing colon), em-dash reason stripped.
  • fit: exactly 140 and 141 characters.

Not pinned, and I'm not treating them as findings:

  • Zero changed files goes to needsVerify false, which reads "Not required".
  • pull_request_review: edited is not a trigger, so a status can go stale if a review body is edited.

I read each assertion and found none that holds whether or not the code under test is correct.

Other notes

  • The body says "37 checks". At this head the file has 59: d001efd added 22.
  • What's good: the pure laneStatuses(facts) / CLI split keeps every rule unit-testable without network. Fork handling is covered twice. permissions: {} at the top with job-scoped statuses: write is the right shape. The #1403 regression (a stale CHANGES_REQUESTED or NOT READY must not show red at a new head) is pinned directly.

SECOND READ: NOT READY — the body says the workflow "never runs the PR's code", but with no ref: the pull_request/review runs execute the PR's own scripts/fleet-status.mjs (run 36072367939, merge 572c3d4) while issue_comment runs master's copy

@askalf askalf removed the verified Adversarially verified at this head label Sep 25, 2026

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: changes requested — public patch narration remains in a code comment. rule:reads-as-generated

Blocking — generated-writing tell — scripts/fleet-status.mjs:4-13

// WHY THIS EXISTS. The review lanes (the Breaker's verification, Redline's gating review, the
// Second Read) run as tickets on the fleet box, so a PR waiting on one shows nothing on GitHub.
// On 2026-09-24 dario#1403 sat all morning with green CI and a red "Changes requested" while its
// verification waited on a budget-paused seat, and nothing on the PR said so.
//
// Read-only as far as the PR goes: this looks at labels, comments and reviews and writes statuses.

This is patch-history narration in a public source comment, rather than documentation of behavior needed to maintain the program. It makes the implementation read as generated and exposes internal fleet-operational history in the shipped repository. Remove the historical explanation (and keep only concise, behavior-oriented documentation where it is needed).

// Computes fleet review-lane commit statuses from pull-request metadata.

The current head’s required CI checks are green. I also checked the head-only status logic and the workflow’s default-branch checkout change.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: changes requested: generated patch narration remains in a newly added public test comment. rule:reads-as-generated

Blocking — test/fleet-status.mjs:3-5

// Each status must say what the dispatcher would do next at this head, never something kinder.
// A green fleet/verify on an unverified head, or a green fleet/review from a verdict on an older
// commit, is the failure that matters: it tells a reader the PR is moving when it is stuck.

This narrates the patch's desired outcome and why the new assertions are correct rather than documenting what the test fixture does. It is a generated-writing tell in a newly added public test file, which first-party strict mode makes blocking. The test names and assertions already communicate the behavior; delete this rationale rather than replacing it with another history-oriented explanation.


The live head is 4082a1e6de2e5649c9cbc994998374a3cd5d2bb2. I reviewed the 462 added lines in the workflow, status script, and unit tests. Required CI checks are green at this head; I did not run the local suite.

What's good: the status computation is factored into pure helpers with focused coverage for stale verification and review state, and the workflow uses default-branch code with narrow permissions.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).

Verdict: not ready at c97b113. The new required-CI verification path has no trigger for the moment CI finishes, so fleet/verify sits on "Waiting on required CI" after CI has gone green. The PR body also still describes the label-and-comment rule, which this head no longer applies on dario.

Note: the ticket named 9c5a714, but the live head is c97b113 (two commits later), so this review is of c97b113.

1. High: fleet/verify never re-evaluates when required CI completes

.github/workflows/fleet-status.yml:6-12

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
  pull_request_review:
    types: [submitted, dismissed]
  issue_comment:
    types: [created, edited]

scripts/fleet-status.mjs (CLI block)

  if (required.length) {
    try {
      const statuses = (await ghAll(`/repos/${repo}/commits/${p.head.sha}/statuses`, token)).reverse()

Before c97b113, every input to fleet/verify (the label and the verification comment) was covered by a trigger. This head adds a new input, the head's required checks, and that input has no trigger. dario's master ruleset requires 9 checks (build (18/20/22), validate-package-json, analyze, actionlint, live-test, test, docker-cap-drop-smoke), so the required-CI branch is the one that runs on every dario PR.

Failure scenario, on any push to a code PR:

  1. synchronize starts this job. It takes about 8s (status in this PR's checks). At that point test (1m49s) and live-test (2m33s) are still in_progress, so requiredCiState returns 'pending' and the script posts fleet/verify = pending "Waiting on required CI at <h>". fleet/review and fleet/second-read post "reads once it is verified".
  2. CI finishes green a couple of minutes later. No check_suite, check_run, status or workflow_run event is subscribed, so the job doesn't run again.
  3. All three lanes keep saying "waiting on CI" while CI is green. This lasts until some unrelated label, review or comment arrives. If a required check fails instead, fleet/verify stays pending rather than going red.

This is the #1403 pattern the PR exists to remove: GitHub shows a stale state that doesn't match what the PR is actually waiting on. The unit tests can't catch it because they feed requiredCi in directly.

Suggested fix (workflow_run with the default-branch script, which already matches this workflow's model; the PR number comes from the run's head sha):

on:
  # ...existing triggers...
  workflow_run:
    workflows: [CI, CodeQL, 'Live test (self-hosted)', actionlint]   # the workflows that produce the required checks
    types: [completed]

Resolve the PR from github.event.workflow_run.pull_requests[0].number (or look it up by head sha, since pull_requests is empty for fork runs, which are skipped anyway), and extend the job if: to cover it. A check_suite: completed trigger would also work.

2. Medium: the PR body describes a verification rule this head no longer applies on dario

PR body:

verification is the verified label plus askalf's latest ## Verification at <sha> comment naming the head

and test/fleet-status.mjs asserts the opposite for any branch with required checks:

  check('CI passed: fleet/verify green with no label or comment', passed[CONTEXTS.verify].state === 'success'

scripts/fleet-status.mjs in laneStatuses:

  const verified = code && (ci === 'passed' || (ci === 'none' && verifiedAtHead(facts)));

dario is the only repo this ships to, and its master requires checks, so on dario the label-and-comment path is unreachable (ci is never 'none' unless the rules call fails). This PR itself shows the gap: it has no verified label and no ## Verification at comment newer than d001efd, yet at c97b113 the code would post fleet/verify = success "Required CI passed".

The body's "How to test" also still says node test/fleet-status.mjs: 37 checks, but the file now has 73. Before merge, the body should state the rule the code implements. The header comment says the rules "match the fleet dispatcher's", and the body links the statuses to tools/review-dispatch.sh / runtime/review-lanes.ts, so the body should also confirm the dispatcher now treats required CI as verification. I couldn't read the platform repo from this identity (404), so I couldn't check that. If the dispatcher still gates on label and comment, then on every dario PR fleet/verify goes green while Redline is still held for the Breaker, which is the status this PR is meant to prevent.

Suggested fix: update the body's rule paragraph, the lane table and the check count to match c97b113, and cite the dispatcher change that pairs with it.

What I checked and found sound

  • requiredCiState: statuses reversed so the newest wins; check-runs sorted by id so a rerun's result wins. EXPECTED/QUEUED/in_progress count as pending, SKIPPED/NEUTRAL as passed, and anything else as failed. Tests cover each of these, including a failure alongside a pending check and non-required checks (fleet/verify isn't in the ruleset, so it can't hold itself).
  • Boundary rows: empty required gives 'none' (pinned). A missing check name gives pending (pinned). An unreadable rules call falls back to the label rule, and unreadable checks count as pending; neither turns fleet/verify green by itself. Descriptions of exactly 140 and 141 characters are pinned. 99 vs 100 files is pinned, and so is the bot exemption at 100.
  • secondReadAtHead: only reviews at the head count, the last verdict line wins, a later review with no verdict line keeps the earlier verdict, READY, mostly doesn't count as READY, and an em-dash reason is stripped cleanly by NOT READY\W*.
  • The security shape is unchanged: default-branch sparse checkout of one file, persist-credentials: false, job-scoped permissions, fork PRs skipped both in the job if: and in the script. The PR's code is never executed.
  • CI is green at c97b113 (all 15 required and optional checks passed).

Not run: the test file itself (CI is the test signal). Not read: the platform dispatcher (404 for this identity).

SECOND READ: NOT READY — fleet/verify now depends on required CI, but no workflow trigger fires when CI completes, so it stays "Waiting on required CI" after CI goes green; the body also still states the label-and-comment rule

… finishes

fleet/verify reads required CI, but nothing ran the workflow when CI completed, so it stayed 'Waiting on required CI' after CI went green. A workflow_run trigger on CI, CodeQL and the live test re-posts it; same-repo pull_request runs only, and it still runs the default branch's script. check_suite would not fire: GitHub does not send it for suites Actions created.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (independent second read; the gating review is posted separately).

Verdict: one reachable gap in the new workflow_run trigger. The workflows: list leaves out one of the four workflows that produce the base branch's required checks. Everything else I checked holds.

Read at live head 894b505 (ticket named c97b113; since then 23992f8 added the workflow_run re-post and 894b505 merged master). Full diff (565 lines), the master ruleset's required checks, the on: blocks and job ids of every workflow at 894b505, and gh pr checks (all green).

Findings

1. Medium: actionlint is a required check, but its workflow is not in the workflow_run list

.github/workflows/fleet-status.yml:15-17

  workflow_run:
    workflows: [CI, CodeQL, 'Live test (self-hosted)']
    types: [completed]

The master ruleset requires build (18|20|22), validate-package-json, test, docker-cap-drop-smoke (workflow CI), analyze (CodeQL), live-test (Live test (self-hosted)) and actionlint. The last one comes from .github/workflows/actionlint.yml, whose name: is actionlint, and that name is missing from the list. 23992f8 says it re-posts "when a required-check workflow finishes", but it does that for three of the four.

Failure scenario: the actionlint job fails on something transient (its curl of the actionlint release tarball, for example) while CI is still running. When CI completes, workflow_run fires, requiredCiState sees actionlint: FAILURE and returns 'failed', so fleet/verify is posted red with "A required check failed at ". Someone re-runs actionlint and it passes. No listed workflow completes after that and nothing is pushed, labelled, reviewed or commented, so fleet/verify stays red while every required check is green. That is the stale-status problem this commit was meant to fix, on the one required check it doesn't cover. The same thing happens on a first run whenever actionlint's runner picks the job up after the other three workflows have finished. requiredCiState treats the unreported check as 'pending', so the status stays at "Waiting on required CI".

Suggested fix:

  workflow_run:
    workflows: [CI, CodeQL, 'Live test (self-hosted)', actionlint]
    types: [completed]

The header comment at :3-4 already describes this generally ("a workflow that produces required checks"), so it doesn't need to change.

2. Low (PR body): stale test count and an empty red column

The body says node test/fleet-status.mjs: "37 checks". test/fleet-status.mjs at this head has 73 check( calls. The body's table also leaves the red column blank for fleet/verify, but scripts/fleet-status.mjs:134 posts failure with "A required check failed at ${h}". Neither affects behaviour. They're claims that no longer match the diff, so worth fixing in the description.

What I checked and found sound

  • Boundaries in requiredCiState: empty required gives 'none' (pinned). A required check that hasn't reported, or that is queued or in progress, gives pending (pinned). A completed run with a null conclusion maps to '', which gives pending. ERROR on a commit status, and cancelled/timed_out/action_required on a check run, give failed. A re-run that passes overrides an earlier failure because check runs are sorted by id and the last one per name counts (pinned). skipped/neutral count as passed, which matches how GitHub treats skipped required jobs (pinned).
  • laneStatuses with requiredCi: covered by tests. With passed, verify is green without the label or comment. With pending, an old label and comment don't turn it green. failed gives red. With none, it falls back to the label and comment. Unreadable rules count as none and unreadable checks as pending, so neither error path can turn verify green.
  • secondReadAtHead: . and $ in JS multiline mode treat \r as a line terminator, so the reason from a CRLF body comes out without a trailing \r. "READY, mostly" is not READY, and a later review without the line keeps the earlier verdict. All pinned.
  • 140-character cap: pinned at exactly 140 and at 141.
  • Tests can fail: each assertion I traced compares an exact state or description string that a wrong branch would change. I didn't find any that hold whichever way the code goes.
  • Security: every event runs the default branch's script (ref: ${{ github.event.repository.default_branch }}, sparse checkout of one file, persist-credentials: false). The workflow_run leg requires event == 'pull_request' and a same-repo head_repository. The token has statuses: write and read scopes only. PR comes from the event number, and the script checks it with /^\d+$/. I found no path that runs PR code.
  • Concurrency is keyed on the PR number for all four event shapes. When CI and CodeQL finish close together, cancel-in-progress keeps the later run, and that run reads fresh state.
  • CI is green at 894b505: all nine required checks pass.

SECOND READ: NOT READY — the workflow_run list omits actionlint, a required check, so fleet/verify stays red or pending after an actionlint re-run goes green (fleet-status.yml:16)

actionlint is one of master's required checks, so its workflow belongs in the workflow_run list with CI, CodeQL and the live test; without it fleet/verify stayed red or pending after an actionlint re-run went green.
@askalf

askalf commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Replaced by #1419: the same change as one commit, without the model attribution in 5a45587's trailer and the body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci GitHub Actions workflow or CI pipeline changes no-changelog PR changes src/ but has nothing user-visible to record; skips the CHANGELOG gate tests Test suite changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants