mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* ci: keep npm download caches from Turbo snapshot growth Per-commit static and portable Turbo snapshots had filled the 10 GB Actions cache, which is what makes warm npm ci cheap. Trusted static runs now keep two generations and one download-cache writer per host. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(ci): declare cache retention types for strict checks Typecheck imports cache-retention.mjs and needs a sibling declaration. The generation count stays module-private so knip does not report it. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(ci): keep compatible caches and qualification read-only Npm dedupe deletes a record only when another has the same key, version, and ref. Cache deletion moves to a follow-up workflow so reusable qualification can keep its read-only token. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(ci): retain caches after failed default-branch runs Static and build save Turbo snapshots before later lanes finish. Retention now follows a failed, timed-out, or cancelled trusted run, not only a green one. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(cli): pass application trace input as a file The CLI already reads workflow JSON from a path. Putting the full application Evidence document on argv exceeds the runner argument limit. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(ci): retain deep and Knip snapshots without deleting a repeated id Fast and deep static checks publish rea-static-deep-v1 and knip-meta-v1 entries per commit. Retention prunes those families with the same two-generation rule. A cache id listed twice is one record; inconsistent repeats abort before any delete. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
539 lines
20 KiB
TypeScript
539 lines
20 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({
|
|
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"))),
|
|
})),
|
|
);
|
|
};
|
|
|
|
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 checkout = steps[0];
|
|
expect(checkout?.uses).toBe(
|
|
"actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
);
|
|
expect(checkout?.with).toMatchObject({
|
|
ref: "${{ github.event.workflow_run.head_sha }}",
|
|
"persist-credentials": false,
|
|
});
|
|
const retention = steps.find(
|
|
(step) => step.run === "node scripts/ci/cache-retention.mjs",
|
|
);
|
|
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('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}",
|
|
);
|
|
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 }}");
|
|
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 }}-";
|
|
expect(retentionEnv.CACHE_DEEP_PREFIX).toBe(deepPrefix);
|
|
expect(retentionEnv.CACHE_DEEP_KEEP_KEY).toBe(
|
|
publishedKey("static-cache").replaceAll(
|
|
"${{ github.sha }}",
|
|
savedOnThisRun,
|
|
),
|
|
);
|
|
expect(publishedKey("static-cache").startsWith(deepPrefix)).toBe(true);
|
|
expect(retentionEnv.CACHE_KNIP_PREFIX).toBe(knipPrefix);
|
|
expect(retentionEnv.CACHE_KNIP_KEEP_KEY).toBe(
|
|
publishedKey("knip-cache").replaceAll("${{ github.sha }}", savedOnThisRun),
|
|
);
|
|
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);
|
|
});
|