test(wasm): 27 tests for two untested modules, and a compile-time pin… #2261
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
| name: Release | |
| # PUBLISH AUTHENTICATION — Phase B trusted publishing | |
| # | |
| # Package registries — short-lived credentials minted via OIDC at runtime: | |
| # | |
| # npm — Trusted Publisher configured per `@ifc-lite/*` package on | |
| # npmjs.com. `id-token: write` + npm CLI ≥ 11.5.1 + | |
| # NPM_CONFIG_PROVENANCE=true completes the OIDC handshake | |
| # automatically; no NPM_TOKEN needed. | |
| # crates.io — Trusted Publisher configured per crate on crates.io. | |
| # rust-lang/crates-io-auth-action exchanges the OIDC token | |
| # for a 30-minute CARGO_REGISTRY_TOKEN. | |
| # | |
| # Git side (push of `changeset-release/main` + opening the version PR) uses | |
| # a fine-grained PAT (`secrets.RELEASE_PAT`) — see the inline comment on | |
| # the Checkout step for why a PAT and not GITHUB_TOKEN or a GitHub App. | |
| # | |
| # The release job runs in the `release-publish` GitHub Environment so any | |
| # residual repo-scoped secrets are unreachable from other jobs. See | |
| # docs/security/incident-response.md (Phase E) for the rotation runbook. | |
| on: | |
| push: | |
| branches: | |
| - main | |
| concurrency: ${{ github.workflow }}-${{ github.ref }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| release: | |
| name: Release | |
| runs-on: ubuntu-latest | |
| environment: release-publish | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| id-token: write | |
| outputs: | |
| published: ${{ steps.changesets.outputs.published }} | |
| version: ${{ steps.get-version.outputs.version }} | |
| steps: | |
| # AUTH for the changesets version PR (`chore: version packages`): | |
| # we use a fine-grained PAT (`secrets.RELEASE_PAT`), NOT the default | |
| # GITHUB_TOKEN, because GitHub suppresses pull_request/push workflow | |
| # triggers on GITHUB_TOKEN-authored events (recursion guard) so the | |
| # required `Build + WASM + Rust + Node` check would never run on a | |
| # version PR and it would sit permanently "Expected" (issue #766). | |
| # | |
| # Previously this used an installation token from a GitHub App | |
| # (`ifc-lite-release-bot-org`) but the install didn't carry over when | |
| # the repo moved from `louistrue/ifc-lite` to `LTplus-AG/ifc-lite`, | |
| # and the App's installation token on the new owner lost | |
| # `pull_requests:write` — every Release run after that failed with | |
| # "Resource not accessible by integration" on the create-PR API call. | |
| # | |
| # A fine-grained PAT scoped to this repo with `contents: read+write` | |
| # and `pull_requests: read+write` survives org/repo moves and removes | |
| # the App-management surface entirely. Rotate the PAT in the repo | |
| # secrets when it expires; no workflow change needed for rotation. | |
| - name: Checkout Repo | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| lfs: false | |
| # Persist the PAT so the version-packages branch push made by | |
| # changesets/action is PAT-authored too — not just the PR API call. | |
| # Both must be PAT-authored for the pull_request event to fire. | |
| token: ${{ secrets.RELEASE_PAT }} | |
| - 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 24 ships npm ≥ 11.5.1 out of the box, which is what npm | |
| # trusted publishing (OIDC handshake) needs. We tried staying on | |
| # Node 22 and self-upgrading npm at runtime in Release #426/#427, | |
| # but `npm install -g npm@latest` (with and without --force) | |
| # leaves the live npm process unable to resolve its own bundled | |
| # deps mid-install — `Cannot find module 'promise-retry'` from | |
| # @npmcli/arborist/lib/arborist/rebuild.js. Node 24 sidesteps | |
| # the self-upgrade entirely. | |
| node-version: 24 | |
| registry-url: 'https://registry.npmjs.org' | |
| # rust-toolchain.toml pins nightly-2025-11-15 + wasm32-unknown-unknown. | |
| # `rustup show` installs that exact toolchain on GHA runners; matches | |
| # test.yml so the published bundle is byte-identical | |
| # to what CI tested. Plain @stable would leave the nightly missing and | |
| # `wasm-pack build` would fail with "toolchain not installed" — exactly | |
| # the regression observed on Release #403 after #657 untracked the wasm. | |
| # Rust + wasm-pack for the wasm32 build (pure-Rust kernel — no C++ | |
| # toolchain since M9). Single-sourced in | |
| # .github/actions/setup-wasm-build (shared with test.yml / sdk-canary.yml). | |
| - name: Setup WASM build toolchain | |
| uses: ./.github/actions/setup-wasm-build | |
| with: | |
| cache-prefix: release | |
| - name: Install Dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Cache + fetch fixtures so cross-package tests that load | |
| # tests/models/ifc5/*.ifcx (pointcloud, export, ifcx) can run. | |
| # Mirrors the Test workflow; key changes only when the manifest does. | |
| - name: Cache test fixtures | |
| id: fixtures-cache | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: tests/models | |
| # Namespace cache key separately from `test.yml` (which uses | |
| # `ci-fixtures-…`). Phase A hardening — keeps the privileged | |
| # release runner from ever reading a cache writeable by a | |
| # fork-triggered job. See AGENTS.md security notes. | |
| key: release-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 | |
| # Build the publishable artifacts. Typecheck / ESM-resolution / | |
| # unit tests are NOT re-run here: branch protection requires | |
| # test.yml's `Build + WASM + Rust + Node` check to be green on | |
| # the PR before merge, so the SHA reaching `push: main` has | |
| # already passed all of them. Repeating them would add ~8 min | |
| # per release without changing the verdict. | |
| - name: Build Packages | |
| run: pnpm build | |
| # Exchange the workflow's OIDC ID-token for a short-lived (30-minute) | |
| # crates.io access token. A Trusted Publisher must be configured on | |
| # crates.io for EACH published crate (ifc-lite-core, -geometry, -clash, | |
| # -processing, -ffi, -wasm) pointing at this repo + this workflow | |
| # before its first automated publish. A brand-new crate name (e.g. | |
| # ifc-lite-ffi) must additionally be bootstrapped once with a personal | |
| # API token — `cargo publish -p <crate> --token …` — to claim the name, | |
| # since a Trusted Publisher can only be configured on an existing crate. | |
| - name: Authenticate to crates.io (OIDC) | |
| id: crates-auth | |
| uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 | |
| # Read the release state BEFORE `changesets/action` runs. That step invokes | |
| # `pnpm run version` when changesets are pending, which CONSUMES | |
| # `.changeset/*.md` and bumps `package.json` in this same working tree. A | |
| # gate reading either of those afterwards sees "no changesets pending" and | |
| # the NEW, not-yet-released version — and would tag a version whose | |
| # packages have not been published, on the very run that only opened the | |
| # version PR. | |
| - name: Capture pre-release state | |
| id: pre | |
| run: | | |
| echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT | |
| echo "sb_version=$(node -p "require('./packages/server-bin/package.json').version")" >> $GITHUB_OUTPUT | |
| echo "pending=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ')" >> $GITHUB_OUTPUT | |
| - name: Create Release Pull Request or Publish | |
| id: changesets | |
| uses: changesets/action@198f833dd7d863100ea6e28967bc9a9fdefadb0a # v2.1.0 | |
| with: | |
| # v2 RENAMED every one of these. The old spellings were `version`, | |
| # `publish`, `commit` and `title`, and GitHub Actions IGNORES an | |
| # unknown `with:` key rather than failing — so carrying them across | |
| # the major bump would leave the action with no version script and | |
| # no publish script, and it would report success having published | |
| # nothing. A release that goes green and ships nothing is worse | |
| # than one that fails, so these names are load-bearing. | |
| version-script: pnpm run version | |
| publish-script: pnpm run release | |
| commit-message: 'chore: version packages' | |
| pr-title: 'chore: version packages' | |
| # Also moved: v2 reads the token from this INPUT and no longer from | |
| # `env.GITHUB_TOKEN`. It defaults to the automatic `github.token`, | |
| # whose generated events do not trigger workflows — see the comment | |
| # on the Checkout step. The PAT must stay, and it must be here. | |
| github-token: ${{ secrets.RELEASE_PAT }} | |
| env: | |
| # npm publishes use OIDC trusted publishing — no NPM_TOKEN. | |
| # `id-token: write` (set at job level) + npm CLI ≥ 11.5.1 + | |
| # NPM_CONFIG_PROVENANCE complete the handshake automatically | |
| # and attach SLSA provenance to every tarball. | |
| NPM_CONFIG_PROVENANCE: 'true' | |
| # crates.io token minted via OIDC above; expires in 30 minutes. | |
| CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }} | |
| # Whether to create this version's release artifacts. | |
| # | |
| # This used to be `steps.changesets.outputs.published == 'true'`, which | |
| # asks "did npm publish in THIS run?" — a different question from "does | |
| # this released version still need its tag?". The two came apart on | |
| # 2026-08-12 (v4.4.1): `changeset publish` published every package, then | |
| # the step failed while pushing tags on a transient TLS error | |
| # ("server certificate verification failed"). `published` was therefore | |
| # not 'true', so the v-tag steps skipped — and every later run skipped | |
| # them too, because by then npm had nothing left to publish. v4.4.0 and | |
| # v4.4.1 both ended up with no `v*` tag or GitHub release at all, and no | |
| # run would ever create them. | |
| # | |
| # So: publish-in-this-run still qualifies, and a released version whose | |
| # tag is missing now qualifies too. "Released" is read from the PRE-action | |
| # state (see `steps.pre`): with changesets pending, this run only opened | |
| # the version PR, and the post-action tree already carries the bumped, | |
| # unpublished version. | |
| # | |
| # Root and server-bin are tracked SEPARATELY. A run that created the root | |
| # release and then failed must still be able to backfill the server-bin | |
| # one, which a single shared flag would skip forever — the same | |
| # "one flag, two questions" mistake in miniature. | |
| - name: Decide release artifacts | |
| id: release-gate | |
| run: | | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| echo "sb_version=${SB_VERSION}" >> $GITHUB_OUTPUT | |
| # Is each version actually OUT, as opposed to merely versioned? | |
| # | |
| # An empty `.changeset` directory does not answer that. After a | |
| # version PR merges its changesets are already consumed, so a run | |
| # whose publish fails outright — bad credentials, a build error, the | |
| # npm 404 on an unbootstrapped package that stranded crates.io for | |
| # nine days — still arrives here with PENDING=0. Inferring "released" | |
| # from that would tag versions that never shipped. | |
| # | |
| # npm is the durable evidence, and each tag is gated on the version | |
| # IT stands for: the root tag on whichever published package carries | |
| # the root version (sync-versions.js keeps them aligned), the | |
| # server-bin tag on @ifc-lite/server-bin itself. `published == true` | |
| # short-circuits it — that IS this run publishing. | |
| root_released=false | |
| sb_released=false | |
| if [ "${PUBLISHED}" = "true" ]; then | |
| echo "npm published in this run" | |
| root_released=true | |
| sb_released=true | |
| elif [ "${PENDING}" != "0" ]; then | |
| echo "${PENDING} changeset(s) were pending — this run opened the version PR, nothing to tag" | |
| else | |
| ROOT_PKG=$(node -e ' | |
| const fs = require("fs"); | |
| const want = process.env.VERSION; | |
| const hit = fs.readdirSync("packages") | |
| .map((d) => { try { return JSON.parse(fs.readFileSync(`packages/${d}/package.json`, "utf8")); } catch { return null; } }) | |
| .find((p) => p && p.private !== true && p.version === want); | |
| process.stdout.write(hit ? hit.name : ""); | |
| ') | |
| if [ -n "${ROOT_PKG}" ] && npm view "${ROOT_PKG}@${VERSION}" version > /dev/null 2>&1; then | |
| echo "${ROOT_PKG}@${VERSION} is on npm — v${VERSION} is genuinely released" | |
| root_released=true | |
| else | |
| echo "no published package carries v${VERSION} on npm — NOT tagging (publish likely failed)" | |
| fi | |
| if npm view "@ifc-lite/server-bin@${SB_VERSION}" version > /dev/null 2>&1; then | |
| echo "@ifc-lite/server-bin@${SB_VERSION} is on npm" | |
| sb_released=true | |
| else | |
| echo "@ifc-lite/server-bin@${SB_VERSION} is not on npm — NOT tagging it" | |
| fi | |
| fi | |
| # Root and server-bin are tracked SEPARATELY. A run that created the | |
| # root release and then failed must still be able to backfill the | |
| # server-bin one, which a single shared flag would skip forever. | |
| root=false | |
| server=false | |
| [ "$root_released" = "true" ] && { gh release view "v${VERSION}" > /dev/null 2>&1 || root=true; } | |
| [ "$sb_released" = "true" ] && { gh release view "v${SB_VERSION}" > /dev/null 2>&1 || server=true; } | |
| # When the two versions coincide, the ONE release must be created by | |
| # the server-bin step, which uses the PAT. A GITHUB_TOKEN-created | |
| # release emits no `release: published` event (GitHub recursion | |
| # guard), so server-binaries.yml would never upload the archives and | |
| # `npx @ifc-lite/server-bin` would 404 against an empty release. | |
| if [ "${VERSION}" = "${SB_VERSION}" ] && [ "$server" = "true" ]; then | |
| echo "root and server-bin versions coincide — letting the PAT-authored server-bin step create v${VERSION}" | |
| root=false | |
| fi | |
| echo "run_root=${root}" >> $GITHUB_OUTPUT | |
| echo "run_server=${server}" >> $GITHUB_OUTPUT | |
| echo "root=${root} server=${server}" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Passed as env, not interpolated into the script: `${{ }}` expansion | |
| # inside `run:` splices the value into the shell source itself | |
| # (zizmor template-injection). | |
| VERSION: ${{ steps.pre.outputs.version }} | |
| SB_VERSION: ${{ steps.pre.outputs.sb_version }} | |
| PENDING: ${{ steps.pre.outputs.pending }} | |
| PUBLISHED: ${{ steps.changesets.outputs.published }} | |
| - name: Get Version | |
| id: get-version | |
| if: steps.release-gate.outputs.run_root == 'true' || steps.release-gate.outputs.run_server == 'true' | |
| run: | | |
| # The pre-action root version — NOT a fresh read of package.json, which | |
| # `changesets/action` may have bumped in this same tree. | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| env: | |
| VERSION: ${{ steps.release-gate.outputs.version }} | |
| - name: Create GitHub Release | |
| if: steps.release-gate.outputs.run_root == 'true' | |
| run: | | |
| # Create the v* tag (changesets creates @scope/package@version tags, not v* tags) | |
| git tag "v${VERSION}" || true | |
| git push origin "v${VERSION}" || true | |
| # Create release, skipping if it already exists | |
| if gh release view "v${VERSION}" > /dev/null 2>&1; then | |
| echo "Release v${VERSION} already exists, skipping" | |
| else | |
| gh release create "v${VERSION}" \ | |
| --title "v${VERSION}" \ | |
| --notes "See CHANGELOG.md for details" | |
| fi | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ steps.get-version.outputs.version }} | |
| # @ifc-lite/server-bin downloads its native archive from a GitHub | |
| # release tagged `v<its-own-version>` (packages/server-bin/src/binary.ts). | |
| # The root `v<version>` release above is keyed to the *highest* workspace | |
| # package version, which is usually NOT server-bin's — so without a | |
| # matching tag `npx @ifc-lite/server-bin` 404s on download, and native | |
| # binaries never carry geometry fixes that bump only server-bin/wasm. | |
| # Create a release at server-bin's own version: that `release: published` | |
| # event triggers server-binaries.yml to build + upload the per-platform | |
| # archives to this tag, so the download URL resolves with fresh binaries. | |
| - name: Create server-bin binary release | |
| if: steps.release-gate.outputs.run_server == 'true' | |
| run: | | |
| # SB_VERSION arrives via env (see below), not spliced into this script. | |
| TAG="v${SB_VERSION}" | |
| # Skip when the tag already exists (server-bin unchanged this cycle, | |
| # or it happens to equal the root release already created above). | |
| if gh release view "$TAG" > /dev/null 2>&1; then | |
| echo "Release $TAG already exists, skipping (server binaries already published)" | |
| else | |
| git tag "$TAG" || true | |
| git push origin "$TAG" || true | |
| gh release create "$TAG" \ | |
| --title "$TAG" \ | |
| --notes "Native server binary for @ifc-lite/server-bin v${SB_VERSION}. See CHANGELOG.md." | |
| fi | |
| env: | |
| # Use the fine-grained PAT, NOT GITHUB_TOKEN: a release created with | |
| # GITHUB_TOKEN does NOT emit a `release: published` event that starts new | |
| # workflow runs (GitHub recursion guard). server-binaries.yml uploads | |
| # binaries only on `release: published`, so a GITHUB_TOKEN-created release | |
| # would sit empty and downloads would still 404. The PAT is a real | |
| # identity, so the event fires and binaries build + upload to this tag. | |
| GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} | |
| SB_VERSION: ${{ steps.release-gate.outputs.sb_version }} | |
| # Verify every published package is actually reachable on npm. | |
| # Catches packages accidentally skipped during release before users hit them. | |
| verify-npm-publish: | |
| name: Verify npm publish | |
| needs: release | |
| # `always()` is the load-bearing part. A failed `needs:` skips the dependent | |
| # whatever the condition says, so the job that exists to confirm packages | |
| # reached npm could not run on the case it exists for: a PARTIAL publish. | |
| # On 2026-08-14 nine packages went out, @ifc-lite/source-dalux 404'd, the | |
| # release job failed — and this was skipped, so nothing named the missing | |
| # package. It was found by hand afterwards. | |
| # | |
| # `published` is still the right gate: changesets/action sets it true | |
| # whenever any package published, INCLUDING the partial-failure path, and | |
| # false on a run that merely opened the version PR. So this now runs exactly | |
| # when something was published and never on the PR-opening runs. | |
| if: always() && needs.release.outputs.published == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout Repo | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| 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 | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Verify all packages on npm | |
| # New packages can take longer to appear in npm metadata after publish. | |
| # Retry for up to ~4 minutes before failing. | |
| run: node scripts/verify-npm-publish.js --retries 12 --delay 20000 |