mandrel-platform 0.29.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.29.0",
3
+ "version": "1.0.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-affected-mode.test.mjs — YAML-level regression guard for the
4
+ * affected-mode wiring in `.github/workflows/pr-quality.yml` (Story #319,
5
+ * follow-up to #314/#315).
6
+ *
7
+ * `scripts/resolve-diff-range.sh` has solid unit coverage
8
+ * (`resolve-diff-range.test.mjs`), but the affected-mode *wiring* inside the
9
+ * reusable workflow was only covered indirectly by anchor-expansion checks.
10
+ * This suite pins the three invariants that keep the affected-mode design
11
+ * correct, modelled on the sibling `check-ci-required-aggregator.test.mjs`
12
+ * (read the real workflow, extract blocks by indentation, assert):
13
+ *
14
+ * 1. GATING — the coverage-gate steps (`Checkout gate scripts`, `Coverage
15
+ * threshold gate`) carry `inputs.coverage-threshold != 0 && !inputs.affected`,
16
+ * and the bypass `::notice::` step carries the *complementary*
17
+ * `inputs.coverage-threshold != 0 && inputs.affected`. A drift that drops
18
+ * `!inputs.affected` (which would false-fail the whole-repo floor on an
19
+ * affected subset) fails this suite.
20
+ * 2. NO EXPORT ON mode=none — executing the resolve-affected `run:` script
21
+ * with an unresolved (`mode=none`) range leaves `TURBO_SCM_*` unset, so
22
+ * turbo falls back to its own default rather than a bogus range.
23
+ * 3. INJECTION-SAFE EXPORT (Story #319 hardening) — a newline-bearing
24
+ * `affected-base` override is rejected before the `$GITHUB_ENV` write, so
25
+ * it can never inject a second env line; a single-line override exports
26
+ * exactly `TURBO_SCM_BASE` / `TURBO_SCM_HEAD` and nothing else.
27
+ *
28
+ * The `run:` script is executed against real bash with a stubbed
29
+ * `resolve-diff-range.sh` (the sourced derivation), so no git repo is needed;
30
+ * skipped when bash is unavailable locally (CI's ubuntu runner always has it).
31
+ *
32
+ * Run: node --test scripts/check-affected-mode.test.mjs
33
+ */
34
+
35
+ import assert from "node:assert/strict";
36
+ import { test } from "node:test";
37
+ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
38
+ import { execFileSync, spawnSync } from "node:child_process";
39
+ import { join, resolve, dirname } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { tmpdir } from "node:os";
42
+
43
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
44
+ const WORKFLOW = ".github/workflows/pr-quality.yml";
45
+ const content = readFileSync(join(repoRoot, WORKFLOW), "utf8");
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Minimal indentation-based extraction (dependency-free, mirrors
49
+ // check-ci-required-aggregator.test.mjs). A step spans from its `- ` bullet to
50
+ // the next sibling bullet at the same indent (or a dedent below it).
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function stepByName(text, name) {
54
+ const lines = text.split("\n");
55
+ const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
56
+ assert.notEqual(nameIdx, -1, `step "${name}" not found`);
57
+ let start = -1;
58
+ for (let i = nameIdx; i >= 0; i--) {
59
+ if (/^\s*-\s/.test(lines[i])) {
60
+ start = i;
61
+ break;
62
+ }
63
+ }
64
+ assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
65
+ const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i++) {
68
+ if (/^\s*$/.test(lines[i])) continue;
69
+ const indent = lines[i].match(/^(\s*)/)[1].length;
70
+ if (indent < bulletIndent) {
71
+ end = i;
72
+ break;
73
+ }
74
+ if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
75
+ end = i;
76
+ break;
77
+ }
78
+ }
79
+ return lines.slice(start, end).join("\n");
80
+ }
81
+
82
+ /** The (first) `if:` expression on a step block, trimmed. */
83
+ function ifCondition(stepBlock) {
84
+ const m = stepBlock.match(/^\s+if:\s*(.+?)\s*$/m);
85
+ assert.ok(m, "step has no `if:` condition");
86
+ return m[1].trim();
87
+ }
88
+
89
+ /** The dedented body of the step's `run: |` block scalar. */
90
+ function runScript(stepBlock) {
91
+ const lines = stepBlock.split("\n");
92
+ const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
93
+ assert.notEqual(start, -1, "`run: |` block not found");
94
+ const runIndent = lines[start].match(/^(\s*)/)[1].length;
95
+ const body = [];
96
+ for (let i = start + 1; i < lines.length; i++) {
97
+ if (/^\s*$/.test(lines[i])) {
98
+ body.push("");
99
+ continue;
100
+ }
101
+ const indent = lines[i].match(/^(\s*)/)[1].length;
102
+ if (indent <= runIndent) break;
103
+ body.push(lines[i].slice(runIndent + 2));
104
+ }
105
+ return body.join("\n");
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 1. GATING — coverage-gate steps gated OUT of affected mode; bypass step gated IN
110
+ // ---------------------------------------------------------------------------
111
+
112
+ test("resolve-affected step runs only in affected mode", () => {
113
+ const cond = ifCondition(stepByName(content, "Resolve affected SCM base/head (turbo)"));
114
+ assert.match(cond, /inputs\.affected/, "the resolve step must be gated on `inputs.affected`");
115
+ assert.doesNotMatch(cond, /!\s*inputs\.affected/, "the resolve step must not be negated");
116
+ });
117
+
118
+ for (const step of ["Checkout gate scripts (mandrel-platform@resolved-sha)", "Coverage threshold gate"]) {
119
+ test(`coverage-gate step "${step}" is gated OUT of affected mode`, () => {
120
+ const cond = ifCondition(stepByName(content, step));
121
+ assert.match(
122
+ cond,
123
+ /inputs\.coverage-threshold != 0/,
124
+ "must still require a non-zero coverage-threshold"
125
+ );
126
+ assert.match(
127
+ cond,
128
+ /!\s*inputs\.affected/,
129
+ "must carry `!inputs.affected` — dropping it false-fails the whole-repo floor on an affected subset"
130
+ );
131
+ });
132
+ }
133
+
134
+ test("bypass `::notice::` step carries the complementary affected-mode gating", () => {
135
+ const step = stepByName(content, "Coverage floor bypassed (affected mode)");
136
+ const cond = ifCondition(step);
137
+ assert.match(cond, /inputs\.coverage-threshold != 0/, "must require a non-zero coverage-threshold");
138
+ assert.match(cond, /&&\s*inputs\.affected/, "must fire only in affected mode");
139
+ assert.doesNotMatch(
140
+ cond,
141
+ /!\s*inputs\.affected/,
142
+ "the bypass notice must be the complement of the gate — never negated"
143
+ );
144
+ assert.match(step, /::notice title=/, "the bypass must emit a visible ::notice::, never a silent skip");
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // 2 & 3. Execute the resolve-affected run script against real bash.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function bashAvailable() {
152
+ try {
153
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
154
+ return true;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ const RESOLVE_SCRIPT = runScript(stepByName(content, "Resolve affected SCM base/head (turbo)"));
161
+
162
+ /**
163
+ * Run the extracted resolve-affected `run:` body with a stubbed
164
+ * resolve-diff-range.sh that sets RESOLVED_* from STUB_* env, capturing the
165
+ * lines the step wrote to $GITHUB_ENV.
166
+ */
167
+ function runResolve({ mode = "", base = "", head = "", override } = {}) {
168
+ const dir = mkdtempSync(join(tmpdir(), "affected-mode-"));
169
+ try {
170
+ const workspace = join(dir, "ws");
171
+ mkdirSync(join(workspace, "_mandrel-platform-range", "scripts"), { recursive: true });
172
+ // Stub the sourced derivation: RESOLVED_* come from STUB_* env.
173
+ writeFileSync(
174
+ join(workspace, "_mandrel-platform-range", "scripts", "resolve-diff-range.sh"),
175
+ ['RESOLVED_EVENT_MODE="${STUB_MODE-}"', 'RESOLVED_BASE_SHA="${STUB_BASE-}"', 'RESOLVED_HEAD_SHA="${STUB_HEAD-}"', ""].join("\n")
176
+ );
177
+ const scriptFile = join(dir, "resolve.sh");
178
+ writeFileSync(scriptFile, RESOLVE_SCRIPT);
179
+ const envFile = join(dir, "github_env");
180
+ writeFileSync(envFile, "");
181
+
182
+ const env = {
183
+ ...process.env,
184
+ GITHUB_WORKSPACE: workspace,
185
+ GITHUB_ENV: envFile,
186
+ STUB_MODE: mode,
187
+ STUB_BASE: base,
188
+ STUB_HEAD: head,
189
+ };
190
+ if (override !== undefined) env.AFFECTED_BASE_OVERRIDE = override;
191
+
192
+ const r = spawnSync("bash", [scriptFile], { encoding: "utf8", env });
193
+ return { status: r.status, stderr: r.stderr, envLines: readFileSync(envFile, "utf8") };
194
+ } finally {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ const exec = { skip: bashAvailable() ? false : "bash not available on this host" };
200
+
201
+ test("mode=none: no ranged base resolvable → TURBO_SCM_* left unset", exec, () => {
202
+ const r = runResolve({ mode: "", base: "", head: "" });
203
+ assert.equal(r.status, 0, r.stderr);
204
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_BASE/, "mode=none must not export a base");
205
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_HEAD/, "mode=none must not export a head");
206
+ });
207
+
208
+ test("newline-bearing affected-base override is rejected before the export (injection guard)", exec, () => {
209
+ const r = runResolve({ override: "deadbeef\nMALICIOUS=pwned", head: "cafebabe" });
210
+ assert.notEqual(r.status, 0, "a multiline override must fail the step, not export");
211
+ assert.doesNotMatch(r.envLines, /MALICIOUS/, "the injected env line must never reach $GITHUB_ENV");
212
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_BASE/, "no partial export on a rejected override");
213
+ });
214
+
215
+ test("carriage-return-bearing affected-base override is rejected too", exec, () => {
216
+ const r = runResolve({ override: "deadbeef\rMALICIOUS=pwned", head: "cafebabe" });
217
+ assert.notEqual(r.status, 0);
218
+ assert.doesNotMatch(r.envLines, /MALICIOUS/);
219
+ });
220
+
221
+ test("single-line affected-base override exports exactly TURBO_SCM_BASE and TURBO_SCM_HEAD", exec, () => {
222
+ const r = runResolve({ override: "origin/main", head: "cafebabe" });
223
+ assert.equal(r.status, 0, r.stderr);
224
+ assert.match(r.envLines, /^TURBO_SCM_BASE=origin\/main$/m);
225
+ assert.match(r.envLines, /^TURBO_SCM_HEAD=cafebabe$/m);
226
+ const nonEmpty = r.envLines.split("\n").filter((l) => l.trim() !== "");
227
+ assert.equal(nonEmpty.length, 2, `expected exactly two env lines, got: ${JSON.stringify(nonEmpty)}`);
228
+ });
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-osv-scan-mode.test.mjs — YAML-level regression guard for the
4
+ * diff-aware OSV wiring (Story #325).
5
+ *
6
+ * The gate LOGIC has unit coverage in `osv-report-gate.test.mjs`, but the
7
+ * logic is inert unless the workflow actually feeds it a baseline. The wiring
8
+ * is where this design silently degrades, and every degradation looks like a
9
+ * green build:
10
+ *
11
+ * • a shallow checkout → `git merge-base` and the baseline worktree cannot
12
+ * reach the fork point → every run silently gates whole-tree, and the
13
+ * incident this Story fixes comes straight back;
14
+ * • a dropped `baseline-ref` input → the composite resolves 'auto' to full
15
+ * with no error, for the same silent outcome;
16
+ * • `advisory-scan.yml` inheriting the diff-aware default → NOTHING owns
17
+ * base-branch advisories, because the PR tier deliberately stopped
18
+ * blocking on them. That is the one true regression here: a real
19
+ * main-level advisory would go completely unreported.
20
+ *
21
+ * This suite pins all three, modelled on the sibling
22
+ * `check-affected-mode.test.mjs` (read the real workflow, extract blocks by
23
+ * indentation, assert — then execute the extracted `run:` bodies against real
24
+ * bash so the fail-closed branches are proven, not just read).
25
+ *
26
+ * Run: node --test scripts/check-osv-scan-mode.test.mjs
27
+ */
28
+
29
+ import assert from "node:assert/strict";
30
+ import { test } from "node:test";
31
+ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
32
+ import { execFileSync, spawnSync } from "node:child_process";
33
+ import { join, resolve, dirname } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { tmpdir } from "node:os";
36
+
37
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
38
+ const prQuality = readFileSync(join(repoRoot, ".github/workflows/pr-quality.yml"), "utf8");
39
+ const advisoryScan = readFileSync(join(repoRoot, ".github/workflows/advisory-scan.yml"), "utf8");
40
+ const composite = readFileSync(join(repoRoot, ".github/actions/osv-scan/action.yml"), "utf8");
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Minimal indentation-based extraction (dependency-free, mirrors
44
+ // check-affected-mode.test.mjs / check-ci-required-aggregator.test.mjs).
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function stepByName(text, name) {
48
+ const lines = text.split("\n");
49
+ const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
50
+ assert.notEqual(nameIdx, -1, `step "${name}" not found`);
51
+ let start = -1;
52
+ for (let i = nameIdx; i >= 0; i--) {
53
+ if (/^\s*-\s/.test(lines[i])) {
54
+ start = i;
55
+ break;
56
+ }
57
+ }
58
+ assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
59
+ const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
60
+ let end = lines.length;
61
+ for (let i = start + 1; i < lines.length; i++) {
62
+ if (/^\s*$/.test(lines[i])) continue;
63
+ const indent = lines[i].match(/^(\s*)/)[1].length;
64
+ if (indent < bulletIndent) {
65
+ end = i;
66
+ break;
67
+ }
68
+ if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
69
+ end = i;
70
+ break;
71
+ }
72
+ }
73
+ return lines.slice(start, end).join("\n");
74
+ }
75
+
76
+ /** The dedented body of a step's `run: |` block scalar. */
77
+ function runScript(stepBlock) {
78
+ const lines = stepBlock.split("\n");
79
+ const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
80
+ assert.notEqual(start, -1, "`run: |` block not found");
81
+ const runIndent = lines[start].match(/^(\s*)/)[1].length;
82
+ const body = [];
83
+ for (let i = start + 1; i < lines.length; i++) {
84
+ if (/^\s*$/.test(lines[i])) {
85
+ body.push("");
86
+ continue;
87
+ }
88
+ const indent = lines[i].match(/^(\s*)/)[1].length;
89
+ if (indent <= runIndent) break;
90
+ body.push(lines[i].slice(runIndent + 2));
91
+ }
92
+ return body.join("\n");
93
+ }
94
+
95
+ /**
96
+ * The body of a mapping key at a given indent, up to the next sibling key or
97
+ * end of input. Terminating on end-of-input matters: the LAST declared input
98
+ * has no following sibling, and a regex that requires one silently matches
99
+ * nothing.
100
+ */
101
+ function blockUnder(text, key, indent) {
102
+ const lines = text.split("\n");
103
+ const start = lines.findIndex((l) => l === `${" ".repeat(indent)}${key}:`);
104
+ assert.notEqual(start, -1, `key "${key}" not found at indent ${indent}`);
105
+ let end = lines.length;
106
+ for (let i = start + 1; i < lines.length; i++) {
107
+ if (/^\s*$/.test(lines[i])) continue;
108
+ // The block ends at the next sibling key (same indent) or any dedent —
109
+ // both are just "leading whitespace no deeper than the key's own".
110
+ if (lines[i].match(/^(\s*)/)[1].length <= indent) {
111
+ end = i;
112
+ break;
113
+ }
114
+ }
115
+ return lines.slice(start + 1, end).join("\n");
116
+ }
117
+
118
+ /** The `osv-scan` job block, so step lookups cannot match a same-named step elsewhere. */
119
+ function osvScanJob() {
120
+ const lines = prQuality.split("\n");
121
+ const start = lines.findIndex((l) => /^ {2}osv-scan:\s*$/.test(l));
122
+ assert.notEqual(start, -1, "osv-scan job not found");
123
+ let end = lines.length;
124
+ for (let i = start + 1; i < lines.length; i++) {
125
+ if (/^ {2}\S/.test(lines[i])) {
126
+ end = i;
127
+ break;
128
+ }
129
+ }
130
+ return lines.slice(start, end).join("\n");
131
+ }
132
+
133
+ const job = osvScanJob();
134
+
135
+ function bashAvailable() {
136
+ try {
137
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+ const exec = { skip: bashAvailable() ? false : "bash not available on this host" };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // 1. INPUT SURFACE — the reusable workflow exposes the knobs, defaulted so the
147
+ // diff-aware behaviour is on and the grace window is off.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("pr-quality declares osv-scan-mode defaulting to 'auto'", () => {
151
+ const block = blockUnder(prQuality, "osv-scan-mode", 6);
152
+ assert.match(block, /type:\s*string/, "osv-scan-mode must be a string input");
153
+ assert.match(
154
+ block,
155
+ /default:\s*'auto'/,
156
+ "osv-scan-mode must default to 'auto' (the diff-aware default)",
157
+ );
158
+ });
159
+
160
+ test("pr-quality declares osv-grace-days defaulting to 0 (window off)", () => {
161
+ const block = blockUnder(prQuality, "osv-grace-days", 6);
162
+ assert.match(block, /type:\s*number/, "osv-grace-days must be a number input");
163
+ assert.match(
164
+ block,
165
+ /default:\s*0\b/,
166
+ "osv-grace-days must default to 0 — no consumer's gate may soften without opting in",
167
+ );
168
+ });
169
+
170
+ test("the composite declares the diff-aware surface with matching defaults", () => {
171
+ assert.match(
172
+ blockUnder(composite, "scan-mode", 2),
173
+ /default:\s*'auto'/,
174
+ "composite scan-mode must default to 'auto'",
175
+ );
176
+ assert.match(
177
+ blockUnder(composite, "grace-days", 2),
178
+ /default:\s*'0'/,
179
+ "composite grace-days must default to '0' — the window is opt-in",
180
+ );
181
+ assert.match(
182
+ blockUnder(composite, "baseline-ref", 2),
183
+ /default:\s*''/,
184
+ "composite baseline-ref must default to empty (no baseline → whole-tree)",
185
+ );
186
+
187
+ const compositeLines = composite.split("\n");
188
+ for (const out of ["preexisting-count", "grace-count", "resolved-scan-mode", "baseline-scanned"]) {
189
+ assert.ok(
190
+ compositeLines.includes(` ${out}:`),
191
+ `composite must expose the ${out} output`,
192
+ );
193
+ }
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 2. WIRING — the osv-scan job can actually reach the merge base, and forwards
198
+ // every input. A shallow checkout here degrades to whole-tree silently.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ test("the osv-scan job checks out full history unless scan-mode is 'full'", () => {
202
+ const checkout = stepByName(job, "Checkout");
203
+ const m = checkout.match(/fetch-depth:\s*(.+)/);
204
+ assert.ok(m, "the osv-scan checkout must pin fetch-depth explicitly");
205
+ const expr = m[1].trim();
206
+ assert.match(
207
+ expr,
208
+ /inputs\.osv-scan-mode == 'full'/,
209
+ "fetch-depth must key off osv-scan-mode — a shallow clone cannot reach the merge base",
210
+ );
211
+ // The strings are load-bearing: a bare numeric 0 is FALSY in a GitHub
212
+ // expression, so `... && 0 || 1` collapses to 1 and silently re-shallows.
213
+ assert.match(expr, /'1'/, "the full-mode arm must be the STRING '1'");
214
+ assert.match(expr, /'0'/, "the diff-mode arm must be the STRING '0'");
215
+ assert.match(checkout, /persist-credentials:\s*false/, "must not persist the token");
216
+ });
217
+
218
+ for (const step of [
219
+ "Checkout diff-range resolver (mandrel-platform@resolved-sha)",
220
+ "Resolve OSV diff baseline (merge base)",
221
+ ]) {
222
+ test(`"${step}" is skipped only under scan-mode: full`, () => {
223
+ const block = stepByName(job, step);
224
+ const m = block.match(/^\s+if:\s*(.+?)\s*$/m);
225
+ assert.ok(m, `step "${step}" has no if: condition`);
226
+ assert.match(
227
+ m[1],
228
+ /inputs\.osv-scan-mode != 'full'/,
229
+ "the baseline steps must run for every mode except full",
230
+ );
231
+ });
232
+ }
233
+
234
+ test("the diff-range resolver is side-checked-out from the pinned platform SHA", () => {
235
+ const block = stepByName(job, "Checkout diff-range resolver (mandrel-platform@resolved-sha)");
236
+ assert.match(block, /repository:\s*dsj1984\/mandrel-platform/);
237
+ assert.match(
238
+ block,
239
+ /job_workflow_sha/,
240
+ "must resolve THIS repo at the SHA the caller pinned, not a floating ref",
241
+ );
242
+ assert.match(
243
+ block,
244
+ /scripts\/resolve-diff-range\.sh/,
245
+ "must reuse the canonical derivation rather than re-deriving base/head",
246
+ );
247
+ });
248
+
249
+ test("the composite step forwards scan-mode, baseline-ref and grace-days", () => {
250
+ const step = stepByName(job, "OSV advisory scan (pinned binary)");
251
+ assert.match(step, /scan-mode:\s*\$\{\{\s*inputs\.osv-scan-mode\s*\}\}/);
252
+ assert.match(
253
+ step,
254
+ /baseline-ref:\s*\$\{\{\s*steps\.osv-baseline\.outputs\.baseline-ref\s*\}\}/,
255
+ "the resolved merge base must reach the composite, or 'auto' silently degrades to full",
256
+ );
257
+ assert.match(step, /grace-days:\s*\$\{\{\s*inputs\.osv-grace-days\s*\}\}/);
258
+ // The existing contract must survive.
259
+ assert.match(step, /fail-on-severity:\s*\$\{\{\s*inputs\.osv-fail-on-severity\s*\}\}/);
260
+ assert.match(step, /allowlist-path:\s*\$\{\{\s*inputs\.osv-allowlist-path\s*\}\}/);
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // 3. THE SCHEDULED OWNER — advisory-scan.yml must stay whole-tree. This is the
265
+ // regression that would make the whole design unsafe.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ test("advisory-scan pins scan-mode: full and cannot inherit the diff-aware default", () => {
269
+ const step = stepByName(advisoryScan, "OSV advisory scan (scheduled, non-blocking)");
270
+ assert.match(
271
+ step,
272
+ /scan-mode:\s*'full'/,
273
+ "the scheduled scan owns base-branch advisories — diff-aware there would leave them unowned",
274
+ );
275
+ });
276
+
277
+ test("advisory-scan still reports findings and still upserts its tracking issue", () => {
278
+ const step = stepByName(advisoryScan, "OSV advisory scan (scheduled, non-blocking)");
279
+ assert.match(step, /non-blocking:\s*'true'/, "the tracking issue is the signal, not a red job");
280
+ assert.match(step, /findings-out:/, "the upsert needs the machine-readable findings");
281
+ assert.match(
282
+ advisoryScan,
283
+ /actions\/osv-track-issue@/,
284
+ "the tracking-issue upsert step must still run",
285
+ );
286
+ assert.match(advisoryScan, /issues:\s*write/, "the upsert needs issues: write");
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // 4. EXECUTE the composite's mode-resolution prologue. 'auto' with no baseline
291
+ // and 'diff' with no baseline must BOTH land on full (blocking) — the
292
+ // fail-closed direction.
293
+ // ---------------------------------------------------------------------------
294
+
295
+ const compositeRun = runScript(stepByName(composite, "OSV advisory scan (pinned binary)"));
296
+ // The prologue is everything before the binary download begins.
297
+ const MODE_PROLOGUE = compositeRun.split('\nos="$(uname -s)"')[0];
298
+
299
+ function resolveMode({ mode, baselineRef = "" }) {
300
+ const dir = mkdtempSync(join(tmpdir(), "osv-mode-"));
301
+ try {
302
+ const scriptFile = join(dir, "prologue.sh");
303
+ writeFileSync(scriptFile, MODE_PROLOGUE);
304
+ const outFile = join(dir, "github_output");
305
+ writeFileSync(outFile, "");
306
+ const r = spawnSync("bash", [scriptFile], {
307
+ encoding: "utf8",
308
+ env: {
309
+ ...process.env,
310
+ GITHUB_OUTPUT: outFile,
311
+ OSV_SCAN_MODE: mode,
312
+ OSV_BASELINE_REF: baselineRef,
313
+ },
314
+ });
315
+ return {
316
+ status: r.status,
317
+ stdout: r.stdout,
318
+ outputs: readFileSync(outFile, "utf8"),
319
+ };
320
+ } finally {
321
+ rmSync(dir, { recursive: true, force: true });
322
+ }
323
+ }
324
+
325
+ test("scan-mode 'full' never resolves to diff, even with a baseline available", exec, () => {
326
+ const r = resolveMode({ mode: "full", baselineRef: "deadbeef" });
327
+ assert.equal(r.status, 0, r.stdout);
328
+ assert.match(r.outputs, /^resolved-scan-mode=full$/m);
329
+ });
330
+
331
+ test("scan-mode 'auto' resolves to diff when a baseline was supplied", exec, () => {
332
+ const r = resolveMode({ mode: "auto", baselineRef: "deadbeef" });
333
+ assert.equal(r.status, 0, r.stdout);
334
+ assert.match(r.outputs, /^resolved-scan-mode=diff$/m);
335
+ });
336
+
337
+ test("scan-mode 'auto' falls back to full when no baseline is resolvable", exec, () => {
338
+ // push / schedule / a merge-queue-less event resolve no base — today's
339
+ // whole-tree behaviour must survive untouched there.
340
+ const r = resolveMode({ mode: "auto", baselineRef: "" });
341
+ assert.equal(r.status, 0, r.stdout);
342
+ assert.match(r.outputs, /^resolved-scan-mode=full$/m);
343
+ });
344
+
345
+ test("scan-mode 'diff' with no baseline degrades to full and says so", exec, () => {
346
+ const r = resolveMode({ mode: "diff", baselineRef: "" });
347
+ assert.equal(r.status, 0, r.stdout);
348
+ assert.match(
349
+ r.outputs,
350
+ /^resolved-scan-mode=full$/m,
351
+ "an unbaselined diff run must BLOCK whole-tree, never pass everything as pre-existing",
352
+ );
353
+ assert.match(r.stdout, /::warning::/, "the degradation must be visible, not silent");
354
+ });
355
+
356
+ test("an invalid scan-mode fails the step closed", exec, () => {
357
+ const r = resolveMode({ mode: "sideways", baselineRef: "deadbeef" });
358
+ assert.notEqual(r.status, 0, "an unrecognized mode must not fall through to a default");
359
+ assert.doesNotMatch(r.outputs, /resolved-scan-mode=/, "no partial output on a rejected mode");
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // 5. EXECUTE the baseline-resolution run script against real bash + real git,
364
+ // so the merge-base shaping and the injection guard are proven.
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const BASELINE_SCRIPT = runScript(stepByName(job, "Resolve OSV diff baseline (merge base)"));
368
+
369
+ /**
370
+ * Run the extracted baseline step with a stubbed resolve-diff-range.sh (the
371
+ * sourced derivation), inside a real throwaway git repo so `git merge-base`
372
+ * resolves for the pull_request shaping.
373
+ */
374
+ function runBaseline({ mode = "", base = "", head = "", realRepo = false } = {}) {
375
+ const dir = mkdtempSync(join(tmpdir(), "osv-baseline-"));
376
+ try {
377
+ const workspace = join(dir, "ws");
378
+ mkdirSync(join(workspace, "_mandrel-platform-osv-range", "scripts"), { recursive: true });
379
+ writeFileSync(
380
+ join(workspace, "_mandrel-platform-osv-range", "scripts", "resolve-diff-range.sh"),
381
+ [
382
+ 'RESOLVED_EVENT_MODE="${STUB_MODE-}"',
383
+ 'RESOLVED_BASE_SHA="${STUB_BASE-}"',
384
+ 'RESOLVED_HEAD_SHA="${STUB_HEAD-}"',
385
+ "",
386
+ ].join("\n"),
387
+ );
388
+
389
+ let baseSha = base;
390
+ let headSha = head;
391
+ if (realRepo) {
392
+ const git = (...args) =>
393
+ execFileSync(
394
+ "git",
395
+ ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args],
396
+ { cwd: workspace, encoding: "utf8" },
397
+ ).trim();
398
+ git("init", "-q", "-b", "main");
399
+ writeFileSync(join(workspace, "a.txt"), "a\n");
400
+ git("add", "a.txt");
401
+ git("commit", "-qm", "root");
402
+ const fork = git("rev-parse", "HEAD");
403
+ // Advance main past the fork point — this drift is exactly why the PR
404
+ // shaping takes the merge base rather than base.sha.
405
+ writeFileSync(join(workspace, "b.txt"), "b\n");
406
+ git("add", "b.txt");
407
+ git("commit", "-qm", "main advances");
408
+ baseSha = git("rev-parse", "HEAD");
409
+ git("checkout", "-q", "-b", "feature", fork);
410
+ writeFileSync(join(workspace, "c.txt"), "c\n");
411
+ git("add", "c.txt");
412
+ git("commit", "-qm", "feature work");
413
+ headSha = git("rev-parse", "HEAD");
414
+ // The merge base of base..head is the fork point, NOT the advanced tip.
415
+ return { ...spawnBaseline(), forkSha: fork };
416
+ }
417
+ return spawnBaseline();
418
+
419
+ function spawnBaseline() {
420
+ const scriptFile = join(dir, "baseline.sh");
421
+ writeFileSync(scriptFile, BASELINE_SCRIPT);
422
+ const outFile = join(dir, "github_output");
423
+ writeFileSync(outFile, "");
424
+ const r = spawnSync("bash", [scriptFile], {
425
+ cwd: workspace,
426
+ encoding: "utf8",
427
+ env: {
428
+ ...process.env,
429
+ GITHUB_WORKSPACE: workspace,
430
+ GITHUB_OUTPUT: outFile,
431
+ STUB_MODE: mode,
432
+ STUB_BASE: baseSha,
433
+ STUB_HEAD: headSha,
434
+ },
435
+ });
436
+ return {
437
+ status: r.status,
438
+ stdout: r.stdout,
439
+ stderr: r.stderr,
440
+ outputs: readFileSync(outFile, "utf8"),
441
+ };
442
+ }
443
+ } finally {
444
+ rmSync(dir, { recursive: true, force: true });
445
+ }
446
+ }
447
+
448
+ test("mode=none exports an EMPTY baseline-ref rather than skipping the output", exec, () => {
449
+ const r = runBaseline({ mode: "" });
450
+ assert.equal(r.status, 0, r.stderr);
451
+ assert.match(
452
+ r.outputs,
453
+ /^baseline-ref=$/m,
454
+ "an unresolvable base must still emit the (empty) output — the composite reads that as 'gate whole-tree'",
455
+ );
456
+ });
457
+
458
+ test("pull_request baselines on the MERGE BASE, not the drifted base tip", exec, () => {
459
+ const r = runBaseline({ mode: "pull_request", realRepo: true });
460
+ assert.equal(r.status, 0, r.stderr);
461
+ const m = r.outputs.match(/^baseline-ref=(.+)$/m);
462
+ assert.ok(m, "no baseline-ref emitted");
463
+ assert.equal(
464
+ m[1],
465
+ r.forkSha,
466
+ "must be the fork point — base.sha drifts forward once main advances, which would baseline against commits the PR never saw",
467
+ );
468
+ });
469
+
470
+ for (const mode of ["merge_group", "push"]) {
471
+ test(`${mode} baselines on the derived base directly (already the fork point)`, exec, () => {
472
+ const r = runBaseline({ mode, base: "cafebabe", head: "deadbeef" });
473
+ assert.equal(r.status, 0, r.stderr);
474
+ assert.match(r.outputs, /^baseline-ref=cafebabe$/m);
475
+ });
476
+ }
477
+
478
+ test("a newline-bearing base is rejected before it reaches $GITHUB_OUTPUT", exec, () => {
479
+ const r = runBaseline({ mode: "push", base: "cafebabe\nMALICIOUS=pwned", head: "deadbeef" });
480
+ assert.notEqual(r.status, 0, "a multiline base must fail the step, not export");
481
+ assert.doesNotMatch(r.outputs, /MALICIOUS/, "the injected line must never reach $GITHUB_OUTPUT");
482
+ assert.doesNotMatch(r.outputs, /baseline-ref=/, "no partial export on a rejected base");
483
+ });
484
+
485
+ test("a carriage-return-bearing base is rejected too", exec, () => {
486
+ const r = runBaseline({ mode: "push", base: "cafebabe\rMALICIOUS=pwned", head: "deadbeef" });
487
+ assert.notEqual(r.status, 0);
488
+ assert.doesNotMatch(r.outputs, /MALICIOUS/);
489
+ });
@@ -16,17 +16,25 @@ import {
16
16
  classify,
17
17
  findingsDigest,
18
18
  renderSummary,
19
+ normalizeSource,
20
+ rowKey,
21
+ buildBaselineSet,
19
22
  OsvGateError,
20
23
  } from "../.github/actions/osv-scan/osv-report-gate.mjs";
21
24
 
22
- // Build an OSV-scanner-shaped report for one grouped advisory.
23
- const reportWith = (groups) => ({
25
+ // Build an OSV-scanner-shaped report for one grouped advisory. `published`
26
+ // rides on the per-vulnerability entries, mirroring the real OSV schema —
27
+ // group rows carry the severity, never the date.
28
+ const reportWith = (groups, { sourcePath = "pnpm-lock.yaml" } = {}) => ({
24
29
  results: [
25
30
  {
26
- source: { path: "pnpm-lock.yaml" },
31
+ source: { path: sourcePath },
27
32
  packages: groups.map((g) => ({
28
33
  package: { name: g.name, version: g.version || "1.0.0", ecosystem: g.ecosystem || "npm" },
29
34
  groups: [{ ids: g.ids, max_severity: g.score }],
35
+ ...(g.published
36
+ ? { vulnerabilities: g.ids.map((id) => ({ id, published: g.published })) }
37
+ : {}),
30
38
  })),
31
39
  },
32
40
  ],
@@ -225,3 +233,307 @@ test("renderSummary reports a clean scan and a blocked scan distinctly", () => {
225
233
  assert.match(blocked.join("\n"), /❌ BLOCKED/);
226
234
  assert.match(blocked.join("\n"), /GHSA-x/);
227
235
  });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Diff-aware baseline + publish grace window (Story #325)
239
+ //
240
+ // The failure these close: a newly-published advisory against a dependency
241
+ // that has been on `main` for weeks reds EVERY open PR simultaneously — the
242
+ // postcss GHSA-r28c-9q8g-f849 / brace-expansion GHSA-mh99-v99m-4gvg incidents
243
+ // on the swarm-os consumer. The gate must tell "this PR introduced it" from
244
+ // "this was already here", without ever letting a real PR-introduced advisory
245
+ // through and without neutering the operator-authored `revisitBy` re-gate.
246
+ // ---------------------------------------------------------------------------
247
+
248
+ // A baseline built from the SAME tree, as the merge-base worktree scan yields.
249
+ const baselineOf = (groups, opts) => buildBaselineSet(collectRows(reportWith(groups, opts), opts));
250
+
251
+ test("a finding already present at the baseline is demoted, not blocked", () => {
252
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }];
253
+ const v = classify(collectRows(reportWith(groups)), {
254
+ failOn: "high",
255
+ baseline: baselineOf(groups),
256
+ });
257
+ assert.equal(v.blocking.length, 0);
258
+ assert.equal(v.preexisting.length, 1);
259
+ assert.equal(v.preexisting[0].ids[0], "GHSA-r28c-9q8g-f849");
260
+ });
261
+
262
+ test("a head-only finding is PR-introduced and still blocks", () => {
263
+ const v = classify(
264
+ collectRows(
265
+ reportWith([
266
+ { name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
267
+ { name: "brand-new-dep", ids: ["GHSA-new"], score: "8.2" },
268
+ ]),
269
+ ),
270
+ {
271
+ failOn: "high",
272
+ baseline: baselineOf([{ name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" }]),
273
+ },
274
+ );
275
+ assert.equal(v.blocking.length, 1);
276
+ assert.equal(v.blocking[0].name, "brand-new-dep");
277
+ assert.equal(v.preexisting.length, 1);
278
+ assert.equal(v.preexisting[0].name, "postcss");
279
+ });
280
+
281
+ test("bumping an advisory-bearing dep to another vulnerable version still blocks", () => {
282
+ // Same package, same advisory id — only the version moved. The baseline key
283
+ // carries @version precisely so this cannot inherit the demotion.
284
+ const v = classify(
285
+ collectRows(reportWith([{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.4.0" }])),
286
+ {
287
+ failOn: "high",
288
+ baseline: baselineOf([
289
+ { name: "postcss", ids: ["GHSA-r28c"], score: "7.5", version: "8.3.0" },
290
+ ]),
291
+ },
292
+ );
293
+ assert.equal(v.blocking.length, 1);
294
+ assert.equal(v.blocking[0].version, "8.4.0");
295
+ assert.equal(v.preexisting.length, 0);
296
+ });
297
+
298
+ test("normalizeSource reduces worktree-rooted and workspace-rooted paths alike", () => {
299
+ assert.equal(normalizeSource("/tmp/osv-baseline/pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
300
+ assert.equal(normalizeSource("/home/runner/work/repo/pnpm-lock.yaml", "/home/runner/work/repo"), "pnpm-lock.yaml");
301
+ assert.equal(normalizeSource("./pnpm-lock.yaml", "/tmp/osv-baseline"), "pnpm-lock.yaml");
302
+ assert.equal(normalizeSource("pnpm-lock.yaml", ""), "pnpm-lock.yaml");
303
+ // A trailing slash on the root must not leave a leading slash behind.
304
+ assert.equal(normalizeSource("/tmp/base/apps/web/pnpm-lock.yaml", "/tmp/base/"), "apps/web/pnpm-lock.yaml");
305
+ });
306
+
307
+ test("the baseline matches across differing scan roots", () => {
308
+ // The head scan runs in the workspace; the baseline scan runs in a git
309
+ // worktree at a different absolute path. Un-normalized, every head finding
310
+ // would look head-only and the diff-aware gate would block everything.
311
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
312
+ const headRows = collectRows(
313
+ reportWith(groups, { sourcePath: "/home/runner/work/repo/pnpm-lock.yaml" }),
314
+ { scanRoot: "/home/runner/work/repo" },
315
+ );
316
+ const baseRows = collectRows(reportWith(groups, { sourcePath: "/tmp/osv-baseline/pnpm-lock.yaml" }), {
317
+ scanRoot: "/tmp/osv-baseline",
318
+ });
319
+ assert.equal(rowKey(headRows[0]), rowKey(baseRows[0]));
320
+
321
+ const v = classify(headRows, { failOn: "high", baseline: buildBaselineSet(baseRows) });
322
+ assert.equal(v.blocking.length, 0);
323
+ assert.equal(v.preexisting.length, 1);
324
+ });
325
+
326
+ test("an EXPIRED suppression outranks BOTH demotions and still re-gates", () => {
327
+ // The load-bearing precedence rule. An expired suppression is by
328
+ // construction on a pre-existing dependency, so letting either demotion
329
+ // apply would neuter `revisitBy` on PRs entirely.
330
+ //
331
+ // The fixture must be genuinely eligible for both demotions or this test
332
+ // passes vacuously: `published` is what puts the row inside the window, and
333
+ // without it `grace` is empty no matter how the precedence chain is wired.
334
+ const groups = [
335
+ {
336
+ name: "brace-expansion",
337
+ ids: ["GHSA-mh99-v99m-4gvg"],
338
+ score: "7.5",
339
+ published: "2026-07-22T00:00:00Z",
340
+ },
341
+ ];
342
+ const rows = collectRows(reportWith(groups));
343
+ const baseline = baselineOf(groups);
344
+ const opts = { failOn: "high", today: "2026-07-24", baseline, graceDays: 30 };
345
+
346
+ // Guard the fixture: with NO allow-list entry this row is demoted. If this
347
+ // ever stops holding, the assertion below has nothing left to prove.
348
+ const unsuppressed = classify(rows, opts);
349
+ assert.equal(unsuppressed.blocking.length, 0, "fixture must be demotable without an allow-list");
350
+ assert.equal(unsuppressed.preexisting.length, 1, "fixture must be baseline-eligible");
351
+ assert.notEqual(rows[0].published, null, "fixture must carry a publish date to be grace-eligible");
352
+
353
+ const v = classify(rows, {
354
+ ...opts,
355
+ allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "stale triage", revisitBy: "2026-01-01" }],
356
+ });
357
+ assert.equal(v.blocking.length, 1);
358
+ assert.equal(v.expired.length, 1);
359
+ assert.equal(v.preexisting.length, 0);
360
+ assert.equal(v.grace.length, 0);
361
+ });
362
+
363
+ test("an UNEXPIRED suppression stays suppressed and is not double-counted", () => {
364
+ const groups = [{ name: "brace-expansion", ids: ["GHSA-mh99-v99m-4gvg"], score: "7.5" }];
365
+ const v = classify(collectRows(reportWith(groups)), {
366
+ failOn: "high",
367
+ allowlist: [{ id: "GHSA-mh99-v99m-4gvg", reason: "not reachable", revisitBy: "2099-12-31" }],
368
+ today: "2026-07-24",
369
+ baseline: baselineOf(groups),
370
+ });
371
+ assert.equal(v.suppressed.length, 1);
372
+ assert.equal(v.blocking.length, 0);
373
+ assert.equal(v.preexisting.length, 0);
374
+ });
375
+
376
+ test("the grace window demotes a recent advisory and not an old one", () => {
377
+ const rows = collectRows(
378
+ reportWith([
379
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-21T00:00:00Z" },
380
+ { name: "stale", ids: ["GHSA-stale"], score: "8.0", published: "2026-06-24T00:00:00Z" },
381
+ ]),
382
+ );
383
+ const v = classify(rows, { failOn: "high", graceDays: 7, today: "2026-07-24" });
384
+ assert.deepEqual(
385
+ v.grace.map((r) => r.name),
386
+ ["fresh"],
387
+ );
388
+ assert.deepEqual(
389
+ v.blocking.map((r) => r.name),
390
+ ["stale"],
391
+ );
392
+ });
393
+
394
+ test("the grace window fails closed when the publish date is unresolvable", () => {
395
+ // No `published` on the vulnerability entries at all…
396
+ const undated = classify(collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0" }])), {
397
+ failOn: "high",
398
+ graceDays: 7,
399
+ today: "2026-07-24",
400
+ });
401
+ assert.equal(undated.blocking.length, 1);
402
+ assert.equal(undated.grace.length, 0);
403
+
404
+ // …and a present-but-garbage date is equally not a free pass.
405
+ const garbage = classify(
406
+ collectRows(reportWith([{ name: "p", ids: ["GHSA-x"], score: "8.0", published: "not-a-date" }])),
407
+ { failOn: "high", graceDays: 7, today: "2026-07-24" },
408
+ );
409
+ assert.equal(garbage.blocking.length, 1);
410
+ assert.equal(garbage.grace.length, 0);
411
+ });
412
+
413
+ test("the grace window is off at the default of 0", () => {
414
+ const rows = collectRows(
415
+ reportWith([
416
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
417
+ ]),
418
+ );
419
+ const v = classify(rows, { failOn: "high", today: "2026-07-24" });
420
+ assert.equal(v.graceDays, 0);
421
+ assert.equal(v.grace.length, 0);
422
+ assert.equal(v.blocking.length, 1);
423
+ });
424
+
425
+ test("collectRows takes the EARLIEST published date across a group's aliased ids", () => {
426
+ const report = {
427
+ results: [
428
+ {
429
+ source: { path: "pnpm-lock.yaml" },
430
+ packages: [
431
+ {
432
+ package: { name: "p", version: "1.0.0", ecosystem: "npm" },
433
+ groups: [{ ids: ["GHSA-a", "CVE-b"], max_severity: "8.0" }],
434
+ vulnerabilities: [
435
+ { id: "GHSA-a", published: "2026-07-20T00:00:00Z" },
436
+ { id: "CVE-b", published: "2026-05-01T00:00:00Z" },
437
+ ],
438
+ },
439
+ ],
440
+ },
441
+ ],
442
+ };
443
+ assert.equal(collectRows(report)[0].published, "2026-05-01T00:00:00Z");
444
+
445
+ // …and the earliest date is what the window is judged against, so an alias
446
+ // published long ago cannot be laundered into the window by a fresh alias.
447
+ const v = classify(collectRows(report), { failOn: "high", graceDays: 7, today: "2026-07-24" });
448
+ assert.equal(v.blocking.length, 1);
449
+ assert.equal(v.grace.length, 0);
450
+ });
451
+
452
+ test("with no baseline and no grace window the partition is unchanged", () => {
453
+ // The backward-compatibility contract: default inputs must classify exactly
454
+ // as they did before diff-awareness existed.
455
+ const rows = collectRows(
456
+ reportWith([
457
+ { name: "crit", ids: ["C"], score: "9.9" },
458
+ { name: "hi", ids: ["H"], score: "7.1" },
459
+ { name: "med", ids: ["M"], score: "4.5" },
460
+ { name: "sup", ids: ["S"], score: "8.0" },
461
+ ]),
462
+ );
463
+ const allowlist = [{ id: "S", reason: "triaged", revisitBy: "2099-12-31" }];
464
+ const v = classify(rows, { failOn: "high", allowlist, today: "2026-07-24" });
465
+
466
+ assert.deepEqual(
467
+ v.blocking.map((r) => r.name),
468
+ ["crit", "hi"],
469
+ );
470
+ assert.deepEqual(
471
+ v.warning.map((r) => r.name),
472
+ ["med"],
473
+ );
474
+ assert.equal(v.suppressed.length, 1);
475
+ assert.equal(v.expired.length, 0);
476
+ // The new buckets exist but are inert.
477
+ assert.deepEqual(v.preexisting, []);
478
+ assert.deepEqual(v.grace, []);
479
+ assert.equal(v.baselineApplied, false);
480
+ });
481
+
482
+ test("findingsDigest ignores preexisting and grace rows entirely", () => {
483
+ // The scheduled tracking issue keys off this digest; a PR-side demotion must
484
+ // not rewrite the issue body or make an unchanged advisory set look new.
485
+ const groups = [
486
+ { name: "p1", ids: ["GHSA-a"], score: "7.5" },
487
+ { name: "p2", ids: ["GHSA-b"], score: "9.1", published: "2026-07-23T00:00:00Z" },
488
+ ];
489
+ const plain = classify(collectRows(reportWith(groups)), { failOn: "high", today: "2026-07-24" });
490
+ const demoted = classify(collectRows(reportWith(groups)), {
491
+ failOn: "high",
492
+ today: "2026-07-24",
493
+ baseline: baselineOf([groups[0]]),
494
+ graceDays: 7,
495
+ });
496
+
497
+ assert.equal(demoted.blocking.length, 0);
498
+ assert.equal(demoted.preexisting.length, 1);
499
+ assert.equal(demoted.grace.length, 1);
500
+ assert.equal(findingsDigest(demoted.blocking), findingsDigest([]));
501
+ assert.notEqual(findingsDigest(plain.blocking), findingsDigest(demoted.blocking));
502
+ });
503
+
504
+ test("renderSummary names both demotion buckets with one table row each", () => {
505
+ const groups = [
506
+ { name: "postcss", ids: ["GHSA-r28c-9q8g-f849"], score: "7.5" },
507
+ { name: "fresh", ids: ["GHSA-fresh"], score: "8.0", published: "2026-07-23T00:00:00Z" },
508
+ ];
509
+ const v = classify(collectRows(reportWith(groups)), {
510
+ failOn: "high",
511
+ today: "2026-07-24",
512
+ baseline: baselineOf([groups[0]]),
513
+ graceDays: 7,
514
+ });
515
+ const out = renderSummary(v).join("\n");
516
+
517
+ assert.match(out, /Pre-existing on the base branch — not introduced by this PR \(1\)/);
518
+ assert.match(out, /Within the publish grace window \(1\)/);
519
+ assert.match(out, /GHSA-r28c-9q8g-f849/);
520
+ assert.match(out, /GHSA-fresh/);
521
+ // Demotions are not a pass-with-nothing-to-say: the verdict line must not
522
+ // claim BLOCKED when everything was demoted.
523
+ assert.doesNotMatch(out, /❌ BLOCKED/);
524
+ // The grace table carries the publish date that justified the demotion.
525
+ assert.match(out, /\| 2026-07-23T00:00:00Z \|/);
526
+ });
527
+
528
+ test("a fully-demoted verdict set is not rendered as a clean scan", () => {
529
+ // Demoted findings still have to be visible — silently reporting "no known
530
+ // advisories" would hide exactly what the diff-aware mode chose not to gate.
531
+ const groups = [{ name: "postcss", ids: ["GHSA-r28c"], score: "7.5" }];
532
+ const v = classify(collectRows(reportWith(groups)), {
533
+ failOn: "high",
534
+ baseline: baselineOf(groups),
535
+ });
536
+ const out = renderSummary(v).join("\n");
537
+ assert.doesNotMatch(out, /no known advisories/);
538
+ assert.match(out, /GHSA-r28c/);
539
+ });
@@ -1,5 +1,6 @@
1
1
  {
2
- "$comment": "Data-driven expected roster for scripts/check-runner-health.mjs (Story #258). Each entry is one repo whose self-hosted runner fleet the scheduled runner-fleet-health.yml workflow monitors. Adding/removing a runner needs only an edit here — the checker reads GET /repos/{owner}/{repo}/actions/runners and compares live status against `expectedCount` + `labels`. All nine runners (including Beestera/swarm-os's three) are co-resident on one operator Mac (2026-07-03 runner audit, repo-ops matrix §1a); if that host sleeps, reboots, fills its disk, or a launchd service dies, every listed repo's CI silently queues with no alert until this monitor catches it.",
2
+ "$comment": "Data-driven expected roster for scripts/check-runner-health.mjs (Story #258). Each entry is one repo whose self-hosted runner fleet the scheduled runner-fleet-health.yml workflow monitors. Adding/removing a runner needs only an edit here — the checker reads GET /repos/{owner}/{repo}/actions/runners and compares live status against `expectedCount` + `labels`. The co-resident operator Mac now carries 7 domio + 5 athportal monitored runners (plus 17 beestera/swarm-os installs that are deliberately NOT listed see `$comment_swarm_os`); if that host sleeps, reboots, fills its disk, or a launchd service dies, every listed repo's CI silently queues with no alert until this monitor catches it. Keep these counts in lockstep with the live fleets: the maintenance contract lives in templates/runbooks/runner-provisioning.md.",
3
+ "$comment_expectedCount": "Semantics: warn-below (over-provisioning is fine). The checker computes shortfall = max(0, expectedCount - matchingOnline) and only alarms when fewer online runners match the labels than expected — adding runners above expectedCount never trips the alarm, so a deliberate scale-up needs no immediate roster edit (though you should still bump the count to keep the shortfall floor meaningful). Removing runners on purpose (scale-down) DOES require lowering expectedCount here, otherwise the monitor will correctly flag the missing runners as a shortfall.",
3
4
  "$comment_swarm_os": "Beestera/swarm-os is deliberately NOT listed: no dsj1984-owned PAT can read another org's runner API (fine-grained PATs are bound to one resource owner; the Beestera org rejects classic PATs), so its row would permanently false-positive as 0/3 degraded. Host-level coverage is retained regardless — its runners share the Mac with the rows below, so a wedged host still trips domio/athportal. What is NOT covered: swarm-os's individual launchd services dying while the host stays healthy, and its stale-queue check. Re-add the entry if a Beestera-owned credential (fine-grained PAT or GitHub App) plus per-repo token support ever lands.",
4
5
  "$comment_staleQueuedMinutes": "A queued/waiting workflow run older than this many minutes with no online runner matching its labels is flagged as a queue-staleness signal (optional per-repo override via `staleQueuedMinutes`).",
5
6
  "defaultStaleQueuedMinutes": 20,
@@ -7,13 +8,13 @@
7
8
  {
8
9
  "name": "domio",
9
10
  "repo": "dsj1984/domio",
10
- "expectedCount": 3,
11
+ "expectedCount": 7,
11
12
  "labels": ["self-hosted", "macOS", "ARM64", "domio-runner"]
12
13
  },
13
14
  {
14
15
  "name": "athportal",
15
16
  "repo": "dsj1984/athportal",
16
- "expectedCount": 3,
17
+ "expectedCount": 5,
17
18
  "labels": ["self-hosted", "macOS", "ARM64", "athportal-runner"]
18
19
  }
19
20
  ]
@@ -66,6 +66,28 @@ shasum -a 256 actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
66
66
  tar xzf actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
67
67
  ```
68
68
 
69
+ ## ⚠️ Read second: keep the health-monitor roster in lockstep
70
+
71
+ Any change to a fleet's size — **adding or removing a runner** — **MUST** be
72
+ accompanied by an update to that repo's `expectedCount` in
73
+ [`scripts/runner-fleet-consumers.json`](../../scripts/runner-fleet-consumers.json).
74
+ That file is the roster the scheduled `runner-fleet-health.yml` monitor
75
+ (`scripts/check-runner-health.mjs`) compares the live fleet against.
76
+
77
+ - **Scale up (add a runner):** bump `expectedCount` so the shortfall floor
78
+ keeps pace. Over-provisioning does not trip the alarm on its own (the
79
+ monitor is **warn-below**: `shortfall = max(0, expectedCount - matchingOnline)`),
80
+ but leaving the count stale hides a later drop back down to the old value.
81
+ - **Scale down (remove a runner):** lower `expectedCount` in the same change,
82
+ otherwise the monitor will correctly flag the now-missing runner(s) as a
83
+ shortfall and page the operator for a deliberate downsizing.
84
+
85
+ If the roster and the live fleet drift apart, the monitor either false-alarms
86
+ (count too high) or goes silent on real outages above the stale threshold
87
+ (count too low) — the exact mis-calibration this contract exists to prevent.
88
+ See also [`runner-fleet-health.md`](runner-fleet-health.md) for the operator
89
+ response when the monitor does fire.
90
+
69
91
  ## 2. Register with `config.sh` (repo-level)
70
92
 
71
93
  Registration is **repo-level** (the fleet's standing model), not org-level.
@@ -179,7 +201,10 @@ The runner loads `.env` at service start — after any `.env` change, restart:
179
201
  (§5). Mint the removal token via
180
202
  `gh api -X POST repos/<OWNER>/<REPO>/actions/runners/remove-token --jq .token`.
181
203
  - **Decommission.** Same removal sequence, then delete `<RUNNER_DIR>`.
182
- Confirm the runner disappeared from *Settings → Actions → Runners*.
204
+ Confirm the runner disappeared from *Settings → Actions → Runners*, **and**
205
+ lower the repo's `expectedCount` in `scripts/runner-fleet-consumers.json`
206
+ (see the roster-lockstep callout above) so the health monitor does not flag
207
+ the intentional removal as a shortfall.
183
208
 
184
209
  ## Project-Specific Notes
185
210