-
-
Notifications
You must be signed in to change notification settings - Fork 92
404 lines (376 loc) · 20.6 KB
/
Copy pathrelease.yml
File metadata and controls
404 lines (376 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
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