Remove the blanket caller ID requirement that rejected the standalone mutation workflow. Keep producer bindings where static cache authorization and package verification consume action outputs.
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { chmod, 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({
|
|
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(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
})
|
|
.parse(parse(await readFile(join(directory, file), "utf8"))),
|
|
})),
|
|
);
|
|
};
|
|
|
|
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`,
|
|
);
|
|
}
|
|
});
|
|
|
|
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.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'");
|
|
expect(restoreOnly?.uses).toBe(
|
|
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
|
);
|
|
expect(restoreOnly?.if).toBe("steps.context.outputs.can-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).not.toHaveProperty("restore-keys");
|
|
expect(restoreOnly?.with).not.toHaveProperty("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 test:python"),
|
|
);
|
|
});
|
|
|
|
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("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);
|
|
});
|