Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ci-mac-release-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': patch
---

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.
29 changes: 27 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,19 +136,44 @@ jobs:
env:
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
IN_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
IN_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
IN_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
IN_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
# A fixed heredoc marker lets a credential that happens to contain that
# line close its own value early and turn the rest into environment
# entries. Draw the delimiter at random so no secret can carry it.
delimiter="EOF_$(openssl rand -hex 16)"
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
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "$value" >> "$GITHUB_ENV"; fi
if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi
done
# The App Store Connect key is held as base64 because it is a file, not a
# string. notarytool and electron-builder both want a path, so materialize
# it outside the workspace to keep it out of the packaged app.
if [ -n "${IN_APPLE_API_KEY_P8:-}" ]; then
key_path="${RUNNER_TEMP}/AuthKey.p8"
printf '%s' "$IN_APPLE_API_KEY_P8" | base64 -d > "$key_path"
chmod 600 "$key_path"
echo "APPLE_API_KEY=${key_path}" >> "$GITHUB_ENV"
fi
if [ -z "${IN_CSC_LINK:-}" ]; then
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
echo 'No macOS signing certificate configured; building unsigned.'
fi

# A tag build that quietly produces an unsigned app is worse than a failed
# one: macOS rejects unsigned updates, so it ships a release users cannot
# install or update from. Manual runs stay free to build unsigned.
- name: Require signing for tagged releases
if: startsWith(github.ref, 'refs/tags/desktop-v')
working-directory: apps/desktop
run: node --import tsx scripts/assert-release-signing.ts
- name: Mint releases-repo token
id: releases_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/scripts/assert-release-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Fail a tagged desktop release that would ship an unsigned or un-notarized macOS build. */

import { spawnSync } from 'node:child_process'
import { assertMacReleaseReady } from './release-preflight'

function listCodeSigningIdentities(): string {
const result = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { encoding: 'utf8' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`security find-identity exited with ${String(result.status)}`)
return result.stdout
}

try {
const result = assertMacReleaseReady({
env: process.env,
platform: process.platform,
listCodeSigningIdentities,
})
console.log(
`macOS release signing is configured: ${result.identity}; signing via ${result.signing}; notarization via ${result.notarization}`,
)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
Loading