#!/usr/bin/env bash # 一键:构建 → 签名 → 公证 → 装订 → 验证 # 用法: pnpm sign:mac set -euo pipefail cd "$(dirname "$0")/.." R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[1m'; N=$'\033[0m' ok() { echo " ${G}✓${N} $1"; } fail() { echo " ${R}✗${N} $1"; } step() { echo ""; echo "${B}── $1 ──${N}"; } LOCK_DIR="${TMPDIR:-/tmp}/geo-rankly-desktop-sign-mac.lock" if ! mkdir "$LOCK_DIR" 2>/dev/null; then fail "已有 sign:mac 正在运行,避免多个构建同时写 release/" echo " 如确认没有进程,可删除: $LOCK_DIR" exit 1 fi trap 'rm -rf "$LOCK_DIR"' EXIT # ── 前置检查(本地/CI 双模式)───────────────────────────────────── # 本地模式:读 .env.signing,公证走钥匙串 profile # CI 模式:靠环境变量 CSC_NAME / APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID NOTARY_ARGS=() if [ -f .env.signing ] && [ -z "${CI:-}" ]; then # shellcheck source=/dev/null set -a; source .env.signing; set +a : "${CSC_NAME:?.env.signing 缺少 CSC_NAME}" : "${APPLE_TEAM_ID:?.env.signing 缺少 APPLE_TEAM_ID}" : "${NOTARY_KEYCHAIN_PROFILE:?.env.signing 缺少 NOTARY_KEYCHAIN_PROFILE}" NOTARY_ARGS=(--keychain-profile "$NOTARY_KEYCHAIN_PROFILE") ok "模式: 本地(钥匙串 profile)" elif [ -n "${CSC_NAME:-}" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then NOTARY_ARGS=(--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID") ok "模式: CI(环境变量)" else fail "未找到签名配置" echo " 本地: 运行 ${B}pnpm sign:setup${N}" echo " CI: 设置 ${B}CSC_NAME / APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID${N} 环境变量" exit 1 fi ok "签名身份: $CSC_NAME" ok "Team ID: $APPLE_TEAM_ID" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-$CSC_NAME}" if [[ ! "$CODESIGN_IDENTITY" =~ ^[0-9A-Fa-f]{40}$ && "$CODESIGN_IDENTITY" != Developer\ ID\ Application:* ]]; then CODESIGN_IDENTITY="Developer ID Application: $CODESIGN_IDENTITY" fi ok "codesign 身份: $CODESIGN_IDENTITY" # ── 1. 构建 ────────────────────────────────────────────────────── step "[1/5] 构建 Electron 产物" pnpm build ok "构建完成" # ── 2. 打包 + 签名 ─────────────────────────────────────────────── step "[2/5] 打包 + 签名 (electron-builder)" # 清理旧产物,避免 spctl 把上次未公证的 dmg 认成本次产物 rm -rf release/mac-* 2>/dev/null || true rm -f release/*.dmg release/*-mac.zip release/*.blockmap 2>/dev/null || true pnpm exec electron-builder --mac --arm64 ok "打包完成" shopt -s nullglob DMGS=(release/*.dmg) ZIPS=(release/*-mac.zip) APPS=(release/mac*/*.app) shopt -u nullglob [ "${#DMGS[@]}" -eq 0 ] && { fail "未找到 .dmg 产物"; exit 1; } [ "${#APPS[@]}" -eq 0 ] && { fail "未找到 .app 产物"; exit 1; } for DMG in "${DMGS[@]}"; do ok "DMG: $DMG"; done for ZIP in "${ZIPS[@]}"; do ok "ZIP: $ZIP"; done for APP in "${APPS[@]}"; do ok "APP: $APP"; done # 验证签名是否真的盖上了 for APP in "${APPS[@]}"; do if ! codesign --verify --deep --strict --verbose=2 "$APP" >/dev/null 2>&1; then fail ".app 签名验证失败,停止公证: $APP" codesign --verify --deep --strict --verbose=2 "$APP" || true exit 1 fi ok ".app 签名校验通过: $APP" done # electron-builder 会签 .app,但 DMG 本体在部分配置/版本下不会自动签。 # Gatekeeper 对安装包执行 `spctl -t install` 时要求外层 DMG 也有可用签名。 for DMG in "${DMGS[@]}"; do echo " 正在签名 DMG: $DMG" codesign --force --timestamp --sign "$CODESIGN_IDENTITY" "$DMG" if ! codesign --verify --verbose=2 "$DMG" >/dev/null 2>&1; then fail "DMG 签名验证失败,停止公证: $DMG" codesign --verify --verbose=2 "$DMG" || true exit 1 fi ok "DMG 签名校验通过: $DMG" done # ── 3. 公证 ────────────────────────────────────────────────────── step "[3/5] 提交公证 (notarytool, 通常 1-5 分钟)" for DMG in "${DMGS[@]}"; do echo " 正在提交 DMG: $DMG" xcrun notarytool submit "$DMG" \ "${NOTARY_ARGS[@]}" \ --wait ok "DMG 公证通过: $DMG" done for ZIP in "${ZIPS[@]}"; do echo " 正在提交 ZIP(用于自动更新): $ZIP" xcrun notarytool submit "$ZIP" \ "${NOTARY_ARGS[@]}" \ --wait ok "ZIP 公证通过: $ZIP" done # ── 4. 装订 ticket ─────────────────────────────────────────────── step "[4/5] 装订 ticket (stapler)" for DMG in "${DMGS[@]}"; do xcrun stapler staple "$DMG" ok "DMG ticket 已装订(断网也能验证): $DMG" done # zip 不能 staple,但用户解压后里面的 .app 已经被 staple 过 # ── 5. 验证 ────────────────────────────────────────────────────── step "[5/5] 最终验证 (Gatekeeper)" for DMG in "${DMGS[@]}"; do LOG_FILE=$(mktemp "${TMPDIR:-/tmp}/spctl.XXXXXX") if spctl -a -vv -t install "$DMG" 2>&1 | tee "$LOG_FILE" | grep -q "accepted"; then ok "Gatekeeper 验证通过: $DMG" grep -E "source=|origin=" "$LOG_FILE" | sed 's/^/ /' else fail "Gatekeeper 拒绝了产物: $DMG" cat "$LOG_FILE" rm -f "$LOG_FILE" exit 1 fi rm -f "$LOG_FILE" done # ── 完成 ──────────────────────────────────────────────────────── echo "" echo "${G}${B}━━━━━━━━━━ 全部完成 ━━━━━━━━━━${N}" for DMG in "${DMGS[@]}"; do SIZE=$(du -h "$DMG" | awk '{print $1}') echo " 📦 $DMG (${SIZE})" done for ZIP in "${ZIPS[@]}"; do echo " 📦 $ZIP" done echo "" echo " 这份 DMG 可以直接发给任何 macOS 用户," echo " 双击安装,首次打开${B}不会${N}弹\"无法验证开发者\"或被 Gatekeeper 拦截。"