Skip to content

fix(viewer): make composeTeardown's equality filter structural and pe… #6831

fix(viewer): make composeTeardown's equality filter structural and pe…

fix(viewer): make composeTeardown's equality filter structural and pe… #6831

Workflow file for this run

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
name: Test
on:
pull_request:
branches: [main]
# Post-merge guard. Two PRs can each be green against their own base and be
# broken as a pair on main: #2551 removed a helper from
# measure-parity.test.tsx at 01:41 on 2026-08-12, #2538 added a caller of it
# at 06:35, and main was red from then on. Nothing ran on main, so it
# surfaced hours later on an unrelated first-time contributor's PR, and every
# open PR's Node lane was red until it was fixed.
push:
branches: [main]
concurrency:
# PR runs share a group per ref, so a new commit supersedes the old run — the
# newer commit is the only one whose verdict matters.
#
# Push runs get a group PER COMMIT. Not merely `cancel-in-progress: false`:
# GitHub keeps only one running plus one PENDING run per group, and a newer
# queued run EVICTS the pending one even when cancellation is off. With three
# merges in quick succession, a docs-only third merge would evict the queued
# run for a frontend-breaking second merge, and its path filters would then
# skip the Node lane — leaving exactly the broken state this guard exists to
# catch unchecked. A per-commit group means no merge can displace another.
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
pull-requests: read
# Force JavaScript actions onto Node 24. GitHub deprecated Node 20 for
# actions in late 2025 and will flip the default in June 2026. Setting
# this opts us in early so the deprecation warning doesn't show up on
# every run, and it covers actions like jetli/wasm-pack-action that
# haven't shipped a Node-24-native release yet.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Path filter — emits booleans the downstream jobs use as `if:` gates.
# Doc-only and non-test changes skip every expensive job below.
changes:
name: Detect changes
# Free GitHub-hosted runner. ifc-lite is a PUBLIC repo, so standard
# `ubuntu-latest` runners are free + unlimited (GitHub only bills public
# repos for *larger* runners). Light jobs — path filter, lint, the gate —
# don't need Depot's paid cores; only the Rust/WASM-compile jobs do.
# See scripts/README-vercel-cost.md for the full runner-cost rationale.
runs-on: ubuntu-latest
outputs:
rust: ${{ steps.filter.outputs.rust }}
frontend: ${{ steps.filter.outputs.frontend }}
geometry: ${{ steps.filter.outputs.geometry }}
plato: ${{ steps.filter.outputs.plato }}
docs: ${{ steps.filter.outputs.docs }}
# `true` when the WASM source is byte-identical to the published release
# tag, so `build` can fetch the prebuilt bundle and run on a FREE runner
# instead of compiling wasm32 on a paid Depot runner. See the `build`
# job and scripts/ci-wasm-prebuilt-eligible.sh.
wasm_prebuilt: ${{ steps.wasm.outputs.eligible }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# A push event filters against the previous commit, which a depth-1
# clone does not contain. PRs filter against the base ref and are
# unaffected, so only the push path pays the extra object.
fetch-depth: ${{ github.event_name == 'push' && 2 || 1 }}
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
rust:
- 'rust/**'
- 'apps/server/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/build-wasm.sh'
- 'tests/models/manifest.json'
- '.github/workflows/test.yml'
frontend:
# A changeset naming a package that does not exist fails the
# RELEASE workflow, which only runs on main - so the Lint job has
# to see changeset-only PRs, or the check it runs cannot fire on
# the PR that introduces the mistake.
- '.changeset/**'
# oxlint lints all of `apps/` except `apps/landing`, but this
# filter names the two apps individually rather than `apps/**` —
# broadening it would drag every landing-page edit through the
# full frontend lane. The cost of that choice: a NEW app under
# `apps/` gets linted without being able to trigger the Lint job,
# so the PR adding it skips the gate and drops its violations on
# whoever touches the frontend next. Add it here when you add it.
- 'apps/viewer/**'
- 'apps/viewer-embed/**'
# `examples/*` is a pnpm-workspace member and is now a lint target
# (scripts/check-lint-ran.mjs). Without it here, an examples-only
# PR leaves `frontend` false, the Lint job never runs, and the new
# target protects nothing on the PRs that change those files --
# the same trap the comment above describes for `apps/`.
- 'examples/**'
- 'packages/**'
- 'tests/e2e/**'
- 'playwright.config.ts'
# The relay handlers and their tests. `pnpm test:api` runs in this
# job, so without these a PR touching ONLY the relay skips the one
# lane that exercises its allowlists - the guard would exist and
# never fire, which is the failure this filter block already warns
# about twice above.
- 'server/**'
- 'api/**'
- 'tests/api/**'
# `pnpm test:integration` runs THIS EXACT FILE in the Node tests
# job. Without it here the test cannot trigger its own execution:
# a PR editing only the integration harness skips the only lane
# that runs it.
- 'tests/integration.test.ts'
# The tsconfig BOTH `pnpm test:integration` and `pnpm test:api`
# compile against (`tsx --tsconfig tests/tsconfig.json`). Same
# trap as `.oxlintrc.json` above: a PR editing only the config a
# lane reads must not be able to skip that lane.
- 'tests/tsconfig.json'
# The lint CONFIG, so a PR that only edits it cannot skip the
# Lint job that reads it. Absent CI reads as green here, and a
# guard cannot fire in a job that never runs.
- '.oxlintrc.json'
# EVERY workflow, not just this one and server-binaries.yml.
# `scripts/check-swallowed-push.mjs` declares its SCOPE to be
# `.github/workflows/**` and runs in the Node tests job, so with
# only two workflows named here the gate was unreachable on 13 of
# the 15 files it guards -- PR #3118 edited release.yml and
# docker.yml and Node tests SKIPPED. `server-binaries.yml` (the
# server-bin platform-parity check's input, issue #2619) and
# `test.yml` are subsumed by this glob; test.yml stays named
# individually in the OTHER filters below, which this does not
# cover. COST: a workflow-only PR now runs the JS lane. Every job
# it adds is on a FREE ubuntu-latest runner except `build`, which
# only reaches Depot when the WASM source has drifted from the
# published release tag -- the same condition every frontend PR
# already pays. Workflow-only PRs are rare (dependabot bumps).
- '.github/workflows/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig*.json'
- 'scripts/**'
# `check-ci-path-coverage` reads .gitignore to decide which paths
# are committed source and which are build output it must not
# walk. That makes .gitignore an INPUT to the gate: an edit to it
# can turn a covered path into an uncovered one. Without this
# line a gitignore-only PR skips Node tests and the gate never
# sees the change that moved its own verdict.
- '.gitignore'
# The offset file itself. `node-tests` carries the Rust
# major-offset gate, and `scripts/**` above already brings the
# gate's own sources into this lane -- but the ONE file whose
# contents that gate exists to check sat in no filter at all, so
# a PR editing only `rust-major-offset.json` skipped the job and
# the gate could not fire on the change that declares the offset.
# Same trap this block warns about for `apps/`, `examples/` and
# `.oxlintrc.json` above. It goes here rather than under `rust:`
# because the gate is a Node step and `rust:` would additionally
# spin the paid Depot compile lane for a one-line JSON edit
# (scripts/README-vercel-cost.md).
- 'rust-major-offset.json'
- 'tests/models/manifest.json'
- '.github/workflows/test.yml'
geometry:
- 'rust/geometry/**'
- 'rust/core/**'
- 'rust/processing/**'
- 'apps/server/Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/test.yml'
# The watertightness census sweeps the fixture set named here, so a
# corpus change moves its pinned counts even with no code change.
- 'tests/models/manifest.json'
plato:
- 'tools/plato/**'
- 'rust/clash/src/generated/**'
- 'packages/clash/src/math/generated/**'
- 'scripts/generate-plato-clash.mjs'
- '.github/workflows/test.yml'
docs:
- 'docs/**'
- 'mkdocs.yml'
- 'README.md'
- 'packages/*/README.md'
# The two SOURCES `scripts/docs/generate-docs-sections.mjs`
# regenerates its sections from. They belong in `docs` rather
# than `frontend` on purpose: `docs` reaches the freshness check
# through the Docs-checks job -- one free ubuntu-latest runner,
# three node scripts, no build artifact and no Depot -- which is
# the CHEAPEST job that runs this gate. `frontend` would drag a
# landing-copy edit through build + typecheck + lint + the viewer
# shards for no added signal. Coverage holds either way: when a
# PR also touches frontend/rust, Docs checks skips itself and
# Node tests runs the same `--check`. PR #1817 changed only
# apps/landing/bench-data.json, and BOTH lanes skipped.
- 'apps/landing/**'
- 'tests/benchmark/baseline.json'
- 'scripts/docs/**'
- '.github/workflows/test.yml'
# Decide whether `build` can skip the from-source wasm32 compile. The
# probe emits `true` only when the WASM source is byte-identical to the
# `@ifc-lite/wasm@<version>` release tag that produced the published
# bundle; any doubt emits `false` → build from source on Depot (unchanged
# behaviour). It can never green-light a stale bundle.
- name: Resolve prebuilt-WASM eligibility
id: wasm
run: echo "eligible=$(bash scripts/ci-wasm-prebuilt-eligible.sh)" >> "$GITHUB_OUTPUT"
# Shared install + WASM + workspace build. Uploads built dist/ for the
# downstream JS jobs; Rust jobs do their own checkout + cargo cache
# restore (Swatinem) and don't need this artifact.
build:
name: Build packages + WASM
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.frontend == 'true'
# Runner is chosen by whether we can skip the wasm32 compile. When the WASM
# source is byte-identical to the published release tag (the common case on
# frontend-only PRs), we fetch the prebuilt @ifc-lite/wasm bundle and run on
# a FREE ubuntu-latest runner — no Rust toolchain, no wasm-pack, no Swatinem
# rust-cache write to Depot. When Rust changed we compile from source on the
# paid Depot runner (needs the cores + the uncapped cargo cache), exactly as
# before. This is the CI twin of the scripts/vercel-install.sh fast path and
# attacks the single largest Depot line — the wasm32 compile on ~2/3 of PRs.
runs-on: ${{ needs.changes.outputs.wasm_prebuilt == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Fixtures live in a GitHub Release (see tests/models/manifest.json
# and AGENTS.md §9). The repo no longer uses Git LFS — explicitly
# disable the smudge filter so a stray .gitattributes can't
# reintroduce it on a contributor branch.
lfs: false
persist-credentials: false
- name: Setup pnpm
# v6 reads the version from the `packageManager` field in
# package.json (`pnpm@10.8.1`); specifying `with.version` here
# would conflict and the action errors with ERR_PNPM_BAD_PM_VERSION.
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
# Rust + wasm-pack for the wasm32 build (pure-Rust CSG kernel — no
# C++ toolchain needed since M9). Single-sourced in
# .github/actions/setup-wasm-build so the four wasm-building
# workflows stay in lock-step. Skipped on the prebuilt fast path — that's
# where the Depot minutes AND the `ci-build` Swatinem rust-cache write to
# Depot both disappear.
- name: Setup WASM build toolchain
if: needs.changes.outputs.wasm_prebuilt != 'true'
uses: ./.github/actions/setup-wasm-build
with:
cache-prefix: ci-build
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Fast path: drop the published bundle into packages/wasm/pkg/ so the
# from-source `Build WASM` step below soft-skips (wasm-pack absent +
# runtime present → exit 0) and downstream consumers get a working
# runtime. Only runs when `changes` proved the source matches the release
# tag, so this is the exact binary a source build would produce. Retried
# because it's the one new hard-fail path (no Rust toolchain to fall back
# to on this runner); a transient npm blip shouldn't block the PR.
- name: Fetch prebuilt WASM
if: needs.changes.outputs.wasm_prebuilt == 'true'
run: |
for attempt in 1 2 3; do
if node scripts/fetch-prebuilt-wasm.mjs; then exit 0; fi
echo "::warning::prebuilt WASM fetch attempt ${attempt} failed; retrying…"
sleep $((attempt * 5))
done
echo "::error::Could not fetch the prebuilt @ifc-lite/wasm bundle after 3 attempts."
exit 1
# Cache the fetched fixture tree keyed on the manifest hash. ~994 MiB.
# Cache hit = no network. Cache miss (manifest changed) = one cold pull
# from the GitHub Release CDN. Pattern documented in AGENTS.md §9.
- name: Cache test fixtures
id: fixtures-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: tests/models
# Namespace cache key separately from `release.yml` (`release-fixtures-…`).
# Phase A hardening — keeps the privileged release runner from ever
# reading a cache writeable by a fork-triggered job.
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
run: pnpm fixtures
- name: Verify fixtures
run: pnpm fixtures:check
# Rebuild the wasm bindings from rust/wasm-bindings/ on every run. The
# runtime (ifc-lite_bg.wasm / ifc-lite.js) is gitignored and rebuilt here
# so a stale binary can never ship (#654); the type surface
# (pkg/ifc-lite.d.ts) is committed for the wasm-free typecheck lane
# (#952). This step catches Rust → wasm breakage before downstream
# consumers see it.
# Skipped on the prebuilt fast path (the bundle was just fetched above);
# the type-sync gate below is skipped there too, since a frontend-only PR
# can't have changed the committed .d.ts.
- name: Build WASM
if: needs.changes.outputs.wasm_prebuilt != 'true'
run: bash scripts/build-wasm.sh
# Guard the wasm-free typecheck lane (#952): the committed
# packages/wasm/pkg/ifc-lite.d.ts must stay in sync with the Rust source.
# The build above just regenerated it (and stripped the platform-variant
# __wasm_bindgen_func_elem_* trampoline indices, so this diff is stable
# across linux/macOS), so any diff means the committed types are stale —
# fail the PR. Scoped to the .d.ts only; pkg/README.md and pkg/package.json
# churn on every wasm-pack run (see AGENTS.md) and are intentionally
# excluded.
- name: Verify committed wasm types are in sync
if: needs.changes.outputs.wasm_prebuilt != 'true'
run: |
if ! git diff --quiet -- packages/wasm/pkg/ifc-lite.d.ts; then
echo "::error file=packages/wasm/pkg/ifc-lite.d.ts::Committed wasm types are out of sync with the Rust source. Run 'bash scripts/build-wasm.sh' and commit the regenerated packages/wasm/pkg/ifc-lite.d.ts."
git diff -- packages/wasm/pkg/ifc-lite.d.ts
exit 1
fi
echo "✅ committed wasm types match the Rust source"
# Build all workspace packages so their dist/ artifacts exist for
# downstream consumers — turbo handles the dep graph.
- name: Build packages
run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-output
# `packages/*/pkg` covers `wasm/pkg`, which downstream
# `pnpm build` calls require.
path: |
packages/*/dist
packages/*/wasm
packages/*/pkg
retention-days: 1
if-no-files-found: error
typecheck:
name: Typecheck
needs: [changes, build]
if: needs.changes.outputs.frontend == 'true'
# tsc is not CPU-bound; the free 2-core runner is fine and costs nothing.
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- run: pnpm typecheck
lint:
name: Lint
needs: [changes, build]
if: needs.changes.outputs.frontend == 'true'
# Two gates, both free-runner: oxlint over the source (no compile), and the
# unused-locals ratchet (scripts/check-unused-locals.mjs), which needs the
# built dist below because it type-checks each package. Before #2603 this
# lane ran NOTHING: the root script was `pnpm -r lint` and no package
# defined one, so it passed in 20s having checked nothing, and dead imports
# shipped through it.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# The unused-locals ratchet runs `tsc` per package, and a package resolves
# its workspace siblings through their built `dist` types — without the
# artifact every dependent package fails to compile, reads as
# unmeasurable, and the ratchet fails closed. (It was written assuming
# "lint needs no dist", which is true of eslint and false of this.)
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- run: pnpm lint
# The viewer's own suite, split out of `node-tests` AND sharded four ways.
#
# Measured, not guessed. Splitting it out of node-tests took that lane from
# a 25:00 timeout to 5:32 SUCCESS, so everything except the viewer costs five
# and a half minutes. The viewer alone then still timed out at 25:29 with a
# whole 4-vCPU runner to itself, which is what made sharding the answer
# rather than more concurrency inside one job.
#
# Healthy shard timings, recorded so the next person seeing this lane creep
# knows what good looks like rather than only what broken looked like:
# 2:29, 2:01, 1:50 against a 25 minute cap.
#
# Shard membership is a hash of the file PATH, not its index in the sorted
# list. With `NR % n` every file after an added or removed test moves to a
# different shard: measured, adding ONE file reassigned 534 of 534. Two
# things that breaks. A flaky file wanders between shards, and "shard 1
# passed on PR X" stops being evidence about shard 1 on PR Y, because they
# are not the same set of files. I used exactly that cross-PR comparison as
# proof while chasing a flake, and it did not hold. With the hash, adding a
# file reassigns 0 of 534.
#
# Parallelism comes from the SHARDS, not from concurrency inside a shard.
# `--test-concurrency=1` is deliberate: at 4 a timing-sensitive test races
# three neighbours, and `useSandbox.runSupersession.test.tsx` failed on three
# unrelated PRs in one hour that way (issue #3060) while passing locally 3/3.
# The bump to 4 came from an earlier attempt at this lane that was MEASURED
# not to help on CI, and it was carried forward as harmless once sharding
# worked. It is not harmless, and its speedup is redundant with sharding.
# Cost of going back to 1, measured: ~70s versus ~19s per shard locally,
# roughly 8 minutes versus 2 on CI, against a 25 minute cap.
#
# Correction: dropping to 1 MITIGATED #3060, it did not fix it. The defect
# was in that file's fixture, which slept a flat 50ms and then asserted that
# a run had reached its host gate. The #1922 tests at the bottom of that file
# abort a WASM module, the aborted module is retired, and the next run pays a
# fresh `newQuickJSWASMModule()` — measured at 46-72ms on a contended
# machine, straddling the sleep. Reproduced locally at 11/30 failures with
# the exact reported `0 !== 1, then 2 !== 1`, on Node 22.13.1 AND 22.23.2,
# by loading the machine rather than by raising concurrency; the fixture now
# waits for the gate instead of predicting it, and the same load gives 30/30.
# So concurrency was the exposure, not the cause: this setting is still worth
# keeping for the other timing-sensitive tests it protects, but it is no
# longer what stands between #3060 and a red shard.
#
# Two earlier explanations in this file were WRONG and are corrected here,
# because a confident wrong comment is worse than none:
#
# - "each process pays its own transform, with no on-disk cache" — tsx DOES
# cache transforms under `os.tmpdir()/tsx-{uid}`. Measured, it barely
# matters either way: the full suite is 112.0s cold versus 110.8s warm.
# The dominant per-file cost is not the transform, it is EVALUATING a
# ~1,200-module import graph in every forked process (~0.4s of ~0.55s).
# So a shared transform cache is NOT the fix; measured benefit ~1%.
#
# - "not a hang, just a long task". Turbo buffering explains the silence
# but never proved forward progress. `node:test` has no per-file timeout,
# so ONE never-exiting child holds the runner until the job cap while the
# other slots finish everything else — which is exactly what identical
# 25:15 caps across four differently-sized shards look like.
#
# Hence `--test-timeout` in apps/viewer's script: a hung test now fails by
# name in two minutes instead of consuming twenty-five and reading as
# slowness. That is the guard this incident was missing.
#
# A timed-out lane reports as `cancelled`, which is the SAME word a
# supersession gets. Separate them by duration against `timeout-minutes`,
# never by the word: 25m16s and 25m18s against a 25 minute cap are timeouts,
# and nothing else in the API says so.
viewer-tests:
name: Viewer tests (shard ${{ matrix.shard }})
needs: [changes, build]
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: Cache test fixtures
id: fixtures-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: tests/models
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
run: pnpm fixtures
# Unconditional — this is the point of the step. The fetch above is
# gated on a cache miss, so on a cache hit nothing had ever confirmed the
# restored tree still matches the manifest. A partial or corrupted cache
# then made every fixture-gated test skip silently and report ok.
- name: Verify fixtures
run: pnpm fixtures:check
# Run the package script DIRECTLY, not through turbo. Two reasons, both
# measured rather than assumed:
#
# 1. Turbo strips undeclared environment variables. `globalPassThroughEnv`
# in turbo.json is an explicit allowlist and does not include these,
# and the `test` task declares no `env`/`passThroughEnv`. Routed
# through turbo, TEST_SHARD never reached the script and all four
# shards ran the full 521 files, taking an identical 25:15 each. That
# identical timing is what exposed it: quartering the input cannot
# leave the duration unchanged.
# 2. `test` dependsOn `build`, so turbo also ran the viewer's Vite app
# build (53s of "✓ built in 53.43s") which its tests do not need. The
# sibling dists the tests DO need come from the downloaded artifact.
#
# `TEST_SHARDS`/`TEST_SHARD` select every Nth file (see apps/viewer's
# test script). Unset, the script takes all 521, so a local `pnpm test`
# is unchanged. The split is exact: 130+131+130+130 = 521 files, 521
# distinct paths, so nothing is dropped or run twice.
- run: pnpm --filter @ifc-lite/viewer run test
env:
TEST_SHARDS: '4'
TEST_SHARD: ${{ matrix.shard }}
node-tests:
name: Node tests
needs: [changes, build]
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.rust == 'true'
# Free runner — vitest consumes the prebuilt artifact; not compile-bound.
# 25 min: the job also runs the previously dark ifcx/renderer suites and
# the integration harness now; 18 min left no headroom (timed out at the
# integration step on a cold-ish run).
#
# 25 stopped being enough on 2026-08-21 and the lane timed out on MAIN.
#
# DISAMBIGUATION RULE, because this cost hours: a timed-out lane reports as
# `cancelled`, the SAME word a supersession gets. Nothing in the API
# distinguishes them. Separate them by DURATION against `timeout-minutes` —
# 25m16s, 25m18s and 25m19s against a 25 minute cap are timeouts. Also note
# turbo BUFFERS a task's output until the task ends, so a task that never
# finishes prints nothing and reads as a hang rather than as slowness.
#
# The viewer suite now runs in its own concurrent job (`viewer-tests`)
# rather than at the tail of this one. Raising the cap was rejected: it
# went 18 -> 25 once already for this reason, and buys time rather than
# headroom.
#
# If this lane needs attention again, `@ifc-lite/provenance` is the next
# target: it alone spent 396s of the run that exposed this, with single
# tests at 87s and 38s.
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: Cache test fixtures
id: fixtures-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: tests/models
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
run: pnpm fixtures
# Unconditional — this is the point of the step. The fetch above is
# gated on a cache miss, so on a cache hit nothing had ever confirmed the
# restored tree still matches the manifest. A partial or corrupted cache
# then made every fixture-gated test skip silently and report ok.
- name: Verify fixtures
run: pnpm fixtures:check
- name: Check test wiring
run: node scripts/check-test-wiring.mjs
# Executable proof the wiring gate cannot pass vacuously — the failure
# mode it exists to catch is exactly the one it could have itself. Its
# first fixture reconstructs #3062's pre-wiring state (a gate script and
# its test committed with no workflow step and no package.json entry),
# which the earlier checker could not see at all because `scripts/` was
# outside its `packages`/`apps` scan. The rest pin the two directions
# that matter: a package.json entry nobody invokes is NOT wiring, and
# the glob catch-all below reaching a gate's TEST does not excuse the
# gate SCRIPT never executing.
- name: Check test-wiring gate regressions
run: node --test scripts/check-test-wiring.test.mjs
# typecheck-tests.mjs writes the `extends` of every generated test
# program. A bare (non-`./`-prefixed) value is resolved by tsc as a NODE
# MODULE, so the base config is not found and the `noEmit` it carries
# silently stops applying -- a repo-root run then scattered 9,609
# untracked .js/.d.ts/.map files through the source tree (#2664 review).
# Nothing about that is visible in normal use, so it is pinned here.
- name: Check typecheck-tests extends resolution
run: node --test scripts/typecheck-tests.test.mjs
# A test that reads its subject's SOURCE and asserts on the text passes
# while the behaviour is broken -- SearchModal.filter's stayed 5/5 green
# with the row handler unwired (#2396 verbatim). The harness that made
# the behavioural version look impossible now exists in
# apps/viewer/src/test/, so this ratchets the remaining list down (#2434).
- name: Check for new source-text assertions
run: node scripts/check-source-text-assertions.mjs
# That gate shipped untested and then acquired a NARROWING: a predicate
# counts only when applied to a value a file read produced, which is what
# the docblock always claimed and what the flat version never checked.
# A narrowing can only ever flag less, so both halves are executable --
# every taint shape it must still catch, and the subprocess-output shape
# it must not.
- name: Check the source-text detector's own regressions
run: node --test scripts/check-source-text-assertions.test.mjs
# That gate's SCAN SCOPE is packages/ and apps/ only -- `grep -c rust` over
# it returns 0 -- so the same AGENTS.md rule was unenforced across the
# whole rust/ tree, demonstrated rather than inferred: a genuine
# `fs::read_to_string(".../foo.rs")` plus a `contains` planted in a real
# Rust test left CI green (#3129, then #3195). This is the sibling gate,
# with its own lexer, because the TypeScript one's precision comes from a
# real parse tree that a regex pass sharing the file would erode.
# Its own tests run FIRST, for the reason the refwalk pair states: this
# check's value is entirely in not passing vacuously, so a broken detector
# must fail here rather than report a clean tree.
- name: Check Rust source-text gate's own tests
run: node --test scripts/check-rust-source-text-assertions.test.mjs
- name: Check for source-text assertions in Rust tests
run: node scripts/check-rust-source-text-assertions.mjs
# A walk that follows a file-supplied entity reference with no visited set
# and no depth cap overflows the Rust stack on a self-referential
# reference, which is SIGABRT rather than a catchable panic -- in the wasm
# geometry worker it takes down the instance (#2866). Six sites were fixed
# one at a time (#2868-#2874) and #2873 wrote the guard-choice rule into
# AGENTS.md, but a convention does not fail CI when a seventh lands (#2944).
# Its own tests run first: this check's value is entirely in not passing
# vacuously, so a broken detector must fail here, not report a clean tree.
# Both files: check-refwalk-guards.test.mjs covers the harness (scan
# roots, candidate floor, allowlist ratchet), refwalk-classify.test.mjs
# covers the DETECTOR. Running only the first would leave the half that
# decides flagged/not-flagged untested in CI.
- name: Check refwalk gate's own tests
run: node --test scripts/check-refwalk-guards.test.mjs scripts/lib/refwalk-classify.test.mjs
- name: Check for unguarded entity-reference walks
run: node scripts/check-refwalk-guards.mjs
# AGENTS.md has asked for TypeScript modules under ~400 lines since long
# before this gate existed, and self-policing left 312 files over the
# line. A convention with no gate ratchets the wrong way: the allowlist
# is the frozen debt, and anything NEW over 400 lines fails here. The
# allowlist is digest-pinned, so editing it to dodge the gate is itself
# a failure rather than a silent widening.
- name: Check TypeScript module size ratchet
run: node scripts/check-module-size.mjs
# Executable proof the ratchet cannot pass vacuously: a gate that
# measures nothing, or an allowlist whose digest is not actually
# enforced, would report the same clean line as a real pass.
# check-module-size.test.mjs covers the harness (tree walk, allowlist
# ratchet, digest pin); lib/module-size-ratchet.test.mjs covers the
# counting and digest logic underneath it.
- name: Check module-size ratchet gate regressions
run: node --test scripts/check-module-size.test.mjs scripts/lib/module-size-ratchet.test.mjs
# The version every Rust crate is published at is the npm version with
# `rust-major-offset.json`'s major offset applied (#3216). That offset is
# applied by `scripts/sync-versions.js`, which runs ONCE per release
# inside `pnpm run version` -- so between releases the file and the
# manifests can disagree with nothing to notice, and the first symptom
# would be a `cargo publish` at a version meaning something other than
# what the file claims. Running it here, on every PR, is what makes the
# `chore: version packages` PR (the one commit that actually moves these
# literals) reviewable before it publishes.
# Its own tests run FIRST, for the reason the pairs above state: this
# gate's value is entirely in not passing over a tree it never read, so a
# broken detector must fail here rather than report agreement.
- name: Check Rust major-offset gate's own tests
run: node --test scripts/check-rust-major-offset.test.mjs scripts/lib/rust-major-offset.test.mjs scripts/sync-versions.test.mjs
- name: Check Rust crate version matches the declared major offset
run: node scripts/check-rust-major-offset.mjs
# A hidden tab never delivers an animation frame, so an unbounded
# `await new Promise(r => requestAnimationFrame(r))` in the load pipeline
# parks finalize / WASM release / setLoading(false) until the user comes
# back (#2385). Absence over five files is a lint, not a unit test --
# it used to be the one non-executable case in frameWait.test.ts (#2434).
- name: Check for unbounded animation-frame waits
run: node scripts/check-unbounded-frame-wait.mjs
# Every `new GeometryProcessor(...)` must free its WASM handle on all
# paths. AGENTS.md required this already, but self-policing decayed one
# call site at a time until 12 sites leaked (#1959) -- including the
# viewer's canonical load path, which fires on every IFC load.
- name: Check WASM handle disposal
run: node scripts/check-wasm-disposal.mjs
# A collab recipient's meshes are hydrated from blobs baked at
# `meta.placementBaseline`, so placement has to be re-derived from the doc
# after every reconstruct -- otherwise a late joiner renders moved
# elements at the wrong place, and a peer's create/delete silently reverts
# every applied move. `placement-sweep.ts` is unit-tested; its CALL SITES
# are what this pins, because `startCollab`'s recipient branch needs
# jsdom, IndexedDB, `import.meta.env` and a websocket and cannot be driven
# under `tsx --test`.
- name: Check collab placement sweep
run: node scripts/check-collab-placement-sweep.mjs
# The collab recipient re-derives the shared model from the CRDT on every
# peer edit; writing that through `setIfcDataStore` / `setGeometryResult`
# targets `activeModelId`, so a recipient with their own file active lost
# that file's store and meshes (#2705). `applyRoomModelData` fixes it and
# is unit-tested -- but the CALL SITES are what was wrong, and reverting
# all three leaves `tsc --noEmit` clean and the viewer suite green: the
# path needs jsdom, IndexedDB, `import.meta.env` and a websocket, so it
# cannot be driven under `tsx --test`. The wiring is pinned here instead.
- name: Check collab room-model targeting
run: node scripts/check-collab-room-model-target.mjs
# Executable proof the guard above cannot be evaded: a template literal
# spelling the required call with the real call deleted (a hole a
# CodeRabbit CLI run found in `blankNoise`) and a banned member hidden
# inside a `${...}` interpolation both run against mutated copies of the
# real sources in a temp tree and must turn the gate red.
- name: Check collab room-model targeting gate regressions
run: node --test scripts/check-collab-room-model-target.test.mjs
# tsx's SYNCHRONOUS loader hook (`module.registerHooks`, Node >= 22.15.0)
# applies the tsconfig `paths` mapping itself and short-circuits, so an
# aliased specifier never reaches an async `register()` hook in any form.
# A hook whose only arm is `specifier === '@/lib/collab/geometry-sync'`
# therefore never matches, the gate the test parks on never fires, and
# the file hangs until the runner's timeout -- CI ONLY, because
# `node-version: 22` floats to the newest 22.x while local machines sit
# on an older one. That has happened twice
# (`collab-session-race-hook.mjs`, then `collab-hydrate-gate-hook.mjs`
# written after the first grew a twenty-line comment about it), which is
# why this is a gate rather than a third comment.
- name: Check loader hook specifier match
run: node scripts/check-loader-hook-specifier-match.mjs
# Executable proof the guard above still fires: both historical incidents
# rebuilt in a temp tree must turn it red, a bare arm on a NON-aliased
# specifier must stay green (`cesium` is one, and works), and neutering
# the alias table in either direction must change the verdict.
- name: Check loader hook specifier match gate regressions
run: node --test scripts/check-loader-hook-specifier-match.test.mjs
# `degenerateReason` crosses the wasm boundary as an untyped string and is
# cast to `ClashSolidDegenerateReason` on arrival, so TypeScript cannot see
# a union that has drifted from the kernel -- `'malformed-operand'` was
# missing exactly that way (#2717). Cross-language declaration parity can
# only be claimed by reading both SOURCES, which is banned in test files
# by the source-text gate above, so it is a lint here instead.
- name: Check clash degenerate-reason parity
run: node scripts/check-clash-degenerate-reason-parity.mjs
# Executable proof the parity gate cannot pass vacuously: two empty sets
# are "equal", so a broken extractor would otherwise turn it green. Each
# drift direction and each vacuity mode runs against mutated copies of the
# real sources in a temp tree and must turn the gate red.
- name: Check clash degenerate-reason parity gate regressions
run: node --test scripts/check-clash-degenerate-reason-parity.test.mjs
# `legacy_entities.rs` is the table every classification pass is told to
# consult instead of a bare `IfcType::from_str`, and nothing checked it
# was complete. Six concrete IFC2X3 products were missing (#3172), and a
# name it misses resolves to `Unknown` -- a subtype of nothing -- so the
# entity is dropped from the attribute export AND from meshing at once.
# Nothing disagrees, so nothing looks wrong. Same call as the parity gate
# above: reading two sources is banned in test files, so it is a lint.
- name: Check legacy entity coverage
run: node scripts/check-legacy-entity-coverage.mjs
# Executable proof the gate cannot pass vacuously: "nothing is missing" is
# equally true of a complete table and of an extractor that found nothing.
# Each of the four extractors is broken in turn against mutated copies of
# the real sources, plus a deleted arm and the #3172 misspelling itself.
- name: Check legacy entity coverage gate regressions
run: node --test scripts/check-legacy-entity-coverage.test.mjs
# `|| true` on `git tag` is right (a re-run hits an existing tag);
# on the PUSH it made a failed push silent, and `gh release create` then
# creates the missing tag ITSELF at default-branch head -- so the run
# stayed green with the tag on a different commit than the packages
# published from it (#3202). Baseline is zero: this gate landed in the
# same change that removed the only two instances.
- name: Check for swallowed git push failures
run: node scripts/check-swallowed-push.mjs
# Executable proof it cannot pass vacuously: a missing or empty workflow
# directory must fail rather than report clean, and `|| true` on `git tag`
# must NOT be flagged -- a rule that caught both would be suppressed on
# its first run.
- name: Check swallowed-push gate regressions
run: node --test scripts/check-swallowed-push.test.mjs
# The class the swallowed-push gate above was itself an instance of: a
# gate is only as good as the job that runs it, and the job only runs
# when the path filter says so. This derives each gate's INPUTS from the
# gate's own source and fails when one of them cannot trigger the job
# that reads it -- an unreachable gate reports success, because a skipped
# job counts as success in the aggregate `test` gate below (#3312).
- name: Check CI path coverage
run: node scripts/check-ci-path-coverage.mjs
# Executable proof it cannot pass vacuously: reintroducing a real hole
# must name that hole, and an empty workflow dir / unparseable filter
# block / zero derived inputs must FAIL rather than report clean.
- name: Check CI path-coverage gate regressions
run: node --test scripts/check-ci-path-coverage.test.mjs
# The CSV cell escaper reached NINE hand-rolled copies and no two were
# identical: some tested the formula trigger anchored at offset 0 (so a
# BOM/ZWSP/LRM/NBSP/U+2028 in front of `=` bypassed the CWE-1236 guard),
# some hardened it by DELETING the leading invisibles (throwing away
# leading spaces, against RFC 4180 2.4), one hard-coded a comma while its
# caller had a configurable delimiter. Correcting the copies only resets
# the clock; this is what stops a tenth.
- name: Check for hand-rolled CSV cell escapers
run: node scripts/check-csv-escaper-copies.mjs
# Executable proof the gate fires: each of the ten real copies is replanted
# in its original shape and must turn the gate red, and a scan that finds
# almost no files must fail rather than report clean.
- name: Check CSV escaper gate regressions
run: node --test scripts/check-csv-escaper-copies.test.mjs
# `scripts/lib/vitest-timeout-audit.mjs` (#2948) replaced
# `audit-test-timeouts.mjs`, which was deleted in the same change rather
# than left beside it: two hand-written JS/TS lexers for one question is
# the defect the CSV-escaper gate above exists to prevent, and the older
# one was the less accurate of the two while being the one CI pinned.
# Both of the original script's failure modes are pinned by the suite
# below: an apostrophe in a comment scanning to EOF and dropping every
# later call, and an options bag inside a test BODY marking the test
# protected. The second was carried over deliberately -- every other
# `timeout:` fixture here sits in an ARGUMENT position, so deleting the
# old suite would have left the body position unpinned, and a classifier
# matching `timeout:` anywhere in a call passes every other case.
# It also resolves a timeout INHERITED from an enclosing
# `describe(name, { timeout }, fn)` and one set by a package's vitest
# config, both of which the grep-built at-risk table in #2948 itself
# scored as unprotected.
- name: Check vitest-timeout audit gate regressions
run: node --test scripts/lib/vitest-timeout-audit.test.mjs
# ...and run the audit itself over every vitest test file in the repo.
# It reports rather than gates on unprotected tests -- the right timeout
# for a slow test needs to know what the test does, which no scan can
# judge -- but it DOES exit non-zero on a call site its parser cannot
# read, and on a run that classified nothing. Those are the two ways this
# audit could go quietly blind against the real tree, so running it here
# is what keeps the numbers it prints meaningful. `--summary-only` drops
# the ~15k-line per-call listing; drop the flag locally to read it.
- name: Check vitest-timeout audit reads every test file
run: pnpm run check:vitest-timeout-audit
# Guard the published API surface: the exported names of every
# non-private packages/* package (read from the dist d.ts in the
# build artifact downloaded above) must match the committed
# scripts/api-surface.json, so an accidental export removal/rename
# can't ship silently.
- name: Check API surface
run: node scripts/check-api-surface.mjs
# A server-bin release must ship one archive per platform the package
# claims to support. Three lists encode that set (SUPPORTED_TARGETS in
# platform.ts, package.json os/cpu, the release matrix in
# server-binaries.yml) and agreed only by hand until v1.16.6 shipped
# without the win32-x64 zip and nothing noticed (issue #2619).
- name: Check server-bin platform parity
run: node scripts/check-server-bin-targets.mjs
# Executable proof that the parity gate itself cannot pass vacuously:
# an adversarial review of PR #2642 produced four false greens
# (commented-out matrix entries and targets still counted; a stale
# literal in a comment satisfied the upload check) that hand-testing
# had missed. Each hostile mutation now runs against the real inputs
# in a temp tree and must turn the gate red with an accurate message.
# The second file unit-tests stripYamlComments, the primitive the gate's
# comment-awareness rests on (a lone apostrophe once blinded it).
- name: Check server-bin parity gate regressions
run: node --test scripts/check-server-bin-targets.test.mjs scripts/lib/server-bin-targets-parse.test.mjs
# Unit tests for the branching check-unused-locals.mjs uses to decide
# "real violation" vs "does not compile" vs "cannot parse this output".
# A #2634 review found the fail-loud branch only engaged when the whole
# run was unparseable — a run with one recognised diagnostic and one
# unrecognised one silently dropped the unrecognised one instead of
# failing. Pinned here so that gap can't reopen unnoticed. The #2663
# review found the twin gap one level up: the exit status itself was
# never inspected, so an ENOBUFS-truncated or OOM-killed run handed the
# classifier a prefix that parsed cleanly into a confident undercount.
# untrustworthyExitReason is pinned in the same file.
- name: Check unused-locals output classification
run: node --test scripts/lib/unused-locals-classify.test.mjs
# check-lint-ran.mjs buffered oxlint's whole output, wrote it, then
# process.exit()'d. On a PIPE — every CI log — stdout is asynchronous, so
# the exit discarded most of the write: a 120k-line failing run reached
# the log as ~1k lines, cut mid-diagnostic, with no summary and no error
# line. A genuine eslint(no-control-regex) failure was diagnosed off such
# a log as "pre-existing warnings, not ours". A TTY or a file kept
# everything, so hand-testing never showed it — this drives the gate
# through a pipe and asserts the bytes that arrive.
- name: Check lint gate survives a piped stdout
run: node --test scripts/check-lint-ran.test.mjs
# check-tla-chunk-await.mjs runs inside the viewer build, so it is only
# ever exercised against whatever that build happens to emit — the one
# bundle shape that cannot demonstrate the gate still has teeth. These
# tests drive it over synthetic bundles instead: the #2246 minified
# single-line importer and its pretty-printed twin must still turn it
# red, a correct bundle (including one whose __tla chunk is only
# import()ed) must still pass, and each way of inspecting nothing —
# missing assets dir, no chunks, chunks but no __tla-wrapped chunk —
# must fail rather than tick.
- name: Check the TLA chunk-await gate cannot pass vacuously
run: node --test scripts/check-tla-chunk-await.test.mjs
# check-coderabbit-review.mjs exists to tell a real CodeRabbit review from
# a green tick with nothing behind it, and it got that wrong in BOTH
# directions at once: an empty comment list returned "no review" before
# the inline thread count was even read (CodeRabbit posts findings while
# its summary is absent, deleted, or rewritten in place), and no timestamp
# reached the classifier at all, so a review from two pushes ago certified
# the commits the bot never saw. The second is the dangerous one -- a
# false "not reviewed" is loud and self-correcting, a false "reviewed" is
# silent and terminal. Both directions, the freshness comparison, and the
# decision that a missing or unparseable timestamp resolves to unreviewed
# are pinned here.
- name: Check the CodeRabbit review classifier
run: node --test scripts/lib/coderabbit-review-state.test.mjs
# The same tick hides four MORE shapes, found by a sweep of all 36 open
# PRs: a fork's branch we cannot push to; a DIRTY base whose green checks
# ran on a merge commit that can no longer be formed (two PRs were in that
# state); ZERO workflow runs at the head commit, which leaves an EMPTY
# statusCheckRollup that counts up to the same fail=0/pending=0 as a fully
# passing one; and a newest run against a superseded commit. The sweep is
# a hand-run tool (check-pr-green.mjs, @unwired-by-design -- its verdicts
# are transient GitHub state), but the classification it rests on is not,
# including the three ways the sweep must refuse to pass vacuously: zero
# PRs, an unreachable API, a malformed response.
- name: Check the PR-green sweep classifier
run: node --test scripts/lib/pr-green-sweep.test.mjs
# fetch-prebuilt-wasm.mjs read `npm pack`'s trimmed stdout as the tarball
# path. npm 11 prints its "npm notice" block on STDOUT, so the trim
# returned twenty lines of prose and the tar two lines down exited 2,
# surfacing as a bare `status: 2` that reads like a corrupt download.
# That script is the only route to a WASM bundle on a machine without a
# Rust toolchain, so the failure blocked the whole viewer build.
- name: Check npm pack output is read machine-readably
run: node --test scripts/lib/npm-pack-output.test.mjs
# The Release workflow runs ONLY on main, and only on an actual publish,
# so nothing on a PR ever read release-crates.mjs's publish ORDER. #2574
# added a versioned dev-dependency (ifc-lite-clash) to rust/geometry and
# it sat latent until the next release, where cargo refused to publish
# geometry ahead of clash — AFTER npm had already gone out, leaving
# crates.io behind npm. This makes that order a PR-visible gate.
- name: Check the crates publish order matches the dependency graph
run: node --test scripts/release-crates-order.test.mjs
# #3180: `cargo publish` does not block until a crate is visible in the
# crates.io index (the comment that used to claim it did was false —
# v6.0.0 left 4 of 7 crates unpublished when the index lagged past
# cargo's own internal wait). release-crates.mjs now polls the index
# itself with a bounded timeout instead of trusting cargo's wait; these
# cover that poll (and the underlying crates.io query helper) against a
# stubbed registry and a fake clock, never a real network call.
- name: Check the crates.io publish poll fails closed on a stuck index
run: node --test scripts/lib/crates-io.test.mjs scripts/release-crates.test.mjs
# #3181: `verify-npm-publish.js` only ever checked npm, so the v6.0.0
# partial crates.io publish (#3180) went unreported — npm was complete,
# and nothing checked the other registry. Covers `verify-crates-publish.js`
# reproducing that exact 3-of-7 shape against a stubbed registry — and
# the EXIT CODE, not just the pure `verifyAll`: the step name claims the
# verifier "catches" a partial publish, which is a claim about what the
# process returns. `runMain` is driven directly so `if (failed.length >
# 0) → if (false)` (a partial publish exiting 0) and an empty crate list
# exiting 0 both fail here rather than at the next release.
- name: Check the crates.io publish verifier catches a partial publish
run: node --test scripts/verify-crates-publish.test.mjs
# The gate that decides whether either publish verifier runs at all. It
# is read from `release.yml` on main only, so nothing on a PR exercises
# it — and its first spelling (root `package.json` version vs `HEAD~1`)
# was wrong on this repo's own history: `sync-versions.js` pins the root
# version to the HIGHEST workspace version without lockstepping the
# rest, so the root version stands still on most release commits and the
# gate collapsed back to `published == 'true'`, skipping verification on
# exactly the re-run that recovers a partial publish (#3181). These
# drive real throwaway git repositories: a root bump, a bump that leaves
# the root alone, no bump at all, and a first commit with no parent.
#
# They also cover the YAML SEAM, by lifting the `VERSION_CHANGED=` lines
# straight out of `release.yml` and running them under the Actions
# default shell. The script fails open on its own, but a bare
# `VAR=$(node …)` under `bash -eo pipefail` takes the substitution's
# exit status: a non-zero exit killed the step, `version_changed` was
# never written, and both verifiers skipped DESPITE `always()`. Editing
# the workflow back to that spelling reddens these tests.
- name: Check the release version-bump gate sees non-root bumps and fails open
run: node --test scripts/release-version-changed.test.mjs
# verify-esm-entrypoints.mjs is the last gate before `pnpm release`
# publishes. If its package discovery ever returns nothing — packages/
# moved, or the publishable filter stops matching — the smoke loop has
# nothing to fail on, the summary reads "0 passed, 0 failed, 0 skipped"
# and the run exits 0: success reported for having imported nothing.
# Same absence-as-success shape as the unbuilt skip it already fails
# closed on. This also pins that a legitimate run still exits 0.
- name: Check the ESM smoke test fails on an empty discovery
run: node --test scripts/verify-esm-entrypoints.test.mjs
# Executable proof for the test-glob-coverage gate, which runs in `lint`:
# its fixtures drive the unmodified checker against synthetic packages via
# `--root` and assert it goes red for a non-recursive shell glob, for an
# unrecognised test-script shape, and for a vitest `include` that misses a
# file — the three ways it could otherwise wave a package through.
- name: Check test-glob-coverage gate regressions
run: node --test scripts/check-test-glob-coverage.test.mjs
# `fetch-fixtures.mjs --check` is the step every fixture-consuming job now
# runs unconditionally after its cache-gated fetch, so it is a gate about
# a gate: if IT can pass over nothing, it is worse than absent, because it
# also buys silence. These cases drive the unmodified script against
# synthetic corpora and assert it reds — naming the offending path — for a
# fixture that is absent, byte-flipped at the same size, or truncated, and
# for each way of verifying nothing: no manifest, an unparseable one, one
# listing zero files, and a scoped `--check a.ifc b.ifc` naming a path the
# manifest no longer lists. Positive controls keep it from being switched
# off for redding a healthy tree.
- name: Check the fixture-cache verification cannot pass vacuously
run: node --test scripts/fixtures/fetch-fixtures.test.mjs
# The steps above name each scripts/ test file one by one, which is
# itself the trap check-test-glob-coverage.mjs exists to catch: the file
# that shipped that gate was the one entry nobody added, so a gate for
# unrun test files was itself unrun. Its own audit cannot see the gap —
# it walks packages/ and apps/, and scripts/ is neither. This catch-all
# closes it by GLOB, so the next script's tests run on the commit that
# adds them. The named steps above stay for their comments and for
# per-gate attribution; re-running them here costs a few seconds.
# scripts/fixtures/ is in the glob because it was NOT: the first test file
# under it would have been added, run locally, and then never run again.
# scripts/docs/ joined it for the same reason in #3200 - the catch-all is
# per-DIRECTORY, so it goes blind on the next subdirectory anyone adds.
- name: Run every scripts/ test file (glob catch-all)
run: node --test scripts/*.test.mjs scripts/lib/*.test.mjs scripts/fixtures/*.test.mjs scripts/docs/*.test.mjs
# The server-parse root-attribute table (attr_indices.rs) is generated
# from @ifc-lite/parser's SCHEMA_REGISTRY — the SAME table the in-browser
# parse resolves names against. Guard that the committed file is in sync
# so a registry change can't silently break server↔client parity. Uses
# the parser dist from the build artifact above; format-agnostic, no Rust
# toolchain needed (issue #1780).
- name: Check server attr-indices freshness
run: node scripts/generate-server-attr-indices.mjs --check
# The sandbox `bim` ambient type surface (apps/viewer/.../bim-globals.d.ts)
# is generated from @ifc-lite/sandbox's NAMESPACE_SCHEMAS — the SAME schema
# the script editor's completions and the LLM system prompt read live.
# Nothing verified it, and the generator itself had stopped running, so the
# file was hand-edited instead: it never carried `bim.clash` at all, and
# lost two `create` parameters along the way (issue #2418). Uses the sandbox
# dist from the build artifact above; the `frontend` path filter covers both
# sides of the contract (packages/**, apps/viewer/**).
- name: Check sandbox bim-globals freshness
run: node scripts/generate-bim-globals.mjs --check
# Typecheck the built-in template scripts AGAINST that generated surface.
# This is the only thing that actually compiles bim-globals.d.ts, and it
# was run by no workflow: it sat at 193 errors (every one of them
# `Cannot find name 'console'`, because the sandbox `console` was never
# declared), which is why the drift in #2418 went unnoticed for months.
# Now that the declaration is generated, this also verifies every type the
# schema NAMES resolves — a `tsReturn` of `BimClash.Nope` fails here
# (#2422). No build needed: the .d.ts is self-contained and the templates
# import nothing.
# Scoped name + `--fail-if-no-match`: an unmatched pnpm filter prints
# "No projects matched the filters" and exits 0, so a filter that stops
# resolving would turn this gate green without running it. That is the
# same "cannot fail" shape as the `skipLibCheck` default this step was
# already fixed for, so it is closed here rather than left to luck.
- name: Check sandbox script templates typecheck
run: pnpm --filter=@ifc-lite/viewer --fail-if-no-match check:templates
# Docs upkeep guards (cheap, no build/fixtures needed — the doc-sample
# typecheck resolves @ifc-lite/* to each package's src via tsconfig
# paths). Keep the docs in lockstep with the code they describe.
- name: Check package READMEs
run: node scripts/docs/check-package-readmes.mjs
# That gate printed `✅ All 0 published packages have a README.md.` over
# an empty packages/, and dropped an unreadable package directory out of
# the count in silence (#3200, finding 9). Its floors are executable, so
# they are run rather than trusted — and named here rather than left to
# the catch-all glob below, which is per-DIRECTORY and did not reach
# scripts/docs/ when this was written.
- name: Check the README gate cannot pass vacuously
run: node --test scripts/docs/check-package-readmes.test.mjs
- name: Check doc code samples
run: node scripts/docs/check-doc-samples.mjs
- name: Check generated doc sections
run: node scripts/docs/generate-docs-sections.mjs --check
# The viewer suite runs in its OWN job (`viewer-tests`) rather than here.
# One job was running all 47 packages' tests in sequence, and the viewer
# alone needs more than the remainder of the budget: on the run that
# exposed this, everything up to and including @ifc-lite/provenance
# finished 7m55s in, and the viewer then had 17 minutes and did not
# finish. Splitting it out runs the two halves CONCURRENTLY, so each gets
# a whole budget instead of a leftover.
- run: pnpm exec turbo test --filter=!@ifc-lite/viewer
- name: Integration tests (parse → quantities → export)
# Step-scoped timeout: a hung harness must fail fast here, not
# silently consume the whole job budget.
timeout-minutes: 5
run: pnpm test:integration
- name: API route tests (relay handlers)
# `tests/api/**` was reachable only by running `pnpm test:api` by hand,
# so the Dalux relay's path allowlist and node allowlist - the guards
# that stop an unauthenticated public endpoint becoming an open proxy -
# existed in the repo but never in the pipeline. A guard no lane invokes
# is not a gate.
timeout-minutes: 5
run: pnpm test:api
- name: WASM contract tests (real processGeometryBatch boundary)
run: pnpm test:wasm-contract
# The snap cache reconstructs model edges from the mesher's per-triangle
# segments, so its correctness depends on geometry the unit fixtures can
# only imitate. This drives the real wasm pipeline over the committed
# samples and asserts the invariants (one edge per straight run, the
# reported length is the whole run, the cache does not move when triangle
# emission order does -- the #2388 failure class). Issue #2199.
- name: Snap edge reconstruction (real wasm pipeline)
run: pnpm test:snap-edges
# Functional smoke of the real viewer: real WASM pipeline + WebGPU
# renderer (swiftshader) + pick pass + section plane, asserted through
# the app's own store. The only job that catches "renders wrong"
# regressions — unit suites mock the WASM boundary and the Rust tests
# exercise a different mesh pipeline (#858/#957).
viewer-e2e:
name: Viewer E2E smoke
needs: [changes, build]
# PR-only: see the `push:` trigger comment. The post-merge run repeats the
# cheap unit lanes that catch cross-PR collisions, not the whole matrix.
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.rust == 'true') && github.event_name == 'pull_request'
# Free runner — vite build + a ~2.4MB model; not compute-bound.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: build-output
path: packages
- name: Cache test fixtures
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: fixtures-cache
with:
path: tests/models
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
run: pnpm fixtures
# Unconditional — this is the point of the step. The fetch above is
# gated on a cache miss, so on a cache hit nothing had ever confirmed the
# restored tree still matches the manifest. A partial or corrupted cache
# then made every fixture-gated test skip silently and report ok.
- name: Verify fixtures
run: pnpm fixtures:check
# Package dists come from the build artifact; the wasm build
# soft-skips (no wasm-pack on this runner) and reuses the artifact
# runtime, so this is just the vite build of the app.
# The viewer's own `build` script runs scripts/check-tla-chunk-await.mjs
# over the emitted dist/assets, so this step enforces the __tla guard
# too. It lives in the build script rather than here because the #2243
# white screen reached users through Vercel, and a CI-only gate does not
# run there: vercel-build.sh -> turbo build --filter -> this same script.
- name: Build viewer app (also runs the __tla chunk-await guard)
run: pnpm turbo build --filter=@ifc-lite/viewer
# No browser download: the CI project uses channel:'chrome' (the
# runner's preinstalled Chrome) because Playwright's headless
# shell has a broken WebGPU device under software rendering.
- name: Run E2E smoke
# E2E_GPU_STRICT=0: the runner's SwiftShader WebGPU device is
# unstable under load (drops mid-upload), so GPU-dependent
# assertions (pick pass, screenshot density, GPU-error
# strictness) skip here and run in the local headed project
# (pnpm test:e2e). The CPU-side pipeline assertions — mesh
# count through real WASM, data-store population, model
# registration, section-plane store contract — still gate.
env:
E2E_GPU_STRICT: '0'
run: pnpm test:e2e:ci
rust-tests:
name: Rust tests
needs: changes
if: needs.changes.outputs.rust == 'true'
# Stays on Depot for the uncapped, fast cargo cache (GitHub's free cache
# is 10 GB LRU and would thrash the Rust target dir). Right-sized 8→4
# cores: Depot bills minutes × (vCPU/2), so -8 is 4× and -4 is 2× the
# base rate — halving per-minute cost for ~1.5× wall-clock. Timeout
# bumped to absorb the slower wall-clock.
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 28
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- name: Setup Rust (pinned by rust-toolchain.toml)
run: rustup show
- name: Cargo cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
prefix-key: ci-rust
- name: Cache test fixtures
id: fixtures-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: tests/models
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
# The fixtures script is pure Node (no npm deps) so we skip the
# pnpm install dance Rust-only jobs don't need.
run: node scripts/fixtures/fetch-fixtures.mjs
# Unconditional — this is the point of the step. The fetch above is
# gated on a cache miss, so on a cache hit nothing had ever confirmed the
# restored tree still matches the manifest. A partial or corrupted cache
# then made every fixture-gated test skip silently and report ok.
- name: Verify fixtures
run: node scripts/fixtures/fetch-fixtures.mjs --check
# Cheap (resolve only, no compile) and it catches a failure mode that
# otherwise stays invisible for weeks: a release bumps the manifests but
# leaves Cargo.lock recording the old member versions, so every `--locked`
# command refuses to run and every local build silently dirties the lock.
# `scripts/sync-versions.js` keeps the two in step; this is the gate that
# notices if that ever stops being true.
- name: Cargo.lock is in sync with the manifests
run: cargo metadata --locked --format-version 1 > /dev/null
# ifc-lite-wasm is included: its only wasm32-only test file,
# tests/mesh_determinism.rs, self-gates with `#![cfg(target_arch =
# "wasm32")]` and compiles to an empty test binary on the native host
# (`running 0 tests ... ok`), so no workspace-wide exclusion is needed.
# That wasm32 leg runs on its own schedule in determinism.yml.
- name: Clippy (lint gate)
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Rust tests
# Fixtures are fetched above (Fetch fixtures / Cache test fixtures),
# so a missing one here is fixture drift, not a legitimate local skip
# (rust/export/src/test_support.rs) — make that a hard failure.
env:
IFC_LITE_REQUIRE_FIXTURES: "1"
run: cargo test --workspace
# `parquet-bos` is OFF by default because arrow/parquet/zip do not target
# wasm32 cleanly (rust/export/Cargo.toml:33). The consequence is that
# `cargo test --workspace` above never COMPILES the module, let alone
# runs it: an off-by-default feature with no CI leg is dead code that
# looks maintained, and the first person to learn it had broken would be
# whoever enabled it (#2802).
#
# Its own step rather than `--all-features`, which would also switch on
# anything added later and make an unrelated feature's breakage read as
# this one's.
- name: Rust tests (parquet-bos feature)
env:
IFC_LITE_REQUIRE_FIXTURES: "1"
run: cargo test -p ifc-lite-export --features parquet-bos
- name: Clippy (parquet-bos feature)
run: cargo clippy -p ifc-lite-export --features parquet-bos --all-targets -- -D warnings
# Geometry watertightness / triangulation-invariance census.
#
# Its own job, not a step in `rust-tests`: the sweep runs the whole fixture
# manifest and takes ~20 min, which pushed that lane past its 28-minute timeout
# and cancelled it. Here it runs concurrently instead of serially, so neither
# lane's wall-clock depends on the other.
#
# Feature-gated because it links a second ear-clipper as a differential oracle,
# so `cargo test --workspace` does not run it — without this job the suite
# early-returns and its pinned baselines gate nothing.
#
# `geometry`, not `rust`: the filter is scoped to what can actually move the
# numbers. That includes rust/core — `ifc-lite-core` is a direct, non-optional
# dependency of `ifc-lite-geometry`, and the census test itself imports
# `build_entity_index`, `EntityDecoder` and `EntityScanner` from it, so the
# whole IFC-bytes → entities → attributes path is in scope:
# - `GeometryRouter::scan_unit_scale` (router/mod.rs) calls
# `ifc_lite_core::extract_length_unit_scale` (core/src/units.rs) for the
# global length scale; the census snaps positions to 1 mm, so any change
# to unit-scale resolution shifts every coordinate and moves the baselines.
# - `extract_coordinate_list_from_entity` / `parse_indices_direct`
# (core/src/fast_parse.rs) are called from processors/tessellated/
# {triangulated,polygonal}.rs — they *are* the triangle data for the
# tessellation bucket.
# - Float-parsing precision at the 1 mm snap boundary, and attribute
# decoding deciding which hosts are found at all (MIN_MODELS /
# MIN_VOID_HOSTS are the only floor).
# Still not `rust`: rust/clash, rust/export and the bindings crates are not on
# this test's path.
geometry-census:
name: Geometry watertightness census
needs: changes
# PR-only — see viewer-e2e.
if: needs.changes.outputs.geometry == 'true' && github.event_name == 'pull_request'
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- name: Setup Rust (pinned by rust-toolchain.toml)
run: rustup show
- name: Cargo cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
prefix-key: ci-rust-census
- name: Cache test fixtures
id: fixtures-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: tests/models
key: ci-fixtures-${{ runner.os }}-${{ hashFiles('tests/models/manifest.json') }}
- name: Fetch fixtures
if: steps.fixtures-cache.outputs.cache-hit != 'true'
run: node scripts/fixtures/fetch-fixtures.mjs
# Unconditional — this is the point of the step. The fetch above is
# gated on a cache miss, so on a cache hit nothing had ever confirmed the
# restored tree still matches the manifest. A partial or corrupted cache
# then made every fixture-gated test skip silently and report ok.
- name: Verify fixtures
run: node scripts/fixtures/fetch-fixtures.mjs --check
- name: Census
run: cargo test -p ifc-lite-geometry --features triangulation-alt --test triangulation_invariance -- --nocapture
# The per-host rows this run measured, uploaded pass or fail. The census
# gates against a checked-in golden, and its log prints its per-element
# lists truncated, so without this a run that disagrees with the golden
# could only be diagnosed by reproducing a ~20-minute sweep over a 1.4 GB
# fixture corpus locally. Re-blessing is now: download, replace the golden.
- name: Upload the census rows
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: watertightness-census-rows
path: target/watertightness_census.run.tsv
if-no-files-found: warn
# Plato clash-math single-source freshness gate. The clash narrow-phase math
# is written once in Plato and transpiled to Rust + TypeScript; the committed
# rust/clash/src/generated/plato.rs and packages/clash/src/math/generated/
# plato.g.ts must stay in sync with the .plato source. The generator clones
# plato + ara3d-sdk at pinned SHAs and rebuilds Plato.CLI (needs the .NET 9
# SDK), so this runs only when the plato sources / generated files / the
# generator change. See tools/plato/README.md.
plato-check:
name: Plato clash-math freshness
needs: changes
# PR-only — see viewer-e2e.
if: needs.changes.outputs.plato == 'true' && github.event_name == 'pull_request'
# Free runner - the generator clones + builds Plato.CLI with dotnet; no
# Depot cores needed.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
# The scalar codemod imports the repo's own pinned typescript package;
# printer output is only byte-stable against that exact version, so the
# gate resolves it from the lockfile like every other node job.
- run: pnpm install --frozen-lockfile
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '9.0.x'
# The toolchain checkouts and NuGet packages only change when the pins
# in the generator script change, so key the cache on that file.
- name: Cache Plato toolchain
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/ifc-lite-plato
~/.nuget/packages
key: plato-toolchain-${{ runner.os }}-${{ hashFiles('scripts/generate-plato-clash.mjs') }}
# Regenerates the Rust + TypeScript outputs from tools/plato/ at the
# pinned plato + ara3d-sdk SHAs and fails (exit 1) if the committed
# files drift.
- name: Check committed Plato outputs are in sync
run: PLATO_WORK_DIR=$HOME/.cache/ifc-lite-plato node scripts/generate-plato-clash.mjs --check
# Docs guards for docs-only PRs. node-tests runs the same three checks,
# but it only triggers on frontend/rust changes - and docs-only PRs are
# exactly the ones most likely to break code samples or generated
# tables. This lightweight twin (install + three node scripts, no build,
# no fixtures) fills that gap and is skipped whenever node-tests already
# covers it.
docs-checks:
name: Docs checks (docs-only PRs)
needs: changes
if: needs.changes.outputs.docs == 'true' && needs.changes.outputs.frontend != 'true' && needs.changes.outputs.rust != 'true'
# Free runner - three node scripts, not compute-bound.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Check package READMEs
run: node scripts/docs/check-package-readmes.mjs
- name: Check doc code samples
run: node scripts/docs/check-doc-samples.mjs
- name: Check generated doc sections
run: node scripts/docs/generate-docs-sections.mjs --check
# Aggregate gate. Keeps the existing required status check name green
# for the branch protection rule so we don't have to retouch settings.
# Succeeds if every dependency succeeded or was skipped (path-filtered
# out); fails the moment any dependency fails or is cancelled.
# The crate semver gate (issue #3216). It runs at release time as the
# crates.io half's precondition, but a release-only gate is exactly the shape
# `release-crates-order.test.mjs` argues against in its own header: the
# Release workflow runs only on main, and only on an actual publish, so a
# breaking API change sits latent until it fails after npm has already gone
# out. This runs it on every PR instead.
#
# It is cheap on an ordinary PR and expensive only where it has signal. Each
# crate whose version is already live on crates.io is skipped BEFORE
# cargo-semver-checks is invoked, which on an ordinary PR is all of them: the
# job is seven registry lookups and a message saying plainly that no API was
# compared. On the `chore: version packages` PR, where `sync-versions.js` has
# moved the workspace version, it does the real comparison — and that is the
# commit whose version the whole issue is about, reviewed before it merges
# and publishes.
rust-semver:
name: Rust crate semver
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
lfs: false
persist-credentials: false
# A stable toolchain, not the dated nightly in rust-toolchain.toml:
# cargo-semver-checks refuses a nightly outright ("rustc version is not
# high enough: >=1.93.0 needed, got 1.93.0-nightly"). The gate invokes it
# as `cargo +stable semver-checks`, so this only has to exist.
- name: Install stable Rust
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-05
with:
toolchain: stable
# Prebuilt binary. `cargo install cargo-semver-checks --locked` compiles
# it from source (~5 min).
- name: Install cargo-semver-checks
uses: taiki-e/install-action@4f5ee4a4759116abc6b10af05f20374e179d8ca3 # cargo-semver-checks (snapshot pinned 2026-08)
- name: Cargo cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
prefix-key: ci-rust-semver
# No `pnpm install`: the gate imports only node builtins and
# scripts/lib/crates-io.mjs, which does the same.
- name: Check crate semver against crates.io
run: node scripts/check-rust-semver.mjs
test:
name: Build + WASM + Rust + Node
needs: [changes, build, typecheck, lint, node-tests, viewer-tests, viewer-e2e, rust-tests, rust-semver, geometry-census, plato-check, docs-checks]
if: always()
# Free runner — the gate just inspects upstream job results.
runs-on: ubuntu-latest
steps:
- name: Gate on dependencies
run: |
declare -A results=(
[build]="${{ needs.build.result }}"
[typecheck]="${{ needs.typecheck.result }}"
[lint]="${{ needs.lint.result }}"
[node-tests]="${{ needs.node-tests.result }}"
[viewer-tests]="${{ needs.viewer-tests.result }}"
[viewer-e2e]="${{ needs.viewer-e2e.result }}"
[rust-tests]="${{ needs.rust-tests.result }}"
[rust-semver]="${{ needs.rust-semver.result }}"
[geometry-census]="${{ needs.geometry-census.result }}"
[plato-check]="${{ needs.plato-check.result }}"
[docs-checks]="${{ needs.docs-checks.result }}"
)
fail=0
for j in "${!results[@]}"; do
r="${results[$j]}"
echo " $j: $r"
case "$r" in
success|skipped) ;;
*) fail=1 ;;
esac
done
exit $fail