Skip to content

Commit 175642a

Browse files
committed
fix(pm): a gate moved out of an existing job is Tier H, and the job token's other spellings are read
The delta review at 87f686f found that a gate step moved into a new job with if: false (or behind an opt-in needs:) read Tier S, because gate tokens and uses: were tracked per file. They are now tracked per job, and an existing job that loses one is H. The credential pattern also reads any github[...] index and toJSON(github). Each shape carries its pin. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jU8C3iu8fsXdU4Rm8o
1 parent 87f686f commit 175642a

1 file changed

Lines changed: 40 additions & 7 deletions

File tree

‎scripts/pm/check-governed-merges.mjs‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,8 @@
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
@@ -482,7 +483,8 @@
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). */
16311633
const 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). */
16351637
const 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

Comments
 (0)