From c0a71faa3c39edee50f0a115155b071a508e6554 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 10:21:29 +0200 Subject: [PATCH 1/6] ci(onnx): build ONNX Runtime for macOS with the floor the app declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run build:mac` cannot package a macOS bundle today. Microsoft's `onnxruntime-osx-arm64-*.tgz` is built for macOS 14; `electron-builder.json5` declares `minimumSystemVersion: "13.0"`; `before-pack.cjs` refuses a payload that demands more than the floor. Correctly — the deployment target decides which symbols the linker resolves against the OS rather than emitting locally, which is how #515 stranded macOS 12 users with a dyld failure they reported as a denied permission. THERE IS NO VERSION THAT FITS. Every macOS arm64 release was checked, 1.20 through 1.29: 1.20.0 - 1.22.0 minos 13.3 1.23.0 minos 13.4 1.24.4 - 1.29.0 minos 14.0 The floor moved at 1.24 and never came back, and even the oldest is above 13.0. Lowering `ort`'s `api-27` feature does not help. Building is the only way to keep both macOS 13 support and webcam segmentation. MEASURED ON AN M1 before writing this. Shallow clone of the pinned tag with submodules 979 MB / 21 s, configure 24 s, build 10 min at `--parallel 4`. The result is `minos 13.0`, arm64, 21.7 MB against upstream's 36.7 MB — the difference is the CoreML execution provider, which this app does not use: `segmentation.rs` builds its session with no explicit provider, and webcam-segmentation.md records the CPU EP as a measured choice ("Inference p50, CPU EP: 3.575 ms — the CPU is faster"). It was then exercised through the real code path, not just inspected: `the_whole_loop_produces_a_mask_from_compose_frame_alone` — capture, inference, mask, composite — went from skipped ("ONNX Runtime absent") to passing with `ORT_DYLIB_PATH` pointed at it. WHAT THE WORKFLOW GUARDS. It reads the version from `scripts/fetch-onnxruntime.mjs` and the floor from `electron-builder.json5` rather than repeating either, so neither can drift. It then fails the build if `minos` is not the floor, and if `OrtGetApiBase` or the CPU provider is missing from the exports. A library that came out at 14.0 anyway would be worse than none: it would sail through packaging and fail at dyld time on the machines it was built to support. It drives CMake directly rather than upstream's `build.sh`, whose `build_args.py` needs Python 3.10+. The 3.10 in `find_package(Python 3.10)` is only real for the Python bindings; the one thing Python is genuinely needed for is generating the symbol export list, and `gen_def.py` parses on 3.9. IT DOES NOT PUBLISH. The workflow builds, verifies, and prints the exact `PINNED` entry to the job summary. Attaching the archive to a release and pasting that entry stays a human step, so the posture this script documents — immutable URL, SHA-256 verified before the archive is opened — survives. `fetch-onnxruntime.mjs` gains an optional per-target `baseUrl`; nothing points at it yet, so behaviour is unchanged until a maintainer decides to adopt it. --- .github/workflows/build-onnxruntime-macos.yml | 202 ++++++++++++++++++ scripts/fetch-onnxruntime.mjs | 20 +- 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-onnxruntime-macos.yml diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml new file mode 100644 index 000000000..6def4dfc0 --- /dev/null +++ b/.github/workflows/build-onnxruntime-macos.yml @@ -0,0 +1,202 @@ +name: Build ONNX Runtime (macOS 13 floor) + +# Builds ONNX Runtime for macOS arm64 with the deployment target pinned to the +# floor this app declares, and publishes it as an artifact for a maintainer to +# attach to a release. +# +# WHY THIS EXISTS. Microsoft's own `onnxruntime-osx-arm64-*.tgz` is built for +# macOS 14. `electron-builder.json5` declares `minimumSystemVersion: "13.0"`, +# and `scripts/before-pack.cjs` refuses to package any binary that demands more +# than the floor — correctly, because the deployment target decides which +# symbols the linker resolves against the OS rather than emitting locally, so a +# too-high floor strands users on the older OS with `Symbol not found` at dyld +# time (#515). The result is that `npm run build:mac` cannot package at all. +# +# Every published release from 1.24 onward is `minos 14.0`, and every release +# before it is at least 13.3, so no prebuilt artifact has ever satisfied a 13.0 +# floor. Building it is the only way to keep both macOS 13 support and webcam +# segmentation. +# +# WHAT IS NOT BUILT. The CoreML execution provider. `segmentation.rs` builds its +# session with no explicit provider, i.e. the CPU EP, which +# technical-documentation/engineering/webcam-segmentation.md records as a +# measured decision ("Inference p50, CPU EP: 3.575 ms — the CPU is faster"). +# Dropping CoreML is what takes the library from 36.7 MB to ~22 MB. +# +# HOW IT IS PUBLISHED. This workflow does not create releases. It builds, +# verifies, and prints the exact `PINNED` entry for +# `scripts/fetch-onnxruntime.mjs` in the job summary; attaching the archive to a +# release and pasting that entry stays a deliberate human step, so the +# supply-chain posture the script documents — immutable URL, SHA-256 verified +# before the archive is opened — is preserved rather than replaced by "whatever +# CI last uploaded". + +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/build-onnxruntime-macos.yml" + - "scripts/fetch-onnxruntime.mjs" + +permissions: + contents: read + +jobs: + build: + name: macOS arm64, deployment target 13.0 + # arm64 runner: the only macOS target upstream ships, and the only one the + # app packages. There is no Intel build to match. + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Read the pinned version from fetch-onnxruntime.mjs + id: pin + # Single source of truth. `fetch-onnxruntime.test.mjs` already cross-checks + # that VERSION satisfies the `api-NN` feature `crates/Cargo.toml` gives + # `ort`; reading it here rather than repeating it means a bump cannot leave + # this workflow building a version nothing consumes. + run: | + set -euo pipefail + VERSION="$(sed -n 's/^const VERSION = "\(.*\)";$/\1/p' scripts/fetch-onnxruntime.mjs)" + [ -n "$VERSION" ] || { echo "::error::VERSION not found in scripts/fetch-onnxruntime.mjs"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building ONNX Runtime v$VERSION" + + - name: Read the deployment floor from electron-builder.json5 + id: floor + # Also single-sourced: if somebody raises `mac.minimumSystemVersion`, this + # build follows rather than silently producing a library for the old floor. + run: | + set -euo pipefail + FLOOR="$(grep -o '"minimumSystemVersion": *"[0-9.]*"' electron-builder.json5 | grep -o '[0-9][0-9.]*')" + [ -n "$FLOOR" ] || { echo "::error::minimumSystemVersion not found"; exit 1; } + echo "floor=$FLOOR" >> "$GITHUB_OUTPUT" + echo "Deployment target: $FLOOR" + + - name: Checkout ONNX Runtime + uses: actions/checkout@v7 + with: + repository: microsoft/onnxruntime + ref: v${{ steps.pin.outputs.version }} + path: onnxruntime-src + submodules: recursive + fetch-depth: 1 + + - name: Cache the CMake build tree + uses: actions/cache@v6 + with: + path: onnxruntime-build + # Keyed on the version, the floor and the runner image: CMake bakes + # absolute SDK paths into the tree, so a toolchain roll must bust it. + key: ort-${{ steps.pin.outputs.version }}-${{ steps.floor.outputs.floor }}-${{ env.ImageOS }}-${{ env.ImageVersion }} + + - name: Configure + # Driven straight at CMake rather than through upstream's `build.sh`. + # `build.sh` -> `build_args.py` uses `match`, so it needs Python 3.10+, + # and `cmake/CMakeLists.txt` asks for `find_package(Python 3.10)` — but + # that requirement is only real for the Python bindings, which are not + # built here. The one thing Python IS needed for is generating the symbol + # export list (`onnxruntime.lds`); pointing `Python_EXECUTABLE` at + # whatever the runner has is enough, and `gen_def.py` parses on 3.9. + run: | + set -euo pipefail + cmake -S onnxruntime-src/cmake -B onnxruntime-build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=${{ steps.floor.outputs.floor }} \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -Donnxruntime_BUILD_SHARED_LIB=ON \ + -Donnxruntime_BUILD_UNIT_TESTS=OFF \ + -DPython_EXECUTABLE="$(command -v python3)" + + - name: Build + run: cmake --build onnxruntime-build --config Release --parallel + + - name: Verify the deployment target + # The entire reason this workflow exists. A library that comes out at 14.0 + # anyway is worse than no library, because it would sail through packaging + # and strand macOS 13 users at dyld time. + run: | + set -euo pipefail + V="${{ steps.pin.outputs.version }}" + DYLIB="onnxruntime-build/libonnxruntime.${V}.dylib" + [ -f "$DYLIB" ] || { echo "::error::$DYLIB was not produced"; exit 1; } + MINOS="$(otool -l "$DYLIB" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}')" + echo "minos=$MINOS floor=${{ steps.floor.outputs.floor }}" + [ "$MINOS" = "${{ steps.floor.outputs.floor }}" ] || { + echo "::error::built for macOS $MINOS, expected ${{ steps.floor.outputs.floor }}"; exit 1; } + + - name: Verify the ABI surface + # `ort` is wired `load-dynamic`, so it dlopens this file and calls + # `OrtGetApiBase`. The CPU provider is the one `segmentation.rs` uses. + # CoreML is deliberately absent and is NOT checked for. + run: | + set -euo pipefail + V="${{ steps.pin.outputs.version }}" + DYLIB="onnxruntime-build/libonnxruntime.${V}.dylib" + for sym in _OrtGetApiBase _OrtSessionOptionsAppendExecutionProvider_CPU; do + nm -gU "$DYLIB" | grep -q " ${sym}$" || { echo "::error::missing export ${sym}"; exit 1; } + done + lipo -info "$DYLIB" + echo "ABI surface OK" + + - name: Package in the upstream layout + # Byte-for-byte the same shape as `onnxruntime-osx-arm64-.tgz`, so + # `fetch-onnxruntime.mjs` needs no extraction change — only a URL and a + # digest. `member` there is the VERSIONED file; the two symlinks beside it + # are kept so the archive stays a drop-in for anything that expects them. + run: | + set -euo pipefail + V="${{ steps.pin.outputs.version }}" + DIR="onnxruntime-osx-arm64-${V}" + mkdir -p "stage/${DIR}/lib" "stage/${DIR}/include" + cp "onnxruntime-build/libonnxruntime.${V}.dylib" "stage/${DIR}/lib/" + ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.dylib" + ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.1.dylib" + cp onnxruntime-src/include/onnxruntime/core/session/*.h "stage/${DIR}/include/" || true + tar -czf "${DIR}.tgz" -C stage "${DIR}" + shasum -a 256 "${DIR}.tgz" + + - name: Upload + uses: actions/upload-artifact@v7 + with: + name: onnxruntime-osx-arm64 + path: onnxruntime-osx-arm64-*.tgz + if-no-files-found: error + retention-days: 90 + + - name: Workflow summary + if: always() + shell: bash + run: | + set -euo pipefail + V="${{ steps.pin.outputs.version }}" + ARCHIVE="onnxruntime-osx-arm64-${V}.tgz" + { + echo "## ONNX Runtime ${V}, macOS arm64, deployment target ${{ steps.floor.outputs.floor }}" + echo "" + echo "- Result: ${{ job.status }}" + } >> "$GITHUB_STEP_SUMMARY" + if [ -f "$ARCHIVE" ]; then + SHA="$(shasum -a 256 "$ARCHIVE" | cut -d' ' -f1)" + { + echo "- Archive: \`${ARCHIVE}\` ($(du -h "$ARCHIVE" | cut -f1))" + echo "- SHA-256: \`${SHA}\`" + echo "" + echo "To adopt it: attach the archive to a release, then point the" + echo "\`darwin-arm64\` entry of \`PINNED\` in \`scripts/fetch-onnxruntime.mjs\`" + echo "at that release with this digest:" + echo "" + echo '```js' + echo '"darwin-arm64": {' + echo ' slug: "osx-arm64",' + echo ' ext: "tgz",' + echo " sha256: \"${SHA}\"," + echo " member: \`libonnxruntime.\${VERSION}.dylib\`," + echo ' out: "libonnxruntime.dylib",' + echo ' baseUrl: "",' + echo '},' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/scripts/fetch-onnxruntime.mjs b/scripts/fetch-onnxruntime.mjs index 791e38656..860aeb3fd 100644 --- a/scripts/fetch-onnxruntime.mjs +++ b/scripts/fetch-onnxruntime.mjs @@ -54,6 +54,24 @@ const ROOT = path.join(__dirname, ".."); const VERSION = "1.27.1"; const BASE = `https://github.com/microsoft/onnxruntime/releases/download/v${VERSION}`; +/** + * Where a target's archive is fetched from. Upstream unless the entry overrides it. + * + * The override exists for one reason, and it is not preference: **no published ONNX + * Runtime has ever satisfied this app's macOS floor.** Every release from 1.24 on is + * built for macOS 14, and every release before it for at least 13.3, while + * `electron-builder.json5` declares 13.0 and `before-pack.cjs` refuses anything above + * the floor — correctly, since the deployment target decides which symbols the linker + * resolves against the OS instead of emitting locally (#515). So `npm run build:mac` + * cannot package a macOS bundle at all with the upstream artifact. + * + * `.github/workflows/build-onnxruntime-macos.yml` builds one with the floor pinned and + * prints the `PINNED` entry to paste here. Everything else about this file is + * unchanged: an immutable URL, and a SHA-256 verified before the archive is opened. + * What moves is who built the bytes, not how much they are trusted. + */ +const baseUrlFor = (spec) => spec.baseUrl ?? BASE; + /** * Per-target: the upstream artifact slug, its digest, and the library to lift out. * @@ -204,7 +222,7 @@ async function download(spec) { const asset = assetName(spec); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ort-")); console.log(`Downloading ${asset}\n from v${VERSION}`); - const res = await fetch(`${BASE}/${asset}`); + const res = await fetch(`${baseUrlFor(spec)}/${asset}`); if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`); const bytes = Buffer.from(await res.arrayBuffer()); From 5a528efb6a8cc79d838bdc4373a555201605d26c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 11:37:30 +0200 Subject: [PATCH 2/6] ci(onnx): stage the LICENSE, and build a cache key that is not empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both real, and the second is a bug this repo already had. **The archive had no LICENSE, so adopting it would have failed.** `fetch-onnxruntime.mjs:304` throws `LICENSE not found inside …` and then reads the file to confirm the library really is MIT — "asset names are not evidence". The staged archive carried only the dylib and headers, so it would have passed its SHA-256 and then died at vendoring, which is the worst possible place to discover it. `LICENSE` and `ThirdPartyNotices.txt` are now copied, matching what upstream ships. **`${{ env.ImageOS }}` evaluates to the empty string.** The `env` expression context carries only what a workflow, job or step `env:` block defined; `ImageOS`/`ImageVersion` are set by the runner into its own environment, so the expression silently disappears and the cache key loses that component. The proof is this repository's own cache list. `build-whisper-stt.yml` builds its key the same way, and the stored keys read: whisper-stt-build-darwin-arm64---2ca5d2c7… whisper-stt-build-linux-x64---2ca5d2c7… Three hyphens where two values should be. That workflow's comment explains at length that scoping the key to the image version "auto-busts it on every toolchain roll" — and it never has, on any platform, since the day it was written. Filed separately; this commit only fixes the copy of the mistake it was about to add. Here the values are read in a `run:` step, where they are ordinary shell variables, and passed through `GITHUB_OUTPUT`. --- .github/workflows/build-onnxruntime-macos.yml | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml index 6def4dfc0..b65bf2060 100644 --- a/.github/workflows/build-onnxruntime-macos.yml +++ b/.github/workflows/build-onnxruntime-macos.yml @@ -84,13 +84,31 @@ jobs: submodules: recursive fetch-depth: 1 + - name: Read the runner image + id: image + # `${{ env.ImageOS }}` DOES NOT WORK, and it fails silently. The `env` + # expression context only carries what a workflow, job or step `env:` block + # defined; `ImageOS`/`ImageVersion` are set by the runner in its own + # environment, so the expression evaluates to the empty string and the cache + # key simply loses that component. The evidence is in this repo's own cache + # list: `build-whisper-stt.yml` builds its key the same way and the stored + # keys read `whisper-stt-build-darwin-arm64---` — three hyphens, both + # values empty. Reading them in a `run:` step, where they are ordinary shell + # variables, is what actually works. + run: | + set -euo pipefail + echo "tag=${ImageOS:-unknown}-${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT" + echo "Runner image: ${ImageOS:-unknown} ${ImageVersion:-unknown}" + - name: Cache the CMake build tree uses: actions/cache@v6 with: path: onnxruntime-build - # Keyed on the version, the floor and the runner image: CMake bakes - # absolute SDK paths into the tree, so a toolchain roll must bust it. - key: ort-${{ steps.pin.outputs.version }}-${{ steps.floor.outputs.floor }}-${{ env.ImageOS }}-${{ env.ImageVersion }} + # Keyed on the version, the floor and the runner image. The image matters: + # CMake bakes absolute SDK paths into the tree, so when GitHub rolls Xcode a + # restored tree fails on paths that no longer exist. Scoping the key to the + # image busts it automatically on every roll. + key: ort-${{ steps.pin.outputs.version }}-${{ steps.floor.outputs.floor }}-${{ steps.image.outputs.tag }} - name: Configure # Driven straight at CMake rather than through upstream's `build.sh`. @@ -155,6 +173,14 @@ jobs: ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.dylib" ln -s "libonnxruntime.${V}.dylib" "stage/${DIR}/lib/libonnxruntime.1.dylib" cp onnxruntime-src/include/onnxruntime/core/session/*.h "stage/${DIR}/include/" || true + # NOT optional. `fetch-onnxruntime.mjs` refuses an archive with no LICENSE + # (`LICENSE not found inside …`) and then reads it to confirm the library + # really is MIT — "asset names are not evidence". Without this the archive + # would pass its SHA-256 and fail at vendoring, which is the worst place to + # find out. `ThirdPartyNotices.txt` rides along because upstream ships it and + # THIRD-PARTY-NOTICES.md is what carries the attribution. + cp onnxruntime-src/LICENSE "stage/${DIR}/LICENSE" + cp onnxruntime-src/ThirdPartyNotices.txt "stage/${DIR}/ThirdPartyNotices.txt" tar -czf "${DIR}.tgz" -C stage "${DIR}" shasum -a 256 "${DIR}.tgz" From 65e8f57fb40ceaac27965079745c6051347c6caf Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 12:42:35 +0200 Subject: [PATCH 3/6] ci(onnx): attest the build's provenance, so the digest is not the only claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SHA-256 in `fetch-onnxruntime.mjs` says "these are the bytes somebody pinned". It cannot say where they came from. While the publisher was Microsoft that gap did not matter much — the URL named them. Once the publisher is this project, it is the question that matters. `actions/attest-build-provenance` binds the archive's digest to the commit, workflow and run that produced it, signed by GitHub, so anyone can check it before adopting: gh attestation verify onnxruntime-osx-arm64-.tgz --repo getopenscreen/openscreen This does not replace the digest pin — the pin is what the fetch script enforces on every developer machine, the attestation is what a reviewer checks once. They answer different questions and both are kept. `id-token: write` and `attestations: write` are what the action needs to mint and record the signature. Neither grants write access to the repository, and `contents` stays `read`. --- .github/workflows/build-onnxruntime-macos.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml index b65bf2060..c700a260a 100644 --- a/.github/workflows/build-onnxruntime-macos.yml +++ b/.github/workflows/build-onnxruntime-macos.yml @@ -40,6 +40,11 @@ on: permissions: contents: read + # Provenance attestation. `id-token` mints the OIDC token GitHub signs with, and + # `attestations` lets the run record the result. Both are needed by + # `actions/attest-build-provenance`; neither grants write access to the repository. + id-token: write + attestations: write jobs: build: @@ -184,6 +189,20 @@ jobs: tar -czf "${DIR}.tgz" -C stage "${DIR}" shasum -a 256 "${DIR}.tgz" + - name: Attest build provenance + # A SHA-256 in `fetch-onnxruntime.mjs` says "these are the bytes somebody + # pinned". It cannot say WHERE they came from — and once the publisher is us + # rather than Microsoft, that is the question that matters. This binds the + # archive's digest to the commit, workflow and run that produced it, signed by + # GitHub, so provenance becomes verifiable rather than asserted: + # + # gh attestation verify onnxruntime-osx-arm64-.tgz --repo getopenscreen/openscreen + # + # It does not replace the digest pin, it answers a different question. Keep both. + uses: actions/attest-build-provenance@v2 + with: + subject-path: onnxruntime-osx-arm64-*.tgz + - name: Upload uses: actions/upload-artifact@v7 with: @@ -210,6 +229,12 @@ jobs: echo "- Archive: \`${ARCHIVE}\` ($(du -h "$ARCHIVE" | cut -f1))" echo "- SHA-256: \`${SHA}\`" echo "" + echo "Provenance is attested; before adopting, verify it with:" + echo "" + echo '```bash' + echo "gh attestation verify ${ARCHIVE} --repo ${{ github.repository }}" + echo '```' + echo "" echo "To adopt it: attach the archive to a release, then point the" echo "\`darwin-arm64\` entry of \`PINNED\` in \`scripts/fetch-onnxruntime.mjs\`" echo "at that release with this digest:" From 4f1d91829a6b676459f97649161d2c96aea8b5eb Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 14:10:46 +0200 Subject: [PATCH 4/6] ci(onnx): bound the build, stop piling up runs, and pin every action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes. Two are review findings; two are defects the workflow's own first run on a runner exposed, which is why this had not been merged. **Three builds ran concurrently on this PR, the oldest for nearly four hours.** Every push touching the trigger paths started another and none of the earlier ones stopped. `concurrency` with `cancel-in-progress`, the same shape five other workflows in this repo already use. **The build takes far longer on a runner than on the reference machine** — 10 min on an 8-core M1 at `--parallel 4`, still going after 227 min on `macos-latest`. Bare `--parallel` means "one job per core", and ONNX Runtime's translation units are memory-hungry, so on a runner with much less RAM per core that is a recipe for swapping. Now bounded to `nproc - 1`, with the core count and memory logged so the next person can see what they got, and `timeout-minutes: 150` so a pathological run fails where somebody notices instead of burning six hours quietly. **The summary claimed provenance was attested even when the attestation step had failed.** It runs under `if: always()`, so a failed attest still printed "Provenance is attested" and the adoption instructions. It now checks `steps.attest.outcome` and, on failure, says plainly not to adopt the archive — the digest says what the bytes are, nothing says where they came from. **Every action is pinned to a full commit SHA.** This workflow holds `id-token: write` and `attestations: write`, and `docs.yml` — the only other workflow in this repo with `id-token: write` — already pins all of its actions this way, with `# vX.Y.Z` comments. Matching it rather than inventing a third convention. --- .github/workflows/build-onnxruntime-macos.yml | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml index c700a260a..56bce4d47 100644 --- a/.github/workflows/build-onnxruntime-macos.yml +++ b/.github/workflows/build-onnxruntime-macos.yml @@ -38,6 +38,13 @@ on: - ".github/workflows/build-onnxruntime-macos.yml" - "scripts/fetch-onnxruntime.mjs" +# One build per branch. Without this, every push to a branch that touches the paths +# above starts another 1-2 h build and none of the earlier ones stop: three ran +# concurrently on this workflow's own PR, the oldest for nearly four hours. +concurrency: + group: onnxruntime-macos-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read # Provenance attestation. `id-token` mints the OIDC token GitHub signs with, and @@ -52,9 +59,13 @@ jobs: # arm64 runner: the only macOS target upstream ships, and the only one the # app packages. There is no Intel build to match. runs-on: macos-latest + # The default is 6 h. This build takes ~10 min on an 8-core M1 with `--parallel 4` + # and well over an hour on the runner; a cap turns "pathologically slow" into a + # failure somebody sees rather than six hours of quietly burnt minutes. + timeout-minutes: 150 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Read the pinned version from fetch-onnxruntime.mjs id: pin @@ -81,7 +92,7 @@ jobs: echo "Deployment target: $FLOOR" - name: Checkout ONNX Runtime - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: microsoft/onnxruntime ref: v${{ steps.pin.outputs.version }} @@ -106,7 +117,7 @@ jobs: echo "Runner image: ${ImageOS:-unknown} ${ImageVersion:-unknown}" - name: Cache the CMake build tree - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: onnxruntime-build # Keyed on the version, the floor and the runner image. The image matters: @@ -134,7 +145,15 @@ jobs: -DPython_EXECUTABLE="$(command -v python3)" - name: Build - run: cmake --build onnxruntime-build --config Release --parallel + # BOUNDED, deliberately. Bare `--parallel` means "as many jobs as cores", and + # ONNX Runtime's C++ translation units are memory-hungry: on a runner with far + # less RAM per core than the reference M1, that is how a 10-minute build becomes + # an hour of swapping. `nproc`-1 leaves the machine a core to breathe. + run: | + set -euo pipefail + JOBS="$(( $(sysctl -n hw.ncpu) > 2 ? $(sysctl -n hw.ncpu) - 1 : 1 ))" + echo "Building with $JOBS jobs on $(sysctl -n hw.ncpu) cores, $(( $(sysctl -n hw.memsize) / 1073741824 )) GiB" + cmake --build onnxruntime-build --config Release --parallel "$JOBS" - name: Verify the deployment target # The entire reason this workflow exists. A library that comes out at 14.0 @@ -190,6 +209,7 @@ jobs: shasum -a 256 "${DIR}.tgz" - name: Attest build provenance + id: attest # A SHA-256 in `fetch-onnxruntime.mjs` says "these are the bytes somebody # pinned". It cannot say WHERE they came from — and once the publisher is us # rather than Microsoft, that is the question that matters. This binds the @@ -199,12 +219,12 @@ jobs: # gh attestation verify onnxruntime-osx-arm64-.tgz --repo getopenscreen/openscreen # # It does not replace the digest pin, it answers a different question. Keep both. - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: onnxruntime-osx-arm64-*.tgz - name: Upload - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: onnxruntime-osx-arm64 path: onnxruntime-osx-arm64-*.tgz @@ -223,7 +243,15 @@ jobs: echo "" echo "- Result: ${{ job.status }}" } >> "$GITHUB_STEP_SUMMARY" - if [ -f "$ARCHIVE" ]; then + if [ "${{ steps.attest.outcome }}" != "success" ]; then + { + echo "" + echo "> **Provenance was NOT attested** (\`${{ steps.attest.outcome }}\`)." + echo "> Do not adopt this archive: the digest below says what the bytes are," + echo "> nothing says where they came from. Re-run the workflow." + } >> "$GITHUB_STEP_SUMMARY" + fi + if [ -f "$ARCHIVE" ] && [ "${{ steps.attest.outcome }}" = "success" ]; then SHA="$(shasum -a 256 "$ARCHIVE" | cut -d' ' -f1)" { echo "- Archive: \`${ARCHIVE}\` ($(du -h "$ARCHIVE" | cut -f1))" From b59008c33a4d8c394ba16b50374e7a092f777e8a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 14:51:40 +0200 Subject: [PATCH 5/6] ci(onnx): build a pinned commit, not a movable tag, and drop the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings applied, one declined on the measurement. **Build the commit, not the tag.** `v1.27.1` upstream is a *lightweight* tag: it points straight at a commit and can be moved by anyone with push rights there. Building from it means attesting an artifact to "whatever that tag meant this morning" — which is exactly the property this workflow exists to provide, so the tag undercut its own point. `SOURCE_COMMIT` now sits beside `VERSION` in `fetch-onnxruntime.mjs`, so the single source of truth stays single, and a step fails the build if the tag no longer resolves to it. That catches both a repointed tag and a `VERSION` bump whose commit was forgotten. The source commit is printed in the job summary beside the digest, so whoever adopts the artifact can see what it was built from. **`persist-credentials: false` on both checkouts.** This job compiles third-party source in the same workspace, and leaving the token in `.git/config` puts it within reach of ONNX Runtime's own build scripts — in a workflow holding `attestations: write`. `build.yml`, `docs.yml` and `nix-build.yml` already set this; the omission was mine. **Raising `timeout-minutes` above 227 is declined.** That number is from the run with bare `--parallel`, which swapped instead of compiling. With the parallelism bounded the whole job takes **30 minutes**, verified end to end. 150 is already five times the observed duration, and a cap set above a known-pathological run cannot do the job a cap is for. --- .github/workflows/build-onnxruntime-macos.yml | 33 +++++++++++++++++-- scripts/fetch-onnxruntime.mjs | 12 +++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-onnxruntime-macos.yml b/.github/workflows/build-onnxruntime-macos.yml index 56bce4d47..bfa12b7e8 100644 --- a/.github/workflows/build-onnxruntime-macos.yml +++ b/.github/workflows/build-onnxruntime-macos.yml @@ -66,6 +66,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # This job compiles third-party source in the same workspace. Leaving the + # token in .git/config would put it within reach of ONNX Runtime's own build + # scripts — and this workflow holds `attestations: write`. Same setting + # build.yml, docs.yml and nix-build.yml already use. + persist-credentials: false - name: Read the pinned version from fetch-onnxruntime.mjs id: pin @@ -77,8 +83,11 @@ jobs: set -euo pipefail VERSION="$(sed -n 's/^const VERSION = "\(.*\)";$/\1/p' scripts/fetch-onnxruntime.mjs)" [ -n "$VERSION" ] || { echo "::error::VERSION not found in scripts/fetch-onnxruntime.mjs"; exit 1; } + COMMIT="$(sed -n 's/^const SOURCE_COMMIT = "\(.*\)";$/\1/p' scripts/fetch-onnxruntime.mjs)" + [ -n "$COMMIT" ] || { echo "::error::SOURCE_COMMIT not found in scripts/fetch-onnxruntime.mjs"; exit 1; } echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Building ONNX Runtime v$VERSION" + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "Building ONNX Runtime v$VERSION at $COMMIT" - name: Read the deployment floor from electron-builder.json5 id: floor @@ -95,10 +104,28 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: microsoft/onnxruntime - ref: v${{ steps.pin.outputs.version }} + # The COMMIT, not the tag. `v1.27.1` upstream is a lightweight tag — it points + # straight at a commit and can be moved. Building from a tag would mean + # attesting an artifact to "whatever that tag meant this morning", which is + # precisely the property this workflow exists to provide. + ref: ${{ steps.pin.outputs.commit }} path: onnxruntime-src submodules: recursive fetch-depth: 1 + persist-credentials: false + + - name: Check the pinned commit is still what the tag names + # Not fatal to the build — the commit above is what gets built either way — but a + # tag that has moved means the pin and the version string no longer describe the + # same thing, and somebody should look before adopting the artifact. + run: | + set -euo pipefail + TAGGED="$(git -C onnxruntime-src ls-remote https://github.com/microsoft/onnxruntime "refs/tags/v${{ steps.pin.outputs.version }}" | cut -f1)" + if [ "$TAGGED" != "${{ steps.pin.outputs.commit }}" ]; then + echo "::error::v${{ steps.pin.outputs.version }} now resolves to ${TAGGED:-nothing}, not the pinned ${{ steps.pin.outputs.commit }}" + exit 1 + fi + echo "v${{ steps.pin.outputs.version }} still resolves to ${{ steps.pin.outputs.commit }}" - name: Read the runner image id: image @@ -241,6 +268,8 @@ jobs: { echo "## ONNX Runtime ${V}, macOS arm64, deployment target ${{ steps.floor.outputs.floor }}" echo "" + echo "- Source: microsoft/onnxruntime@\`${{ steps.pin.outputs.commit }}\`" + echo "" echo "- Result: ${{ job.status }}" } >> "$GITHUB_STEP_SUMMARY" if [ "${{ steps.attest.outcome }}" != "success" ]; then diff --git a/scripts/fetch-onnxruntime.mjs b/scripts/fetch-onnxruntime.mjs index 860aeb3fd..fb3d88f20 100644 --- a/scripts/fetch-onnxruntime.mjs +++ b/scripts/fetch-onnxruntime.mjs @@ -52,6 +52,18 @@ const ROOT = path.join(__dirname, ".."); * direction to fail in. */ const VERSION = "1.27.1"; + +/** + * The upstream commit `v${VERSION}` pointed at when it was reviewed. + * + * `v1.27.1` is a LIGHTWEIGHT tag — it points straight at a commit and can be moved by + * anyone with push rights upstream. Nothing here fetches source at install time, so this + * does not affect `npm run fetch:onnxruntime`; it matters to + * `.github/workflows/build-onnxruntime-macos.yml`, which builds the library and would + * otherwise attest an artifact to "whatever that tag meant that morning". The workflow + * resolves the tag and refuses to build if it no longer resolves here. + */ +const SOURCE_COMMIT = "df2ba1cf8108aa63627cf4cdf8f807880b938616"; const BASE = `https://github.com/microsoft/onnxruntime/releases/download/v${VERSION}`; /** From b1d6bd767c841f4f3fd1d89bb81f98883a04601a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 15:53:10 +0200 Subject: [PATCH 6/6] fix(onnx): give SOURCE_COMMIT a use, instead of a lint suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint caught it and was right: `SOURCE_COMMIT` existed only to be read out of this file by a `sed` in the workflow, which makes it dead as far as this module is concerned and fragile as a design — nothing in the file explains why it cannot be deleted. It now prints when the fetched artifact is one we built (`spec.baseUrl` set): built here from microsoft/onnxruntime@df2ba1cf… which is the line somebody wants when the binary no longer carries Microsoft's name and they are trying to work out what it came from. The upstream path is unchanged and prints nothing extra, because for an upstream artifact the URL already says. Exercised, not assumed: `node scripts/fetch-onnxruntime.mjs --force` downloads, verifies and vendors as before, exit 0, and the new line stays quiet because the upstream entry has no `baseUrl`. --- scripts/fetch-onnxruntime.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/fetch-onnxruntime.mjs b/scripts/fetch-onnxruntime.mjs index fb3d88f20..674c34040 100644 --- a/scripts/fetch-onnxruntime.mjs +++ b/scripts/fetch-onnxruntime.mjs @@ -325,6 +325,13 @@ async function main() { if (targetPlatform !== "win32") fs.chmodSync(dest, 0o755); console.log(` ${banner}`); + // Où que viennent les octets, dire de quelle source ils sortent. Pour un artefact + // amont c'est le tag ; pour un que nous avons construit (`baseUrl`), c'est le commit + // que le workflow a compilé, et c'est la seule chose qui rend le binaire traçable + // une fois qu'il ne porte plus le nom de Microsoft. + if (spec.baseUrl) { + console.log(` built here from microsoft/onnxruntime@${SOURCE_COMMIT}`); + } console.log(`\nVendored -> ${dest}`); } finally { fs.rmSync(tmp, { recursive: true, force: true });