756 lines
27 KiB
TypeScript
756 lines
27 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
chmod,
|
|
copyFile,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { delimiter, join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const repository = process.cwd();
|
|
const actionPath = join(repository, ".github/actions/npm-cache/action.yml");
|
|
const readAction = async () =>
|
|
z
|
|
.object({
|
|
inputs: z
|
|
.record(
|
|
z.string(),
|
|
z.object({ default: z.string().optional() }).passthrough(),
|
|
)
|
|
.optional(),
|
|
outputs: z.record(z.string(), z.object({ value: z.string() })),
|
|
runs: z.object({
|
|
steps: z.array(
|
|
z.object({
|
|
id: z.string().optional(),
|
|
if: z.string().optional(),
|
|
uses: z.string().optional(),
|
|
with: z.record(z.string(), z.string()).optional(),
|
|
run: z.string().optional(),
|
|
env: z.record(z.string(), z.string()).optional(),
|
|
}),
|
|
),
|
|
}),
|
|
})
|
|
.parse(parse(await readFile(actionPath, "utf8")));
|
|
|
|
const readWorkflows = async () => {
|
|
const directory = join(repository, ".github/workflows");
|
|
const files = (await readdir(directory)).filter((file) =>
|
|
/\.ya?ml$/u.test(file),
|
|
);
|
|
return Promise.all(
|
|
files.map(async (file) => ({
|
|
file,
|
|
workflow: z
|
|
.object({
|
|
jobs: z
|
|
.record(
|
|
z.string(),
|
|
z
|
|
.object({
|
|
steps: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
})
|
|
.passthrough(),
|
|
)
|
|
.optional(),
|
|
})
|
|
.parse(parse(await readFile(join(directory, file), "utf8"))),
|
|
})),
|
|
);
|
|
};
|
|
|
|
function aggregateHashFilesContent(contents: Buffer[]) {
|
|
const aggregate = createHash("sha256");
|
|
for (const content of contents)
|
|
aggregate.update(createHash("sha256").update(content).digest());
|
|
return aggregate.digest("hex");
|
|
}
|
|
|
|
async function verifyPrefixedHashFiles(
|
|
retentionEnv: Record<string, string>,
|
|
runnerWorkspace: string,
|
|
legacyRoot: string,
|
|
) {
|
|
const keyExpressions = [
|
|
retentionEnv.CACHE_KEEP_KEY,
|
|
retentionEnv.CACHE_DEEP_KEEP_KEY,
|
|
retentionEnv.CACHE_KNIP_KEEP_KEY,
|
|
];
|
|
let targetHash: string | undefined;
|
|
for (const expression of keyExpressions) {
|
|
if (expression === undefined)
|
|
throw new Error("Missing retention cache key");
|
|
const paths = expression.match(/hashFiles\(([^)]*)\)/u)?.[1];
|
|
expect(paths).toBeDefined();
|
|
const targetPaths = paths!.split(", ").map((path) => path.slice(1, -1));
|
|
expect(
|
|
targetPaths.every((path) => path.startsWith("triggering-source/")),
|
|
).toBe(true);
|
|
const fileNames = targetPaths.map((path) =>
|
|
path.slice("triggering-source/".length),
|
|
);
|
|
const nestedHash = aggregateHashFilesContent(
|
|
await Promise.all(
|
|
targetPaths.map((path) => readFile(join(runnerWorkspace, path))),
|
|
),
|
|
);
|
|
const flatHash = aggregateHashFilesContent(
|
|
await Promise.all(
|
|
fileNames.map((name) => readFile(join(legacyRoot, name))),
|
|
),
|
|
);
|
|
expect(nestedHash).toBe(flatHash);
|
|
if (expression === retentionEnv.CACHE_KEEP_KEY) targetHash = nestedHash;
|
|
}
|
|
expect(targetHash).toBeDefined();
|
|
return targetHash!;
|
|
}
|
|
|
|
async function exerciseTrustedRetentionWithLegacyTarget(
|
|
retentionRun: unknown,
|
|
retentionEnv: Record<string, string>,
|
|
) {
|
|
const runnerWorkspace = await createTestTempDirectory(
|
|
"rea-ci-retention-trusted-checkout-",
|
|
);
|
|
const legacyTarget = join(runnerWorkspace, "triggering-source");
|
|
const legacyRoot = join(runnerWorkspace, "legacy-root");
|
|
const trustedSource = join(runnerWorkspace, "trusted-retention");
|
|
const inputNames = [
|
|
".nvmrc",
|
|
".npmrc",
|
|
"package-lock.json",
|
|
"knip.json",
|
|
"tsconfig.json",
|
|
"package.json",
|
|
];
|
|
const targetSha = "0123456789abcdef0123456789abcdef01234567";
|
|
await mkdir(legacyTarget, { recursive: true });
|
|
await mkdir(legacyRoot, { recursive: true });
|
|
for (const name of inputNames) {
|
|
const contents = `target-source:${name}\n`;
|
|
await writeFile(join(legacyTarget, name), contents);
|
|
await writeFile(join(legacyRoot, name), contents);
|
|
}
|
|
const trustedHelperPath = join(
|
|
trustedSource,
|
|
"scripts/ci/cache-retention.mjs",
|
|
);
|
|
await mkdir(join(trustedSource, "scripts/ci"), { recursive: true });
|
|
await copyFile(
|
|
join(repository, "scripts/ci/cache-retention.mjs"),
|
|
trustedHelperPath,
|
|
);
|
|
await expect(
|
|
access(join(legacyTarget, "scripts/ci/cache-retention.mjs")),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(access(trustedHelperPath)).resolves.toBeUndefined();
|
|
|
|
// Actions hashFiles hashes file bytes, then the ordered per-file digests.
|
|
// A common checkout-directory prefix preserves both file contents and order.
|
|
const targetHash = await verifyPrefixedHashFiles(
|
|
retentionEnv,
|
|
runnerWorkspace,
|
|
legacyRoot,
|
|
);
|
|
|
|
const prefix = "rea-static-v1-Linux-X64-";
|
|
const keepKey = `${prefix}${targetHash}-${targetSha}`;
|
|
const cacheFixture = {
|
|
actions_caches: [
|
|
{
|
|
id: 1,
|
|
key: `${prefix}old`,
|
|
created_at: "2026-10-01T00:00:00Z",
|
|
last_accessed_at: "2026-10-01T00:00:00Z",
|
|
size_in_bytes: 10,
|
|
version: "a".repeat(64),
|
|
ref: "refs/heads/main",
|
|
},
|
|
{
|
|
id: 2,
|
|
key: `${prefix}mid`,
|
|
created_at: "2026-10-15T00:00:00Z",
|
|
last_accessed_at: "2026-10-15T00:00:00Z",
|
|
size_in_bytes: 20,
|
|
version: "a".repeat(64),
|
|
ref: "refs/heads/main",
|
|
},
|
|
{
|
|
id: 3,
|
|
key: `${prefix}new`,
|
|
created_at: "2026-10-20T00:00:00Z",
|
|
last_accessed_at: "2026-10-20T00:00:00Z",
|
|
size_in_bytes: 30,
|
|
version: "a".repeat(64),
|
|
ref: "refs/heads/main",
|
|
},
|
|
{
|
|
id: 4,
|
|
key: keepKey,
|
|
created_at: "2026-09-30T00:00:00Z",
|
|
last_accessed_at: "2026-09-30T00:00:00Z",
|
|
size_in_bytes: 40,
|
|
version: "a".repeat(64),
|
|
ref: "refs/heads/main",
|
|
},
|
|
],
|
|
};
|
|
const ghPath = join(runnerWorkspace, "bin/gh");
|
|
const ghLog = join(runnerWorkspace, "fake-gh.log");
|
|
const cacheInput = join(runnerWorkspace, "cache-list.json");
|
|
await mkdir(join(runnerWorkspace, "bin"), { recursive: true });
|
|
await writeFile(cacheInput, JSON.stringify(cacheFixture));
|
|
await writeFile(
|
|
ghPath,
|
|
`#!${process.execPath}\nconst fs = require("node:fs");\nconst [command, option, method, endpoint] = process.argv.slice(2);\nif (command !== "api" || option !== "--method") process.exit(2);\nfs.appendFileSync(process.env.FAKE_GH_LOG, method + " " + endpoint + "\\n");\nif (method === "GET") process.stdout.write(fs.readFileSync(process.env.FAKE_GH_CACHE_INPUT, "utf8"));\nelse if (method !== "DELETE") process.exit(2);\n`,
|
|
);
|
|
await chmod(ghPath, 0o755);
|
|
expect(retentionRun).toBe(
|
|
"node trusted-retention/scripts/ci/cache-retention.mjs",
|
|
);
|
|
const helperPath = join(
|
|
runnerWorkspace,
|
|
String(retentionRun).replace(/^node /u, ""),
|
|
);
|
|
const command = execFileSync(process.execPath, [helperPath], {
|
|
cwd: runnerWorkspace,
|
|
env: {
|
|
PATH: join(runnerWorkspace, "bin"),
|
|
CI_REPOSITORY: "morluto/rea",
|
|
CACHE_PREFIX: prefix,
|
|
CACHE_KEEP_KEY: keepKey,
|
|
FAKE_GH_LOG: ghLog,
|
|
FAKE_GH_CACHE_INPUT: cacheInput,
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
const requests = await readFile(ghLog, "utf8");
|
|
expect(JSON.parse(command)).toEqual({
|
|
dryRun: false,
|
|
families: [{ prefix, deleteCount: 1, deleteBytes: 10 }],
|
|
npmDuplicateDeleteCount: 0,
|
|
npmDuplicateDeleteBytes: 0,
|
|
});
|
|
expect(requests.trim().split("\n")).toEqual([
|
|
"GET repos/morluto/rea/actions/caches?per_page=100&page=1&key=rea-static-v1-Linux-X64-",
|
|
"DELETE repos/morluto/rea/actions/caches/1",
|
|
]);
|
|
}
|
|
|
|
describe("hosted npm download-cache policy", () => {
|
|
it("only allows shared writes for trusted original default-branch events", async () => {
|
|
const action = await readAction();
|
|
const policy = action.runs.steps.find((step) => step.id === "context");
|
|
const command = z.string().parse(policy?.run);
|
|
|
|
const outputDirectory = await createTestTempDirectory(
|
|
"rea-ci-cache-policy-",
|
|
);
|
|
const npmStub = join(outputDirectory, "npm");
|
|
await writeFile(
|
|
npmStub,
|
|
String.raw`#!/bin/sh
|
|
printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache'
|
|
`,
|
|
);
|
|
await chmod(npmStub, 0o755);
|
|
|
|
for (const [event, ref, defaultBranch, expected] of [
|
|
["push", "refs/heads/main", "main", "true"],
|
|
["schedule", "refs/heads/main", "main", "true"],
|
|
["workflow_dispatch", "refs/heads/main", "main", "true"],
|
|
["push", "refs/heads/stable", "stable", "true"],
|
|
["pull_request", "refs/heads/main", "main", "false"],
|
|
["pull_request_target", "refs/heads/main", "main", "false"],
|
|
["workflow_call", "refs/heads/main", "main", "false"],
|
|
["push", "refs/heads/feature/cache", "main", "false"],
|
|
["push", "refs/heads/main", "stable", "false"],
|
|
["push", "refs/heads/main", "", "false"],
|
|
]) {
|
|
const outputPath = join(
|
|
outputDirectory,
|
|
`policy-${Date.now()}-${Math.random()}`,
|
|
);
|
|
execFileSync("bash", ["-euo", "pipefail", "-c", command], {
|
|
env: {
|
|
...process.env,
|
|
PATH: `${outputDirectory}${delimiter}${process.env.PATH ?? ""}`,
|
|
GITHUB_EVENT_NAME: event,
|
|
GITHUB_REF: ref,
|
|
GITHUB_OUTPUT: outputPath,
|
|
DEFAULT_BRANCH: defaultBranch,
|
|
LOCKFILES_HASH: "lock-hash",
|
|
RUNNER_OS: "Windows",
|
|
},
|
|
});
|
|
const output = await readFile(outputPath, "utf8");
|
|
expect(output).toContain(`can-save=${expected}\n`);
|
|
expect(output).toContain(
|
|
`path=${String.raw`C:\Users\runner with spaces\AppData\Local\npm-cache`}\n`,
|
|
);
|
|
expect(output).toContain(
|
|
`key=npm-download-Windows-${process.arch}-lock-hash\n`,
|
|
);
|
|
expect(output).toContain(
|
|
`restore-key=npm-download-Windows-${process.arch}-\n`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("uses a lockfile-, npmrc-, OS-, and runtime-architecture-scoped download cache", async () => {
|
|
const action = await readAction();
|
|
const policy = action.runs.steps.find((step) => step.id === "context");
|
|
const restoreAndSave = action.runs.steps.find((step) =>
|
|
step.uses?.startsWith("actions/cache@"),
|
|
);
|
|
const restoreOnly = action.runs.steps.find((step) =>
|
|
step.uses?.startsWith("actions/cache/restore@"),
|
|
);
|
|
|
|
expect(action.inputs?.save?.default).toBe("false");
|
|
expect(action.outputs["can-save"]?.value).toBe(
|
|
"${{ steps.context.outputs.can-save }}",
|
|
);
|
|
expect(action.outputs.path?.value).toBe(
|
|
"${{ steps.context.outputs.path }}",
|
|
);
|
|
expect(policy?.env?.DEFAULT_BRANCH).toBe(
|
|
"${{ github.event.repository.default_branch }}",
|
|
);
|
|
expect(policy?.env?.LOCKFILES_HASH).toBe(
|
|
"${{ hashFiles('package-lock.json', 'npm-shrinkwrap.json', '.npmrc') }}",
|
|
);
|
|
expect(policy?.run).toContain(
|
|
'cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"',
|
|
);
|
|
expect(restoreAndSave?.uses).toBe(
|
|
"actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(restoreAndSave?.if).toBe(
|
|
"steps.context.outputs.can-save == 'true' && inputs.save == 'true'",
|
|
);
|
|
expect(restoreOnly?.uses).toBe(
|
|
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(restoreOnly?.if).toBe(
|
|
"steps.context.outputs.can-save != 'true' || inputs.save != 'true'",
|
|
);
|
|
expect(restoreAndSave?.with?.path).toBe(
|
|
"${{ steps.context.outputs.path }}",
|
|
);
|
|
expect(restoreOnly?.with?.path).toBe(restoreAndSave?.with?.path);
|
|
expect(restoreAndSave?.with?.key).toBe("${{ steps.context.outputs.key }}");
|
|
expect(restoreOnly?.with?.key).toBe(restoreAndSave?.with?.key);
|
|
expect(restoreAndSave?.with?.["restore-keys"]).toBe(
|
|
"${{ steps.context.outputs.restore-key }}",
|
|
);
|
|
expect(restoreOnly?.with?.["restore-keys"]).toBe(
|
|
restoreAndSave?.with?.["restore-keys"],
|
|
);
|
|
});
|
|
|
|
it("replaces every inline setup-node npm cache immediately and preserves conditions", async () => {
|
|
const workflows = await readWorkflows();
|
|
let npmCacheSteps = 0;
|
|
|
|
for (const { file, workflow } of workflows) {
|
|
const source = await readFile(
|
|
join(repository, ".github/workflows", file),
|
|
"utf8",
|
|
);
|
|
expect(source).not.toMatch(/^\s+cache:\s*npm\s*$/mu);
|
|
for (const [jobName, job] of Object.entries(workflow.jobs ?? {})) {
|
|
const steps = job.steps ?? [];
|
|
for (let index = 0; index < steps.length; index++) {
|
|
const step = steps[index];
|
|
if (step?.uses !== "./.github/actions/npm-cache") continue;
|
|
npmCacheSteps++;
|
|
const setup = steps[index - 1];
|
|
expect(
|
|
typeof setup?.uses === "string" &&
|
|
setup.uses.startsWith("actions/setup-node@"),
|
|
`${file}:${jobName} npm cache action must immediately follow setup-node`,
|
|
).toBe(true);
|
|
expect(step.if).toBe(setup?.if);
|
|
expect(setup?.with).not.toHaveProperty("cache", "npm");
|
|
const npmCiIndex = steps.findIndex(
|
|
(candidate, candidateIndex) =>
|
|
candidateIndex > index && candidate.run === "npm ci",
|
|
);
|
|
expect(
|
|
npmCiIndex,
|
|
`${file}:${jobName} retains npm ci after cache`,
|
|
).toBeGreaterThan(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(npmCacheSteps).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it("shares static task snapshots only after validation using the npm cache owner's write authority", async () => {
|
|
const workflows = await readWorkflows();
|
|
const ci = workflows.find(({ file }) => file === "ci.yml");
|
|
const steps = ci?.workflow.jobs?.static?.steps ?? [];
|
|
expect(
|
|
steps.find((step) => step.uses === "./.github/actions/npm-cache")?.id,
|
|
).toBe("npm-cache");
|
|
const restore = steps.find((step) => step.id === "static-cache");
|
|
const save = steps.find(
|
|
(step) => step.name === "Save validated static task cache",
|
|
);
|
|
expect(restore?.uses).toBe(
|
|
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(restore?.if).toBe("needs.changes.outputs.code == 'true'");
|
|
expect(restore?.with).toMatchObject({ path: ".turbo/cache/" });
|
|
expect(save?.uses).toBe(
|
|
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(save?.if).toContain("success()");
|
|
expect(save?.if).toContain("steps.npm-cache.outputs.can-save == 'true'");
|
|
expect(save?.with).toEqual({
|
|
path: ".turbo/cache/",
|
|
key: "${{ steps.static-cache.outputs.cache-primary-key }}",
|
|
});
|
|
expect(steps.indexOf(save!)).toBeGreaterThan(
|
|
steps.findIndex((step) => step.run === "npm run check:ci:fast"),
|
|
);
|
|
const deep = ci?.workflow.jobs?.["static-deep"]?.steps ?? [];
|
|
const deepSave = deep.find(
|
|
(step) => step.name === "Save validated deep static task cache",
|
|
);
|
|
const knipRestore = deep.find((step) => step.id === "knip-cache");
|
|
const knipSave = deep.find(
|
|
(step) => step.name === "Save Knip analysis cache",
|
|
);
|
|
expect(deepSave?.uses).toBe(
|
|
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(deepSave?.if).toContain("success()");
|
|
expect(deepSave?.if).toContain("steps.npm-cache.outputs.can-save == 'true'");
|
|
expect(deepSave?.if).toContain(
|
|
"steps.static-cache.outputs.cache-hit != 'true'",
|
|
);
|
|
expect(deep.indexOf(deepSave!)).toBeGreaterThan(
|
|
deep.findIndex((step) => step.run === "npm run check:ci:deep"),
|
|
);
|
|
const knipWith = z
|
|
.object({
|
|
path: z.string(),
|
|
key: z.string(),
|
|
"restore-keys": z.string(),
|
|
})
|
|
.parse(knipRestore?.with);
|
|
expect(knipWith.path).toBe("node_modules/.cache/knip");
|
|
expect(knipWith.key).toContain("${{ github.sha }}");
|
|
expect(knipWith["restore-keys"]).not.toContain("github.sha");
|
|
expect(knipWith["restore-keys"].startsWith("knip-meta-v1-")).toBe(true);
|
|
expect(knipSave?.if).toContain(
|
|
"steps.knip-cache.outputs.cache-hit != 'true'",
|
|
);
|
|
expect(knipSave?.with).toEqual({
|
|
path: "node_modules/.cache/knip",
|
|
key: "${{ steps.knip-cache.outputs.cache-primary-key }}",
|
|
});
|
|
expect(
|
|
steps.find((step) => step.run === "node scripts/ci/cache-retention.mjs"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("deletes superseded caches only from the trusted follow-up workflow", async () => {
|
|
const workflow = z
|
|
.object({
|
|
on: z.object({
|
|
workflow_run: z.object({
|
|
workflows: z.array(z.literal("CI")),
|
|
types: z.array(z.literal("completed")),
|
|
}),
|
|
workflow_call: z.unknown().optional(),
|
|
pull_request: z.unknown().optional(),
|
|
}),
|
|
permissions: z.record(z.string(), z.string()),
|
|
jobs: z.record(
|
|
z.string(),
|
|
z.object({
|
|
if: z.string(),
|
|
"runs-on": z.literal("ubuntu-latest"),
|
|
steps: z.array(z.record(z.string(), z.unknown())),
|
|
}),
|
|
),
|
|
})
|
|
.parse(
|
|
parse(
|
|
await readFile(
|
|
join(repository, ".github/workflows/cache-retention.yml"),
|
|
"utf8",
|
|
),
|
|
),
|
|
);
|
|
expect(workflow.on.workflow_call).toBeUndefined();
|
|
expect(workflow.on.pull_request).toBeUndefined();
|
|
expect(workflow.permissions).toEqual({
|
|
contents: "read",
|
|
actions: "write",
|
|
});
|
|
const job = workflow.jobs.retain;
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.conclusion == 'success'",
|
|
);
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.conclusion == 'failure'",
|
|
);
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.conclusion == 'timed_out'",
|
|
);
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.conclusion == 'cancelled'",
|
|
);
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.head_repository.full_name == github.repository",
|
|
);
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.head_branch == github.event.repository.default_branch",
|
|
);
|
|
expect(job?.if).toContain("github.event.workflow_run.event == 'push'");
|
|
expect(job?.if).toContain("github.event.workflow_run.event == 'schedule'");
|
|
expect(job?.if).toContain(
|
|
"github.event.workflow_run.event == 'workflow_dispatch'",
|
|
);
|
|
expect(job?.if).not.toContain("pull_request");
|
|
const steps = job?.steps ?? [];
|
|
expect(steps.some((step) => step.run === "npm ci")).toBe(false);
|
|
const checkouts = steps.filter(
|
|
(step) =>
|
|
step.uses === "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
);
|
|
const trustedCheckout = checkouts[0];
|
|
expect(trustedCheckout?.with).toMatchObject({
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: "trusted-retention",
|
|
"persist-credentials": false,
|
|
});
|
|
const targetCheckout = checkouts[1];
|
|
expect(targetCheckout?.with).toMatchObject({
|
|
ref: "${{ github.event.workflow_run.head_sha }}",
|
|
path: "triggering-source",
|
|
"persist-credentials": false,
|
|
});
|
|
expect(checkouts).toHaveLength(2);
|
|
expect(steps.some((step) => step.run === "npm ci")).toBe(false);
|
|
const setupNode = steps.find(
|
|
(step) =>
|
|
step.uses ===
|
|
"actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
|
|
);
|
|
expect(setupNode?.with).toMatchObject({
|
|
"node-version-file": "trusted-retention/.nvmrc",
|
|
});
|
|
const retention = steps.find(
|
|
(step) =>
|
|
step.run === "node trusted-retention/scripts/ci/cache-retention.mjs",
|
|
);
|
|
expect(retention).toBeDefined();
|
|
const retentionEnv = z.record(z.string(), z.string()).parse(retention?.env);
|
|
expect(retentionEnv.CACHE_PREFIX).toBe(
|
|
"rea-static-v1-${{ runner.os }}-${{ runner.arch }}-",
|
|
);
|
|
expect(retentionEnv.CACHE_KEEP_KEY).toBe(
|
|
"rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}",
|
|
);
|
|
expect(retentionEnv.CACHE_DEEP_KEEP_KEY).toContain(
|
|
"hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json')",
|
|
);
|
|
expect(retentionEnv.CACHE_KNIP_KEEP_KEY).toContain(
|
|
"hashFiles('triggering-source/knip.json', 'triggering-source/tsconfig.json', 'triggering-source/package.json', 'triggering-source/package-lock.json')",
|
|
);
|
|
expect(retentionEnv.CACHE_PORTABLE_PREFIX).toBe(
|
|
"rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-",
|
|
);
|
|
expect(retentionEnv.DEDUPE_NPM_DOWNLOADS).toBe("true");
|
|
expect(retentionEnv.GH_TOKEN).toBe("${{ github.token }}");
|
|
|
|
// Exercise the selected trusted helper against a legacy target tree.
|
|
await exerciseTrustedRetentionWithLegacyTarget(retention?.run, retentionEnv);
|
|
const ci = (await readWorkflows()).find(({ file }) => file === "ci.yml");
|
|
expect(JSON.stringify(ci?.workflow)).not.toContain('"actions":"write"');
|
|
const deepSteps = ci?.workflow.jobs?.["static-deep"]?.steps ?? [];
|
|
const publishedKey = (id: string) =>
|
|
z
|
|
.string()
|
|
.parse(
|
|
z
|
|
.record(z.string(), z.unknown())
|
|
.parse(deepSteps.find((step) => step.id === id)?.with).key,
|
|
);
|
|
const savedOnThisRun = "${{ github.event.workflow_run.head_sha }}";
|
|
const deepPrefix = "rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-";
|
|
const knipPrefix = "knip-meta-v1-${{ runner.os }}-";
|
|
const targetSourceKey = (id: string) =>
|
|
publishedKey(id)
|
|
.replaceAll("${{ github.sha }}", savedOnThisRun)
|
|
.replaceAll("hashFiles('", "hashFiles('triggering-source/")
|
|
.replaceAll(", '", ", 'triggering-source/");
|
|
expect(retentionEnv.CACHE_DEEP_PREFIX).toBe(deepPrefix);
|
|
expect(retentionEnv.CACHE_DEEP_KEEP_KEY).toBe(
|
|
targetSourceKey("static-cache"),
|
|
);
|
|
expect(publishedKey("static-cache").startsWith(deepPrefix)).toBe(true);
|
|
expect(retentionEnv.CACHE_KNIP_PREFIX).toBe(knipPrefix);
|
|
expect(retentionEnv.CACHE_KNIP_KEEP_KEY).toBe(targetSourceKey("knip-cache"));
|
|
expect(publishedKey("knip-cache").startsWith(knipPrefix)).toBe(true);
|
|
});
|
|
|
|
it("publishes each hosted npm download cache from one writer", async () => {
|
|
const workflows = await readWorkflows();
|
|
const writers: { file: string; job: string; save: string }[] = [];
|
|
for (const { file, workflow } of workflows)
|
|
for (const [job, definition] of Object.entries(workflow.jobs ?? {}))
|
|
for (const step of definition.steps ?? []) {
|
|
if (step.uses !== "./.github/actions/npm-cache") continue;
|
|
const save = z
|
|
.record(z.string(), z.unknown())
|
|
.parse(step.with ?? {}).save;
|
|
if (save === undefined) continue;
|
|
writers.push({ file, job, save: z.string().parse(save) });
|
|
}
|
|
expect(writers).toEqual([
|
|
{ file: "ci.yml", job: "static", save: "true" },
|
|
{
|
|
file: "ci.yml",
|
|
job: "package-e2e",
|
|
save: "${{ matrix.os != 'ubuntu-latest' }}",
|
|
},
|
|
{ file: "ci.yml", job: "windows-curated", save: "true" },
|
|
]);
|
|
const ci = workflows.find(({ file }) => file === "ci.yml");
|
|
expect(ci?.workflow.jobs?.static?.permissions).toBeUndefined();
|
|
expect(ci?.workflow.jobs?.build?.permissions).toEqual({
|
|
contents: "read",
|
|
actions: "read",
|
|
});
|
|
});
|
|
|
|
it("passes the npm cache owner's selected path to package installation", async () => {
|
|
const workflows = await readWorkflows();
|
|
const ci = workflows.find(({ file }) => file === "ci.yml");
|
|
const steps = ci?.workflow.jobs?.["package-e2e"]?.steps ?? [];
|
|
const cacheIndex = steps.findIndex(
|
|
(step) => step.uses === "./.github/actions/npm-cache",
|
|
);
|
|
expect(steps[cacheIndex]?.id).toBe("npm-cache");
|
|
const consumerIndex = steps.findIndex(
|
|
(step) => step.run === "npm run verify:package",
|
|
);
|
|
expect(consumerIndex).toBeGreaterThan(cacheIndex);
|
|
expect(
|
|
z.record(z.string(), z.unknown()).parse(steps[consumerIndex]?.env)
|
|
.npm_config_cache,
|
|
).toBe("${{ steps.npm-cache.outputs.path }}");
|
|
});
|
|
|
|
it("exports turbo remote-cache credentials wherever a workflow runs turbo", async () => {
|
|
const directory = join(repository, ".github/workflows");
|
|
const files = (await readdir(directory)).filter((file) =>
|
|
/\.ya?ml$/u.test(file),
|
|
);
|
|
const turboCommand =
|
|
/\bturbo\b|build:cached|check:ci|docs:check|docs:generate|docs:prepare|test:prepare/u;
|
|
const workflowSchema = z.object({
|
|
env: z.record(z.string(), z.unknown()).optional(),
|
|
jobs: z.record(
|
|
z.string(),
|
|
z
|
|
.object({
|
|
env: z.record(z.string(), z.unknown()).optional(),
|
|
steps: z.array(z.object({ run: z.string().optional() })).optional(),
|
|
})
|
|
.passthrough(),
|
|
),
|
|
});
|
|
for (const file of files) {
|
|
const workflow = workflowSchema.parse(
|
|
parse(await readFile(join(directory, file), "utf8")),
|
|
);
|
|
for (const [name, job] of Object.entries(workflow.jobs)) {
|
|
const runsTurbo = job.steps?.some(
|
|
(step) => step.run !== undefined && turboCommand.test(step.run),
|
|
);
|
|
if (!runsTurbo) continue;
|
|
const env = { ...workflow.env, ...job.env };
|
|
const token = z.string().parse(env.TURBO_TOKEN);
|
|
const cache = z.string().parse(env.TURBO_CACHE);
|
|
expect(token, `${file}:${name}`).toContain("secrets.TURBO_TOKEN");
|
|
expect(token, `${file}:${name}`).toContain("|| ''");
|
|
expect(token, `${file}:${name}`).not.toBe("${{ secrets.TURBO_TOKEN }}");
|
|
const grantsReleaseWrite = token.includes(
|
|
"inputs.remote_cache_write == true",
|
|
);
|
|
const grantsCalledEvent = token.includes(
|
|
"github.event_name == 'workflow_call'",
|
|
);
|
|
expect(grantsReleaseWrite || grantsCalledEvent, `${file}:${name}`).toBe(
|
|
true,
|
|
);
|
|
if (grantsReleaseWrite)
|
|
expect(token, `${file}:${name}`).not.toContain(
|
|
"github.event_name == 'workflow_call'",
|
|
);
|
|
for (const event of ["push", "schedule", "workflow_dispatch"])
|
|
expect(token, `${file}:${name}`).toContain(
|
|
`github.event_name == '${event}'`,
|
|
);
|
|
expect(token, `${file}:${name}`).not.toContain(
|
|
"github.event_name == 'pull_request'",
|
|
);
|
|
if (grantsReleaseWrite) {
|
|
expect(cache, `${file}:${name}`).toContain(
|
|
"inputs.remote_cache_write == true",
|
|
);
|
|
expect(cache, `${file}:${name}`).toContain("|| 'local:rw'");
|
|
expect(cache, `${file}:${name}`).not.toContain("local:rw,remote:r'");
|
|
} else expect(cache, `${file}:${name}`).toContain("local:rw,remote:r'");
|
|
expect(cache, `${file}:${name}`).toContain("local:rw,remote:rw");
|
|
expect(env.TURBO_TEAM, `${file}:${name}`).toBe("${{ vars.TURBO_TEAM }}");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("bounds the rolling static snapshot with Turbo age and size eviction", async () => {
|
|
const turbo = z
|
|
.object({
|
|
cacheMaxAge: z.string(),
|
|
cacheMaxSize: z.string(),
|
|
})
|
|
.parse(JSON.parse(await readFile(join(repository, "turbo.json"), "utf8")));
|
|
// The static job restores the previous snapshot by prefix and saves the
|
|
// whole .turbo/cache directory again. Without local eviction every main
|
|
// commit carries all historical entries into a new immutable GitHub cache,
|
|
// growing transfers toward the 10 GB repository limit.
|
|
expect(turbo.cacheMaxAge).toMatch(/^[1-9]\d*(s|m|h|d|w)$/u);
|
|
expect(turbo.cacheMaxAge).not.toBe("0");
|
|
const size = /^(\d+(?:\.\d+)?)(MB|GB|TB)$/iu.exec(turbo.cacheMaxSize);
|
|
expect(size, "cacheMaxSize must use an explicit MB/GB/TB budget").not.toBe(
|
|
null,
|
|
);
|
|
const value = Number(size?.[1] ?? "0");
|
|
const unit = (size?.[2] ?? "MB").toUpperCase();
|
|
const megabytes =
|
|
unit === "TB" ? value * 1024 * 1024 : unit === "GB" ? value * 1024 : value;
|
|
expect(megabytes).toBeGreaterThan(0);
|
|
expect(megabytes).toBeLessThanOrEqual(1024);
|
|
});
|