From 2d80e5612bfeec2ad530bc5acb92370865b65074 Mon Sep 17 00:00:00 2001 From: jerryfane Date: Wed, 9 Sep 2026 09:15:25 +0200 Subject: [PATCH] ci: add an automated release gate before inscribing - scripts/gate.mjs: tests, pinned minification, tests against the minified artifact, byte budget against the live inscription body, browser smoke, and a printed release record (bytes, sha256, dependency inscription IDs) - scripts/browser-smoke.mjs: real Chromium loading the library through /content/ against a stub ord server, asserting the 1 MiB round-trip, the empty-sat path making no /content/null request, and the decoder inscription loading lazily exactly once - package.json/.gitignore: dev-only tooling, nothing inscribed - CI runs the same gate on push and pull request --- .github/workflows/gate.yml | 31 +++++++++ .gitignore | 3 +- README.md | 24 ++++++- bun.lock | 17 +++++ package.json | 15 +++++ scripts/browser-smoke.mjs | 126 +++++++++++++++++++++++++++++++++++++ scripts/gate.mjs | 104 ++++++++++++++++++++++++++++++ 7 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/gate.yml create mode 100644 bun.lock create mode 100644 package.json create mode 100644 scripts/browser-smoke.mjs create mode 100644 scripts/gate.mjs diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..89ade8e --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,31 @@ +name: release gate + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + # The minifier configuration is part of the release record, so the version + # is pinned rather than floating. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + + - run: npm install --no-package-lock --no-save playwright@1.56.1 + - run: npx playwright install --with-deps chromium + + # Unit tests, minification, unit tests against the minified artifact, the + # byte budget, and a real Chromium loading the library through + # /content/ exactly as an inscription does. + - run: node scripts/gate.mjs diff --git a/.gitignore b/.gitignore index ccc9fd9..9ba488b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -*.DS_Store \ No newline at end of file +*.DS_Store +node_modules/ diff --git a/README.md b/README.md index 8b3799e..f018eb6 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ This example outlines the basic structure for utilizing OrdJS within an asynchro - The pinned CBOR decoder still loses precision on 64-bit integers and collapses distinct map keys. Fixing that needs a separate audited decoder inscription. -## Tests +## Tests and the release gate `test/` runs on Node's built-in runner with no dependencies: @@ -85,8 +85,26 @@ This example outlines the basic structure for utilizing OrdJS within an asynchro node --test test/ordjs.test.mjs ``` -Tests, examples and this README are repository files only; inscribing the library -should use a minified `src/content/OrdJS.js`. +Before inscribing, run the full gate. It runs the tests, minifies with a pinned +configuration, re-runs the tests against the **minified** artifact, enforces a byte +budget against the size of the live inscription, drives a real Chromium that loads +the library through `/content/` exactly as an inscription does, and prints the +byte counts, hash and dependency inscription IDs a release record needs: + +```sh +bun install # playwright, dev-only +npx playwright install chromium +node scripts/gate.mjs # --no-browser skips only the Chromium stage +``` + +CI runs the same gate on every push and pull request. + +The gate does not cover, and a release still requires: a real ord server with the +sat index both enabled and disabled, Firefox and WebKit, and a commit/reveal fee +dry-run at the chosen rate. + +Tests, examples, tooling and this README are repository files only; inscribing the +library uses a minified `src/content/OrdJS.js` and nothing else. ## Contributing diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..dfcfe53 --- /dev/null +++ b/bun.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ordjs", + "devDependencies": { + "playwright": "^1.56.1", + }, + }, + }, + "packages": { + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f826fb1 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "ordjs", + "version": "0.1.3-beta", + "private": true, + "description": "Development tooling for the OrdJS inscription. Nothing here is inscribed: the released artifact is a minified src/content/OrdJS.js.", + "license": "MIT", + "scripts": { + "test": "node --test test/ordjs.test.mjs", + "smoke": "node scripts/browser-smoke.mjs", + "gate": "node scripts/gate.mjs" + }, + "devDependencies": { + "playwright": "^1.56.1" + } +} diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs new file mode 100644 index 0000000..f54500c --- /dev/null +++ b/scripts/browser-smoke.mjs @@ -0,0 +1,126 @@ +// Real-browser smoke test for the inscription path. +// +// Serves a stub ord server, loads the library the way an inscription does — +// `); + return; + } + const route = routes.get(req.url); + if (!route) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + return; + } + res.writeHead(200, { 'content-type': route.type }); + res.end(route.body); +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; + +const browser = await chromium.launch(); +const failures = []; +try { + const page = await browser.newPage(); + page.on('pageerror', (error) => failures.push(`page error: ${error.message}`)); + await page.goto(`${origin}/`, { waitUntil: 'load' }); + + const loaded = await page.evaluate(() => typeof OrdJS); + if (loaded !== 'function') { + throw new Error(`library did not define OrdJS through the recursive script path (typeof ${loaded})`); + } + + const result = await page.evaluate(async (ids) => { + const ord = new OrdJS(''); + const out = {}; + out.blockheight = await ord.getBlockheight(); + out.emptySat = await ord.getSatLastInscriptionContent(ids.emptySat); + + const content = await ord.getSatLastInscriptionContent(ids.fullSat); + out.mime = content.mime; + const bytes = Uint8Array.from(atob(content.base64), (c) => c.charCodeAt(0)); + out.length = bytes.length; + out.exact = bytes.every((b, i) => b === i % 256); + + out.decoderBefore = globalThis.__decoderLoads ?? 0; + const [a, b] = await Promise.all([ + ord.getDecodedMetadata(ids.imageId), + ord.getDecodedMetadata(ids.imageId) + ]); + out.decoded = [a.bytes, b.bytes]; + out.decoderAfter = globalThis.__decoderLoads ?? 0; + out.scriptTags = document.querySelectorAll('script[src^="/content/"]').length; + return out; + }, { emptySat: EMPTY_SAT, fullSat: FULL_SAT, imageId: IMAGE_ID }); + + const check = (name, ok, detail) => { + if (!ok) failures.push(`${name}: ${detail}`); + }; + check('blockheight', result.blockheight === 850000, `got ${result.blockheight}`); + check('empty sat resolves null', result.emptySat === null, `got ${JSON.stringify(result.emptySat)}`); + check('mime preserved', result.mime?.startsWith('image/png'), `got ${result.mime}`); + check('1 MiB length', result.length === 1024 * 1024, `got ${result.length}`); + check('1 MiB bytes exact', result.exact === true, 'content did not round-trip'); + check('decoder not loaded early', result.decoderBefore === 0, `loaded ${result.decoderBefore} times before use`); + check('decoder loaded once', result.decoderAfter === 1, `loaded ${result.decoderAfter} times`); + check('decoded metadata', JSON.stringify(result.decoded) === JSON.stringify([[1, 2], [1, 2]]), + JSON.stringify(result.decoded)); + check('no /content/null request', !requested.includes('/content/null'), 'library requested /content/null'); + check('recursive script tags', result.scriptTags === 2, `found ${result.scriptTags}`); +} finally { + await browser.close(); + server.close(); +} + +if (failures.length > 0) { + console.error('browser smoke FAILED'); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log(`browser smoke ok (${libPath})`); diff --git a/scripts/gate.mjs b/scripts/gate.mjs new file mode 100644 index 0000000..7452cc7 --- /dev/null +++ b/scripts/gate.mjs @@ -0,0 +1,104 @@ +// Release gate: everything that must pass before bytes are inscribed. +// +// Inscribing is irreversible and costs money, so this runs the checks that a +// unit test alone does not cover, and prints the byte accounting a release +// record needs. +// +// node scripts/gate.mjs full gate (needs bun + playwright chromium) +// node scripts/gate.mjs --no-browser skip only the real-browser stage +// +// Stages: +// 1. unit tests against the readable source +// 2. minify with a pinned, recorded configuration +// 3. unit tests again against the MINIFIED artifact (the thing that ships) +// 4. byte budget: the artifact must not exceed the live inscription body +// 5. real browser: recursive /content/ load, 1 MiB round-trip, lazy decoder +// 6. release record: sizes, hashes, dependency inscription IDs + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, writeFileSync, cpSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const source = join(root, 'src/content/OrdJS.js'); +const withBrowser = !process.argv.includes('--no-browser'); + +// The body of the live v0.1.2 inscription. A release must not grow past what is +// already on chain without a deliberate decision. +const BUDGET_BYTES = 3911; + +// Minifier configuration is part of the release record: a different config +// produces a different artifact from identical source. +const MINIFY = ['build', '--no-bundle', '--minify-syntax', '--minify-whitespace', '--target=browser']; + +const steps = []; +function run(name, command, args, options = {}) { + process.stdout.write(`\n=== ${name}\n`); + const result = spawnSync(command, args, { stdio: 'inherit', cwd: root, ...options }); + const ok = result.status === 0; + steps.push({ name, ok }); + if (!ok && result.error) console.error(String(result.error.message)); + return ok; +} + +run('unit tests (source)', 'node', ['--test', 'test/ordjs.test.mjs']); + +const work = mkdtempSync(join(tmpdir(), 'ordjs-gate-')); +const minified = join(work, 'OrdJS.min.js'); +const minifiedOk = run('minify', 'bun', [...MINIFY, source, '--outfile', minified]); + +let bytes = null; +if (minifiedOk && existsSync(minified)) { + const artifact = readFileSync(minified); + bytes = artifact.length; + + // Run the same suite against the minified artifact by swapping it in for the + // source the tests read. A minifier that breaks behaviour must fail here. + const mirror = join(work, 'repo'); + cpSync(root, mirror, { + recursive: true, + filter: (path) => !path.includes('/.git/') && !path.endsWith('/.git') + }); + writeFileSync(join(mirror, 'src/content/OrdJS.js'), artifact); + run('unit tests (minified artifact)', 'node', ['--test', 'test/ordjs.test.mjs'], { cwd: mirror }); + + const withinBudget = bytes <= BUDGET_BYTES; + steps.push({ name: `byte budget (${bytes} <= ${BUDGET_BYTES})`, ok: withinBudget }); + process.stdout.write(`\n=== byte budget\n${bytes} B minified, budget ${BUDGET_BYTES} B, ` + + `${withinBudget ? `${BUDGET_BYTES - bytes} B of headroom` : `${bytes - BUDGET_BYTES} B OVER`}\n`); + + if (withBrowser) { + run('browser smoke (minified artifact)', 'node', ['scripts/browser-smoke.mjs', minified]); + } else { + process.stdout.write('\n=== browser smoke\nSKIPPED (--no-browser): the inscription path is unverified\n'); + } + + const sha256 = createHash('sha256').update(artifact).digest('hex'); + const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout?.trim(); + const dependencies = [...readFileSync(source, 'utf8').matchAll(/[0-9a-f]{64}i\d+/g)].map((m) => m[0]); + + process.stdout.write('\n=== release record\n'); + process.stdout.write(JSON.stringify({ + commit, + source_bytes: readFileSync(source).length, + artifact_bytes: bytes, + artifact_sha256: sha256, + minifier: `bun ${spawnSync('bun', ['--version'], { encoding: 'utf8' }).stdout?.trim()} ${MINIFY.join(' ')}`, + dependency_inscriptions: dependencies, + live_inscription_body_bytes: BUDGET_BYTES + }, null, 2) + '\n'); + process.stdout.write('\nNot covered by this gate, and required before inscribing: a real ord server ' + + '(indexed and non-indexed), Firefox and WebKit, and a commit/reveal fee dry-run at the chosen rate.\n'); +} + +const failed = steps.filter((step) => !step.ok); +process.stdout.write('\n=== gate summary\n'); +for (const step of steps) process.stdout.write(`${step.ok ? 'pass' : 'FAIL'} ${step.name}\n`); +if (failed.length > 0) { + process.stdout.write(`\n${failed.length} stage(s) failed; do not inscribe.\n`); + process.exit(1); +} +process.stdout.write('\nAll gate stages passed.\n');