Files
reverse-engineering-anything/scripts/ci/cache-retention.mjs
T
morluto b138f52e12 refactor: prune internal seams and preserve configuration ownership (#10430)
* refactor: prune internal seams and preserve configuration ownership

* fix(setup): keep YAML aliases and inherited registration settings on refresh

- Reuse an unchanged YAML collection only while every alias in it still
  resolves after the edit; otherwise rebuild it so the alias becomes its
  value. A sibling anchor replaced or moved in the same refresh no longer
  leaves an unresolved alias in Hermes or Goose output.
- Keep a connection selector whose existing value already chooses stdio
  (for example an inherited Hermes transport: stdio) instead of superseding
  it; remote selectors are still replaced. Explicit stdio selectors are
  therefore no longer rewritten (Kimi expectation updated).
- When the whole Hermes registration is inherited through a merge,
  override it with that merge and write only the changed fields.

* fix(setup): edit registration fields whose YAML key is an alias

A key written as an alias to a string scalar (for example *field with
field: &field env) was treated as a source-only key, so any change to
its value was refused in Hermes and Goose. Resolve such keys to their
string, keep the aliased spelling while its anchor survives the edit,
and write the plain key when the same edit rebuilds the anchor.

* test(process): bound cleanup-boundary waits and always release gates

Both cancellation tests awaited a bare started promise and released
their gates only on success, so a missed boundary waited for the outer
deadline and could leave cleanup parked. Use awaitAcknowledgement and
release and join the cleanup in finally.

* fix(setup): repair only aliases an edit leaves unresolved

Replace the pre-edit alias survival guess, which expanded aliases to
sibling anchors that survive unchanged, with an exact repair: record
what each alias in the replaced value resolves to, write the edit, then
replace only aliases left without a preceding anchor by that value.

- Keep an aliased key's source position, so its anchor stays ahead of
  later aliases.
- Treat an aliased key as its string only when no other key in the map
  resolves to the same string; an ambiguous one stays guarded.
- Override an inherited Hermes registration only when the requested
  entry keeps every inherited field, so an explicit replacement can drop
  remote selectors.

* fix(setup): repair aliases whose binding an edit changes, keeping comments

An alias whose own anchor was removed could resolve to an earlier anchor
with the same name and silently change value. Record the node each alias
resolved to and repair any alias that no longer resolves to that node.
Carry the alias's comments and spacing onto the repaired value.

* test(setup): split the Hermes anchor and merge refresh cases

* fix(setup): refuse removing an overridden key its merge would restore

The merge-retention check skipped keys the mapping also overrides, so
dropping such a key (for example a remote url during an explicit stdio
replacement) let the retained merge restore it. Check removal before
skipping overridden keys, so the edit is refused with the existing
message instead of producing a registration that cannot be applied.
2026-11-14 04:08:37 -05:00

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. */
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();