mandrel-platform 0.29.1 → 1.0.1
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 +1 -1
- package/scripts/check-cancelled-provenance.test.mjs +494 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/check-osv-scan-mode.test.mjs +489 -0
- package/scripts/osv-report-gate.test.mjs +315 -3
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
|
@@ -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
|
+
});
|