build(desktop): enable macOS signing & notarization for release

This commit is contained in:
zhayujie
2026-07-03 12:01:42 +08:00
parent b44154fe02
commit 01ec49afd2
5 changed files with 124 additions and 0 deletions

View File

@@ -108,6 +108,8 @@ jobs:
# is the correct state for unsigned builds. # is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
# Signing identity name for the backend-signing afterPack hook.
MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -123,6 +125,9 @@ jobs:
if [ -n "$MAC_CSC_LINK" ]; then if [ -n "$MAC_CSC_LINK" ]; then
export CSC_LINK="$MAC_CSC_LINK" export CSC_LINK="$MAC_CSC_LINK"
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
# Identity name used by the afterPack hook to sign the embedded
# PyInstaller backend (extraResources aren't signed by default).
export CSC_NAME="$MAC_CSC_NAME"
fi fi
if [ -z "$WIN_CSC_LINK" ]; then if [ -z "$WIN_CSC_LINK" ]; then
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD

2
.gitignore vendored
View File

@@ -54,6 +54,8 @@ desktop/build/*
!desktop/build/cowagent-backend.spec !desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt !desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh !desktop/build/build-backend.sh
!desktop/build/sign-backend.js
!desktop/build/entitlements.mac.plist
# Icon authoring scratch dir: intermediate assets used to produce the final # Icon authoring scratch dir: intermediate assets used to produce the final
# icons. Only the finished icons under desktop/resources/ should be committed. # icons. Only the finished icons under desktop/resources/ should be committed.

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron needs JIT and writable/executable memory for V8. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- PyInstaller backend loads many unsigned/third-party dylibs. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- Allow spawning the bundled backend and other child processes. -->
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,92 @@
/**
* electron-builder afterPack hook.
*
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
* into Contents/Resources/backend/. electron-builder's deep signing does NOT
* cover extraResources, so its executable + hundreds of .so/.dylib files stay
* unsigned — which makes notarization reject the whole app.
*
* This hook runs before electron-builder signs the .app: it code-signs every
* Mach-O file under the backend dir with hardened runtime + entitlements, so
* the outer app signature and notarization succeed.
*/
const { execFileSync, execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
exports.default = async function signBackend(context) {
const { electronPlatformName, appOutDir, packager } = context
if (electronPlatformName !== 'darwin') return
// Signing identity comes from CSC_NAME (set in CI and, for local builds,
// your shell). No identity => unsigned build (e.g. dry runs), so skip the
// backend signing rather than fail.
const identity = process.env.CSC_NAME
if (!identity) {
console.log('[sign-backend] CSC_NAME not set — unsigned build, skipping backend signing')
return
}
const appName = packager.appInfo.productFilename
const backendDir = path.join(
appOutDir,
`${appName}.app`,
'Contents',
'Resources',
'backend'
)
if (!fs.existsSync(backendDir)) {
console.log(`[sign-backend] no backend dir at ${backendDir}, skipping`)
return
}
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
// Collect every Mach-O file (executables + dylibs/so) under backend/.
// `file` output contains "Mach-O" for native binaries.
const files = []
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name)
const stat = fs.lstatSync(full)
if (stat.isSymbolicLink()) continue
if (stat.isDirectory()) {
walk(full)
continue
}
try {
const out = execSync(`file -b "${full}"`, { encoding: 'utf8' })
if (out.includes('Mach-O')) files.push(full)
} catch {
// ignore unreadable entries
}
}
}
walk(backendDir)
console.log(`[sign-backend] signing ${files.length} Mach-O files under backend/`)
// Sign inner libraries first, then the main executable last (inside-out).
files.sort((a, b) => b.length - a.length)
for (const f of files) {
execFileSync(
'codesign',
[
'--force',
'--timestamp',
'--options',
'runtime',
'--entitlements',
entitlements,
'--sign',
identity,
f,
],
{ stdio: 'inherit' }
)
}
console.log('[sign-backend] backend signing complete')
}

View File

@@ -44,6 +44,7 @@
"build": { "build": {
"appId": "com.cowagent.desktop", "appId": "com.cowagent.desktop",
"productName": "CowAgent", "productName": "CowAgent",
"afterPack": "build/sign-backend.js",
"directories": { "directories": {
"output": "release" "output": "release"
}, },
@@ -67,6 +68,11 @@
"mac": { "mac": {
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"icon": "resources/icon.icns", "icon": "resources/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"notarize": true,
"target": [ "target": [
{ {
"target": "dmg", "target": "dmg",