mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: configure ios app store release upload
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = /
|
||||
|
||||
Executable
+823
@@ -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);
|
||||
}
|
||||
Executable
+43
@@ -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
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user