feat(ios): add local beta release flow (#42991)

Merged via squash.

Prepared head SHA: 82b38fe93b
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
Nimrod Gutman
2026-03-11 12:32:28 +02:00
committed by GitHub
parent 665f677265
commit 2d91284fdb
21 changed files with 569 additions and 79 deletions

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -1,10 +1,12 @@
// Shared iOS signing defaults for local development + CI.
#include "Version.xcconfig"
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -0,0 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 0.0.0
OPENCLAW_MARKETING_VERSION = 0.0.0
OPENCLAW_BUILD_VERSION = 0
#include? "../build/Version.xcconfig"

View File

@@ -1,15 +1,12 @@
# OpenClaw iOS (Super Alpha)
NO TEST FLIGHT AVAILABLE AT THIS POINT
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
## Distribution Status
NO TEST FLIGHT AVAILABLE AT THIS POINT
- Current distribution: local/manual deploy from source via Xcode.
- App Store flow is not part of the current internal development path.
- Public distribution: not available.
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
- Local/manual deploy from source via Xcode remains the default development path.
## Super-Alpha Disclaimer
@@ -50,6 +47,45 @@ Shortcut command (same flow + open project):
pnpm ios:open
```
## Local Beta Release Flow
Prereqs:
- Xcode 16+
- `pnpm`
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for automatic signing/provisioning
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
Release behavior:
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.9-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.9`
- `CFBundleVersion = next TestFlight build number for 2026.3.9`
Archive without upload:
```bash
pnpm ios:beta:archive
```
Archive and upload to TestFlight:
```bash
pnpm ios:beta
```
If you need to force a specific build number:
```bash
pnpm ios:beta -- --build-number 7
```
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -2,6 +2,8 @@
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
#include "Config/Version.xcconfig"
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ

View File

@@ -23,7 +23,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -36,7 +36,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>

View File

@@ -2255,8 +2255,7 @@ extension NodeAppModel {
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
"Pending actions pulled trigger=\(trigger, privacy: .public) "
+ "count=\(decoded.actions.count, privacy: .public)")
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
@@ -2279,9 +2278,7 @@ extension NodeAppModel {
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
"Pending action replay trigger=\(trigger, privacy: .public) "
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
+ "ok=\(result.ok, privacy: .public)")
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
@@ -2306,9 +2303,7 @@ extension NodeAppModel {
return true
} catch {
self.pendingActionLogger.error(
"Pending action ack failed trigger=\(trigger, privacy: .public) "
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
+ "error=\(String(describing: error), privacy: .public)")
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
return false
}
}

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
</dict>
</plist>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.9</string>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>20260308</string>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -1,8 +1,11 @@
require "shellwords"
require "open3"
require "json"
default_platform(:ios)
BETA_APP_IDENTIFIER = "ai.openclaw.client"
def load_env_file(path)
return unless File.exist?(path)
@@ -84,6 +87,111 @@ def read_asc_key_content_from_keychain
end
end
def repo_root
File.expand_path("../../..", __dir__)
end
def ios_root
File.expand_path("..", __dir__)
end
def normalize_release_version(raw_value)
version = raw_value.to_s.strip.sub(/\Av/, "")
UI.user_error!("Missing root package.json version.") unless env_present?(version)
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
end
version
end
def read_root_package_version
package_json_path = File.join(repo_root, "package.json")
UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path)
parsed = JSON.parse(File.read(package_json_path))
normalize_release_version(parsed["version"])
rescue JSON::ParserError => e
UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}")
end
def short_release_version(version)
normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "")
end
def shell_join(parts)
Shellwords.join(parts.compact)
end
def resolve_beta_build_number(api_key:, version:)
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
if env_present?(explicit)
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
UI.message("Using explicit iOS beta build number #{explicit}.")
return explicit
end
short_version = short_release_version(version)
latest_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: BETA_APP_IDENTIFIER,
version: short_version,
initial_build_number: 0
)
next_build = latest_build.to_i + 1
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
next_build.to_s
end
def beta_build_number_needs_asc_auth?
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
!env_present?(explicit)
end
def prepare_beta_release!(version:, build_number:)
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
sh(shell_join(["bash", script_path, "--build-number", build_number]))
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
beta_xcconfig
end
def build_beta_release(context)
version = context[:version]
output_directory = File.join("build", "beta")
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
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"
}
)
{
archive_path: archive_path,
build_number: context[:build_number],
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
short_version: context[:short_version],
version: version
}
end
platform :ios do
private_lane :asc_api_key do
load_env_file(File.join(__dir__, ".env"))
@@ -132,38 +240,48 @@ platform :ios do
api_key
end
desc "Build + upload to TestFlight"
private_lane :prepare_beta_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
api_key = needs_api_key ? asc_api_key : nil
version = read_root_package_version
build_number = resolve_beta_build_number(api_key: api_key, version: version)
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
{
api_key: api_key,
beta_xcconfig: beta_xcconfig,
build_number: build_number,
short_version: short_release_version(version),
version: version
}
end
desc "Build a beta archive locally without uploading"
lane :beta_archive do
context = prepare_beta_context(require_api_key: false)
build = build_beta_release(context)
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
build
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Build + upload a beta to TestFlight"
lane :beta do
api_key = asc_api_key
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
if team_id.nil? || team_id.strip.empty?
helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
if File.exist?(helper_path)
# Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
end
end
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
build_app(
project: "OpenClaw.xcodeproj",
scheme: "OpenClaw",
export_method: "app-store",
clean: true,
skip_profile_detection: true,
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
export_xcargs: "-allowProvisioningUpdates",
export_options: {
signingStyle: "automatic"
}
)
context = prepare_beta_context(require_api_key: true)
build = build_beta_release(context)
upload_to_testflight(
api_key: api_key,
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata (and optionally screenshots)"

View File

@@ -32,9 +32,9 @@ ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
ASC_APP_IDENTIFIER=ai.openclaw.ios
ASC_APP_IDENTIFIER=ai.openclaw.client
# or
ASC_APP_ID=6760218713
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
```
File-based fallback (CI/non-macOS):
@@ -60,9 +60,37 @@ cd apps/ios
fastlane ios auth_check
```
Run:
ASC auth is only required when:
- uploading to TestFlight
- auto-resolving the next build number from App Store Connect
If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
Archive locally without upload:
```bash
pnpm ios:beta:archive
```
Upload to TestFlight:
```bash
pnpm ios:beta
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane beta
fastlane ios beta
```
Versioning rules:
- Root `package.json.version` is the single source of truth for iOS
- Use `YYYY.M.D` for stable versions and `YYYY.M.D-beta.N` for beta versions
- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D`
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched

View File

@@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
```bash
cd apps/ios
ASC_APP_ID=6760218713 \
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```

View File

@@ -107,8 +107,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -168,8 +168,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -205,8 +205,8 @@ targets:
path: ActivityWidget/Info.plist
properties:
CFBundleDisplayName: OpenClaw Activity
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSSupportsLiveActivities: true
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
@@ -224,6 +224,7 @@ targets:
Release: Config/Signing.xcconfig
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -231,8 +232,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -256,8 +257,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -293,8 +294,8 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
OpenClawLogicTests:
type: bundle.unit-test
@@ -319,5 +320,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "2026.3.9"
CFBundleVersion: "20260308"
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"