chore(ios): migrate release signing to fastlane match

This commit is contained in:
joshavant 2026-06-16 13:03:14 +02:00
parent df8ceb5267
commit 0af07bb378
No known key found for this signature in database
GPG key ID: 4463B60B0DD49BC4
13 changed files with 306 additions and 841 deletions

View file

@ -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",

View file

@ -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`.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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`

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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}`);
}

View file

@ -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",
);