From c5beb6835438d1fdb4a1edd62bc8d13081821fb2 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 5 Aug 2026 00:14:08 +0200 Subject: [PATCH] fix(ci): notarize release candidates like stable releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four notarization steps carried `&& !contains(github.ref_name, '-')`, which skipped them for every pre-release. Two costs, and the second is the one that mattered. Testers paid the first. A DMG signed with Developer ID but not notarized is still refused by Gatekeeper — `spctl` answers `rejected, source= Unnotarized Developer ID` — so anyone testing an RC had to know about `xattr -rd com.apple.quarantine` before they could open the build they were being asked to try. The release paid the second. Notarization never ran until the stable tag, so the first exercise of the credentials, the certificate chain and Apple's acceptance of every nested Mach-O landed on the highest-stakes build there is. The run that first enabled signing died in `Package .app bundle` on a malformed `MAC_CSC_NAME`; it was caught only because a full build was dispatched deliberately. Notarizing each RC makes every candidate a rehearsal. The trade is a few minutes per macOS job and a dependency on Apple's notary service being reachable, with `--wait` capped at 15 minutes. If that turns flaky enough to block RCs, the answer is `continue-on-error` on pre-releases rather than skipping them again. Five documentation sites asserted the old behaviour and are corrected here, so nothing claims RCs are unnotarized after this lands. --- .github/workflows/build.yml | 30 ++++++++++++++++--- .harness/docs/git-workflow.md | 2 +- AGENTS.md | 2 +- .../engineering/build-and-packaging.md | 2 +- .../engineering/ci-workflows.md | 2 +- .../engineering/release-and-secrets.md | 2 +- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67842431f..8df311221 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -344,8 +344,30 @@ jobs: rm -rf "$STAGING" echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" + # The four steps below used to carry `&& !contains(github.ref_name, '-')`, + # which skipped them for every pre-release, `-rc.N` tags included. Two + # costs, and the second is the one that mattered. + # + # Testers paid the first: a DMG signed with Developer ID but not notarized + # is still refused by Gatekeeper — `spctl` answers `rejected, source= + # Unnotarized Developer ID` — so every RC tester had to know about + # `xattr -rd com.apple.quarantine` before they could open the thing they + # were being asked to test. + # + # The release paid the second. With the skip in place, notarization never + # ran until the stable tag, so the first exercise of the credentials, the + # certificate chain and Apple's acceptance of every nested Mach-O landed on + # the highest-stakes build there is. That is not theoretical: the run that + # first enabled signing here died in `Package .app bundle` on a malformed + # `MAC_CSC_NAME`, and it was only visible because a full build was run + # deliberately. Notarizing each RC turns every candidate into a rehearsal. + # + # The trade is a few minutes per macOS job and a dependency on Apple's + # notary service being reachable — `--wait` is capped at 15 minutes below. + # If that ever becomes flaky enough to block RCs, the fix is + # `continue-on-error` on pre-releases, not going back to skipping them. - name: Sign DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + if: steps.signing.outputs.enabled == 'true' run: | codesign --force \ --sign "${{ secrets.MAC_CSC_NAME }}" \ @@ -353,7 +375,7 @@ jobs: "${{ steps.dmg.outputs.dmg_path }}" - name: Notarize DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + if: steps.signing.outputs.enabled == 'true' run: | xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ --apple-id "${{ secrets.APPLE_ID }}" \ @@ -363,11 +385,11 @@ jobs: timeout-minutes: 15 - name: Staple notarization ticket - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + if: steps.signing.outputs.enabled == 'true' run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" - name: Validate stapled DMG - if: steps.signing.outputs.enabled == 'true' && !contains(github.ref_name, '-') + if: steps.signing.outputs.enabled == 'true' run: | xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index 68f248645..f6ca4fbe6 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -56,7 +56,7 @@ The workflow: 1. Computes the next SemVer from `package.json` + `bump`, builds `vX.Y.Z-rc.N`. 2. Migrates every issue/PR in the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone. Each migrated item gets a hidden marker comment so re-running is idempotent. 3. Commits `package.json` → `X.Y.Z-rc.N` on a fresh branch `release/vX.Y.Z-rc.N`. **The branch is NOT merged into `main`** — it stays frozen so the RC build only contains what was on `main` at the moment of cut. -4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). macOS notarization is skipped on RC tags. +4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). RC tags are signed and notarized like stable ones, so testers do not have to clear the quarantine attribute by hand. 5. Posts in `#rc-testing` on Discord with the download link. Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're already gated on `!prerelease`. diff --git a/AGENTS.md b/AGENTS.md index bfa79b498..5f270ef88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC) first, then promote to stable. Trunk-based, no extra branch. Full operational guide in `.harness/docs/git-workflow.md` § Release flow. -- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. Notarization is skipped on RCs. Notifies `#rc-testing` on Discord. +- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. RCs are notarized like stable releases, which also rehearses the credentials before the promotion build depends on them. Notifies `#rc-testing` on Discord. - **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord. - **Manual fallback**: `git tag vX.Y.Z-rc.N && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts. diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index c1e623ed5..11d8eae30 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -88,7 +88,7 @@ The default electron-builder target is NSIS, with an assisted installer that all > **The macOS job is currently disabled** (`if: false` in `build.yml`) because 1.8.0 ships Windows-only. That flag is release-branch-only and must not reach `main` when promoting, or every later release becomes Windows-only too. Until it is lifted, the macOS packaging path — including the compositor and ffmpeg steps described above — is exercised only by `npm run build:mac` locally. -Electron-builder targets DMG for both `arm64` and `x64`, enables hardened runtime, and applies `macos.entitlements` to the app and inherited code. The entitlements allow Electron JIT/native library loading and audio, camera, and screen capture. The configuration itself sets `notarize: false`; release CI packages the `.app`, creates and signs the DMG manually, submits stable tags to `notarytool`, staples the ticket, and validates Gatekeeper. Pre-release tags skip DMG signing/notarization, and missing Apple credentials produce an unsigned artifact. +Electron-builder targets DMG for both `arm64` and `x64`, enables hardened runtime, and applies `macos.entitlements` to the app and inherited code. The entitlements allow Electron JIT/native library loading and audio, camera, and screen capture. The configuration itself sets `notarize: false`; release CI packages the `.app`, creates and signs the DMG manually, submits it to `notarytool`, staples the ticket, and validates Gatekeeper. Pre-release tags go through the same path as stable ones — signing alone leaves Gatekeeper at `rejected, source=Unnotarized Developer ID`, so an RC that is signed but not notarized still forces testers to clear the quarantine attribute. Missing Apple credentials produce an ad-hoc-signed artifact. ### Linux and Nix diff --git a/technical-documentation/engineering/ci-workflows.md b/technical-documentation/engineering/ci-workflows.md index f46b73ea0..512588d74 100644 --- a/technical-documentation/engineering/ci-workflows.md +++ b/technical-documentation/engineering/ci-workflows.md @@ -100,7 +100,7 @@ A `v*` tag or manual dispatch starts platform builds. Dispatch accepts `arch` (` - `build-windows` runs `npm run build:win` and uploads `openscreen-windows` for 30 days. - `build-windows-store` runs `npm run build:win:store` and uploads `openscreen-windows-store` for 30 days. -- `build-macos` is an `arm64`/`x64` matrix. It builds Vite/Electron and native helpers, packages and optionally signs the app, creates DMGs, notarizes stable signed builds, and uploads one artifact per architecture for 30 days. +- `build-macos` is an `arm64`/`x64` matrix. It builds Vite/Electron and native helpers, packages and optionally signs the app, creates DMGs, notarizes every signed build including pre-releases, and uploads one artifact per architecture for 30 days. - `build-linux` produces AppImage, zsync, deb, and pacman files and uploads `openscreen-linux` for 30 days. - `publish-release` waits for Windows NSIS, macOS, and Linux jobs; the Store job is not a dependency. It checks the tag against `package.json`, downloads the NSIS/macOS/Linux artifacts, and creates or updates a GitHub release with `OPENSCREEN_RELEASE_TOKEN`. diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index eeaa94a6c..f42cde69c 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -82,7 +82,7 @@ The repository's main-branch ruleset must also permit the configured maintainer/ | `APPLE_TEAM_ID` | Apple Developer team identifier. | | `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password used by `notarytool`. | -The certificate account needs Developer ID signing capability, and the Apple account/app-specific password must be able to submit notarization requests for the team. Stable tags sign the DMG, notarize, staple, and validate it. Pre-release tags skip DMG signing/notarization. If any value is missing, the macOS job disables signing and still creates an unsigned DMG. +The certificate account needs Developer ID signing capability, and the Apple account/app-specific password must be able to submit notarization requests for the team. Every tag signs the DMG, notarizes, staples, and validates it, pre-releases included — that keeps RC testers out of `xattr -rd com.apple.quarantine`, and exercises the whole credential path on each candidate instead of first proving it on the promotion build. If any value is missing, the macOS job falls back to an ad-hoc signature and still creates a DMG. Rotate the certificate by exporting a replacement P12, base64-encoding it without line-wrap changes, updating the P12/password/name secrets together, testing a stable-format manual build, then revoking the old certificate if required. Rotate the app-specific password in Apple ID settings, replace `APPLE_APP_SPECIFIC_PASSWORD`, verify notarization, and revoke the old password. `APPLE_ID` and `APPLE_TEAM_ID` normally change only when the owning account or team changes.