From 4196958c107387a48678db22eb4d565d5d4ae68d Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:45:40 +0200 Subject: [PATCH] ci: upload each run's screenshots + meta.json to R2 (runs/pcbjam//) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// — 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 --- .github/workflows/wasm-build.yml | 28 +++++ tests/package.json | 1 + tests/tools/screenshots/r2-store.ts | 15 +++ tests/tools/screenshots/upload-run.ts | 148 ++++++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 tests/tools/screenshots/upload-run.ts diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index 65e5135..290aa22 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -50,6 +50,13 @@ on: required: false CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: required: false + # WRITE keypair for the per-run screenshot uploads (runs/pcbjam//, + # 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: build-and-test: @@ -479,6 +486,27 @@ jobs: npm run screenshots:check 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// 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 # rich screenshot report above did NOT post (build broke before the tests → # report skipped, or the report itself errored). An e2e-only failure already diff --git a/tests/package.json b/tests/package.json index 5807716..7144191 100644 --- a/tests/package.json +++ b/tests/package.json @@ -24,6 +24,7 @@ "screenshots:changelog": "tsx tools/screenshots/changelog.ts", "screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts", "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", "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", diff --git a/tests/tools/screenshots/r2-store.ts b/tests/tools/screenshots/r2-store.ts index 6e7a469..3c717b0 100644 --- a/tests/tools/screenshots/r2-store.ts +++ b/tests/tools/screenshots/r2-store.ts @@ -98,6 +98,21 @@ export class R2Store { await res.arrayBuffer().catch(() => undefined); 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 { + 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; diff --git a/tests/tools/screenshots/upload-run.ts b/tests/tools/screenshots/upload-run.ts new file mode 100644 index 0000000..9fb131a --- /dev/null +++ b/tests/tools/screenshots/upload-run.ts @@ -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///.png + * runs/pcbjam//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 { + 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 { + 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; + }); +}