Skip to content

Commit 824d720

Browse files
committed
feat(ios): add local beta release flow
1 parent 23cd997 commit 824d720

File tree

12 files changed

+499
-48
lines changed

12 files changed

+499
-48
lines changed

apps/ios/Config/Signing.xcconfig

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Shared iOS signing defaults for local development + CI.
22
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
33
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
4-
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
5-
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
6-
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
7-
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
4+
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
5+
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
6+
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
7+
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
88

99
// Local contributors can override this by running scripts/ios-configure-signing.sh.
1010
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

apps/ios/README.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
# OpenClaw iOS (Super Alpha)
22

3-
NO TEST FLIGHT AVAILABLE AT THIS POINT
4-
53
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
64

75
## Distribution Status
86

9-
NO TEST FLIGHT AVAILABLE AT THIS POINT
10-
11-
- Current distribution: local/manual deploy from source via Xcode.
12-
- App Store flow is not part of the current internal development path.
7+
- Public distribution: not available.
8+
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
9+
- Local/manual deploy from source via Xcode remains the default development path.
1310

1411
## Super-Alpha Disclaimer
1512

@@ -50,6 +47,44 @@ Shortcut command (same flow + open project):
5047
pnpm ios:open
5148
```
5249

50+
## Local Beta Release Flow
51+
52+
Prereqs:
53+
54+
- Xcode 16+
55+
- `pnpm`
56+
- `xcodegen`
57+
- `fastlane`
58+
- Apple account signed into Xcode for automatic signing/provisioning
59+
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh`
60+
61+
Release behavior:
62+
63+
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
64+
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
65+
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
66+
- Version input `2026.3.9-beta.1` becomes:
67+
- `CFBundleShortVersionString = 2026.3.9`
68+
- `CFBundleVersion = next TestFlight build number for 2026.3.9`
69+
70+
Archive without upload:
71+
72+
```bash
73+
pnpm ios:beta:archive -- --version 2026.3.9-beta.1
74+
```
75+
76+
Archive and upload to TestFlight:
77+
78+
```bash
79+
pnpm ios:beta -- --version 2026.3.9-beta.1
80+
```
81+
82+
If you need to force a specific build number:
83+
84+
```bash
85+
pnpm ios:beta -- --version 2026.3.9-beta.1 --build-number 7
86+
```
87+
5388
## APNs Expectations For Local/Manual Builds
5489

5590
- The app calls `registerForRemoteNotifications()` at launch.

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,8 +2255,7 @@ extension NodeAppModel {
22552255
from: payload)
22562256
guard !decoded.actions.isEmpty else { return }
22572257
self.pendingActionLogger.info(
2258-
"Pending actions pulled trigger=\(trigger, privacy: .public) "
2259-
+ "count=\(decoded.actions.count, privacy: .public)")
2258+
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
22602259
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
22612260
} catch {
22622261
// Best-effort only.
@@ -2279,9 +2278,7 @@ extension NodeAppModel {
22792278
paramsJSON: action.paramsJSON)
22802279
let result = await self.handleInvoke(req)
22812280
self.pendingActionLogger.info(
2282-
"Pending action replay trigger=\(trigger, privacy: .public) "
2283-
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
2284-
+ "ok=\(result.ok, privacy: .public)")
2281+
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
22852282
guard result.ok else { return }
22862283
let acked = await self.ackPendingForegroundNodeAction(
22872284
id: action.id,
@@ -2306,9 +2303,7 @@ extension NodeAppModel {
23062303
return true
23072304
} catch {
23082305
self.pendingActionLogger.error(
2309-
"Pending action ack failed trigger=\(trigger, privacy: .public) "
2310-
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
2311-
+ "error=\(String(describing: error), privacy: .public)")
2306+
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
23122307
return false
23132308
}
23142309
}

apps/ios/fastlane/Fastfile

Lines changed: 125 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ require "open3"
33

44
default_platform(:ios)
55

6+
BETA_APP_IDENTIFIER = "ai.openclaw.client"
7+
68
def load_env_file(path)
79
return unless File.exist?(path)
810

@@ -84,6 +86,96 @@ def read_asc_key_content_from_keychain
8486
end
8587
end
8688

89+
def repo_root
90+
File.expand_path("../../..", __dir__)
91+
end
92+
93+
def ios_root
94+
File.expand_path("..", __dir__)
95+
end
96+
97+
def normalize_beta_version(raw_value)
98+
version = raw_value.to_s.strip.sub(/\Av/, "")
99+
UI.user_error!("Missing IOS_BETA_VERSION. Example: IOS_BETA_VERSION=2026.3.9-beta.1 fastlane ios beta") unless env_present?(version)
100+
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
101+
UI.user_error!("Invalid IOS_BETA_VERSION '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.")
102+
end
103+
104+
version
105+
end
106+
107+
def short_beta_version(version)
108+
normalize_beta_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "")
109+
end
110+
111+
def shell_join(parts)
112+
Shellwords.join(parts.compact)
113+
end
114+
115+
def resolve_beta_build_number(api_key:, version:)
116+
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
117+
if env_present?(explicit)
118+
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
119+
UI.message("Using explicit iOS beta build number #{explicit}.")
120+
return explicit
121+
end
122+
123+
short_version = short_beta_version(version)
124+
latest_build = latest_testflight_build_number(
125+
api_key: api_key,
126+
app_identifier: BETA_APP_IDENTIFIER,
127+
version: short_version,
128+
initial_build_number: 0
129+
)
130+
next_build = latest_build.to_i + 1
131+
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
132+
next_build.to_s
133+
end
134+
135+
def prepare_beta_release!(version:, build_number:)
136+
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
137+
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
138+
sh(shell_join(["bash", script_path, "--version", version, "--build-number", build_number]))
139+
140+
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
141+
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
142+
143+
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
144+
beta_xcconfig
145+
end
146+
147+
def build_beta_release(context)
148+
version = context[:version]
149+
output_directory = File.join("build", "beta")
150+
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
151+
152+
build_app(
153+
project: "OpenClaw.xcodeproj",
154+
scheme: "OpenClaw",
155+
configuration: "Release",
156+
export_method: "app-store",
157+
clean: true,
158+
skip_profile_detection: true,
159+
build_path: "build",
160+
archive_path: archive_path,
161+
output_directory: output_directory,
162+
output_name: "OpenClaw-#{version}.ipa",
163+
xcargs: "-allowProvisioningUpdates",
164+
export_xcargs: "-allowProvisioningUpdates",
165+
export_options: {
166+
signingStyle: "automatic"
167+
}
168+
)
169+
170+
{
171+
archive_path: archive_path,
172+
build_number: context[:build_number],
173+
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
174+
short_version: context[:short_version],
175+
version: version
176+
}
177+
end
178+
87179
platform :ios do
88180
private_lane :asc_api_key do
89181
load_env_file(File.join(__dir__, ".env"))
@@ -132,38 +224,46 @@ platform :ios do
132224
api_key
133225
end
134226

135-
desc "Build + upload to TestFlight"
136-
lane :beta do
227+
private_lane :prepare_beta_context do
137228
api_key = asc_api_key
229+
version = normalize_beta_version(ENV["IOS_BETA_VERSION"])
230+
build_number = resolve_beta_build_number(api_key: api_key, version: version)
231+
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
138232

139-
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
140-
if team_id.nil? || team_id.strip.empty?
141-
helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
142-
if File.exist?(helper_path)
143-
# Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
144-
team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
145-
end
146-
end
147-
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?
148-
149-
build_app(
150-
project: "OpenClaw.xcodeproj",
151-
scheme: "OpenClaw",
152-
export_method: "app-store",
153-
clean: true,
154-
skip_profile_detection: true,
155-
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
156-
export_xcargs: "-allowProvisioningUpdates",
157-
export_options: {
158-
signingStyle: "automatic"
159-
}
160-
)
233+
{
234+
api_key: api_key,
235+
beta_xcconfig: beta_xcconfig,
236+
build_number: build_number,
237+
short_version: short_beta_version(version),
238+
version: version
239+
}
240+
end
241+
242+
desc "Build a beta archive locally without uploading"
243+
lane :beta_archive do
244+
context = prepare_beta_context
245+
build = build_beta_release(context)
246+
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
247+
build
248+
ensure
249+
ENV.delete("XCODE_XCCONFIG_FILE")
250+
end
251+
252+
desc "Build + upload a beta to TestFlight"
253+
lane :beta do
254+
context = prepare_beta_context
255+
build = build_beta_release(context)
161256

162257
upload_to_testflight(
163-
api_key: api_key,
258+
api_key: context[:api_key],
259+
ipa: build[:ipa_path],
164260
skip_waiting_for_build_processing: true,
165261
uses_non_exempt_encryption: false
166262
)
263+
264+
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
265+
ensure
266+
ENV.delete("XCODE_XCCONFIG_FILE")
167267
end
168268

169269
desc "Upload App Store metadata (and optionally screenshots)"

apps/ios/fastlane/SETUP.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
3232
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
3333

3434
```bash
35-
ASC_APP_IDENTIFIER=ai.openclaw.ios
35+
ASC_APP_IDENTIFIER=ai.openclaw.client
3636
# or
37-
ASC_APP_ID=6760218713
37+
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
3838
```
3939

4040
File-based fallback (CI/non-macOS):
@@ -60,9 +60,29 @@ cd apps/ios
6060
fastlane ios auth_check
6161
```
6262

63-
Run:
63+
Archive locally without upload:
64+
65+
```bash
66+
pnpm ios:beta:archive -- --version 2026.3.9-beta.1
67+
```
68+
69+
Upload to TestFlight:
70+
71+
```bash
72+
pnpm ios:beta -- --version 2026.3.9-beta.1
73+
```
74+
75+
Direct Fastlane entry point:
6476

6577
```bash
6678
cd apps/ios
67-
fastlane beta
79+
IOS_BETA_VERSION=2026.3.9-beta.1 fastlane ios beta
6880
```
81+
82+
Versioning rules:
83+
84+
- Input release version uses CalVer beta format: `YYYY.M.D-beta.N`
85+
- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D`
86+
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
87+
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
88+
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched

apps/ios/fastlane/metadata/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
66

77
```bash
88
cd apps/ios
9-
ASC_APP_ID=6760218713 \
9+
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
1010
DELIVER_METADATA=1 fastlane ios metadata
1111
```
1212

apps/ios/project.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ targets:
224224
Release: Config/Signing.xcconfig
225225
settings:
226226
base:
227+
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
227228
ENABLE_APPINTENTS_METADATA: NO
228229
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
229230
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@
262262
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
263263
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
264264
"ghsa:patch": "node scripts/ghsa-patch.mjs",
265+
"ios:beta": "bash scripts/ios-beta-release.sh",
266+
"ios:beta:archive": "bash scripts/ios-beta-archive.sh",
267+
"ios:beta:prepare": "bash scripts/ios-beta-prepare.sh",
265268
"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'",
266269
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'",
267270
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",

0 commit comments

Comments
 (0)