From 908ec22812de5c74da6e7e41f9961d0a5ef71abc Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Wed, 11 Mar 2026 10:54:59 +0200 Subject: [PATCH] feat(ios): add local beta release flow --- apps/ios/Config/Signing.xcconfig | 8 +- apps/ios/README.md | 47 ++++++- apps/ios/Sources/Model/NodeAppModel.swift | 11 +- apps/ios/fastlane/Fastfile | 148 ++++++++++++++++++---- apps/ios/fastlane/SETUP.md | 28 +++- apps/ios/fastlane/metadata/README.md | 2 +- apps/ios/project.yml | 1 + package.json | 3 + scripts/ios-beta-archive.sh | 53 ++++++++ scripts/ios-beta-prepare.sh | 94 ++++++++++++++ scripts/ios-beta-release.sh | 53 ++++++++ scripts/ios-sync-version.ts | 97 ++++++++++++++ 12 files changed, 498 insertions(+), 47 deletions(-) create mode 100755 scripts/ios-beta-archive.sh create mode 100755 scripts/ios-beta-prepare.sh create mode 100755 scripts/ios-beta-release.sh create mode 100755 scripts/ios-sync-version.ts diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig index 1285d2a38a4..108dcf1a2f7 100644 --- a/apps/ios/Config/Signing.xcconfig +++ b/apps/ios/Config/Signing.xcconfig @@ -1,10 +1,10 @@ // Shared iOS signing defaults for local development + CI. 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/README.md b/apps/ios/README.md index c7c501fcbff..03da0692c3a 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,44 @@ 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` + +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`. +- Version input `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 -- --version 2026.3.9-beta.1 +``` + +Archive and upload to TestFlight: + +```bash +pnpm ios:beta -- --version 2026.3.9-beta.1 +``` + +If you need to force a specific build number: + +```bash +pnpm ios:beta -- --version 2026.3.9-beta.1 --build-number 7 +``` + ## APNs Expectations For Local/Manual Builds - The app calls `registerForRemoteNotifications()` at launch. 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/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 33e6bfa8adb..3bc3b4ba37d 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -3,6 +3,8 @@ require "open3" default_platform(:ios) +BETA_APP_IDENTIFIER = "ai.openclaw.client" + def load_env_file(path) return unless File.exist?(path) @@ -84,6 +86,96 @@ 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_beta_version(raw_value) + version = raw_value.to_s.strip.sub(/\Av/, "") + UI.user_error!("Missing IOS_BETA_VERSION. Example: IOS_BETA_VERSION=2026.3.9-beta.1 fastlane ios beta") unless env_present?(version) + unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i) + UI.user_error!("Invalid IOS_BETA_VERSION '#{raw_value}'. Expected 2026.3.9 or 2026.3.9-beta.1.") + end + + version +end + +def short_beta_version(version) + normalize_beta_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_beta_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 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, "--version", version, "--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 +224,46 @@ platform :ios do api_key end - desc "Build + upload to TestFlight" - lane :beta do + private_lane :prepare_beta_context do api_key = asc_api_key + version = normalize_beta_version(ENV["IOS_BETA_VERSION"]) + build_number = resolve_beta_build_number(api_key: api_key, version: version) + beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number) - 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? + { + api_key: api_key, + beta_xcconfig: beta_xcconfig, + build_number: build_number, + short_version: short_beta_version(version), + version: version + } + end - 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" - } - ) + desc "Build a beta archive locally without uploading" + lane :beta_archive do + context = prepare_beta_context + 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 + context = prepare_beta_context + 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..14035f9f21e 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,29 @@ cd apps/ios fastlane ios auth_check ``` -Run: +Archive locally without upload: + +```bash +pnpm ios:beta:archive -- --version 2026.3.9-beta.1 +``` + +Upload to TestFlight: + +```bash +pnpm ios:beta -- --version 2026.3.9-beta.1 +``` + +Direct Fastlane entry point: ```bash cd apps/ios -fastlane beta +IOS_BETA_VERSION=2026.3.9-beta.1 fastlane ios beta ``` + +Versioning rules: + +- Input release version uses CalVer beta format: `YYYY.M.D-beta.N` +- 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..82985a6ec72 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -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)" diff --git a/package.json b/package.json index 2e4dbc0d97e..a71c3dbbe40 100644 --- a/package.json +++ b/package.json @@ -262,6 +262,9 @@ "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: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 && 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'", diff --git a/scripts/ios-beta-archive.sh b/scripts/ios-beta-archive.sh new file mode 100755 index 00000000000..ab9c855ed22 --- /dev/null +++ b/scripts/ios-beta-archive.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/ios-beta-archive.sh --version 2026.3.9-beta.1 [--build-number 7] + +Archives and exports a beta-release IPA locally without uploading. +EOF +} + +VERSION="${IOS_BETA_VERSION:-}" +BUILD_NUMBER="${IOS_BETA_BUILD_NUMBER:-}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +while [[ $# -gt 0 ]]; do + case "$1" in + --) + shift + ;; + --version) + VERSION="${2:-}" + shift 2 + ;; + --build-number) + BUILD_NUMBER="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + echo "Missing required --version (or IOS_BETA_VERSION)." >&2 + usage + exit 1 +fi + +( + cd "${ROOT_DIR}/apps/ios" + IOS_BETA_VERSION="${VERSION}" \ + 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..38649a78f24 --- /dev/null +++ b/scripts/ios-beta-prepare.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/ios-beta-prepare.sh --version 2026.3.9-beta.1 --build-number 7 [--team-id TEAMID] + +Prepares local beta-release inputs without touching local signing overrides: +- stamps apps/ios/project.yml with the short version + build number +- 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" +BETA_XCCONFIG="${IOS_DIR}/build/BetaRelease.xcconfig" +TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh" + +VERSION="" +BUILD_NUMBER="" +TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --) + shift + ;; + --version) + VERSION="${2:-}" + shift 2 + ;; + --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 "${VERSION}" || -z "${BUILD_NUMBER}" ]]; then + echo "Missing required --version or --build-number." >&2 + usage + exit 1 +fi + +if [[ -z "${TEAM_ID}" ]]; then + TEAM_ID="$(IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash "${TEAM_HELPER}")" +fi + +mkdir -p "${IOS_DIR}/build" + +( + cd "${ROOT_DIR}" + node --import tsx scripts/ios-sync-version.ts \ + --version "${VERSION}" \ + --build-number "${BUILD_NUMBER}" +) + +cat >"${BETA_XCCONFIG}" <&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${VERSION}" ]]; then + echo "Missing required --version (or IOS_BETA_VERSION)." >&2 + usage + exit 1 +fi + +( + cd "${ROOT_DIR}/apps/ios" + IOS_BETA_VERSION="${VERSION}" \ + IOS_BETA_BUILD_NUMBER="${BUILD_NUMBER}" \ + fastlane ios beta +) diff --git a/scripts/ios-sync-version.ts b/scripts/ios-sync-version.ts new file mode 100755 index 00000000000..17d302a6ebe --- /dev/null +++ b/scripts/ios-sync-version.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env -S node --import tsx + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +type CliOptions = { + buildNumber: string; + version: string; +}; + +function parseArgs(argv: string[]): CliOptions { + const options = new Map(); + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + continue; + } + + const key = arg.slice(2); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`); + } + options.set(key, value); + i += 1; + } + + const version = options.get("version")?.trim(); + const buildNumber = options.get("build-number")?.trim(); + + if (!version) { + throw new Error("Missing required --version"); + } + if (!buildNumber) { + throw new Error("Missing required --build-number"); + } + if (!/^[0-9]+$/.test(buildNumber)) { + throw new Error(`Invalid --build-number '${buildNumber}'; expected digits only.`); + } + + return { buildNumber, version }; +} + +function toShortVersion(input: string): string { + const trimmed = input.trim().replace(/^v/, ""); + const shortVersion = trimmed.replace(/([.-]?beta[.-]\d+)$/i, ""); + if (!/^\d+\.\d+\.\d+$/.test(shortVersion)) { + throw new Error( + `Invalid --version '${input}'; expected CalVer like 2026.3.9 or 2026.3.9-beta.1.`, + ); + } + return shortVersion; +} + +function replaceAllExact(params: { content: string; pattern: RegExp; replacement: string }) { + const { content, pattern, replacement } = params; + const matches = [...content.matchAll(pattern)]; + if (matches.length === 0) { + throw new Error(`Pattern not found: ${pattern}`); + } + return content.replace(pattern, replacement); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const shortVersion = toShortVersion(options.version); + const projectPath = resolve("apps/ios/project.yml"); + const original = readFileSync(projectPath, "utf8"); + + let updated = original; + updated = replaceAllExact({ + content: updated, + pattern: /(CFBundleShortVersionString:\s*")[^"]+(")/g, + replacement: `$1${shortVersion}$2`, + }); + updated = replaceAllExact({ + content: updated, + pattern: /(CFBundleVersion:\s*")[^"]+(")/g, + replacement: `$1${options.buildNumber}$2`, + }); + + if (updated === original) { + console.log(`iOS version already set: short=${shortVersion} build=${options.buildNumber}`); + return; + } + + writeFileSync(projectPath, updated); + console.log(`Updated iOS project version: short=${shortVersion} build=${options.buildNumber}`); +} + +try { + main(); +} catch (error) { + console.error(`ios-sync-version: ${(error as Error).message}`); + process.exit(1); +}