Skip to content

Commit 240a2a5

Browse files
committed
fix(ci): the full-run card speaks its workflow's cadence, so the Rerun Safety card does not call itself hourly
A closed per-workflow map (CARD_CADENCES: CI and Lint & Type Check hourly, Rerun Safety nightly) drives the card's title prefix, marker prefix and the body's cadence sentences. A workflow outside the map is refused, never defaulted. For the hourly workflows every byte is unchanged -- title, marker, marker line and body -- so the open CI card is still found and refreshed. Claude-Session: https://claude.ai/code/session_016PVjjuVPte5XEJvaQx1E4t Co-authored-by: Claude <noreply@anthropic.com>
1 parent b48f908 commit 240a2a5

2 files changed

Lines changed: 165 additions & 20 deletions

File tree

‎.github/workflows/scheduled-full-run-card.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ on:
7676
# `Rerun Safety` (rerun-safety-nightly.yml, #23880) runs once a night, and
7777
# its scheduled runs from 2026-09-17 to 10-16 concluded `success` once in
7878
# thirty nights with no filer at all. It gets its own card by the same
79-
# identity rule. The card body's cadence words ("hourly") are the hourly
80-
# runs' and are wrong for it -- #23880 records that follow-up.
79+
# identity rule, in its own cadence's words: every name listed here must
80+
# have an entry in `CARD_CADENCES` (scripts/ci/scheduled-full-run.mjs),
81+
# which refuses a name it does not hold rather than default one.
8182
workflows: ['CI', 'Lint & Type Check', 'Rerun Safety']
8283
types: [completed]
8384

‎scripts/ci/scheduled-full-run.mjs‎

