Files
2026-08-16 22:51:30 +09:00

160 lines
5.5 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { resolvePython } = require("./ci-run");
const repoRoot = path.resolve(__dirname, "..");
const workflowDir = path.join(repoRoot, ".github", "workflows");
// Reviewed action runtime coverage is intentionally curated, not exhaustive:
// these pins are the Node 20 deprecation migration set verified from each
// listed action ref's GitHub `action.yml` metadata on the review date below.
const actionRuntimeGuardScope = {
coverage: "curated migration set; not exhaustive for every external workflow action",
reviewedAt: "2026-05-22",
sourceUrls: new Map([
["actions/checkout", "https://github.com/actions/checkout/blob/v5/action.yml"],
["actions/setup-node", "https://github.com/actions/setup-node/blob/v5/action.yml"],
[
"google-github-actions/setup-gcloud",
"https://github.com/google-github-actions/setup-gcloud/blob/v3/action.yml",
],
[
"googleapis/release-please-action",
"https://github.com/googleapis/release-please-action/blob/v5/action.yml",
],
]),
};
const expectedNode24ActionPins = new Map([
["actions/checkout", "v5"],
["actions/setup-node", "v5"],
["google-github-actions/setup-gcloud", "v3"],
["googleapis/release-please-action", "v5"],
]);
const knownNode20ActionPins = new Map([
["actions/checkout", new Set(["v4"])],
["actions/setup-node", new Set(["v4"])],
["google-github-actions/setup-gcloud", new Set(["v2"])],
["googleapis/release-please-action", new Set(["v4"])],
]);
const usesLinePattern = /^\s*(?:-\s*)?uses:\s*['"]?([^'"\s#]+)['"]?(?:\s*#.*)?\s*$/gm;
function readWorkflow(name) {
return fs.readFileSync(path.join(workflowDir, name), "utf8");
}
function listWorkflowFiles() {
return fs
.readdirSync(workflowDir)
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
.sort();
}
function usesFromWorkflowBody(name, body) {
return [...body.matchAll(usesLinePattern)].map((match) => {
const spec = match[1];
const at = spec.lastIndexOf("@");
assert.notEqual(at, -1, `${name} action use must be pinned with @ref: ${spec}`);
return { file: name, action: spec.slice(0, at), ref: spec.slice(at + 1), spec };
});
}
function workflowUses() {
return listWorkflowFiles().flatMap((name) => {
const body = readWorkflow(name);
return usesFromWorkflowBody(name, body);
});
}
test("workflow action extractor includes uses lines with inline comments", () => {
const uses = usesFromWorkflowBody(
"inline-comment.yml",
[
"jobs:",
" validate:",
" steps:",
" - uses: actions/checkout@v4 # intentionally stale fixture",
" - uses: 'actions/setup-node@v5' # quoted fixture",
' - uses: "google-github-actions/setup-gcloud@v3"',
].join("\n"),
);
assert.deepEqual(
uses.map((use) => use.spec),
["actions/checkout@v4", "actions/setup-node@v5", "google-github-actions/setup-gcloud@v3"],
);
});
test("workflow action runtime guard documents its reviewed coverage scope", () => {
assert.match(actionRuntimeGuardScope.coverage, /curated/i);
assert.match(actionRuntimeGuardScope.coverage, /not exhaustive/i);
assert.match(actionRuntimeGuardScope.reviewedAt, /^\d{4}-\d{2}-\d{2}$/);
for (const action of expectedNode24ActionPins.keys()) {
assert.match(actionRuntimeGuardScope.sourceUrls.get(action), /^https:\/\/github\.com\/.+\/action\.yml$/);
}
});
test("workflow action pins avoid reviewed Node 20 action majors", () => {
for (const use of workflowUses()) {
const bannedRefs = knownNode20ActionPins.get(use.action);
if (!bannedRefs) continue;
assert.ok(
!bannedRefs.has(use.ref),
`${use.file} must not use ${use.spec}; this action major is known to run on Node 20`,
);
}
});
test("workflow action pins use the selected Node 24 runtime majors when present", () => {
const uses = workflowUses();
for (const [action, expectedRef] of expectedNode24ActionPins) {
const refs = uses.filter((use) => use.action === action).map((use) => `${use.file}:${use.ref}`);
if (refs.length === 0) continue;
assert.deepEqual(
[...new Set(refs.map((entry) => entry.split(":").at(-1)))],
[expectedRef],
`${action} should be pinned to ${expectedRef} everywhere it appears (${refs.join(", ")})`,
);
}
});
test("store longevity mirror workflow uses an import-safe module entrypoint", () => {
const workflow = readWorkflow("store-longevity-r2-mirror.yml");
assert.match(workflow, /\bpython3 -m scripts\.store_longevity_mirror\b/);
const result = spawnSync(
resolvePython(),
["-m", "scripts.store_longevity_mirror", "--help"],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /--manifest-url/);
});
test("store longevity mirror uses R2 S3 multipart for the oversized ZIP", () => {
const workflow = readWorkflow("store-longevity-r2-mirror.yml");
assert.match(workflow, /AWS_ACCESS_KEY_ID: \$\{\{ secrets\.R2_ACCESS_KEY_ID \}\}/);
assert.match(workflow, /AWS_SECRET_ACCESS_KEY: \$\{\{ secrets\.R2_SECRET_ACCESS_KEY \}\}/);
assert.match(workflow, /\baws s3 cp\b/);
assert.match(
workflow,
/https:\/\/\$CLOUDFLARE_ACCOUNT_ID\.r2\.cloudflarestorage\.com/,
);
assert.doesNotMatch(
workflow,
/wrangler@[\w.-]+ r2 object put \\\n\s+"\$R2_BUCKET\/\$\{\{ steps\.prepare\.outputs\.object_key \}\}"/,
);
});