471471 * H by PATH — the release, publish and deploy files in
472472 * `WORKFLOW_TIER_H_FILES`, each with its reason, any edit at all.
473473 * H by DIFF (C2) — an added or changed `permissions:`; a `secrets` or
474- * `github.token` line (any case, dot or index) added, changed or moved;
474+ * job-token line (`github.token` in any case, any `github[…]` index,
475+ * `toJSON(github)`) added, changed or moved;
475476 * `pull_request_target`; a `uses:` (or image) not already used at the
476477 * base; a deleted job, a changed literal check-run name or a changed
477478 * `needs:`; a new `continue-on-error`; an `if:` the base does not hold for
482483 * ⛔ Biased to H, because this surface can switch the other gates off: a
483484 * changed job, step or workflow that holds a secret or a write token; an
484485 * added `|| true`, `set +e`, `exit 0` or `OS_SKIP_`/`OS_DISABLE_`/`OS_ALLOW_`;
485- * a gate invocation or a `uses:` dropped; a changed trigger set or workflow
486+ * a gate invocation or a `uses:` dropped from the file, or from an existing
487+ * job (a gate moved into a new job that never runs); a changed trigger set or workflow
486488 * name; a new job with a computed or colliding name; a new, deleted or
487489 * non-YAML file. ⛔ Fail closed: a copy the line reader refuses (anchors,
488490 * tags, tabs, flow mappings, flow spanning lines, double-quoted escapes, a
@@ -1083,7 +1085,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
10831085 '⭐ the argv is CLOSED: a flag the mode does not read, or a second repository beside a bare --pr, is refused': 22,
10841086 'the per-row repository scope (ruled B2, 2026-10-13)': 20,
10851087 'the nested `ui/` workspace rows, on the register (#25712)': 16,
1086- '⭐ the workflows split: Tier H by path or by C2 trigger, else Tier S (ruling record 6722205630)': 61 ,
1088+ '⭐ the workflows split: Tier H by path or by C2 trigger, else Tier S (ruling record 6722205630)': 66 ,
10871089});
10881090
10891091// DELETING an entry silences that battery's floor exactly as effectively as
@@ -1629,8 +1631,8 @@ export const WORKFLOW_TIER_H_FILES = Object.freeze([
16291631
16301632/** The patterns an ADDED line may not carry without Tier H (biased cases: each can turn a check off or skip a gate). */
16311633const WORKFLOW_RISKY_LINE = /\|\|\s*true\b|\bset\s+\+e\b|\bexit\s+0\b|\bOS_(?:SKIP|DISABLE|ALLOW)_[A-Z0-9_]+/;
1632- /** `secrets` or the job's own token, in any case and by dot or index, as the expression evaluator reads them . */
1633- const WORKFLOW_CREDENTIAL = /\bsecrets\b|\bgithub\s*(?:\.\s*token\b|\[\s*['"]token['"]\s*\] )/i;
1634+ /** `secrets`, or the job's token: `github. token` in any case, any `github[…]` index, or `toJSON(github)` whole . */
1635+ const WORKFLOW_CREDENTIAL = /\bsecrets\b|\bgithub\s*(?:\.\s*token\b|\[)|\btojson\s*\(\s*github\s*\ )/i;
16341636/** The gate invocations a run block carries, each as a token; one dropped from a file is Tier H (biased: a removed check). */
16351637const WORKFLOW_INVOCATIONS = Object.freeze([
16361638 [/check:[A-Za-z0-9:._-]+/g, (m) => m[0]],
@@ -1849,6 +1851,14 @@ export function workflowFacts(text) {
18491851 permissions: [], ifs: [], coes: [], credentialLines: [], risky: [],
18501852 credentialOwners: new Set(), writeOwners: new Set(),
18511853 uses: [], invocations: new Set(),
1854+ // Per JOB: the gate tokens and `uses:` identities it holds, so a step moved out of an existing job is seen.
1855+ jobTokens: new Map(),
1856+ };
1857+ const holdToken = (owner, token) => {
1858+ if (!owner.startsWith('job:')) return;
1859+ const id = owner.split('/')[0].slice('job:'.length);
1860+ if (!facts.jobTokens.has(id)) facts.jobTokens.set(id, new Set());
1861+ facts.jobTokens.get(id).add(token);
18521862 };
18531863 const visit = (node, owner) => {
18541864 const text = ownText(node);
@@ -1865,11 +1875,19 @@ export function workflowFacts(text) {
18651875 for (const line of [node.value, ...node.block]) {
18661876 if (!line.trim().startsWith('#') && WORKFLOW_RISKY_LINE.test(line)) facts.risky.push(JSON.stringify([owner, line.trim()]));
18671877 }
1868- if (node.key === 'uses' || (node.key === 'image' && node.value !== '')) facts.uses.push(workflowUsesIdentity(node.value));
1878+ if (node.key === 'uses' || (node.key === 'image' && node.value !== '')) {
1879+ facts.uses.push(workflowUsesIdentity(node.value));
1880+ holdToken(owner, `uses: ${workflowUsesIdentity(node.value)}`);
1881+ }
18691882 if (node.key === 'run') {
18701883 for (const line of [node.value, ...node.block]) {
18711884 if (line.trim().startsWith('#')) continue;
1872- for (const [pattern, token] of WORKFLOW_INVOCATIONS) for (const m of line.matchAll(pattern)) facts.invocations.add(token(m));
1885+ for (const [pattern, token] of WORKFLOW_INVOCATIONS) {
1886+ for (const m of line.matchAll(pattern)) {
1887+ facts.invocations.add(token(m));
1888+ holdToken(owner, token(m));
1889+ }
1890+ }
18731891 }
18741892 }
18751893 for (const child of node.children) visit(child, owner);
@@ -1973,6 +1991,9 @@ export function workflowDiffReasons(a, b, inventory) {
19731991 const computed = (name) => name.includes('${{');
19741992 if (now.name !== job.name && !(computed(now.name) && computed(job.name))) say(`a job's check-run name changed: \`${id}\` \`${job.name}\` → \`${now.name}\``);
19751993 // Either direction: a job whose dependency is skipped is skipped, and a skipped required check reads green.
1994+ // A gate step moved out of an existing job (into a new job that never runs) is a dropped gate there.
1995+ const held = b.jobTokens.get(id) ?? new Set();
1996+ for (const token of a.jobTokens.get(id) ?? []) if (!held.has(token)) say(`an existing job no longer holds \`${token}\`: \`${id}\``);
19761997 if ([...new Set(job.needs)].sort().join() !== [...new Set(now.needs)].sort().join()) say(`a job's \`needs:\` changed: \`${id}\` [${job.needs.join(', ')}] → [${now.needs.join(', ')}]`);
19771998 }
19781999 }
@@ -5249,6 +5270,18 @@ async function selfTest() {
52495270 const valuedParent = tierOf(edit(' name: Build Core\n', ' name: Build Core\n if: false\n'));
52505271 assert('⛔ M2d-a-child-under-a-node-with-a-scalar-value-is-refused-and-H', isH(valuedParent, /does not parse/), show(valuedParent));
52515272 assert('M2d-a-plain-scalar-continuation-still-reads', workflowFacts(edit(' name: Build Core\n', ' name: Build\n Core\n')).ok === true);
5273+ // The delta review's holes (FAIL at 87f686fc): a gate moved into a new job that never runs; two token spellings.
5274+ const GATE_BLOCK = " - name: Gate\n if: github.event_name != 'schedule'\n run: |\n # the gate\n pnpm check:generated\n node scripts/check-required-contexts.mjs\n";
5275+ const movedOff = tierOf(`${edit(GATE_BLOCK, '')} gate2:\n if: false\n runs-on: ubuntu-latest\n steps:\n${GATE_BLOCK}`);
5276+ assert('⛔ D1-a-gate-step-moved-into-a-new-job-with-if-false-is-H', isH(movedOff, /an existing job no longer holds `check:generated`: `build`/), show(movedOff));
5277+ const movedBehind = tierOf(`${edit(GATE_BLOCK, '')} opt-in:\n if: contains(github.event.pull_request.labels.*.name, 'x')\n runs-on: ubuntu-latest\n steps:\n - run: echo opt\n gate2:\n needs: [opt-in]\n runs-on: ubuntu-latest\n steps:\n${GATE_BLOCK}`);
5278+ assert('⛔ D1-a-gate-step-moved-into-a-new-job-behind-an-opt-in-needs-is-H', isH(movedBehind, /an existing job no longer holds `scripts\/check-required-contexts\.mjs`: `build`/), show(movedBehind));
5279+ const actionMoved = tierOf(`${edit(" - name: Restore cache\n uses: actions/cache@v4\n with:\n key: core-${{ hashFiles('pnpm-lock.yaml') }}\n", '')} cache2:\n if: false\n runs-on: ubuntu-latest\n steps:\n - uses: actions/cache@v4\n`);
5280+ assert('⛔ D1-a-uses-moved-out-of-an-existing-job-is-H', isH(actionMoved, /an existing job no longer holds `uses: actions\/cache`: `build`/), show(actionMoved));
5281+ for (const spelling of ['${{ toJSON(github) }}', "${{ github[format('tok{0}', 'en')] }}"]) {
5282+ const r = tierOf(edit(' - run: pnpm vitest run', ` - run: pnpm vitest run\n env:\n T: ${spelling}`));
5283+ assert(`⛔ D2-the-job-token-reached-as-${spelling}-is-H`, isH(r, /token reference added, changed or moved/), show(r));
5284+ }
52525285 const addedNeeds = tierOf(edit(' needs: [build]', ' needs: [build, opt-in]'));
52535286 assert('⛔ S1-a-needs-added-on-an-existing-job-is-H', isH(addedNeeds, /`needs:` changed: `shards` \[build\] → \[build, opt-in\]/), show(addedNeeds));
52545287
0 commit comments