Run trusted private ROM and reference CI on docker-build (#66)
Checks / web (push) Successful in 2m4s
Private ROM checks / private-rom (push) Failing after 17m47s

This commit is contained in:
kawa committed 2026-10-03 19:34:12 +02:00
1 parent 15eaf06c51
commit 03aa913a94
5 files changed
+213 -4

No files matched your search

+52
View File
@@ -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
+3 -1
View File
@@ -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:
+3 -3
View File
@@ -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.
+72
View File
@@ -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;
}
}
+83
View File
@@ -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);
});
});