feat: configure ios app store release upload

This commit is contained in:
joshavant
2026-06-15 19:27:55 +02:00
parent a23de348b2
commit 9092578d8d
21 changed files with 1281 additions and 84 deletions
+53
View File
@@ -0,0 +1,53 @@
{
"teamId": "FWJYW4S8P8",
"signingRepo": "git@github.com:openclaw/ios-signing.git",
"certificateType": "IOS_DISTRIBUTION",
"profileType": "IOS_APP_STORE",
"targets": [
{
"target": "OpenClaw",
"displayName": "OpenClaw",
"bundleId": "ai.openclawfoundation.app",
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
"displayName": "OpenClaw Share",
"bundleId": "ai.openclawfoundation.app.share",
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
"displayName": "OpenClaw Activity Widget",
"bundleId": "ai.openclawfoundation.app.activitywidget",
"platform": "IOS",
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
"capabilities": []
},
{
"target": "OpenClawWatchApp",
"displayName": "OpenClaw Watch App",
"bundleId": "ai.openclawfoundation.app.watchkitapp",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}
+1
View File
@@ -10,6 +10,7 @@ OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
+1
View File
@@ -14,5 +14,6 @@ OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.exten
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
+34 -8
View File
@@ -56,7 +56,8 @@ Prereqs:
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
- Xcode account permissions to create Apple Distribution certificates, App IDs, and App Store provisioning profiles
- `asc` CLI authenticated for the canonical OpenClaw team
- Release-owner access to the encrypted signing repo password (`ASC_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
@@ -64,9 +65,12 @@ 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 automatic signing with `Apple Distribution` and `-allowProvisioningUpdates`, so Xcode can create missing distribution certificates/profiles for the canonical team when the signed-in account has permission.
- 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.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- App Store screenshots use `pnpm ios:screenshots`, which drives Fastlane Snapshot through a deterministic connected screenshot fixture instead of a live gateway.
- `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.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
@@ -79,10 +83,27 @@ Release behavior:
Relay behavior for App Store builds:
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
- Release builds default to `https://ios-push-relay.openclaw.ai`.
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
Signing setup commands:
```bash
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
```
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`.
- 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.
Prepare the generated release xcconfig/project without archiving:
```bash
@@ -98,13 +119,13 @@ pnpm ios:release:archive
Archive and upload to App Store Connect:
```bash
pnpm ios:release
pnpm ios:release:upload
```
If you need to force a specific build number:
```bash
pnpm ios:release -- --build-number 7
pnpm ios:release:upload -- --build-number 7
```
### Maintainer Quick Release Checklist
@@ -136,6 +157,8 @@ 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.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
```bash
@@ -151,16 +174,19 @@ pnpm ios:version:pin -- --from-gateway
6. Upload the build:
```bash
pnpm ios:release
pnpm ios:release:upload
```
7. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes and screenshots to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- uploads the IPA to App Store Connect for TestFlight/App Review use
- leaves App Review submission for a maintainer to complete manually
8. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
@@ -193,7 +219,7 @@ Recommended flow:
1. Keep `apps/ios/version.json` pinned to the current train version.
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
3. Run `pnpm ios:version:sync` after changelog changes.
4. Upload more TestFlight builds with `pnpm ios:release`.
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
5. Let Fastlane bump only the numeric build number.
### Starting the next production release train
+3
View File
@@ -17,6 +17,9 @@ OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.
+11 -3
View File
@@ -88,9 +88,14 @@ Pinned iOS version `2026.4.10` maps to:
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
- `scripts/ios-release-prepare.sh`
- 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
- `apps/ios/fastlane/Fastfile`
- resolves version metadata from the pinned iOS helper
- increments App Store Connect build numbers for the pinned short version
- uploads screenshots and release notes before archiving a release build
## Release-note resolution order
@@ -118,7 +123,7 @@ pnpm ios:version:pin -- --version 2026.4.10
1. keep `apps/ios/version.json` pinned to the current TestFlight train version
2. update `apps/ios/CHANGELOG.md` under `## Unreleased` while iterating
3. upload more App Store Connect builds with the usual flow
3. upload more App Store Connect builds with `pnpm ios:release:upload`
4. let Fastlane increment only `CFBundleVersion`
This keeps the TestFlight version stable while review is in flight.
@@ -139,12 +144,15 @@ pnpm ios:version:pin -- --from-gateway
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
3. update `apps/ios/CHANGELOG.md` for the new release if needed
4. run `pnpm ios:version:sync` again if the changelog changed
5. submit the first App Store Connect build for that newly pinned version
5. upload the first App Store Connect build for that newly pinned version
6. keep iterating only by build number until the release candidate is ready
7. release that reviewed TestFlight build to production
7. manually submit the reviewed build for App Review in App Store Connect
8. release the approved build to production
## Important invariant
Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`.
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
+181 -19
View File
@@ -1,6 +1,9 @@
require "shellwords"
require "open3"
require "json"
require "fileutils"
require "tmpdir"
require "cgi"
default_platform(:ios)
@@ -39,6 +42,10 @@ def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def release_notes_upload_requested?
ENV["DELIVER_RELEASE_NOTES"] == "1"
end
def screenshot_paths
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
end
@@ -125,6 +132,92 @@ def ios_root
File.expand_path("..", __dir__)
end
def preserve_file(path)
existed = File.exist?(path)
contents = existed ? File.binread(path) : nil
yield
ensure
if existed
File.binwrite(path, contents)
else
FileUtils.rm_f(path)
end
end
def preserve_local_signing
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
yield
end
end
def app_store_signing_manifest
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end
def app_store_provisioning_profiles
app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles|
profiles[target.fetch("bundleId")] = target.fetch("profileName")
end
end
def xml_string(value)
CGI.escapeHTML(value.to_s)
end
def write_app_store_export_options(path)
manifest = app_store_signing_manifest
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
end.join("\n")
File.write(path, <<~PLIST)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>teamID</key>
<string>#{xml_string(manifest.fetch("teamId"))}</string>
<key>provisioningProfiles</key>
<dict>
#{profile_entries}
</dict>
<key>destination</key>
<string>export</string>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
PLIST
end
def release_signing_check!
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"]))
end
def release_notes_path
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
end
def release_notes_metadata_path
source = release_notes_path
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
temp_root = Dir.mktmpdir("openclaw-release-notes")
target_dir = File.join(temp_root, "en-US")
FileUtils.mkdir_p(target_dir)
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
temp_root
end
def read_ios_version_metadata
script_path = File.join(repo_root, "scripts", "ios-version.ts")
stdout, stderr, status = Open3.capture3(
@@ -178,6 +271,11 @@ def shell_join(parts)
Shellwords.join(parts.compact)
end
def xcodebuild_shell_join(parts)
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
shell_join(["env", "PATH=#{xcode_path}", *parts])
end
def resolve_release_build_number(api_key:, short_version:)
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
if env_present?(explicit)
@@ -216,31 +314,59 @@ end
def build_app_store_release(context)
version = context[:version]
output_directory = File.join("build", "app-store")
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
output_directory = File.join(ios_root, "build", "app-store")
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
export_options_path = File.join(output_directory, "ExportOptions.plist")
output_name = "OpenClaw-#{version}.ipa"
expected_ipa_path = File.join(output_directory, output_name)
build_app(
project: "OpenClaw.xcodeproj",
scheme: "OpenClaw",
configuration: "Release",
export_method: "app-store",
clean: true,
skip_profile_detection: true,
build_path: "build",
archive_path: archive_path,
output_directory: output_directory,
output_name: "OpenClaw-#{version}.ipa",
xcargs: "-allowProvisioningUpdates",
export_xcargs: "-allowProvisioningUpdates",
export_options: {
signingStyle: "automatic"
}
FileUtils.mkdir_p(output_directory)
FileUtils.rm_rf(archive_path)
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
write_app_store_export_options(export_options_path)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-project",
project_path,
"-scheme",
"OpenClaw",
"-configuration",
"Release",
"-destination",
"generic/platform=iOS",
"-archivePath",
archive_path,
"clean",
"archive",
])
)
sh(
xcodebuild_shell_join([
"xcodebuild",
"-exportArchive",
"-archivePath",
archive_path,
"-exportPath",
output_directory,
"-exportOptionsPlist",
export_options_path,
])
)
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
exported_ipa = exported_ipas.first
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
{
archive_path: archive_path,
build_number: context[:build_number],
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
ipa_path: expected_ipa_path,
short_version: context[:short_version],
version: version
}
@@ -341,6 +467,32 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
lane :release_upload do
release_signing_check!
preserve_local_signing do
screenshots
end
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
sync_ios_versioning!
@@ -360,6 +512,13 @@ platform :ios do
validate_required_screenshots!(paths)
end
metadata_path = File.join(__dir__, "metadata")
skip_metadata = ENV["DELIVER_METADATA"] != "1"
if release_notes_upload_requested? && skip_metadata
metadata_path = release_notes_metadata_path
skip_metadata = false
end
deliver_options = {
api_key: api_key,
force: true,
@@ -367,10 +526,13 @@ platform :ios do
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
metadata_path: metadata_path,
skip_screenshots: !screenshot_upload_requested?,
skip_metadata: ENV["DELIVER_METADATA"] != "1",
skip_metadata: skip_metadata,
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
skip_app_version_update: false,
submit_for_review: false,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier
+24 -4
View File
@@ -55,6 +55,25 @@ 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`.
One-time or rotation setup:
```bash
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
Shared encrypted signing storage:
```bash
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
ASC_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.
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`.
Validate auth:
@@ -88,14 +107,14 @@ The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters
Upload to App Store Connect:
```bash
pnpm ios:release
pnpm ios:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane ios app_store
fastlane ios release_upload
```
Maintainer recovery path for a fresh clone on the same Mac:
@@ -132,7 +151,7 @@ export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
6. Upload:
```bash
pnpm ios:release
pnpm ios:release:upload
```
Quick verification after upload:
@@ -153,5 +172,6 @@ Versioning rules:
- Run `pnpm ios:version:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md`
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local App Store signing uses a temporary generated xcconfig and leaves local development signing overrides untouched
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
- See `apps/ios/VERSIONING.md` for the detailed workflow
+10
View File
@@ -10,6 +10,15 @@ ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```
## Release notes only
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
```bash
cd apps/ios
DELIVER_RELEASE_NOTES=1 fastlane ios metadata
```
## Optional: include screenshots
```bash
@@ -39,6 +48,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
- 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)
+1
View File
@@ -230,6 +230,7 @@ targets:
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_PROFILE)"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
+9 -3
View File
@@ -1552,10 +1552,16 @@
"ghsa:patch": "node scripts/ghsa-patch.mjs",
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:release": "bash scripts/ios-release.sh",
"ios:release:archive": "bash scripts/ios-release-archive.sh",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"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:upload": "bash scripts/ios-release-upload.sh",
"ios:run": "bash scripts/ios-run.sh",
"ios:screenshots": "bash scripts/ios-screenshots.sh",
"ios:version": "node --import tsx scripts/ios-version.ts --json",
+4 -2
View File
@@ -77,6 +77,7 @@ code_sign_identity="${OPENCLAW_IOS_CODE_SIGN_IDENTITY:-Apple Development}"
apns_entitlement_environment="${OPENCLAW_IOS_APNS_ENTITLEMENT_ENVIRONMENT:-development}"
app_profile="${OPENCLAW_IOS_APP_PROFILE:-}"
share_profile="${OPENCLAW_IOS_SHARE_PROFILE:-}"
activity_widget_profile="${OPENCLAW_IOS_ACTIVITY_WIDGET_PROFILE:-}"
watch_app_profile="${OPENCLAW_IOS_WATCH_APP_PROFILE:-}"
watch_extension_profile="${OPENCLAW_IOS_WATCH_EXTENSION_PROFILE:-}"
@@ -91,8 +92,8 @@ cat >"${tmp_file}" <<EOF
// OPENCLAW_IOS_WATCH_APP_BUNDLE_ID / OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID
// OPENCLAW_IOS_CODE_SIGN_STYLE / OPENCLAW_IOS_CODE_SIGN_IDENTITY
// OPENCLAW_IOS_APNS_ENTITLEMENT_ENVIRONMENT / OPENCLAW_IOS_APP_PROFILE
// OPENCLAW_IOS_SHARE_PROFILE / OPENCLAW_IOS_WATCH_APP_PROFILE
// OPENCLAW_IOS_WATCH_EXTENSION_PROFILE
// OPENCLAW_IOS_SHARE_PROFILE / OPENCLAW_IOS_ACTIVITY_WIDGET_PROFILE
// OPENCLAW_IOS_WATCH_APP_PROFILE / OPENCLAW_IOS_WATCH_EXTENSION_PROFILE
OPENCLAW_CODE_SIGN_STYLE = ${code_sign_style}
OPENCLAW_CODE_SIGN_IDENTITY = ${code_sign_identity}
OPENCLAW_DEVELOPMENT_TEAM = ${team_id}
@@ -106,6 +107,7 @@ OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ${watch_extension_bundle_id}
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = ${apns_entitlement_environment}
OPENCLAW_APP_PROFILE = ${app_profile}
OPENCLAW_SHARE_PROFILE = ${share_profile}
OPENCLAW_ACTIVITY_WIDGET_PROFILE = ${activity_widget_profile}
OPENCLAW_WATCH_APP_PROFILE = ${watch_app_profile}
OPENCLAW_WATCH_EXTENSION_PROFILE = ${watch_extension_profile}
EOF
+2 -1
View File
@@ -12,6 +12,7 @@ EOF
BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -36,5 +37,5 @@ done
(
cd "${ROOT_DIR}/apps/ios"
IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios app_store_archive
IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" run_ios_fastlane ios app_store_archive
)
+10 -7
View File
@@ -14,7 +14,7 @@ Prepares local App Store release inputs without touching local signing overrides
- reads apps/ios/version.json and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/AppStoreRelease.xcconfig with canonical bundle IDs
- configures the release build for relay-backed APNs registration
- configures automatic App Store distribution signing for the canonical team
- configures manual App Store distribution signing with pinned provisioning profiles
- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
EOF
}
@@ -27,6 +27,7 @@ TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh"
VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
IOS_VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts"
RELEASE_SIGNING_HELPER="${ROOT_DIR}/scripts/ios-release-signing.mjs"
CANONICAL_TEAM_ID="FWJYW4S8P8"
BUILD_NUMBER=""
@@ -35,6 +36,7 @@ DEFAULT_IOS_PUSH_RELAY_BASE_URL="https://ios-push-relay.openclaw.ai"
PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-${DEFAULT_IOS_PUSH_RELAY_BASE_URL}}}"
PUSH_RELAY_BASE_URL_XCCONFIG=""
IOS_VERSION=""
RELEASE_SIGNING_XCCONFIG=""
prepare_build_dir() {
if [[ -L "${BUILD_DIR}" ]]; then
@@ -150,6 +152,12 @@ if [[ -z "${IOS_VERSION}" ]]; then
exit 1
fi
RELEASE_SIGNING_XCCONFIG="$(cd "${ROOT_DIR}" && node "${RELEASE_SIGNING_HELPER}" --mode xcconfig)"
if [[ -z "${RELEASE_SIGNING_XCCONFIG}" ]]; then
echo "Unable to resolve App Store release signing profile settings." >&2
exit 1
fi
(
bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}"
)
@@ -157,8 +165,7 @@ fi
write_generated_file "${RELEASE_XCCONFIG}" <<EOF
// Auto-generated by scripts/ios-release-prepare.sh.
// Local App Store release override; do not commit.
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Distribution
${RELEASE_SIGNING_XCCONFIG}
OPENCLAW_DEVELOPMENT_TEAM = ${TEAM_ID}
OPENCLAW_IOS_SELECTED_TEAM = ${TEAM_ID}
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
@@ -167,10 +174,6 @@ OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = production
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
OPENCLAW_PUSH_TRANSPORT = relay
OPENCLAW_PUSH_DISTRIBUTION = official
OPENCLAW_URL_SLASH = /
+823
View File
@@ -0,0 +1,823 @@
#!/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.
`);
}
function parseArgs(argv) {
let mode = "";
let manifestPath = defaultManifestPath;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--mode") {
mode = argv[i + 1] ?? "";
i += 1;
} else if (arg === "--manifest") {
manifestPath = path.resolve(argv[i + 1] ?? "");
i += 1;
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!mode) {
throw new Error("Missing required --mode.");
}
return { mode, manifestPath };
}
function readManifest(manifestPath) {
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const requiredStrings = ["teamId", "signingRepo", "certificateType", "profileType"];
for (const key of requiredStrings) {
if (typeof parsed[key] !== "string" || parsed[key].trim() === "") {
throw new Error(`Signing manifest missing ${key}.`);
}
}
if (!Array.isArray(parsed.targets) || parsed.targets.length === 0) {
throw new Error("Signing manifest must include targets.");
}
for (const target of parsed.targets) {
for (const key of [
"target",
"displayName",
"bundleId",
"platform",
"profileKey",
"profileName",
]) {
if (typeof target[key] !== "string" || target[key].trim() === "") {
throw new Error(`Signing target is missing ${key}.`);
}
}
if (!Array.isArray(target.capabilities)) {
throw new Error(`Signing target ${target.target} must include capabilities array.`);
}
}
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)}`);
}
}
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}`);
}
}
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",
"OPENCLAW_CODE_SIGN_IDENTITY = Apple Distribution",
];
for (const target of manifest.targets) {
lines.push(`${target.profileKey} = ${target.profileName}`);
}
process.stdout.write(`${lines.join("\n")}\n`);
}
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}
Targets:
`);
for (const target of manifest.targets) {
const capabilities = target.capabilities.length > 0 ? target.capabilities.join(", ") : "none";
process.stdout.write(
`- ${target.target}: ${target.bundleId}, profile "${target.profileName}", capabilities: ${capabilities}\n`,
);
}
}
try {
const { mode, manifestPath } = parseArgs(process.argv.slice(2));
const manifest = readManifest(manifestPath);
if (mode === "plan") {
writePlan(manifest);
} 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);
} else {
throw new Error(`Unknown mode: ${mode}`);
}
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-upload.sh [--build-number 7]
Generates App Store screenshots, updates release metadata, archives, and uploads
an App Store distribution build to App Store Connect. This does not submit the
build for App Review.
EOF
}
BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
--build-number)
BUILD_NUMBER="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
(
cd "${ROOT_DIR}/apps/ios"
IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" run_ios_fastlane ios release_upload
)
+1 -36
View File
@@ -1,40 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release.sh [--build-number 7]
Archives and uploads an App Store distribution build to App Store Connect.
EOF
}
BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
--build-number)
BUILD_NUMBER="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
(
cd "${ROOT_DIR}/apps/ios"
IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios app_store
)
exec bash "${ROOT_DIR}/scripts/ios-release-upload.sh" "$@"
+2 -1
View File
@@ -2,8 +2,9 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
(
cd "${ROOT_DIR}/apps/ios"
fastlane ios screenshots
run_ios_fastlane ios screenshots
)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
run_ios_fastlane() {
if command -v fastlane >/dev/null 2>&1 && fastlane --version >/dev/null 2>&1; then
fastlane "$@"
return
fi
if command -v rbenv >/dev/null 2>&1; then
local version=""
while IFS= read -r version; do
if RBENV_VERSION="${version}" rbenv which fastlane >/dev/null 2>&1; then
RBENV_VERSION="${version}" rbenv exec fastlane "$@"
return
fi
done < <(rbenv versions --bare)
fi
echo "fastlane not found. Install fastlane or select a Ruby version that has the fastlane gem." >&2
return 127
}
@@ -66,6 +66,7 @@ describe.sequential("scripts/ios-configure-signing.sh", () => {
expect(generated).toContain("OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8");
expect(generated).toContain("OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app");
expect(generated).toContain("OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share");
expect(generated).toContain("OPENCLAW_ACTIVITY_WIDGET_PROFILE = ");
});
it("keeps unique local bundle IDs for non-canonical fallback teams", () => {
+46
View File
@@ -0,0 +1,46 @@
// iOS release signing tests cover checked-in App Store profile pinning.
import { execFileSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT = path.join(process.cwd(), "scripts", "ios-release-signing.mjs");
function runSigning(mode: string): string {
return execFileSync(process.execPath, [SCRIPT, "--mode", mode], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
describe("scripts/ios-release-signing.mjs", () => {
it("emits manual App Store profile settings for every signed target", () => {
const output = runSigning("xcconfig");
expect(output).toContain("OPENCLAW_CODE_SIGN_STYLE = Manual");
expect(output).toContain("OPENCLAW_CODE_SIGN_IDENTITY = Apple Distribution");
expect(output).toContain("OPENCLAW_APP_PROFILE = OpenClaw App Store ai.openclawfoundation.app");
expect(output).toContain(
"OPENCLAW_SHARE_PROFILE = OpenClaw App Store ai.openclawfoundation.app.share",
);
expect(output).toContain(
"OPENCLAW_ACTIVITY_WIDGET_PROFILE = OpenClaw App Store ai.openclawfoundation.app.activitywidget",
);
expect(output).toContain(
"OPENCLAW_WATCH_APP_PROFILE = OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
);
expect(output).toContain(
"OPENCLAW_WATCH_EXTENSION_PROFILE = OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
);
});
it("documents the canonical release signing plan", () => {
const output = runSigning("plan");
expect(output).toContain("Team ID: FWJYW4S8P8");
expect(output).toContain("Signing repo: git@github.com:openclaw/ios-signing.git");
expect(output).toContain(
"OpenClawWatchExtension: ai.openclawfoundation.app.watchkitapp.extension",
);
expect(output).toContain("capabilities: PUSH_NOTIFICATIONS");
});
});