diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd2acb94b22..1edb0770e3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
+- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
### Breaking
diff --git a/apps/ios/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist
index 4c2d89e1566..4c965121bf9 100644
--- a/apps/ios/ActivityWidget/Info.plist
+++ b/apps/ios/ActivityWidget/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionPointIdentifier
diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig
index 1285d2a38a4..4fef287a09d 100644
--- a/apps/ios/Config/Signing.xcconfig
+++ b/apps/ios/Config/Signing.xcconfig
@@ -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.
diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig
new file mode 100644
index 00000000000..db38e86df80
--- /dev/null
+++ b/apps/ios/Config/Version.xcconfig
@@ -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"
diff --git a/apps/ios/README.md b/apps/ios/README.md
index c7c501fcbff..42c5a51dec2 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -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.
diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist
index 90a7e09e0fc..9469daa08a8 100644
--- a/apps/ios/ShareExtension/Info.plist
+++ b/apps/ios/ShareExtension/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionAttributes
diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig
index 5966d6e2c2f..d6acc35dee8 100644
--- a/apps/ios/Signing.xcconfig
+++ b/apps/ios/Signing.xcconfig
@@ -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
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 2f1f03d24a1..892d53e7ae9 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -23,7 +23,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleURLTypes
@@ -36,7 +36,7 @@
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
ITSAppUsesNonExemptEncryption
NSAppTransportSecurity
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index babb6b449da..685b30f0887 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -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
}
}
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index 46e3fb97eb1..5bcf88ff5ad 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,8 +17,8 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist
index fa45d719b9c..3eea1e6ff09 100644
--- a/apps/ios/WatchApp/Info.plist
+++ b/apps/ios/WatchApp/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
WKCompanionAppBundleIdentifier
$(OPENCLAW_APP_BUNDLE_ID)
WKWatchKitApp
diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist
index 1d898d43757..87313064945 100644
--- a/apps/ios/WatchExtension/Info.plist
+++ b/apps/ios/WatchExtension/Info.plist
@@ -15,9 +15,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 2026.3.9
+ $(OPENCLAW_MARKETING_VERSION)
CFBundleVersion
- 20260308
+ $(OPENCLAW_BUILD_VERSION)
NSExtension
NSExtensionAttributes
diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile
index 33e6bfa8adb..62d79f9995c 100644
--- a/apps/ios/fastlane/Fastfile
+++ b/apps/ios/fastlane/Fastfile
@@ -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)"
diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md
index 8dccf264b41..67d4fcc843a 100644
--- a/apps/ios/fastlane/SETUP.md
+++ b/apps/ios/fastlane/SETUP.md
@@ -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
diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md
index 74eb7df87d3..07e7824311f 100644
--- a/apps/ios/fastlane/metadata/README.md
+++ b/apps/ios/fastlane/metadata/README.md
@@ -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
```
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 0664db9c6be..91b2a8e46d1 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -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)"
diff --git a/package.json b/package.json
index 2e4dbc0d97e..f673633009c 100644
--- a/package.json
+++ b/package.json
@@ -262,10 +262,13 @@
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
"ghsa:patch": "node scripts/ghsa-patch.mjs",
- "ios:build": "bash -lc './scripts/ios-configure-signing.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 && cd apps/ios && xcodegen generate'",
- "ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
- "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
+ "ios:beta": "bash scripts/ios-beta-release.sh",
+ "ios:beta:archive": "bash scripts/ios-beta-archive.sh",
+ "ios:beta:prepare": "bash scripts/ios-beta-prepare.sh",
+ "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:run": "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 && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
"lint": "oxlint --type-aware",
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
"lint:all": "pnpm lint && pnpm lint:swift",
diff --git a/scripts/ios-beta-archive.sh b/scripts/ios-beta-archive.sh
new file mode 100755
index 00000000000..c65e9991389
--- /dev/null
+++ b/scripts/ios-beta-archive.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-beta-archive.sh [--build-number 7]
+
+Archives and exports a beta-release IPA locally without uploading.
+EOF
+}
+
+BUILD_NUMBER="${IOS_BETA_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_BETA_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios beta_archive
+)
diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh
new file mode 100755
index 00000000000..1d88add46db
--- /dev/null
+++ b/scripts/ios-beta-prepare.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
+
+Prepares local beta-release inputs without touching local signing overrides:
+- reads package.json.version and writes apps/ios/build/Version.xcconfig
+- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs
+- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
+EOF
+}
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+IOS_DIR="${ROOT_DIR}/apps/ios"
+BUILD_DIR="${IOS_DIR}/build"
+BETA_XCCONFIG="${IOS_DIR}/build/BetaRelease.xcconfig"
+TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh"
+VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
+
+BUILD_NUMBER=""
+TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
+PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
+
+prepare_build_dir() {
+ if [[ -L "${BUILD_DIR}" ]]; then
+ echo "Refusing to use symlinked build directory: ${BUILD_DIR}" >&2
+ exit 1
+ fi
+
+ mkdir -p "${BUILD_DIR}"
+}
+
+write_generated_file() {
+ local output_path="$1"
+ local tmp_file=""
+
+ if [[ -e "${output_path}" && -L "${output_path}" ]]; then
+ echo "Refusing to overwrite symlinked file: ${output_path}" >&2
+ exit 1
+ fi
+
+ tmp_file="$(mktemp "${output_path}.XXXXXX")"
+ cat >"${tmp_file}"
+ mv -f "${tmp_file}" "${output_path}"
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --)
+ shift
+ ;;
+ --build-number)
+ BUILD_NUMBER="${2:-}"
+ shift 2
+ ;;
+ --team-id)
+ TEAM_ID="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "${BUILD_NUMBER}" ]]; then
+ echo "Missing required --build-number." >&2
+ usage
+ exit 1
+fi
+
+if [[ -z "${TEAM_ID}" ]]; then
+ TEAM_ID="$(IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash "${TEAM_HELPER}")"
+fi
+
+if [[ -z "${TEAM_ID}" ]]; then
+ echo "Could not resolve Apple Team ID. Set IOS_DEVELOPMENT_TEAM or sign into Xcode." >&2
+ exit 1
+fi
+
+prepare_build_dir
+
+(
+ bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}"
+)
+
+write_generated_file "${BETA_XCCONFIG}" <&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+(
+ cd "${ROOT_DIR}/apps/ios"
+ IOS_BETA_BUILD_NUMBER="${BUILD_NUMBER}" fastlane ios beta
+)
diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh
new file mode 100755
index 00000000000..e6214c9188c
--- /dev/null
+++ b/scripts/ios-write-version-xcconfig.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/ios-write-version-xcconfig.sh [--build-number 7]
+
+Writes apps/ios/build/Version.xcconfig from root package.json.version:
+- OPENCLAW_GATEWAY_VERSION = exact package.json version
+- OPENCLAW_MARKETING_VERSION = short iOS/App Store version
+- OPENCLAW_BUILD_VERSION = explicit build number or local numeric fallback
+EOF
+}
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+IOS_DIR="${ROOT_DIR}/apps/ios"
+BUILD_DIR="${IOS_DIR}/build"
+VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig"
+PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
+BUILD_NUMBER=""
+
+prepare_build_dir() {
+ if [[ -L "${BUILD_DIR}" ]]; then
+ echo "Refusing to use symlinked build directory: ${BUILD_DIR}" >&2
+ exit 1
+ fi
+
+ mkdir -p "${BUILD_DIR}"
+}
+
+write_generated_file() {
+ local output_path="$1"
+ local tmp_file=""
+
+ if [[ -e "${output_path}" && -L "${output_path}" ]]; then
+ echo "Refusing to overwrite symlinked file: ${output_path}" >&2
+ exit 1
+ fi
+
+ tmp_file="$(mktemp "${output_path}.XXXXXX")"
+ cat >"${tmp_file}"
+ mv -f "${tmp_file}" "${output_path}"
+}
+
+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
+
+PACKAGE_VERSION="$(printf '%s' "${PACKAGE_VERSION}" | tr -d '\n' | xargs)"
+if [[ -z "${PACKAGE_VERSION}" ]]; then
+ echo "Unable to read package.json.version from ${ROOT_DIR}/package.json." >&2
+ exit 1
+fi
+
+if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
+ MARKETING_VERSION="${BASH_REMATCH[1]}"
+else
+ echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.9 or 2026.3.9-beta.1." >&2
+ exit 1
+fi
+
+if [[ -z "${BUILD_NUMBER}" ]]; then
+ BUILD_NUMBER="$(cd "${ROOT_DIR}" && git rev-list --count HEAD 2>/dev/null || printf '0')"
+fi
+
+if [[ ! "${BUILD_NUMBER}" =~ ^[0-9]+$ ]]; then
+ echo "Invalid build number '${BUILD_NUMBER}'. Expected digits only." >&2
+ exit 1
+fi
+
+prepare_build_dir
+
+write_generated_file "${VERSION_XCCONFIG}" <