Skip to content

Commit 86ae47e

Browse files
ci(fleet-patrols): pin each schedule cron to its job if: twin in the watchdog gate (#26653)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9DQWWBhDUtPvKnwBReoUmbC)_ Fixes #26526 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`check-platform-checklist-watchdog` 新增第 7 条:`fleet-patrols.yml` 里每个 `on.schedule` cron 都必须有某个 job 的 `if:` 与之逐字相同,反之亦然;只改脚本,workflow 只读。 - 为什么改:同一个 cron 写在两处,只改一处时巡查会静默停跑或空跑,CI 毫无红灯;现在任一侧单边修改都会在 lint 的既有步骤里变红。 - 风险与回滚:只是一条新增的静态断言,今天 main 上四对 cron 全部相同,读数绿;回滚即 revert 本 PR。 - 先看:`scripts/check-platform-checklist-watchdog.mjs` 的 `judgeScheduleTwins`,以及 self-test 里 clause 7 那组用例。 - 两分钟验证:`node scripts/check-platform-checklist-watchdog.mjs --self-test && node scripts/check-platform-checklist-watchdog.mjs`,预期两行 OK(53 条断言、9 组)。 - 需要你做的:无,席位落地。 ## What changes `scripts/check-platform-checklist-watchdog.mjs` gains **clause 7**, `judgeScheduleTwins(doc)`, called from `judgeWorkflow`: - every `on.schedule[].cron` must be named by some **job-level** `if:` as `github.event.schedule == 'CRON'` (byte-for-byte, because `github.event.schedule` carries the string exactly as the schedule spells it); - every cron a job-level `if:` names that way must be an `on.schedule` entry; - a job-level `if:` that reads `github.event.schedule` in any other spelling (`!=`, the reversed operand order, …) is **refused as unreadable**, never skipped: a comparison the clause cannot read is one it cannot check. The docblock's clause list (six → seven) gains the clause and a `## Clause 7` section; `--list` and the OK line name it. No edit to `.github/workflows/**`, no new CI job, required context or package script: the watchdog already runs as the two lines of `lint.yml`'s `Platform-checklist watchdog workflow pin` step (`--self-test`, then the gate), and the clause rides both. **Forced test edits** (the self-test is in the same file): - the `GOOD` fixture's `watchdog` job gains a job-level `if:` in the real file's folded shape — without it the positive control's cron has no twin and clause 7 fires on it; - `SELF_TEST_BATTERY_FLOOR` 8 → 9, so deleting the new battery from the roster reds. ## Tests New battery `clause 7 — every schedule cron and every job if: cron has its twin`, floor 9, 9 cases: - two crons, each named by its own job ⇒ silent (positive control); - ⭐ a cron added under `on.schedule` with no job `if:` twin ⇒ fires, and is the only finding; - ⭐ a job `if:` cron edited with no `on.schedule` twin ⇒ names the job, and the cron it left behind fires from the other side; - a cron edited under `on.schedule` alone ⇒ exactly two findings, one each way; - a job `if:` cron with no `schedule:` at all ⇒ fires; - `!=` spelling and the reversed spelling ⇒ refused as unreadable; - a twin in a STEP-level `if:` is not a job twin ⇒ fires. Self-test: 44 → 53 assertions across 8 → 9 floored batteries. ## Acceptance notes - **Mechanism hypothesis (measured, head `9f54e952`):** all FOUR jobs route by the same spelling, `(github.event_name != 'schedule' || github.event.schedule == 'CRON')` — `required-set-patrol` :271, `platform-checklist-watchdog` :475, `release-coverage-patrol` :857, `board-snapshot` :1182. No job uses another spelling, so the pin reads exactly that one and refuses any other loudly rather than widening. - **Stale workflow prose (governed, not edited):** `fleet-patrols.yml`'s header (lines 22–25) still says of the two one-sided shapes "neither shape is red anywhere". After this PR the watchdog gate reds both. The file is Tier H and outside this card's surface; the sentence wants a governed follow-up naming clause 7. - **A third spelling, not pinned:** `PATROL_SCHEDULE` restates two of the crons in job `env:` (:515 for the watchdog, :983 for release-coverage), already flagged in the workflow as "Two readers, one fact". This card's scope is `on.schedule` against job `if:`; the env twin stays unpinned. - **Not pinned on purpose:** two jobs naming the same cron (both would run on it). The card asked for orphans on either side, not multiplicity. - The card's acceptance names "its existing package.json check": there is no `check:platform-checklist-watchdog` package script; the existing check is the `lint.yml` step above, which is what runs the clause. ## Verification Gate list derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head `9f54e952` (1 path, +101 / −4). <details><summary>Watchdog gate and self-test at 9f54e95</summary> ```text OK check-platform-checklist-watchdog self-test: 53 assertions across 9 floored batteries, every clause driven by a fixture that makes it fire. OK .github/workflows/fleet-patrols.yml: schedule + workflow_dispatch present, pull_request_target / merge_group absent, any pull_request trigger paths-filtered to this file and the sibling patrol jobs' own inputs alone, no board write reachable from a pull_request run, every schedule cron twinned with a job `if:`, invoked through `pnpm check:platform-checklist`. ``` </details> <details><summary>One-sided edits of the REAL fleet-patrols.yml (scratch probe, not committed)</summary> ```text real file at HEAD: 0 finding(s) schedule side only: '19 4 * * *' -> '19 5 * * *': 2 finding(s) - job `release-coverage-patrol`'s `if:` compares `github.event.schedule` to `19 4 * * *`, which no `on.schedule` entry declares … - `on.schedule` declares the cron `19 5 * * *` but no job's `if:` compares `github.event.schedule` to it … job if: side only: board-snapshot '7 2,8,14,20' -> '7 3,9,15,21': 2 finding(s) - job `board-snapshot`'s `if:` compares `github.event.schedule` to `7 3,9,15,21 * * *`, which no `on.schedule` entry declares … - `on.schedule` declares the cron `7 2,8,14,20 * * *` but no job's `if:` compares `github.event.schedule` to it … ``` </details> <details><summary>Derived local gate commands at 9f54e95</summary> ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-platform-checklist-watchdog.mjs :: exit 0 node scripts/check-platform-checklist-watchdog.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 dispatch-gates --ran: Run reconciliation — 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 32 recorded an exit code and none of them is 3). Artifact-roster families whose roster lives under scripts/ (outside the derived 32), read too: node scripts/check-published-list-mirrors.mjs :: exit 0 node scripts/check-published-list-mirrors.mjs --self-test :: exit 0 node scripts/check-dts-references.mjs --self-test :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:dts-closure :: exit 3 (PREREQUISITE NOT MET: no package build in this worktree; NOT MEASURED, CI runs it) pnpm check:published-readme-exports :: exit 3 (PREREQUISITE NOT MET: no package build; NOT MEASURED, CI runs it) ``` </details> ESLint on the file: `pnpm exec eslint --no-inline-config scripts/check-platform-checklist-watchdog.mjs` exit 0. No package touched, so no build closure (①) and no package test/typecheck (②); `skip-changeset`: the diff is a root `scripts/` file of the private root package and publishes nothing. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRDtDiaiKtmnnobjEt)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 079deea commit 86ae47e

1 file changed

Lines changed: 101 additions & 4 deletions

File tree

‎scripts/check-platform-checklist-watchdog.mjs‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* an unrelated pull request is never blocked by checklist drift. The watchdog
2121
* changes the REPORTING CHANNEL and nothing else.
2222
*
23-
* So this gate pins six things, and the third and fourth are the ones worth
23+
* So this gate pins seven things, and the third and fourth are the ones worth
2424
* having:
2525
*
2626
* 1. the workflow FILE EXISTS and parses as YAML;
@@ -30,7 +30,9 @@
3030
* WORKFLOW FILE AND THE SIBLING PATROL JOBS' OWN INPUTS -- a list frozen
3131
* here as a SET -- and nothing else;
3232
* 5. it invokes the gate's PACKAGE SCRIPT, `pnpm check:platform-checklist`;
33-
* 6. and it does NOT inline a copy of that alias's command.
33+
* 6. it does NOT inline a copy of that alias's command;
34+
* 7. and every `on.schedule` cron has its twin in some job's `if:`, and
35+
* every cron a job's `if:` names has its twin under `on.schedule`.
3436
*
3537
* Clauses 3 and 4 are why this file exists rather than a comment asking nicely.
3638
* A pin that asserted only the positive ones would be GREEN on a workflow that
@@ -92,6 +94,24 @@
9294
* type. Clause 5 is clause 4's other half: asserting the alias is present says
9395
* nothing about a copy sitting beside it.
9496
*
97+
* ## Clause 7: one cron, spelled twice
98+
*
99+
* The workflow is four patrols on four clocks. A `schedule` event carries the
100+
* cron that fired it (`github.event.schedule`), and each job's `if:` compares
101+
* against its own, so one cron string lives in two places: under
102+
* `on.schedule`, and in exactly one job's `if:`. Edit one side alone and
103+
* nothing reds: a cron no `if:` names fires a run in which every job skips,
104+
* and a job whose `if:` names a cron the schedule does not declare never runs
105+
* on a schedule again. So clause 7 reads both sides and refuses an orphan on
106+
* either one.
107+
*
108+
* It reads ONE spelling, the one every job uses today:
109+
* `github.event.schedule == 'CRON'`. A job-level `if:` that reads
110+
* `github.event.schedule` any other way is refused as unreadable, never
111+
* skipped, because a comparison this clause cannot read is one it cannot
112+
* check. Only job-level `if:`s count: they are what decides whether a job
113+
* runs at all.
114+
*
95115
* ## Why the workflow itself cannot hold this
96116
*
97117
* The watchdog's `pull_request:` trigger fires only on the pull requests
@@ -172,11 +192,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
172192
'clause 4b — no board write reachable from a pull_request run': 6,
173193
'clauses 5 and 6 — the package script, and no inlined copy of it': 5,
174194
'the manifest clause — the package script exists to be invoked': 3,
195+
'clause 7 — every schedule cron and every job if: cron has its twin': 9,
175196
});
176197

