mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
chore(ios): migrate release signing to fastlane match
This commit is contained in:
parent
df8ceb5267
commit
0af07bb378
13 changed files with 306 additions and 841 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/<id>/...` URL)
|
||||
- `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID)
|
||||
- `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
|
||||
- For first app versions, include review contact files under `metadata/review_information/`:
|
||||
- `first_name.txt`
|
||||
- `last_name.txt`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
10
package.json
10
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",
|
||||
|
|
|
|||
|
|
@ -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 <issuer-uuid> [options]
|
||||
scripts/ios-app-store-connect-keychain-setup.sh --key-path /path/to/AuthKey_XXXXXX.p8 --issuer-id <issuer-uuid> [options]
|
||||
|
||||
Required:
|
||||
--key-path <path> Path to App Store Connect API key (.p8)
|
||||
|
|
@ -12,14 +12,14 @@ Required:
|
|||
|
||||
Optional:
|
||||
--key-id <id> API key ID (auto-detected from AuthKey_<id>.p8 if omitted)
|
||||
--service <name> Keychain service name (default: openclaw-asc-key)
|
||||
--service <name> Keychain service name (default: openclaw-app-store-connect-key)
|
||||
--account <name> Keychain account name (default: $USER or $LOGNAME)
|
||||
--write-env Upsert non-secret env vars into apps/ios/fastlane/.env
|
||||
--env-file <path> 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
|
||||
|
|
@ -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 =
|
||||
/<key>DeveloperCertificates<\/key>\s*<array>([\s\S]*?)<\/array>/u.exec(xml)?.[1] ?? "";
|
||||
const fingerprints = new Set();
|
||||
for (const match of developerCertificates.matchAll(/<data>([\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 = /<key>Entitlements<\/key>\s*<dict>([\s\S]*?)<\/dict>/u.exec(xml)?.[1] ?? "";
|
||||
return new RegExp(`<key>${escapeRegExp(entitlement)}</key>`, "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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue