Build Runtime 相容性
本頁說明維持本範本 build chain 在所支援 Node 版本上順利執行所需的執行環境要求與驗證步驟。契約規範於 openspec/specs/build-runtime-compatibility/spec.md,本頁為其人類可讀的對應版本。
必備 Node.js 版本範圍
| 約束 | 值 | 來源 |
|---|---|---|
| 下限 | >=22.0.0 | package.json engines.node |
| 建議 | 22(當前 LTS 主版本) | .nvmrc 首行 |
| 一同驗證 | Node 24(apply 階段的 current Active LTS) | 本頁 + change fix-build-blockers 的 audit |
repo 同時宣告 engines.node(由 pnpm 強制檢查)與 .nvmrc(由 nvm 強制切換),因此:
- 在 Node 20 或更舊版本上跑
pnpm install會被 engines 拒絕,連相依套件都不會下載。 - 在 repo 根目錄跑
nvm use會自動切換到相容的 Node 主版本。
Node 20 與更舊版本不在支援範圍。如果你的 fork 想下修,請先把 Canonical 驗證指令鏈 全部重跑一次再決定。
系統 wasm-pack 必裝
wasm-pack 不再透過 npm 散布。原本由 npm 提供的 wasm-pack@0.14.0 內含 minizlib shim,在 Node 24 載入時會拋 Class extends value is not a constructor。本範本已移除該 devDependency,改為要求 wasm-pack 二進位安裝在你的 PATH 上。
安裝步驟(透過 rustup):
# 1. 安裝 Rust toolchain(只需一次)
curl https://sh.rustup.rs -sSf | sh
# 2. 安裝 wasm-pack
# 選項 A — 透過官方 installer 腳本
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# 選項 B — 透過 cargo
cargo install wasm-pack安裝完成後,which wasm-pack 應該指到真正的 binary(通常是 ~/.cargo/bin/wasm-pack)。若你在 PATH 上沒有 wasm-pack 而執行 pnpm qa:build-prereqs,腳本會在其他任何檢查之前以 non-zero exit code 結束,並印出上面的安裝連結。
Canonical 驗證指令鏈
本範本必須在乾淨環境下(無 node_modules、無 simnet-engine/target/、無 .vitepress/dist、無 .vitepress/cache/、系統有 wasm-pack)跑通以下指令鏈,且需在 Node 22 LTS 與 Node 24 上分別驗過:
pnpm install --frozen-lockfile
pnpm test # 基線 ≥ 16 個測試檔 / 430 tests 通過
pnpm validate:challenges # exit 0
pnpm wasm:build # 產生 docs/public/wasm/
pnpm docs:build # 產生 .vitepress/dist/
pnpm build # 端到端(flags:ensure + wasm:build:release + docs:build)跑完 build chain 後,繼續執行雙語 smoke:
pnpm qa:site-smoke:dev # /、/challenges/...、/zh-TW/、/zh-TW/challenges/... 皆 exit 0
pnpm qa:site-smoke:preview # 同樣 routes,對著 build 產物乾淨環境跑完之後,git status --porcelain 不應有 tracked 區改動(僅允許預期內的未追蹤 build 產物)。
VitePress 版本的 fallback 機制
vitepress@2.0.0-alpha.17 帶有一個 server-side rendering bug(vitepress data not properly injected in app),會讓下游 fork 的 pnpm docs:build 直接掛掉。本 change fix-build-blockers 把 dependencies.vitepress 釘到當下最新的 1.x stable(^1.6.4),因為升版當下並沒有任何 2.x stable 存在。
如果未來再次升 VitePress 又踩到同樣的 SSR 失敗,請依照下列 fallback 程序:
- 開一個新的 Spectra change,把
dependencies.vitepress釘回最新的 1.x stable。 - 跑
pnpm install更新pnpm-lock.yaml,再依照 Canonical 驗證指令鏈 在 Node 22 LTS 與 Node 24 上各跑一次。 pnpm qa:site-smoke:*兩個模式對/與/zh-TW/都重跑一次。- 審視
.vitepress/theme/**與.vitepress/config.mts在兩個 major 版本之間的 breaking change,採取最小修補。 - 在新 change 的
audit/upgrade-notes.md紀錄 registry 快照(npm view vitepress dist-tags --json)與選版決策。
原始升版的 fallback 依據見 openspec/changes/fix-build-blockers/audit/upgrade-notes.md。
CI quality gates
.github/workflows/test.yml 在每一次以 main 為目標的 pull request 與每次推送到 main 時強制執行 canonical 指令鏈。工作流刻意拆成六個 job,讓每一條契約失敗時都能在 pull request 上顯示獨立的 status check:
| Job | Matrix | 守備的 spec 契約 |
|---|---|---|
lint-validate | Node 22 | quality-assurance-gates 之 pnpm validate:challenges + pnpm qa:build-prereqs |
rust-test | (無) | quality-assurance-gates 之 simnet-engine/ 內 cargo test |
ts-test | Node 22、24 | quality-assurance-gates 之 pnpm test;Node 版本範圍來自 build-runtime-compatibility |
wasm-build | Node 22、24 | quality-assurance-gates 之 pnpm wasm:build;per-version artifact simnet-wasm-node<version> 來自 build-runtime-compatibility |
docs-build | Node 22、24 | quality-assurance-gates 之 pnpm docs:build 與雙語入口檔案存在性驗證 |
site-smoke | Node 22、24 | quality-assurance-gates 之 pnpm qa:site-smoke:dev + :preview;master key 來源規則來自 flag-secrets-management |
帶 matrix 的 job 在顯示名稱裡含 Node 版本(例如 TypeScript Tests (Node 22)),GitHub branch protection 設定畫面因此能逐一勾選每個 matrix 變數作為 required check。乾淨推到 main 的工作流總共會產生十個 required status check:兩個單版本 job 加上四個 matrix-aware job 在兩個 Node 版本下各跑一次。
docs-build 與 site-smoke 透過 needs: wasm-build 宣告依賴並 download 與自身 Node matrix 變數對應的 artifact;wasm-build 成功後沒有任何 job 會再次重編 wasm-pack。
docs-build 與 site-smoke 為什麼需要 SIMNET_MASTER_KEY
SIMNET_MASTER_KEY 是 build pipeline 派生每個 challenge flag 的 HMAC 根。派生動作在 build 時跑於兩個位置:
scripts/ensure-flag-secrets.mjs(pnpm build的 prebuild step)用HMAC-SHA256(masterKey, "flag:v1:" + challengeSlug)為每個 challenge 算出 flag plaintext,寫進docs/challenges/flags.secret.yaml(gitignore)。.vitepress/theme/loaders/challenge.data.ts在 VitePress SSR 階段執行,把每條 flag plaintext 的 SHA-256 hash 嵌入渲染後的 challenge 頁面。學生瀏覽器隨後 hash 提交內容,與嵌入值比對驗證。
scripts/ensure-flag-secrets.mjs 與 data loader 兩處都依 NODE_ENV 分流。Dev 模式(pnpm dev)下若 SIMNET_MASTER_KEY 未設,會 fallback 到公開常量 simnet_dev_only_key_32bytes_xx!! 並印 stderr 警告——本地開發可接受,因為這把 dev-only key 不會跑到真實使用者環境。Production 模式(NODE_ENV=production,pnpm docs:build 預設套用)下沒有任何 fallback:未設 key 直接 exit 1 並印 openssl rand -hex 16 指引。
docs-build 與 site-smoke 兩個 job 都會跑 production build(docs-build 直接呼叫 pnpm docs:build,site-smoke --mode=preview 內部 chain 到 pnpm run docs:build),所以兩個 job 都需要 SIMNET_MASTER_KEY。其他四個 job 不需要:lint-validate 跑 schema 驗證、rust-test 跑 cargo test、ts-test 在 jsdom 跑 vitest、wasm-build 只呼叫 wasm-pack,這四條 path 都不會碰到 loader 或 prebuild flag 派生流程。
Dev-only fallback 常量是 repo 內刻意公開的字串,正因如此它不能進到 CI run:任何人看到 CI build log 裡用 simnet_dev_only_key_32bytes_xx!! 算出的 flag hash,都能反向算出該 build 對應的所有 flag plaintext。CI 專用的 SIMNET_MASTER_KEY_CI secret 切斷這條線:它與任何 production key 無關、也與 dev-only 常量無關,即使 CI artifact 外洩也不會危及部署中的真實環境。
docs-build 與 site-smoke 的 master key 取得方式
smoke job 透過工作流 step 上的 if: 條件,從以下兩條路徑之一取得 SIMNET_MASTER_KEY:
- 內部 pull request 或推送到
main:step 透過 GitHub Actions${{ secrets.SIMNET_MASTER_KEY_CI }}表示式注入SIMNET_MASTER_KEY_CIrepository secret。這條 secret 是 repository 維護者另行透過gh secret set SIMNET_MASTER_KEY_CI指令(或 GitHub Settings 介面)建立的 CI 專用值,必須與任何 production / staging master key 完全獨立。可用openssl rand -hex 16產生,懷疑外洩時立即 rotate。 - 來自 fork repository 的 pull request:GitHub 不會把 repository secret 暴露給 fork 的工作流。對應 step 改為在執行期內以
openssl rand -hex 16產生一把 ephemeral 隨機 key,整輪 workflow 結束就丟棄。Fork 的 smoke check 仍會跑、仍會產生 status;SIMNET_MASTER_KEY_CI不會進入 fork 的 job 環境。
scripts/qa/site-smoke.mjs 與 scripts/qa/build-prereqs.mjs 不再保留任何 module-level 的 master key fallback。若執行時 SIMNET_MASTER_KEY 沒設定,script 會在啟動 Playwright 之前以 exit code 1 結束,並在 stderr 印出含 openssl rand -hex 16 的提示。本地貢獻者跑 smoke 必須自行 export 該變數;pnpm dev 走另一條 flags:ensure 路徑,不受影響。
GitHub Actions 全部釘到 commit SHA
test.yml 內每一條 uses: 引用都釘到完整 40-char commit SHA,後面用 # vN.x.y 註解標記語意版本。如果改用 mutable tag(例如 @v4),action 上游維護者或攻擊者就能讓該 tag 指向新 commit、在 CI 內執行未審閱的程式碼。要升版時用 gh api repos/<owner>/<repo>/commits/<tag> --jq '.sha' 查出新 SHA,並把 SHA 與註解版本一次替換。
下游 fork 啟用 CI 的設定步驟
新 fork 會直接繼承本工作流,但 SIMNET_MASTER_KEY_CI secret 預設不存在。內部 pull request 要能跑綠 site-smoke job,維護者需先做以下兩件一次性動作:
- 建立 CI master key secret。 在已對 fork 認證的 shell 內執行:bash或在 Settings → Secrets and variables → Actions → New repository secret 介面設定。同一把 key 不要跨環境重複使用。
gh secret set SIMNET_MASTER_KEY_CI --body "$(openssl rand -hex 16)" - 鎖定 matrix-aware required check。 在 Settings → Branches → Branch protection rule 把以下十條 status check 加進 "Status checks that are required" 清單,任何 matrix 變數失敗就擋住 merge:
Lint and Validate、Rust TestsTypeScript 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 request 不需要任何額外動作;它們會自動走 ephemeral key 路徑(如上節所述)。