177198
// DELETING an entry silences that battery's floor exactly as effectively as
178199
// zeroing it, so the roster's own size is pinned beside the floors.
179-
const SELF_TEST_BATTERY_FLOOR = 8;
200+
const SELF_TEST_BATTERY_FLOOR = 9;
180201

181202
// The key an assertion is filed under when no battery is open. It is not a
182203
// declared battery, so it reds by the same set difference rather than silently
@@ -328,6 +349,46 @@ export function judgeBoardWrites(doc) {
328349
return out;
329350
}
330351

352+
/** The one comparison clause 7 reads in a job's `if:`; group 1 is the cron. */
353+
const SCHEDULE_COMPARISON = /github\.event\.schedule\s*==\s*'([^']*)'/g;
354+
const SCHEDULE_MENTION = /github\.event\.schedule\b/g;
355+
356+
/**
357+
* Clause 7. Every `on.schedule` cron must be named by some job's `if:`, and
358+
* every cron a job's `if:` names must be an `on.schedule` entry. Compared
359+
* byte-for-byte, because `github.event.schedule` carries the string exactly as
360+
* the schedule spells it.
361+
*/
362+
export function judgeScheduleTwins(doc) {
363+
const out = [];
364+
const schedule = triggerBlock(doc)?.schedule;
365+
const declared = Array.isArray(schedule)
366+
? schedule.filter((e) => e && typeof e.cron === 'string' && e.cron.trim() !== '').map((e) => e.cron)
367+
: [];
368+
const named = new Set();
369+
const jobs = doc?.jobs && typeof doc.jobs === 'object' ? doc.jobs : {};
370+
for (const [jobId, job] of Object.entries(jobs)) {
371+
const cond = typeof job?.if === 'string' ? job.if : '';
372+
const mentions = cond.match(SCHEDULE_MENTION)?.length ?? 0;
373+
const crons = [...cond.matchAll(SCHEDULE_COMPARISON)].map((m) => m[1]);
374+
if (crons.length !== mentions) {
375+
out.push(`${WORKFLOW_REL}: job \`${jobId}\`'s \`if:\` reads \`github.event.schedule\` in a spelling this pin does not read, so its cron cannot be checked against \`on.schedule\`. Spell the comparison \`github.event.schedule == 'CRON'\`, the one every patrol job uses.`);
376+
}
377+
for (const cron of crons) {
378+
named.add(cron);
379+
if (!declared.includes(cron)) {
380+
out.push(`${WORKFLOW_REL}: job \`${jobId}\`'s \`if:\` compares \`github.event.schedule\` to \`${cron}\`, which no \`on.schedule\` entry declares, so the job never runs on a schedule and nothing reds. A cron under \`on.schedule\` and the same string in its job's \`if:\` are one fact: change both in the same edit.`);
381+
}
382+
}
383+
}
384+
for (const cron of declared) {
385+
if (!named.has(cron)) {
386+
out.push(`${WORKFLOW_REL}: \`on.schedule\` declares the cron \`${cron}\` but no job's \`if:\` compares \`github.event.schedule\` to it, so that cron fires a run in which every job skips and nothing reds. A cron under \`on.schedule\` and the same string in its job's \`if:\` are one fact: change both in the same edit.`);
387+
}
388+
}
389+
return out;
390+
}
391+
331392
/**
332393
* Judge one workflow's TEXT. Pure over its inputs so `--self-test` can drive
333394
* every clause with a fixture instead of asserting it into the void.
@@ -378,6 +439,7 @@ export function judgeWorkflow(text, parse) {
378439
}
379440

380441
failures.push(...judgeBoardWrites(doc));
442+
failures.push(...judgeScheduleTwins(doc));
381443

382444
const commands = runBlocks(doc).flatMap((run) => shellCommands(run));
383445
if (!commands.some((c) => invokes(c, 'pnpm', PACKAGE_SCRIPT))) {
@@ -426,6 +488,9 @@ permissions:
426488
issues: write
427489
jobs:
428490
watchdog:
491+
if: >-
492+
(github.event_name != 'schedule' || github.event.schedule == '51 2 * * *')
493+
&& (github.event_name != 'workflow_dispatch' || inputs.patrol == 'all')
429494
runs-on: ubuntu-latest
430495
steps:
431496
- uses: actions/checkout@v7
@@ -587,6 +652,37 @@ export function selfTest(parse) {
587652
t('a manifest with the script ⇒ silent', judgeManifest(`{"scripts":{"${PACKAGE_SCRIPT}":"node x.mjs"}}`).length === 0);
588653
t('an unparseable manifest ⇒ fires', judgeManifest('{').length === 1);
589654

655+
battery('clause 7 — every schedule cron and every job if: cron has its twin');
656+
// Clause 7 -- the two spellings of one cron. The good fixture carries one
657+
// twinned pair and is silent (asserted above); TWO_CLOCKS adds a second pair,
658+
// so each case below is one edit to ONE side of one pair.
659+
const IF_51 = "github.event.schedule == '51 2 * * *'";
660+
const TWO_CLOCKS = `${GOOD.replace(" - cron: '51 2 * * *'\n", " - cron: '51 2 * * *'\n - cron: '7 2,8,14,20 * * *'\n")}`
661+
+ " snapshot:\n if: github.event_name != 'schedule' || github.event.schedule == '7 2,8,14,20 * * *'\n"
662+
+ ' runs-on: ubuntu-latest\n steps:\n - run: echo snapshot\n';
663+
const twins = (text) => judgeScheduleTwins(parse(text));
664+
t('two crons, each named by its own job ⇒ silent (positive control)', judgeWorkflow(TWO_CLOCKS, parse).failures.length === 0);
665+
const NO_JOB_TWIN = TWO_CLOCKS.replace(" - cron: '7 2,8,14,20 * * *'\n", " - cron: '7 2,8,14,20 * * *'\n - cron: '23 4,16 * * *'\n");
666+
t('⭐ a cron added under `on.schedule` with no job `if:` twin ⇒ fires', fires(NO_JOB_TWIN, 'declares the cron `23 4,16 * * *` but no job'));
667+
t('…and it is the only finding', twins(NO_JOB_TWIN).length === 1);
668+
const NO_SCHEDULE_TWIN = TWO_CLOCKS.replace("github.event.schedule == '7 2,8,14,20 * * *'", "github.event.schedule == '7 3,9,15,21 * * *'");
669+
t('⭐ a job `if:` cron edited with no `on.schedule` twin ⇒ names the job', fires(NO_SCHEDULE_TWIN, "job `snapshot`'s `if:` compares `github.event.schedule` to `7 3,9,15,21 * * *`"));
670+
t('…and the cron it left behind fires from the other side', fires(NO_SCHEDULE_TWIN, 'declares the cron `7 2,8,14,20 * * *` but no job'));
671+
t('a cron edited under `on.schedule` alone ⇒ fires both ways', (() => {
672+
const f = twins(GOOD.replace(" - cron: '51 2 * * *'\n", " - cron: '52 2 * * *'\n"));
673+
return f.length === 2 && f.some((m) => m.includes('`52 2 * * *` but no job')) && f.some((m) => m.includes('to `51 2 * * *`, which no'));
674+
})());
675+
t('a job `if:` cron with no `schedule:` at all ⇒ fires', fires(GOOD.replace(/ schedule:\n - cron: '51 2 \* \* \*'\n/, ''), 'which no `on.schedule` entry declares'));
676+
t('another spelling (`!=`) ⇒ refused as unreadable, not skipped', fires(
677+
GOOD.replace(IF_51, "github.event.schedule != '51 2 * * *'"), 'in a spelling this pin does not read'));
678+
t('the reversed spelling ⇒ refused as unreadable, not read as a twin', fires(
679+
GOOD.replace(IF_51, "'51 2 * * *' == github.event.schedule"), 'in a spelling this pin does not read'));
680+
t('a twin in a STEP-level `if:` is not a job twin ⇒ fires', fires(
681+
GOOD.replace(IF_51, "github.event_name == 'schedule'")
682+
.replace(" if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request'\n",
683+
` if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request' && ${IF_51}\n`),
684+
'declares the cron `51 2 * * *` but no job'));
685+
590686
// ── The floor, evaluated BEFORE either verdict is printed ───────────────
591687
// A set difference over battery NAMES, so a battery that stopped running
592688
// names itself instead of hiding inside a smaller total.
@@ -646,6 +742,7 @@ async function main(argv) {
646742
console.log(`refused: ${REFUSED_TRIGGERS.join(', ')}`);
647743
console.log(`pull_request: permitted, and only \`paths\`-filtered to exactly ${PR_TRIGGER_ALLOWED_PATHS.join(', ')}`);
648744
console.log(`board write: every step calling the issues endpoint must carry \`${PR_WRITE_GUARD}\` in its own \`if:\``);
745+
console.log("schedule: every `on.schedule` cron named by some job's `if:` as `github.event.schedule == 'CRON'`, and every such cron declared");
649746
}
650747

651748
if (failures.length > 0) {
@@ -655,7 +752,7 @@ async function main(argv) {
655752
process.exit(1);
656753
}
657754

658-
console.log(`OK ${WORKFLOW_REL}: ${REQUIRED_TRIGGERS.join(' + ')} present, ${REFUSED_TRIGGERS.join(' / ')} absent, any pull_request trigger paths-filtered to this file and the sibling patrol jobs' own inputs alone, no board write reachable from a pull_request run, invoked through \`pnpm ${PACKAGE_SCRIPT}\`.`);
755+
console.log(`OK ${WORKFLOW_REL}: ${REQUIRED_TRIGGERS.join(' + ')} present, ${REFUSED_TRIGGERS.join(' / ')} absent, any pull_request trigger paths-filtered to this file and the sibling patrol jobs' own inputs alone, no board write reachable from a pull_request run, every schedule cron twinned with a job \`if:\`, invoked through \`pnpm ${PACKAGE_SCRIPT}\`.`);
659756
}
660757

661758
if (isEntrypoint(import.meta.url)) {

0 commit comments

Comments
 (0)