diff --git a/apps/ios/Config/AppStoreSigning.json b/apps/ios/Config/AppStoreSigning.json index 156c02de559..e368ca05ebb 100644 --- a/apps/ios/Config/AppStoreSigning.json +++ b/apps/ios/Config/AppStoreSigning.json @@ -1,8 +1,8 @@ { "teamId": "FWJYW4S8P8", "signingRepo": "git@github.com:openclaw/ios-signing.git", - "certificateType": "IOS_DISTRIBUTION", - "profileType": "IOS_APP_STORE", + "signingBranch": "main", + "profileType": "appstore", "targets": [ { "target": "OpenClaw", diff --git a/apps/ios/README.md b/apps/ios/README.md index d9325f675b8..1f187bfc5d5 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -56,17 +56,17 @@ Prereqs: - `xcodegen` - `fastlane` - Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`) -- `asc` CLI authenticated for the canonical OpenClaw team -- Release-owner access to the encrypted signing repo password (`ASC_MATCH_PASSWORD`) +- Fastlane Apple Developer Portal session for the canonical OpenClaw team when creating bundle IDs or enabling services +- Release-owner access to the encrypted signing repo password (`MATCH_PASSWORD`) - App Store Connect app already created for `ai.openclawfoundation.app` -- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect +- App Store Connect API key set up in Keychain via `scripts/ios-app-store-connect-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect Release behavior: - Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams. - App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`. - App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`. -- `asc` owns one-time Developer Portal setup and encrypted signing sync. Fastlane owns release handling after those assets exist. +- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling. - App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement. - `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA. - `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation. @@ -93,16 +93,16 @@ Signing setup commands: pnpm ios:release:signing:plan pnpm ios:release:signing:check pnpm ios:release:signing:setup -ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push -ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull +MATCH_PASSWORD=... pnpm ios:release:signing:sync:push +MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull ``` Release-owner secrets: - App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables. -- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `ASC_MATCH_PASSWORD`. +- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`. - Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored. -- Rotating release signing means revoking/replacing the Developer Portal certificate or profile with `asc`, then pushing a fresh encrypted sync state. +- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state. Prepare the generated release xcconfig/project without archiving: @@ -142,13 +142,13 @@ fastlane ios auth_check 2. If auth is missing, bootstrap it once on this Mac: ```bash -scripts/ios-asc-keychain-setup.sh \ +scripts/ios-app-store-connect-keychain-setup.sh \ --key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \ --issuer-id YOUR_ISSUER_ID \ --write-env ``` -This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain. +This should create `apps/ios/fastlane/.env` with non-secret App Store Connect variables while the private key stays in Keychain. 3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for: - `ai.openclawfoundation.app` @@ -157,7 +157,7 @@ This should create `apps/ios/fastlane/.env` with the non-secret ASC variables wh - `ai.openclawfoundation.app.watchkitapp` - `ai.openclawfoundation.app.watchkitapp.extension` - Use `pnpm ios:release:signing:setup` for the initial portal setup, then `ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted signing assets to the shared private repo. + Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo. 4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`. diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index de42d0962f0..4b2b32439f7 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -90,10 +90,11 @@ Pinned iOS version `2026.4.10` maps to: - prepares App Store distribution signing and bundle settings against the pinned iOS version - `scripts/ios-release-signing.mjs` - validates the checked-in App Store signing manifest - - creates or verifies Developer Portal bundle IDs, capabilities, certificates, and profiles through `asc` - - syncs encrypted signing assets with the private shared signing repo + - renders the temporary release xcconfig profile pins - `apps/ios/fastlane/Fastfile` - resolves version metadata from the pinned iOS helper + - creates or verifies Developer Portal bundle IDs/services through Fastlane `produce` + - syncs encrypted App Store signing assets with Fastlane `match` - increments App Store Connect build numbers for the pinned short version - uploads screenshots and release notes before archiving a release build diff --git a/apps/ios/fastlane/.env.example b/apps/ios/fastlane/.env.example index 77c9fb0cf6b..72075c54acf 100644 --- a/apps/ios/fastlane/.env.example +++ b/apps/ios/fastlane/.env.example @@ -1,17 +1,26 @@ # App Store Connect API key (pick one approach) # # Recommended (use the downloaded .p8 directly): -# ASC_KEY_ID=XXXXXXXXXX -# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX +# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# APP_STORE_CONNECT_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 # # Or (JSON key file): # APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json # # Or: -# ASC_KEY_ID=XXXXXXXXXX -# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# ASC_KEY_CONTENT=BASE64_P8_CONTENT +# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX +# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# APP_STORE_CONNECT_KEY_CONTENT=BASE64_P8_CONTENT +# +# Or (macOS Keychain, recommended for maintainer machines): +# APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX +# APP_STORE_CONNECT_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key +# APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=your-macos-user + +# Fastlane match signing repo encryption +# MATCH_PASSWORD=... # Code signing # IOS_DEVELOPMENT_TEAM=XXXXXXXXXX diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile index 7059a183bb7..da06332e587 100644 --- a/apps/ios/fastlane/Appfile +++ b/apps/ios/fastlane/Appfile @@ -4,12 +4,14 @@ app_identifier("ai.openclawfoundation.app") # Provide either: # - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended) # or: -# - ASC_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with ASC_KEY_ID and ASC_ISSUER_ID -# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content) -# - ASC_KEY_ID and ASC_ISSUER_ID plus Keychain fallback: -# ASC_KEYCHAIN_SERVICE (default: openclaw-asc-key) -# ASC_KEYCHAIN_ACCOUNT (default: USER/LOGNAME) +# - APP_STORE_CONNECT_KEY_PATH=/path/to/AuthKey_XXXXXX.p8 with +# APP_STORE_CONNECT_KEY_ID and APP_STORE_CONNECT_ISSUER_ID +# - APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, and +# APP_STORE_CONNECT_KEY_CONTENT (base64 or raw p8 content) +# - APP_STORE_CONNECT_KEY_ID and APP_STORE_CONNECT_ISSUER_ID plus Keychain fallback: +# APP_STORE_CONNECT_KEYCHAIN_SERVICE (default: openclaw-app-store-connect-key) +# APP_STORE_CONNECT_KEYCHAIN_ACCOUNT (default: USER/LOGNAME) # # Optional deliver app lookup overrides: -# - ASC_APP_IDENTIFIER (bundle ID) -# - ASC_APP_ID (numeric App Store Connect app ID) +# - APP_STORE_CONNECT_APP_IDENTIFIER (bundle ID) +# - APP_STORE_CONNECT_APP_ID (numeric App Store Connect app ID) diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index cfb2829325c..d233734097d 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -9,6 +9,7 @@ require "cgi" default_platform(:ios) APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app" +DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key" DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)" WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode" @@ -349,7 +350,7 @@ def maybe_decode_hex_keychain_secret(value) beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret endPemMarker = %w[END PRIVATE KEY].join(" ") if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker) - UI.message("Decoded hex-encoded ASC key content from Keychain.") + UI.message("Decoded hex-encoded App Store Connect key content from Keychain.") return decoded end rescue StandardError @@ -359,11 +360,11 @@ def maybe_decode_hex_keychain_secret(value) candidate end -def read_asc_key_content_from_keychain - service = ENV["ASC_KEYCHAIN_SERVICE"] - service = "openclaw-asc-key" unless env_present?(service) +def read_app_store_connect_key_content_from_keychain + service = ENV["APP_STORE_CONNECT_KEYCHAIN_SERVICE"] + service = DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE unless env_present?(service) - account = ENV["ASC_KEYCHAIN_ACCOUNT"] + account = ENV["APP_STORE_CONNECT_KEYCHAIN_ACCOUNT"] account = ENV["USER"] unless env_present?(account) account = ENV["LOGNAME"] unless env_present?(account) return nil unless env_present?(account) @@ -385,7 +386,7 @@ def read_asc_key_content_from_keychain key_content = maybe_decode_hex_keychain_secret(key_content) return nil unless env_present?(key_content) - UI.message("Loaded ASC key content from Keychain service '#{service}' (account '#{account}').") + UI.message("Loaded App Store Connect key content from Keychain service '#{service}' (account '#{account}').") key_content rescue Errno::ENOENT nil @@ -423,8 +424,16 @@ def app_store_signing_manifest JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json"))) end +def app_store_signing_targets + app_store_signing_manifest.fetch("targets") +end + +def app_store_bundle_identifiers + app_store_signing_targets.map { |target| target.fetch("bundleId") } +end + def app_store_provisioning_profiles - app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles| + app_store_signing_targets.each_with_object({}) do |target, profiles| profiles[target.fetch("bundleId")] = target.fetch("profileName") end end @@ -467,8 +476,114 @@ def write_app_store_export_options(path) PLIST end +def produce_services_for_target(target) + services = {} + if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS") + services[:push_notification] = "on" + end + services +end + +def ensure_release_bundle_ids! + manifest = app_store_signing_manifest + app_store_signing_targets.each do |target| + options = { + app_identifier: target.fetch("bundleId"), + app_name: target.fetch("displayName"), + skip_itc: true, + team_id: manifest.fetch("teamId") + } + services = produce_services_for_target(target) + options[:enable_services] = services unless services.empty? + produce(**options) + unless services.empty? + modify_services( + app_identifier: target.fetch("bundleId"), + services: services, + team_id: manifest.fetch("teamId") + ) + end + end +end + +def app_store_match_options(readonly:, target:, api_key:) + manifest = app_store_signing_manifest + options = { + type: manifest.fetch("profileType"), + app_identifier: target.fetch("bundleId"), + profile_name: target.fetch("profileName"), + git_url: manifest.fetch("signingRepo"), + git_branch: manifest.fetch("signingBranch"), + platform: "ios", + team_id: manifest.fetch("teamId"), + readonly: readonly + } + options[:api_key] = api_key if api_key + options +end + +def validate_match_profile_mapping!(target) + bundle_id = target.fetch("bundleId") + expected_profile_name = target.fetch("profileName") + actual = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {} + actual_profile_name = actual[bundle_id] + return if actual_profile_name == expected_profile_name + + UI.user_error!( + "Fastlane match did not resolve the pinned App Store profile for #{bundle_id}: expected #{expected_profile_name}, got #{actual_profile_name || "no match output"}" + ) +end + +def match_profile_env_key(target, suffix) + ["sigh", target.fetch("bundleId"), app_store_signing_manifest.fetch("profileType"), suffix].join("_") +end + +def profile_plist_value(profile_path, key_path) + Tempfile.create(["openclaw-profile", ".plist"]) do |file| + stdout, stderr, status = Open3.capture3("security", "cms", "-D", "-i", profile_path) + unless status.success? + detail = stderr.to_s.strip + detail = stdout.to_s.strip if detail.empty? + UI.user_error!("Failed to decode provisioning profile #{profile_path}: #{detail}") + end + + file.write(stdout) + file.flush + value, _plist_stderr, plist_status = Open3.capture3("/usr/libexec/PlistBuddy", "-c", "Print:#{key_path}", file.path) + return nil unless plist_status.success? + + value.to_s.strip + end +end + +def validate_match_profile_capabilities!(target) + capabilities = target.fetch("capabilities") + return if capabilities.empty? + + profile_path = ENV[match_profile_env_key(target, "profile-path")] + UI.user_error!("Fastlane match did not expose an installed profile path for #{target.fetch("bundleId")}.") unless env_present?(profile_path) + + if capabilities.include?("PUSH_NOTIFICATIONS") + aps_environment = profile_plist_value(profile_path, "Entitlements:aps-environment") + if aps_environment != "production" + UI.user_error!( + "Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production push entitlement; expected aps-environment=production, got #{aps_environment || "missing"}." + ) + end + end +end + +def sync_app_store_signing!(readonly:) + api_key = readonly ? nil : app_store_connect_api_key_config + app_store_signing_targets.each do |target| + match(**app_store_match_options(readonly: readonly, target: target, api_key: api_key)) + validate_match_profile_mapping!(target) + validate_match_profile_capabilities!(target) + end +end + def release_signing_check! - sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"])) + sync_app_store_signing!(readonly: true) end def release_notes_path @@ -563,7 +678,7 @@ def resolve_release_build_number(api_key:, short_version:) next_build.to_s end -def release_build_number_needs_asc_auth? +def release_build_number_needs_app_store_connect_auth? explicit = ENV["IOS_RELEASE_BUILD_NUMBER"] !env_present?(explicit) end @@ -640,58 +755,58 @@ def build_app_store_release(context) } end -platform :ios do - private_lane :asc_api_key do - load_env_file(File.join(__dir__, ".env")) - clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") - clear_empty_env_var("ASC_KEY_PATH") - clear_empty_env_var("ASC_KEY_CONTENT") +def app_store_connect_api_key_config + load_env_file(File.join(__dir__, ".env")) + clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") + clear_empty_env_var("APP_STORE_CONNECT_KEY_PATH") + clear_empty_env_var("APP_STORE_CONNECT_KEY_CONTENT") - api_key = nil + api_key = nil - key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] - if env_present?(key_path) - api_key = app_store_connect_api_key(path: key_path) + key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] + if env_present?(key_path) + api_key = app_store_connect_api_key(path: key_path) + else + p8_path = ENV["APP_STORE_CONNECT_KEY_PATH"] + if env_present?(p8_path) + key_id = ENV["APP_STORE_CONNECT_KEY_ID"] + issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"] + UI.user_error!("Missing APP_STORE_CONNECT_KEY_ID or APP_STORE_CONNECT_ISSUER_ID for APP_STORE_CONNECT_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) } + + api_key = app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_filepath: p8_path + ) else - p8_path = ENV["ASC_KEY_PATH"] - if env_present?(p8_path) - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) } + key_id = ENV["APP_STORE_CONNECT_KEY_ID"] + issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"] + key_content = ENV["APP_STORE_CONNECT_KEY_CONTENT"] + key_content = read_app_store_connect_key_content_from_keychain unless env_present?(key_content) - api_key = app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_filepath: p8_path - ) - else - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - key_content = ENV["ASC_KEY_CONTENT"] - key_content = read_asc_key_content_from_keychain unless env_present?(key_content) + UI.user_error!( + "Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), APP_STORE_CONNECT_KEY_PATH (p8), or APP_STORE_CONNECT_KEY_ID/APP_STORE_CONNECT_ISSUER_ID with APP_STORE_CONNECT_KEY_CONTENT (or Keychain via APP_STORE_CONNECT_KEYCHAIN_SERVICE/APP_STORE_CONNECT_KEYCHAIN_ACCOUNT)." + ) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) } - UI.user_error!( - "Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), ASC_KEY_PATH (p8), or ASC_KEY_ID/ASC_ISSUER_ID with ASC_KEY_CONTENT (or Keychain via ASC_KEYCHAIN_SERVICE/ASC_KEYCHAIN_ACCOUNT)." - ) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) } + is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true - is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true - - api_key = app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_content: key_content, - is_key_content_base64: is_base64 - ) - end + api_key = app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_content: key_content, + is_key_content_base64: is_base64 + ) end - - api_key end + api_key +end + +platform :ios do private_lane :prepare_app_store_context do |options| require_api_key = options[:require_api_key] == true - needs_api_key = require_api_key || release_build_number_needs_asc_auth? - api_key = needs_api_key ? asc_api_key : nil + needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth? + api_key = needs_api_key ? app_store_connect_api_key_config : nil sync_ios_versioning! version_metadata = read_ios_version_metadata version = version_metadata[:version] @@ -708,6 +823,37 @@ platform :ios do } end + desc "Print the App Store signing plan" + lane :signing_plan do + sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"])) + end + + desc "Check local App Store signing assets through Fastlane match" + lane :signing_check do + sync_app_store_signing!(readonly: true) + UI.success("Fastlane match App Store signing assets are available locally.") + end + + desc "Create Developer Portal bundle IDs/services and sync App Store signing assets" + lane :signing_setup do + ensure_release_bundle_ids! + sync_app_store_signing!(readonly: false) + UI.success("Fastlane App Store signing setup is complete.") + end + + desc "Pull encrypted App Store signing assets from the shared Fastlane match repo" + lane :signing_sync_pull do + sync_app_store_signing!(readonly: true) + UI.success("Pulled Fastlane match App Store signing assets.") + end + + desc "Create or refresh encrypted App Store signing assets in the shared Fastlane match repo" + lane :signing_sync_push do + ensure_release_bundle_ids! + sync_app_store_signing!(readonly: false) + UI.success("Pushed Fastlane match App Store signing assets.") + end + desc "Build an App Store distribution archive locally without uploading" lane :app_store_archive do context = prepare_app_store_context(require_api_key: false) @@ -765,10 +911,10 @@ platform :ios do lane :metadata do sync_ios_versioning! version_metadata = read_ios_version_metadata - api_key = asc_api_key + api_key = app_store_connect_api_key_config clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") - app_identifier = ENV["ASC_APP_IDENTIFIER"] - app_id = ENV["ASC_APP_ID"] + app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"] + app_id = ENV["APP_STORE_CONNECT_APP_ID"] app_identifier = nil unless env_present?(app_identifier) app_id = nil unless env_present?(app_id) @@ -849,7 +995,7 @@ platform :ios do desc "Validate App Store Connect API auth" lane :auth_check do - asc_api_key + app_store_connect_api_key_config UI.success("App Store Connect API auth loaded successfully.") end end diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 15905d50480..e8e47963938 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -14,7 +14,7 @@ Create an App Store Connect API key: Recommended (macOS): store the private key in Keychain and write non-secret vars: ```bash -scripts/ios-asc-keychain-setup.sh \ +scripts/ios-app-store-connect-keychain-setup.sh \ --key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \ --issuer-id YOUR_ISSUER_ID \ --write-env @@ -23,10 +23,10 @@ scripts/ios-asc-keychain-setup.sh \ This writes these auth variables in `apps/ios/fastlane/.env`: ```bash -ASC_KEY_ID=YOUR_KEY_ID -ASC_ISSUER_ID=YOUR_ISSUER_ID -ASC_KEYCHAIN_SERVICE=openclaw-asc-key -ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME +APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID +APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID +APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key +APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME ``` Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional release-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds. @@ -34,17 +34,17 @@ Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle): ```bash -ASC_APP_IDENTIFIER=ai.openclawfoundation.app +APP_STORE_CONNECT_APP_IDENTIFIER=ai.openclawfoundation.app # or -ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID +APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID ``` File-based fallback (CI/non-macOS): ```bash -ASC_KEY_ID=YOUR_KEY_ID -ASC_ISSUER_ID=YOUR_ISSUER_ID -ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID +APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID +APP_STORE_CONNECT_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 ``` Code signing variable (optional in `.env`): @@ -55,7 +55,7 @@ IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID Tip: run `scripts/ios-team-id.sh --require-canonical` from repo root to verify the canonical OpenClaw iOS team (`FWJYW4S8P8`) is available locally. Fastlane uses the same canonical-only path when `IOS_DEVELOPMENT_TEAM` is missing, and rejects non-canonical teams for release archives. -App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`. +App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`, and Fastlane `match` owns the encrypted signing repo and branch named there. One-time or rotation setup: @@ -65,14 +65,16 @@ pnpm ios:release:signing:check pnpm ios:release:signing:setup ``` +`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`. + Shared encrypted signing storage: ```bash -ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push -ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull +MATCH_PASSWORD=... pnpm ios:release:signing:sync:push +MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull ``` -The signing repo is private and encrypted. Store `ASC_MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` writes decrypted assets under `apps/ios/build/signing/`; import the distribution certificate/private key into Keychain before archiving. +The signing repo is private and encrypted. Store `MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` uses Fastlane `match` to decrypt, install profiles, and import the distribution signing identity into the local Keychain. For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`. @@ -83,12 +85,12 @@ cd apps/ios fastlane ios auth_check ``` -ASC auth is only required when: +App Store Connect API auth is required when: - uploading to App Store Connect - auto-resolving the next build number from App Store Connect -If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need ASC auth. +If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need App Store Connect API auth. Archive locally without upload: @@ -119,14 +121,14 @@ fastlane ios release_upload Maintainer recovery path for a fresh clone on the same Mac: -1. Reuse the existing Keychain-backed ASC key on that machine. +1. Reuse the existing Keychain-backed App Store Connect key on that machine. 2. Restore or recreate `apps/ios/fastlane/.env` so it contains the non-secret variables: ```bash -ASC_KEY_ID=YOUR_KEY_ID -ASC_ISSUER_ID=YOUR_ISSUER_ID -ASC_KEYCHAIN_SERVICE=openclaw-asc-key -ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME +APP_STORE_CONNECT_KEY_ID=YOUR_KEY_ID +APP_STORE_CONNECT_ISSUER_ID=YOUR_ISSUER_ID +APP_STORE_CONNECT_KEYCHAIN_SERVICE=openclaw-app-store-connect-key +APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME ``` 3. Re-run auth validation: diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 59d64247f47..1963420977d 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata ```bash cd apps/ios -ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \ +APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \ DELIVER_METADATA=1 fastlane ios metadata ``` @@ -31,14 +31,14 @@ DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata The `ios metadata` lane uses App Store Connect API key auth from `apps/ios/fastlane/.env`: - Keychain-backed (recommended on macOS): - - `ASC_KEY_ID` - - `ASC_ISSUER_ID` - - `ASC_KEYCHAIN_SERVICE` (default: `openclaw-asc-key`) - - `ASC_KEYCHAIN_ACCOUNT` (default: current user) + - `APP_STORE_CONNECT_KEY_ID` + - `APP_STORE_CONNECT_ISSUER_ID` + - `APP_STORE_CONNECT_KEYCHAIN_SERVICE` (default: `openclaw-app-store-connect-key`) + - `APP_STORE_CONNECT_KEYCHAIN_ACCOUNT` (default: current user) - File/path fallback: - - `ASC_KEY_ID` - - `ASC_ISSUER_ID` - - `ASC_KEY_PATH` + - `APP_STORE_CONNECT_KEY_ID` + - `APP_STORE_CONNECT_ISSUER_ID` + - `APP_STORE_CONNECT_KEY_PATH` Or set `APP_STORE_CONNECT_API_KEY_PATH`. @@ -51,8 +51,8 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`. - The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review. - `privacy_url.txt` is set to `https://openclaw.ai/privacy`. - If app lookup fails in `deliver`, set one of: - - `ASC_APP_IDENTIFIER` (bundle ID) - - `ASC_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps//...` URL) + - `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID) + - `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps//...` URL) - For first app versions, include review contact files under `metadata/review_information/`: - `first_name.txt` - `last_name.txt` diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 25e9741d07e..3de1e7b3df0 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -193,8 +193,8 @@ export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)" ``` These are gateway-host runtime env vars, not Fastlane settings. `apps/ios/fastlane/.env` only stores -App Store Connect / TestFlight auth such as `ASC_KEY_ID` and `ASC_ISSUER_ID`; it does not configure -direct APNs delivery for local iOS builds. +App Store Connect / TestFlight auth such as `APP_STORE_CONNECT_KEY_ID` and +`APP_STORE_CONNECT_ISSUER_ID`; it does not configure direct APNs delivery for local iOS builds. Recommended gateway-host storage: diff --git a/package.json b/package.json index 052ff006318..feed4bd4196 100644 --- a/package.json +++ b/package.json @@ -1556,11 +1556,11 @@ "ios:release": "bash scripts/ios-release.sh", "ios:release:archive": "bash scripts/ios-release-archive.sh", "ios:release:prepare": "bash scripts/ios-release-prepare.sh", - "ios:release:signing:check": "node scripts/ios-release-signing.mjs --mode check", - "ios:release:signing:plan": "node scripts/ios-release-signing.mjs --mode plan", - "ios:release:signing:setup": "node scripts/ios-release-signing.mjs --mode setup", - "ios:release:signing:sync:pull": "node scripts/ios-release-signing.mjs --mode sync-pull", - "ios:release:signing:sync:push": "node scripts/ios-release-signing.mjs --mode sync-push", + "ios:release:signing:check": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_check'", + "ios:release:signing:plan": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_plan'", + "ios:release:signing:setup": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_setup'", + "ios:release:signing:sync:pull": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_sync_pull'", + "ios:release:signing:sync:push": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_sync_push'", "ios:release:upload": "bash scripts/ios-release-upload.sh", "ios:run": "bash scripts/ios-run.sh", "ios:screenshots": "bash scripts/ios-screenshots.sh", diff --git a/scripts/ios-asc-keychain-setup.sh b/scripts/ios-app-store-connect-keychain-setup.sh similarity index 78% rename from scripts/ios-asc-keychain-setup.sh rename to scripts/ios-app-store-connect-keychain-setup.sh index 125a3c54b82..ed6244d48d3 100755 --- a/scripts/ios-asc-keychain-setup.sh +++ b/scripts/ios-app-store-connect-keychain-setup.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-asc-keychain-setup.sh --key-path /path/to/AuthKey_XXXXXX.p8 --issuer-id [options] + scripts/ios-app-store-connect-keychain-setup.sh --key-path /path/to/AuthKey_XXXXXX.p8 --issuer-id [options] Required: --key-path Path to App Store Connect API key (.p8) @@ -12,14 +12,14 @@ Required: Optional: --key-id API key ID (auto-detected from AuthKey_.p8 if omitted) - --service Keychain service name (default: openclaw-asc-key) + --service Keychain service name (default: openclaw-app-store-connect-key) --account Keychain account name (default: $USER or $LOGNAME) --write-env Upsert non-secret env vars into apps/ios/fastlane/.env --env-file Override env file path used with --write-env -h, --help Show this help Example: - scripts/ios-asc-keychain-setup.sh \ + scripts/ios-app-store-connect-keychain-setup.sh \ --key-path "$HOME/keys/AuthKey_ABC1234567.p8" \ --issuer-id "00000000-1111-2222-3333-444444444444" \ --write-env @@ -69,7 +69,7 @@ delete_env_line() { KEY_PATH="" KEY_ID="" ISSUER_ID="" -SERVICE="openclaw-asc-key" +SERVICE="openclaw-app-store-connect-key" ACCOUNT="${USER:-${LOGNAME:-}}" WRITE_ENV=0 ENV_FILE="" @@ -157,13 +157,13 @@ security add-generic-password \ -w "$KEY_CONTENT" \ -U >/dev/null -echo "Stored ASC API private key in macOS Keychain (service='$SERVICE', account='$ACCOUNT')." +echo "Stored App Store Connect API private key in macOS Keychain (service='$SERVICE', account='$ACCOUNT')." echo echo "Export these vars for Fastlane:" -echo "ASC_KEY_ID=$KEY_ID" -echo "ASC_ISSUER_ID=$ISSUER_ID" -echo "ASC_KEYCHAIN_SERVICE=$SERVICE" -echo "ASC_KEYCHAIN_ACCOUNT=$ACCOUNT" +echo "APP_STORE_CONNECT_KEY_ID=$KEY_ID" +echo "APP_STORE_CONNECT_ISSUER_ID=$ISSUER_ID" +echo "APP_STORE_CONNECT_KEYCHAIN_SERVICE=$SERVICE" +echo "APP_STORE_CONNECT_KEYCHAIN_ACCOUNT=$ACCOUNT" if [[ "$WRITE_ENV" -eq 1 ]]; then if [[ -z "$ENV_FILE" ]]; then @@ -173,13 +173,13 @@ if [[ "$WRITE_ENV" -eq 1 ]]; then mkdir -p "$(dirname "$ENV_FILE")" touch "$ENV_FILE" - upsert_env_line "$ENV_FILE" "ASC_KEY_ID" "$KEY_ID" - upsert_env_line "$ENV_FILE" "ASC_ISSUER_ID" "$ISSUER_ID" - upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_SERVICE" "$SERVICE" - upsert_env_line "$ENV_FILE" "ASC_KEYCHAIN_ACCOUNT" "$ACCOUNT" + upsert_env_line "$ENV_FILE" "APP_STORE_CONNECT_KEY_ID" "$KEY_ID" + upsert_env_line "$ENV_FILE" "APP_STORE_CONNECT_ISSUER_ID" "$ISSUER_ID" + upsert_env_line "$ENV_FILE" "APP_STORE_CONNECT_KEYCHAIN_SERVICE" "$SERVICE" + upsert_env_line "$ENV_FILE" "APP_STORE_CONNECT_KEYCHAIN_ACCOUNT" "$ACCOUNT" # Remove file/path based keys so Keychain is used by default. - delete_env_line "$ENV_FILE" "ASC_KEY_PATH" - delete_env_line "$ENV_FILE" "ASC_KEY_CONTENT" + delete_env_line "$ENV_FILE" "APP_STORE_CONNECT_KEY_PATH" + delete_env_line "$ENV_FILE" "APP_STORE_CONNECT_KEY_CONTENT" delete_env_line "$ENV_FILE" "APP_STORE_CONNECT_API_KEY_PATH" echo diff --git a/scripts/ios-release-signing.mjs b/scripts/ios-release-signing.mjs index 1cf0ad47cff..dc2baf7bac8 100755 --- a/scripts/ios-release-signing.mjs +++ b/scripts/ios-release-signing.mjs @@ -1,34 +1,23 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import crypto from "node:crypto"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultManifestPath = path.join(rootDir, "apps", "ios", "Config", "AppStoreSigning.json"); -const generatedSigningDir = path.join(rootDir, "apps", "ios", "build", "signing"); -const generatedProfileDir = path.join(generatedSigningDir, "profiles"); -const generatedKeyPath = path.join(generatedSigningDir, "OpenClaw-IOS-Distribution.key"); -const generatedCsrPath = path.join(generatedSigningDir, "OpenClaw-IOS-Distribution.csr"); -const generatedCertificatePath = path.join(generatedSigningDir, "OpenClaw-IOS-Distribution.cer"); function usage() { process.stdout.write(`Usage: scripts/ios-release-signing.mjs --mode plan scripts/ios-release-signing.mjs --mode xcconfig scripts/ios-release-signing.mjs --mode check - scripts/ios-release-signing.mjs --mode setup - scripts/ios-release-signing.mjs --mode sync-push - scripts/ios-release-signing.mjs --mode sync-pull Options: --manifest PATH Signing manifest path. Defaults to apps/ios/Config/AppStoreSigning.json. -The check/setup/sync modes require asc authentication. sync modes require ASC_MATCH_PASSWORD -or an asc --password value supplied through the environment. +Fastlane owns App Store signing setup and encrypted sync. This helper only +validates the checked-in manifest and renders local release xcconfig settings. `); } @@ -61,7 +50,7 @@ function parseArgs(argv) { function readManifest(manifestPath) { const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - const requiredStrings = ["teamId", "signingRepo", "certificateType", "profileType"]; + const requiredStrings = ["teamId", "signingRepo", "signingBranch", "profileType"]; for (const key of requiredStrings) { if (typeof parsed[key] !== "string" || parsed[key].trim() === "") { throw new Error(`Signing manifest missing ${key}.`); @@ -92,689 +81,6 @@ function readManifest(manifestPath) { return parsed; } -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? rootDir, - encoding: "utf8", - env: process.env, - stdio: options.stdio ?? ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - const stderr = result.stderr?.trim(); - const stdout = result.stdout?.trim(); - const detail = stderr || stdout || `${command} exited with status ${result.status}`; - if ( - options.allowAlreadyExists && - detail.includes("The specified item already exists in the keychain") - ) { - return result.stdout ?? ""; - } - throw new Error(detail); - } - - return result.stdout ?? ""; -} - -function runAscJson(args) { - const stdout = run("asc", [...args, "--output", "json"]); - const trimmed = stdout.trim(); - if (!trimmed) { - return {}; - } - - try { - return JSON.parse(trimmed); - } catch (error) { - throw new Error( - `asc returned non-JSON output for ${args.join(" ")}: ${trimmed.slice(0, 500)}`, - { - cause: error, - }, - ); - } -} - -function runAsc(args, options = {}) { - return run("asc", args, options); -} - -function records(payload) { - if (Array.isArray(payload)) { - return payload; - } - if (Array.isArray(payload.data)) { - return payload.data; - } - if (Array.isArray(payload.results)) { - return payload.results; - } - if (Array.isArray(payload.items)) { - return payload.items; - } - return []; -} - -function recordFromPayload(payload) { - if (Array.isArray(payload)) { - return payload.find((record) => recordId(record)) ?? null; - } - if (payload?.data && !Array.isArray(payload.data) && recordId(payload.data)) { - return payload.data; - } - if (recordId(payload)) { - return payload; - } - return records(payload).find((record) => recordId(record)) ?? null; -} - -function recordId(record) { - return String(record?.id ?? record?.attributes?.id ?? ""); -} - -function recordAttr(record, key) { - const attributes = record?.attributes ?? {}; - return record?.[key] ?? attributes[key]; -} - -function profileState(record) { - return String(recordAttr(record, "profileState") ?? recordAttr(record, "state") ?? ""); -} - -function profileName(record) { - return String(recordAttr(record, "name") ?? ""); -} - -function profileContent(record) { - return String(recordAttr(record, "profileContent") ?? ""); -} - -function certificateName(record) { - return String( - recordAttr(record, "name") ?? recordAttr(record, "serialNumber") ?? recordId(record), - ); -} - -function certificateContent(record) { - return String(recordAttr(record, "certificateContent") ?? ""); -} - -function certificateFingerprint(record) { - const content = certificateContent(record); - if (!content) { - return ""; - } - - return crypto - .createHash("sha1") - .update(Buffer.from(content, "base64")) - .digest("hex") - .toUpperCase(); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - -function ensureAscAvailable() { - try { - run("asc", ["--help"]); - } catch (error) { - throw new Error(`asc CLI is required for iOS release signing setup: ${error.message}`, { - cause: error, - }); - } -} - -function bundleIdentifier(record) { - return String(recordAttr(record, "identifier") ?? recordAttr(record, "bundleId") ?? ""); -} - -function capabilityType(record) { - return String(recordAttr(record, "capabilityType") ?? recordAttr(record, "capability") ?? ""); -} - -function listBundleIds() { - return records(runAscJson(["bundle-ids", "list", "--paginate"])); -} - -function listProfiles(manifest) { - return records( - runAscJson([ - "profiles", - "list", - "--profile-type", - manifest.profileType, - "--profile-state", - "ACTIVE,INVALID", - "--paginate", - ]), - ); -} - -function listCertificates(manifest) { - return records( - runAscJson([ - "certificates", - "list", - "--certificate-type", - manifest.certificateType, - "--paginate", - ]), - ); -} - -function listLocalProfiles(manifest) { - if (process.platform !== "darwin") { - return []; - } - return records(runAscJson(["profiles", "local", "list", "--team-id", manifest.teamId])); -} - -function findBundle(bundleIds, target) { - return bundleIds.find((bundle) => bundleIdentifier(bundle) === target.bundleId) ?? null; -} - -function findProfile(profiles, target) { - return ( - profiles.find((profile) => { - return profileName(profile) === target.profileName && profileState(profile) !== "INVALID"; - }) ?? null - ); -} - -function profileCertificateFingerprints(profile) { - const xml = profileXml(profile); - if (!xml) { - return new Set(); - } - - const developerCertificates = - /DeveloperCertificates<\/key>\s*([\s\S]*?)<\/array>/u.exec(xml)?.[1] ?? ""; - const fingerprints = new Set(); - for (const match of developerCertificates.matchAll(/([\s\S]*?)<\/data>/gu)) { - const der = match[1].replace(/\s+/gu, ""); - if (der) { - fingerprints.add( - crypto.createHash("sha1").update(Buffer.from(der, "base64")).digest("hex").toUpperCase(), - ); - } - } - return fingerprints; -} - -function profileXml(profile) { - const content = profileContent(profile); - if (!content) { - return ""; - } - try { - return Buffer.from(content, "base64").toString("utf8"); - } catch { - return ""; - } -} - -function profileIncludesCertificate(profile, certificateRecord) { - const fingerprint = certificateFingerprint(certificateRecord); - return fingerprint !== "" && profileCertificateFingerprints(profile).has(fingerprint); -} - -function profileHasEntitlement(profile, entitlement) { - const xml = profileXml(profile); - const entitlements = /Entitlements<\/key>\s*([\s\S]*?)<\/dict>/u.exec(xml)?.[1] ?? ""; - return new RegExp(`${escapeRegExp(entitlement)}`, "u").test(entitlements); -} - -function profileSupportsCapabilities(profile, target) { - for (const capability of target.capabilities) { - if (capability === "PUSH_NOTIFICATIONS" && !profileHasEntitlement(profile, "aps-environment")) { - return false; - } - } - return true; -} - -function findInstalledProfile(localProfiles, manifest, target) { - return ( - localProfiles.find((profile) => { - return ( - profileName(profile) === target.profileName && - bundleIdentifier(profile) === target.bundleId && - String(recordAttr(profile, "teamId") ?? "") === manifest.teamId && - recordAttr(profile, "expired") !== true - ); - }) ?? null - ); -} - -function listCapabilities(bundleRecord, target) { - const bundle = recordId(bundleRecord) || target.bundleId; - return records( - runAscJson(["bundle-ids", "capabilities", "list", "--bundle", bundle, "--paginate"]), - ); -} - -function createBundle(target) { - process.stderr.write(`Creating bundle ID ${target.bundleId}\n`); - return runAscJson([ - "bundle-ids", - "create", - "--identifier", - target.bundleId, - "--name", - target.displayName, - "--platform", - target.platform, - ]); -} - -function addMissingCapabilities(bundleRecord, target) { - const existing = new Set(listCapabilities(bundleRecord, target).map(capabilityType)); - for (const capability of target.capabilities) { - if (existing.has(capability)) { - continue; - } - process.stderr.write(`Adding ${capability} capability to ${target.bundleId}\n`); - runAscJson([ - "bundle-ids", - "capabilities", - "add", - "--bundle", - recordId(bundleRecord) || target.bundleId, - "--capability", - capability, - ]); - } -} - -function ensureDistributionCertificate(manifest) { - const existing = listCertificates(manifest); - const certificateWithLocalKey = findCertificateWithLocalKey(existing, manifest); - if (certificateWithLocalKey) { - process.stderr.write( - `Using ${manifest.certificateType} certificate ${certificateName(certificateWithLocalKey)} with local private key\n`, - ); - return certificateWithLocalKey; - } - - const certificate = - process.platform === "darwin" ? null : existing.find((candidate) => recordId(candidate)); - if (certificate) { - process.stderr.write( - `Using ${manifest.certificateType} certificate ${certificateName(certificate)}\n`, - ); - return certificate; - } - - fs.mkdirSync(generatedSigningDir, { recursive: true, mode: 0o700 }); - process.stderr.write(`Creating ${manifest.certificateType} certificate and local private key\n`); - const existingIds = new Set(existing.map(recordId).filter(Boolean)); - const createPayload = runAscJson([ - "certificates", - "create", - "--certificate-type", - manifest.certificateType, - "--generate-csr", - "--key-out", - generatedKeyPath, - "--csr-out", - generatedCsrPath, - ]); - - const createdFromResponse = recordFromPayload(createPayload); - const createdId = recordId(createdFromResponse); - const refreshed = listCertificates(manifest); - const created = - (createdId ? refreshed.find((candidate) => recordId(candidate) === createdId) : null) ?? - createdFromResponse ?? - refreshed.find((candidate) => { - const candidateId = recordId(candidate); - return candidateId && !existingIds.has(candidateId); - }); - if (!created) { - throw new Error( - `Created ${manifest.certificateType} certificate, but could not resolve its certificate ID.`, - ); - } - process.stderr.write( - `Created ${manifest.certificateType} certificate ${certificateName(created)}\n`, - ); - return created; -} - -function findCertificateWithLocalKey(certificates, manifest) { - const localFingerprints = localDistributionIdentityFingerprints(manifest); - if (localFingerprints.size === 0) { - return null; - } - return ( - certificates.find((candidate) => { - const fingerprint = certificateFingerprint(candidate); - return fingerprint && localFingerprints.has(fingerprint); - }) ?? null - ); -} - -function localDistributionIdentityFingerprints(manifest) { - if (process.platform !== "darwin") { - return new Set(); - } - - let output; - try { - output = run("security", ["find-identity", "-p", "codesigning", "-v"]); - } catch { - return new Set(); - } - - const fingerprints = new Set(); - for (const line of output.split("\n")) { - if (!line.includes(manifest.teamId) || !/(Apple|iPhone) Distribution:/.test(line)) { - continue; - } - const match = /\b([0-9A-Fa-f]{40})\b/u.exec(line); - if (match) { - fingerprints.add(match[1].toUpperCase()); - } - } - return fingerprints; -} - -function selectedDistributionIdentityPresent(certificateRecord) { - const fingerprint = certificateFingerprint(certificateRecord); - return ( - fingerprint !== "" && localDistributionIdentityFingerprints({ teamId: "" }).has(fingerprint) - ); -} - -function writeCertificate(certificateRecord) { - const content = certificateContent(certificateRecord); - if (!content) { - throw new Error( - `Certificate ${certificateName(certificateRecord)} is missing certificateContent.`, - ); - } - - fs.mkdirSync(generatedSigningDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(generatedCertificatePath, Buffer.from(content, "base64")); -} - -function importGeneratedSigningIdentity(manifest, certificateRecord) { - if (process.platform !== "darwin") { - process.stderr.write("Skipping Keychain import because this is not macOS.\n"); - return; - } - - if (selectedDistributionIdentityPresent(certificateRecord)) { - process.stderr.write( - "Selected Apple Distribution signing identity is already available in Keychain.\n", - ); - return; - } - - if (!fs.existsSync(generatedKeyPath)) { - throw new Error( - `No local private key found at apps/ios/build/signing/OpenClaw-IOS-Distribution.key. Pull shared signing assets or create a new distribution certificate before archiving.`, - ); - } - - writeCertificate(certificateRecord); - const keychainPath = path.join(os.homedir(), "Library", "Keychains", "login.keychain-db"); - process.stderr.write("Importing generated Apple Distribution identity into login Keychain\n"); - run( - "security", - [ - "import", - generatedKeyPath, - "-k", - keychainPath, - "-T", - "/usr/bin/codesign", - "-T", - "/usr/bin/security", - ], - { allowAlreadyExists: true }, - ); - run( - "security", - ["import", generatedCertificatePath, "-k", keychainPath, "-T", "/usr/bin/codesign"], - { - allowAlreadyExists: true, - }, - ); - - if (!selectedDistributionIdentityPresent(certificateRecord)) { - throw new Error( - "Imported distribution certificate/private key, but no Apple Distribution codesigning identity is visible in Keychain.", - ); - } -} - -function createProfile(manifest, target, bundleRecord, certificateRecord) { - process.stderr.write(`Creating profile ${target.profileName}\n`); - runAscJson([ - "profiles", - "create", - "--name", - target.profileName, - "--profile-type", - manifest.profileType, - "--bundle", - recordId(bundleRecord) || target.bundleId, - "--certificate", - recordId(certificateRecord), - ]); -} - -function deleteProfile(profileRecord) { - runAscJson(["profiles", "delete", "--id", recordId(profileRecord), "--confirm"]); -} - -function profilePath(target) { - return path.join( - generatedProfileDir, - `${target.profileName.replaceAll("/", "-")}.mobileprovision`, - ); -} - -function downloadAndInstallProfile(target, profileRecord) { - const outputPath = profilePath(target); - fs.mkdirSync(generatedProfileDir, { recursive: true }); - fs.rmSync(outputPath, { force: true }); - runAsc(["profiles", "download", "--id", recordId(profileRecord), "--output", outputPath]); - runAsc(["profiles", "local", "install", "--path", outputPath, "--force"]); - process.stderr.write(`Installed profile ${target.profileName}\n`); -} - -function checkSigning(manifest) { - ensureAscAvailable(); - const bundleIds = listBundleIds(); - const profiles = listProfiles(manifest); - const certificates = listCertificates(manifest); - const certificateWithLocalKey = findCertificateWithLocalKey(certificates, manifest); - const localProfiles = listLocalProfiles(manifest); - const missing = []; - - for (const target of manifest.targets) { - const bundle = findBundle(bundleIds, target); - if (!bundle) { - missing.push(`bundle ID ${target.bundleId}`); - continue; - } - - const existingCapabilities = new Set(listCapabilities(bundle, target).map(capabilityType)); - for (const capability of target.capabilities) { - if (!existingCapabilities.has(capability)) { - missing.push(`${target.bundleId} capability ${capability}`); - } - } - - const profile = findProfile(profiles, target); - if (!profile) { - missing.push(`profile ${target.profileName}`); - } else if ( - certificateWithLocalKey && - !profileIncludesCertificate(profile, certificateWithLocalKey) - ) { - missing.push(`profile ${target.profileName} with selected distribution certificate`); - } else if (!profileSupportsCapabilities(profile, target)) { - missing.push(`profile ${target.profileName} with required entitlements`); - } - if (process.platform === "darwin" && !findInstalledProfile(localProfiles, manifest, target)) { - missing.push(`installed profile ${target.profileName}`); - } - } - - if (certificates.length === 0) { - missing.push(`${manifest.certificateType} certificate`); - } else if (process.platform === "darwin" && !certificateWithLocalKey) { - missing.push(`${manifest.certificateType} certificate with matching local private key`); - } - - if (missing.length > 0) { - throw new Error(`iOS App Store signing is incomplete:\n- ${missing.join("\n- ")}`); - } - - process.stdout.write( - "iOS App Store signing assets are present and match apps/ios/Config/AppStoreSigning.json.\n", - ); -} - -function setupSigning(manifest) { - ensureAscAvailable(); - let bundleIds = listBundleIds(); - const certificate = ensureDistributionCertificate(manifest); - importGeneratedSigningIdentity(manifest, certificate); - - for (const target of manifest.targets) { - let bundle = findBundle(bundleIds, target); - if (!bundle) { - createBundle(target); - bundleIds = listBundleIds(); - bundle = findBundle(bundleIds, target); - } - if (!bundle) { - throw new Error(`Could not resolve bundle ID ${target.bundleId} after creation.`); - } - addMissingCapabilities(bundle, target); - } - - let profiles = listProfiles(manifest); - for (const target of manifest.targets) { - let profile = findProfile(profiles, target); - if ( - profile && - (!profileIncludesCertificate(profile, certificate) || - !profileSupportsCapabilities(profile, target)) - ) { - process.stderr.write( - `Replacing profile ${target.profileName} because it does not match the selected certificate or required entitlements\n`, - ); - deleteProfile(profile); - profiles = listProfiles(manifest); - profile = null; - } - if (!profile) { - const bundle = findBundle(bundleIds, target); - createProfile(manifest, target, bundle, certificate); - profiles = listProfiles(manifest); - profile = findProfile(profiles, target); - } - if (!profile) { - throw new Error(`Could not resolve profile ${target.profileName} after creation.`); - } - if (!profileIncludesCertificate(profile, certificate)) { - throw new Error( - `Profile ${target.profileName} does not include the selected distribution certificate.`, - ); - } - if (!profileSupportsCapabilities(profile, target)) { - throw new Error(`Profile ${target.profileName} does not include the required entitlements.`); - } - downloadAndInstallProfile(target, profile); - } - - process.stdout.write("iOS App Store signing setup is complete.\n"); - process.stdout.write( - "Run `pnpm ios:release:signing:sync:push` to encrypt the assets into the shared signing repo.\n", - ); -} - -function requireSyncPassword() { - if (!process.env.ASC_MATCH_PASSWORD?.trim()) { - throw new Error("ASC_MATCH_PASSWORD is required for encrypted signing sync."); - } -} - -function syncPush(manifest) { - ensureAscAvailable(); - requireSyncPassword(); - for (const target of manifest.targets) { - process.stderr.write(`Syncing ${target.bundleId} to ${manifest.signingRepo}\n`); - runAscJson([ - "signing", - "sync", - "push", - "--bundle-id", - target.bundleId, - "--profile-type", - manifest.profileType, - "--certificate-type", - manifest.certificateType, - "--create-missing", - "--repo", - manifest.signingRepo, - ]); - } - process.stdout.write("Encrypted iOS signing assets pushed to the shared signing repo.\n"); -} - -function installProfilesFromDirectory(dir) { - if (!fs.existsSync(dir)) { - return 0; - } - - let installed = 0; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - installed += installProfilesFromDirectory(entryPath); - } else if (entry.isFile() && entry.name.endsWith(".mobileprovision")) { - runAsc(["profiles", "local", "install", "--path", entryPath, "--force"]); - installed += 1; - } - } - return installed; -} - -function syncPull(manifest) { - ensureAscAvailable(); - requireSyncPassword(); - const outputDir = path.join(generatedSigningDir, "sync"); - fs.mkdirSync(outputDir, { recursive: true, mode: 0o700 }); - runAscJson([ - "signing", - "sync", - "pull", - "--repo", - manifest.signingRepo, - "--output-dir", - outputDir, - ]); - const installedProfiles = installProfilesFromDirectory(outputDir); - process.stdout.write(`Pulled encrypted iOS signing assets into apps/ios/build/signing/sync.\n`); - process.stdout.write(`Installed ${installedProfiles} provisioning profile(s).\n`); - process.stdout.write( - "Import the pulled distribution certificate/private key into Keychain before archiving.\n", - ); -} - function writeXcconfig(manifest) { const lines = [ "OPENCLAW_CODE_SIGN_STYLE = Manual", @@ -791,9 +97,10 @@ function writeXcconfig(manifest) { function writePlan(manifest) { process.stdout.write(`iOS App Store signing plan Team ID: ${manifest.teamId} -Certificate type: ${manifest.certificateType} Profile type: ${manifest.profileType} Signing repo: ${manifest.signingRepo} +Signing branch: ${manifest.signingBranch} +Signing setup and sync: Fastlane match Targets: `); @@ -814,13 +121,9 @@ try { } else if (mode === "xcconfig") { writeXcconfig(manifest); } else if (mode === "check") { - checkSigning(manifest); - } else if (mode === "setup") { - setupSigning(manifest); - } else if (mode === "sync-push") { - syncPush(manifest); - } else if (mode === "sync-pull") { - syncPull(manifest); + process.stdout.write( + "iOS App Store signing manifest is valid. Fastlane match owns remote signing asset checks.\n", + ); } else { throw new Error(`Unknown mode: ${mode}`); } diff --git a/test/scripts/ios-release-signing.test.ts b/test/scripts/ios-release-signing.test.ts index c58b4a9adac..daec4da47aa 100644 --- a/test/scripts/ios-release-signing.test.ts +++ b/test/scripts/ios-release-signing.test.ts @@ -1,4 +1,4 @@ -// iOS release signing tests cover checked-in App Store profile pinning. +// iOS release signing tests cover checked-in Fastlane-managed profile pinning. import { execFileSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -38,6 +38,8 @@ describe("scripts/ios-release-signing.mjs", () => { expect(output).toContain("Team ID: FWJYW4S8P8"); expect(output).toContain("Signing repo: git@github.com:openclaw/ios-signing.git"); + expect(output).toContain("Signing branch: main"); + expect(output).toContain("Signing setup and sync: Fastlane match"); expect(output).toContain( "OpenClawWatchExtension: ai.openclawfoundation.app.watchkitapp.extension", );