ci: upload each run's screenshots + meta.json to R2 (runs/pcbjam/<run-id>/)
Durable per-run screenshot store for the morelli review app (github.com/PCBJam/morelli): after the report step, CI uploads the renders and a meta.json index (identity, branch/commit, per-shot sha256+dims, embedded compare summary) to runs/pcbjam/<GITHUB_RUN_ID>/ — 30-day R2 lifecycle; GH artifacts remain the debugging archive. meta.json is written last as the upload-complete marker. Uses a new optional WRITE keypair (CI_SCREENSHOTS_S3_WRITE_*); without it the step no-ops, so secretless callers stay green. R2Store gains putKey() for the non-CAS run keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
aeacadf603
commit
4196958c10
4 changed files with 192 additions and 0 deletions
28
.github/workflows/wasm-build.yml
vendored
28
.github/workflows/wasm-build.yml
vendored
|
|
@ -50,6 +50,13 @@ on:
|
||||||
required: false
|
required: false
|
||||||
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY:
|
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY:
|
||||||
required: false
|
required: false
|
||||||
|
# WRITE keypair for the per-run screenshot uploads (runs/pcbjam/<run-id>/,
|
||||||
|
# 30-day R2 lifecycle) consumed by the morelli review app. Optional for the
|
||||||
|
# same reason: without it the upload-run step just no-ops.
|
||||||
|
CI_SCREENSHOTS_S3_WRITE_ACCESS_KEY_ID:
|
||||||
|
required: false
|
||||||
|
CI_SCREENSHOTS_S3_WRITE_SECRET_ACCESS_KEY:
|
||||||
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
|
|
@ -479,6 +486,27 @@ jobs:
|
||||||
npm run screenshots:check
|
npm run screenshots:check
|
||||||
npm run screenshots:report -- --e2e "$E2E"
|
npm run screenshots:report -- --e2e "$E2E"
|
||||||
|
|
||||||
|
# Durable per-run screenshot store for the morelli review app: upload this
|
||||||
|
# run's renders + meta.json to runs/pcbjam/<run-id>/ in the R2 bucket
|
||||||
|
# (30-day lifecycle rule — GH artifacts stay the debugging archive, R2 is
|
||||||
|
# the promotion source). Runs AFTER the report step so compare.ts's
|
||||||
|
# report.json exists to embed. Needs the WRITE keypair (mapped onto the
|
||||||
|
# standard env names for this step only); without it the tool no-ops, so
|
||||||
|
# forks/secretless callers stay green. Never blocks a build.
|
||||||
|
- name: Upload run screenshots to R2
|
||||||
|
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
working-directory: tests
|
||||||
|
env:
|
||||||
|
CI_SCREENSHOTS_S3_ENDPOINT: ${{ vars.CI_SCREENSHOTS_S3_ENDPOINT }}
|
||||||
|
CI_SCREENSHOTS_S3_ACCESS_KEY_ID: ${{ secrets.CI_SCREENSHOTS_S3_WRITE_ACCESS_KEY_ID }}
|
||||||
|
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: ${{ secrets.CI_SCREENSHOTS_S3_WRITE_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
E2E=pass
|
||||||
|
{ [ "${{ steps.e2e.outcome }}" = "success" ] \
|
||||||
|
&& [ "${{ steps.web_e2e.outcome }}" = "success" ]; } || E2E=fail
|
||||||
|
npm run screenshots:upload-run -- --e2e "$E2E"
|
||||||
|
|
||||||
# FALLBACK on failure: a minimal text-only "CI failed" notice, only when the
|
# FALLBACK on failure: a minimal text-only "CI failed" notice, only when the
|
||||||
# rich screenshot report above did NOT post (build broke before the tests →
|
# rich screenshot report above did NOT post (build broke before the tests →
|
||||||
# report skipped, or the report itself errored). An e2e-only failure already
|
# report skipped, or the report itself errored). An e2e-only failure already
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"screenshots:changelog": "tsx tools/screenshots/changelog.ts",
|
"screenshots:changelog": "tsx tools/screenshots/changelog.ts",
|
||||||
"screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts",
|
"screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts",
|
||||||
"screenshots:fetch": "tsx tools/screenshots/r2-sync.ts --pull",
|
"screenshots:fetch": "tsx tools/screenshots/r2-sync.ts --pull",
|
||||||
|
"screenshots:upload-run": "tsx tools/screenshots/upload-run.ts",
|
||||||
"screenshots:push": "tsx tools/screenshots/r2-sync.ts --push",
|
"screenshots:push": "tsx tools/screenshots/r2-sync.ts --push",
|
||||||
"3d:compare": "tsx tools/screenshots/compare-dirs.ts",
|
"3d:compare": "tsx tools/screenshots/compare-dirs.ts",
|
||||||
"3d:check": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/native --out 3d-regression/output/diff/native-self --floors 3d-regression/floors.json --level native-self --label 3d-native --fail-on-change",
|
"3d:check": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/native --out 3d-regression/output/diff/native-self --floors 3d-regression/floors.json --level native-self --label 3d-native --fail-on-change",
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,21 @@ export class R2Store {
|
||||||
await res.arrayBuffer().catch(() => undefined);
|
await res.arrayBuffer().catch(() => undefined);
|
||||||
return 'uploaded';
|
return 'uploaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let envFileLoaded = false;
|
let envFileLoaded = false;
|
||||||
|
|
|
||||||
148
tests/tools/screenshots/upload-run.ts
Normal file
148
tests/tools/screenshots/upload-run.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/**
|
||||||
|
* Upload this CI run's screenshot renders + a meta.json index to the private
|
||||||
|
* R2 bucket, so the morelli review app (github.com/PCBJam/morelli) can list
|
||||||
|
* builds and promote baselines from the UI.
|
||||||
|
*
|
||||||
|
* Key layout (30-day lifecycle rule on runs/ — objects expire, baselines don't):
|
||||||
|
* runs/pcbjam/<GITHUB_RUN_ID>/<engine>/<name>.png
|
||||||
|
* runs/pcbjam/<GITHUB_RUN_ID>/meta.json ← written LAST = upload-complete marker
|
||||||
|
*
|
||||||
|
* The meta.json schema is MIRRORED from morelli's src/shared/schemas.ts
|
||||||
|
* (RunMeta, schemaVersion 1) — that file is canonical; change it first.
|
||||||
|
*
|
||||||
|
* Workflow re-runs reuse GITHUB_RUN_ID, so a re-run overwrites the prefix
|
||||||
|
* (meta.json records runAttempt; last attempt wins — deliberate).
|
||||||
|
*
|
||||||
|
* Needs the WRITE keypair (CI maps CI_SCREENSHOTS_S3_WRITE_* GH secrets onto
|
||||||
|
* the standard env names for this step only). Without credentials it warns and
|
||||||
|
* exits 0, like r2-sync --pull — screenshot uploads never fail a build.
|
||||||
|
*
|
||||||
|
* CLI (from tests/): tsx tools/screenshots/upload-run.ts --e2e pass|fail
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { DIFF_OUT_DIR, RESULTS_DIR, isIgnored, listEngineKeys, splitKey } from './config';
|
||||||
|
import { hashBytes, missingEnv, storeFromEnv, type R2Store } from './r2-store';
|
||||||
|
import { pool } from './r2-sync';
|
||||||
|
import { loadPng } from './image-ops';
|
||||||
|
|
||||||
|
const PIPELINE = 'pcbjam';
|
||||||
|
const META_SCHEMA_VERSION = 1;
|
||||||
|
const UPLOAD_CONCURRENCY = 8;
|
||||||
|
|
||||||
|
type RunScreenshot = { name: string; engine: string; sha256: string; bytes: number; width: number; height: number };
|
||||||
|
|
||||||
|
function runPrefix(runId: string): string {
|
||||||
|
return `runs/${PIPELINE}/${runId}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitSubject(): string {
|
||||||
|
try {
|
||||||
|
return execFileSync('git', ['log', '-1', '--pretty=%s'], { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prNumber(): number | null {
|
||||||
|
const m = /^refs\/pull\/(\d+)\//.exec(process.env.GITHUB_REF ?? '');
|
||||||
|
return m ? Number(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compress compare.ts's report.json (if this run produced one) into the meta summary. */
|
||||||
|
function reportSummary(root: string): object | undefined {
|
||||||
|
const p = path.join(root, DIFF_OUT_DIR, 'report.json');
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
const report = JSON.parse(fs.readFileSync(p, 'utf8')) as {
|
||||||
|
changed: Array<{ name: string; changedRatio: number; driftHint: string | null }>;
|
||||||
|
added: Array<{ name: string }>;
|
||||||
|
removed: Array<{ name: string }>;
|
||||||
|
unchangedCount: number;
|
||||||
|
driftLikely: boolean;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
changed: report.changed.map((c) => ({ ...splitKey(c.name), changedRatio: c.changedRatio, driftHint: c.driftHint })),
|
||||||
|
added: report.added.map((a) => a.name),
|
||||||
|
removed: report.removed.map((r) => r.name),
|
||||||
|
unchangedCount: report.unchangedCount,
|
||||||
|
driftLikely: report.driftLikely,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[upload-run] unreadable ${DIFF_OUT_DIR}/report.json — omitting summary: ${(e as Error).message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadRun(root: string, store: R2Store, runId: string, e2e: string): Promise<number> {
|
||||||
|
const resultsDir = path.join(root, RESULTS_DIR);
|
||||||
|
// Engine-qualified renders only; screenshot-diff/ isn't an engine dir so
|
||||||
|
// listEngineKeys never picks up compare artifacts.
|
||||||
|
const keys = listEngineKeys(resultsDir).filter((k) => !isIgnored(k));
|
||||||
|
if (keys.length === 0) {
|
||||||
|
console.warn(`[upload-run] no renders under ${RESULTS_DIR}/{chromium,firefox,webkit} — nothing to upload`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshots: RunScreenshot[] = keys
|
||||||
|
.map((key) => {
|
||||||
|
const file = path.join(resultsDir, key);
|
||||||
|
const bytes = fs.readFileSync(file);
|
||||||
|
const png = loadPng(file);
|
||||||
|
const { engine, name } = splitKey(key);
|
||||||
|
return { name, engine, sha256: hashBytes(bytes), bytes: bytes.length, width: png.width, height: png.height };
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.engine !== b.engine ? (a.engine < b.engine ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||||
|
|
||||||
|
const prefix = runPrefix(runId);
|
||||||
|
await pool(screenshots, UPLOAD_CONCURRENCY, async (shot) => {
|
||||||
|
const bytes = fs.readFileSync(path.join(resultsDir, `${shot.engine}/${shot.name}`));
|
||||||
|
await store.putKey(`${prefix}${shot.engine}/${shot.name}`, bytes, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
schemaVersion: META_SCHEMA_VERSION,
|
||||||
|
pipeline: PIPELINE,
|
||||||
|
repo: process.env.GITHUB_REPOSITORY ?? 'PCBJam/pcbjam',
|
||||||
|
runId,
|
||||||
|
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? 1),
|
||||||
|
workflow: process.env.GITHUB_WORKFLOW ?? '',
|
||||||
|
event: process.env.GITHUB_EVENT_NAME ?? '',
|
||||||
|
branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || '',
|
||||||
|
prNumber: prNumber(),
|
||||||
|
commit: process.env.GITHUB_SHA ?? '',
|
||||||
|
commitSubject: gitSubject(),
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
e2e: e2e === 'pass' || e2e === 'fail' ? e2e : 'unknown',
|
||||||
|
screenshots,
|
||||||
|
...(reportSummary(root) ? { report: reportSummary(root) } : {}),
|
||||||
|
};
|
||||||
|
// meta.json goes LAST: its presence is the upload-complete marker the app keys on.
|
||||||
|
await store.putKey(`${prefix}meta.json`, Buffer.from(JSON.stringify(meta, null, 2) + '\n'), 'application/json');
|
||||||
|
return screenshots.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const e2eIdx = process.argv.indexOf('--e2e');
|
||||||
|
const e2e = e2eIdx !== -1 ? (process.argv[e2eIdx + 1] ?? 'unknown') : 'unknown';
|
||||||
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
|
if (!runId || !/^\d+$/.test(runId)) {
|
||||||
|
console.warn('[upload-run] GITHUB_RUN_ID unset — not a CI run, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const store = storeFromEnv();
|
||||||
|
if (!store) {
|
||||||
|
console.warn(`[upload-run] R2 credentials unset (${missingEnv().join(', ')}) — skipping run upload`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = await uploadRun(process.cwd(), store, runId, e2e);
|
||||||
|
if (count > 0) console.log(`[upload-run] uploaded ${count} screenshots + meta.json to ${runPrefix(runId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(`[upload-run] ${(e as Error).message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue