Merge pull request #2265 from Cedric921/ci/release-dry-run-signing

ci(local): keep the macOS certificate off Windows and check Apple's credentials before every build
This commit is contained in:
Rohan Verma 2026-10-17 18:11:20 -07:00 • committed by GitHub
commit efb156d814
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 27 deletions

View file

@ -0,0 +1,48 @@
name: Apple Notarization Credentials
# A lapsed Apple Developer agreement or credential stops notarization, which
# otherwise shows only at the end of a release build. This asks Apple in
# seconds: weekly, on demand, and before every release build, dry runs too.
on:
schedule:
# Mondays, 06:00 UTC.
- cron: "0 6 * * 1"
workflow_dispatch:
workflow_call:
inputs:
publish:
# The release run's publish mode: "always" must notarize, so missing secrets fail.
type: string
default: never
permissions:
contents: read
jobs:
check:
runs-on: macos-15
steps:
- name: Ask Apple for the notarization history
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The caller's event when called from the release workflow.
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
PUBLISH: ${{ inputs.publish || 'never' }}
run: |
if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
# A tag or a publishing run must notarize; a fork's dry run has no secrets and builds unsigned.
if [ "$PUBLISH" = "always" ] || { [ "$EVENT" = "push" ] && [[ "$REF" == refs/tags/v* ]]; }; then
echo "::error::Apple notarization secrets are missing for a release that publishes."
exit 1
fi
echo "::notice::No Apple notarization secrets here; nothing to check."
exit 0
fi
xcrun notarytool history \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" > /dev/null

View file

