优化打包公证流程。

This commit is contained in:
Hommy
2026-09-14 11:12:03 +08:00
parent 43961e6b69
commit 9d2c2020a6
3 changed files with 392 additions and 100 deletions
+218 -72
View File
@@ -38,29 +38,10 @@ jobs:
echo "desktop=true" >> "$GITHUB_OUTPUT"
fi
build:
build-windows:
needs: changes
if: needs.changes.outputs.desktop == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# 固定 runner 大版本,避免 -latest 自动迁移导致构建环境突变
- os: windows-2022
platform: win
artifact_name: windows-build
installer_command: build-win-installer
- os: macos-14
platform: mac
artifact_name: macos-build
installer_command: build-mac-installer
- os: macos-15-intel
platform: mac-x64
artifact_name: macos-build-x64
installer_command: build-mac-x64-installer
runs-on: windows-2022
steps:
- name: 准备代码
uses: actions/checkout@v4
@@ -72,35 +53,56 @@ jobs:
cache: 'npm'
cache-dependency-path: desktop-client/package-lock.json
- name: 打印构建环境信息
run: |
echo "Runner OS: ${{ runner.os }}"
echo "Runner image: ${{ matrix.os }}"
node -v
npm -v
- name: 安装依赖并构建
working-directory: ./desktop-client
shell: bash
run: |
npm ci
npm --prefix web ci
npm --prefix web run build
node scripts/update-version.js
env:
GITHUB_REF: ${{ github.ref }}
- name: 安装Electron依赖
run: npm ci
- name: 创建 Windows 安装包
working-directory: ./desktop-client
shell: bash
run: npm run build-win-installer
timeout-minutes: 45
- name: 安装前端依赖
run: npm ci
working-directory: ./desktop-client/web
- name: 上传 Windows 安装包
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: desktop-client/dist/capcut-mate-windows-x64-installer.exe
if-no-files-found: error
- name: 构建前端
run: npm run build
working-directory: ./desktop-client/web
build-mac-arm64:
needs: changes
if: needs.changes.outputs.desktop == 'true'
runs-on: macos-14
steps:
- name: 准备代码
uses: actions/checkout@v4
- name: 更新版本号
run: node scripts/update-version.js
- name: 设置Node.js环境
uses: actions/setup-node@v4
with:
node-version: '22.14.0'
cache: 'npm'
cache-dependency-path: desktop-client/package-lock.json
- name: 安装依赖并构建
working-directory: ./desktop-client
run: |
npm ci
npm --prefix web ci
npm --prefix web run build
node scripts/update-version.js
env:
GITHUB_REF: ${{ github.ref }}
# 预导入证书并授予 codesign 无 UI 权限,避免 electron-builder 临时钥匙串缺 codesign: 导致挂起
- name: 导入 macOS 签名证书
if: startsWith(matrix.os, 'macos')
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@@ -124,63 +126,207 @@ jobs:
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# 把签名钥匙串放到搜索列表最前
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g')
echo "Imported signing identities:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
rm -f "$CERTIFICATE_PATH"
- name: 根据平台创建安装包
timeout-minutes: 75
- name: 准备 App Store Connect API Key
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
if [ -z "${APPLE_API_KEY_BASE64:-}" ] || [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
echo "Missing required secrets: APPLE_API_KEY_BASE64 / APPLE_API_KEY_ID / APPLE_API_ISSUER"
exit 1
fi
API_KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
echo "$APPLE_API_KEY_BASE64" | openssl base64 -d -A -out "$API_KEY_PATH"
chmod 600 "$API_KEY_PATH"
if ! grep -q "BEGIN PRIVATE KEY" "$API_KEY_PATH"; then
echo "Decoded APPLE_API_KEY_BASE64 is not a valid .p8 private key"
exit 1
fi
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "API key ready: $API_KEY_PATH"
- name: 创建 macOS arm64 安装包
timeout-minutes: 120
working-directory: ./desktop-client
env:
# 不传 CSC_LINK:使用上一步导入的钥匙串,避免再建缺 codesign: 的临时钥匙串
CSC_IDENTITY_AUTO_DISCOVERY: ${{ startsWith(matrix.os, 'macos') && 'true' || 'false' }}
APPLE_ID: ${{ startsWith(matrix.os, 'macos') && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ startsWith(matrix.os, 'macos') && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ startsWith(matrix.os, 'macos') && secrets.APPLE_TEAM_ID || '' }}
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
NOTARIZE_REQUIRE_API_KEY: '1'
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
MAC_BUILD_MAX_ATTEMPTS: '3'
run: |
if [ -n "${KEYCHAIN_PATH:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
fi
npm run ${{ matrix.installer_command }}
shell: bash
chmod +x scripts/ci-build-mac.sh
scripts/ci-build-mac.sh build-mac-installer
- name: 清理 macOS 签名钥匙串
if: always() && startsWith(matrix.os, 'macos')
- name: 清理 macOS 凭据
if: always()
run: |
if [ -n "${KEYCHAIN_PATH:-}" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
if [ -n "${APPLE_API_KEY:-}" ]; then
rm -f "$APPLE_API_KEY" || true
fi
- name: 列出dist目录内容
run: |
echo "当前操作系统: ${{ runner.os }}"
ls desktop-client/dist/
shell: bash
- name: 上传安装包构件
- name: 上传 macOS arm64 安装包
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}-installer
path: desktop-client/dist/
if-no-files-found: ignore
name: macos-arm64-installer
path: desktop-client/dist/capcut-mate-macos-arm64-installer.dmg
if-no-files-found: error
- name: 发布版本
if: startsWith(github.ref, 'refs/tags/v')
# 等 arm64 公证结束后再跑 x64,降低同时打 Apple 公证接口导致超时的概率
build-mac-x64:
needs: [changes, build-mac-arm64]
if: |
always() &&
needs.changes.outputs.desktop == 'true' &&
needs.build-mac-arm64.result != 'cancelled'
runs-on: macos-15-intel
steps:
- name: 准备代码
uses: actions/checkout@v4
- name: 设置Node.js环境
uses: actions/setup-node@v4
with:
node-version: '22.14.0'
cache: 'npm'
cache-dependency-path: desktop-client/package-lock.json
- name: 安装依赖并构建
working-directory: ./desktop-client
run: |
npm ci
npm --prefix web ci
npm --prefix web run build
node scripts/update-version.js
env:
GITHUB_REF: ${{ github.ref }}
- name: 导入 macOS 签名证书
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
set -euo pipefail
CERTIFICATE_PATH="$RUNNER_TEMP/signing-cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"
echo "$CSC_LINK" | openssl base64 -d -A -out "$CERTIFICATE_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -P "$CSC_KEY_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" \
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g')
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
rm -f "$CERTIFICATE_PATH"
- name: 准备 App Store Connect API Key
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
if [ -z "${APPLE_API_KEY_BASE64:-}" ] || [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
echo "Missing required secrets: APPLE_API_KEY_BASE64 / APPLE_API_KEY_ID / APPLE_API_ISSUER"
exit 1
fi
API_KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
echo "$APPLE_API_KEY_BASE64" | openssl base64 -d -A -out "$API_KEY_PATH"
chmod 600 "$API_KEY_PATH"
if ! grep -q "BEGIN PRIVATE KEY" "$API_KEY_PATH"; then
echo "Decoded APPLE_API_KEY_BASE64 is not a valid .p8 private key"
exit 1
fi
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "API key ready: $API_KEY_PATH"
- name: 创建 macOS x64 安装包
timeout-minutes: 120
working-directory: ./desktop-client
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
NOTARIZE_REQUIRE_API_KEY: '1'
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
MAC_BUILD_MAX_ATTEMPTS: '3'
run: |
chmod +x scripts/ci-build-mac.sh
scripts/ci-build-mac.sh build-mac-x64-installer
- name: 清理 macOS 凭据
if: always()
run: |
if [ -n "${KEYCHAIN_PATH:-}" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
if [ -n "${APPLE_API_KEY:-}" ]; then
rm -f "$APPLE_API_KEY" || true
fi
- name: 上传 macOS x64 安装包
uses: actions/upload-artifact@v4
with:
name: macos-x64-installer
path: desktop-client/dist/capcut-mate-macos-x64-installer.dmg
if-no-files-found: error
release:
needs: [changes, build-windows, build-mac-arm64, build-mac-x64]
if: |
always() &&
startsWith(github.ref, 'refs/tags/v') &&
needs.changes.outputs.desktop == 'true' &&
needs.build-windows.result == 'success' &&
needs.build-mac-arm64.result == 'success' &&
needs.build-mac-x64.result == 'success'
runs-on: ubuntu-latest
steps:
- name: 下载全部安装包
uses: actions/download-artifact@v4
with:
path: release-assets
- name: 整理发布文件
run: |
set -euo pipefail
mkdir -p dist
find release-assets -type f \( -name '*.exe' -o -name '*.dmg' \) -print -exec cp {} dist/ \;
ls -lh dist/
- name: 发布到 GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
files: |
desktop-client/dist/capcut-mate-windows-x64-installer.exe
desktop-client/dist/capcut-mate-macos-arm64-installer.dmg
desktop-client/dist/capcut-mate-macos-x64-installer.dmg
dist/capcut-mate-windows-x64-installer.exe
dist/capcut-mate-macos-arm64-installer.dmg
dist/capcut-mate-macos-x64-installer.dmg
overwrite: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# macOS CI:安装包构建(含签名/公证/DMG),对 DMG Resource busy 等偶发错误自动重试
set -euo pipefail
INSTALLER_COMMAND="${1:?installer npm script required}"
MAX_ATTEMPTS="${MAC_BUILD_MAX_ATTEMPTS:-3}"
cleanup_dmg_devices() {
echo "Cleaning up attached DMG devices (best effort)..."
# 卸下可能残留的临时磁盘,减轻 hdiutil Resource busy
hdiutil info 2>/dev/null | awk '/\/dev\/disk[0-9]+/ {print $1}' | sort -u | while read -r disk; do
hdiutil detach -force "$disk" >/dev/null 2>&1 || true
done || true
sleep 2
}
unlock_signing_keychain() {
if [ -n "${KEYCHAIN_PATH:-}" ] && [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" || true
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN_PATH" || true
fi
}
is_retryable_build_error() {
local log_file="$1"
grep -Eiq \
'Resource busy|Unable to detach device|couldn.?t eject|hdiutil|DMGError|ECONNRESET|ETIMEDOUT|timed out|offline|No network route' \
"$log_file"
}
LOG_FILE="$(mktemp)"
trap 'rm -f "$LOG_FILE"' EXIT
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
echo "==> mac installer attempt ${attempt}/${MAX_ATTEMPTS}: npm run ${INSTALLER_COMMAND}"
unlock_signing_keychain
cleanup_dmg_devices
set +e
npm run "$INSTALLER_COMMAND" 2>&1 | tee "$LOG_FILE"
status=${PIPESTATUS[0]}
set -e
if [ "$status" -eq 0 ]; then
echo "==> mac installer succeeded on attempt ${attempt}"
exit 0
fi
echo "==> mac installer failed on attempt ${attempt} (exit ${status})"
if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
exit "$status"
fi
if ! is_retryable_build_error "$LOG_FILE"; then
echo "==> error does not look retryable; stopping"
exit "$status"
fi
sleep $((attempt * 20))
done
exit 1
+110 -28
View File
@@ -1,64 +1,146 @@
/**
* macOS 公证(带重试)
* 解决 Apple notarytool 偶发返回非 JSON 的 HTTP 错误导致 CI 失败。
* 优先使用 App Store Connect API Key;未配置时回退 Apple ID + App 专用密码。
*/
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
function buildNotarizeAuth() {
const appleApiKey = process.env.APPLE_API_KEY;
const appleApiKeyId = process.env.APPLE_API_KEY_ID;
const appleApiIssuer = process.env.APPLE_API_ISSUER;
if (appleApiKey && appleApiKeyId) {
if (!fs.existsSync(appleApiKey)) {
throw new Error(`APPLE_API_KEY file not found: ${appleApiKey}`);
}
const pem = fs.readFileSync(appleApiKey, "utf8");
if (!pem.includes("BEGIN PRIVATE KEY")) {
throw new Error(
"APPLE_API_KEY does not look like a valid .p8 private key (missing BEGIN PRIVATE KEY)"
);
}
const auth = {
appleApiKey,
appleApiKeyId
};
// Team Key 需要 Issuer ID;个人账号开通 API 后一般也是 Team Key
if (appleApiIssuer) {
auth.appleApiIssuer = appleApiIssuer;
} else {
console.warn(
"APPLE_API_ISSUER is empty; Team API Key normally requires Issuer ID"
);
}
return { mode: "apiKey", auth };
}
const appleId = process.env.APPLE_ID;
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD;
const teamId = process.env.APPLE_TEAM_ID;
if (appleId && appleIdPassword && teamId) {
return {
mode: "appleId",
auth: { appleId, appleIdPassword, teamId }
};
}
return null;
}
function unlockKeychainIfNeeded() {
const keychainPath = process.env.KEYCHAIN_PATH;
const keychainPassword = process.env.KEYCHAIN_PASSWORD;
if (!keychainPath || !keychainPassword) return;
try {
execFileSync("security", ["unlock-keychain", "-p", keychainPassword, keychainPath], {
stdio: "inherit"
});
} catch (err) {
console.warn("unlock-keychain failed:", err && err.message ? err.message : err);
}
}
function isTransientNotarizeError(message) {
const text = String(message || "").toLowerCase();
return (
text.includes("timed out") ||
text.includes("timeout") ||
text.includes("offline") ||
text.includes("no network route") ||
text.includes("connection") ||
text.includes("httperror") ||
text.includes("socket") ||
text.includes("econnreset") ||
text.includes("enotfound") ||
text.includes("unexpected result")
);
}
module.exports = async function notarizeAfterSign(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") return;
const appleId = process.env.APPLE_ID;
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD;
const teamId = process.env.APPLE_TEAM_ID;
if (!appleId || !appleIdPassword || !teamId) {
console.log("skip notarize: APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID not set");
const requireApiKey = process.env.NOTARIZE_REQUIRE_API_KEY === "1";
const creds = buildNotarizeAuth();
if (!creds) {
const msg =
"notarize credentials missing: set App Store Connect API Key " +
"(APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER) " +
"or Apple ID (APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID)";
if (requireApiKey) {
throw new Error(msg);
}
console.log(`skip notarize: ${msg}`);
return;
}
// 长时间打包后钥匙串可能再次上锁,公证前先解锁
const keychainPath = process.env.KEYCHAIN_PATH;
const keychainPassword = process.env.KEYCHAIN_PASSWORD;
if (keychainPath && keychainPassword) {
try {
execFileSync("security", ["unlock-keychain", "-p", keychainPassword, keychainPath], {
stdio: "inherit"
});
} catch (err) {
console.warn("unlock-keychain failed:", err && err.message ? err.message : err);
}
if (requireApiKey && creds.mode !== "apiKey") {
throw new Error(
"NOTARIZE_REQUIRE_API_KEY=1 but API Key is not configured; refusing Apple ID fallback"
);
}
unlockKeychainIfNeeded();
const { notarize } = require("@electron/notarize");
const appName = context.packager.appInfo.productFilename;
const appPath = path.join(appOutDir, `${appName}.app`);
const maxAttempts = 5;
const maxAttempts = 6;
let lastError;
console.log(`notarize auth mode: ${creds.mode}`);
if (creds.mode === "apiKey") {
console.log(`notarize api key id: ${creds.auth.appleApiKeyId}`);
console.log(`notarize api issuer set: ${Boolean(creds.auth.appleApiIssuer)}`);
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const started = Date.now();
try {
unlockKeychainIfNeeded();
console.log(`notarizing ${appPath} (attempt ${attempt}/${maxAttempts})`);
// @electron/notarize v3:提交公证并自动 staple
await notarize({
appPath,
appleId,
appleIdPassword,
teamId
...creds.auth
});
console.log(`notarization successful in ${Math.round((Date.now() - started) / 1000)}s`);
return;
} catch (err) {
lastError = err;
const message = err && err.message ? err.message : String(err);
console.warn(`notarize attempt ${attempt} failed after ${Math.round((Date.now() - started) / 1000)}s: ${message}`);
if (attempt < maxAttempts) {
const waitSec = attempt * 60;
console.log(`waiting ${waitSec}s before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSec * 1000));
console.warn(
`notarize attempt ${attempt} failed after ${Math.round((Date.now() - started) / 1000)}s: ${message}`
);
if (attempt >= maxAttempts) break;
if (!isTransientNotarizeError(message)) {
console.warn("non-transient notarize error, stop retrying");
break;
}
const waitSec = Math.min(180, attempt * 45);
console.log(`waiting ${waitSec}s before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSec * 1000));
}
}