tests: move screenshot baselines from git to R2 (content-addressed manifest v2)
Baseline PNGs (492, 35 MB) leave git: they now live in the private R2 bucket
pcbjam-ci-screenshots as immutable sha256/<hex>.png objects, pinned by the
committed screenshot-manifest.json (v2: name, engine, sha256, bytes, dims).
tests/baseline-screenshots/ becomes a gitignored cache materialized by the new
`npm run screenshots:fetch` (r2-sync.ts + aws4fetch r2-store.ts).
- promote.ts: requires the RW keypair (shell env or gitignored tests/.env),
syncs the cache, uploads new hashes BEFORE rewriting the manifest; the
manifest diff is the only git-visible output. --prune only edits the
manifest — R2 objects are never deleted, old commits still resolve.
- compare.ts: skips the gate (exit 0, no report.json) when the manifest
expects baselines but the cache is empty (secretless callers).
- changelog.ts: diffs the manifest between revs and fetches bytes from R2;
guards against the migration commit (base manifest not v2 → skip).
- gen-manifest --check: v2 schema + resurrection guard (fails if baseline
PNGs are ever re-committed); credential-free so every caller can gate.
- wasm-build.yml: declares optional read-only S3 secrets, caches + fetches
baselines before the lint gate; release.yml/deploy-staging.yml pass them.
- screenshot-changelog.yml: triggers on the manifest path instead of PNGs.
All 492 objects are seeded and hash-verified in the bucket; fetch/compare
degrade to a warn-and-skip without credentials.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:01:46 +02:00
|
|
|
/**
|
|
|
|
|
* Minimal S3 client for the content-addressed screenshot-baseline bucket.
|
|
|
|
|
*
|
|
|
|
|
* Baselines live in a PRIVATE R2 bucket as immutable objects keyed by content
|
2026-08-19 11:57:50 +02:00
|
|
|
* hash (`sha256/<hex>.png`); the R2-hosted manifest
|
|
|
|
|
* (baselines/pcbjam/manifest.json, written only by the morelli app) maps
|
|
|
|
|
* `<engine>/<name>` to a hash. Auth is a bucket-scoped S3 keypair — read-only
|
|
|
|
|
* for fetch, a separate write pair for CI's run uploads — via the env vars in
|
|
|
|
|
* config.ts R2_ENV; see this directory's README for where to get credentials.
|
tests: move screenshot baselines from git to R2 (content-addressed manifest v2)
Baseline PNGs (492, 35 MB) leave git: they now live in the private R2 bucket
pcbjam-ci-screenshots as immutable sha256/<hex>.png objects, pinned by the
committed screenshot-manifest.json (v2: name, engine, sha256, bytes, dims).
tests/baseline-screenshots/ becomes a gitignored cache materialized by the new
`npm run screenshots:fetch` (r2-sync.ts + aws4fetch r2-store.ts).
- promote.ts: requires the RW keypair (shell env or gitignored tests/.env),
syncs the cache, uploads new hashes BEFORE rewriting the manifest; the
manifest diff is the only git-visible output. --prune only edits the
manifest — R2 objects are never deleted, old commits still resolve.
- compare.ts: skips the gate (exit 0, no report.json) when the manifest
expects baselines but the cache is empty (secretless callers).
- changelog.ts: diffs the manifest between revs and fetches bytes from R2;
guards against the migration commit (base manifest not v2 → skip).
- gen-manifest --check: v2 schema + resurrection guard (fails if baseline
PNGs are ever re-committed); credential-free so every caller can gate.
- wasm-build.yml: declares optional read-only S3 secrets, caches + fetches
baselines before the lint gate; release.yml/deploy-staging.yml pass them.
- screenshot-changelog.yml: triggers on the manifest path instead of PNGs.
All 492 objects are seeded and hash-verified in the bucket; fetch/compare
degrade to a warn-and-skip without credentials.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:01:46 +02:00
|
|
|
*
|
|
|
|
|
* aws4fetch does the SigV4 signing (dependency-free; region is always "auto"
|
|
|
|
|
* on R2). Objects are never deleted here — a baseline prune only edits the
|
|
|
|
|
* manifest, old hashes stay resolvable for old commits.
|
|
|
|
|
*/
|
|
|
|
|
import * as crypto from 'crypto';
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import { AwsClient } from 'aws4fetch';
|
|
|
|
|
import { R2_DEFAULT_BUCKET, R2_ENV, R2_KEY_PREFIX } from './config';
|
|
|
|
|
|
|
|
|
|
export function hashBytes(bytes: Buffer): string {
|
|
|
|
|
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hashFile(file: string): string {
|
|
|
|
|
return hashBytes(fs.readFileSync(file));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RETRIES = 3;
|
|
|
|
|
const BACKOFF_MS = 2000;
|
|
|
|
|
|
|
|
|
|
export class R2Store {
|
|
|
|
|
private client: AwsClient;
|
|
|
|
|
private base: string;
|
|
|
|
|
|
|
|
|
|
constructor(opts: { endpoint: string; bucket: string; accessKeyId: string; secretAccessKey: string }) {
|
|
|
|
|
// aws4fetch has its own 5xx retry loop; disable it (retries: 0) so the
|
|
|
|
|
// backoff policy lives in exactly one place (fetchWithRetry below).
|
|
|
|
|
this.client = new AwsClient({
|
|
|
|
|
accessKeyId: opts.accessKeyId,
|
|
|
|
|
secretAccessKey: opts.secretAccessKey,
|
|
|
|
|
region: 'auto',
|
|
|
|
|
service: 's3',
|
|
|
|
|
retries: 0,
|
|
|
|
|
});
|
|
|
|
|
this.base = `${opts.endpoint.replace(/\/+$/, '')}/${opts.bucket}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private url(hash: string): string {
|
|
|
|
|
return `${this.base}/${R2_KEY_PREFIX}${hash}.png`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Signed fetch, retrying network errors and 5xx — an R2 blip shouldn't fail a whole sync. */
|
|
|
|
|
private async fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
|
|
|
|
let lastErr: unknown;
|
|
|
|
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await this.client.fetch(url, init);
|
|
|
|
|
if (res.status < 500 && res.status !== 429) return res;
|
|
|
|
|
lastErr = new Error(`HTTP ${res.status}`);
|
|
|
|
|
await res.arrayBuffer().catch(() => undefined); // drain before retrying
|
|
|
|
|
} catch (e) {
|
|
|
|
|
lastErr = e;
|
|
|
|
|
}
|
|
|
|
|
if (attempt < RETRIES) await new Promise((r) => setTimeout(r, BACKOFF_MS * attempt));
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`request failed after ${RETRIES} attempts: ${(lastErr as Error).message}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async exists(hash: string): Promise<boolean> {
|
|
|
|
|
const res = await this.fetchWithRetry(this.url(hash), { method: 'HEAD' });
|
|
|
|
|
if (res.status === 200) return true;
|
|
|
|
|
if (res.status === 404) return false;
|
|
|
|
|
throw new Error(`HEAD ${R2_KEY_PREFIX}${hash}.png → HTTP ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Download one object; the key IS the sha256, so every download is integrity-checked. */
|
|
|
|
|
async get(hash: string): Promise<Buffer> {
|
|
|
|
|
const res = await this.fetchWithRetry(this.url(hash), { method: 'GET' });
|
|
|
|
|
if (res.status === 404) throw new Error(`missing object ${R2_KEY_PREFIX}${hash}.png`);
|
|
|
|
|
if (res.status !== 200) throw new Error(`GET ${R2_KEY_PREFIX}${hash}.png → HTTP ${res.status}`);
|
|
|
|
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
|
|
|
const actual = hashBytes(bytes);
|
|
|
|
|
if (actual !== hash) throw new Error(`corrupt download for ${hash} (received sha256 ${actual})`);
|
|
|
|
|
return bytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Upload one object unless already present (same-hash PUTs are idempotent regardless). */
|
|
|
|
|
async put(hash: string, bytes: Buffer): Promise<'uploaded' | 'exists'> {
|
|
|
|
|
if (await this.exists(hash)) return 'exists';
|
|
|
|
|
const res = await this.fetchWithRetry(this.url(hash), {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
// Buffer is a Uint8Array and a valid fetch body; the cast bridges
|
|
|
|
|
// @types/node's Buffer<ArrayBufferLike> vs DOM BufferSource generics.
|
|
|
|
|
body: bytes as unknown as BodyInit,
|
|
|
|
|
headers: { 'content-type': 'image/png' },
|
|
|
|
|
});
|
|
|
|
|
if (res.status !== 200) throw new Error(`PUT ${R2_KEY_PREFIX}${hash}.png → HTTP ${res.status}`);
|
|
|
|
|
await res.arrayBuffer().catch(() => undefined);
|
|
|
|
|
return 'uploaded';
|
|
|
|
|
}
|
2026-08-19 11:45:40 +02:00
|
|
|
|
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 11:54:20 +02:00
|
|
|
/**
|
|
|
|
|
* Download an ARBITRARY key (the R2-hosted baseline manifest) — unlike
|
|
|
|
|
* get(), not content-addressed, so no hash verification is possible; null
|
|
|
|
|
* on 404 so the caller can degrade like the no-credentials path.
|
|
|
|
|
*/
|
|
|
|
|
async getKey(key: string): Promise<Buffer | null> {
|
|
|
|
|
const res = await this.fetchWithRetry(`${this.base}/${key}`, { method: 'GET' });
|
|
|
|
|
if (res.status === 404) {
|
|
|
|
|
await res.arrayBuffer().catch(() => undefined);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (res.status !== 200) throw new Error(`GET ${key} → HTTP ${res.status}`);
|
|
|
|
|
return Buffer.from(await res.arrayBuffer());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 11:45:40 +02:00
|
|
|
/**
|
|
|
|
|
* Upload to an ARBITRARY key (the per-run uploads under runs/…, consumed by
|
|
|
|
|
* the morelli review app) — unlike put(), not content-addressed and always
|
|
|
|
|
* overwrites (workflow re-runs reuse the run id; last attempt wins).
|
|
|
|
|
*/
|
|
|
|
|
async putKey(key: string, bytes: Buffer, contentType: string): Promise<void> {
|
|
|
|
|
const res = await this.fetchWithRetry(`${this.base}/${key}`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: bytes as unknown as BodyInit,
|
|
|
|
|
headers: { 'content-type': contentType },
|
|
|
|
|
});
|
|
|
|
|
if (res.status !== 200) throw new Error(`PUT ${key} → HTTP ${res.status}`);
|
|
|
|
|
await res.arrayBuffer().catch(() => undefined);
|
|
|
|
|
}
|
tests: move screenshot baselines from git to R2 (content-addressed manifest v2)
Baseline PNGs (492, 35 MB) leave git: they now live in the private R2 bucket
pcbjam-ci-screenshots as immutable sha256/<hex>.png objects, pinned by the
committed screenshot-manifest.json (v2: name, engine, sha256, bytes, dims).
tests/baseline-screenshots/ becomes a gitignored cache materialized by the new
`npm run screenshots:fetch` (r2-sync.ts + aws4fetch r2-store.ts).
- promote.ts: requires the RW keypair (shell env or gitignored tests/.env),
syncs the cache, uploads new hashes BEFORE rewriting the manifest; the
manifest diff is the only git-visible output. --prune only edits the
manifest — R2 objects are never deleted, old commits still resolve.
- compare.ts: skips the gate (exit 0, no report.json) when the manifest
expects baselines but the cache is empty (secretless callers).
- changelog.ts: diffs the manifest between revs and fetches bytes from R2;
guards against the migration commit (base manifest not v2 → skip).
- gen-manifest --check: v2 schema + resurrection guard (fails if baseline
PNGs are ever re-committed); credential-free so every caller can gate.
- wasm-build.yml: declares optional read-only S3 secrets, caches + fetches
baselines before the lint gate; release.yml/deploy-staging.yml pass them.
- screenshot-changelog.yml: triggers on the manifest path instead of PNGs.
All 492 objects are seeded and hash-verified in the bucket; fetch/compare
degrade to a warn-and-skip without credentials.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:01:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let envFileLoaded = false;
|
|
|
|
|
/** Best-effort load of ./.env (gitignored) so a dev's keypair can live in a file
|
|
|
|
|
* instead of the shell. Node ≥20.12 built-in; CI is unaffected (no .env there). */
|
|
|
|
|
function ensureEnvFileLoaded(): void {
|
|
|
|
|
if (envFileLoaded) return;
|
|
|
|
|
envFileLoaded = true;
|
|
|
|
|
try {
|
|
|
|
|
if (fs.existsSync('.env')) process.loadEnvFile('.env');
|
|
|
|
|
} catch {
|
|
|
|
|
/* unreadable/malformed .env → shell env only */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The required R2 env vars that are currently unset (bucket has a default, so it's not required). */
|
|
|
|
|
export function missingEnv(): string[] {
|
|
|
|
|
ensureEnvFileLoaded();
|
|
|
|
|
return [R2_ENV.endpoint, R2_ENV.accessKeyId, R2_ENV.secretAccessKey].filter((v) => !process.env[v]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Build a store from the environment (shell vars, falling back to ./.env), or null when credentials are absent. */
|
|
|
|
|
export function storeFromEnv(): R2Store | null {
|
|
|
|
|
if (missingEnv().length) return null;
|
|
|
|
|
return new R2Store({
|
|
|
|
|
endpoint: process.env[R2_ENV.endpoint]!,
|
|
|
|
|
bucket: process.env[R2_ENV.bucket] || R2_DEFAULT_BUCKET,
|
|
|
|
|
accessKeyId: process.env[R2_ENV.accessKeyId]!,
|
|
|
|
|
secretAccessKey: process.env[R2_ENV.secretAccessKey]!,
|
|
|
|
|
});
|
|
|
|
|
}
|