* 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>
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { realpathSync } from "node:fs";
|
|
import { pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
const MAX_PAGES = 10;
|
|
const PAGE_SIZE = 100;
|
|
|
|
// Keep the newest snapshots, not only the key just saved. A concurrent job may
|
|
// still be restoring the previous generation; deleting that id fails its restore.
|
|
// The following trusted run drops anything older than those two.
|
|
const RETAINED_CACHE_GENERATIONS = 2;
|
|
|
|
const PREFIX_PATTERNS = [
|
|
/^rea-static-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
|
/^rea-static-deep-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
|
/^rea-portable-build-v2-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
|
/^knip-meta-v1-[A-Za-z0-9]+-$/u,
|
|
];
|
|
|
|
/** Reject prefixes that could match npm downloads or another platform. */
|
|
export function assertRetentionPrefix(prefix) {
|
|
if (
|
|
typeof prefix !== "string" ||
|
|
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
|
|
)
|
|
throw new Error(
|
|
"Cache retention prefix must be one Turbo family for one OS and architecture, or one Knip metadata family for one OS",
|
|
);
|
|
}
|
|
|
|
function normalizeCache(cache, index) {
|
|
if (typeof cache !== "object" || cache === null || Array.isArray(cache))
|
|
throw new Error(`Cache entry ${String(index + 1)} is malformed`);
|
|
const { id, key, size_in_bytes: size } = cache;
|
|
if (!Number.isSafeInteger(id) || id <= 0)
|
|
throw new Error(`Cache entry ${String(index + 1)} has an invalid id`);
|
|
if (typeof key !== "string" || key.length === 0 || /[\r\n]/u.test(key))
|
|
throw new Error(`Cache entry ${String(index + 1)} has an invalid key`);
|
|
const createdAt = Date.parse(cache.created_at);
|
|
const accessedAt = Date.parse(cache.last_accessed_at ?? cache.created_at);
|
|
if (!Number.isFinite(createdAt) || !Number.isFinite(accessedAt))
|
|
throw new Error(
|
|
`Cache entry ${String(index + 1)} has an invalid timestamp`,
|
|
);
|
|
if (!Number.isSafeInteger(size) || size < 0)
|
|
throw new Error(`Cache entry ${String(index + 1)} has an invalid size`);
|
|
// actions/cache restores a key only when this metadata hash matches.
|
|
const version = cache.version;
|
|
if (typeof version !== "string" || !/^[a-f0-9]{64}$/u.test(version))
|
|
throw new Error(`Cache entry ${String(index + 1)} has an invalid version`);
|
|
// The git ref is the cache's access scope. Another branch cannot use it.
|
|
const ref = cache.ref;
|
|
if (typeof ref !== "string" || !/^refs\/[^\s]+$/u.test(ref))
|
|
throw new Error(`Cache entry ${String(index + 1)} has an invalid ref`);
|
|
return { id, key, version, ref, createdAt, accessedAt, size };
|
|
}
|
|
|
|
function sameStableIdentity(left, right) {
|
|
return (
|
|
left.key === right.key &&
|
|
left.version === right.version &&
|
|
left.ref === right.ref &&
|
|
left.size === right.size &&
|
|
left.createdAt === right.createdAt
|
|
);
|
|
}
|
|
|
|
// The list API orders by last access. A restore between pages can place one
|
|
// id on two pages. That is still one cache: collapse it, or abort when the
|
|
// pages disagree, before any id can be both kept and deleted.
|
|
function uniqueById(entries) {
|
|
const byId = new Map();
|
|
for (const entry of entries) {
|
|
const existing = byId.get(entry.id);
|
|
if (existing === undefined) {
|
|
byId.set(entry.id, entry);
|
|
continue;
|
|
}
|
|
if (!sameStableIdentity(existing, entry))
|
|
throw new Error(
|
|
`Cache id ${String(entry.id)} was listed with inconsistent fields`,
|
|
);
|
|
if (entry.accessedAt > existing.accessedAt) byId.set(entry.id, entry);
|
|
}
|
|
return [...byId.values()];
|
|
}
|
|
|
|
function byNewest(left, right) {
|
|
if (left.createdAt !== right.createdAt)
|
|
return right.createdAt - left.createdAt;
|
|
return right.id - left.id;
|
|
}
|
|
|
|
/**
|
|
* Choose Turbo snapshot ids to delete. `keepKey` is protected even when it is
|
|
* older than the retained generations. Keys outside the prefix abort retention.
|
|
*/
|
|
export function selectPrefixDeletions(
|
|
caches,
|
|
{ prefix, keepKey } = {},
|
|
keepGenerations = RETAINED_CACHE_GENERATIONS,
|
|
) {
|
|
assertRetentionPrefix(prefix);
|
|
if (!Number.isSafeInteger(keepGenerations) || keepGenerations < 1)
|
|
throw new Error("Cache retention must keep at least one generation");
|
|
if (keepKey !== undefined) {
|
|
if (
|
|
typeof keepKey !== "string" ||
|
|
!keepKey.startsWith(prefix) ||
|
|
keepKey === prefix
|
|
)
|
|
throw new Error("Cache retention keep key must extend its prefix");
|
|
}
|
|
const entries = uniqueById(caches.map(normalizeCache));
|
|
for (const entry of entries)
|
|
if (!entry.key.startsWith(prefix))
|
|
throw new Error(
|
|
`Refusing to evaluate a cache outside ${prefix}: ${entry.key}`,
|
|
);
|
|
const protectedIds = new Set(
|
|
[...entries]
|
|
.sort(byNewest)
|
|
.slice(0, keepGenerations)
|
|
.map((entry) => entry.id),
|
|
);
|
|
if (keepKey !== undefined)
|
|
for (const entry of entries)
|
|
if (entry.key === keepKey) protectedIds.add(entry.id);
|
|
return entries.filter((entry) => !protectedIds.has(entry.id));
|
|
}
|
|
|
|
/**
|
|
* Drop extra copies of one npm restore target. Records that share a user key
|
|
* but differ by cache version or git ref stay, because a workflow can restore
|
|
* only a matching version inside its visible scope.
|
|
*/
|
|
export function selectNpmDownloadDuplicateDeletions(caches) {
|
|
const entries = uniqueById(caches.map(normalizeCache));
|
|
for (const entry of entries)
|
|
if (!entry.key.startsWith("npm-download-"))
|
|
throw new Error(
|
|
`Refusing to evaluate a non-download cache: ${entry.key}`,
|
|
);
|
|
const groups = new Map();
|
|
for (const entry of entries) {
|
|
const identity = `${entry.version}\n${entry.ref}\n${entry.key}`;
|
|
const group = groups.get(identity) ?? [];
|
|
group.push(entry);
|
|
groups.set(identity, group);
|
|
}
|
|
const deletions = [];
|
|
for (const group of groups.values()) {
|
|
if (group.length < 2) continue;
|
|
const ranked = [...group].sort((left, right) => {
|
|
if (left.accessedAt !== right.accessedAt)
|
|
return right.accessedAt - left.accessedAt;
|
|
if (left.createdAt !== right.createdAt)
|
|
return right.createdAt - left.createdAt;
|
|
return right.id - left.id;
|
|
});
|
|
deletions.push(...ranked.slice(1));
|
|
}
|
|
return deletions;
|
|
}
|
|
|
|
export async function listAllCaches(request, repository, prefix) {
|
|
const caches = [];
|
|
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
const body = await request(
|
|
"GET",
|
|
`repos/${repository}/actions/caches?per_page=${String(PAGE_SIZE)}&page=${String(page)}&key=${encodeURIComponent(prefix)}`,
|
|
);
|
|
if (
|
|
typeof body !== "object" ||
|
|
body === null ||
|
|
Array.isArray(body) ||
|
|
!Array.isArray(body.actions_caches)
|
|
)
|
|
throw new Error("GitHub cache list is malformed");
|
|
caches.push(...body.actions_caches);
|
|
if (body.actions_caches.length < PAGE_SIZE) return caches;
|
|
}
|
|
throw new Error(
|
|
`Cache list for ${prefix} exceeded ${String(MAX_PAGES)} pages; deleted nothing`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List every requested family before deleting. A malformed list or an unsafe
|
|
* prefix leaves the repository caches unchanged.
|
|
*/
|
|
export async function retainCaches({
|
|
repository,
|
|
request,
|
|
deleteCache,
|
|
families = [],
|
|
dedupeNpmDownloads = false,
|
|
dryRun = false,
|
|
}) {
|
|
if (
|
|
typeof repository !== "string" ||
|
|
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
|
)
|
|
throw new Error(
|
|
"CI_REPOSITORY must be an owner/name repository identifier",
|
|
);
|
|
for (const family of families) {
|
|
assertRetentionPrefix(family.prefix);
|
|
if (
|
|
family.keepKey !== undefined &&
|
|
(typeof family.keepKey !== "string" ||
|
|
!family.keepKey.startsWith(family.prefix) ||
|
|
family.keepKey === family.prefix)
|
|
)
|
|
throw new Error("Cache retention keep key must extend its prefix");
|
|
}
|
|
const plan = [];
|
|
for (const family of families) {
|
|
const caches = await listAllCaches(request, repository, family.prefix);
|
|
plan.push({
|
|
prefix: family.prefix,
|
|
deletions: selectPrefixDeletions(caches, family),
|
|
});
|
|
}
|
|
const npmDeletions = dedupeNpmDownloads
|
|
? selectNpmDownloadDuplicateDeletions(
|
|
await listAllCaches(request, repository, "npm-download-"),
|
|
)
|
|
: [];
|
|
if (!dryRun)
|
|
for (const entry of [
|
|
...plan.flatMap((family) => family.deletions),
|
|
...npmDeletions,
|
|
])
|
|
await deleteCache(entry.id);
|
|
return { dryRun, plan, npmDeletions };
|
|
}
|
|
|
|
function repositoryFromEnv(env) {
|
|
const repository = env.CI_REPOSITORY;
|
|
if (
|
|
typeof repository !== "string" ||
|
|
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
|
)
|
|
throw new Error(
|
|
"CI_REPOSITORY must be an owner/name repository identifier",
|
|
);
|
|
return repository;
|
|
}
|
|
|
|
function familyWithKeepKey(env, prefixName, keepName) {
|
|
const prefix = env[prefixName];
|
|
const keepKey = env[keepName];
|
|
const hasPrefix = typeof prefix === "string" && prefix.length > 0;
|
|
const hasKeepKey = typeof keepKey === "string" && keepKey.length > 0;
|
|
if (!hasPrefix && !hasKeepKey) return undefined;
|
|
if (!hasPrefix || !hasKeepKey)
|
|
throw new Error(`${keepName} is required with ${prefixName}`);
|
|
return { prefix, keepKey };
|
|
}
|
|
|
|
function familiesFromEnv(env) {
|
|
const families = [
|
|
familyWithKeepKey(env, "CACHE_PREFIX", "CACHE_KEEP_KEY"),
|
|
familyWithKeepKey(env, "CACHE_DEEP_PREFIX", "CACHE_DEEP_KEEP_KEY"),
|
|
familyWithKeepKey(env, "CACHE_KNIP_PREFIX", "CACHE_KNIP_KEEP_KEY"),
|
|
typeof env.CACHE_PORTABLE_PREFIX === "string" &&
|
|
env.CACHE_PORTABLE_PREFIX.length > 0
|
|
? { prefix: env.CACHE_PORTABLE_PREFIX }
|
|
: undefined,
|
|
].filter((family) => family !== undefined);
|
|
if (families.length === 0 && env.DEDUPE_NPM_DOWNLOADS !== "true")
|
|
throw new Error(
|
|
"Cache retention requires CACHE_PREFIX or CACHE_PORTABLE_PREFIX or npm dedupe",
|
|
);
|
|
return families;
|
|
}
|
|
|
|
function failureText(error) {
|
|
if (typeof error !== "object" || error === null) return String(error);
|
|
const stderr =
|
|
"stderr" in error && error.stderr !== undefined ? String(error.stderr) : "";
|
|
const message = error instanceof Error ? error.message : "";
|
|
return `${message}\n${stderr}`;
|
|
}
|
|
|
|
async function githubRequest(method, endpoint, env) {
|
|
const { stdout } = await execFileAsync(
|
|
"gh",
|
|
["api", "--method", method, endpoint],
|
|
{
|
|
env,
|
|
encoding: "utf8",
|
|
maxBuffer: MAX_BUFFER,
|
|
timeout: REQUEST_TIMEOUT_MS,
|
|
},
|
|
);
|
|
if (method === "DELETE") return undefined;
|
|
try {
|
|
return JSON.parse(stdout);
|
|
} catch (cause) {
|
|
throw new Error("GitHub cache API returned malformed JSON", { cause });
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const env = process.env;
|
|
const dryRun = env.CACHE_RETENTION_DRY_RUN === "true";
|
|
const report = await retainCaches({
|
|
repository: repositoryFromEnv(env),
|
|
request: (method, endpoint) => githubRequest(method, endpoint, env),
|
|
deleteCache: async (id) => {
|
|
try {
|
|
await githubRequest(
|
|
"DELETE",
|
|
`repos/${repositoryFromEnv(env)}/actions/caches/${String(id)}`,
|
|
env,
|
|
);
|
|
} catch (error) {
|
|
if (/404|not found/iu.test(failureText(error))) return;
|
|
throw error;
|
|
}
|
|
},
|
|
families: familiesFromEnv(env),
|
|
dedupeNpmDownloads: env.DEDUPE_NPM_DOWNLOADS === "true",
|
|
dryRun,
|
|
});
|
|
const summary = {
|
|
dryRun: report.dryRun,
|
|
families: report.plan.map((family) => ({
|
|
prefix: family.prefix,
|
|
deleteCount: family.deletions.length,
|
|
deleteBytes: family.deletions.reduce((sum, entry) => sum + entry.size, 0),
|
|
})),
|
|
npmDuplicateDeleteCount: report.npmDeletions.length,
|
|
npmDuplicateDeleteBytes: report.npmDeletions.reduce(
|
|
(sum, entry) => sum + entry.size,
|
|
0,
|
|
),
|
|
};
|
|
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
}
|
|
|
|
function isDirectRun() {
|
|
const entry = process.argv[1];
|
|
if (!entry) return false;
|
|
try {
|
|
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (isDirectRun()) await main();
|