Lines changed: 162 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,50 @@ export function concurrencyVerdict(groupExpression, { workflow = 'W' } = {}) {
211211
// ⛔ The marker is PLAIN TEXT, never an HTML comment. This platform's body
212212
// sanitizer is measured to eat short angle-bracket fragments, and a de-dup key
213213
// that can be swallowed files a duplicate an hour.
214-
const MARKER_PREFIX = 'os-hourly-full-run';
215-
const TITLE_PREFIX = 'hourly full run: red on main';
214+
//
215+
// Both prefixes carry the watched workflow's CADENCE (#23880): the card for a
216+
// nightly workflow must not call itself hourly. For an hourly workflow they
217+
// spell exactly what they always did -- `hourly full run: red on main` and
218+
// `os-hourly-full-run` -- so an open card filed before the cadence existed is
219+
// still found by its title and by its marker, and refreshed, never duplicated.
220+
const markerPrefix = (cadence) => `os-${cadence}-full-run`;
221+
const titlePrefix = (cadence) => `${cadence} full run: red on main`;
222+
223+
// THE CADENCE OF EACH WATCHED WORKFLOW, by its byte-exact `name:` -- the same
224+
// spelling `scheduled-full-run-card.yml` lists in `workflow_run.workflows`.
225+
// CLOSED on purpose: a workflow missing here is REFUSED by `cardIdentity`,
226+
// never defaulted, because a defaulted cadence prints the wrong one on the card
227+
// and the card is the only reader of that run. Adding a workflow to the card's
228+
// watched list therefore means adding its cadence here in the same change.
229+
export const CARD_CADENCES = Object.freeze({
230+
CI: 'hourly',
231+
'Lint & Type Check': 'hourly',
232+
'Rerun Safety': 'nightly',
233+
});
234+
235+
// The words each cadence puts in the body. The `hourly` entry is the text the
236+
// body has always carried, byte for byte.
237+
const CADENCE_WORDS = Object.freeze({
238+
hourly: Object.freeze({
239+
every: 'every hour',
240+
previous: 'an hour ago',
241+
about: (name) => [
242+
`\`${name}\` runs the FULL battery on \`main\` every hour (#16467). A \`push\` run on`,
243+
'`main` tests only the packages the merge touched, and a merge-queue build tests only what the group',
244+
'contained — so this hourly run is the only thing that says whether the WHOLE tree is green, and',
245+
'this card is the only channel that reports it. Nothing is blocked by it.',
246+
],
247+
}),
248+
nightly: Object.freeze({
249+
every: 'every night',
250+
previous: 'the night before',
251+
about: (name) => [
252+
`\`${name}\` runs on \`main\` once a night, and no pull request, merge-queue build or other`,
253+
'scheduled run takes its measurement -- so this nightly run is the only thing that reports it, and',
254+
'this card is the only channel that carries it. Nothing is blocked by it.',
255+
],
256+
}),
257+
});
216258

217259
// ONE CARD PER WORKFLOW, not one card for both, and that is a decision rather
218260
// than an oversight. `CI` and `Lint & Type Check` are two different batteries
@@ -229,10 +271,19 @@ export function cardIdentity(workflowName) {
229271
.replace(/[^a-z0-9]+/g, '-')
230272
.replace(/^-+|-+$/g, '');
231273
if (slug === '') throw new Error(`cardIdentity: '${name}' slugs to nothing, so it cannot be an identity`);
274+
if (!Object.hasOwn(CARD_CADENCES, name)) {
275+
throw new Error(
276+
`cardIdentity: '${name}' has no cadence in CARD_CADENCES (${Object.keys(CARD_CADENCES).join(', ')}). ` +
277+
'Add it there in the same change that makes scheduled-full-run-card.yml watch it -- a defaulted ' +
278+
'cadence would print the wrong one on the only card that reports this run.'
279+
);
280+
}
281+
const cadence = CARD_CADENCES[name];
232282
return {
233283
workflowName: name,
234-
title: `${TITLE_PREFIX} (${name})`,
235-
marker: `${MARKER_PREFIX}-${slug}`,
284+
cadence,
285+
title: `${titlePrefix(cadence)} (${name})`,
286+
marker: `${markerPrefix(cadence)}-${slug}`,
236287
labels: ['bug', 'domain:devx', 'priority:p1'],
237288
};
238289
}
@@ -298,8 +349,9 @@ export async function fileOrRefreshCard({ identity, body, listPage, createIssue,
298349
}
299350

300351
// The card body. The run link and the commit range are the whole product: a red
301-
// hourly run says "main broke somewhere in the last hour", and the commits
302-
// between the previous GREEN scheduled run and this one are that hour.
352+
// scheduled run says "main broke somewhere since the last green one" -- the last
353+
// hour for an hourly workflow, the last night for a nightly one -- and the
354+
// commits between the previous GREEN scheduled run and this one are that span.
303355
export function renderBody({
304356
identity,
305357
runUrl,
@@ -316,6 +368,13 @@ export function renderBody({
316368
// opposite directions.
317369
rangeNote = null,
318370
}) {
371+
// The cadence's words. `cardIdentity` refuses a workflow with no cadence; an
372+
// identity built by hand without one is refused here for the same reason.
373+
const words = CADENCE_WORDS[identity?.cadence];
374+
if (!words) {
375+
throw new Error(`renderBody: the identity carries no known cadence ('${identity?.cadence}') -- build it with cardIdentity`);
376+
}
377+
const heading = `## What landed since the last green ${identity.cadence} run`;
319378
const range = previousGreen
320379
? `\`${String(previousGreen.head_sha).slice(0, 10)}\` (run ${previousGreen.run_id}, the last green ` +
321380
`\`${identity.workflowName}\` schedule run) .. \`${String(headSha).slice(0, 10)}\``
@@ -324,7 +383,7 @@ export function renderBody({
324383
const suspects = previousGreen
325384
? commits.length > 0
326385
? [
327-
`## What landed since the last green hourly run`,
386+
heading,
328387
'',
329388
`Range: ${range}${compareUrl ? ` — [compare](${compareUrl})` : ''}`,
330389
commitsTruncated
@@ -335,17 +394,17 @@ export function renderBody({
335394
'',
336395
]
337396
: [
338-
`## What landed since the last green hourly run`,
397+
heading,
339398
'',
340399
`Range: ${range}`,
341400
'',
342-
'⚠️ Nothing landed in that range. The same tree was green an hour ago and is red now, so',
401+
`⚠️ Nothing landed in that range. The same tree was green ${words.previous} and is red now, so`,
343402
'this is a FLAKE, an infrastructure fault, or a suite that depends on wall-clock time or on',
344403
'something outside the repository. ⛔ Do not go looking for the commit that broke it.',
345404
'',
346405
]
347406
: [
348-
`## What landed since the last green hourly run`,
407+
heading,
349408
'',
350409
'⚠️ No commit range could be named for this red run.',
351410
rangeNote ?? '',
@@ -356,19 +415,16 @@ export function renderBody({
356415

357416
return [
358417
`${identity.marker} — machine-findable marker for this generated card. ⛔ Do not delete this line: ` +
359-
'it is how the hourly full run finds this card instead of filing a new one every hour.',
418+
`it is how the ${identity.cadence} full run finds this card instead of filing a new one ${words.every}.`,
360419
'',
361420
`# ${identity.title}`,
362421
'',
363422
`_Swept ${sweptAt} · [run log](${runUrl}) · commit \`${headSha}\` · conclusion \`${conclusion}\`._`,
364423
'',
365-
`\`${identity.workflowName}\` runs the FULL battery on \`main\` every hour (#16467). A \`push\` run on`,
366-
'`main` tests only the packages the merge touched, and a merge-queue build tests only what the group',
367-
'contained — so this hourly run is the only thing that says whether the WHOLE tree is green, and',
368-
'this card is the only channel that reports it. Nothing is blocked by it.',
424+
...words.about(identity.workflowName),
369425
'',
370-
'⛔ The remedy is never to narrow, skip or delete the failing check to make the hourly run green.',
371-
'Fix what it names and let the next hourly run refresh this card; a card nobody reopens is closed by',
426+
`⛔ The remedy is never to narrow, skip or delete the failing check to make the ${identity.cadence} run green.`,
427+
`Fix what it names and let the next ${identity.cadence} run refresh this card; a card nobody reopens is closed by`,
372428
'the next green run being uneventful, not by editing this one.',
373429
'',
374430
...suspects,
@@ -632,7 +688,7 @@ async function releaseGate(argv) {
632688
// remedy is to find what stopped registering, never to lower the number.
633689
const SELF_TEST_BATTERIES = Object.freeze({
634690
'scheduled-full-run concurrency isolation': 20,
635-
'scheduled-full-run card identity and de-dup': 23,
691+
'scheduled-full-run card identity and de-dup': 34,
636692
'release gate: the selection rule on the recorded 2026-10-12 listing': 8,
637693
'release gate: the selection rule on synthetic runs': 17,
638694
'release gate: release.yml wiring': 6,
@@ -899,6 +955,94 @@ async function selfTest() {
899955
if (found?.number !== 42) throw new Error('de-dup: a multi-page scan lost the card');
900956
});
901957

958+
// -- The cadence (#23880): each cadence, the back-compat pins for the card
959+
// already open under the hourly identity, and the refusal of a workflow
960+
// the closed map does not name.
961+
const rerun = cardIdentity('Rerun Safety');
962+
const OPEN_CI_TITLE = 'hourly full run: red on main (CI)';
963+
const OPEN_CI_MARKER = 'os-hourly-full-run-ci';
964+
const OPEN_CI_MARKER_LINE =
965+
'os-hourly-full-run-ci — machine-findable marker for this generated card. ⛔ Do not delete this line: ' +
966+
'it is how the hourly full run finds this card instead of filing a new one every hour.';
967+
const bodiesOf = (identityUnderTest) => [
968+
renderBody({ identity: identityUnderTest, runUrl: 'u', headSha: 'a', conclusion: 'failure', sweptAt: 't',
969+
previousGreen: { run_id: 4, head_sha: 'b' }, commits: [{ sha: 'c', title: 'x' }] }),
970+
renderBody({ identity: identityUnderTest, runUrl: 'u', headSha: 'a', conclusion: 'failure', sweptAt: 't',
971+
previousGreen: { run_id: 4, head_sha: 'b' }, commits: [] }),
972+
renderBody({ identity: identityUnderTest, runUrl: 'u', headSha: 'a', conclusion: 'failure', sweptAt: 't', rangeNote: 'n' }),
973+
];
974+
await check(() => {
975+
if (ci.cadence !== 'hourly' || ci.title !== OPEN_CI_TITLE || ci.marker !== OPEN_CI_MARKER) {
976+
throw new Error(`cadence: the CI identity moved (${ci.cadence}, '${ci.title}', '${ci.marker}') -- the open card would be duplicated`);
977+
}
978+
});
979+
await check(() => {
980+
for (const b of bodiesOf(ci)) {
981+
if (b.split('\n')[0] !== OPEN_CI_MARKER_LINE) throw new Error(`cadence: the CI marker line moved: '${b.split('\n')[0]}'`);
982+
}
983+
});
984+
await check(async () => {
985+
// The open CI card exactly as it was filed before the cadence existed.
986+
const open = { number: 23885, title: OPEN_CI_TITLE, body: `${OPEN_CI_MARKER_LINE}\n# ${OPEN_CI_TITLE}` };
987+
const found = await findExistingCard({ identity: cardIdentity('CI'), listPage: page([issue(7), open]) });
988+
if (found?.number !== 23885) throw new Error(`cadence: the open CI card was not found by its title (${found?.number})`);
989+
});
990+
await check(async () => {
991+
const renamed = { number: 23885, title: 'renamed by a human', body: `${OPEN_CI_MARKER_LINE}\n` };
992+
const found = await findExistingCard({ identity: cardIdentity('CI'), listPage: page([renamed]) });
993+
if (found?.number !== 23885) throw new Error('cadence: the open CI card was not found by its marker alone');
994+
});
995+
await check(() => {
996+
if (lint.cadence !== 'hourly' || lint.title !== 'hourly full run: red on main (Lint & Type Check)') {
997+
throw new Error(`cadence: Lint & Type Check is not hourly ('${lint.title}')`);
998+
}
999+
});
1000+
await check(() => {
1001+
if (
1002+
rerun.cadence !== 'nightly' ||
1003+
rerun.title !== 'nightly full run: red on main (Rerun Safety)' ||
1004+
rerun.marker !== 'os-nightly-full-run-rerun-safety'
1005+
) {
1006+
throw new Error(`cadence: Rerun Safety is not nightly ('${rerun.title}', '${rerun.marker}')`);
1007+
}
1008+
});
1009+
await check(() => {
1010+
for (const b of bodiesOf(rerun)) {
1011+
if (/hourly|every hour|an hour ago/.test(b)) throw new Error(`cadence: a nightly card body says hourly:\n${b}`);
1012+
if (!b.includes('every night') || !b.includes('## What landed since the last green nightly run')) {
1013+
throw new Error(`cadence: a nightly card body lacks its own cadence words:\n${b}`);
1014+
}
1015+
}
1016+
if (!bodiesOf(rerun)[1].includes('green the night before')) throw new Error('cadence: the nightly flake reading is not nightly');
1017+
});
1018+
await check(() => {
1019+
for (const b of bodiesOf(ci)) {
1020+
if (/nightly|every night|the night before/.test(b)) throw new Error(`cadence: an hourly card body says nightly:\n${b}`);
1021+
}
1022+
});
1023+
await check(() => {
1024+
let message = '';
1025+
try {
1026+
cardIdentity('Spec Coverage');
1027+
} catch (error) {
1028+
message = error.message;
1029+
}
1030+
if (!message.includes('CARD_CADENCES')) {
1031+
throw new Error(`cadence: a workflow outside the map was not refused by name (${message || 'accepted'})`);
1032+
}
1033+
});
1034+
await check(() => {
1035+
const { cadence: _dropped, ...handBuilt } = ci;
1036+
if (!threw(() => renderBody({ identity: handBuilt, runUrl: 'u', headSha: 'a', conclusion: 'failure', sweptAt: 't' }))) {
1037+
throw new Error('cadence: renderBody defaulted the cadence of an identity that carries none');
1038+
}
1039+
});
1040+
await check(() => {
1041+
if (matchesCard(issue(1, { title: ci.title, body: ci.marker }), rerun) || matchesCard(issue(1, { title: rerun.title, body: rerun.marker }), ci)) {
1042+
throw new Error("cadence: the nightly and hourly identities matched each other's cards");
1043+
}
1044+
});
1045+
9021046
// -- THE ACCEPTANCE SHAPE, driven end to end against a mutable board: one red
9031047
// run files exactly one card, and the NEXT red run refreshes that card and
9041048
// files no second one. This is the property the filer exists for, and a

0 commit comments

Comments
 (0)