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>
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { delimiter, join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
listAllCaches,
|
|
retainCaches,
|
|
selectNpmDownloadDuplicateDeletions,
|
|
selectPrefixDeletions,
|
|
} from "../../../scripts/ci/cache-retention.mjs";
|
|
|
|
const cache = (
|
|
id: number,
|
|
key: string,
|
|
created: string,
|
|
accessed = created,
|
|
size = 100,
|
|
version = "a".repeat(64),
|
|
ref = "refs/heads/main",
|
|
) => ({
|
|
id,
|
|
key,
|
|
version,
|
|
ref,
|
|
created_at: created,
|
|
last_accessed_at: accessed,
|
|
size_in_bytes: size,
|
|
});
|
|
|
|
describe("Turbo snapshot retention", () => {
|
|
const prefix = "rea-static-v1-Linux-X64-";
|
|
|
|
it("keeps the two newest snapshots and the saved key", () => {
|
|
const keepKey = `${prefix}saved`;
|
|
const deletions = selectPrefixDeletions(
|
|
[
|
|
cache(1, `${prefix}old`, "2026-10-20T01:00:00Z", undefined, 10),
|
|
cache(2, `${prefix}older`, "2026-10-20T02:00:00Z", undefined, 20),
|
|
cache(3, `${prefix}recent`, "2026-10-20T03:00:00Z", undefined, 30),
|
|
cache(4, keepKey, "2026-10-20T00:00:00Z", undefined, 40),
|
|
],
|
|
{ prefix, keepKey },
|
|
);
|
|
expect(deletions.map((entry) => entry.id)).toEqual([1]);
|
|
expect(deletions.reduce((sum, entry) => sum + entry.size, 0)).toBe(10);
|
|
});
|
|
|
|
it("keeps an in-flight generation when the saved key is already newest", () => {
|
|
const keepKey = `${prefix}newest`;
|
|
const deletions = selectPrefixDeletions(
|
|
[
|
|
cache(1, `${prefix}a`, "2026-10-20T01:00:00Z"),
|
|
cache(2, `${prefix}b`, "2026-10-20T02:00:00Z"),
|
|
cache(3, `${prefix}c`, "2026-10-20T03:00:00Z"),
|
|
cache(4, keepKey, "2026-10-20T04:00:00Z"),
|
|
],
|
|
{ prefix, keepKey },
|
|
);
|
|
expect(
|
|
deletions.map((entry) => entry.id).sort((left, right) => left - right),
|
|
).toEqual([1, 2]);
|
|
});
|
|
|
|
it("keeps two generations of the deep and Knip snapshot families", () => {
|
|
const deep = "rea-static-deep-v1-Linux-X64-";
|
|
const knip = "knip-meta-v1-Linux-";
|
|
expect(
|
|
selectPrefixDeletions(
|
|
[
|
|
cache(1, `${deep}old`, "2026-10-20T01:00:00Z"),
|
|
cache(2, `${deep}mid`, "2026-10-20T02:00:00Z"),
|
|
cache(3, `${deep}new`, "2026-10-20T03:00:00Z"),
|
|
],
|
|
{ prefix: deep, keepKey: `${deep}new` },
|
|
).map((entry) => entry.id),
|
|
).toEqual([1]);
|
|
expect(
|
|
selectPrefixDeletions(
|
|
[
|
|
cache(4, `${knip}old`, "2026-10-20T01:00:00Z"),
|
|
cache(5, `${knip}mid`, "2026-10-20T02:00:00Z"),
|
|
cache(6, `${knip}new`, "2026-10-20T03:00:00Z"),
|
|
],
|
|
{ prefix: knip, keepKey: `${knip}new` },
|
|
).map((entry) => entry.id),
|
|
).toEqual([4]);
|
|
});
|
|
|
|
it("retains the newest portable snapshots without a caller keep key", () => {
|
|
const portable = "rea-portable-build-v2-Linux-X64-";
|
|
const deletions = selectPrefixDeletions(
|
|
[
|
|
cache(7, `${portable}one`, "2026-10-20T01:00:00Z"),
|
|
cache(8, `${portable}two`, "2026-10-20T02:00:00Z"),
|
|
cache(9, `${portable}three`, "2026-10-20T03:00:00Z"),
|
|
],
|
|
{ prefix: portable },
|
|
);
|
|
expect(deletions.map((entry) => entry.id)).toEqual([7]);
|
|
});
|
|
|
|
it("refuses prefixes and lists that can reach download caches", () => {
|
|
expect(() =>
|
|
selectPrefixDeletions([], { prefix: "npm-download-Linux-x64-" }),
|
|
).toThrow(/one Turbo family/u);
|
|
expect(() =>
|
|
selectPrefixDeletions([], { prefix: "rea-static-v1-" }),
|
|
).toThrow(/one Turbo family/u);
|
|
expect(() =>
|
|
selectPrefixDeletions([], { prefix: "knip-meta-v1-" }),
|
|
).toThrow(/one Turbo family/u);
|
|
expect(() =>
|
|
selectPrefixDeletions(
|
|
[cache(1, "npm-download-Linux-x64-abc", "2026-10-20T01:00:00Z")],
|
|
{
|
|
prefix,
|
|
keepKey: `${prefix}saved`,
|
|
},
|
|
),
|
|
).toThrow(/outside/u);
|
|
expect(() =>
|
|
selectPrefixDeletions([], {
|
|
prefix,
|
|
keepKey: "rea-static-v1-Windows-X64-other",
|
|
}),
|
|
).toThrow(/keep key/u);
|
|
});
|
|
});
|
|
|
|
describe("npm download duplicate retention", () => {
|
|
it("deletes only an older copy of the same key, version, and ref", () => {
|
|
const key = "npm-download-Linux-x64-lock";
|
|
const version = "a".repeat(64);
|
|
const ref = "refs/heads/main";
|
|
const deletions = selectNpmDownloadDuplicateDeletions([
|
|
cache(
|
|
1,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T02:00:00Z",
|
|
179,
|
|
version,
|
|
ref,
|
|
),
|
|
cache(
|
|
2,
|
|
key,
|
|
"2026-10-20T01:05:00Z",
|
|
"2026-10-20T03:00:00Z",
|
|
199,
|
|
version,
|
|
ref,
|
|
),
|
|
cache(
|
|
3,
|
|
"npm-download-Windows-x64-lock",
|
|
"2026-10-20T01:00:00Z",
|
|
undefined,
|
|
186,
|
|
),
|
|
]);
|
|
expect(deletions.map((entry) => entry.id)).toEqual([1]);
|
|
});
|
|
|
|
it("keeps records that share a key but differ by version or ref", () => {
|
|
const key = "npm-download-Linux-x64-lock";
|
|
const version = "a".repeat(64);
|
|
const otherVersion = "b".repeat(64);
|
|
const deletions = selectNpmDownloadDuplicateDeletions([
|
|
cache(
|
|
1,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T04:00:00Z",
|
|
199,
|
|
version,
|
|
"refs/heads/main",
|
|
),
|
|
cache(
|
|
2,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T03:00:00Z",
|
|
188,
|
|
otherVersion,
|
|
"refs/heads/main",
|
|
),
|
|
cache(
|
|
3,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T02:00:00Z",
|
|
180,
|
|
version,
|
|
"refs/heads/feature",
|
|
),
|
|
]);
|
|
expect(deletions).toEqual([]);
|
|
});
|
|
|
|
it("refuses a download record without a version or ref", () => {
|
|
const key = "npm-download-Linux-x64-lock";
|
|
const { version: _version, ...withoutVersion } = cache(
|
|
1,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
);
|
|
expect(() => selectNpmDownloadDuplicateDeletions([withoutVersion])).toThrow(
|
|
/version/u,
|
|
);
|
|
const { ref: _ref, ...withoutRef } = cache(2, key, "2026-10-20T01:00:00Z");
|
|
expect(() => selectNpmDownloadDuplicateDeletions([withoutRef])).toThrow(
|
|
/ref/u,
|
|
);
|
|
});
|
|
|
|
it("does not delete the only copy when pagination repeats its id", () => {
|
|
const key = "npm-download-Linux-x64-lock";
|
|
const version = "a".repeat(64);
|
|
const ref = "refs/heads/main";
|
|
const deletions = selectNpmDownloadDuplicateDeletions([
|
|
cache(
|
|
5,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T02:00:00Z",
|
|
179,
|
|
version,
|
|
ref,
|
|
),
|
|
cache(
|
|
5,
|
|
key,
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T04:00:00Z",
|
|
179,
|
|
version,
|
|
ref,
|
|
),
|
|
]);
|
|
expect(deletions).toEqual([]);
|
|
});
|
|
|
|
it("aborts when the same id is listed with a different version", () => {
|
|
const key = "npm-download-Linux-x64-lock";
|
|
expect(() =>
|
|
selectNpmDownloadDuplicateDeletions([
|
|
cache(5, key, "2026-10-20T01:00:00Z", undefined, 179, "a".repeat(64)),
|
|
cache(5, key, "2026-10-20T01:00:00Z", undefined, 179, "b".repeat(64)),
|
|
]),
|
|
).toThrow(/inconsistent/u);
|
|
});
|
|
|
|
it("refuses a Turbo snapshot in the download list", () => {
|
|
expect(() =>
|
|
selectNpmDownloadDuplicateDeletions([
|
|
cache(1, "rea-static-v1-Linux-X64-sha", "2026-10-20T01:00:00Z"),
|
|
]),
|
|
).toThrow(/non-download/u);
|
|
});
|
|
});
|
|
|
|
describe("retention orchestration", () => {
|
|
const repository = "morluto/rea";
|
|
const prefix = "rea-static-v1-Linux-X64-";
|
|
|
|
it("deletes nothing when a later list is incomplete", async () => {
|
|
const deleted: number[] = [];
|
|
const page = Array.from({ length: 100 }, (_, index) =>
|
|
cache(index + 1, `${prefix}${String(index)}`, "2026-10-20T01:00:00Z"),
|
|
);
|
|
await expect(
|
|
retainCaches({
|
|
repository,
|
|
request: async (_method, endpoint) => {
|
|
if (endpoint.includes("npm-download-"))
|
|
throw new Error("list failed after the first family");
|
|
return { actions_caches: page };
|
|
},
|
|
deleteCache: async (id) => {
|
|
deleted.push(id);
|
|
},
|
|
families: [{ prefix, keepKey: `${prefix}0` }],
|
|
dedupeNpmDownloads: true,
|
|
}),
|
|
).rejects.toThrow(/list failed|exceeded/u);
|
|
expect(deleted).toEqual([]);
|
|
});
|
|
|
|
it("stops listing once a short page proves the inventory is complete", async () => {
|
|
const requests: string[] = [];
|
|
const caches = await listAllCaches(
|
|
async (_method, endpoint) => {
|
|
requests.push(endpoint);
|
|
const page = new URL(endpoint, "https://example.test").searchParams.get(
|
|
"page",
|
|
);
|
|
return {
|
|
actions_caches:
|
|
page === "1"
|
|
? Array.from({ length: 100 }, (_, index) =>
|
|
cache(
|
|
index + 1,
|
|
`${prefix}${String(index)}`,
|
|
"2026-10-20T01:00:00Z",
|
|
),
|
|
)
|
|
: [cache(101, `${prefix}last`, "2026-10-20T02:00:00Z")],
|
|
};
|
|
},
|
|
repository,
|
|
prefix,
|
|
);
|
|
expect(caches).toHaveLength(101);
|
|
expect(requests).toHaveLength(2);
|
|
});
|
|
|
|
it("applies every family only after both lists succeed", async () => {
|
|
const deleted: number[] = [];
|
|
const keepKey = `${prefix}saved`;
|
|
const portable = "rea-portable-build-v2-Linux-X64-";
|
|
const result = await retainCaches({
|
|
repository,
|
|
request: async (_method, endpoint) => {
|
|
const key = new URL(endpoint, "https://example.test").searchParams.get(
|
|
"key",
|
|
);
|
|
if (key === prefix)
|
|
return {
|
|
actions_caches: [
|
|
cache(1, `${prefix}old`, "2026-10-20T01:00:00Z"),
|
|
cache(2, `${prefix}mid`, "2026-10-20T02:00:00Z"),
|
|
cache(3, keepKey, "2026-10-20T03:00:00Z"),
|
|
],
|
|
};
|
|
if (key === portable)
|
|
return {
|
|
actions_caches: [
|
|
cache(4, `${portable}old`, "2026-10-20T01:00:00Z"),
|
|
cache(5, `${portable}new`, "2026-10-20T02:00:00Z"),
|
|
],
|
|
};
|
|
return {
|
|
actions_caches: [
|
|
cache(
|
|
6,
|
|
"npm-download-Linux-x64-lock",
|
|
"2026-10-20T01:00:00Z",
|
|
"2026-10-20T01:00:00Z",
|
|
),
|
|
cache(
|
|
7,
|
|
"npm-download-Linux-x64-lock",
|
|
"2026-10-20T02:00:00Z",
|
|
"2026-10-20T03:00:00Z",
|
|
),
|
|
],
|
|
};
|
|
},
|
|
deleteCache: async (id) => {
|
|
deleted.push(id);
|
|
},
|
|
families: [{ prefix, keepKey }, { prefix: portable }],
|
|
dedupeNpmDownloads: true,
|
|
});
|
|
expect(deleted).toEqual([1, 6]);
|
|
expect(result.plan[0]?.deletions.map((entry) => entry.id)).toEqual([1]);
|
|
expect(result.plan[1]?.deletions).toEqual([]);
|
|
expect(result.npmDeletions.map((entry) => entry.id)).toEqual([6]);
|
|
});
|
|
|
|
it("dry-run reports the same deletions without calling delete", async () => {
|
|
const result = await retainCaches({
|
|
repository,
|
|
request: async () => ({
|
|
actions_caches: [
|
|
cache(1, `${prefix}old`, "2026-10-20T01:00:00Z", undefined, 50),
|
|
cache(2, `${prefix}mid`, "2026-10-20T02:00:00Z", undefined, 60),
|
|
cache(3, `${prefix}new`, "2026-10-20T03:00:00Z", undefined, 70),
|
|
],
|
|
}),
|
|
deleteCache: async () => {
|
|
throw new Error("dry-run must not delete");
|
|
},
|
|
families: [{ prefix, keepKey: `${prefix}new` }],
|
|
dryRun: true,
|
|
});
|
|
expect(result.dryRun).toBe(true);
|
|
expect(result.plan[0]?.deletions.map((entry) => entry.id)).toEqual([1]);
|
|
});
|
|
});
|
|
|
|
describe("repeated cache ids", () => {
|
|
const repository = "morluto/rea";
|
|
const prefix = "rea-static-v1-Linux-X64-";
|
|
|
|
it("treats one cache id on two pages as a single snapshot", () => {
|
|
const keepKey = `${prefix}saved`;
|
|
const deletions = selectPrefixDeletions(
|
|
[
|
|
cache(1, `${prefix}old`, "2026-10-20T01:00:00Z"),
|
|
cache(2, `${prefix}mid`, "2026-10-20T02:00:00Z"),
|
|
cache(3, keepKey, "2026-10-20T03:00:00Z", "2026-10-20T03:00:00Z"),
|
|
cache(3, keepKey, "2026-10-20T03:00:00Z", "2026-10-20T04:00:00Z"),
|
|
],
|
|
{ prefix, keepKey },
|
|
);
|
|
expect(deletions.map((entry) => entry.id)).toEqual([1]);
|
|
});
|
|
|
|
it("does not delete when one cache id is listed with inconsistent fields", async () => {
|
|
const deleted: number[] = [];
|
|
const repeated = cache(3, `${prefix}new`, "2026-10-20T03:00:00Z");
|
|
await expect(
|
|
retainCaches({
|
|
repository,
|
|
request: async () => ({
|
|
actions_caches: [
|
|
cache(1, `${prefix}old`, "2026-10-20T01:00:00Z"),
|
|
cache(2, `${prefix}mid`, "2026-10-20T02:00:00Z"),
|
|
repeated,
|
|
{ ...repeated, version: "b".repeat(64) },
|
|
],
|
|
}),
|
|
deleteCache: async (id) => {
|
|
deleted.push(id);
|
|
},
|
|
families: [{ prefix, keepKey: `${prefix}new` }],
|
|
}),
|
|
).rejects.toThrow(/inconsistent/u);
|
|
expect(deleted).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("runs retention from the command line without deleting on an unsafe prefix", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-cache-retention-"));
|
|
const log = join(root, "gh.log");
|
|
const gh = join(root, "gh");
|
|
await writeFile(
|
|
gh,
|
|
`#!/bin/sh
|
|
printf '%s\\n' "$@" >> "${log}"
|
|
exit 1
|
|
`,
|
|
);
|
|
await chmod(gh, 0o755);
|
|
expect(() =>
|
|
execFileSync("node", ["scripts/ci/cache-retention.mjs"], {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
PATH: `${root}${delimiter}${process.env.PATH ?? ""}`,
|
|
CI_REPOSITORY: "morluto/rea",
|
|
CACHE_PREFIX: "npm-download-Linux-x64-",
|
|
CACHE_KEEP_KEY: "npm-download-Linux-x64-lock",
|
|
},
|
|
encoding: "utf8",
|
|
}),
|
|
).toThrow(/one Turbo family/u);
|
|
await expect(readFile(log, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|