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.