Files
pangolin/scripts/ci/compile-macos.sh
T
wangjia 6ce67176da
Deploy Client / build-android (push) Successful in 1m39s
Deploy Client / build-windows (push) Successful in 1m53s
Deploy Client / build-macos (push) Successful in 3m43s
Deploy Client / build-ios (push) Successful in 3m5s
Deploy Client / release-deploy (push) Successful in 2m7s
fix(ci/macos): 构建后从 WORK 目录 embed 描述文件(xcodebuild 会清 PROF_DIR)
build-macos 失败:flutter build macos 期间 xcodebuild 修剪
~/Library/Developer/Xcode/UserData/Provisioning Profiles,把构建前装进去的
Developer ID 描述文件删掉,构建后 inside-out 重签时从该目录 cp 扑空
(cp: <uuid>.provisionprofile: No such file → exit 1)。改为从 mktemp 的 WORK
目录复制原始 .provisionprofile(xcodebuild 不碰),幂等可靠。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 11:27:50 +08:00

272 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# compile-macos.sh <tag> — build the Flutter macOS app (with its embedded
# PacketTunnel System Extension), Developer ID sign + notarize + staple,
# package into dist/.
#
# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain
# Developer ID import, inside-out codesign, notarytool submit --wait, staple,
# ditto zip), but pangolin's macOS app is heavier than jiu's plain window:
# - it embeds a System Extension (com.pangolin.pangolin.PacketTunnel,
# `.systemextension` bundle under Contents/Library/SystemExtensions/)
# that needs ITS OWN Developer ID provisioning profile + entitlements,
# signed separately, inside-out, before the outer app is signed — see
# CLAUDE.md "client/ macOS 原生隧道" and
# docs/macos-sysext-realize-troubleshooting.html for why every one of
# these steps is load-bearing (wrong order / missing entitlement /
# unsigned nested item => sysextd silently refuses to load it).
# - the Xcode project (client/macos/Runner.xcodeproj) is ALREADY configured
# CODE_SIGN_STYLE=Manual with CODE_SIGN_IDENTITY="Developer ID
# Application" + PROVISIONING_PROFILE_SPECIFIER set per target (unlike
# jiu, whose Release config has CODE_SIGN_IDENTITY="-", i.e. unsigned at
# build time, signed entirely by hand afterward). So `flutter build macos
# --release` here should already produce a signed .app *if* the identity
# is in a searched keychain and the profiles are in place. This script
# still re-runs the explicit inside-out codesign pass below (mirroring
# scripts/local_test.sh's now-legacy `cmd_sign`) as a defensive,
# idempotent safety net — resigning with the same identity is harmless,
# and it removes any dependency on Xcode's automatic nested-item signing
# behaving identically on a CI runner vs. the maintainer's dev machine.
# - CFBundleVersion for the PacketTunnel extension is NOT derived from
# FLUTTER_BUILD_NUMBER (unlike the main Runner target, and unlike iOS
# where every target uses "$(FLUTTER_BUILD_NUMBER)") — it's a literal
# CURRENT_PROJECT_VERSION build setting hardcoded in project.pbxproj
# (currently 53, see CLAUDE.md: "每次构建必递增 CFBundleVersion
# (CURRENT_PROJECT_VERSION)——否则 sysextd 视为同版本不更新"). This script
# computes a monotonic build number from the tag version (same
# major*10000+minor*100+patch formula as compile-android.sh /
# compile-ios.sh) and sed's every CURRENT_PROJECT_VERSION occurrence in
# the pbxproj (RunnerTests + PacketTunnel Debug/Release/Profile, 6 total)
# before building. This edit is NOT committed back to git — each CI run
# starts from the repo's checked-in value and recomputes deterministically
# from the tag, so it stays monotonic across releases as long as the
# version itself increases.
#
# Required CI secrets (fail-fast — ANY missing => abort, never ship unsigned):
# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书(.p12)base64
# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码
# MACOS_APP_PROVISION_PROFILE_BASE64 "Pangolin App DevID" 描述文件 base64
# MACOS_SYSEXT_PROVISION_PROFILE_BASE64 "Pangolin PacketTunnel DevID" 描述文件 base64
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
# App Store Connect API Key(公证用,notarytool --key/--key-id/--issuer
# 不同于 scripts/local_test.sh 本机用的 --keychain-profile pangolin-notary,
# CI 无法预置 keychain profile,必须走显式 API key 三件套)。
#
# Output: dist/pangolin-macos-x64.zip (stable name — deploy-client.sh /
# release-client.sh key off this exact filename).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
TAG="${1:?usage: compile-macos.sh <tag>}"
ver_file="/tmp/compile_macos_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
# 单调递增 build 号:major*10000 + minor*100 + patch(同 compile-android.sh /
# compile-ios.sh 公式)。纯参数展开,不用 cut/$();剥掉 patch 段的 `-suffix`
# (如预发布 tag "48-rc1")以保证后面的算术展开是纯数字。
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-macos: tag=${TAG} version=${VER} build=${BUILD}"
# ── [0/7] fail-fast:签名/公证所需 secret 一个不少 ──────────────────────────
# 比 jiu 的 mac 脚本(只查 2 个)更严格:任一缺失都直接报错中止,不产出未签名/
# 未公证包。用 `${VAR:?msg}` 而非 if-empty,出错信息更精确定位到具体哪个 secret。
: "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:?缺少 MACOS_DEVELOPER_ID_CERT_P12_BASE64:未配置 Developer ID 证书,构建中止(不产出未签名包)}"
: "${MACOS_DEVELOPER_ID_CERT_PASSWORD:?缺少 MACOS_DEVELOPER_ID_CERT_PASSWORD}"
: "${MACOS_APP_PROVISION_PROFILE_BASE64:?缺少 MACOS_APP_PROVISION_PROFILE_BASE64('Pangolin App DevID' 描述文件)}"
: "${MACOS_SYSEXT_PROVISION_PROFILE_BASE64:?缺少 MACOS_SYSEXT_PROVISION_PROFILE_BASE64('Pangolin PacketTunnel DevID' 描述文件)}"
: "${APPSTORE_API_KEY_ID:?缺少 APPSTORE_API_KEY_ID(公证用 App Store Connect API Key}"
: "${APPSTORE_API_ISSUER_ID:?缺少 APPSTORE_API_ISSUER_ID}"
: "${APPSTORE_API_KEY_P8_BASE64:?缺少 APPSTORE_API_KEY_P8_BASE64}"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
SIGN_ID_PREFIX="Developer ID Application"
TEAM_ID="BYL4KQHMTN"
APP_BUNDLE_ID="com.pangolin.pangolin"
SYSEXT_BUNDLE_ID="com.pangolin.pangolin.PacketTunnel"
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
WORK="$(mktemp -d)"
KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db"
# 与 scripts/local_test.sh 用的描述文件目录保持一致(Xcode UserData 目录,
# 而非经典的 ~/Library/MobileDevice/Provisioning Profiles/)——CI 与本机联调
# 复用同一处,两边的 "Pangolin App DevID"/"Pangolin PacketTunnel DevID" 描述
# 文件本就应该是同一份,CI 覆盖写入是幂等的。故清理阶段【不删除】这两个描述
# 文件(只清理临时 keychain/证书),避免影响维护者在同一台 mac runner 上继续
# 用 local_test.sh 手动联调。
PROF_DIR="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$PROF_DIR"
cleanup_mac() {
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
# 把 keychain 搜索列表还原成默认单项,不把临时 keychain 的痕迹留在这台常驻
# runner 上(mac-pangolin-2 同时也被人手动用来跑 local_test.sh)。
security list-keychains -d user -s login.keychain-db 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup_mac EXIT
# ── [1/7] 重建 libbox.xcframeworkgitignore 产物,每次换 worktree/CI 都要重建)─
echo "==> compile-macos: building Libbox.xcframework (apple macos)"
bash "${REPO_ROOT}/scripts/build-libbox.sh" apple macos
LIBBOX_FW="${REPO_ROOT}/client/macos/Frameworks/Libbox.xcframework"
if [ ! -d "$LIBBOX_FW" ]; then
echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2
exit 1
fi
# ── [2/7] 递增 CURRENT_PROJECT_VERSIONsysext CFBundleVersion,见头部说明)──
PBXPROJ="${REPO_ROOT}/client/macos/Runner.xcodeproj/project.pbxproj"
echo "==> compile-macos: bumping CURRENT_PROJECT_VERSION -> ${BUILD} in project.pbxproj"
sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${BUILD};/g" "$PBXPROJ"
# 同步 pubspec.yaml(主 app 自身的 FLUTTER_BUILD_NUMBER,和 android/windows 脚本一致)。
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml"
# ── [3/7] 解码 2 个 Developer ID 描述文件到 local_test.sh 同款目录 ──────────
printf '%s' "$MACOS_APP_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/app.provisionprofile"
printf '%s' "$MACOS_SYSEXT_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/sysext.provisionprofile"
# 提取 UUID 用来落盘为规范文件名(Xcode/xcodebuild 靠内容匹配,文件名本身不
# 强制要求,但用 UUID 命名是 Xcode 自身导入时的惯例,避免与其他描述文件重名冲突)。
security cms -D -i "${WORK}/app.provisionprofile" > "${WORK}/app_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt"
APP_PROFILE_UUID=""
read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true
APP_PROFILE="${PROF_DIR}/${APP_PROFILE_UUID}.provisionprofile"
cp "${WORK}/app.provisionprofile" "$APP_PROFILE"
security cms -D -i "${WORK}/sysext.provisionprofile" > "${WORK}/sysext_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/sysext_profile.plist" > "${WORK}/sysext_uuid.txt"
SYSEXT_PROFILE_UUID=""
read -r SYSEXT_PROFILE_UUID < "${WORK}/sysext_uuid.txt" || true
SYSEXT_PROFILE="${PROF_DIR}/${SYSEXT_PROFILE_UUID}.provisionprofile"
cp "${WORK}/sysext.provisionprofile" "$SYSEXT_PROFILE"
echo "==> compile-macos: profiles installed to '${PROF_DIR}' (app=${APP_PROFILE_UUID}, sysext=${SYSEXT_PROFILE_UUID})"
# ── [4/7] 临时 keychain 导入 Developer ID Application 证书 ──────────────────
KEYCHAIN_PWD="ci-temp-$$"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
printf '%s' "$MACOS_DEVELOPER_ID_CERT_P12_BASE64" | base64 --decode > "${WORK}/devid.p12"
security import "${WORK}/devid.p12" -k "$KEYCHAIN" \
-P "$MACOS_DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
security find-identity -v -p codesigning "$KEYCHAIN" > "${WORK}/identities.txt"
grep "$SIGN_ID_PREFIX" "${WORK}/identities.txt" | head -1 | sed -E 's/.*"(.*)"/\1/' > "${WORK}/identity.txt"
IDENTITY=""
read -r IDENTITY < "${WORK}/identity.txt" || true
if [ -z "$IDENTITY" ]; then
echo "ERROR: '${SIGN_ID_PREFIX}' identity not found in imported keychain" >&2
exit 1
fi
echo "==> compile-macos: signing identity '${IDENTITY}'"
# ── [5/7] flutter build macos --release ─────────────────────────────────────
# 项目已配 CODE_SIGN_STYLE=Manual + PROVISIONING_PROFILE_SPECIFIER(见头部说
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
cd "${REPO_ROOT}/client"
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
APP="${REPO_ROOT}/client/build/macos/Build/Products/Release/pangolin_vpn.app"
SE="${APP}/Contents/Library/SystemExtensions/${SYSEXT_BUNDLE_ID}.systemextension"
[ -d "$APP" ] || { echo "ERROR: build product not found: ${APP}" >&2; exit 1; }
[ -d "$SE" ] || { echo "ERROR: sysext bundle not found: ${SE}" >&2; exit 1; }
# ── [6/7] inside-out 重签(sysext → app frameworks → app),同 local_test.sh cmd_sign ──
cat > "${WORK}/app.entitlements" <<PLIST
<?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>
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${APP_BUNDLE_ID}</string>
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
<key>com.apple.developer.system-extension.install</key><true/>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>keychain-access-groups</key>
<array><string>${APP_GROUP}</string></array>
</dict></plist>
PLIST
cat > "${WORK}/sysext.entitlements" <<PLIST
<?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>
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${SYSEXT_BUNDLE_ID}</string>
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>group.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
echo "==> compile-macos: inside-out codesign (sysext -> frameworks -> app)"
xattr -cr "$APP"
# 从 WORK 临时目录复制(不是 PROF_DIR):flutter build macos 期间 xcodebuild 会
# 修剪 ~/Library/Developer/Xcode/UserData/Provisioning Profiles,把[3/7]构建前
# 装进去的 profile 清掉,构建后再从那目录 cp 会扑空(No such file → 本步失败)。
# WORK 是 mktemp 目录、xcodebuild 不碰,里面的原始 .provisionprofile 全程留存。
cp "${WORK}/sysext.provisionprofile" "${SE}/Contents/embedded.provisionprofile"
cp "${WORK}/app.provisionprofile" "${APP}/Contents/embedded.provisionprofile"
# sysext first — libbox is linked (not embedded) into the sysext binary
# itself (see CLAUDE.md), so signing the sysext bundle re-covers its statically
# linked libbox; no separate Libbox.framework to sign inside it.
codesign --force --options runtime --timestamp \
--entitlements "${WORK}/sysext.entitlements" --sign "$IDENTITY" "$SE"
if [ -d "${APP}/Contents/Frameworks" ]; then
for item in "${APP}/Contents/Frameworks/"*; do
[ -e "$item" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$item"
done
fi
codesign --force --options runtime --timestamp \
--entitlements "${WORK}/app.entitlements" --sign "$IDENTITY" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "==> compile-macos: signed + verified (${IDENTITY})"
# ── [7/7] 公证(notarytoolAPI key 三件套) + staple + 打包 ──────────────────
echo "==> compile-macos: notarizing (notarytool submit --wait, ~1-5min)"
NOTARIZE_ZIP="${WORK}/notarize.zip"
ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP"
printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${WORK}/AuthKey.p8"
xcrun notarytool submit "$NOTARIZE_ZIP" \
--key "${WORK}/AuthKey.p8" \
--key-id "$APPSTORE_API_KEY_ID" \
--issuer "$APPSTORE_API_ISSUER_ID" \
--wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl -a -vvv -t install "$APP" || true # 期望 source=Notarized Developer ID
echo "==> compile-macos: notarized + stapled"
mkdir -p "${REPO_ROOT}/dist"
ditto -c -k --keepParent "$APP" "${REPO_ROOT}/dist/pangolin-macos-x64.zip"
echo "==> compile-macos: done — dist/ contents:"
ls -lh "${REPO_ROOT}/dist/"