fix(geometry): carry the no-void probe reading through the census rec… #2372
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 }} | |
| # Should the publish verifiers run for this commit? See the comment on | |
| # `version_changed` in the `pre` step, and on `verify-npm-publish`. | |
| verify: ${{ steps.changesets.outputs.published == 'true' || steps.pre.outputs.version_changed == 'true' }} | |
| 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 }} | |
| # 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. | |
| # | |
| # This sits IMMEDIATELY after the checkout, and must stay there. Its | |
| # outputs gate both publish-verification jobs, and an output is unset | |
| # when the step never runs: every step placed above it — pnpm/Node | |
| # setup, the WASM toolchain, `pnpm install`, the fixtures, `pnpm build`, | |
| # the crates.io OIDC exchange — would take `verify` down with it, and | |
| # both verifiers would skip DESPITE `always()`, on exactly the broken | |
| # release that needs them. It needs no toolchain to do its job: `node` | |
| # and `find` only, both present on the runner image before any setup | |
| # step, and reading earlier only strengthens the | |
| # "before changesets/action mutates the tree" premise above. | |
| - 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" | |
| # Does THIS commit carry a version bump? | |
| # | |
| # This is what gates the publish verifiers below, and it answers a | |
| # different question from `changesets.outputs.published`. `published` | |
| # is "did this RUN publish something", which is false on the re-run | |
| # that RECOVERS a partial publish (npm has nothing left to publish | |
| # by then) — so the verifier was skipped on exactly the attempt that | |
| # needed checking (#3181, v6.0.0 attempt 2). "Is this the release | |
| # commit" stays true across every re-run of it, and stays false for | |
| # the ~50 ordinary pushes a day that publish nothing, so the | |
| # verifiers neither skip a recovery nor fire vacuously. | |
| # | |
| # The comparison lives in a script, not inline here, because the | |
| # obvious inline spelling — root `package.json` version vs `HEAD~1` — | |
| # is WRONG on this repo and looked right: `sync-versions.js` pins the | |
| # root version to the HIGHEST workspace version and does not lockstep | |
| # the rest, so the root version only moves when that one top package | |
| # is bumped. Replayed over the last 60 `chore: version packages` | |
| # commits it opened on 16; the whole-workspace comparison the script | |
| # does opens on 60/60, and on 0/40 recent ordinary pushes. See the | |
| # header of the script and its test for the measurement. | |
| # The script fails OPEN internally, but a bare assignment from a | |
| # command substitution takes the substitution's exit status, and the | |
| # Actions default shell is `bash --noprofile --norc -eo pipefail`. | |
| # So ANY non-zero `node` exit — the script renamed or absent, an | |
| # import-time throw, OOM, node failing to start — would abort this | |
| # STEP, leaving `version_changed` unwritten; `verify` would then read | |
| # `''` and both verifiers would skip DESPITE `always()`, which is the | |
| # exact failure the placement above exists to prevent. The `||` keeps | |
| # the step alive; the second line makes anything that is not | |
| # literally `false` — including the empty string an exit-0 run that | |
| # printed nothing would leave — open the gate. | |
| VERSION_CHANGED=$(node scripts/release-version-changed.mjs) || VERSION_CHANGED=true | |
| [ "${VERSION_CHANGED}" = "false" ] || VERSION_CHANGED=true | |
| echo "version_changed=${VERSION_CHANGED}" >> "$GITHUB_OUTPUT" | |
| - 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 | |
| # SEMVER GATE for the crates (issue #3216). `pnpm release` runs | |
| # `check:rust-semver` before it publishes anything; these two steps put | |
| # the tool it needs on PATH. | |
| # | |
| # 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; it does not | |
| # become the default toolchain and nothing else in the release uses it. | |
| - name: Install stable Rust for the crate semver gate | |
| 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) on a runner that is already the slowest job in | |
| # the repo. | |
| - name: Install cargo-semver-checks | |
| uses: taiki-e/install-action@4f5ee4a4759116abc6b10af05f20374e179d8ca3 # cargo-semver-checks (snapshot pinned 2026-08) | |
| - name: Create Release Pull Request or Publish | |
| id: changesets | |
| uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1 | |
| 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) | |
| # | |
| # `|| true` on `git tag` is deliberate: a backfill or a re-run hits an | |
| # existing tag and idempotency is the point. It is NOT on the push. | |
| # A swallowed push is not "no release" -- `gh release create` CREATES a | |
| # missing tag itself, and with no `--target` it does so from the latest | |
| # state of the default branch (its own `--help` says so). So the failure | |
| # mode was a green release pointing at a DIFFERENT commit than the | |
| # packages published from it, which `packages/server-bin/src/binary.ts` | |
| # then resolves its download URL against (#3202). | |
| # | |
| # The backfill path added after 2026-08-12 is the one most exposed to | |
| # this: a backfill by definition runs after `main` has moved on. | |
| # | |
| # THE COMMIT IS NAMED EXPLICITLY (#3209). `git tag <name>` with no | |
| # commit-ish tags the current checkout HEAD, and this job checks out | |
| # with no `ref:` — so HEAD is the commit that TRIGGERED the run. Every | |
| # push to `main` triggers a Release run, so a correct tag was only ever | |
| # a coincidence: it held when the trigger happened to be the version | |
| # commit. On v6.0.1 it did not, and the tag landed on an unrelated | |
| # test-only commit fourteen minutes later, green and unreported. | |
| # | |
| # The resolver also REFUSES when the tag already exists on a different | |
| # commit, which is why `|| true` below is still safe: by the time it | |
| # runs, any existing tag has been proven to name this same commit. The | |
| # `$( )` assignment carries the script's exit status under the Actions | |
| # default shell (`bash --noprofile --norc -eo pipefail`), so a refusal | |
| # kills the step before anything is pushed. | |
| VERSION_COMMIT=$(node scripts/release-tag-commit.mjs package.json "${VERSION}" "v${VERSION}") | |
| git tag "v${VERSION}" "${VERSION_COMMIT}" || true | |
| git push origin "v${VERSION}" | |
| # 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 | |
| # --verify-tag: refuse to invent the tag. The push above is the only | |
| # thing allowed to create it, and it is now unguarded, so a failure is | |
| # loud where it happens instead of being reasoned about downstream. | |
| gh release create "v${VERSION}" \ | |
| --verify-tag \ | |
| --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 | |
| # Same shape as the root release above: idempotent tag, UNGUARDED | |
| # push, and `--verify-tag` so `gh` cannot invent the ref at | |
| # default-branch head if the push failed (#3202). Getting this wrong | |
| # here is worse than above -- server-binaries.yml uploads the | |
| # per-platform archives to whatever commit this tag names. | |
| # | |
| # Resolved from server-bin's OWN manifest, independently of the root | |
| # version: the two are usually different versions and therefore | |
| # different commits. `v1.16.7` is the standing proof — it points at | |
| # db35b4a6, 35 commits after 44c4201e where server-bin's version was | |
| # actually set, and both commits report 1.16.7, so nothing noticed | |
| # for nine days (#3209). The resolver refuses rather than reusing a | |
| # tag that names a different commit, which turns that damage into a | |
| # red job the next time a run touches it. | |
| SB_COMMIT=$(node scripts/release-tag-commit.mjs packages/server-bin/package.json "${SB_VERSION}" "$TAG") | |
| git tag "$TAG" "${SB_COMMIT}" || true | |
| git push origin "$TAG" | |
| gh release create "$TAG" \ | |
| --verify-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 }} | |
| # ── Publish verification ──────────────────────────────────────────────── | |
| # | |
| # Verify every published package/crate is actually reachable on its | |
| # registry. Catches a partial publish before users hit it. | |
| # | |
| # THE GATE, and why it is not `published`: | |
| # | |
| # These used to be gated `if: always() && needs.release.outputs.published | |
| # == 'true'`. That asks "did THIS RUN publish something", which is false on | |
| # the re-run that RECOVERS a partial publish — npm has nothing left to | |
| # publish by then. #3181: on v6.0.0, attempt 2 was the attempt that fixed | |
| # the partial publish, and it is the attempt the gate skipped. A verifier | |
| # that cannot run on a recovery is not watching the case it exists for. | |
| # | |
| # `needs.release.outputs.verify` asks the question that survives a re-run | |
| # instead: "is this commit a version bump, or did this run publish?" Both | |
| # verifiers re-derive the expected versions from package.json / Cargo.toml | |
| # at the checked-out commit, so they never needed to know whether THIS run | |
| # is what published them. | |
| # | |
| # `always()` is the other load-bearing half. A failed `needs:` skips the | |
| # dependent whatever the condition says, so the job that exists to confirm | |
| # packages reached the registry 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. | |
| # | |
| # These stay JOBS IN THIS RUN rather than a separate `workflow_run` | |
| # workflow. #3181's original report argued for moving them out because | |
| # attempt 1's verifier had been "starved of a runner by the tag-push | |
| # flood"; that was measured afterwards and is false — it queued 7m46s, ran, | |
| # and passed, one second before the attempt was cancelled by hand. The | |
| # remaining structural argument does not hold either: `concurrency:` above | |
| # has no `cancel-in-progress`, which defaults to false, so a newer push | |
| # QUEUES behind this run rather than cancelling it — GitHub never cancels | |
| # these jobs with their parent. And a separate workflow queues into the | |
| # same account-level runner pool, so being separate would confer no | |
| # scheduling priority in any case. Staying in-run keeps the verdict on the | |
| # Release run itself, keeps the YAML and `scripts/` at one commit, and | |
| # cannot fire on the ~50 pushes a day that publish nothing. | |
| verify-npm-publish: | |
| name: Verify npm publish | |
| needs: release | |
| if: always() && needs.release.outputs.verify == 'true' | |
| runs-on: ubuntu-latest | |
| # Bounded on purpose. The retry budget below is per PACKAGE and the walk | |
| # is sequential: 40-odd packages x 11 waits x 20s is over two hours with | |
| # npm down, and without this the job would hold a runner for the 6-hour | |
| # default before saying so. The cap has to clear the case this job exists | |
| # for, though — a PARTIAL publish, where the missing packages each burn | |
| # the full ~3.7-minute budget and the run must survive long enough to name | |
| # them — so it is set to allow roughly a dozen missing packages rather | |
| # than to the tightest value that fits a healthy run. | |
| timeout-minutes: 60 | |
| 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 | |
| # npm completing tells you NOTHING about crates.io — they are two | |
| # independent registries published by `release-all.mjs`. #3181: the v6.0.0 | |
| # release published all 34 npm packages while only 3 of 7 crates reached | |
| # crates.io (#3180), and there was no crates.io-side check at all to catch | |
| # it. That absence, not any scheduling problem, is why the half-publish | |
| # went unnoticed. | |
| verify-crates-publish: | |
| name: Verify crates.io publish | |
| needs: release | |
| if: always() && needs.release.outputs.verify == 'true' | |
| runs-on: ubuntu-latest | |
| # Same reasoning as the npm job: 7 crates x 11 waits x 20s is ~26 minutes | |
| # of pure waiting during a registry outage, plus an artifact download per | |
| # crate. Cap it rather than holding a runner for the 6-hour default. | |
| timeout-minutes: 35 | |
| steps: | |
| - name: Checkout Repo | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| lfs: false | |
| persist-credentials: false | |
| - name: Setup Node.js | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| # Deliberately the same 22 as the npm verify job above, not the 24 | |
| # the `release` job needs. 24 is there for npm ≥ 11.5.1's OIDC | |
| # trusted publishing; a verifier publishes nothing, so it has no | |
| # such requirement and the two verify lanes are better kept on one | |
| # runtime than silently drifting apart. | |
| node-version: 22 | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Verify all crates on crates.io | |
| # Crates.io propagation is normally fast, but give it the same | |
| # ~4-minute retry budget as the npm check for a slow moment. | |
| run: node scripts/verify-crates-publish.js --retries 12 --delay 20000 |