diff --git a/.github/workflows/desktop-client-dev.yml b/.github/workflows/desktop-client-dev.yml index a8b6a51..2e04993 100644 --- a/.github/workflows/desktop-client-dev.yml +++ b/.github/workflows/desktop-client-dev.yml @@ -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 }} diff --git a/desktop-client/scripts/ci-build-mac.sh b/desktop-client/scripts/ci-build-mac.sh new file mode 100644 index 0000000..9861823 --- /dev/null +++ b/desktop-client/scripts/ci-build-mac.sh @@ -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 diff --git a/desktop-client/scripts/notarize.js b/desktop-client/scripts/notarize.js index f28c029..815be7e 100644 --- a/desktop-client/scripts/notarize.js +++ b/desktop-client/scripts/notarize.js @@ -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)); } }