pcbjam/tests/tools/screenshots/r2-sync.ts
Istvan Matejcsok ec7a1b0787 screenshots: R2-hosted manifest becomes the baseline source of truth (morelli cutover)
The committed tests/screenshot-manifest.json is retired. CI now downloads
baselines/pcbjam/manifest.json (manifest v3, written only by the morelli
review app + its seed script) to the gitignored .baseline-manifest.json,
and everything downstream (pull, verify, compare) reads that copy:

- config.ts: MANIFEST_VERSION 3, MANIFEST_PATH .baseline-manifest.json,
  R2_BASELINES_MANIFEST_KEY; ManifestEntry grows opaque provenance
- r2-sync.ts: new --manifest mode (atomic fetch; no-creds skip DELETES a
  stale copy so the gate skips rather than using old baselines); --push
  gone (bytes enter the CAS only via morelli's promote)
- compare.ts: hard-skips when no manifest was fetched — a stale warm
  cache can never gate
- wasm-build.yml: fetch-manifest step before the baselines cache; cache
  key now hashes the fetched manifest; the gen-manifest --check lint gate
  goes with the committed manifest
- deleted: screenshot-manifest.json, promote.ts, changelog.ts,
  gen-manifest.ts, screenshot-changelog.yml, promote-screenshots skill
- docs (CLAUDE/README/TESTING/WHATWORKS/tools README): promote flow is
  now https://pcbjam-morelli-staging.pcbjam-staging.workers.dev

Validated locally against the real bucket: fetch-manifest (492), cold
pull 492 / warm pull cached=492, no-creds skip chain, compare gate skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:35:08 +02:00

194 lines
8.6 KiB
TypeScript

/**
* Sync the local baseline-screenshots/ cache with the R2 CAS bucket.
*
* The R2-HOSTED manifest (baselines/pcbjam/manifest.json, written only by the
* morelli review app + its seed script) is the source of truth. --manifest
* downloads it to the gitignored MANIFEST_PATH; everything downstream (--pull,
* --verify, compare.ts) reads that local copy, so a single fetch pins the
* whole run to one manifest version.
*
* CLI (from tests/):
* tsx tools/screenshots/r2-sync.ts --manifest # R2 manifest → local .baseline-manifest.json
* tsx tools/screenshots/r2-sync.ts --pull # fetched manifest → local tree (CI fetch step + local gate)
* tsx tools/screenshots/r2-sync.ts --verify # HEAD every manifest hash, exit 1 on any miss
*
* --manifest and --pull without credentials warn and exit 0 — and --manifest
* DELETES a stale local manifest so the gate skips rather than comparing
* against outdated baselines. Secretless callers of the reusable CI workflow
* (release.yml, pcbjam deploy-staging.yml, fork PRs) therefore stay green;
* compare.ts skips its gate when no manifest was fetched. With credentials,
* any 404/corrupt object is collected and the run exits 1.
*
* (--push is gone with the git-manifest era: baseline bytes now enter the CAS
* only via morelli's promote, which copies them from the CI run uploads.)
*/
import * as fs from 'fs';
import * as path from 'path';
import { BASELINE_ROOT, MANIFEST_PATH, MANIFEST_VERSION, R2_BASELINES_MANIFEST_KEY, listEngineKeys, type Manifest, type ManifestEntry } from './config';
import { R2Store, hashFile, missingEnv, storeFromEnv } from './r2-store';
const CONCURRENCY = 16;
/** Parse the fetched manifest if it is the morelli-era v3 format; null when
* absent/unparsable (→ the gate skips). A NEWER version throws — old tooling
* silently no-oping on a future format would disable the whole gate. */
export function loadManifest(root: string): Manifest | null {
const p = path.join(root, MANIFEST_PATH);
if (!fs.existsSync(p)) return null;
let m: Manifest;
try {
m = JSON.parse(fs.readFileSync(p, 'utf8')) as Manifest;
} catch {
return null;
}
if (m.version > MANIFEST_VERSION) {
throw new Error(`${MANIFEST_PATH} is version ${m.version} but this tooling expects ${MANIFEST_VERSION} — update your checkout`);
}
return m.version === MANIFEST_VERSION ? m : null;
}
/**
* Download the R2-hosted baseline manifest to MANIFEST_PATH (atomic tmp+rename).
* Returns false when it could not be fetched — in which case any stale local
* copy is removed, so downstream steps skip instead of using old baselines.
*/
export async function fetchManifest(root: string, store: R2Store): Promise<boolean> {
const dest = path.join(root, MANIFEST_PATH);
const bytes = await store.getKey(R2_BASELINES_MANIFEST_KEY);
if (!bytes) {
fs.rmSync(dest, { force: true });
console.warn(`[r2-sync] ${R2_BASELINES_MANIFEST_KEY} not found in R2 — no baselines (seed via morelli first)`);
return false;
}
const tmp = `${dest}.tmp-${process.pid}`;
fs.writeFileSync(tmp, bytes);
fs.renameSync(tmp, dest);
return true;
}
/** Run `fn` over `items` with at most `limit` in flight. */
export async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) await fn(items[i++]);
});
await Promise.all(workers);
}
/**
* Materialize the manifest's baselines into the local cache tree. Files already
* hashing correctly are skipped (idempotent — a warm cache or restored
* actions/cache costs nothing), writes are atomic (tmp + rename), and local
* PNGs the manifest no longer lists are deleted so a stale branch switch can't
* leave ghost baselines for compare.ts to gate against.
*/
export async function pullBaselines(
root: string,
store: R2Store
): Promise<{ downloaded: number; cached: number; deleted: number }> {
const manifest = loadManifest(root);
if (!manifest) throw new Error(`no fetched ${MANIFEST_PATH} — run \`npm run screenshots:fetch-manifest\` first`);
const base = path.join(root, BASELINE_ROOT);
const wanted = new Map<string, ManifestEntry>();
for (const e of manifest.screenshots) wanted.set(`${e.engine}/${e.name}`, e);
let downloaded = 0;
let cached = 0;
const errors: string[] = [];
await pool([...wanted.values()], CONCURRENCY, async (e) => {
const dest = path.join(base, e.engine, e.name);
// Size is a cheap pre-filter: only pay the full read + hash when it can match.
if (fs.existsSync(dest) && fs.statSync(dest).size === e.bytes && hashFile(dest) === e.sha256) {
cached++;
return;
}
try {
const bytes = await store.get(e.sha256);
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = `${dest}.tmp-${process.pid}`;
fs.writeFileSync(tmp, bytes);
fs.renameSync(tmp, dest);
downloaded++;
} catch (err) {
errors.push(`${e.engine}/${e.name}: ${(err as Error).message}`);
}
});
let deleted = 0;
for (const key of listEngineKeys(base)) {
if (wanted.has(key)) continue;
fs.rmSync(path.join(base, key), { force: true });
deleted++;
}
if (errors.length) throw new Error(`${errors.length} object(s) failed:\n ${errors.join('\n ')}`);
return { downloaded, cached, deleted };
}
/** HEAD every manifest hash; returns the missing keys. */
export async function verifyBaselines(root: string, store: R2Store): Promise<string[]> {
const manifest = loadManifest(root);
if (!manifest) throw new Error(`no fetched ${MANIFEST_PATH} — run \`npm run screenshots:fetch-manifest\` first`);
const missing: string[] = [];
await pool(manifest.screenshots, CONCURRENCY, async (e) => {
if (!(await store.exists(e.sha256))) missing.push(`${e.engine}/${e.name} (${e.sha256})`);
});
return missing;
}
async function main(): Promise<void> {
const mode = process.argv.find((a) => a === '--manifest' || a === '--pull' || a === '--verify');
if (!mode) {
console.error('usage: r2-sync.ts --manifest | --pull | --verify');
process.exitCode = 2;
return;
}
const root = process.cwd();
if (mode === '--pull' && !loadManifest(root)) {
console.log(`[r2-sync] no fetched ${MANIFEST_PATH} — skipping pull (run screenshots:fetch-manifest first)`);
return; // exit 0: manifest fetch skipped/failed → the gate skips, never gates on stale data
}
const store = storeFromEnv();
if (!store) {
if (mode === '--manifest') {
// Delete a stale copy so the downstream gate SKIPS instead of
// comparing against whatever manifest a previous run left behind.
fs.rmSync(path.join(root, MANIFEST_PATH), { force: true });
console.log(`[r2-sync] R2 credentials unset (${missingEnv().join(', ')}) — skipping manifest fetch`);
return; // exit 0: secretless CI callers stay green
}
if (mode === '--pull') {
console.log(`[r2-sync] R2 credentials unset (${missingEnv().join(', ')}) — skipping baseline fetch`);
return; // exit 0: secretless CI callers stay green
}
console.error(`[r2-sync] R2 credentials required for ${mode}: set ${missingEnv().join(', ')}`);
process.exitCode = 2;
return;
}
if (mode === '--manifest') {
if (await fetchManifest(root, store)) {
const manifest = loadManifest(root);
console.log(`[r2-sync] fetched ${MANIFEST_PATH}: ${manifest?.screenshots.length ?? 0} baselines (updated ${manifest?.updatedAt ?? '?'} by ${manifest?.updatedBy ?? '?'})`);
}
} else if (mode === '--pull') {
const { downloaded, cached, deleted } = await pullBaselines(root, store);
console.log(`[r2-sync] pull done: downloaded=${downloaded} cached=${cached} deleted=${deleted}`);
} else {
const missing = await verifyBaselines(root, store);
if (missing.length) {
console.error(`[r2-sync] ${missing.length} manifest hash(es) MISSING in R2:\n ${missing.join('\n ')}`);
process.exitCode = 1;
} else {
console.log('[r2-sync] verify ok — every manifest hash is present in R2');
}
}
}
if (require.main === module) {
main().catch((e) => {
console.error(`[r2-sync] ${(e as Error).message}`);
process.exitCode = 1;
});
}