diff --git a/.github/workflows/publish-wren-core-wasm-rc.yml b/.github/workflows/publish-wren-core-wasm-rc.yml index e1c64488ef..5c506ed30d 100644 --- a/.github/workflows/publish-wren-core-wasm-rc.yml +++ b/.github/workflows/publish-wren-core-wasm-rc.yml @@ -33,6 +33,7 @@ jobs: build-and-publish: name: Build WASM + publish to npm (token-based) runs-on: ubuntu-latest + timeout-minutes: 40 permissions: contents: read steps: @@ -88,6 +89,10 @@ jobs: working-directory: core/wren-core-wasm run: npm run build:dist + - name: WASM binary size check + working-directory: core/wren-core-wasm + run: npm run check:size + - name: Run integration tests working-directory: core/wren-core-wasm run: npm test diff --git a/.github/workflows/publish-wren-core-wasm.yml b/.github/workflows/publish-wren-core-wasm.yml index e9470b385b..3c2b48bb6d 100644 --- a/.github/workflows/publish-wren-core-wasm.yml +++ b/.github/workflows/publish-wren-core-wasm.yml @@ -24,6 +24,7 @@ jobs: build-and-publish: name: Build WASM + publish to npm runs-on: ubuntu-latest + timeout-minutes: 40 # Required by the npm Trusted Publisher config (Environment name: npm). # The 'npm' environment must exist under repo Settings → Environments. environment: npm @@ -87,6 +88,10 @@ jobs: working-directory: core/wren-core-wasm run: npm run build:dist + - name: WASM binary size check + working-directory: core/wren-core-wasm + run: npm run check:size + - name: Run integration tests working-directory: core/wren-core-wasm run: npm test diff --git a/.github/workflows/wasm-ci.yml b/.github/workflows/wasm-ci.yml index 0bef5e4fde..162e3c42b1 100644 --- a/.github/workflows/wasm-ci.yml +++ b/.github/workflows/wasm-ci.yml @@ -24,6 +24,7 @@ jobs: build: name: Build WASM + TypeScript SDK runs-on: ubuntu-latest + timeout-minutes: ${{ github.event_name == 'pull_request' && 20 || 40 }} steps: - uses: actions/checkout@v4 @@ -33,6 +34,7 @@ jobs: targets: wasm32-unknown-unknown - name: Cache Cargo + id: cargo-cache uses: actions/cache@v4 with: path: | @@ -58,10 +60,29 @@ jobs: working-directory: core/wren-core-wasm run: npm install - - name: Build WASM (wasm-pack) + - name: Record build start + run: echo "WASM_BUILD_START=$(date +%s)" >> "$GITHUB_ENV" + + - name: Build WASM (PR fast path, release --no-opt) + if: github.event_name == 'pull_request' + working-directory: core/wren-core-wasm + run: wasm-pack build --target web --release --no-opt + + - name: Build WASM (optimized) + if: github.event_name != 'pull_request' working-directory: core/wren-core-wasm run: wasm-pack build --target web --release + - name: Report build metrics + if: ${{ !cancelled() && env.WASM_BUILD_START != '' }} + run: | + { + echo "### WASM build" + echo "- Mode: ${{ github.event_name == 'pull_request' && 'release --no-opt (PR fast path)' || 'release + wasm-opt (optimized)' }}" + echo "- Cargo cache exact hit: ${{ steps.cargo-cache.outputs.cache-hit == 'true' }}" + echo "- wasm-pack phase: $(( $(date +%s) - WASM_BUILD_START ))s" + } >> "$GITHUB_STEP_SUMMARY" + - name: Build dist (TypeScript + copy) working-directory: core/wren-core-wasm run: npm run build:dist @@ -70,21 +91,10 @@ jobs: working-directory: core/wren-core-wasm run: npm run typecheck - - name: WASM binary size check + - name: WASM binary size check (optimized artifact only) + if: github.event_name != 'pull_request' working-directory: core/wren-core-wasm - run: | - raw_size=$(stat -c%s dist/wren_core_wasm_bg.wasm) - gzip_size=$(gzip -c dist/wren_core_wasm_bg.wasm | wc -c) - raw_mb=$(echo "scale=1; $raw_size / 1048576" | bc) - gzip_mb=$(echo "scale=1; $gzip_size / 1048576" | bc) - echo "WASM binary: ${raw_mb} MB raw, ${gzip_mb} MB gzip" - - # Fail if gzip > 15 MB - max_gzip=$((15 * 1048576)) - if [ "$gzip_size" -gt "$max_gzip" ]; then - echo "::error::WASM binary gzip size (${gzip_mb} MB) exceeds 15 MB limit" - exit 1 - fi + run: npm run check:size - name: Run integration tests working-directory: core/wren-core-wasm diff --git a/core/wren-core-wasm/justfile b/core/wren-core-wasm/justfile index 3f8bb92526..09dde26d85 100644 --- a/core/wren-core-wasm/justfile +++ b/core/wren-core-wasm/justfile @@ -35,9 +35,9 @@ build-dist: # -- Test ----------------------------------------------------------- -# Run SDK integration tests (requires dist/) +# Run the complete SDK test suite: integration + size-gate tests (requires dist/) test: - node --test sdk/tests/index.test.mjs + npm test # TypeScript type check only typecheck: diff --git a/core/wren-core-wasm/package.json b/core/wren-core-wasm/package.json index 252bc74807..169072b0bb 100644 --- a/core/wren-core-wasm/package.json +++ b/core/wren-core-wasm/package.json @@ -21,7 +21,8 @@ "build:dist": "node scripts/build.mjs", "build": "npm run build:wasm && npm run build:dist", "typecheck": "tsc -p sdk/tsconfig.json --noEmit", - "test": "node --test sdk/tests/index.test.mjs" + "test": "node --test sdk/tests/index.test.mjs sdk/tests/check-size.test.mjs", + "check:size": "node scripts/check-size.mjs" }, "keywords": [ "wasm", diff --git a/core/wren-core-wasm/scripts/check-size.mjs b/core/wren-core-wasm/scripts/check-size.mjs new file mode 100644 index 0000000000..7b7b03d006 --- /dev/null +++ b/core/wren-core-wasm/scripts/check-size.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Size gate for the production WASM artifact, shared by wasm-ci.yml (main +// path) and both npm publish workflows via `npm run check:size`. +// +// Measures the artifact with system gzip using the file-argument form +// (`gzip -c -- `) so the filename header is included. Reports the exact +// gzip byte count and MiB figures truncated to one decimal place. +// +// The gate fails closed: missing artifact, invalid limit, or gzip failure +// all exit 1. +// +// Usage: node scripts/check-size.mjs [wasmPath] +// MAX_GZIP_MIB override the limit in MiB (default 15); must be a finite, +// non-negative number, otherwise the gate fails. +import { statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const MIB = 1024 * 1024; +const DEFAULT_WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "dist", + "wren_core_wasm_bg.wasm", +); + +function fail(message) { + console.error(`::error::${message}`); + process.exit(1); +} + +// Truncate (not round) to one decimal place. +function truncMib(bytes) { + return (Math.floor((bytes / MIB) * 10) / 10).toFixed(1); +} + +const wasmPath = process.argv[2] ?? DEFAULT_WASM; + +const rawLimit = process.env.MAX_GZIP_MIB; +const maxGzipMib = + rawLimit === undefined || rawLimit === "" ? 15 : Number(rawLimit); +if (!Number.isFinite(maxGzipMib) || maxGzipMib < 0) { + fail(`Invalid MAX_GZIP_MIB value: ${rawLimit}`); +} +const maxGzipBytes = Math.round(maxGzipMib * MIB); + +let rawBytes; +try { + rawBytes = statSync(wasmPath).size; +} catch (err) { + fail(`Cannot read WASM artifact at ${wasmPath}: ${err.message}`); +} + +const gzip = spawnSync("gzip", ["-c", "--", wasmPath], { + maxBuffer: 512 * MIB, +}); +if (gzip.error) { + fail(`Failed to run system gzip: ${gzip.error.message}`); +} +if (gzip.status !== 0) { + fail(`gzip exited with status ${gzip.status}: ${gzip.stderr}`); +} +const gzipBytes = gzip.stdout.length; + +console.log( + `WASM binary: ${truncMib(rawBytes)} MiB raw, ${truncMib(gzipBytes)} MiB gzip ` + + `(${gzipBytes} bytes gzip, limit ${maxGzipMib} MiB gzip)`, +); +if (gzipBytes > maxGzipBytes) { + fail( + `WASM binary gzip size (${truncMib(gzipBytes)} MiB) exceeds ${maxGzipMib} MiB limit`, + ); +} diff --git a/core/wren-core-wasm/sdk/tests/check-size.test.mjs b/core/wren-core-wasm/sdk/tests/check-size.test.mjs new file mode 100644 index 0000000000..790399a0ca --- /dev/null +++ b/core/wren-core-wasm/sdk/tests/check-size.test.mjs @@ -0,0 +1,126 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "scripts", + "check-size.mjs", +); +const MIB = 1024 * 1024; + +const tmpDirs = []; +after(() => { + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTmpDir(prefix) { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} + +function runGate(args, env = {}) { + return spawnSync(process.execPath, [SCRIPT, ...args], { + env: { ...process.env, ...env }, + encoding: "utf8", + }); +} + +function makeFixture(bytes) { + const file = path.join(makeTmpDir("check-size-"), "fixture.wasm"); + writeFileSync(file, Buffer.alloc(bytes, 0xab)); + return file; +} + +// A fake gzip on PATH that exits with the given code, to exercise the +// "executable runs but fails" branch. +function makeFakeGzipDir(exitCode) { + const dir = makeTmpDir("fake-gzip-"); + const fake = path.join(dir, "gzip"); + writeFileSync(fake, `#!/bin/sh\nexit ${exitCode}\n`); + chmodSync(fake, 0o755); + return dir; +} + +// The gate's own measurement: file-argument form, same file path — so the +// at-limit/over-limit boundary tests are byte-exact against the gate. +function gzipBytesOf(file) { + const out = spawnSync("gzip", ["-c", "--", file], { maxBuffer: 64 * MIB }); + assert.equal(out.status, 0, "test helper: system gzip must be available"); + return out.stdout.length; +} + +test("passes when the artifact is below the limit", () => { + const file = makeFixture(1024); + const res = runGate([file]); + assert.equal(res.status, 0); + assert.match(res.stdout, /limit 15 MiB gzip/); +}); + +test("passes when the artifact is exactly at the limit", () => { + const file = makeFixture(256 * 1024); + const limitMib = gzipBytesOf(file) / MIB; + const res = runGate([file], { MAX_GZIP_MIB: String(limitMib) }); + assert.equal(res.status, 0); +}); + +test("fails when the artifact exceeds the limit", () => { + const file = makeFixture(256 * 1024); + const limitMib = (gzipBytesOf(file) - 1) / MIB; + const res = runGate([file], { MAX_GZIP_MIB: String(limitMib) }); + assert.equal(res.status, 1); + assert.match(res.stderr, /exceeds/); +}); + +test("reports the exact gzip byte count", () => { + const file = makeFixture(256 * 1024); + const res = runGate([file]); + assert.equal(res.status, 0); + assert.match(res.stdout, new RegExp(`\\(${gzipBytesOf(file)} bytes gzip`)); +}); + +test("fails when the artifact is missing", () => { + const missing = path.join(makeTmpDir("check-size-missing-"), "missing.wasm"); + const res = runGate([missing]); + assert.equal(res.status, 1); + assert.match(res.stderr, /Cannot read WASM artifact/); +}); + +test("fails closed on an invalid limit", () => { + const file = makeFixture(1024); + for (const bad of ["abc", "NaN", "-1", "Infinity"]) { + const res = runGate([file], { MAX_GZIP_MIB: bad }); + assert.equal(res.status, 1, `MAX_GZIP_MIB=${bad} must fail closed`); + assert.match(res.stderr, /Invalid MAX_GZIP_MIB/); + } +}); + +test("empty MAX_GZIP_MIB falls back to the default limit", () => { + const file = makeFixture(1024); + const res = runGate([file], { MAX_GZIP_MIB: "" }); + assert.equal(res.status, 0); + assert.match(res.stdout, /limit 15 MiB gzip/); +}); + +test("fails when the gzip executable is unavailable", () => { + const file = makeFixture(1024); + const res = runGate([file], { PATH: "" }); + assert.equal(res.status, 1); + assert.match(res.stderr, /Failed to run system gzip/); +}); + +test("fails when gzip exits non-zero", () => { + const file = makeFixture(1024); + const res = runGate([file], { PATH: makeFakeGzipDir(3) }); + assert.equal(res.status, 1); + assert.match(res.stderr, /gzip exited with status 3/); +});