feat(ios): add local beta release flow

This commit is contained in:
Nimrod Gutman
2026-03-11 10:54:59 +02:00
parent 665f677265
commit 908ec22812
12 changed files with 498 additions and 47 deletions

View File

@@ -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.

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,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.

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

@@ -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)"

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,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

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

@@ -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)"

View File

@@ -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'",

53
scripts/ios-beta-archive.sh Executable file
View File

@@ -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
)

94
scripts/ios-beta-prepare.sh Executable file
View File

@@ -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}" <<EOF
// Auto-generated by scripts/ios-beta-prepare.sh.
// Local beta-release override; do not commit.
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_DEVELOPMENT_TEAM = ${TEAM_ID}
OPENCLAW_IOS_SELECTED_TEAM = ${TEAM_ID}
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
EOF
(
cd "${IOS_DIR}"
xcodegen generate
)
echo "Prepared iOS beta release: version=${VERSION} build=${BUILD_NUMBER} team=${TEAM_ID}"
echo "XCODE_XCCONFIG_FILE=${BETA_XCCONFIG}"

53
scripts/ios-beta-release.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-beta-release.sh --version 2026.3.9-beta.1 [--build-number 7]
Archives and uploads a beta-release IPA to TestFlight locally.
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
)

97
scripts/ios-sync-version.ts Executable file
View File

@@ -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<string, string>();
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);
}