Skip to content

Commit 46a9cd1

Browse files
authored
ci(desktop): sign macOS releases and refuse unsigned tagged builds (#123)
## Related Issue No issue — found while wiring up the first real macOS signing credentials. ## Problem Two gaps meant a remote release could never be properly signed: 1. **The API-key notarization path was unreachable in CI.** The job exported only the Apple ID trio (`APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID`). The App Store Connect key is a *file*, so it cannot be passed as a plain secret string and had no handling at all. 2. **A tagged release could ship unsigned and still go green.** With no certificate secret the job set `CSC_IDENTITY_AUTO_DISCOVERY=false`, printed `No macOS signing certificate configured; building unsigned.` and exited zero. macOS refuses updates from an unsigned app, so that publishes a release users cannot install or update from — while the pipeline reports success. ## What changed - Accept `APPLE_API_KEY_P8` as a base64 secret, decode it to `$RUNNER_TEMP/AuthKey.p8` with mode 600, and export `APPLE_API_KEY` as the path electron-builder and notarytool expect. Kept outside the workspace so it cannot be swept into the packaged app. - Also export `CSC_NAME`, which the signing preflight requires whenever `CSC_LINK` supplies the certificate. - Add a `Require signing for tagged releases` step that runs only on `desktop-v*` tags and calls the existing `assertMacReleaseReady` preflight through a small entry script. Manual `workflow_dispatch` runs stay free to build unsigned for packaging checks. The gate reuses the preflight rather than re-checking credentials, so CI and local `dist:mac` cannot drift apart. ## Verification The gate was exercised directly, all three paths: ``` no credentials -> exit 1: "macOS notarization credentials are required: set APPLE_KEYCHAIN_PROFILE, the Apple ID trio, or the App Store Connect API key trio" p12 + API key -> exit 0: "signing via p12; notarization via api-key" p12 without password -> exit 1: "CSC_KEY_PASSWORD is required when CSC_LINK supplies a macOS signing certificate" ``` - Workflow YAML parses; the gate lands after credential resolution and before packaging. - `cd apps/desktop && npx vitest run` — 97 passed - `pnpm --filter @pymodel/pythinker-desktop run typecheck` — clean - `pnpm run lint` — no `: error ` lines ## Known limitation The DMG produced remotely is signed (via `dmg.sign`) and its `.app` is notarized and stapled, but **the DMG itself is not stapled in CI**. Stapling mutates the file after electron-builder has hashed it for `latest-mac.yml`, and the current job builds and publishes in one `--publish always` invocation, so there is no point at which the DMG can be stapled without invalidating the already-uploaded metadata. Doing it properly requires splitting build from publish. Local `dist:mac` does staple, because it controls that ordering. Left as follow-up rather than half-done. ## Checklist - [x] I have read the CONTRIBUTING document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved MCP server form alignment and spacing for clearer configuration. * Long fields now span the full form width, with responsive layout support on smaller screens. * Provider manager dialogs now maintain clean rounded corners without content overflow. * **Release Improvements** * macOS releases now support signing and notarization through Apple API credentials. * Tagged macOS releases validate signing configuration and fail when required credentials are unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5f51b83 commit 46a9cd1

3 files changed

Lines changed: 57 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': patch
3+
---
4+
5+
Sign and notarize the macOS desktop build in the release pipeline, and fail a tagged release outright when the signing credentials are missing instead of quietly shipping an unsigned app.

.github/workflows/desktop-release.yml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,19 +136,44 @@ jobs:
136136
env:
137137
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
138138
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
139+
IN_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
139140
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
140141
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
141142
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
143+
IN_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
144+
IN_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
145+
IN_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
142146
run: |
143-
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
147+
# A fixed heredoc marker lets a credential that happens to contain that
148+
# line close its own value early and turn the rest into environment
149+
# entries. Draw the delimiter at random so no secret can carry it.
150+
delimiter="EOF_$(openssl rand -hex 16)"
151+
for name in CSC_LINK CSC_KEY_PASSWORD CSC_NAME APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER; do
144152
input="IN_${name}"
145153
value="${!input:-}"
146-
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
154+
if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi
147155
done
156+
# The App Store Connect key is held as base64 because it is a file, not a
157+
# string. notarytool and electron-builder both want a path, so materialize
158+
# it outside the workspace to keep it out of the packaged app.
159+
if [ -n "${IN_APPLE_API_KEY_P8:-}" ]; then
160+
key_path="${RUNNER_TEMP}/AuthKey.p8"
161+
printf '%s' "$IN_APPLE_API_KEY_P8" | base64 -d > "$key_path"
162+
chmod 600 "$key_path"
163+
echo "APPLE_API_KEY=${key_path}" >> "$GITHUB_ENV"
164+
fi
148165
if [ -z "${IN_CSC_LINK:-}" ]; then
149166
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
150167
echo 'No macOS signing certificate configured; building unsigned.'
151168
fi
169+
170+
# A tag build that quietly produces an unsigned app is worse than a failed
171+
# one: macOS rejects unsigned updates, so it ships a release users cannot
172+
# install or update from. Manual runs stay free to build unsigned.
173+
- name: Require signing for tagged releases
174+
if: startsWith(github.ref, 'refs/tags/desktop-v')
175+
working-directory: apps/desktop
176+
run: node --import tsx scripts/assert-release-signing.ts
152177
- name: Mint releases-repo token
153178
id: releases_token
154179
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/** Fail a tagged desktop release that would ship an unsigned or un-notarized macOS build. */
2+
3+
import { spawnSync } from 'node:child_process'
4+
import { assertMacReleaseReady } from './release-preflight'
5+
6+
function listCodeSigningIdentities(): string {
7+
const result = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' })
8+
if (result.error !== undefined) throw result.error
9+
if (result.status !== 0) throw new Error(`security find-identity exited with ${String(result.status)}`)
10+
return result.stdout
11+
}
12+
13+
try {
14+
const result = assertMacReleaseReady({
15+
env: process.env,
16+
platform: process.platform,
17+
listCodeSigningIdentities,
18+
})
19+
console.log(
20+
`macOS release signing is configured: ${result.identity}; signing via ${result.signing}; notarization via ${result.notarization}`,
21+
)
22+
} catch (error) {
23+
console.error(error instanceof Error ? error.message : String(error))
24+
process.exit(1)
25+
}

0 commit comments

Comments
 (0)