fix(viewer): make an omitted teardown scope arm a compile error, not a silent no-op #6812
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |