From 2257e8e96d59145ac779f905e1aa4ef99112bc7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 00:02:24 +0000 Subject: [PATCH 1/4] ci(devx): give check:platform-checklist's red a daily reporting channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub Actions workflow on a daily schedule (plus workflow_dispatch) runs `pnpm check:platform-checklist` on `main`. On red it files, or refreshes, one issue carrying the gate's own output; on green it does nothing. The gate stays OUT of per-PR CI, exactly as the standing maintainer decision in lint.yml says — only the reporting channel changes. `scripts/check-platform-checklist-watchdog.mjs` is the static pin, wired into lint.yml on every PR: the workflow exists, fires on schedule + workflow_dispatch, carries NO pull_request / pull_request_target / merge_group trigger, and invokes the gate's package script rather than an inlined copy of its command. Every clause, positive and negative, has a --self-test fixture that makes it fire. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 --- .github/workflows/lint.yml | 24 ++ .../workflows/platform-checklist-watchdog.yml | 347 +++++++++++++++++ docs/qa/platform-checklist/README.md | 9 +- docs/qa/platform-checklist/RUNNER.md | 7 + scripts/check-platform-checklist-watchdog.mjs | 362 ++++++++++++++++++ 5 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/platform-checklist-watchdog.yml create mode 100644 scripts/check-platform-checklist-watchdog.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 219990aec9..561b9161a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2325,6 +2325,30 @@ jobs: # PR. The checklist is a QA ledger, not a code gate; keeping it out of the # per-PR path means an unrelated PR is never blocked by checklist drift. # Run it by hand: `pnpm check:platform-checklist`. See that dir's README. + # Its red does have a reporting channel now, and it is NOT this file: + # `.github/workflows/platform-checklist-watchdog.yml` runs the gate on `main` + # daily and files or refreshes one issue when it is red. + + # ⛔ …and the step below is NOT that wiring. `check:platform-checklist` still + # never runs on a pull request. What runs here is the STATIC PIN over the + # watchdog workflow: it asserts the file exists, fires on `schedule:` + + # `workflow_dispatch:`, carries NO `pull_request:` / `pull_request_target:` / + # `merge_group:` trigger, and invokes the gate through its package script + # rather than an inlined copy of its command. Two file reads, no socket. + # + # It has to live HERE rather than inside the watchdog. The watchdog runs on no + # pull request by construction — that absence is the very thing being pinned — + # so it cannot judge the PR that edits it, and a PR DELETING it would silence + # any check that lived inside it. This job is the only place where the absence + # of that file is observable before it merges. + # + # Invoked as `node scripts/…` rather than a `pnpm check:*` alias: see the GATE + # INVOCATION IDIOM note at the top of this file. The `--self-test` leg runs + # first because a checker whose own cases failed has no verdict worth printing. + - name: Platform-checklist watchdog workflow pin + run: | + node scripts/check-platform-checklist-watchdog.mjs --self-test + node scripts/check-platform-checklist-watchdog.mjs # #3280/#3290 org-identifier guard: `organizationId` is the blessed # developer-facing name for the caller's active org in hook/action bodies; diff --git a/.github/workflows/platform-checklist-watchdog.yml b/.github/workflows/platform-checklist-watchdog.yml new file mode 100644 index 0000000000..74cb5e85f8 --- /dev/null +++ b/.github/workflows/platform-checklist-watchdog.yml @@ -0,0 +1,347 @@ +name: Platform-Checklist Watchdog + +# The standing caller for `pnpm check:platform-checklist` (#11730). +# +# ## What this changes, and the one thing it deliberately does NOT +# +# The gate stays OUT of per-PR CI. That is a recorded maintainer decision, kept +# verbatim in the `lint.yml` NOTE beside the checklist gates and in +# `docs/qa/platform-checklist/README.md` under "Operating cadence": the +# checklist is a QA ledger, not a code gate, so an unrelated PR is never blocked +# by checklist drift. ⛔ This workflow does not touch that. Only the REPORTING +# CHANNEL changes — which is the whole of what was ruled. +# +# That is why this file carries `schedule:` and `workflow_dispatch:` and ⛔ NO +# `pull_request:` and NO `merge_group:` trigger, unlike every other patrol in +# this repo (`half-state-patrol.yml`, `release-coverage-patrol.yml` and +# `required-set-patrol.yml` all carry a paths-filtered `pull_request:` so a +# change to the patrol is exercised before it merges). Copying that row here +# would put `check:platform-checklist` on a PR's critical path through the back +# door and contradict the decision this workflow exists to preserve. The cost is +# stated rather than hidden: a change to THIS FILE is not proven on a real +# runner before it merges, so the first scheduled run after such a change is the +# proof. `scripts/check-platform-checklist-watchdog.mjs` runs on every PR and +# holds the shape statically — including the absence of those two triggers — so +# what a PR loses is the live rehearsal, not the shape. +# +# ## Why a workflow, and not "a seat should run it" +# +# Measured, twice, not supposed. The gate went RED on `main` (a coverage.json / +# manifest UNCLASSIFIED reading) and NO lane turned red, because there was no +# channel: the red was found by a dev who happened to run the gate. Two further +# checklist cards had their devs run the gate on their own initiative and record +# the verdict honestly. Three for three is good discipline and says nothing at +# all about the mechanism — the gate's correctness was resting on somebody +# remembering, which is the shape a gate exists to replace. An alarm added to a +# script nobody runs is still silence. +# +# ## What lands where — and why this one is NOT a pinned anchor +# +# The other three patrols own ONE pinned anchor issue and rewrite its body every +# run, heartbeat included. This one files or refreshes an issue only when the +# gate is RED, and does nothing whatsoever on green. That is the ruled +# behaviour, and the difference is a real trade, stated plainly in both +# directions: +# +# * Their heartbeat is what tells a reader the patrol is still alive — a +# `Swept` timestamp that stops advancing is how a dead caller is noticed. +# This workflow has no such tell, so a silent death here reads exactly like +# a clean checklist. What stands in for it: this job goes RED (and the +# Actions run list shows it) whenever the gate could not run or the report +# could not be delivered, and the run history itself is the record. +# * In exchange, a green tree has no standing issue to prune, and the card the +# devx seat grades is minted only when there is something to grade. +# +# ⛔ On green this job files nothing, edits nothing and CLOSES nothing — an open +# watchdog issue that has gone green is a triage judgement for the devx seat +# (which owns the filed cards), not a state for an unattended run to clear. +# +# ## De-duplication — read off `merge-queue-triage.yml`, whose lesson is negative +# +# The anchor lookup keys on an IDENTITY LABEL that is never graded off, plus a +# stable body marker, plus the exact title as a third way in. Each of those three +# is there because one of the others fails on its own: +# +# * ⛔ NOT `pm:queue` and ⛔ NOT `tooling`. Those are TRIAGE-STATE labels: +# first-touch grading takes `pm:queue` off by definition, and a lookup keyed +# on it stops seeing the anchor the moment somebody triages it — turning +# every further red into a fresh duplicate. `merge-queue-triage.yml` paid +# for that reading already and its header records it; this file inherits the +# fix rather than re-deriving it. +# * The TITLE is stable across refreshes — it names no failing item, no count +# and no date. A title carrying the first failing item would change with the +# failure and make the anchor unfindable by title the moment the checklist +# drifted somewhere else. +# * The BODY MARKER is plain text, ⛔ never an HTML comment: this platform's +# body sanitizer is measured to eat short angle-bracket fragments, and a +# de-dup key that can be silently swallowed is a de-dup key that files a +# duplicate a day. `half-state-patrol.yml`'s generated body uses the same +# plain-text marker idiom for the same reason. +# +# A CLOSED anchor is evidence too, and is not reopened: the gate going red again +# after the card was answered is a REGRESSION, so a new issue is filed and it +# names the one it regressed from. Reopening would bury that distinction. + +on: + schedule: + # Daily. The gate is a ~1s offline read, so cadence is bounded by how long a + # red may sit unseen, not by cost; a day is the same order the sibling + # patrols settled on and is two orders better than the status quo (never). + # + # The minute is offset off the top of the hour ON PURPOSE — scheduled + # workflows queue behind everyone else's `:00` cron — and off the three + # sibling patrols' minutes, so two patrols never contend for the same runner + # minute. + - cron: '51 2 * * *' + # The manual fire, and the smoke test this card exits on. + workflow_dispatch: {} + # ⛔ NO `pull_request:` and ⛔ NO `merge_group:` here, deliberately — see the + # header. `scripts/check-platform-checklist-watchdog.mjs` fails when either + # appears, and its self-test proves that refusal fires rather than asserting + # it into the void. + +# Least privilege. The gate is read-only against the filesystem by construction +# (no socket, no token), and this job writes issues and nothing else — no label +# on anybody's PR, no state, no comment stream. +permissions: + contents: read + issues: write + +# One watchdog at a time. A scheduled run overlapping a manual dispatch would +# have two runs racing to file the same anchor, and the loser would mint a +# duplicate. +concurrency: + group: platform-checklist-watchdog + cancel-in-progress: false + +env: + # The anchor's IDENTITY — all three ways in, single-sourced here so the + # workflow and its static pin read the same strings. + # + # ⛔ The identity label is the machine's and is never graded off. It is NOT a + # triage-state label; see the header. + ANCHOR_IDENTITY_LABEL: platform-checklist-watchdog + ANCHOR_TITLE: 'check:platform-checklist is red on main' + ANCHOR_MARKER: os-platform-checklist-watchdog + # Applied on CREATE only, beside the identity label: the ruled routing for the + # cards this watchdog mints. Never rewritten on a refresh — grading is the + # devx seat's and a refresh must not undo it. + ANCHOR_ROUTING_LABELS: 'pm:queue,tooling' + +jobs: + watchdog: + # ⛔ This job is NOT a required context and must never become one. It runs on + # no pull request at all, so a ruleset naming it would block every PR + # forever on a check that cannot report. + name: Platform checklist gate sweep + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # Kept as this job's own step rather than folded into the composite + # below: `scripts/check-node-version.mjs` scans `.github/workflows/*.yml` + # only and reports how many setup-node steps it audited, so a step moved + # out of sight would silently shrink its census. + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + # `pnpm` itself is the requirement, NOT the workspace. The gate is invoked + # through its PACKAGE SCRIPT (`pnpm check:platform-checklist`), which is a + # pinned property of this file — an inlined `node scripts/...` copy would + # drift from the script the maintainer's cadence documentation names, and + # would silently drop the `checklist-select --self-test` leg the alias runs + # first. + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + # No `pnpm install`. Both halves of the alias import `node:` builtins and + # repo-local modules only — no npm dependency — so installing the + # workspace would buy nothing and would give an unattended daily run a + # lockfile it could fail on. + - name: Run the platform checklist gate + id: gate + run: | + set +e + pnpm check:platform-checklist \ + > "$RUNNER_TEMP/gate.out" 2> "$RUNNER_TEMP/gate.err" + code=$? + set -e + # Captured with NO pipe in between. `cmd | tail` reports the PIPE's + # status — `tail` essentially never fails — so a red gate and a green + # one would both read as 0, and this exit code is the entire input to + # the decision below. + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + echo "pnpm check:platform-checklist exited $code" + + - name: Publish the gate output to the run summary + # Always: on a green run this IS the whole product, and on a red one it + # makes the run log self-contained for whoever opens it from the issue. + if: always() + run: | + { + echo "### Platform-checklist watchdog — gate exit ${{ steps.gate.outputs.exit_code }}" + echo + echo '```' + cat "$RUNNER_TEMP/gate.out" 2>/dev/null || echo '(no stdout captured)' + cat "$RUNNER_TEMP/gate.err" 2>/dev/null || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: File or refresh the watchdog issue + # RED only. On green this step does not run, and nothing else in this + # job writes anything — that is the ruled behaviour. + if: steps.gate.outputs.exit_code != '0' + uses: actions/github-script@v9 + env: + GATE_EXIT: ${{ steps.gate.outputs.exit_code }} + with: + # Delivery is retried, never assumed: this write is the entire product + # of a red run, and a transient answer from the issues endpoint would + # otherwise discard a completed sweep. + retries: 3 + script: | + const fs = require('fs'); + const path = require('path'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const marker = process.env.ANCHOR_MARKER; + const title = process.env.ANCHOR_TITLE; + const identityLabel = process.env.ANCHOR_IDENTITY_LABEL; + const routingLabels = process.env.ANCHOR_ROUTING_LABELS + .split(',').map((s) => s.trim()).filter(Boolean); + const gateExit = process.env.GATE_EXIT; + const runUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; + + const read = (name) => { + try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); } + catch { return ''; } + }; + + // The gate's OWN output, verbatim, both streams — the ruling asks + // for the gate's output and this workflow does not re-word it. + const output = [read('gate.out'), read('gate.err')] + .filter((s) => s.trim()).join('\n').trimEnd(); + if (!output.trim()) { + // A red gate that printed nothing is not a finding anybody can + // act on, and filing it would mint an empty card that reads like + // a judged one. Fail instead: the last step turns the run red. + throw new Error(`the gate exited ${gateExit} but produced no output on either stream — refusing to file an empty card`); + } + + // ── Anchor lookup ──────────────────────────────────────────────── + // Three ways in, none of them sufficient alone; see this file's + // header. `pull_request` excludes PRs, which the issues endpoint + // returns alongside issues. + const isThisAnchor = (i) => !i.pull_request + && (String(i.body ?? '').includes(marker) || i.title === title); + const MAX_PAGES = 3; + const candidates = []; + let scanComplete = true; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const res = await github.rest.issues.listForRepo({ + owner, repo, state: 'all', labels: identityLabel, + sort: 'created', direction: 'desc', per_page: 100, page, + }); + candidates.push(...res.data.filter(isThisAnchor)); + if (res.data.length < 100) break; + if (page === MAX_PAGES) scanComplete = false; + } + if (!scanComplete) { + // A truncated scan cannot establish ABSENCE, and filing on an + // unestablished absence is how a watchdog mints a duplicate a + // day. Refusing is the safe direction: the finding is still in + // this run's summary and the next run retries. + throw new Error(`the anchor scan hit its ${MAX_PAGES}-page bound without completing — absence is NOT established, so nothing was filed. The gate output is in this run's summary.`); + } + + // Resolution order: an OPEN anchor wins, and the OLDEST open one + // wins among those — that is the issue any duplicates were closed + // against. + const open = candidates.filter((i) => i.state !== 'closed') + .sort((a, b) => a.number - b.number); + const closed = candidates.filter((i) => i.state === 'closed') + .sort((a, b) => b.number - a.number); + const existing = open[0] ?? null; + // A closed anchor that was not closed as a duplicate is an ANSWERED + // one: the gate is red again, which is a regression rather than a + // continuation, and the new card has to say which issue it + // regressed from or the previous answer is lost. + const priorAnchor = !existing + ? (closed.find((i) => i.state_reason !== 'duplicate') ?? null) + : null; + + const body = [ + `${marker} — machine-findable marker for this generated view. ⛔ Do not delete this line: it is how the watchdog finds this card instead of filing a new one every day.`, + '', + `# \`check:platform-checklist\` is RED on \`main\``, + '', + `_Swept ${new Date().toISOString()} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`, + '', + 'The platform test checklist gate is red. It is **not** wired into per-PR CI (a standing', + 'maintainer decision — the checklist is a QA ledger, not a code gate), so this card is the', + 'channel that sees the red. Nothing is blocked by it.', + '', + '⛔ The remedy is **never** to edit the checklist data to make the gate green. Read the', + 'output below, fix what it names, and re-run `pnpm check:platform-checklist` locally.', + '', + ...(priorAnchor + ? [ + `⚠️ The previous watchdog card #${priorAnchor.number} was closed on its own merits (not as a`, + 'duplicate) and the gate is red again ⇒ this is a **regression**; the earlier conclusion is', + 'in that issue.', + '', + ] + : []), + "The gate's own output, verbatim:", + '', + '```', + output, + '```', + ].join('\n'); + + if (existing) { + // Rewritten in place, never a comment per run: a per-run comment + // stream is a second tracker nobody prunes, and this platform's + // edit history is already the archive. ⛔ Labels are NOT rewritten + // — grading is the devx seat's and a refresh must not undo it. + await github.rest.issues.update({ + owner, repo, issue_number: existing.number, body, + }); + core.info(`refreshed watchdog anchor #${existing.number} (${body.length} chars, gate exit ${gateExit})`); + core.notice(`check:platform-checklist is red on main — watchdog card #${existing.number} refreshed.`); + return; + } + + const created = await github.rest.issues.create({ + owner, repo, title, body, + // Additive on create; ⛔ nothing here ever replaces a whole label + // set (`check:whole-set-label-write` refuses that verb outright). + labels: [identityLabel, ...routingLabels], + }); + core.info(`filed watchdog anchor #${created.data.number} (${body.length} chars, gate exit ${gateExit})`); + core.notice(`check:platform-checklist is red on main — watchdog card #${created.data.number} filed.`); + + - name: Fail the run if the sweep left no reading + # LAST, on purpose: on a red gate the card is filed or refreshed BEFORE + # this runs, so the run that raises an alarm has already landed the + # truth. + # + # ⛔ A RED GATE is not a failure condition and never reaches here — a + # red gate is a SUCCESSFUL patrol, and a job that went red every day the + # checklist was red would teach everyone to skim it, which is the + # failure this whole card is about. Delivery failure needs no step + # either: the github-script above throws, and a throw fails that step. + # + # What is left is the one reading that must never look green: a sweep + # step that finished without recording an exit code at all. Nothing + # downstream could tell that apart from `exit_code=0`, and "the gate was + # not read" must never render as "the checklist is clean". + if: always() && steps.gate.outputs.exit_code == '' + run: | + echo "the gate step recorded no exit code, so this run is NOT a reading about the checklist -- it is neither a clean one nor a red one. See this run's summary." + exit 1 diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md index 0ab629db6b..f5961f29e0 100644 --- a/docs/qa/platform-checklist/README.md +++ b/docs/qa/platform-checklist/README.md @@ -193,7 +193,8 @@ Why this shape: census and not a verdict). Adding anchors never reds; ⛔ lowering a floor is MAINTAINER-ONLY. ⚠️ Note what a red here reaches: `check:platform-checklist` is not CI-wired (see "Operating cadence"), so it reaches whoever runs the gate — **not every - PR**. #11730 holds that gap. + PR** — plus the daily `platform-checklist-watchdog` workflow, which files or refreshes + one issue when the gate is red. - **`why` is the debt marker.** A recipe exists because stock fixtures cannot demonstrate something — the same discipline as a coverage waiver. Landing the fixture in the showcase seeds proper retires the recipe; until then `why` says what is missing and @@ -352,6 +353,12 @@ checklist drift. It runs on a **manual / periodic cadence** instead. Run dangling id or a forgotten `revision` bump in your own edit; - **alongside a `checklist-author`** (find gaps) **or `checklist-test`** (execute items). +A red gate does have a reporting channel, and it is not this cadence: +[`.github/workflows/platform-checklist-watchdog.yml`](../../../.github/workflows/platform-checklist-watchdog.yml) +runs `pnpm check:platform-checklist` on `main` daily and files or refreshes one issue when +it is red (⛔ and does nothing when it is green). That closes the visibility half only — +the cadence above is still how the gate gets run *before* a red can matter. + The trade-off of staying out of CI: a new capability kind or enum value that lands on `main` between runs is caught at the **next** manual run, not the moment it merged. The ratchets still detect it — they just aren't a blocking gate. If drift-catching latency diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index 9acd71d549..28900287c2 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -11,6 +11,13 @@ build/runtime model incl. the vendored-console staleness trap (§2), and the anti-false-positive rule (§3). This file assumes it and adds the checklist-specific contract. +**Who sees a red gate.** `pnpm check:platform-checklist` is not wired into per-PR CI (a +standing maintainer decision — see the README's "Operating cadence"), so the channel that +sees its red is +[`.github/workflows/platform-checklist-watchdog.yml`](../../../.github/workflows/platform-checklist-watchdog.yml): +it runs the gate on `main` daily, files or refreshes exactly one issue when the gate is red, +and does nothing at all when it is green. + ## Verdicts Per **clause** (each acceptance entry gets exactly one): diff --git a/scripts/check-platform-checklist-watchdog.mjs b/scripts/check-platform-checklist-watchdog.mjs new file mode 100644 index 0000000000..630befd734 --- /dev/null +++ b/scripts/check-platform-checklist-watchdog.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-platform-checklist-watchdog (#11730) -- the static pin over + * `.github/workflows/platform-checklist-watchdog.yml`, the daily caller that + * gives `check:platform-checklist`'s red a reporting channel. + * + * node scripts/check-platform-checklist-watchdog.mjs # the gate + * node scripts/check-platform-checklist-watchdog.mjs --list # what it read + * node scripts/check-platform-checklist-watchdog.mjs --self-test # prove it can go red + * + * ## What is pinned, and why the NEGATIVE half is the load-bearing half + * + * `check:platform-checklist` is NOT wired into per-PR CI, by a standing + * maintainer decision recorded in the checklist NOTE in + * `.github/workflows/lint.yml` and in `docs/qa/platform-checklist/README.md` + * under "Operating cadence": the checklist is a QA ledger, not a code gate, so + * an unrelated pull request is never blocked by checklist drift. The watchdog + * changes the REPORTING CHANNEL and nothing else. + * + * So this gate pins five things, and the third is the one worth having: + * + * 1. the workflow FILE EXISTS and parses as YAML; + * 2. it carries `schedule:` (with a real `cron:`) and `workflow_dispatch:`; + * 3. it carries NO `pull_request:`, NO `pull_request_target:` and NO + * `merge_group:` trigger; + * 4. it invokes the gate's PACKAGE SCRIPT, `pnpm check:platform-checklist`; + * 5. and it does NOT inline a copy of that alias's command. + * + * Clause 3 is why this file exists rather than a comment asking nicely. A pin + * that asserted only 1, 2, 4 and 5 would be GREEN on a workflow that had been + * quietly given a `pull_request:` trigger -- i.e. green on the one edit that + * contradicts the decision the watchdog was built to preserve. A test that + * cannot fail on the change it exists to catch is the shape this tree keeps + * paying for, so every clause below, positive and negative, has a `--self-test` + * fixture that makes it FIRE, next to one that keeps it silent. + * + * `pull_request_target:` is refused beside the two the ruling names. It is a + * per-PR trigger under another name, and a pin that refuses `pull_request:` + * while accepting its sibling is a pin with a documented hole in it. + * + * ## Why clause 4 is not cosmetic + * + * `check:platform-checklist` is an alias for TWO commands -- a + * `checklist-select.mjs --self-test` leg and the gate itself -- so an inlined + * `node scripts/check-platform-checklist.mjs` in the workflow would run the + * gate while silently dropping the self-test leg the alias runs first, and + * would drift from the invocation the cadence documentation tells a human to + * type. Clause 5 is clause 4's other half: asserting the alias is present says + * nothing about a copy sitting beside it. + * + * ## Why the workflow itself cannot hold this + * + * The watchdog carries no `pull_request:` trigger, by clause 3, so it never + * runs on a pull request and cannot judge the pull request that edits it. + * Worse, a pull request DELETING the workflow would silence any check that + * lived inside it -- a patrol cannot report its own removal. This gate runs in + * `lint.yml` on every pull request instead, which is the only place the + * absence of a file is observable before it merges. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { requireDependency } from './import-prerequisite.mjs'; +import { commandWords, invokes, shellCommands } from './check-shard-attestation.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); + +export const WORKFLOW_REL = '.github/workflows/platform-checklist-watchdog.yml'; +export const PACKAGE_SCRIPT = 'check:platform-checklist'; +export const REQUIRED_TRIGGERS = Object.freeze(['schedule', 'workflow_dispatch']); +export const REFUSED_TRIGGERS = Object.freeze(['pull_request', 'pull_request_target', 'merge_group']); + +/** + * The basenames whose direct invocation clause 5 refuses. Both halves of the + * alias, because dropping either one is the drift clause 4 exists to prevent. + */ +const INLINED_GATE_BASENAMES = Object.freeze([ + 'check-platform-checklist.mjs', + 'checklist-select.mjs', +]); + +/** Every `run:` script in the workflow, flattened across jobs and steps. */ +function runBlocks(doc) { + const jobs = doc?.jobs; + if (!jobs || typeof jobs !== 'object') return []; + const out = []; + for (const job of Object.values(jobs)) { + const steps = job?.steps; + if (!Array.isArray(steps)) continue; + for (const step of steps) { + if (step && typeof step.run === 'string') out.push(step.run); + } + } + return out; +} + +/** + * The trigger block's key. + * + * YAML 1.2's core schema reads `on` as the string it looks like, which is what + * the `yaml` package gives us. YAML 1.1 read it as the boolean `true`, and a + * parser that did so would hand us an object with no `on` key at all -- so a + * rule looking only for `on` would find no triggers, report no refused ones, + * and pass. Both spellings are accepted so a parser swap cannot turn this gate + * into one that silently reads nothing. + */ +function triggerNames(doc) { + const key = Object.prototype.hasOwnProperty.call(doc, 'on') ? 'on' + : Object.prototype.hasOwnProperty.call(doc, 'true') ? 'true' + : null; + if (key === null) return { names: null, reason: 'the workflow declares no trigger block at all (no `on:` key)' }; + const block = doc[key]; + if (Array.isArray(block)) return { names: block.map((n) => String(n)), reason: null }; + if (typeof block === 'string') return { names: [block], reason: null }; + if (block && typeof block === 'object') return { names: Object.keys(block), reason: null }; + return { names: null, reason: 'the `on:` key is present but carries no trigger names' }; +} + +/** Does the `schedule:` trigger carry at least one real `cron:` expression? */ +function hasRealCron(doc) { + const key = Object.prototype.hasOwnProperty.call(doc, 'on') ? 'on' : 'true'; + const schedule = doc?.[key]?.schedule; + if (!Array.isArray(schedule)) return false; + return schedule.some((e) => e && typeof e.cron === 'string' && e.cron.trim() !== ''); +} + +function invokesInlinedGate(commands) { + return commands.some((command) => { + const words = commandWords(command).filter((w) => !w.quoted).map((w) => w.word); + const at = words.findIndex((w) => w === 'node' || w.endsWith('/node')); + if (at === -1) return false; + return words.slice(at + 1).some((w) => INLINED_GATE_BASENAMES + .some((b) => w === b || w.endsWith(`/${b}`))); + }); +} + +/** + * Judge one workflow's TEXT. Pure over its inputs so `--self-test` can drive + * every clause with a fixture instead of asserting it into the void. + * + * @param {string|null} text the workflow source, or null when the file is absent + * @param {(s: string) => unknown} parse a YAML parser + * @returns {{ failures: string[], triggers: string[] | null }} + */ +export function judgeWorkflow(text, parse) { + if (text === null || text === undefined) { + return { failures: [`${WORKFLOW_REL} does not exist. It is the ONLY reporting channel \`${PACKAGE_SCRIPT}\` has -- the gate is not in per-PR CI by maintainer decision, so without this file a red checklist is visible to nobody.`], triggers: null }; + } + if (typeof text !== 'string' || text.trim() === '') { + return { failures: [`${WORKFLOW_REL} is empty.`], triggers: null }; + } + + let doc; + try { + doc = parse(text); + } catch (err) { + return { failures: [`${WORKFLOW_REL} could not be read as YAML: ${err?.message ?? err}`], triggers: null }; + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + return { failures: [`${WORKFLOW_REL} does not parse to a workflow mapping.`], triggers: null }; + } + + const failures = []; + const { names, reason } = triggerNames(doc); + if (names === null) { + failures.push(`${WORKFLOW_REL}: ${reason}.`); + } else { + for (const t of REQUIRED_TRIGGERS) { + if (!names.includes(t)) { + failures.push(`${WORKFLOW_REL} declares no \`${t}:\` trigger. A watchdog with no schedule reports on a cadence of never, and one with no manual dispatch cannot be smoke-tested.`); + } + } + for (const t of REFUSED_TRIGGERS) { + if (names.includes(t)) { + failures.push(`${WORKFLOW_REL} declares a \`${t}:\` trigger. ⛔ \`${PACKAGE_SCRIPT}\` is kept OUT of the per-PR path by a standing maintainer decision; this workflow changes the reporting channel and must never put the gate back on a pull request's critical path.`); + } + } + if (names.includes('schedule') && !hasRealCron(doc)) { + failures.push(`${WORKFLOW_REL} declares \`schedule:\` but no usable \`cron:\` expression under it, so it would never fire.`); + } + } + + const commands = runBlocks(doc).flatMap((run) => shellCommands(run)); + if (!commands.some((c) => invokes(c, 'pnpm', PACKAGE_SCRIPT))) { + failures.push(`${WORKFLOW_REL} never invokes \`pnpm ${PACKAGE_SCRIPT}\`. The package script is what the cadence documentation tells a human to run, and it is an alias for two commands -- pinning the alias is what keeps the workflow and the documented invocation from drifting apart.`); + } + if (invokesInlinedGate(commands)) { + failures.push(`${WORKFLOW_REL} invokes the gate's script directly instead of through \`pnpm ${PACKAGE_SCRIPT}\`. An inlined copy silently drops whichever leg of the alias it did not copy.`); + } + + return { failures, triggers: names }; +} + +/** The package script clause -- read from the root manifest, not from the workflow. */ +export function judgeManifest(manifestText) { + let manifest; + try { + manifest = JSON.parse(manifestText); + } catch (err) { + return [`the root package.json could not be parsed: ${err?.message ?? err}`]; + } + const scripts = manifest?.scripts; + if (!scripts || typeof scripts !== 'object' || !(PACKAGE_SCRIPT in scripts)) { + return [`the root package.json declares no \`${PACKAGE_SCRIPT}\` script, so the invocation ${WORKFLOW_REL} is pinned to would fail on every run.`]; + } + return []; +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── +// A minimal workflow that satisfies every clause. Each self-test case mutates +// exactly one thing about it, so a case that fires names one cause. +const GOOD = `name: Platform-Checklist Watchdog +on: + schedule: + - cron: '51 2 * * *' + workflow_dispatch: {} +permissions: + contents: read + issues: write +jobs: + watchdog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Run the platform checklist gate + run: | + set +e + pnpm check:platform-checklist > "$RUNNER_TEMP/gate.out" 2>&1 + echo "exit_code=$?" >> "$GITHUB_OUTPUT" +`; + +const withTrigger = (name) => GOOD.replace(' workflow_dispatch: {}\n', ` workflow_dispatch: {}\n ${name}: {}\n`); + +export function selfTest(parse) { + const failures = []; + let checked = 0; + const t = (what, ok) => { + checked += 1; + if (!ok) failures.push(what); + }; + const fires = (text, needle) => { + const { failures: f } = judgeWorkflow(text, parse); + return f.some((m) => m.includes(needle)); + }; + + // The positive control. Every clause must be SILENT on a good workflow -- + // without this, a rule that fires on everything would pass every case below. + const good = judgeWorkflow(GOOD, parse); + t('the good fixture must produce zero findings (positive control)', good.failures.length === 0); + t('the good fixture must report its triggers', Array.isArray(good.triggers) && good.triggers.includes('schedule')); + + // Clause 1 -- absence and unreadability. + t('a missing workflow ⇒ names the file', fires(null, WORKFLOW_REL)); + t('a missing workflow ⇒ says the gate has no other channel', fires(null, 'is visible to nobody')); + t('an empty workflow ⇒ fires', fires('', 'is empty')); + t('unparseable YAML ⇒ fires with the parse error', fires('jobs:\n a:\n \tbad: [', 'could not be read as YAML')); + t('a YAML scalar ⇒ fires', fires('just a string', 'does not parse to a workflow mapping')); + + // Clause 2 -- the positive triggers. + t('no `on:` block at all ⇒ fires', fires('name: x\njobs: {}\n', 'declares no trigger block')); + t('no `schedule:` ⇒ fires', fires(GOOD.replace(/ schedule:\n - cron: '51 2 \* \* \*'\n/, ''), 'declares no `schedule:` trigger')); + t('no `workflow_dispatch:` ⇒ fires', fires(GOOD.replace(' workflow_dispatch: {}\n', ''), 'declares no `workflow_dispatch:` trigger')); + t('a `schedule:` with no cron ⇒ fires', fires(GOOD.replace(" - cron: '51 2 * * *'\n", ' - {}\n'), 'no usable `cron:`')); + t('a `schedule:` with an empty cron ⇒ fires', fires(GOOD.replace("'51 2 * * *'", "''"), 'no usable `cron:`')); + + // Clause 3 -- THE NEGATIVE CLAUSES. Each refused trigger fires on its own, + // and the good fixture above proves none of them fires without cause. + for (const trigger of REFUSED_TRIGGERS) { + t(`a \`${trigger}:\` trigger ⇒ fires`, fires(withTrigger(trigger), `declares a \`${trigger}:\` trigger`)); + t(`a \`${trigger}:\` trigger ⇒ cites the standing decision`, fires(withTrigger(trigger), 'standing maintainer decision')); + } + // A paths-filtered `pull_request:` is the shape the three sibling patrols + // carry, so it is the shape a copy-paste would import. It must fire too. + t('a paths-filtered `pull_request:` ⇒ fires', fires( + GOOD.replace(' workflow_dispatch: {}\n', " workflow_dispatch: {}\n pull_request:\n paths:\n - '.github/workflows/platform-checklist-watchdog.yml'\n"), + 'declares a `pull_request:` trigger', + )); + // A trigger named only in a COMMENT is not a trigger. This gate judges the + // parsed document, so prose about the refusal cannot be read as the refusal + // being violated -- and this file's own workflow header says `pull_request` + // out loud several times. + t('`pull_request` in a comment ⇒ silent', !fires( + GOOD.replace('on:\n', '# deliberately no pull_request: and no merge_group: trigger\non:\n'), + 'declares a `pull_request:` trigger', + )); + + // Clause 4 / 5 -- the invocation. + t('no `pnpm check:platform-checklist` ⇒ fires', fires( + GOOD.replace('pnpm check:platform-checklist', 'pnpm check:something-else'), + `never invokes \`pnpm ${PACKAGE_SCRIPT}\``, + )); + t('an inlined `node scripts/check-platform-checklist.mjs` ⇒ fires', fires( + GOOD.replace('pnpm check:platform-checklist', 'node scripts/check-platform-checklist.mjs'), + "invokes the gate's script directly", + )); + t('an inlined gate BESIDE the alias still fires', fires( + GOOD.replace('pnpm check:platform-checklist', 'pnpm check:platform-checklist && node scripts/check-platform-checklist.mjs'), + "invokes the gate's script directly", + )); + t('an inlined `node scripts/checklist-select.mjs` ⇒ fires', fires( + GOOD.replace('pnpm check:platform-checklist', 'node scripts/checklist-select.mjs --self-test'), + "invokes the gate's script directly", + )); + t('the alias named in an `echo` is not an invocation', fires( + GOOD.replace('pnpm check:platform-checklist', 'echo "run pnpm check:platform-checklist by hand"'), + `never invokes \`pnpm ${PACKAGE_SCRIPT}\``, + )); + + // The manifest clause. + t('a manifest without the script ⇒ fires', judgeManifest('{"scripts":{"lint":"eslint ."}}').length === 1); + t('a manifest with the script ⇒ silent', judgeManifest(`{"scripts":{"${PACKAGE_SCRIPT}":"node x.mjs"}}`).length === 0); + t('an unparseable manifest ⇒ fires', judgeManifest('{').length === 1); + + return { failures, checked }; +} + +async function main(argv) { + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); + + if (argv.includes('--self-test')) { + const { failures, checked } = selfTest(parse); + if (failures.length > 0) { + console.error(`\nx check-platform-checklist-watchdog self-test: ${failures.length} of ${checked} assertions failed\n`); + for (const f of failures) console.error(` - ${f}`); + console.error(''); + process.exit(1); + } + console.log(`OK check-platform-checklist-watchdog self-test: ${checked} assertions, every clause driven by a fixture that makes it fire.`); + return; + } + + const workflowPath = join(ROOT, WORKFLOW_REL); + const text = existsSync(workflowPath) ? readFileSync(workflowPath, 'utf8') : null; + const { failures, triggers } = judgeWorkflow(text, parse); + failures.push(...judgeManifest(readFileSync(join(ROOT, 'package.json'), 'utf8'))); + + if (argv.includes('--list')) { + console.log(`workflow: ${WORKFLOW_REL}${text === null ? ' (ABSENT)' : ''}`); + console.log(`triggers: ${triggers === null ? '(none read)' : triggers.join(', ')}`); + console.log(`required: ${REQUIRED_TRIGGERS.join(', ')}`); + console.log(`refused: ${REFUSED_TRIGGERS.join(', ')}`); + } + + if (failures.length > 0) { + console.error(`\nx ${WORKFLOW_REL} -- ${failures.length} finding${failures.length === 1 ? '' : 's'}:\n`); + for (const f of failures) console.error(` - ${f}`); + console.error('\nThis workflow is the only reporting channel the platform checklist gate has.\n'); + process.exit(1); + } + + console.log(`OK ${WORKFLOW_REL}: ${REQUIRED_TRIGGERS.join(' + ')} present, ${REFUSED_TRIGGERS.join(' / ')} absent, invoked through \`pnpm ${PACKAGE_SCRIPT}\`.`); +} + +if (isEntrypoint(import.meta.url)) { + await main(process.argv.slice(2)); +} From eafd62622f3ef9fcf3d6f685f003e9cb3e4e9a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 00:06:29 +0000 Subject: [PATCH 2/4] ci(devx): run the platform-checklist gate's own self-test through its alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:self-test-wired` reds the moment CI runs a script that ships a `--self-test` no workflow executes — and the watchdog makes CI run this one. Wiring the flag into the root alias is the remedy that gate names; a SELF_TEST_RUN_OTHERWISE row would be the finding written down somewhere quieter. The gate's header said a `--self-test` leg here "would execute NOWHERE". That premise is falsified by the watchdog, so the header is corrected rather than left to read as verified. The inline battery stays inline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 --- package.json | 2 +- scripts/check-platform-checklist.mjs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2211c09b22..6cdcff7716 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs", "check:adr-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs --self-test && node scripts/check-adr-symbol-anchors.mjs", "check:scripts-symbol-anchors": "node scripts/symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs --self-test && node scripts/check-scripts-symbol-anchors.mjs", - "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs", + "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs --self-test && node scripts/check-platform-checklist.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs --self-test && node scripts/check-org-identifier.mjs", "check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs", "check:cli-test-child-env": "node scripts/check-cli-test-child-env.mjs --self-test && node scripts/check-cli-test-child-env.mjs", diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index 3c7fff880e..9065f9fed4 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -142,7 +142,17 @@ const err = (file, id, msg) => errors.push(`${file}${id ? ` · ${id}` : ''}: ${m // The battery runs inline, on every invocation, not only behind `--self-test`, // because a `--self-test` here would otherwise execute NOWHERE: this gate is // not CI-wired by maintainer decision (README "Operating cadence"), so nothing -// on a PR would ever reach a `--self-test` leg. NOT because its `pnpm` alias is +// on a PR would ever reach a `--self-test` leg. +// +// ⚠️ CORRECTED (#11730): the second half of that sentence no longer holds, and +// the first half is unaffected. `.github/workflows/platform-checklist-watchdog.yml` +// now runs this gate on `main` daily through its package script, so a +// `--self-test` leg DOES execute somewhere — and the root alias +// `check:platform-checklist` carries one, which is what `check:self-test-wired` +// requires of every script CI runs. The gate is still NOT wired into per-PR CI; +// only the reporting channel changed. The inline battery stays inline: a +// `--self-test` that runs once a day is not a reason to stop running the cases +// on the invocation whose verdict is being published. NOT because its `pnpm` alias is // unavailable to it: `check:platform-checklist` is already a key in root // package.json, and the reading that the #9465 fence covers that file is false // -- the GATE INVOCATION IDIOM note at the top of `.github/workflows/lint.yml` From 993d85a6219500ea7082bef83e5d61ce042395e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:24:10 +0000 Subject: [PATCH 3/4] ci(devx): the watchdog's pull_request trigger is paths-filtered, and the pin narrows to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling of 2026-09-06, answering the A/B/C fork this PR opened: A. The workflow gains a `pull_request:` trigger filtered to its own file and nothing else — the posture every patrol in this repo keeps, and the one `scripts/pm/dispatch-gates.mjs` pins tree-wide. It fires on no unrelated pull request, so the standing decision's purpose ("an unrelated PR is never blocked by checklist drift") is untouched; only its letter changed. The pin narrows rather than drops its negative half: `merge_group` and `pull_request_target` stay refused outright, and a `pull_request` trigger must carry a `paths:` filter naming this workflow and nothing else. Unfiltered, an empty list, a `paths-ignore:` complement, an extra path and a different path each fire with their own message. A second clause follows from the first: no board write may be reachable from a pull_request run, judged on the step's own `if:`. 42 self-test assertions, up from 28. Measured before the edit: `check:pm-dispatch-gates` went from `3 of 1511 case(s) failed` to `1511 cases pass`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 --- .github/workflows/lint.yml | 26 +- .../workflows/platform-checklist-watchdog.yml | 62 ++++- scripts/check-platform-checklist-watchdog.mjs | 241 +++++++++++++++--- 3 files changed, 270 insertions(+), 59 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 561b9161a8..c394946d5b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2329,18 +2329,20 @@ jobs: # `.github/workflows/platform-checklist-watchdog.yml` runs the gate on `main` # daily and files or refreshes one issue when it is red. - # ⛔ …and the step below is NOT that wiring. `check:platform-checklist` still - # never runs on a pull request. What runs here is the STATIC PIN over the - # watchdog workflow: it asserts the file exists, fires on `schedule:` + - # `workflow_dispatch:`, carries NO `pull_request:` / `pull_request_target:` / - # `merge_group:` trigger, and invokes the gate through its package script - # rather than an inlined copy of its command. Two file reads, no socket. - # - # It has to live HERE rather than inside the watchdog. The watchdog runs on no - # pull request by construction — that absence is the very thing being pinned — - # so it cannot judge the PR that edits it, and a PR DELETING it would silence - # any check that lived inside it. This job is the only place where the absence - # of that file is observable before it merges. + # ⛔ …and the step below is NOT that wiring. What runs here is the STATIC PIN + # over the watchdog workflow: it asserts the file exists, fires on + # `schedule:` + `workflow_dispatch:`, carries NO `merge_group:` / + # `pull_request_target:` trigger, that any `pull_request:` trigger is + # `paths`-filtered to the watchdog file ALONE, that no board write is + # reachable from a pull_request run, and that the gate is invoked through its + # package script rather than an inlined copy. Two file reads, no socket. + # + # The watchdog's own `pull_request:` trigger names only itself, so it fires on + # no unrelated PR — that filter is what keeps the decision above intact, and + # pinning it is most of this step's job. It has to live HERE rather than + # inside the watchdog for the other half: a PR DELETING the watchdog would + # silence any check that lived inside it, and this job is the only place where + # the absence of that file is observable before it merges. # # Invoked as `node scripts/…` rather than a `pnpm check:*` alias: see the GATE # INVOCATION IDIOM note at the top of this file. The `--self-test` leg runs diff --git a/.github/workflows/platform-checklist-watchdog.yml b/.github/workflows/platform-checklist-watchdog.yml index 74cb5e85f8..8e42e9caae 100644 --- a/.github/workflows/platform-checklist-watchdog.yml +++ b/.github/workflows/platform-checklist-watchdog.yml @@ -11,18 +11,22 @@ name: Platform-Checklist Watchdog # by checklist drift. ⛔ This workflow does not touch that. Only the REPORTING # CHANNEL changes — which is the whole of what was ruled. # -# That is why this file carries `schedule:` and `workflow_dispatch:` and ⛔ NO -# `pull_request:` and NO `merge_group:` trigger, unlike every other patrol in -# this repo (`half-state-patrol.yml`, `release-coverage-patrol.yml` and -# `required-set-patrol.yml` all carry a paths-filtered `pull_request:` so a -# change to the patrol is exercised before it merges). Copying that row here -# would put `check:platform-checklist` on a PR's critical path through the back -# door and contradict the decision this workflow exists to preserve. The cost is -# stated rather than hidden: a change to THIS FILE is not proven on a real -# runner before it merges, so the first scheduled run after such a change is the -# proof. `scripts/check-platform-checklist-watchdog.mjs` runs on every PR and -# holds the shape statically — including the absence of those two triggers — so -# what a PR loses is the live rehearsal, not the shape. +# So this file carries `schedule:`, `workflow_dispatch:`, and a `pull_request:` +# trigger `paths`-filtered to THIS FILE AND NOTHING ELSE — the posture +# `half-state-patrol.yml`, `release-coverage-patrol.yml` and +# `required-set-patrol.yml` all keep, so a change to the patrol is exercised +# before it merges. ⛔ NO `merge_group:` and ⛔ NO `pull_request_target:`, ever. +# +# The filter is what makes that trigger compatible with the standing decision +# rather than a back door through it. The decision's own words, in +# `.github/workflows/lint.yml`: "keeping it out of the per-PR path means an +# unrelated PR is never blocked by checklist drift." A trigger that fires only +# on a pull request editing this workflow blocks no unrelated pull request at +# all — it preserves the decision's purpose while changing its letter, which is +# exactly what the maintainer ruled on 2026-09-06. +# +# ⛔ A `pull_request` run NEVER writes to the board. It runs the gate, publishes +# the output to the run summary, and stops there. # # ## Why a workflow, and not "a seat should run it" # @@ -95,6 +99,27 @@ on: - cron: '51 2 * * *' # The manual fire, and the smoke test this card exits on. workflow_dispatch: {} + # ⛔ PATHS-FILTERED TO THIS ONE FILE, and that filter is the whole reason this + # trigger is allowed to exist. The maintainer's standing decision, in its own + # words in `.github/workflows/lint.yml`: "keeping it out of the per-PR path + # means an unrelated PR is never blocked by checklist drift." A trigger that + # fires ONLY on a pull request editing this workflow blocks no unrelated PR at + # all, so it preserves that decision's purpose while changing its letter — the + # posture every patrol in this repo already keeps, and the one + # `scripts/pm/dispatch-gates.mjs` pins tree-wide. + # + # ⛔ Do NOT widen this list. A second path here would put the checklist gate on + # the critical path of pull requests that have nothing to do with it, which IS + # the thing the decision forbids; + # `scripts/check-platform-checklist-watchdog.mjs` refuses any widening and its + # self-test proves that refusal fires. + # + # ⛔ And a pull_request run NEVER writes to the board — see the `if:` on the + # issue step. It proves the transport, the invocation and the rendering on a + # real runner, and nothing else. + pull_request: + paths: + - '.github/workflows/platform-checklist-watchdog.yml' # ⛔ NO `pull_request:` and ⛔ NO `merge_group:` here, deliberately — see the # header. `scripts/check-platform-checklist-watchdog.mjs` fails when either # appears, and its self-test proves that refusal fires rather than asserting @@ -185,6 +210,10 @@ jobs: { echo "### Platform-checklist watchdog — gate exit ${{ steps.gate.outputs.exit_code }}" echo + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "_Board write skipped: a pull_request run proves the sweep without filing or refreshing anything._" + echo + fi echo '```' cat "$RUNNER_TEMP/gate.out" 2>/dev/null || echo '(no stdout captured)' cat "$RUNNER_TEMP/gate.err" 2>/dev/null || true @@ -194,7 +223,14 @@ jobs: - name: File or refresh the watchdog issue # RED only. On green this step does not run, and nothing else in this # job writes anything — that is the ruled behaviour. - if: steps.gate.outputs.exit_code != '0' + # + # ⛔ And NEVER on a `pull_request` run, whatever the gate said. A pull + # request proves the transport, the invocation and the rendering on a + # real runner; it must not touch the board. That is the posture every + # patrol in this repo keeps, and + # `scripts/check-platform-checklist-watchdog.mjs` refuses a board write + # whose `if:` lacks this guard. + if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request' uses: actions/github-script@v9 env: GATE_EXIT: ${{ steps.gate.outputs.exit_code }} diff --git a/scripts/check-platform-checklist-watchdog.mjs b/scripts/check-platform-checklist-watchdog.mjs index 630befd734..1b290aa8e2 100644 --- a/scripts/check-platform-checklist-watchdog.mjs +++ b/scripts/check-platform-checklist-watchdog.mjs @@ -19,26 +19,53 @@ * an unrelated pull request is never blocked by checklist drift. The watchdog * changes the REPORTING CHANNEL and nothing else. * - * So this gate pins five things, and the third is the one worth having: + * So this gate pins six things, and the third and fourth are the ones worth + * having: * * 1. the workflow FILE EXISTS and parses as YAML; * 2. it carries `schedule:` (with a real `cron:`) and `workflow_dispatch:`; - * 3. it carries NO `pull_request:`, NO `pull_request_target:` and NO - * `merge_group:` trigger; - * 4. it invokes the gate's PACKAGE SCRIPT, `pnpm check:platform-checklist`; - * 5. and it does NOT inline a copy of that alias's command. + * 3. it carries NO `merge_group:` and NO `pull_request_target:` trigger; + * 4. any `pull_request:` trigger it carries is `paths`-filtered to THIS + * WORKFLOW FILE AND NOTHING ELSE; + * 5. it invokes the gate's PACKAGE SCRIPT, `pnpm check:platform-checklist`; + * 6. and it does NOT inline a copy of that alias's command. * - * Clause 3 is why this file exists rather than a comment asking nicely. A pin - * that asserted only 1, 2, 4 and 5 would be GREEN on a workflow that had been - * quietly given a `pull_request:` trigger -- i.e. green on the one edit that - * contradicts the decision the watchdog was built to preserve. A test that - * cannot fail on the change it exists to catch is the shape this tree keeps - * paying for, so every clause below, positive and negative, has a `--self-test` - * fixture that makes it FIRE, next to one that keeps it silent. + * Clauses 3 and 4 are why this file exists rather than a comment asking nicely. + * A pin that asserted only the positive ones would be GREEN on a workflow that + * had been quietly given an UNFILTERED `pull_request:` trigger -- i.e. green on + * the one edit that contradicts the decision the watchdog was built to + * preserve. A test that cannot fail on the change it exists to catch is the + * shape this tree keeps paying for, so every clause below, positive and + * negative, has a `--self-test` fixture that makes it FIRE, next to one that + * keeps it silent. * - * `pull_request_target:` is refused beside the two the ruling names. It is a - * per-PR trigger under another name, and a pin that refuses `pull_request:` - * while accepting its sibling is a pin with a documented hole in it. + * ## Why clause 4 is a FILTER and not a refusal (the 2026-09-06 ruling) + * + * The card's first ruling refused `pull_request:` outright. Implementing that + * literally created this tree's FIRST scheduled-only gate family and reddened + * `scripts/pm/dispatch-gates.mjs`, which pins tree-wide that every discovered + * family reaches a PR-time trigger -- because every patrol here carries a + * paths-filtered `pull_request:`. Put to the maintainer as an A/B/C fork, the + * answer was A: filter the trigger rather than forbid it. + * + * The filter is the whole of what makes that compatible. The decision's own + * words, in `.github/workflows/lint.yml`: "keeping it out of the per-PR path + * means an unrelated PR is never blocked by checklist drift." A trigger naming + * only this workflow fires on no unrelated pull request, so the decision's + * PURPOSE is untouched; only its letter changed. Widen the filter by one path + * and that stops being true, which is exactly what clause 4 refuses. + * + * `pull_request_target:` stays refused outright beside `merge_group:`. It is a + * per-PR trigger under another name that no `paths:` filter makes safe here, + * and a pin refusing one while accepting its sibling is a pin with a documented + * hole in it. + * + * ## Clause 4's other half: a pull request must never write the board + * + * A trigger that runs the gate on a pull request is only safe while that run + * cannot FILE anything. So a step that calls the issues endpoint must carry the + * `pull_request` guard in its own `if:`; a board write reachable from a pull + * request run is refused, and the self-test drives both directions. * * ## Why clause 4 is not cosmetic * @@ -74,7 +101,28 @@ const ROOT = resolve(HERE, '..'); export const WORKFLOW_REL = '.github/workflows/platform-checklist-watchdog.yml'; export const PACKAGE_SCRIPT = 'check:platform-checklist'; export const REQUIRED_TRIGGERS = Object.freeze(['schedule', 'workflow_dispatch']); -export const REFUSED_TRIGGERS = Object.freeze(['pull_request', 'pull_request_target', 'merge_group']); +export const REFUSED_TRIGGERS = Object.freeze(['pull_request_target', 'merge_group']); + +/** + * The only path a `pull_request:` trigger on this workflow may name. One entry, + * and the gate compares the declared list to it as a SET -- not a prefix, not a + * subset -- so neither widening it nor swapping it for some other file passes. + */ +export const PR_TRIGGER_ALLOWED_PATHS = Object.freeze([WORKFLOW_REL]); + +/** The `if:` condition a board-writing step must carry. */ +export const PR_WRITE_GUARD = "github.event_name != 'pull_request'"; + +/** Calls that write to the board. Matched in a step's `run:` or `with.script`. */ +const BOARD_WRITE_CALLS = Object.freeze([ + 'issues.create(', + 'issues.update(', + 'issues.createComment(', + 'issues.addLabels(', +]); + +/** The tail every trigger refusal carries, so one reason is stated once. */ +const DECISION_TAIL = 'A standing maintainer decision keeps `check:platform-checklist` off the per-PR path, in its own words so that "an unrelated PR is never blocked by checklist drift"; this workflow changes the reporting channel and must never undo that.'; /** * The basenames whose direct invocation clause 5 refuses. Both halves of the @@ -140,6 +188,77 @@ function invokesInlinedGate(commands) { }); } +/** The trigger block, under either spelling of the `on:` key. */ +function triggerBlock(doc) { + const key = Object.prototype.hasOwnProperty.call(doc, 'on') ? 'on' + : Object.prototype.hasOwnProperty.call(doc, 'true') ? 'true' + : null; + return key === null ? undefined : doc[key]; +} + +/** + * Clause 4. A `pull_request:` trigger is PERMITTED here, and only while it is + * `paths`-filtered to this workflow alone. Every way of not being that -- no + * configuration at all, a `paths-ignore:` (which is the filter's COMPLEMENT and + * fires on everything else), an empty list, an extra entry, a different file -- + * is a separate refusal, because the remedy differs and a reader should be told + * which one they wrote. + */ +export function judgePullRequestTrigger(doc) { + const block = triggerBlock(doc); + const pr = block && typeof block === 'object' && !Array.isArray(block) ? block.pull_request : undefined; + const head = `${WORKFLOW_REL} declares a \`pull_request:\` trigger`; + const out = []; + if (!pr || typeof pr !== 'object' || Array.isArray(pr)) { + out.push(`${head} with no configuration, so it fires on EVERY pull request. ${DECISION_TAIL} Filter it to \`paths: ['${WORKFLOW_REL}']\`.`); + return out; + } + if ('paths-ignore' in pr) { + out.push(`${head} filtered by \`paths-ignore:\`, which is a filter's COMPLEMENT — it fires on every pull request that does NOT touch the listed paths, i.e. on almost all of them. ${DECISION_TAIL} Use \`paths:\` instead.`); + } + const paths = pr.paths; + if (!Array.isArray(paths) || paths.length === 0) { + out.push(`${head} with no usable \`paths:\` filter, so it fires on EVERY pull request. ${DECISION_TAIL} Filter it to \`paths: ['${WORKFLOW_REL}']\`.`); + return out; + } + const declared = paths.map((x) => String(x)); + const extra = declared.filter((x) => !PR_TRIGGER_ALLOWED_PATHS.includes(x)); + const missing = PR_TRIGGER_ALLOWED_PATHS.filter((x) => !declared.includes(x)); + if (extra.length > 0) { + out.push(`${head} whose \`paths:\` filter also names ${extra.map((x) => `\`${x}\``).join(', ')}. ${DECISION_TAIL} Widening this filter by one path puts the checklist gate on the critical path of pull requests that have nothing to do with it — the filter may name this workflow and nothing else.`); + } + if (missing.length > 0) { + out.push(`${head} whose \`paths:\` filter does not name ${missing.map((x) => `\`${x}\``).join(', ')}, so a change to the watchdog itself is never exercised before it merges — which is the only thing this trigger is for.`); + } + return out; +} + +/** + * Clause 4's other half. A step that writes to the board must be unreachable + * from a `pull_request` run. Judged on the step's own `if:`, because that is + * what Actions evaluates — a comment promising the same thing is not a guard. + */ +export function judgeBoardWrites(doc) { + const out = []; + const jobs = doc?.jobs; + if (!jobs || typeof jobs !== 'object') return out; + for (const [jobId, job] of Object.entries(jobs)) { + const steps = Array.isArray(job?.steps) ? job.steps : []; + for (const step of steps) { + const text = [step?.run, step?.with?.script] + .filter((x) => typeof x === 'string').join('\n'); + const call = BOARD_WRITE_CALLS.find((c) => text.includes(c)); + if (!call) continue; + const cond = typeof step?.if === 'string' ? step.if : ''; + if (!cond.includes(PR_WRITE_GUARD)) { + const where = step?.name ? `step "${step.name}"` : `a step of job \`${jobId}\``; + out.push(`${WORKFLOW_REL}: ${where} calls \`${call}\` but its \`if:\` does not carry \`${PR_WRITE_GUARD}\`, so a pull_request run could write to the board. A pull request proves the sweep on a real runner; ⛔ it must never file or refresh anything.`); + } + } + } + return out; +} + /** * Judge one workflow's TEXT. Pure over its inputs so `--self-test` can drive * every clause with a fixture instead of asserting it into the void. @@ -178,14 +297,19 @@ export function judgeWorkflow(text, parse) { } for (const t of REFUSED_TRIGGERS) { if (names.includes(t)) { - failures.push(`${WORKFLOW_REL} declares a \`${t}:\` trigger. ⛔ \`${PACKAGE_SCRIPT}\` is kept OUT of the per-PR path by a standing maintainer decision; this workflow changes the reporting channel and must never put the gate back on a pull request's critical path.`); + failures.push(`${WORKFLOW_REL} declares a \`${t}:\` trigger. ⛔ Refused outright — no \`paths:\` filter makes it safe here. ${DECISION_TAIL}`); } } + if (names.includes('pull_request')) { + failures.push(...judgePullRequestTrigger(doc)); + } if (names.includes('schedule') && !hasRealCron(doc)) { failures.push(`${WORKFLOW_REL} declares \`schedule:\` but no usable \`cron:\` expression under it, so it would never fire.`); } } + failures.push(...judgeBoardWrites(doc)); + const commands = runBlocks(doc).flatMap((run) => shellCommands(run)); if (!commands.some((c) => invokes(c, 'pnpm', PACKAGE_SCRIPT))) { failures.push(`${WORKFLOW_REL} never invokes \`pnpm ${PACKAGE_SCRIPT}\`. The package script is what the cadence documentation tells a human to run, and it is an alias for two commands -- pinning the alias is what keeps the workflow and the documented invocation from drifting apart.`); @@ -220,6 +344,9 @@ on: schedule: - cron: '51 2 * * *' workflow_dispatch: {} + pull_request: + paths: + - '.github/workflows/platform-checklist-watchdog.yml' permissions: contents: read issues: write @@ -229,14 +356,25 @@ jobs: steps: - uses: actions/checkout@v7 - name: Run the platform checklist gate + id: gate run: | set +e pnpm check:platform-checklist > "$RUNNER_TEMP/gate.out" 2>&1 echo "exit_code=$?" >> "$GITHUB_OUTPUT" + - name: File or refresh the watchdog issue + if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request' + uses: actions/github-script@v9 + with: + script: | + await github.rest.issues.create({ owner, repo, title, body }); `; const withTrigger = (name) => GOOD.replace(' workflow_dispatch: {}\n', ` workflow_dispatch: {}\n ${name}: {}\n`); +/** Swap the whole `pull_request:` block for another spelling of it. */ +const PR_BLOCK = " pull_request:\n paths:\n - '.github/workflows/platform-checklist-watchdog.yml'\n"; +const withPullRequest = (block) => GOOD.replace(PR_BLOCK, block); + export function selfTest(parse) { const failures = []; let checked = 0; @@ -269,26 +407,59 @@ export function selfTest(parse) { t('a `schedule:` with no cron ⇒ fires', fires(GOOD.replace(" - cron: '51 2 * * *'\n", ' - {}\n'), 'no usable `cron:`')); t('a `schedule:` with an empty cron ⇒ fires', fires(GOOD.replace("'51 2 * * *'", "''"), 'no usable `cron:`')); - // Clause 3 -- THE NEGATIVE CLAUSES. Each refused trigger fires on its own, - // and the good fixture above proves none of them fires without cause. + // Clause 3 -- REFUSED OUTRIGHT. Each fires on its own, and the good fixture + // above proves neither fires without cause. for (const trigger of REFUSED_TRIGGERS) { t(`a \`${trigger}:\` trigger ⇒ fires`, fires(withTrigger(trigger), `declares a \`${trigger}:\` trigger`)); - t(`a \`${trigger}:\` trigger ⇒ cites the standing decision`, fires(withTrigger(trigger), 'standing maintainer decision')); + t(`a \`${trigger}:\` trigger ⇒ says it is refused outright`, fires(withTrigger(trigger), 'Refused outright')); + t(`a \`${trigger}:\` trigger ⇒ cites the decision it protects`, fires(withTrigger(trigger), 'never blocked by checklist drift')); } - // A paths-filtered `pull_request:` is the shape the three sibling patrols - // carry, so it is the shape a copy-paste would import. It must fire too. - t('a paths-filtered `pull_request:` ⇒ fires', fires( - GOOD.replace(' workflow_dispatch: {}\n', " workflow_dispatch: {}\n pull_request:\n paths:\n - '.github/workflows/platform-checklist-watchdog.yml'\n"), - 'declares a `pull_request:` trigger', - )); + + // Clause 4 -- THE NARROWED CLAUSE. `pull_request:` is permitted, and ONLY + // while its `paths:` filter names this workflow and nothing else. The good + // fixture carries exactly that and is silent (asserted above), so each case + // here is a single mutation away from a shape that passes. + t('an UNFILTERED `pull_request:` ⇒ fires', fires(withPullRequest(' pull_request: {}\n'), 'fires on EVERY pull request')); + t('an unfiltered `pull_request:` ⇒ names the remedy', fires(withPullRequest(' pull_request: {}\n'), "Filter it to `paths:")); + t('a `pull_request:` with only `types:` and no paths ⇒ fires', fires( + withPullRequest(' pull_request:\n types: [opened, synchronize]\n'), 'no usable `paths:` filter')); + t('a `pull_request:` with an EMPTY paths list ⇒ fires', fires( + withPullRequest(' pull_request:\n paths: []\n'), 'no usable `paths:` filter')); + t('⭐ a paths filter naming ANOTHER file BESIDE this one ⇒ fires', fires( + withPullRequest(" pull_request:\n paths:\n - '.github/workflows/platform-checklist-watchdog.yml'\n - 'docs/qa/platform-checklist/**'\n"), + 'also names `docs/qa/platform-checklist/**`')); + t('…and it says widening is the defect', fires( + withPullRequest(" pull_request:\n paths:\n - '.github/workflows/platform-checklist-watchdog.yml'\n - 'docs/qa/platform-checklist/**'\n"), + 'may name this workflow and nothing else')); + t('⭐ a paths filter naming a DIFFERENT file instead ⇒ fires on both halves', (() => { + const text = withPullRequest(" pull_request:\n paths:\n - 'scripts/check-platform-checklist.mjs'\n"); + return fires(text, 'also names `scripts/check-platform-checklist.mjs`') + && fires(text, 'does not name `.github/workflows/platform-checklist-watchdog.yml`'); + })()); + t('a `paths-ignore:` filter ⇒ fires, and is called the complement', fires( + withPullRequest(" pull_request:\n paths-ignore:\n - 'README.md'\n"), "filter's COMPLEMENT")); + t('the bare flow-sequence spelling `on: [pull_request]` ⇒ fires', fires( + 'on: [schedule, workflow_dispatch, pull_request]\njobs: {}\n', 'with no configuration')); // A trigger named only in a COMMENT is not a trigger. This gate judges the - // parsed document, so prose about the refusal cannot be read as the refusal - // being violated -- and this file's own workflow header says `pull_request` - // out loud several times. - t('`pull_request` in a comment ⇒ silent', !fires( - GOOD.replace('on:\n', '# deliberately no pull_request: and no merge_group: trigger\non:\n'), - 'declares a `pull_request:` trigger', - )); + // parsed document, so prose about the filter cannot be read as the filter + // being violated -- and the workflow's own header says `pull_request` out + // loud several times. + t('`pull_request` in a comment ⇒ silent', judgeWorkflow( + GOOD.replace('on:\n', '# the pull_request trigger below is paths-filtered; merge_group is refused\non:\n'), parse, + ).failures.length === 0); + + // Clause 4's other half -- a board write reachable from a pull_request run. + const UNGUARDED = GOOD.replace(" if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request'\n", " if: steps.gate.outputs.exit_code != '0'\n"); + t('⭐ a board write whose `if:` drops the pull_request guard ⇒ fires', fires(UNGUARDED, 'could write to the board')); + t('…and it names the call it found', fires(UNGUARDED, 'issues.create(')); + t('a board write with NO `if:` at all ⇒ fires', fires( + GOOD.replace(" if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request'\n", ''), 'could write to the board')); + t('a comment promising the guard is not a guard', fires( + UNGUARDED.replace(' script: |\n', ' # never on a pull_request run\n script: |\n'), 'could write to the board')); + t('`issues.update(` is caught as well as `issues.create(`', fires( + UNGUARDED.replace('issues.create(', 'issues.update('), 'issues.update(')); + t('a step that writes nothing needs no guard (no false positive)', judgeBoardWrites( + parse('jobs:\n j:\n steps:\n - name: read only\n run: echo hi\n')).length === 0); // Clause 4 / 5 -- the invocation. t('no `pnpm check:platform-checklist` ⇒ fires', fires( @@ -345,6 +516,8 @@ async function main(argv) { console.log(`triggers: ${triggers === null ? '(none read)' : triggers.join(', ')}`); console.log(`required: ${REQUIRED_TRIGGERS.join(', ')}`); console.log(`refused: ${REFUSED_TRIGGERS.join(', ')}`); + console.log(`pull_request: permitted, and only \`paths\`-filtered to exactly ${PR_TRIGGER_ALLOWED_PATHS.join(', ')}`); + console.log(`board write: every step calling the issues endpoint must carry \`${PR_WRITE_GUARD}\` in its own \`if:\``); } if (failures.length > 0) { @@ -354,7 +527,7 @@ async function main(argv) { process.exit(1); } - console.log(`OK ${WORKFLOW_REL}: ${REQUIRED_TRIGGERS.join(' + ')} present, ${REFUSED_TRIGGERS.join(' / ')} absent, invoked through \`pnpm ${PACKAGE_SCRIPT}\`.`); + console.log(`OK ${WORKFLOW_REL}: ${REQUIRED_TRIGGERS.join(' + ')} present, ${REFUSED_TRIGGERS.join(' / ')} absent, any pull_request trigger paths-filtered to this file alone, no board write reachable from a pull_request run, invoked through \`pnpm ${PACKAGE_SCRIPT}\`.`); } if (isEntrypoint(import.meta.url)) { From fdb408a7d08a5bff5e817de19f2918adeb464e31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:50:22 +0000 Subject: [PATCH 4/4] ci(devx): floor and handshake the watchdog pin's self-test; drop a comment its own trigger falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two things and both are correct. 1. The workflow carried a comment claiming NO pull_request and NO merge_group trigger, four lines under the pull_request trigger this branch added, and false about the pin besides. Replaced with the true statement. 2. The new gate's self-test decided success by `failures.length === 0` and printed an UNCOMPARED case count, and the dispatch discarded its completion — so an early return would have printed `0 assertions` and exited 0. Landing a new scripts/** gate in that shape re-opens two closed sets by one. Brought to the landed shape: a frozen SELF_TEST_BATTERIES roster of eight battery names with per-battery floors, the roster's own size pinned, registerCase() attribution to the most recently opened battery, the floor evaluated at the verdict site before the green line, and the reached-verdict handshake read at the --self-test dispatch. Measured, not asserted: `measure-self-test-floor.mjs --json` now reads ROSTER for this file (was COUNT), and `--probe --only` reads HELD (0 DEFEATED, 1 HELD, 0 ACCIDENT). Every floor equals its measured count; 42 assertions across 8 batteries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 --- .../workflows/platform-checklist-watchdog.yml | 10 +- scripts/check-platform-checklist-watchdog.mjs | 107 ++++++++++++++++-- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/.github/workflows/platform-checklist-watchdog.yml b/.github/workflows/platform-checklist-watchdog.yml index 8e42e9caae..4f2f8e84d2 100644 --- a/.github/workflows/platform-checklist-watchdog.yml +++ b/.github/workflows/platform-checklist-watchdog.yml @@ -120,10 +120,12 @@ on: pull_request: paths: - '.github/workflows/platform-checklist-watchdog.yml' - # ⛔ NO `pull_request:` and ⛔ NO `merge_group:` here, deliberately — see the - # header. `scripts/check-platform-checklist-watchdog.mjs` fails when either - # appears, and its self-test proves that refusal fires rather than asserting - # it into the void. + # ⛔ NO `merge_group:` and ⛔ NO `pull_request_target:` here, ever — those two + # are refused OUTRIGHT, because no `paths:` filter makes either safe in this + # workflow. `scripts/check-platform-checklist-watchdog.mjs` fails when either + # appears, and fails just as loudly when the `pull_request:` trigger above + # loses its filter or gains a second path; its self-test drives every one of + # those cases rather than asserting them into the void. # Least privilege. The gate is read-only against the filesystem by construction # (no socket, no token), and this job writes issues and nothing else — no label diff --git a/scripts/check-platform-checklist-watchdog.mjs b/scripts/check-platform-checklist-watchdog.mjs index 1b290aa8e2..8c43456dc7 100644 --- a/scripts/check-platform-checklist-watchdog.mjs +++ b/scripts/check-platform-checklist-watchdog.mjs @@ -121,6 +121,47 @@ const BOARD_WRITE_CALLS = Object.freeze([ 'issues.addLabels(', ]); +// ── The self-test's own battery roster and floor ─────────────────────────── +// +// `failures.length === 0` alone cannot tell "every case held" from "the cases +// never ran": both print the same green line, and a printed `checked` count +// that nothing COMPARES is evidence, not proof. So each battery declares a +// FLOOR and the roster is compared as a SET — a set difference names WHICH +// battery stopped, where a count says only that something did. +// +// The counts are a floor, not an equality: adding cases is ordinary work and +// must not red. A battery BELOW its floor means cases stopped running, and the +// remedy is to find what stopped registering — ⛔ never to lower the number. +// +// ⛔ A pinned TOTAL is not the repair either: one battery dropping from 12 +// cases to 2 keeps a total "right" the moment a sibling grows. +const SELF_TEST_BATTERIES = Object.freeze({ + 'the positive control — a compliant workflow yields no finding': 2, + 'clause 1 — the file exists, is non-empty, and parses as a workflow': 5, + 'clause 2 — schedule (with a real cron) and workflow_dispatch': 5, + 'clause 3 — merge_group and pull_request_target refused outright': 6, + 'clause 4 — a pull_request trigger paths-filtered to this file ALONE': 10, + 'clause 4b — no board write reachable from a pull_request run': 6, + 'clauses 5 and 6 — the package script, and no inlined copy of it': 5, + 'the manifest clause — the package script exists to be invoked': 3, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned beside the floors. +const SELF_TEST_BATTERY_FLOOR = 8; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +// Set by `selfTest()` only after a verdict is printed — EITHER verdict — and +// read at the dispatch: a `return` that leaves the function above those lines +// prints nothing and still exits 0, so a self-test that never finished would +// report as one that passed. The failure path sets it too, so the refusal fires +// only when NEITHER verdict was printed, never on a genuine red. +let selfTestReachedVerdict = false; + /** The tail every trigger refusal carries, so one reason is stated once. */ const DECISION_TAIL = 'A standing maintainer decision keeps `check:platform-checklist` off the per-PR path, in its own words so that "an unrelated PR is never blocked by checklist drift"; this workflow changes the reporting channel and must never undo that.'; @@ -376,9 +417,22 @@ const PR_BLOCK = " pull_request:\n paths:\n - '.github/workflows/platfo const withPullRequest = (block) => GOOD.replace(PR_BLOCK, block); export function selfTest(parse) { + // `battery()` opens a battery; every assertion below is attributed to the one + // most recently opened, so a section that stops running stops registering and + // names ITSELF at the floor rather than going quiet. + const seen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const registerCase = () => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + seen.set(b, (seen.get(b) ?? 0) + 1); + }; const failures = []; let checked = 0; const t = (what, ok) => { + registerCase(); checked += 1; if (!ok) failures.push(what); }; @@ -387,12 +441,14 @@ export function selfTest(parse) { return f.some((m) => m.includes(needle)); }; + battery('the positive control — a compliant workflow yields no finding'); // The positive control. Every clause must be SILENT on a good workflow -- // without this, a rule that fires on everything would pass every case below. const good = judgeWorkflow(GOOD, parse); t('the good fixture must produce zero findings (positive control)', good.failures.length === 0); t('the good fixture must report its triggers', Array.isArray(good.triggers) && good.triggers.includes('schedule')); + battery('clause 1 — the file exists, is non-empty, and parses as a workflow'); // Clause 1 -- absence and unreadability. t('a missing workflow ⇒ names the file', fires(null, WORKFLOW_REL)); t('a missing workflow ⇒ says the gate has no other channel', fires(null, 'is visible to nobody')); @@ -400,6 +456,7 @@ export function selfTest(parse) { t('unparseable YAML ⇒ fires with the parse error', fires('jobs:\n a:\n \tbad: [', 'could not be read as YAML')); t('a YAML scalar ⇒ fires', fires('just a string', 'does not parse to a workflow mapping')); + battery('clause 2 — schedule (with a real cron) and workflow_dispatch'); // Clause 2 -- the positive triggers. t('no `on:` block at all ⇒ fires', fires('name: x\njobs: {}\n', 'declares no trigger block')); t('no `schedule:` ⇒ fires', fires(GOOD.replace(/ schedule:\n - cron: '51 2 \* \* \*'\n/, ''), 'declares no `schedule:` trigger')); @@ -407,6 +464,7 @@ export function selfTest(parse) { t('a `schedule:` with no cron ⇒ fires', fires(GOOD.replace(" - cron: '51 2 * * *'\n", ' - {}\n'), 'no usable `cron:`')); t('a `schedule:` with an empty cron ⇒ fires', fires(GOOD.replace("'51 2 * * *'", "''"), 'no usable `cron:`')); + battery('clause 3 — merge_group and pull_request_target refused outright'); // Clause 3 -- REFUSED OUTRIGHT. Each fires on its own, and the good fixture // above proves neither fires without cause. for (const trigger of REFUSED_TRIGGERS) { @@ -415,6 +473,7 @@ export function selfTest(parse) { t(`a \`${trigger}:\` trigger ⇒ cites the decision it protects`, fires(withTrigger(trigger), 'never blocked by checklist drift')); } + battery('clause 4 — a pull_request trigger paths-filtered to this file ALONE'); // Clause 4 -- THE NARROWED CLAUSE. `pull_request:` is permitted, and ONLY // while its `paths:` filter names this workflow and nothing else. The good // fixture carries exactly that and is silent (asserted above), so each case @@ -448,6 +507,7 @@ export function selfTest(parse) { GOOD.replace('on:\n', '# the pull_request trigger below is paths-filtered; merge_group is refused\non:\n'), parse, ).failures.length === 0); + battery('clause 4b — no board write reachable from a pull_request run'); // Clause 4's other half -- a board write reachable from a pull_request run. const UNGUARDED = GOOD.replace(" if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request'\n", " if: steps.gate.outputs.exit_code != '0'\n"); t('⭐ a board write whose `if:` drops the pull_request guard ⇒ fires', fires(UNGUARDED, 'could write to the board')); @@ -461,6 +521,7 @@ export function selfTest(parse) { t('a step that writes nothing needs no guard (no false positive)', judgeBoardWrites( parse('jobs:\n j:\n steps:\n - name: read only\n run: echo hi\n')).length === 0); + battery('clauses 5 and 6 — the package script, and no inlined copy of it'); // Clause 4 / 5 -- the invocation. t('no `pnpm check:platform-checklist` ⇒ fires', fires( GOOD.replace('pnpm check:platform-checklist', 'pnpm check:something-else'), @@ -483,27 +544,57 @@ export function selfTest(parse) { `never invokes \`pnpm ${PACKAGE_SCRIPT}\``, )); + battery('the manifest clause — the package script exists to be invoked'); // The manifest clause. t('a manifest without the script ⇒ fires', judgeManifest('{"scripts":{"lint":"eslint ."}}').length === 1); t('a manifest with the script ⇒ silent', judgeManifest(`{"scripts":{"${PACKAGE_SCRIPT}":"node x.mjs"}}`).length === 0); t('an unparseable manifest ⇒ fires', judgeManifest('{').length === 1); - return { failures, checked }; + // ── The floor, evaluated BEFORE either verdict is printed ─────────────── + // A set difference over battery NAMES, so a battery that stopped running + // names itself instead of hiding inside a smaller total. + const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); + if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { + failures.push(`SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); + } + for (const [name, count] of seen) { + if (declaredBatteries.includes(name)) continue; + failures.push(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.`); + } + for (const name of declaredBatteries) { + const count = seen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + failures.push(count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. The verdict would otherwise have claimed those cases hold.` + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do (⛔ MAINTAINER-ONLY: lowering a floor is not the repair).`); + } + + if (failures.length > 0) { + console.error(`\nx check-platform-checklist-watchdog self-test: ${failures.length} of ${checked} assertions failed\n`); + for (const f of failures) console.error(` - ${f}`); + console.error(''); + selfTestReachedVerdict = true; + return 1; + } + console.log(`OK check-platform-checklist-watchdog self-test: ${checked} assertions across ${declaredBatteries.length} floored batteries, every clause driven by a fixture that makes it fire.`); + selfTestReachedVerdict = true; + return 0; } async function main(argv) { const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); if (argv.includes('--self-test')) { - const { failures, checked } = selfTest(parse); - if (failures.length > 0) { - console.error(`\nx check-platform-checklist-watchdog self-test: ${failures.length} of ${checked} assertions failed\n`); - for (const f of failures) console.error(` - ${f}`); - console.error(''); + const code = selfTest(parse); + if (!selfTestReachedVerdict) { + console.error( + '\nx check-platform-checklist-watchdog self-test: selfTest() returned without reaching its\n' + + 'verdict, so neither line was printed and its battery floors never ran. Exiting 0 here\n' + + 'would report a self-test that never finished as a self-test that passed.\n', + ); process.exit(1); } - console.log(`OK check-platform-checklist-watchdog self-test: ${checked} assertions, every clause driven by a fixture that makes it fire.`); - return; + process.exit(code); } const workflowPath = join(ROOT, WORKFLOW_REL);