|
1 | 1 | import assert from "node:assert/strict"; |
| 2 | +import { existsSync } from "node:fs"; |
2 | 3 | import { glob, readFile } from "node:fs/promises"; |
| 4 | +import path from "node:path"; |
3 | 5 | import { describe, test } from "node:test"; |
4 | 6 |
|
5 | 7 | import { type Document, isMap, isSeq, parseDocument, type YAMLMap } from "yaml"; |
6 | 8 |
|
7 | 9 | import { planRelease, releaseOutputs } from "./plan-release.ts"; |
8 | 10 | import { planSubjects } from "./scan-main-images.ts"; |
9 | | -import { planUpstreamSubjects } from "./scan-upstream-images.ts"; |
| 11 | +import { PLATFORMS, planUpstreamSubjects } from "./scan-upstream-images.ts"; |
10 | 12 | import { validateManifest } from "./verify-release-evidence.ts"; |
11 | 13 |
|
12 | 14 | function job(source: string, name: string): string { |
@@ -40,6 +42,30 @@ function escapeRegExp(value: string): string { |
40 | 42 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
41 | 43 | } |
42 | 44 |
|
| 45 | +/** |
| 46 | + * Every repository script a given entry point loads, transitively — its static relative imports, |
| 47 | + * plus any sibling script it names as a string, which is how the scanners reach the policy |
| 48 | + * evaluator (they spawn it rather than importing it, so that its exit status is the verdict). |
| 49 | + */ |
| 50 | +async function importClosure(entry: string): Promise<string[]> { |
| 51 | + const seen = new Set<string>(); |
| 52 | + const queue = [entry]; |
| 53 | + while (queue.length > 0) { |
| 54 | + const file = queue.shift(); |
| 55 | + if (file === undefined || seen.has(file)) continue; |
| 56 | + seen.add(file); |
| 57 | + const source = await readFile(file, "utf8"); |
| 58 | + const directory = path.dirname(file); |
| 59 | + for (const [, specifier] of source.matchAll(/from\s+"(\.[^"]+\.ts)"/g)) |
| 60 | + queue.push(path.normalize(path.join(directory, specifier ?? ""))); |
| 61 | + for (const [, name] of source.matchAll(/"([\w.-]+\.ts)"/g)) { |
| 62 | + const candidate = path.normalize(path.join(directory, "..", name ?? "")); |
| 63 | + if (candidate.startsWith("scripts/") && existsSync(candidate)) queue.push(candidate); |
| 64 | + } |
| 65 | + } |
| 66 | + return [...seen].toSorted(); |
| 67 | +} |
| 68 | + |
43 | 69 | /** The `with` map of the first step in a job whose `uses` starts with `action`. */ |
44 | 70 | function step(workflow: Document, jobPath: string[], action: string): YAMLMap { |
45 | 71 | const steps = workflow.getIn([...jobPath, "steps"]); |
@@ -445,10 +471,28 @@ void describe("CI contract", () => { |
445 | 471 | /run: node scripts\/scan-upstream-images\.ts reports --report-only\n/, |
446 | 472 | ); |
447 | 473 | const detection = job(await readFile(".github/workflows/cicd.yml", "utf8"), "detect-changes"); |
| 474 | + const filter = pathFilter(detection, "release-images"); |
448 | 475 | assert.match( |
449 | | - pathFilter(detection, "release-images"), |
| 476 | + filter, |
450 | 477 | /- 'security\/release-images\.json'[\s\S]*- 'security\/vulnerability-policy\.json'/, |
451 | 478 | ); |
| 479 | + // The trigger is derived, not trusted: a filter that lists the entry point but not the module |
| 480 | + // it parses JSON with skips the gate on the pull request that breaks the parser. Re-walk the |
| 481 | + // imports and require every file the gate actually loads to appear. |
| 482 | + for (const file of await importClosure("scripts/scan-upstream-images.ts")) |
| 483 | + assert.ok( |
| 484 | + filter.includes(`- '${file}'`), |
| 485 | + `release-images must trigger on ${file}, which the upstream scan loads`, |
| 486 | + ); |
| 487 | + }); |
| 488 | + |
| 489 | + void test("scans both released platforms before the release, not just linux/amd64", async () => { |
| 490 | + // The policy match key is `image | platform | vulnerability | package | installedVersion`, so |
| 491 | + // a single-platform pre-release scan leaves an arm64-only finding — or an arm64 exception |
| 492 | + // nobody wrote — to be discovered by the release gate, which is the failure this PR removes. |
| 493 | + assert.deepEqual([...PLATFORMS], ["linux/amd64", "linux/arm64"]); |
| 494 | + const source = await readFile("scripts/scan-upstream-images.ts", "utf8"); |
| 495 | + assert.match(source, /for \(const platform of PLATFORMS\)/); |
452 | 496 | }); |
453 | 497 |
|
454 | 498 | void test("rescans main's images weekly and reports drift to an issue, not a status", async () => { |
|
0 commit comments