From 03aa913a94b8bb70cafc261432dacd9fd06b03b8 Mon Sep 17 00:00:00 2001 From: kawa Date: Sat, 3 Oct 2026 19:34:12 +0200 Subject: [PATCH] Run trusted private ROM and reference CI on docker-build (#66) --- .gitea/workflows/private-checks.yml | 52 ++++++++++++++++++ README.md | 4 +- docs/NATIVE_PORT.md | 6 +-- scripts/fetch-private-rom.mjs | 72 +++++++++++++++++++++++++ tests/private-rom-download.test.ts | 83 +++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 .gitea/workflows/private-checks.yml create mode 100644 scripts/fetch-private-rom.mjs create mode 100644 tests/private-rom-download.test.ts diff --git a/.gitea/workflows/private-checks.yml b/.gitea/workflows/private-checks.yml new file mode 100644 index 0000000..12e5df9 --- /dev/null +++ b/.gitea/workflows/private-checks.yml @@ -0,0 +1,52 @@ +name: Private ROM checks +on: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +jobs: + private-rom: + # Never expose the ROM to pull-request code or a manually selected branch. + if: github.ref == 'refs/heads/main' + runs-on: docker-build + container: + image: node:22-bookworm + timeout-minutes: 40 + env: + CRYSTAL_ROM_PATH: .reference/ci/crystal.gbc + steps: + - uses: https://github.com/actions/checkout@v4 + with: + persist-credentials: false + - name: Download and verify the private ROM + env: + CRYSTAL_ROM_URL: ${{ secrets.CRYSTAL_ROM_URL }} + run: node scripts/fetch-private-rom.mjs + - run: npm ci + - name: Install reference build dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends build-essential bison pkg-config libpng-dev + - name: Build RGBDS 1.0.3 + run: | + git clone --depth 1 --branch v1.0.3 https://github.com/gbdev/rgbds.git .reference/rgbds + test "$(git -C .reference/rgbds rev-parse HEAD)" = 307846b03ea89ee57bf75f179d5f8051175ac60d + make -C .reference/rgbds -j2 + make -C .reference/rgbds install + - name: Build the pinned PRET reference and verify parity inputs + run: | + git init .reference/pokecrystal + git -C .reference/pokecrystal remote add origin https://github.com/pret/pokecrystal.git + git -C .reference/pokecrystal fetch --depth 1 origin 7a7881d0d62e0ddbd82dcf10e7116807487ac651 + git -C .reference/pokecrystal checkout --detach FETCH_HEAD + make -C .reference/pokecrystal crystal11 -j2 + npm run check:reference + - name: Unit and playthrough tests with the ROM + run: npm test -- --maxWorkers=2 + - run: npx playwright install --with-deps chromium webkit + - name: Chromium and WebKit gameplay and reference comparisons + run: npm run test:e2e -- --workers=2 --trace=off + - name: Remove private data and browser output + if: always() + run: rm -rf .reference test-results playwright-report test-saves diff --git a/README.md b/README.md index 0542a8b..57a615a 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ npm run check:vendor Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored. -[CI checks](https://git.azuze.fr/kawa/Crystal/actions?workflow=checks.yml) run on pushes and pull requests, or manually from Actions. The [workflow](.gitea/workflows/checks.yml) uses the shared `docker-build` runner with Node 22 in a Debian container. It installs from the lockfile, runs synthetic unit and Chromium/WebKit browser tests, builds both applications, and checks vendor integrity and native release isolation. `CRYSTAL_ROM_PATH` points to a verified absent file, so ROM-dependent tests skip; `check:reference` remains a local check requiring the private ROM and reference build. +[CI checks](https://git.azuze.fr/kawa/Crystal/actions?workflow=checks.yml) run on pushes and pull requests, or manually from Actions. The [workflow](.gitea/workflows/checks.yml) uses the shared `docker-build` runner with Node 22 in a Debian container. It installs from the lockfile, runs synthetic unit and Chromium/WebKit browser tests, builds both applications, and checks vendor integrity and native release isolation. `CRYSTAL_ROM_PATH` points to a verified absent file, so ROM-dependent tests skip; `check:reference` runs separately with the private ROM and reference build. + +[Private ROM checks](.gitea/workflows/private-checks.yml) also run on pushes to `main`, or manually with `main` selected. Set the repository Actions secret `CRYSTAL_ROM_URL` to a direct HTTPS download URL accessible to the runner. The job rejects redirects, verifies the 2 MiB Crystal 1.1 SHA-1, builds the pinned PRET reference with RGBDS 1.0.3, runs `check:reference`, then runs unit/playthrough and Chromium/WebKit gameplay/reference tests. The URL is exposed only to the download step. No ROM, reference build, save or gameplay artifact is uploaded or cached; private files and browser output are removed even after test failure. Pull requests retain the synthetic workflow. A missing or inaccessible secret fails the private job instead of silently skipping its tests. Manual-device and visual acceptance cards still require their recorded checks. To reproduce the CI test selection locally: diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 3cd532c..70bab1f 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -13,9 +13,9 @@ make -C .reference/pokecrystal crystal11 -j4 npm run check:reference ``` -Install RGBDS 1.0.3 separately. No script downloads a game ROM. `CRYSTAL_ROM_PATH` optionally selects another local location; checks otherwise use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root. +Install RGBDS 1.0.3 separately for local checks. `CRYSTAL_ROM_PATH` optionally selects another local location; checks otherwise use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root. The private CI workflow downloads the user's ROM from the `CRYSTAL_ROM_URL` repository secret, verifies its size and SHA-1, and builds this reference with the pinned RGBDS 1.0.3 source. It runs only for `main`, never pull requests. -The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay screenshots must stay local and ignored. The shipping profile contains addresses and format constants, not game content. +The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay screenshots must stay in local or private CI workspaces and remain ignored. Private CI deletes them and does not upload artifacts or cache game data. The shipping profile contains addresses and format constants, not game content. ## Routine map @@ -86,7 +86,7 @@ npm run check:release npm run build:reference ``` -User-ROM tests skip when the file is absent, including public CI. Synthetic tests still run. Comparing with rebuilt graphics also needs the private PRET build. Do not report skipped private checks as CI passes. +User-ROM tests skip when the file is absent, including synthetic CI. The separate private CI job requires a verified ROM and PRET build before running these tests. Comparing with rebuilt graphics also needs that build. Manual-save generators stay opt-in, and browser-independent engine traces run only in Chromium. Do not report skipped checks as CI passes. Native production is `dist/`; the optional reference output is `dist-reference/`. Release checks reject emulator dependencies, binary game files and embedded binary assets in the native output. Source review is still required when adding assets or data. diff --git a/scripts/fetch-private-rom.mjs b/scripts/fetch-private-rom.mjs new file mode 100644 index 0000000..ac5ad73 --- /dev/null +++ b/scripts/fetch-private-rom.mjs @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const SIZE = 2 * 1024 * 1024; +const SHA1 = "f2f52230b536214ef7c9924f483392993e226cfb"; + +// Never print the private URL or network errors that might contain it. +export async function downloadPrivateRom(url, destination, fetchImpl = fetch) { + if (!url || !destination) + throw new Error("Set CRYSTAL_ROM_URL and CRYSTAL_ROM_PATH."); + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error("CRYSTAL_ROM_URL must be a direct HTTPS download URL."); + } + if (parsed.protocol !== "https:") + throw new Error("CRYSTAL_ROM_URL must use HTTPS."); + + let response; + let bytes; + try { + response = await fetchImpl(parsed, { + redirect: "error", + signal: AbortSignal.timeout(60_000), + }); + if (response.ok) { + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > SIZE) throw new Error("oversized"); + chunks.push(chunk); + } + bytes = Buffer.concat(chunks); + } + } catch { + throw new Error( + "Private ROM download failed. Check access, HTTPS, direct URL, size and runner IP restrictions.", + ); + } + if (!response.ok) + throw new Error(`Private ROM server returned HTTP ${response.status}.`); + if ( + bytes.length !== SIZE || + createHash("sha1").update(bytes).digest("hex") !== SHA1 + ) + throw new Error( + "Private ROM must match the 2 MiB Crystal 1.1 profile (SHA-1 verification failed).", + ); + + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + await writeFile(destination, bytes, { mode: 0o600, flag: "wx" }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + await downloadPrivateRom( + process.env.CRYSTAL_ROM_URL, + process.env.CRYSTAL_ROM_PATH, + ); + console.log("Private Crystal 1.1 ROM downloaded and verified."); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/tests/private-rom-download.test.ts b/tests/private-rom-download.test.ts new file mode 100644 index 0000000..4da0350 --- /dev/null +++ b/tests/private-rom-download.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { downloadPrivateRom } from "../scripts/fetch-private-rom.mjs"; + +const directories: string[] = []; +const secret = "https://private.invalid/crystal?token=never-log-this"; +function destination() { + const directory = mkdtempSync(join(tmpdir(), "crystal-download-")); + directories.push(directory); + return join(directory, "private", "crystal.gbc"); +} +afterEach(() => { + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); + +it("requires HTTPS before making a request", async () => { + const fetcher = vi.fn(); + await expect( + downloadPrivateRom("http://private.invalid/game", destination(), fetcher), + ).rejects.toThrow("HTTPS"); + expect(fetcher).not.toHaveBeenCalled(); +}); +it("does not disclose the URL in network errors and refuses redirects", async () => { + const fetcher = vi + .fn() + .mockRejectedValue(new Error(`Cannot connect to ${secret}`)); + const path = destination(); + await expect(downloadPrivateRom(secret, path, fetcher)).rejects.toThrow( + /^Private ROM download failed\./, + ); + expect(fetcher.mock.calls[0][1].redirect).toBe("error"); + expect(existsSync(path)).toBe(false); +}); +it("reports an IP restriction failure without saving the error page", async () => { + const path = destination(); + await expect( + downloadPrivateRom( + secret, + path, + async () => new Response("Forbidden", { status: 403 }), + ), + ).rejects.toThrow("HTTP 403"); + expect(existsSync(path)).toBe(false); +}); +it.each([100, 2 * 1024 * 1024, 2 * 1024 * 1024 + 1])( + "refuses invalid ROM content of %i bytes", + async (size) => { + const path = destination(); + await expect( + downloadPrivateRom( + secret, + path, + async () => new Response(new Uint8Array(size)), + ), + ).rejects.toThrow(); + expect(existsSync(path)).toBe(false); + }, +); + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; +describe.skipIf(!existsSync(romPath))("verified private download", () => { + it("writes the exact verified bytes privately and refuses to overwrite an existing file", async () => { + const path = destination(); + const rom = readFileSync(romPath); + const fetcher = async () => new Response(rom); + await downloadPrivateRom(secret, path, fetcher); + expect(readFileSync(path).equals(rom)).toBe(true); + expect(statSync(path).mode & 0o777).toBe(0o600); + await expect(downloadPrivateRom(secret, path, fetcher)).rejects.toThrow(); + expect(readFileSync(path).equals(rom)).toBe(true); + }); +});