@ -39,8 +39,15 @@ jobs:
sdcpp:
uses: ./.github/workflows/build-sdcpp.yml
# Before the release build: a lapsed Apple agreement fails here in seconds.
notarization:
uses: ./.github/workflows/notarization-credentials.yml
with:
publish: ${{ inputs.publish || 'always' }}
secrets: inherit
build:
needs: [audiocpp, sdcpp]
needs: [audiocpp, sdcpp, notarization]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@ -104,27 +111,16 @@ jobs:
- name: Detect release signing eligibility
id: sign
run: |
# Sign only on production tag pushes (v*), reusing the org certs.
# Contributor forks and dry runs lack the secrets and build unsigned.
# Windows signs through Azure only on production tag pushes (v*), so a
# dry run's Windows build is unsigned. macOS signs and notarizes
# whenever its secrets are present, dry runs in this repo included;
# contributor forks lack them and build unsigned.
if [ "${{ github.event_name }}" = "push" ] && [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "prod=true" >> "$GITHUB_OUTPUT"
else
echo "prod=false" >> "$GITHUB_OUTPUT"
fi
- name: Verify Apple notarization credentials
if: runner.os == 'macOS' && steps.sign.outputs.prod == 'true'
# Fails in seconds on bad secrets instead of after the full build.
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
xcrun notarytool history \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" > /dev/null
- name: Setup uv
uses: astral-sh/setup-uv@v8.1.0
with:
@ -272,11 +268,13 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROD: ${{ steps.sign.outputs.prod }}
CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# macOS only: on Windows electron-builder would sign every .exe with
# this .p12 through signtool, so a dry run never signed as a tag does.
CSC_LINK: ${{ runner.os == 'macOS' && secrets.MAC_CERT_P12_BASE64 || '' }}
CSC_KEY_PASSWORD: ${{ runner.os == 'macOS' && secrets.MAC_CERT_PASSWORD || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
# Windows Azure Trusted Signing — the service principal is only wired
# in on production tags (see the CLI flags below).
WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }}

View file

@ -147,7 +147,9 @@ The Linux runner is pinned because `ubuntu-latest` moves to 26.04 and would sile
Each runner, in order, checks the version (semver; on a tag push it must equal `surfsense_local/VERSION`; whenever it publishes it must carry no prerelease suffix), refuses to build with the test signing key ([license](license/app.md)), freezes the binaries, smokes the frozen worker's Docling vision imports (`--check-vision-runtime`) and the frozen API's `/health`, stages the model packs, builds the SPA and the Electron bundles, stages llama.cpp, opencode, audio.cpp and sd.cpp, generates the third-party notices, and runs `electron-builder`. On macOS and Windows, electron-builder's `afterSign` hook, `scripts/opencode/after-sign.mjs`, runs `check-stage.mjs` on the packaged opencode folder once the app is signed, and on macOS notarized, before any installer is made, so a tag's build never uploads one whose opencode signing broke; unsigned builds run it too. Linux signs nothing, so electron-builder skips the hook there with a warning. Every runner then runs `check-stage.mjs` again on the folder its build packaged: `win-unpacked`, `linux-unpacked` or `mac-arm64/SurfSense.app`. The three runners wait for the `audiocpp` and `sdcpp` jobs, and unpack audio.cpp's Windows and Linux builds and sd.cpp's Linux and macOS builds. On Linux it then starts the API from the packaged `linux-unpacked` resources and runs `llama-server --list-devices`, `audiocpp_server --list-devices` and `sd-server --help` from their packaged directories, because ggml finds its backends only next to the running executable, and checks that eSpeak-ng and its licence are packaged beside `audiocpp_server`, sd.cpp's Vulkan backend and licence beside `sd-server`, and the notices in `resources/licenses` without their fragments.
The macOS build is signed with the hardened runtime and notarized whenever the signing secrets are available, a dry run in this repository included. electron-builder signs every Mach-O file in the bundle, `Contents/Resources` included, with `entitlementsInherit`, except `opencode`: `mac.signIgnore` leaves it with its vendor's Developer ID signature, hardened runtime, secure timestamp and the five entitlements Bun asks for, among them `disable-executable-page-protection`, which the app's file lacks (**measured** on the pinned `opencode-darwin-arm64.zip`, 7 Oct 2026). The notary service takes it so: v2.2.0's tag build was notarized carrying it (9 Oct 2026). `rg` arrives only ad-hoc signed, so it is signed with the app. Two steps run only on tag pushes: the early check of the Apple notarization credentials, and Azure Trusted Signing for the Windows build, which signs every `.exe` under `resources/` as electron-builder copies it, replacing any signature it carries. `opencode.exe` arrives Authenticode-signed by its vendor and `rg.exe` unsigned, and a tag's build runs both signed with SurfSense's certificate. A dry run has no Azure credentials, but `CSC_LINK`, the macOS certificate, reaches the Windows runner too, so electron-builder signs every `.exe` in the app with it through signtool (the 2.1.0 dry run's log, 3 Oct 2026); opencode's and rg's would be signed the same way, though no dry run has staged them yet.
The macOS build is signed with the hardened runtime and notarized whenever the signing secrets are available, a dry run in this repository included. electron-builder signs every Mach-O file in the bundle, `Contents/Resources` included, with `entitlementsInherit`, except `opencode`: `mac.signIgnore` leaves it with its vendor's Developer ID signature, hardened runtime, secure timestamp and the five entitlements Bun asks for, among them `disable-executable-page-protection`, which the app's file lacks (**measured** on the pinned `opencode-darwin-arm64.zip`, 7 Oct 2026). The notary service takes it so: v2.2.0's tag build was notarized carrying it (9 Oct 2026). `rg` arrives only ad-hoc signed, so it is signed with the app. Azure Trusted Signing for the Windows build runs only on tag pushes, and signs every `.exe` under `resources/` as electron-builder copies it, replacing any signature it carries. `opencode.exe` arrives Authenticode-signed by its vendor and `rg.exe` unsigned, and a tag's build runs both signed with SurfSense's certificate. A dry run has no Azure credentials and `CSC_LINK`, the macOS certificate, reaches only the macOS runner, so a dry run's Windows build is unsigned; before that it reached Windows too and electron-builder signed every `.exe` with it through signtool (the 2.1.0 dry run's log, 3 Oct 2026).
The Apple notarization credentials are checked by their own workflow, [`notarization-credentials.yml`](../../.github/workflows/notarization-credentials.yml), with `notarytool history`: weekly on Mondays, on demand, and as a job the release build waits on, dry runs included; the audio and image sidecar builds run alongside it. A lapsed agreement or credential fails there in seconds, not at the end of a build, as the 2.1.0 dry run's did with Apple's 403 on 3 Oct 2026. Without the secrets it passes with a notice, except on a run that publishes, a tag's included, which must notarize.
The entitlements file also carries `com.apple.security.device.audio-input`, without which the hardened runtime keeps the microphone from the app and from its helpers, which inherit the file, and `mac.extendInfo` sets `NSMicrophoneUsageDescription`, the sentence macOS's microphone prompt shows: "SurfSense turns your speech into text on this computer." Electron's `systemPreferences.askForMediaAccess`, which the main process calls before the page opens the microphone, needs both: without the entitlement it can crash the app, and without the sentence the prompt shows Electron's own. [`scripts/mac-microphone.test.mjs`](../../surfsense_local/electron/scripts/mac-microphone.test.mjs) checks that both stay configured. Which page may use the microphone once macOS allows it is decided at runtime ([overview](overview.md#frontend)).
@ -182,7 +184,7 @@ Seven run in CI: `test_license_key.py` inside the release workflow, `test_frozen
- On macOS `nemotron_asr` and `canary_asr` are checked only for a loader: the `macos-archive` job stages upstream's archive and transcribes nothing, so a macOS server that cannot transcribe would still ship.
- No packaging test checks that the frozen API carries the agent's `SKILL.md`; `api.spec` collects it, and it was checked by hand.
- The packaged `llama-server`, `audiocpp_server` and `sd-server` are started only on the Linux runner ([#1986](https://github.com/MODSetter/SurfSense/issues/1986)).
- A dry run's Windows build is signed with the macOS certificate through signtool, not through Azure Trusted Signing, so only a tag's build exercises Windows signing as it ships, and a lapsed Azure client secret shows there first ([#2223](https://github.com/MODSetter/SurfSense/issues/2223)).
- Only a tag's build exercises Windows signing: a dry run's Windows build is unsigned, so a lapsed Azure client secret shows on the tag first.
- No notarized macOS build has shown the microphone prompt yet: the entitlement and the usage sentence are checked only as configuration, and the Privacy › Microphone address `openSettings()` opens is unchecked on macOS 13 to 15.
- The sidecars are signed with `entitlementsInherit`, so they carry the audio-input entitlement too: once macOS lets SurfSense use the microphone, a sidecar may open it without the page, the Studio worker running a model's analysis script among them, as any process may on Windows and Linux. electron-builder gives every file but the app's own executable that one list, so narrowing it needs a signing hook of the app's own.
- What matplotlib adds to the installer has not been measured against its size gate. A scratch freeze of the worker put it at about 15 MB unpacked under `_internal/matplotlib`, plus about 0.6 MB for contourpy and kiwisolver.

View file

@ -75,7 +75,7 @@ Facts below were checked on 3 Oct 2026 in this repo, in the local Windows build
- On a `v*` tag, electron-builder signs every `.exe` under `resources/` and no `.dll` or `.pyd` (I7, from the 2.1.0 dry-run log). The local worker holds 323 `.dll` and `.pyd` files, of which 89 carry a valid upstream signature, such as the PSF's on `python312.dll` (**measured**, `Get-AuthenticodeSignature`).
- The macOS app uses the hardened runtime. `entitlementsInherit: build/entitlements.mac.plist` gives every nested binary `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation` and `allow-dyld-environment-variables` ([`entitlements.mac.plist`](../../../surfsense_local/electron/build/entitlements.mac.plist), whose comment says the frozen sidecars and llama-server need the last two).
- The 2.1.0 macOS dry run failed at notarization with Apple's 403 "A required agreement is missing or has expired" (I7, run 37102724459). The early credential check runs only on tag pushes ([`release-local.yml`](../../../.github/workflows/release-local.yml) "Verify Apple notarization credentials"). *Since 4 Oct 2026:* the account holder accepted the agreement, and that dry run's re-run on 4 Oct and the v2.1.0 and v2.2.0 tag builds logged "notarization successful", v2.2.0's with opencode inside.
- The 2.1.0 macOS dry run failed at notarization with Apple's 403 "A required agreement is missing or has expired" (I7, run 37102724459). The early credential check runs only on tag pushes ([`release-local.yml`](../../../.github/workflows/release-local.yml) "Verify Apple notarization credentials"). *Since 4 Oct 2026:* the account holder accepted the agreement, and that dry run's re-run on 4 Oct and the v2.1.0 and v2.2.0 tag builds logged "notarization successful", v2.2.0's with opencode inside. *Since 16 Oct 2026:* that step is gone; [`notarization-credentials.yml`](../../../.github/workflows/notarization-credentials.yml) checks before every release build, dry runs included, and weekly.
### Licences
@ -472,7 +472,7 @@ The gate runs in `code-quality.yml` on pull requests that change a lockfile, a `
- **Windows, Office pack.** TDF's signatures survive the trim (measured), and SurfSense signs nothing in it. A file the backend's own HTTP client writes carries no Mark of the Web, so SmartScreen does not prompt (estimate, checked in RT3b on a clean VM).
- **macOS, installer.** PBS's interpreter is signed by the `afterPack` hook with its own entitlements (section 4); its `.so` files are signed with the bundle. v2.2.0 is notarized and carries opencode under its vendor's signature (`mac.signIgnore`), so the agent ships on macOS as on the other two. No build has carried PBS yet, and a notarized build carrying it gates RT4 on macOS.
- **macOS, Office pack.** TDF's `.app` is copied whole, so its signature and stapled ticket stay valid wherever it is extracted (verified in RT3b with `spctl`). A trimmed macOS pack needs SurfSense to re-sign and notarize it.
- **The Apple Developer agreement.** Notarization stops whenever it lapses, as it did on 3 Oct 2026 until the account holder accepted it, which needs no code. RT0 runs the existing `notarytool history` step on manual dry runs too, and weekly on a schedule, so a lapsed agreement fails before a release rather than after a full build.
- **The Apple Developer agreement.** Notarization stops whenever it lapses, as it did on 3 Oct 2026 until the account holder accepted it, which needs no code. Built: `notarytool history` runs in its own workflow, weekly, on demand and before every release build, dry runs included, so a lapsed agreement fails before a release rather than after a full build ([packaging](../../architecture/packaging.md)).
## Options considered and rejected

View file

@ -20,7 +20,7 @@ Publishing a release makes it Latest, and the legacy 0.0.x desktop reads `latest
From the repo root, on `dev`. A release is `dev` merged into `main` by pull request, with the tag on that merge. Replace `2.3.0` with this release and `v2.2.0` with the previous 2.x you shipped.
Before you start, check both signing credentials are current. Apple refuses notarization with a 403 until the account holder accepts a new developer agreement, as on 3 Oct 2026, and only a tag's build checks for that before building ([#2224](https://github.com/MODSetter/SurfSense/issues/2224)). Windows signing needs the Azure client secret behind `AZURE_CLIENT_SECRET`, whose expiry Entra ID shows; only a tag's build uses it.
Before you start, check both signing credentials are current. Apple refuses notarization with a 403 until the account holder accepts a new developer agreement, as on 3 Oct 2026, and every release run checks for that before building, as does a weekly run of [`notarization-credentials.yml`](../.github/workflows/notarization-credentials.yml) ([#2224](https://github.com/MODSetter/SurfSense/issues/2224)). Windows signing needs the Azure client secret behind `AZURE_CLIENT_SECRET`, whose expiry Entra ID shows; only a tag's build uses it.
### 1. Write the new number
@ -146,7 +146,7 @@ Actions → **Desktop Release** → version `0.0.0-test` (or any `X.Y.Z`) → pu
gh workflow run release-local.yml --ref dev -f version=0.0.0-test -f publish=never
```
Not a tag, not an update: the installers stay on the run as artifacts, and `legacy-update-bridge` does not run. It is not unsigned. macOS is signed and notarized, because the Apple secrets reach every run in this repository. Windows is signed through signtool with that same macOS certificate (`CSC_LINK` reaches every runner), not through Azure Trusted Signing, which only a tag uses. Linux signs nothing. A fork has none of these secrets, so its run signs nothing.
Not a tag, not an update: the installers stay on the run as artifacts, and `legacy-update-bridge` does not run. It is not unsigned. macOS is signed and notarized, because the Apple secrets reach every run in this repository. Windows is unsigned: the macOS certificate reaches only the macOS runner, and Azure Trusted Signing is used only by a tag. Linux signs nothing. A fork has none of these secrets, so its run signs nothing.
## Do not