From 3d8023dbb04c50992c3f1332627900b3a01239ea Mon Sep 17 00:00:00 2001 From: stors789 Date: Fri, 14 Aug 2026 11:14:47 +0800 Subject: [PATCH] fix(ci): provision App Groups for Developer ID releases --- .github/workflows/release.yml | 158 ++++++++++++++++-- ...64\346\226\260\346\226\271\346\241\210.md" | 17 +- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ee1bfb..e3cf990 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,10 @@ jobs: runs-on: macos-latest env: APP_NAME: RClick + APP_BUNDLE_ID: cn.wflixu.RClick EXTENSION_NAME: FinderSyncExt + EXTENSION_BUNDLE_ID: cn.wflixu.RClick.FinderSyncExt + APP_GROUP_ID: group.cn.wflixu.RClick SCHEME: RClick PROJECT: RClick.xcodeproj steps: @@ -63,9 +66,104 @@ jobs: security list-keychains -d user -s build.keychain login.keychain - CERT_NAME=$(security find-identity -v -s 'Developer ID Application' | head -n 1 | awk -F'"' '{print $2}') - echo "CERT_NAME=$CERT_NAME" >> $GITHUB_ENV - echo "Using certificate: $CERT_NAME" + IDENTITY_INFO=$(security find-identity -v -p codesigning build.keychain \ + | awk '/"Developer ID Application:/') + IDENTITY_COUNT=$(printf '%s\n' "$IDENTITY_INFO" \ + | awk 'NF { count++ } END { print count + 0 }') + if [ "$IDENTITY_COUNT" -ne 1 ]; then + echo "Expected exactly one Developer ID Application identity, found $IDENTITY_COUNT" + printf '%s\n' "$IDENTITY_INFO" + exit 1 + fi + + CERT_IDENTITY=$(printf '%s\n' "$IDENTITY_INFO" | awk '{print $2}') + CERT_NAME=$(printf '%s\n' "$IDENTITY_INFO" | awk -F'"' '{print $2}') + echo "CERT_IDENTITY=$CERT_IDENTITY" >> "$GITHUB_ENV" + echo "Using certificate: $CERT_NAME ($CERT_IDENTITY)" + + - name: Prepare App Group Provisioning Profiles + env: + APP_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISIONING_PROFILE }} + EXTENSION_PROFILE_BASE64: ${{ secrets.MACOS_EXTENSION_PROVISIONING_PROFILE }} + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + : "${APP_PROFILE_BASE64:?Missing MACOS_APP_PROVISIONING_PROFILE secret}" + : "${EXTENSION_PROFILE_BASE64:?Missing MACOS_EXTENSION_PROVISIONING_PROFILE secret}" + : "${TEAM_ID:?Missing APPLE_TEAM_ID secret}" + : "${CERT_IDENTITY:?Missing Developer ID Application identity}" + + mkdir -p signing + APP_PROFILE_PATH="$PWD/signing/${APP_NAME}.provisionprofile" + EXTENSION_PROFILE_PATH="$PWD/signing/${EXTENSION_NAME}.provisionprofile" + printf '%s' "$APP_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH" + printf '%s' "$EXTENSION_PROFILE_BASE64" | base64 --decode > "$EXTENSION_PROFILE_PATH" + + security cms -D -i "$APP_PROFILE_PATH" -o signing/app-profile.plist + security cms -D -i "$EXTENSION_PROFILE_PATH" -o signing/extension-profile.plist + + validate_profile() { + local profile_plist="$1" + local expected_bundle_id="$2" + + python3 - \ + "$profile_plist" \ + "$expected_bundle_id" \ + "$TEAM_ID" \ + "$APP_GROUP_ID" \ + "$CERT_IDENTITY" <<'PY' + import hashlib + import plistlib + import sys + + profile_path, expected_bundle_id, expected_team, expected_group, signing_identity = sys.argv[1:] + with open(profile_path, "rb") as profile_file: + profile = plistlib.load(profile_file) + + entitlements = profile.get("Entitlements", {}) + actual_identifier = entitlements.get("com.apple.application-identifier", "") + _, separator, actual_bundle_id = actual_identifier.partition(".") + if not separator or actual_bundle_id != expected_bundle_id: + raise SystemExit( + f"Provisioning profile does not match bundle ID " + f"{expected_bundle_id}: {actual_identifier}" + ) + + actual_team = entitlements.get("com.apple.developer.team-identifier") + if actual_team != expected_team: + raise SystemExit( + f"Provisioning profile team mismatch: expected {expected_team}, got {actual_team}" + ) + + actual_groups = entitlements.get("com.apple.security.application-groups") + if not isinstance(actual_groups, list) or expected_group not in actual_groups: + raise SystemExit(f"Provisioning profile does not authorize {expected_group}") + + certificate_hashes = { + hashlib.sha1(certificate).hexdigest().upper() + for certificate in profile.get("DeveloperCertificates", []) + if isinstance(certificate, bytes) + } + if signing_identity.upper() not in certificate_hashes: + raise SystemExit( + f"Provisioning profile does not contain signing certificate {signing_identity}" + ) + PY + } + + validate_profile signing/app-profile.plist "$APP_BUNDLE_ID" + validate_profile signing/extension-profile.plist "$EXTENSION_BUNDLE_ID" + APP_IDENTIFIER=$(/usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.application-identifier' \ + signing/app-profile.plist) + EXTENSION_IDENTIFIER=$(/usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.application-identifier' \ + signing/extension-profile.plist) + + echo "APP_PROFILE_PATH=$APP_PROFILE_PATH" >> "$GITHUB_ENV" + echo "EXTENSION_PROFILE_PATH=$EXTENSION_PROFILE_PATH" >> "$GITHUB_ENV" + echo "APP_IDENTIFIER=$APP_IDENTIFIER" >> "$GITHUB_ENV" + echo "EXTENSION_IDENTIFIER=$EXTENSION_IDENTIFIER" >> "$GITHUB_ENV" # --- 2. 构建 Release App --- - name: Build Release @@ -102,10 +200,29 @@ jobs: # --- 3. 分层代码签名 --- - name: Code Sign + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - echo "Signing with: $CERT_NAME" + echo "Signing with identity: $CERT_IDENTITY" + + MAIN_ENTITLEMENTS="signing/${APP_NAME}.entitlements" + EXTENSION_ENTITLEMENTS="signing/${EXTENSION_NAME}.entitlements" + cp "${APP_NAME}/${APP_NAME}.entitlements" "$MAIN_ENTITLEMENTS" + cp "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" "$EXTENSION_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.application-identifier string $APP_IDENTIFIER" \ + "$MAIN_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.developer.team-identifier string $TEAM_ID" \ + "$MAIN_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.application-identifier string $EXTENSION_IDENTIFIER" \ + "$EXTENSION_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.developer.team-identifier string $TEAM_ID" \ + "$EXTENSION_ENTITLEMENTS" # 1) 签名所有 framework if [ -d "$APP_PATH/Contents/Frameworks" ]; then @@ -113,32 +230,45 @@ jobs: find "$APP_PATH/Contents/Frameworks" -type f -perm +111 \ -exec codesign --force --options runtime \ --timestamp \ - --sign "$CERT_NAME" {} \; + --sign "$CERT_IDENTITY" {} \; fi # 2) 签名扩展 (FinderSyncExt.appex) echo "Signing FinderSync extension..." EXT_PATH="$APP_PATH/Contents/PlugIns/${EXTENSION_NAME}.appex" if [ -d "$EXT_PATH" ]; then + cp "$EXTENSION_PROFILE_PATH" "$EXT_PATH/Contents/embedded.provisionprofile" codesign --force --options runtime \ --timestamp \ - --entitlements "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" \ - --sign "$CERT_NAME" \ + --entitlements "$EXTENSION_ENTITLEMENTS" \ + --sign "$CERT_IDENTITY" \ "$EXT_PATH" + else + echo "FinderSync extension not found at $EXT_PATH" + exit 1 fi - # 3) 签名主 App (使用项目 entitlements) + # 3) 嵌入主 App profile 并签名 echo "Signing main app..." + cp "$APP_PROFILE_PATH" "$APP_PATH/Contents/embedded.provisionprofile" codesign --force --options runtime \ --timestamp \ - --entitlements "${APP_NAME}/${APP_NAME}.entitlements" \ - --sign "$CERT_NAME" \ + --entitlements "$MAIN_ENTITLEMENTS" \ + --sign "$CERT_IDENTITY" \ "$APP_PATH" - # 验证签名 - echo "Verifying signatures..." + # 验证签名、profile 和 App Group 身份声明 + echo "Verifying signatures and App Group authorization..." + test -f "$APP_PATH/Contents/embedded.provisionprofile" + test -f "$EXT_PATH/Contents/embedded.provisionprofile" + APP_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$APP_PATH" 2>&1) + EXTENSION_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$EXT_PATH" 2>&1) + grep -Fq "$APP_IDENTIFIER" <<< "$APP_SIGNED_ENTITLEMENTS" + grep -Fq "$EXTENSION_IDENTIFIER" <<< "$EXTENSION_SIGNED_ENTITLEMENTS" + grep -Fq "$APP_GROUP_ID" <<< "$APP_SIGNED_ENTITLEMENTS" + grep -Fq "$APP_GROUP_ID" <<< "$EXTENSION_SIGNED_ENTITLEMENTS" codesign -dv --verbose=4 "$APP_PATH" 2>&1 - codesign --verify --verbose "$APP_PATH" + codesign --verify --deep --strict --verbose=4 "$APP_PATH" # --- 4. 打包为 ZIP 用于公证 --- - name: Zip for Notarization @@ -217,7 +347,7 @@ jobs: "$DMG_NAME" \ "$DMG_STAGING/${APP_NAME}.app" - codesign --force --sign "$CERT_NAME" "$DMG_NAME" + codesign --force --sign "$CERT_IDENTITY" "$DMG_NAME" rm -rf "$DMG_STAGING" diff --git "a/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" "b/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" index 3756701..0e5a43a 100644 --- "a/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" +++ "b/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" @@ -61,7 +61,7 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 | 0. 准备 | Checkout, 选 Xcode, 从 tag 提取版本号 | `actions/checkout@v4` | | 1. 导入证书 | 创建临时 keychain, 导入 Developer ID 证书 | `security import` | | 2. 构建 | xcodebuild Release, `CODE_SIGNING_ALLOWED=NO`, `ARCHS=arm64` | `xcodebuild` | -| 3. 分层签名 | Frameworks → Extensions → Main App, `--options runtime` | `codesign` | +| 3. 授权并分层签名 | 校验并嵌入主 App/扩展的 Developer ID profile,再签 Frameworks → Extension → Main App | `security cms` + `codesign` | | 4. 打包 ZIP | `ditto -c -k` 打包用于公证 | `ditto` | | 5. 公证 | `notarytool submit` 使用 App Store Connect API Key (p8) | `xcrun notarytool` | | 6. 装订票据 | `stapler staple` + `spctl --assess` 验证 | `xcrun stapler` | @@ -76,6 +76,8 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 |--------|------| | `MACOS_CERT_P12` | Developer ID Application 证书的 base64 | | `MACOS_CERT_PASSWORD` | 证书导出密码 | +| `MACOS_APP_PROVISIONING_PROFILE` | `cn.wflixu.RClick` 的 Developer ID provisioning profile(base64),须授权 `group.cn.wflixu.RClick` | +| `MACOS_EXTENSION_PROVISIONING_PROFILE` | `cn.wflixu.RClick.FinderSyncExt` 的 Developer ID provisioning profile(base64),须授权同一 App Group | | `APPLE_TEAM_ID` | Apple Developer Team ID | | `NOTARY_KEY_ID` | App Store Connect API Key ID | | `NOTARY_ISSUER_ID` | API Key Issuer ID | @@ -87,16 +89,19 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 | 文件 | 对象 | 关键权限 | |------|------|---------| -| `RClick/RClick.entitlements` | 主 App (Release) | app-groups, bookmarks, user-selected.read-write, **temporary-exception.apple-events**, **temporary-exception.files.home-relative-path.read-write(/)**, app-sandbox(通过 build setting) | +| `RClick/RClick.entitlements` | 主 App (Release) | app-groups, bookmarks, user-selected.read-write, **temporary-exception.apple-events**, **temporary-exception.files.home-relative-path.read-write(/)**;为兼容 Accessibility,主 App 不启用 App Sandbox | | `RClick/RClickDebug.entitlements` | 主 App (Debug) | app-groups, bookmarks, temporary-exception.files.home-relative-path.read-write(/) | | `FinderSyncExt/FinderSyncExt.entitlements` | 扩展 | app-groups, bookmarks, app-sandbox(通过 build setting) | -**Release 签名策略**:**不创建动态 entitlements**,直接使用项目已有文件。 +`group.` 前缀的 App Group 在 macOS 上必须由每个进程自己的 provisioning profile 授权。主 App 和 FinderSync 扩展使用不同 Bundle ID,因此需要各自的 Developer ID profile;二者都必须包含 `group.cn.wflixu.RClick`。 + +**Release 签名策略**:以项目 entitlements 为基础,签名前精确校验 profile 的 Bundle ID、App Group 和签名证书,再补入 `com.apple.application-identifier` 与 `com.apple.developer.team-identifier`。所有代码签名均使用导入证书的 identity hash,避免同名证书歧义。 构建时 `CODE_SIGNING_ALLOWED=NO`,然后分层手动签名: -1. **Frameworks**(如有):`codesign --force --options runtime --timestamp --sign "$CERT_NAME"` -2. **FinderSyncExt.appex**:`codesign` + 使用 `FinderSyncExt/FinderSyncExt.entitlements` -3. **RClick.app**:`codesign` + 使用 `RClick/RClick.entitlements` + `--options runtime` +1. **Frameworks**(如有):`codesign --force --options runtime --timestamp --sign "$CERT_IDENTITY"` +2. **FinderSyncExt.appex**:嵌入扩展 profile,使用补全身份声明后的 `FinderSyncExt.entitlements` 签名 +3. **RClick.app**:嵌入主 App profile,使用补全身份声明后的 `RClick.entitlements` + `--options runtime` 签名 +4. **验证**:检查两份 profile 存在、最终签名包含各自 application identifier 和 App Group,并执行 `codesign --verify --deep --strict` **公证注意事项**:`RClick.entitlements` 包含 `temporary-exception.files.home-relative-path.read-write` 设为 `/`(整个家目录),这是一个宽泛的临时例外。RClick 作为 Finder 右键扩展需要访问用户在 Finder 中选中的任意文件,功能上合理。公证时 Apple 可能会审查此项,若不通过则需要收紧权限范围。