Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/publish-wren-core-wasm-rc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-wren-core-wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 25 additions & 15 deletions .github/workflows/wasm-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,6 +34,7 @@ jobs:
targets: wasm32-unknown-unknown

- name: Cache Cargo
id: cargo-cache
uses: actions/cache@v4
with:
path: |
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions core/wren-core-wasm/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion core/wren-core-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions core/wren-core-wasm/scripts/check-size.mjs
Original file line number Diff line number Diff line change
@@ -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 -- <file>`) 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`,
);
}
126 changes: 126 additions & 0 deletions core/wren-core-wasm/sdk/tests/check-size.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading