Skip to content

Build Runtime Compatibility

This page documents the runtime requirements and verification steps that keep this template's build chain green across the Node versions it supports. The contract lives in openspec/specs/build-runtime-compatibility/spec.md; this page is its human-readable mirror.

Required Node.js version range

ConstraintValueSource
Lower bound>=22.0.0package.json engines.node
Recommended22 (current LTS minor).nvmrc (first line)
Also verifiedNode 24 (current Active LTS at apply time)This page + change fix-build-blockers audit

The repository declares both engines.node (pnpm-enforced) and .nvmrc (nvm-enforced) so that:

  • pnpm install on Node 20 or earlier fails with an engines-mismatch error before downloading dependencies.
  • nvm use in the project root automatically switches contributors to a compatible Node major.

Node 20 and earlier are not supported. Drop them in your fork only after independently re-verifying every command in Canonical verification command set.

System wasm-pack requirement

wasm-pack is not distributed via npm any more. The npm-wrapped distribution (wasm-pack@0.14.0) bundles a minizlib shim that fails to load on Node 24 with Class extends value is not a constructor. The template removed that devDependency and instead relies on a wasm-pack binary installed in your PATH.

Install via rustup:

bash
# 1. Install Rust toolchain (one-time)
curl https://sh.rustup.rs -sSf | sh

# 2. Install wasm-pack
# Option A — via the official installer script
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Option B — via cargo
cargo install wasm-pack

After installation, which wasm-pack should point at a real binary (typically ~/.cargo/bin/wasm-pack). If you run pnpm qa:build-prereqs without wasm-pack on PATH, the script exits non-zero before any other check and prints the install URLs above.

Canonical verification command set

The template MUST satisfy this command chain end-to-end in a clean environment (no node_modules, no simnet-engine/target/, no .vitepress/dist, no .vitepress/cache/, system wasm-pack installed) on both Node 22 LTS and Node 24:

bash
pnpm install --frozen-lockfile
pnpm test                  # baseline ≥ 16 test files / 430 tests pass
pnpm validate:challenges    # exit 0
pnpm wasm:build             # populates docs/public/wasm/
pnpm docs:build             # populates .vitepress/dist/
pnpm build                  # end-to-end (flags:ensure + wasm:build:release + docs:build)

After the build chain, run the dual-locale smoke tests:

bash
pnpm qa:site-smoke:dev      # exits 0 across /, /challenges/..., /zh-TW/, /zh-TW/challenges/...
pnpm qa:site-smoke:preview  # same routes against the built artefacts

A clean run leaves no tracked-file changes (git status --porcelain is clean except for the predictable untracked build outputs).

Fallback notice — VitePress version

vitepress@2.0.0-alpha.17 shipped a server-side rendering bug (vitepress data not properly injected in app) that broke pnpm docs:build for downstream forks. The change fix-build-blockers pinned dependencies.vitepress to the latest 1.x stable (^1.6.4) because no stable 2.x existed at upgrade time.

If a future VitePress upgrade reintroduces the same SSR failure, follow this fallback:

  1. Open a new Spectra change that pins dependencies.vitepress to the latest 1.x stable.
  2. Update pnpm-lock.yaml (pnpm install) and re-run the canonical command set on Node 22 LTS and Node 24.
  3. Re-run both pnpm qa:site-smoke:* modes against / and /zh-TW/.
  4. Audit .vitepress/theme/** and .vitepress/config.mts for breaking-change drift between the two majors; apply the smallest possible patch.
  5. Record the registry snapshot (npm view vitepress dist-tags --json) and the decision in your change's audit/upgrade-notes.md.

The supporting fallback audit for the original upgrade lives at openspec/changes/fix-build-blockers/audit/upgrade-notes.md.

CI quality gates

.github/workflows/test.yml enforces the canonical command set on every pull request targeting main and every push to main. The workflow is laid out as six jobs so each contract failure surfaces a discrete status check on the pull request:

JobMatrixSpec contract guarded
lint-validateNode 22pnpm validate:challenges + pnpm qa:build-prereqs from quality-assurance-gates
rust-test(none)cargo test in simnet-engine/ from quality-assurance-gates
ts-testNode 22, 24pnpm test from quality-assurance-gates; Node range from build-runtime-compatibility
wasm-buildNode 22, 24pnpm wasm:build from quality-assurance-gates; per-version artifact simnet-wasm-node<version> from build-runtime-compatibility
docs-buildNode 22, 24pnpm docs:build and dual-locale entry-point presence from quality-assurance-gates
site-smokeNode 22, 24pnpm qa:site-smoke:dev + :preview from quality-assurance-gates; master-key sourcing from flag-secrets-management

Matrix-aware jobs include the Node version in their display name (e.g. TypeScript Tests (Node 22)), so GitHub branch protection lists each matrix variant as an independently selectable required check. A clean push to main therefore produces ten required status checks: two single-version jobs plus four matrix-aware jobs across two Node versions.

docs-build and site-smoke declare needs: wasm-build and download the artifact whose name matches their own Node matrix variant. No job re-runs wasm-pack once wasm-build has succeeded.

Why docs-build and site-smoke need SIMNET_MASTER_KEY

SIMNET_MASTER_KEY is the HMAC root that the build pipeline uses to derive every challenge's flag. The derivation runs in two places at build time:

  1. scripts/ensure-flag-secrets.mjs (prebuild step inside pnpm build) computes HMAC-SHA256(masterKey, "flag:v1:" + challengeSlug) for each challenge and writes the resulting plaintext to docs/challenges/flags.secret.yaml (gitignored).
  2. .vitepress/theme/loaders/challenge.data.ts runs during VitePress SSR and embeds the SHA-256 hash of each flag plaintext into the rendered challenge page. The student's browser later hashes whatever they submit and compares against the embedded value.

scripts/ensure-flag-secrets.mjs and the data loader both gate on NODE_ENV. In dev mode (pnpm dev), an unset SIMNET_MASTER_KEY falls back to the well-known constant simnet_dev_only_key_32bytes_xx!! with a [simnet] WARNING on stderr — fine for local hacking, because the dev-only key never reaches a real user. In production mode (NODE_ENV=production, which pnpm docs:build sets), there is no fallback: an unset key triggers exit 1 with an openssl rand -hex 16 hint.

docs-build and site-smoke both invoke production builds (pnpm docs:build directly, and site-smoke --mode=preview chains through pnpm run docs:build internally), so both jobs require SIMNET_MASTER_KEY. The other four jobs do not: lint-validate runs schema checks, rust-test runs cargo test, ts-test runs vitest in jsdom, and wasm-build only invokes wasm-pack. None of them touch the loader or the prebuild flag derivation.

The dev-only fallback constant is deliberately public in the repository — that is why it must never enter a CI run. Anyone who sees a CI build log containing flag hashes computed from simnet_dev_only_key_32bytes_xx!! can pre-compute every flag plaintext for that build. The CI-only SIMNET_MASTER_KEY_CI secret breaks that link: it is unrelated to any production key and unrelated to the dev-only constant, so even a leak of CI artefacts does not compromise deployed instances.

Master-key sourcing for docs-build and site-smoke

The smoke job sources SIMNET_MASTER_KEY from one of two paths, chosen by if: conditions on the workflow step:

  • Internal pull request or push to main — the step reads the SIMNET_MASTER_KEY_CI repository secret via a GitHub Actions ${{ secrets.SIMNET_MASTER_KEY_CI }} expression. The secret is a dedicated CI-only value created by repository maintainers via gh secret set SIMNET_MASTER_KEY_CI (or the GitHub Settings UI). It MUST be distinct from any production or staging master key. Generate one with openssl rand -hex 16 and rotate it whenever the value is suspected to have leaked.
  • Pull request from a forked repository — GitHub does not expose repository secrets to fork workflows. The step instead generates an ephemeral random key with openssl rand -hex 16 for the duration of the run. The fork's smoke check still runs and produces a status, but SIMNET_MASTER_KEY_CI never enters the fork's job environment.

scripts/qa/site-smoke.mjs and scripts/qa/build-prereqs.mjs no longer carry any module-level master-key fallback. If SIMNET_MASTER_KEY is unset at runtime the script exits with code 1 before launching Playwright and prints an openssl rand -hex 16 hint to stderr. Local contributors running smoke checks must export the variable themselves; pnpm dev is unaffected because its own flags:ensure path runs separately.

GitHub Actions are pinned to commit SHAs

Every uses: reference in test.yml is pinned to a full 40-character commit SHA followed by a # vN.x.y comment marker. Pinning to mutable tags (@v4) would let a compromised action repository ship a new commit to that tag and execute unreviewed code in CI. To upgrade an action, look up the new SHA with gh api repos/<owner>/<repo>/commits/<tag> --jq '.sha' and replace both the SHA and the version comment in a single edit.

Configuring CI in a downstream fork

A new fork inherits this workflow as-is but starts without the SIMNET_MASTER_KEY_CI secret. Two one-time maintainer actions are required before the workflow turns green on internal pull requests:

  1. Create the CI master-key secret. From a shell authenticated to your fork:
    bash
    gh secret set SIMNET_MASTER_KEY_CI --body "$(openssl rand -hex 16)"
    or set it via Settings → Secrets and variables → Actions → New repository secret. Do not reuse the value across environments.
  2. Pin the matrix-aware required checks. In Settings → Branches → Branch protection rule, add the ten status checks to "Status checks that are required" so that branch protection blocks merge when any matrix variant fails:
    • Lint and Validate, Rust Tests
    • TypeScript Tests (Node 22), TypeScript Tests (Node 24)
    • WASM Build (Node 22), WASM Build (Node 24)
    • Docs Build (Node 22), Docs Build (Node 24)
    • Site Smoke (Node 22), Site Smoke (Node 24)

Fork pull requests pass through without the CI secret automatically; they will use the ephemeral-key path described above.