mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
build(desktop): decouple macOS notarization from CI into 3-stage release
This commit is contained in:
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* electron-builder afterSign hook: notarize the signed .app ourselves.
|
||||
*
|
||||
* Why not electron-builder's built-in notarize (mac.notarize):
|
||||
* it runs `notarytool submit --wait`, which aborts the whole build on ANY
|
||||
* network blip during status polling (NSURLErrorDomain -1001 timeout / -1009
|
||||
* offline), with no retry — and on failure it re-submits a fresh upload,
|
||||
* piling up duplicate submissions. We saw arm64 get "Accepted" yet the build
|
||||
* still failed because the poll request dropped.
|
||||
*
|
||||
* This hook runs after signing and BEFORE the dmg is built, so we:
|
||||
* 1. zip the .app with ditto (the payload Apple actually accepts for this
|
||||
* large PyInstaller bundle — submitting the dmg got stuck In Progress),
|
||||
* 2. `notarytool submit --no-wait` ONCE to get a submission id,
|
||||
* 3. poll that SAME id until Accepted/Invalid — a failed poll (network) is
|
||||
* ignored and retried; we never re-submit,
|
||||
* 4. staple the ticket onto the .app; electron-builder then packs the dmg.
|
||||
*
|
||||
* Requires env: APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID.
|
||||
* If they're absent (unsigned/dry build) the hook is a no-op.
|
||||
* Tunables: NOTARIZE_MAX_WAIT_MINUTES (default 180), NOTARIZE_POLL_SECONDS (default 30).
|
||||
*/
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
function sh(cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, { encoding: 'utf8', ...opts })
|
||||
}
|
||||
|
||||
exports.default = async function notarizeApp(context) {
|
||||
const { electronPlatformName, appOutDir, packager } = context
|
||||
if (electronPlatformName !== 'darwin') return
|
||||
|
||||
const appleId = process.env.APPLE_ID
|
||||
const applePassword = process.env.APPLE_APP_SPECIFIC_PASSWORD
|
||||
const teamId = process.env.APPLE_TEAM_ID
|
||||
if (!appleId || !applePassword || !teamId) {
|
||||
console.log('[notarize-app] APPLE_* env not set — skipping notarization')
|
||||
return
|
||||
}
|
||||
|
||||
const appName = packager.appInfo.productFilename
|
||||
const appPath = path.join(appOutDir, `${appName}.app`)
|
||||
if (!fs.existsSync(appPath)) {
|
||||
console.log(`[notarize-app] no .app at ${appPath}, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
const auth = [
|
||||
'--apple-id', appleId,
|
||||
'--password', applePassword,
|
||||
'--team-id', teamId,
|
||||
]
|
||||
|
||||
const maxWaitMin = parseInt(process.env.NOTARIZE_MAX_WAIT_MINUTES || '180', 10)
|
||||
const pollSec = parseInt(process.env.NOTARIZE_POLL_SECONDS || '30', 10)
|
||||
|
||||
// 1) zip the .app (ditto preserves symlinks/metadata; --keepParent keeps .app).
|
||||
const zipPath = path.join(os.tmpdir(), `${appName}-${context.arch}-notarize.zip`)
|
||||
console.log(`[notarize-app] zipping ${appPath} -> ${zipPath}`)
|
||||
sh('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath], { stdio: 'inherit' })
|
||||
|
||||
// 2) submit ONCE, no --wait, capture the submission id.
|
||||
console.log('[notarize-app] submitting to notary service (no-wait)...')
|
||||
const submitOut = sh('xcrun', ['notarytool', 'submit', zipPath, ...auth, '--no-wait', '--output-format', 'json'])
|
||||
let submissionId
|
||||
try {
|
||||
submissionId = JSON.parse(submitOut).id
|
||||
} catch (e) {
|
||||
throw new Error(`[notarize-app] could not parse submission id:\n${submitOut}`)
|
||||
}
|
||||
if (!submissionId) throw new Error(`[notarize-app] empty submission id:\n${submitOut}`)
|
||||
console.log(`[notarize-app] submission id: ${submissionId} (polling same id, never resubmitting)`)
|
||||
|
||||
// 3) poll the SAME id; a failed poll (network) is ignored and retried.
|
||||
const deadline = Date.now() + maxWaitMin * 60 * 1000
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
for (;;) {
|
||||
let status = ''
|
||||
try {
|
||||
const infoOut = sh('xcrun', ['notarytool', 'info', submissionId, ...auth, '--output-format', 'json'])
|
||||
status = JSON.parse(infoOut).status || ''
|
||||
} catch (e) {
|
||||
status = '' // treat query failure as unknown; keep polling
|
||||
}
|
||||
const ts = new Date().toISOString().slice(11, 19)
|
||||
console.log(`[notarize-app] [${ts}] status: ${status || '<query failed, retrying>'}`)
|
||||
|
||||
if (status === 'Accepted') break
|
||||
if (status === 'Invalid' || status === 'Rejected') {
|
||||
try {
|
||||
console.log(sh('xcrun', ['notarytool', 'log', submissionId, ...auth]))
|
||||
} catch {}
|
||||
throw new Error(`[notarize-app] notarization ${status} (id: ${submissionId})`)
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
`[notarize-app] not finished after ${maxWaitMin} min (id: ${submissionId}). ` +
|
||||
`NOT resubmitting. Check later: xcrun notarytool info ${submissionId}`
|
||||
)
|
||||
}
|
||||
await sleep(pollSec * 1000)
|
||||
}
|
||||
|
||||
// 4) staple the ticket onto the .app (dmg is built afterwards by electron-builder).
|
||||
console.log('[notarize-app] Accepted; stapling ticket to .app')
|
||||
let stapleTry = 1
|
||||
for (;;) {
|
||||
try {
|
||||
sh('xcrun', ['stapler', 'staple', appPath], { stdio: 'inherit' })
|
||||
break
|
||||
} catch (e) {
|
||||
if (stapleTry >= 3) throw e
|
||||
console.log('[notarize-app] staple failed, retrying in 15s...')
|
||||
await sleep(15000)
|
||||
stapleTry++
|
||||
}
|
||||
}
|
||||
try { fs.unlinkSync(zipPath) } catch {}
|
||||
console.log('[notarize-app] notarization + staple complete')
|
||||
}
|
||||
147
desktop/build/notarize-dmg.sh
Executable file
147
desktop/build/notarize-dmg.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally.
|
||||
#
|
||||
# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored
|
||||
# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller
|
||||
# bundle "In Progress" for hours, so we notarize here — off the CI clock — and
|
||||
# staple the ticket straight onto the dmg (users download it ready-to-run).
|
||||
#
|
||||
# What it does for each dmg passed on the command line:
|
||||
# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id,
|
||||
# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and
|
||||
# retried, and it NEVER resubmits (avoids piling up duplicate submissions),
|
||||
# 3. staple the ticket onto the dmg,
|
||||
# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished
|
||||
# copy, so the CDN serves the notarized bytes.
|
||||
#
|
||||
# Auth: uses a stored keychain profile (default: cow-notary). Create it once via
|
||||
# xcrun notarytool store-credentials cow-notary \
|
||||
# --apple-id <id> --team-id <team> --password <app-specific-password>
|
||||
#
|
||||
# Usage:
|
||||
# # notarize + staple only (no upload):
|
||||
# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...]
|
||||
#
|
||||
# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds):
|
||||
# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg
|
||||
#
|
||||
# Env:
|
||||
# PROFILE keychain profile name (default: cow-notary)
|
||||
# UPLOAD set to 1 to re-upload stapled dmgs to R2
|
||||
# VER version string for the R2 key desktop/v${VER}/<file> (required if UPLOAD=1)
|
||||
# R2_BUCKET R2 bucket (default: cow-skills)
|
||||
# POLL_SECONDS status poll interval (default: 60)
|
||||
# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE="${PROFILE:-cow-notary}"
|
||||
R2_BUCKET="${R2_BUCKET:-cow-skills}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-60}"
|
||||
MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "usage: $0 <dmg> [dmg ...]" >&2
|
||||
echo " set UPLOAD=1 and VER=<version> to also re-upload stapled dmgs to R2" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then
|
||||
echo "error: UPLOAD=1 requires VER=<version> (used for the R2 key)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log() { echo "[notarize-dmg] $*"; }
|
||||
|
||||
notarize_one() {
|
||||
local dmg="$1"
|
||||
if [ ! -f "$dmg" ]; then
|
||||
log "SKIP: not a file: $dmg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If it's already stapled (e.g. re-run), skip straight to (optional) upload.
|
||||
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
|
||||
log "$dmg already stapled — skipping notarization."
|
||||
else
|
||||
log "submitting $dmg (no-wait)..."
|
||||
local submit_out submission_id
|
||||
submit_out="$(xcrun notarytool submit "$dmg" \
|
||||
--keychain-profile "$PROFILE" --no-wait --output-format json)"
|
||||
submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)"
|
||||
if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then
|
||||
# Fallback parse without plutil (json is single-line).
|
||||
submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
if [ -z "$submission_id" ]; then
|
||||
log "ERROR: could not parse submission id from:"
|
||||
echo "$submit_out" >&2
|
||||
return 1
|
||||
fi
|
||||
log "submission id: $submission_id (polling same id, never resubmitting)"
|
||||
|
||||
local deadline status ts
|
||||
deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 ))
|
||||
while :; do
|
||||
status=""
|
||||
status="$(xcrun notarytool info "$submission_id" \
|
||||
--keychain-profile "$PROFILE" --output-format json 2>/dev/null \
|
||||
| sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)"
|
||||
ts="$(date +%H:%M:%S)"
|
||||
log "[$ts] status: ${status:-<query failed, retrying>}"
|
||||
|
||||
case "$status" in
|
||||
Accepted) break ;;
|
||||
Invalid|Rejected)
|
||||
log "notarization $status — fetching log:"
|
||||
xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)."
|
||||
log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE"
|
||||
return 1
|
||||
fi
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
|
||||
log "Accepted; stapling ticket to $dmg"
|
||||
local staple_try=1
|
||||
until xcrun stapler staple "$dmg"; do
|
||||
if [ "$staple_try" -ge 3 ]; then
|
||||
log "ERROR: stapling failed after 3 attempts"
|
||||
return 1
|
||||
fi
|
||||
log "staple failed, retrying in 15s..."
|
||||
sleep 15
|
||||
staple_try=$((staple_try + 1))
|
||||
done
|
||||
xcrun stapler validate "$dmg"
|
||||
log "$dmg notarized + stapled."
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ]; then
|
||||
local base key
|
||||
base="$(basename "$dmg")"
|
||||
key="desktop/v${VER}/${base}"
|
||||
log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}"
|
||||
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||
--file "$dmg" --remote
|
||||
log "uploaded $base"
|
||||
fi
|
||||
}
|
||||
|
||||
rc=0
|
||||
for dmg in "$@"; do
|
||||
echo "======================================================================"
|
||||
notarize_one "$dmg" || rc=1
|
||||
done
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log "one or more dmgs failed — see output above."
|
||||
exit 1
|
||||
fi
|
||||
log "all done."
|
||||
Reference in New Issue
Block a user