feat(ios): distribute daily TestFlight builds for external testing (#161460)

* feat(ios): distribute daily TestFlight builds for external testing

* fix(ios): reuse App Store builds for first TestFlight distribution

Recognize the App Store-selected build of the same recorded source when it is eligible for beta submission and has no beta notes. Freeze its identity and notes and stage it without another archive or upload. Verify the adopted build ID and preserve existing beta notes on recovery.

The CLI and registered Fastlane regression cases fail before the correction. The complete two-file suite passes 43 cases; the noted-store recovery guard also passes. Script and affected root-test types, scoped lint, formatting, and independent P0-P2 review pass.
This commit is contained in:
Josh Avant 2026-09-29 20:43:46 -05:00 • committed by GitHub
parent 477250c066
commit 8c6fbf5f89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1236 additions and 160 deletions

View file

@ -1,17 +1,21 @@
name: iOS Store Release
run-name: iOS Store ${{ inputs.operation }}
run-name: iOS ${{ (github.event_name == 'schedule' || inputs.operation == 'testflight') && 'TestFlight distribution' || format('Store {0}', inputs.operation) }}
on:
schedule:
- cron: "0 7 * * *"
timezone: America/Los_Angeles
workflow_dispatch:
inputs:
operation:
description: Upload a release or capture screenshots without uploading
description: Stage an App Store release, distribute TestFlight, or capture screenshots
required: true
default: release
type: choice
options:
- release
- testflight
- screenshots
permissions: {}
@ -28,7 +32,7 @@ env:
jobs:
screenshots:
name: Capture release screenshots without uploading
if: inputs.operation == 'screenshots' && github.repository == 'openclaw/openclaw'
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'screenshots' && github.repository == 'openclaw/openclaw'
permissions:
contents: read
runs-on: xcode-27-xlarge
@ -100,7 +104,10 @@ jobs:
retention-days: 14
qualify:
if: inputs.operation == 'release' && github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw'
if: >-
github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw' &&
((github.event_name == 'schedule' && vars.IOS_TESTFLIGHT_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'release' || inputs.operation == 'testflight')))
permissions:
contents: read
# Qualification rebuilds tracked plugin manifests; keep its workspace separate
@ -113,12 +120,15 @@ jobs:
release:
name: Prepare and upload iOS release
needs: qualify
if: inputs.operation == 'release' && github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw'
if: >-
github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw' &&
((github.event_name == 'schedule' && vars.IOS_TESTFLIGHT_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'release' || inputs.operation == 'testflight')))
permissions:
contents: write
runs-on: xcode-27-xlarge
timeout-minutes: 360
environment: ios-store-release
environment: ${{ (github.event_name == 'schedule' || inputs.operation == 'testflight') && 'ios-testflight' || 'ios-store-release' }}
steps:
- name: Checkout release source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -226,12 +236,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
IOS_RELEASE_DESTINATION: ${{ (github.event_name == 'schedule' || inputs.operation == 'testflight') && 'testflight' || 'app-store' }}
OPENCLAW_TESTFLIGHT_GROUP_ID: ${{ vars.OPENCLAW_TESTFLIGHT_GROUP_ID }}
SCAN_APP_NAME: OpenClaw
SCAN_DEPLOYMENT_TARGET_VERSION: "18.0"
run: |
set -euo pipefail
gh auth setup-git
pnpm ios:release:upload -- \
--destination "$IOS_RELEASE_DESTINATION" \
--recovery-dir "$RUNNER_TEMP/ios-release-recovery"
- name: Retain release plan and notes
@ -242,6 +255,7 @@ jobs:
path: |
${{ runner.temp }}/ios-release-recovery/ios-plan.json
${{ runner.temp }}/ios-release-recovery/release-notes.json
${{ runner.temp }}/ios-release-recovery/testflight-result.json
if-no-files-found: error
retention-days: 30

View file

@ -26,16 +26,17 @@ Root rules still apply. This file adds the iOS release guardrails.
- Keep Licenses in the offline Settings fallback's Device section and in the Dashboard's This iPhone/This iPad page through the native bridge panel.
- When changing license loading or presentation, update `apps/ios/Tests/LicenseDocumentLoaderTests.swift` and `apps/ios/Tests/SwiftUIRenderSmokeTests.swift`, then run focused iOS tests.
## App Store Releases
## App Store and TestFlight Releases
- Agent-driven App Store uploads must use only `pnpm ios:release:upload`.
- Run **iOS Store Release** from `main` or `pnpm ios:release:upload` without release arguments. The release entry point owns live planning, generated notes, the unchanged source SHA, upload, and staging of the processed build. It saves notes as an immutable artifact and does not edit tracked files or create preparation commits or metadata PRs.
- The planner derives the gateway version from root `package.json`, reuses the one editable App Store revision for that gateway, retries an unreleased revision found in App Store Connect build-upload history, and allocates the next revision only after released history. Historical exact gateway versions consume revision zero.
- Agent-driven iOS uploads must use only `pnpm ios:release:upload`. The default destination is App Store staging; `--destination testflight` distributes to the configured external TestFlight group without staging the App Store listing.
- Run **iOS Store Release** from `main` with operation `release` or `testflight`, or use the matching `pnpm ios:release:upload` destination locally. The release entry point owns live planning, generated notes, the unchanged source SHA, upload, and staging of the processed build. It saves notes as an immutable artifact and does not edit tracked files or create preparation commits or metadata PRs.
- The planner derives the gateway version from root `package.json`. App Store staging reuses the one editable revision for that gateway, retries an unreleased revision found in App Store Connect build-upload history, and allocates the next revision only after released history. Historical exact gateway versions consume revision zero.
- Build allocation uses App Store Connect `buildUploads`, including awaiting, processing, failed, and complete uploads. Every Apple-visible attempt consumes its build number; retries increment the build within the same App Store revision.
- Only one iOS release uploader may run at a time. Multiple active App Store versions, locked/in-review state, a different active gateway, unknown upload state, or revision exhaustion must fail closed for human resolution.
- Only one iOS release uploader may run at a time. App Store staging fails closed for multiple active versions, locked/in-review state, or a different active gateway. TestFlight planning uses the current gateway train independently of editable App Store drafts. Unknown upload state or revision exhaustion still fails closed.
- `--version`, `--revision`, and `--build-number` remain checked overrides. The pipeline must reject an override that differs from the live deterministic plan. `--version` is always the gateway version, never the encoded App Store version.
- Do not infer release identity from the current date, mobile-release refs, or generated local files. Generate store notes from changes since the exact build attached to the latest public App Store version. Require the saved notes artifact; historical changelog tools do not supply or gate store notes.
- If `pnpm ios:release:upload` exits non-zero, stop immediately and report the failing step.
- After a failed `pnpm ios:release:upload`, do not continue with a lower-level upload path. For an already processed upload whose notes or build selection failed, use `node scripts/mobile-release.mjs stage --platform ios --recovery-dir DIR` with its saved artifacts; this must never reupload or regenerate notes. Otherwise inspect and reconcile App Store Connect state before another upload attempt.
- After a failed `pnpm ios:release:upload`, do not continue with a lower-level upload path. For an already processed upload whose notes, build selection, or TestFlight distribution failed, use `node scripts/mobile-release.mjs stage --platform ios --recovery-dir DIR` with its saved artifacts; this must never reupload or regenerate notes. Otherwise inspect and reconcile App Store Connect state before another upload attempt.
- Daily TestFlight runs use the `ios-testflight` environment, the configured external group ID, and existing beta metadata. Submit for TestFlight beta review when required and enable automatic tester notification; preserve pending reviews and use saved-artifact recovery for partially completed distribution. See [TestFlight distribution](VERSIONING.md#testflight-distribution).
- Do not submit an iOS App Store version for App Review. App Review submission stays manual unless the user explicitly asks to submit a specific already-prepared version after the failed state has been reported.
- `pnpm ios:release:archive` is for local archive validation only. It is not a fallback release path after screenshot, metadata, or upload-lane failure.

View file

@ -58,7 +58,7 @@ Or use the same release entry point from a clean local `main` checkout that matc
pnpm ios:release:upload
```
GitHub releases freeze the `main` commit selected when the workflow is dispatched,
GitHub releases freeze the `main` commit selected when the workflow is triggered,
even if `main` advances while the run is queued. Local releases freeze current
`main`. The entry point freezes the live plan, generates and reviews release
notes from Git history, and saves them in an immutable JSON
@ -83,6 +83,60 @@ overrides, never alternate release identities. No release arguments are required
pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3
```
## TestFlight distribution
Run **iOS Store Release** with operation **testflight** from `main`:
```bash
gh workflow run ios-store-release.yml --ref main -f operation=testflight
```
The same local entry point accepts the destination explicitly:
```bash
OPENCLAW_TESTFLIGHT_GROUP_ID="<EXTERNAL_GROUP_ID>" pnpm ios:release:upload -- --destination testflight
```
The scheduled operation runs daily at **7:00 AM America/Los_Angeles**, including
daylight saving time. GitHub may delay scheduled jobs. Both release destinations
share the `ios-release` concurrency lock and native release qualification.
TestFlight uses the main-only `ios-testflight` environment without per-run
approval; App Store staging retains `ios-store-release` and its approval rules.
See [environment setup](fastlane/SETUP.md#github-actions) for activation and
credentials.
TestFlight uses the existing **External Testing** group, pinned by
`OPENCLAW_TESTFLIGHT_GROUP_ID`, and the beta metadata configured in App Store
Connect. The preflight validates group ownership and external-testing status,
and required beta contact and review information before archiving. It does not capture store screenshots, stage listing metadata, select
an App Store build, or submit a public App Store version for App Review.
The planner allocates builds within the current gateway's TestFlight train using
Apple's upload history, independently of whether an App Store draft is editable.
It preserves pending beta reviews and defers a new same-train upload while one
is pending. An unchanged source SHA is skipped only when its build is awaiting
review or available to testers and the group, notes, and automatic notification
settings are verified. Each skip or deferral records its reason in
`testflight-result.json`.
If the same source already has a build selected by the App Store draft, is ready
for beta submission, and has no beta notes, TestFlight reuses it. The attempt saves
a TestFlight plan and new beta notes, then stages that exact build without another
archive or upload. Builds with existing beta notes require their saved recovery
artifacts so a partial distribution cannot silently regenerate its notes.
After processing, the pipeline saves the immutable source ref, writes the saved
What to Test notes, assigns the external group, and submits for TestFlight review
when the build is eligible. Automatic tester notification distributes the build
after Apple approves it. The result distinguishes pending review from a build
available to testers; the runner does not wait for human review. Apple determines
whether each build requires review, so the schedule is a daily distribution
attempt rather than a guarantee of daily availability.
If distribution fails after upload, use [staging recovery](#staging-recovery)
with the saved artifacts. Recovery reads the destination from `ios-plan.json`
and resumes the same build without another upload or regenerated notes.
## Screenshot-only validation
Run **iOS Store Release** with operation **screenshots** and select the candidate
@ -97,7 +151,7 @@ pnpm ios:screenshots
The command builds the simulator app, captures four screenshots each on iPhone
and 13-inch iPad, and captures the Apple Watch screenshot. It does not generate
release notes, archive an IPA, or access signing assets or App Store credentials.
The existing release operation remains restricted to `main`.
Both upload operations remain restricted to `main`.
Capture creates a fresh simulator for each selected device type and runtime,
then shuts down and deletes that exact simulator before starting the next one.
@ -144,7 +198,7 @@ therefore the appended App Store version.
numbers; every Apple-visible upload reservation or attempt does.
- App Review submission remains manual.
Before screenshot or archive work, the upload lane checks App Store Connect:
Before screenshot or archive work, the App Store destination checks App Store Connect:
- an absent version may be created during metadata staging
- the one editable version for the current gateway is reused
@ -211,6 +265,7 @@ Generated or derived files:
- `apps/ios/build/AppStoreRelease.xcconfig`
- `apps/ios/SwiftSources.input.xcfilelist`
- `ios-plan.json` and `release-notes.json` in the printed recovery directory
- `testflight-result.json` for TestFlight outcomes, including skips and pending review
- temporary Fastlane metadata for screenshots and the App Review attachment
The canonical implementation is split across:
@ -244,8 +299,8 @@ refs/openclaw/mobile-releases/ios/2026.7.21-3
The ref is checked before archive/upload work and created only after App Store
Connect finishes processing the upload, before notes and build selection are
staged. Existing refs are immutable; their presence proves the uploaded source,
not successful completion of later staging.
staged or TestFlight distribution begins. Existing refs are immutable; their
presence proves the uploaded source, not successful completion of later staging.
## Normal workflow
@ -260,8 +315,9 @@ not successful completion of later staging.
## Staging recovery
If upload and processing succeeded but saving notes or selecting the build
failed, retain the printed recovery directory or download its workflow artifact.
If upload and processing succeeded but saving notes, selecting the App Store
build, or completing TestFlight distribution failed, retain the printed recovery
directory or download its workflow artifact.
Retry staging from a clean checkout using that original saved state:
```bash
@ -271,12 +327,14 @@ node scripts/mobile-release.mjs stage --platform ios --recovery-dir /path/to/rec
This verifies the immutable upload ref, restores the original source if needed,
and uses the saved notes and build identity. It does not generate new notes,
replan a release, build, or upload another IPA. App Store Connect credentials
are required. A locked version, invalid or expired build, newer selected build,
or mismatched source stops recovery for human resolution. Partial staging can
be retried with the same command after the cause is fixed.
are required. Invalid or expired builds and mismatched source stop recovery for
human resolution. App Store recovery also refuses a locked version or newer
selected build. TestFlight recovery uses the saved group identity and existing
review submission, and records the current distribution state. Partial staging
can be retried with the same command after the cause is fixed.
The recovery directory contains the saved plan and notes, any exported signed
binaries under `artifacts/`, and screenshot fixture PNGs and the capture-attempt
The recovery directory contains the saved plan and notes, the TestFlight result
when applicable, any exported signed binaries under `artifacts/`, and screenshot fixture PNGs and the capture-attempt
ledger under `screenshot-diagnostics/`. Raw Xcode logs and XCTest results are
excluded because they can contain credentials. Failed local attempts also keep
their source worktree; staging recovery can restore source from the immutable

View file

@ -9,6 +9,7 @@ require "digest/md5"
require "time"
require "rubygems/version"
require_relative "screenshot_diagnostics"
require_relative "testflight"
default_platform(:ios)
@ -1320,7 +1321,8 @@ def ios_public_release_notes_baseline(versions)
{ audience: "ios", version: latest.version_string, build: build.version.to_s }
end
def resolve_ios_release_plan!(release_version: nil, app_store_revision: nil, build_number: nil)
def resolve_ios_release_plan!(release_version: nil, app_store_revision: nil, build_number: nil, destination: nil)
destination = ios_release_destination(destination)
gateway_metadata = read_ios_version_metadata
if env_present?(release_version.to_s)
explicit_gateway = read_ios_version_metadata(release_version: release_version)[:version]
@ -1331,12 +1333,14 @@ def resolve_ios_release_plan!(release_version: nil, app_store_revision: nil, bui
end
end
app = app_store_connect_target_app
testflight_group = preflight_testflight!(app: app) if destination == "testflight"
versions = app.get_app_store_versions(
filter: { platform: Spaceship::ConnectAPI::Platform::IOS },
includes: nil
)
uploads = app_store_build_uploads(app_id: app.id)
input = {
destination: destination,
appStoreVersions: versions.map do |version|
{
id: version.id.to_s,
@ -1380,7 +1384,11 @@ def resolve_ios_release_plan!(release_version: nil, app_store_revision: nil, bui
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Unable to resolve deterministic iOS release plan: #{detail}")
end
JSON.parse(stdout)
plan = JSON.parse(stdout)
if testflight_group
plan["testflight"] = testflight_plan_facts(app: app, group: testflight_group, short_version: plan.fetch("appStoreVersion"), versions: versions)
end
plan
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from iOS release planner: #{e.message}")
end
@ -1951,6 +1959,7 @@ end
platform :ios do
private_lane :prepare_app_store_context do |options|
destination = ios_release_destination(options[:destination])
require_api_key = options[:require_api_key] == true
release_version = options[:release_version].to_s.strip
app_store_revision = options[:app_store_revision].to_s.strip
@ -1962,9 +1971,11 @@ platform :ios do
release_plan = resolve_ios_release_plan!(
release_version: release_version,
app_store_revision: app_store_revision,
build_number: explicit_build_number
build_number: explicit_build_number,
destination: destination
)
assert_ios_release_notes_baseline!(release_plan)
assert_testflight_upload_ready!(release_plan) if destination == "testflight"
release_version = release_plan.fetch("gatewayVersion")
app_store_revision = release_plan.fetch("appStoreRevision").to_s
explicit_build_number = release_plan.fetch("buildNumber").to_s
@ -2000,6 +2011,7 @@ platform :ios do
{
api_key: api_key,
destination: destination,
app_store_revision: version_metadata[:app_store_revision],
build_timestamp: provenance[:build_timestamp],
build_number: build_number,
@ -2017,13 +2029,14 @@ platform :ios do
plan = resolve_ios_release_plan!(
release_version: options[:release_version],
app_store_revision: options[:app_store_revision],
build_number: options[:build_number]
build_number: options[:build_number],
destination: options[:destination]
)
output_path = options[:output_path].to_s.strip
UI.user_error!("Missing release plan output_path.") if output_path.empty?
File.write(output_path, "#{JSON.pretty_generate(plan)}\n")
UI.success(
"Planned iOS App Store release: gateway=#{plan.fetch("gatewayVersion")} " \
"Planned iOS #{plan.fetch("destination")} release: gateway=#{plan.fetch("gatewayVersion")} " \
"revision=#{plan.fetch("appStoreRevision")} short=#{plan.fetch("appStoreVersion")} " \
"build=#{plan.fetch("buildNumber")} decision=#{plan.fetch("decision")}"
)
@ -2075,7 +2088,7 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
desc "Upload an iOS build and stage its selected App Store or TestFlight destination"
lane :release_upload do |options|
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane upload is disabled.")
@ -2086,24 +2099,27 @@ platform :ios do
require_api_key: true,
release_version: options[:release_version],
app_store_revision: options[:app_store_revision],
build_number: options[:build_number]
build_number: options[:build_number],
destination: options[:destination]
)
render_ios_release_notes(short_version: context[:short_version], build_number: context[:build_number])
release_sha = context[:git_commit]
preflight_app_store_version!(short_version: context[:short_version])
preflight_app_store_version!(short_version: context[:short_version]) if context[:destination] == "app-store"
ensure_mobile_release_ref_available!(
platform: "ios",
version: context[:short_version],
build: context[:build_number],
sha: release_sha
)
without_xcode_xcconfig_file do
preserve_local_signing do
screenshots(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
build_number: context[:build_number]
)
if context[:destination] == "app-store"
without_xcode_xcconfig_file do
preserve_local_signing do
screenshots(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
build_number: context[:build_number]
)
end
end
end
verify_apple_release_source!(release_sha)
@ -2114,18 +2130,23 @@ platform :ios do
current_plan = resolve_ios_release_plan!(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
build_number: context[:build_number]
build_number: context[:build_number],
destination: context[:destination]
)
assert_ios_release_notes_baseline!(current_plan)
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "0"
metadata(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
review_attachment: true
)
if context[:destination] == "testflight"
assert_testflight_upload_ready!(current_plan)
else
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "0"
metadata(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
review_attachment: true
)
end
upload_to_testflight(
api_key: context[:api_key],
@ -2145,9 +2166,10 @@ platform :ios do
release_stage(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
build_number: context[:build_number]
build_number: context[:build_number],
destination: context[:destination]
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.success("Uploaded iOS #{context[:destination]} build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
UI.important("App Review submission remains manual in App Store Connect.")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
@ -2167,10 +2189,14 @@ platform :ios do
version = read_ios_version_metadata(release_version: release_version, app_store_revision: app_store_revision)
notes = render_ios_release_notes(short_version: version[:short_version], build_number: build_number)
assert_ios_uploaded_release_source!(short_version: version[:short_version], build_number: build_number)
app_store_connect_api_key_config
notes_staged = stage_ios_app_store_release!(short_version: version[:short_version], build_number: build_number, notes: notes)
notes_result = notes_staged ? "saved notes staged" : "first-version notes retained in the artifact"
UI.success("Selected iOS build #{version[:short_version]} (#{build_number}); #{notes_result}.")
api_key = app_store_connect_api_key_config
if ios_release_destination(options[:destination]) == "testflight"
stage_ios_testflight_release!(api_key: api_key, short_version: version[:short_version], build_number: build_number, notes: notes)
else
notes_staged = stage_ios_app_store_release!(short_version: version[:short_version], build_number: build_number, notes: notes)
notes_result = notes_staged ? "saved notes staged" : "first-version notes retained in the artifact"
UI.success("Selected iOS build #{version[:short_version]} (#{build_number}); #{notes_result}.")
end
UI.important("App Review submission remains manual in App Store Connect.")
end

View file

@ -214,8 +214,8 @@ These diagnostics produce `native-build`/`built` or `gateway-probe`/`probe-passe
proofs, respectively. Neither is release qualification. Gateway runtime preparation
continues to use the existing build owner's cache in every mode.
The stock gate runs in **iOS Store Release** after native tool setup and before signing
assets are accessed. It qualifies the checked-out `main` commit used for release
The stock gate runs for both upload destinations in **iOS Store Release** after
native tool setup and before signing assets are accessed. It qualifies the checked-out `main` commit used for release
preparation and records the installed Xcode version and build without requiring
a specific Xcode version. Manual CI also requires the stock gate when
`validation_tier=full` and its checkout revision equals the workflow run's SHA.
@ -254,14 +254,44 @@ and prevent native test execution.
## GitHub Actions
Run **iOS Store Release** from `main` using the `ios-store-release` environment.
The workflow has no input parameters and uses the same
`pnpm ios:release:upload` entry point. It installs the pinned build tools, uses
readonly encrypted signing assets and a job-owned temporary keychain, and
uploads screenshots, the App Review PDF attachment, and the IPA. After Apple
processing it stages the saved release notes and selects the exact build.
App Review submission remains manual. For a failure after upload, use
[staging recovery](../VERSIONING.md#staging-recovery); do not repeat the upload.
Run **iOS Store Release** from `main` with one of these operations:
| Operation | Environment | Outcome |
| --- | --- | --- |
| `release` (default) | `ios-store-release` | Upload screenshots, the App Review attachment, and the IPA; stage saved notes and select the processed build for manual App Review. |
| `testflight` | `ios-testflight` | Upload the IPA, assign the external group, and submit for TestFlight review when required; automatically notify testers after approval. |
| `screenshots` | None | Capture screenshots without signing or upload; candidate branches are allowed. |
Both upload operations use `pnpm ios:release:upload`, the pinned build tools,
readonly encrypted signing assets, a job-owned temporary keychain, and the shared
`ios-release` concurrency lock. TestFlight does not stage the App Store listing.
For a failure after upload, use [staging recovery](../VERSIONING.md#staging-recovery)
with the saved destination; do not repeat the upload.
Create `ios-testflight` as a GitHub environment restricted to `main` with no
required reviewers, and make the secrets below available to it. Keep the
`ios-store-release` environment's existing approval policy.
Set `OPENCLAW_TESTFLIGHT_GROUP_ID` as an `ios-testflight` environment variable to
the existing **External Testing** group's App Store Connect ID. Populate the
app's required TestFlight beta metadata in App Store Connect before the first
run, including feedback email, review contact, and reviewer access instructions.
The pipeline validates these values and never copies or stages App Store listing
metadata for a TestFlight run.
Daily TestFlight runs are scheduled at **7:00 AM America/Los_Angeles**, with
daylight saving time handled by GitHub. Initially leave the repository variable
`IOS_TESTFLIGHT_ENABLED` unset or `false`. Run one manual distribution:
```bash
gh workflow run ios-store-release.yml --ref main -f operation=testflight
```
Inspect `testflight-result.json` in the recovery artifact and the matching build
and group in App Store Connect. Once the manual flow is verified, set repository
variable `IOS_TESTFLIGHT_ENABLED` to `true` to enable scheduled runs. Manual
TestFlight dispatch is available regardless of that activation variable. Set it
back to `false` to stop future scheduled jobs without disabling manual releases.
Repository/environment secrets required by name:
@ -272,8 +302,9 @@ Repository/environment secrets required by name:
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_KEY_CONTENT`
App Store Connect supplies the revision and next build number. No TestFlight
group ID or manually prepared mobile release branch is required.
App Store Connect supplies the revision and next build number. The TestFlight
destination requires the external group variable; App Store staging does not.
Neither destination requires a prepared mobile release branch.
Local authentication setup for a fresh clone on the same Mac:
@ -323,5 +354,5 @@ Versioning rules:
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
- `pnpm ios:release:upload` stages screenshots and the App Review PDF attachment before uploading the IPA, waits for processing, then stages saved notes and selects the build. It does not submit for App Review or upload the App Store Connect `Notes` field
- The default `pnpm ios:release:upload` destination stages screenshots and the App Review PDF attachment before uploading the IPA, waits for processing, then stages saved notes and selects the build. It does not submit for App Review or upload the App Store Connect `Notes` field
- See `apps/ios/VERSIONING.md` for the detailed workflow

View file

@ -0,0 +1,160 @@
TESTFLIGHT_REVIEW_STATES = %w[WAITING_FOR_BETA_REVIEW IN_BETA_REVIEW].freeze
TESTFLIGHT_AVAILABLE_STATES = %w[READY_FOR_BETA_TESTING IN_BETA_TESTING].freeze
def ios_release_destination(value)
destination = value.to_s.strip
destination = "app-store" if destination.empty?
UI.user_error!("Unsupported iOS release destination #{destination}.") unless %w[app-store testflight].include?(destination)
destination
end
def preflight_testflight!(app:, expected_group_id: nil)
group_id = ENV["OPENCLAW_TESTFLIGHT_GROUP_ID"].to_s.strip
UI.user_error!("Set OPENCLAW_TESTFLIGHT_GROUP_ID to the existing external testing group's ID.") if group_id.empty?
if expected_group_id && group_id != expected_group_id
UI.user_error!("The TestFlight group changed after planning; restore the saved group before continuing.")
end
groups = app.get_beta_groups
group = groups.find { |candidate| candidate.id == group_id }
unless group && group.is_internal_group == false
UI.user_error!("The configured TestFlight group must belong to this app and be external.")
end
# Pilot accepts both names and IDs. Prevent a second group's name from
# broadening the requested ID into an unintended distribution target.
if groups.any? { |candidate| candidate.id != group_id && candidate.name == group_id }
UI.user_error!("The TestFlight group ID also names another group; resolve the ambiguous group name before distributing.")
end
localizations = app.get_beta_app_localizations.select { |localization| localization.locale == "en-US" }
unless localizations.length == 1 && %i[description feedback_email].all? { |field| env_present?(localizations.first.public_send(field).to_s) }
UI.user_error!("Complete the en-US TestFlight beta description and feedback email in App Store Connect.")
end
details = Spaceship::ConnectAPI.get_beta_app_review_detail(filter: { app: app.id }).all_pages.flat_map(&:to_models)
required = %i[contact_first_name contact_last_name contact_email contact_phone notes]
unless details.length == 1 && required.all? { |field| env_present?(details.first.public_send(field).to_s) }
UI.user_error!("Complete the TestFlight review contact and reviewer instructions in App Store Connect.")
end
detail = details.first
if detail.demo_account_required == true && %i[demo_account_name demo_account_password].any? { |field| !env_present?(detail.public_send(field).to_s) }
UI.user_error!("TestFlight requires a demo account; complete its credentials in App Store Connect.")
end
group
end
def testflight_train_builds(app:, short_version:)
Spaceship::ConnectAPI::Build.all(
app_id: app.id, version: short_version, platform: Spaceship::ConnectAPI::Platform::IOS,
includes: "preReleaseVersion,buildBetaDetail", limit: 200
).select { |build| build.processing_state == "VALID" && build.expired == false }
end
def testflight_build_facts(build, group_build_ids, store_build_ids)
localizations = build.get_beta_build_localizations
english = localizations.select { |localization| localization.locale == "en-US" }
{
"id" => build.id,
"shortVersion" => build.app_version,
"buildNumber" => build.version.to_s,
"externalState" => build.build_beta_detail&.external_build_state,
"hasBetaNotes" => localizations.any? { |localization| env_present?(localization.whats_new.to_s) },
"selectedForAppStore" => store_build_ids.include?(build.id),
"configured" => group_build_ids.include?(build.id) && build.build_beta_detail&.auto_notify_enabled == true &&
english.length == 1 && env_present?(english.first.whats_new.to_s)
}
end
def testflight_plan_facts(app:, group:, short_version:, versions:)
group_build_ids = group.fetch_builds.map(&:id)
store_build_ids = versions.select { |version| version.version_string == short_version }.filter_map { |version| version.get_build&.id }
builds = testflight_train_builds(app: app, short_version: short_version).map { |build| testflight_build_facts(build, group_build_ids, store_build_ids) }
pending = builds.select { |build| TESTFLIGHT_REVIEW_STATES.include?(build.fetch("externalState")) }
UI.user_error!("Multiple TestFlight builds are in review for #{short_version}; reconcile App Store Connect before continuing.") if pending.length > 1
{ "groupId" => group.id, "builds" => builds, "pendingBuild" => pending.first }
end
def assert_testflight_upload_ready!(plan)
frozen = JSON.parse(File.read(ENV.fetch("OPENCLAW_IOS_RELEASE_PLAN")))
unless frozen.fetch("destination") == "testflight" && frozen.fetch("testflight").fetch("groupId") == plan.fetch("testflight").fetch("groupId")
UI.user_error!("TestFlight destination or group changed after planning; start a new release attempt.")
end
pending = plan.fetch("testflight").fetch("pendingBuild")
if pending
UI.user_error!("TestFlight build #{pending.fetch("shortVersion")} (#{pending.fetch("buildNumber")}) is already awaiting review; no new upload was attempted.")
end
end
def stage_ios_testflight_release!(api_key:, short_version:, build_number:, notes:)
frozen = JSON.parse(File.read(ENV.fetch("OPENCLAW_IOS_RELEASE_PLAN")))
unless frozen.fetch("destination") == "testflight" && frozen.fetch("appStoreVersion") == short_version && frozen.fetch("buildNumber").to_s == build_number
UI.user_error!("TestFlight staging requires the exact saved release destination and build identity.")
end
app = app_store_connect_target_app
group = preflight_testflight!(app: app, expected_group_id: frozen.fetch("testflight").fetch("groupId"))
builds = testflight_train_builds(app: app, short_version: short_version)
matches = builds.select { |build| build.version.to_s == build_number }
UI.user_error!("Expected one valid, unexpired processed TestFlight build #{short_version} (#{build_number}); found #{matches.length}. No upload was attempted.") unless matches.length == 1
build = matches.first
state = build.build_beta_detail&.external_build_state
pending = builds.find { |candidate| candidate.id != build.id && TESTFLIGHT_REVIEW_STATES.include?(candidate.build_beta_detail&.external_build_state) }
if pending && state == "READY_FOR_BETA_SUBMISSION"
UI.user_error!("TestFlight build #{pending.version} is already awaiting review in this train; retry staging after its review completes.")
end
unless ["READY_FOR_BETA_SUBMISSION", "BETA_APPROVED", *TESTFLIGHT_REVIEW_STATES, *TESTFLIGHT_AVAILABLE_STATES].include?(state)
UI.user_error!("TestFlight build #{short_version} (#{build_number}) cannot be distributed in state #{state || "unknown"}; inspect App Store Connect before retrying staging.")
end
require "pilot"
expected_notes = Pilot::BuildManager.sanitize_changelog(notes)
UI.user_error!("Saved TestFlight What to Test notes are empty after Apple's formatting restrictions.") unless env_present?(expected_notes)
existing_build_id = frozen.fetch("testflight")["existingBuildId"]
if existing_build_id
UI.user_error!("TestFlight staging target does not match the saved existing build.") unless build.id == existing_build_id
# Adopting a store upload starts its first beta distribution. Only a retry
# of these exact saved notes may reuse an already-noted build.
different_notes = build.get_beta_build_localizations.any? do |localization|
env_present?(localization.whats_new.to_s) &&
(localization.locale != "en-US" || localization.whats_new != expected_notes)
end
UI.user_error!("The existing build already has different TestFlight notes; recover its original beta attempt before continuing.") if different_notes
end
# This is distribution-only. Upload and immutable source recording complete
# first, so a rejected submission or partial metadata write can be recovered.
upload_to_testflight(
api_key: api_key,
apple_id: app.id,
app_platform: "ios",
app_version: short_version,
build_number: build_number,
distribute_only: true,
skip_waiting_for_build_processing: false,
wait_processing_timeout_duration: APP_STORE_BUILD_PROCESSING_TIMEOUT_SECONDS,
distribute_external: true,
groups: [group.id],
localized_build_info: { "en-US" => { whats_new: expected_notes } },
notify_external_testers: true,
submit_beta_review: state == "READY_FOR_BETA_SUBMISSION",
skip_submission: false,
reject_build_waiting_for_review: false,
expire_previous_builds: false,
uses_non_exempt_encryption: false
)
saved_build = Spaceship::ConnectAPI::Build.get(build_id: build.id, includes: "preReleaseVersion,buildBetaDetail")
saved_notes = saved_build.get_beta_build_localizations.select { |localization| localization.locale == "en-US" }
unless group.fetch_builds.any? { |candidate| candidate.id == build.id } && saved_build.build_beta_detail&.auto_notify_enabled == true &&
saved_notes.length == 1 && saved_notes.first.whats_new == expected_notes
UI.user_error!("TestFlight distribution readback did not match the group, automatic notification, and saved notes; retry staging without uploading again.")
end
state = saved_build.build_beta_detail.external_build_state
outcome = if TESTFLIGHT_REVIEW_STATES.include?(state)
"awaiting-review"
elsif TESTFLIGHT_AVAILABLE_STATES.include?(state)
"available"
elsif state == "BETA_APPROVED"
"approved"
else
UI.user_error!("TestFlight distribution returned state #{state}; reconcile this build before retrying staging. No new upload is needed.")
end
result = { "buildId" => build.id, "shortVersion" => short_version, "buildNumber" => build_number, "groupId" => group.id, "externalState" => state, "outcome" => outcome }
result_path = ENV["OPENCLAW_TESTFLIGHT_RESULT_FILE"].to_s
File.write(result_path, "#{JSON.pretty_generate(result)}\n") unless result_path.empty?
UI.success("TestFlight build #{short_version} (#{build_number}): #{outcome}; external state #{state}.")
result
end

View file

@ -4,7 +4,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-plan.sh [--json] [--version 2026.7.2] [--revision 1] [--build-number 3]
scripts/ios-release-plan.sh [--json] [--destination app-store|testflight] [--version 2026.7.2] [--revision 1] [--build-number 3]
Reads App Store Connect state and prints the deterministic iOS release plan.
This command does not mutate App Store Connect or repository files.
@ -14,6 +14,7 @@ EOF
BUILD_NUMBER=""
APP_STORE_REVISION=""
RELEASE_VERSION=""
RELEASE_DESTINATION=""
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
@ -22,6 +23,7 @@ parse_ios_release_args plan "$@"
PLAN_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-ios-release-plan.XXXXXX")"
trap 'rm -f "${PLAN_FILE}"' EXIT
FASTLANE_ARGS=(ios release_plan "output_path:${PLAN_FILE}")
[[ -n "${RELEASE_DESTINATION}" ]] && FASTLANE_ARGS+=("destination:${RELEASE_DESTINATION}")
[[ -n "${RELEASE_VERSION}" ]] && FASTLANE_ARGS+=("release_version:${RELEASE_VERSION}")
[[ -n "${APP_STORE_REVISION}" ]] && FASTLANE_ARGS+=("app_store_revision:${APP_STORE_REVISION}")
[[ -n "${BUILD_NUMBER}" ]] && FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")

View file

@ -4,19 +4,21 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-upload.sh [--version 2026.7.2] [--revision 1] [--build-number 3]
scripts/ios-release-upload.sh [--destination app-store|testflight] [--version 2026.7.2] [--revision 1] [--build-number 3]
scripts/ios-release-upload.sh --stage-only --version 2026.7.2 --revision 1 --build-number 3
Generates App Store screenshots, updates release metadata, archives, and uploads
an App Store distribution build to App Store Connect. This does not submit the
build for App Review.
--stage-only recovers saved notes and build selection without rebuilding or uploading.
build for App Review. The testflight destination skips App Store staging and
distributes to the configured external group, submitting for beta review as needed.
--stage-only recovers the saved destination without rebuilding or uploading.
EOF
}
BUILD_NUMBER=""
APP_STORE_REVISION=""
RELEASE_VERSION=""
RELEASE_DESTINATION=""
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
@ -31,6 +33,9 @@ FASTLANE_ARGS=(ios release_upload)
if [[ "${STAGE_ONLY}" == 1 ]]; then
FASTLANE_ARGS=(ios release_stage)
fi
if [[ -n "${RELEASE_DESTINATION}" ]]; then
FASTLANE_ARGS+=("destination:${RELEASE_DESTINATION}")
fi
if [[ -n "${RELEASE_VERSION}" ]]; then
FASTLANE_ARGS+=("release_version:${RELEASE_VERSION}")
fi

View file

@ -9,10 +9,13 @@ parse_ios_release_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--build-number|--revision|--version|--team-id)
--build-number|--revision|--version|--team-id|--destination)
if [[ "$1" == --team-id && "$mode" != prepare ]]; then
break
fi
if [[ "$1" == --destination && "$mode" != plan && "$mode" != upload ]]; then
break
fi
if [[ -z "${2-}" || "${2-}" == --* ]]; then
echo "Missing value for $1." >&2
usage >&2
@ -23,6 +26,12 @@ parse_ios_release_args() {
--revision) APP_STORE_REVISION="$2" ;;
--version) RELEASE_VERSION="$2" ;;
--team-id) TEAM_ID="$2" ;;
--destination)
case "$2" in
app-store|testflight) RELEASE_DESTINATION="$2" ;;
*) echo "Unsupported iOS release destination: $2" >&2; exit 1 ;;
esac
;;
esac
shift 2
;;

View file

@ -40,9 +40,12 @@ type IosRemoteBuildUpload = {
state: string;
};
type IosReleaseDestination = "app-store" | "testflight";
export type IosReleasePlanInput = {
appStoreVersions: IosRemoteAppStoreVersion[];
buildUploads: IosRemoteBuildUpload[];
destination?: IosReleaseDestination;
explicitBuildNumber?: string | null;
explicitRevision?: string | number | null;
gatewayVersion: string;
@ -59,7 +62,13 @@ export type IosReleasePlan = {
buildNumber: number;
buildUploads: IosRemoteBuildUpload[];
releaseNotesBaselines: IosReleasePlanInput["releaseNotesBaselines"];
decision: "new-revision" | "resume-editable" | "retry-upload";
decision:
| "new-revision"
| "resume-editable"
| "resume-testflight"
| "retry-upload"
| "stage-existing";
destination: IosReleaseDestination;
gatewayVersion: string;
sourceClean: boolean | null;
sourceSha: string | null;
@ -180,16 +189,24 @@ function assertExplicitSelection(
}
export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePlan {
const destination = input.destination ?? "app-store";
if (destination !== "app-store" && destination !== "testflight") {
throw new Error("Unknown iOS release destination. Choose app-store or testflight.");
}
const gatewayVersion = normalizePinnedIosVersion(input.gatewayVersion);
const decodedVersions = input.appStoreVersions.map((version) => ({
decoded: decodeIosAppStoreVersion(gatewayVersion, version.versionString),
version,
}));
// App Store Connect permits only one mutable iOS version. Treat any extra
// active record as ambiguous instead of guessing which release owns it.
const activeVersions = input.appStoreVersions.filter(
(version) => !RELEASED_APP_STORE_VERSION_STATES.has(version.state),
);
// Store releases own the mutable version. TestFlight shares its revision when
// it matches this gateway, but does not depend on another train's store draft.
const activeVersions = decodedVersions
.filter(
({ decoded, version }) =>
!RELEASED_APP_STORE_VERSION_STATES.has(version.state) &&
(destination === "app-store" || (decoded && !decoded.legacy)),
)
.map(({ version }) => version);
if (activeVersions.length > 1) {
throw new Error(
`App Store Connect has multiple active iOS versions: ${activeVersions
@ -197,6 +214,10 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla
.join(", ")}.`,
);
}
const releasedRevisions = decodedVersions.flatMap(({ decoded, version }) =>
decoded && RELEASED_APP_STORE_VERSION_STATES.has(version.state) ? [decoded.revision] : [],
);
const highestReleased = releasedRevisions.length === 0 ? -1 : Math.max(...releasedRevisions);
let revision: number;
let decision: IosReleasePlan["decision"];
@ -204,7 +225,10 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla
if (activeVersions.length === 1) {
selectedVersion = activeVersions[0] ?? null;
if (!selectedVersion || !EDITABLE_APP_STORE_VERSION_STATES.has(selectedVersion.state)) {
if (
!selectedVersion ||
(destination === "app-store" && !EDITABLE_APP_STORE_VERSION_STATES.has(selectedVersion.state))
) {
throw new Error(
`App Store version ${selectedVersion?.versionString ?? "unknown"} is locked in state ${selectedVersion?.state ?? "UNKNOWN"}.`,
);
@ -216,11 +240,24 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla
);
}
revision = decoded.revision;
decision = "resume-editable";
decision = destination === "testflight" ? "resume-testflight" : "resume-editable";
if (destination === "testflight") {
const conflictingUploads = input.buildUploads.filter((upload) => {
const uploaded = decodeIosAppStoreVersion(gatewayVersion, upload.shortVersion);
return (
uploaded &&
!uploaded.legacy &&
uploaded.revision > highestReleased &&
uploaded.revision !== revision
);
});
if (conflictingUploads.length > 0) {
throw new Error(
`Multiple unreleased TestFlight revisions exist for gateway ${gatewayVersion}: App Store version ${selectedVersion.versionString} conflicts with uploaded ${[...new Set(conflictingUploads.map((upload) => upload.shortVersion))].join(", ")}. Resolve App Store Connect state before retrying.`,
);
}
}
} else {
const releasedRevisions = decodedVersions.flatMap(({ decoded, version }) =>
decoded && RELEASED_APP_STORE_VERSION_STATES.has(version.state) ? [decoded.revision] : [],
);
let hasLegacyUpload = false;
const uploadedRevisions = input.buildUploads.flatMap((upload) => {
const decoded = decodeIosAppStoreVersion(gatewayVersion, upload.shortVersion);
@ -239,7 +276,6 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla
}
return [decoded.revision];
});
const highestReleased = releasedRevisions.length === 0 ? -1 : Math.max(...releasedRevisions);
const highestUploaded = uploadedRevisions.length === 0 ? -1 : Math.max(...uploadedRevisions);
const unreleasedUploadedRevisions = [
...new Set(uploadedRevisions.filter((uploaded) => uploaded > highestReleased)),
@ -310,12 +346,13 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla
const plan: IosReleasePlan = {
appStoreRevision: revision,
appStoreVersion,
appStoreVersionId: selectedVersion?.id ?? null,
appStoreVersionState: selectedVersion?.state ?? null,
appStoreVersionId: destination === "app-store" ? (selectedVersion?.id ?? null) : null,
appStoreVersionState: destination === "app-store" ? (selectedVersion?.state ?? null) : null,
buildNumber,
buildUploads: uploads,
releaseNotesBaselines: baselines,
decision,
destination,
gatewayVersion,
sourceClean: input.sourceClean ?? null,
sourceSha: input.sourceSha?.trim() || null,

View file

@ -137,6 +137,7 @@ function collectArtifacts(source, recovery, platform) {
function uploadArgs(plan) {
return [
...(plan.destination === "testflight" ? ["--destination", "testflight"] : []),
"--version",
plan.gatewayVersion,
"--revision",
@ -154,7 +155,10 @@ function releaseEnvironment(platform, recovery, sourceSha) {
OPENCLAW_MOBILE_RELEASE_NOTES: path.join(recovery, "release-notes.json"),
...(platform === "android"
? { OPENCLAW_ANDROID_RELEASE_PLAN: path.join(recovery, "android-plan.json") }
: { OPENCLAW_IOS_RELEASE_PLAN: path.join(recovery, "ios-plan.json") }),
: {
OPENCLAW_IOS_RELEASE_PLAN: path.join(recovery, "ios-plan.json"),
OPENCLAW_TESTFLIGHT_RESULT_FILE: path.join(recovery, "testflight-result.json"),
}),
};
}
@ -168,7 +172,83 @@ function retainSummary(artifactPath) {
}
}
function prepareAndUpload(root, platform, recovery, releaseArgs) {
function testflightNonUploadOutcome(root, plan, sourceSha) {
const facts = plan.testflight;
if (!facts?.groupId || !Array.isArray(facts.builds)) {
throw new Error("The TestFlight plan is missing its external-group and build preflight.");
}
const refs = new Map(
git(root, "ls-remote", "--refs", "origin", "refs/openclaw/mobile-releases/ios/*")
.split("\n")
.filter(Boolean)
.map((row) => {
const [sha, ref] = row.split(/\s+/);
return [ref, sha];
}),
);
const sameSourceBuilds = facts.builds.filter((build) => {
const ref = `refs/openclaw/mobile-releases/ios/${build.shortVersion}-${build.buildNumber}`;
return refs.get(ref) === sourceSha;
});
for (const build of sameSourceBuilds) {
if (
build.configured &&
[
"WAITING_FOR_BETA_REVIEW",
"IN_BETA_REVIEW",
"BETA_APPROVED",
"READY_FOR_BETA_TESTING",
"IN_BETA_TESTING",
].includes(build.externalState)
) {
return { outcome: "unchanged", groupId: facts.groupId, build, sourceSha };
}
}
if (facts.pendingBuild) {
return {
outcome: "deferred-review",
groupId: facts.groupId,
build: facts.pendingBuild,
sourceSha,
};
}
const pendingUpload = plan.buildUploads?.find((upload) =>
["AWAITING_UPLOAD", "PROCESSING"].includes(upload.state),
);
if (pendingUpload) {
return {
outcome: "deferred-processing",
groupId: facts.groupId,
upload: pendingUpload,
sourceSha,
};
}
const storeBuild = sameSourceBuilds.find(
(build) =>
build.selectedForAppStore === true &&
build.hasBetaNotes === false &&
build.externalState === "READY_FOR_BETA_SUBMISSION",
);
if (storeBuild) {
return { outcome: "stage-existing", build: storeBuild };
}
if (sameSourceBuilds.length) {
const build = sameSourceBuilds[0];
throw new Error(
`This source already uploaded iOS build ${build.shortVersion} (${build.buildNumber}) in state ${build.externalState}. Recover its saved destination with mobile-release.mjs stage; do not upload it again.`,
);
}
for (const build of facts.builds) {
if (!refs.has(`refs/openclaw/mobile-releases/ios/${build.shortVersion}-${build.buildNumber}`)) {
throw new Error(
`TestFlight build ${build.shortVersion} (${build.buildNumber}) has no recorded source. Reconcile that upload before creating another build.`,
);
}
}
return null;
}
function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
clean(root);
const isGithubActions = process.env.GITHUB_ACTIONS === "true";
if (isGithubActions && process.env.GITHUB_RUN_ATTEMPT !== "1") {
@ -178,14 +258,19 @@ function prepareAndUpload(root, platform, recovery, releaseArgs) {
}
const sourceSha = git(root, "rev-parse", "HEAD");
if (isGithubActions) {
const eventAllowed =
process.env.GITHUB_EVENT_NAME === "workflow_dispatch" ||
(platform === "ios" &&
destination === "testflight" &&
process.env.GITHUB_EVENT_NAME === "schedule");
if (
process.env.GITHUB_EVENT_NAME !== "workflow_dispatch" ||
!eventAllowed ||
process.env.GITHUB_REPOSITORY !== "openclaw/openclaw" ||
process.env.GITHUB_REF !== "refs/heads/main" ||
sourceSha !== process.env.GITHUB_SHA
) {
throw new Error(
"CI releases require the exact workflow_dispatch commit on openclaw/openclaw main.",
"CI releases require the exact workflow_dispatch commit on openclaw/openclaw main; scheduled events are accepted only for iOS TestFlight.",
);
}
} else if (git(root, "branch", "--show-current") !== "main") {
@ -199,7 +284,7 @@ function prepareAndUpload(root, platform, recovery, releaseArgs) {
} else if (sourceSha !== currentMain) {
throw new Error("Local main differs from origin/main. Update it before starting the release.");
}
if (!process.env.OPENAI_API_KEY?.trim()) {
if (destination !== "testflight" && !process.env.OPENAI_API_KEY?.trim()) {
throw new Error("OPENAI_API_KEY is required to prepare store release notes.");
}
if (fs.existsSync(recovery) && fs.readdirSync(recovery).length) {
@ -218,8 +303,20 @@ function prepareAndUpload(root, platform, recovery, releaseArgs) {
let plan;
if (platform === "ios") {
plan = JSON.parse(
run("/bin/bash", ["scripts/ios-release-plan.sh", "--json", ...releaseArgs], source),
run(
"/bin/bash",
[
"scripts/ios-release-plan.sh",
"--json",
...(destination === "testflight" ? ["--destination", destination] : []),
...releaseArgs,
],
source,
),
);
if ((plan.destination ?? "app-store") !== destination) {
throw new Error("The planned iOS destination does not match the requested destination.");
}
} else {
run(
"/bin/bash",
@ -236,6 +333,53 @@ function prepareAndUpload(root, platform, recovery, releaseArgs) {
}
plan.sourceSha = sourceSha;
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
let stageExisting = false;
if (destination === "testflight") {
const outcome = testflightNonUploadOutcome(root, plan, sourceSha);
if (outcome?.outcome === "stage-existing") {
const build = outcome.build;
const buildNumber = Number(build.buildNumber);
if (
build.shortVersion !== plan.appStoreVersion ||
!/^[1-9]\d*$/.test(build.buildNumber) ||
!Number.isSafeInteger(buildNumber) ||
!build.id
) {
throw new Error(
"The existing App Store build does not match the planned TestFlight train.",
);
}
if (releaseArgs.includes("--build-number")) {
throw new Error(
"This source already has an App Store build. Omit --build-number to distribute that build through TestFlight without another upload.",
);
}
plan = {
...plan,
buildNumber,
decision: "stage-existing",
testflight: { ...plan.testflight, existingBuildId: build.id },
};
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
stageExisting = true;
} else if (outcome) {
fs.writeFileSync(
path.join(recovery, "testflight-result.json"),
`${JSON.stringify(outcome, null, 2)}\n`,
{ mode: 0o600 },
);
const summary = `TestFlight: ${outcome.outcome}. No new build uploaded.\n`;
console.log(summary.trim());
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
}
completed = true;
return;
}
if (!process.env.OPENAI_API_KEY?.trim()) {
throw new Error("OPENAI_API_KEY is required to prepare TestFlight notes.");
}
}
run(
process.execPath,
[
@ -267,9 +411,16 @@ function prepareAndUpload(root, platform, recovery, releaseArgs) {
console.log(
`Prepared ${platform} release from main source ${sourceSha}. Saved plan and notes: ${recovery}`,
);
if (stageExisting) {
uploadedRef(root, platform, plan, planPath);
}
run(
"/bin/bash",
[`scripts/${platform}-release-upload.sh`, ...(platform === "ios" ? uploadArgs(plan) : [])],
[
`scripts/${platform}-release-upload.sh`,
...(stageExisting ? ["--stage-only"] : []),
...(platform === "ios" ? uploadArgs(plan) : []),
],
source,
{
stdio: "inherit",
@ -313,20 +464,23 @@ function stageIos(root, recovery) {
env: releaseEnvironment("ios", recovery, plan.sourceSha),
});
git(root, "worktree", "remove", "--force", source);
console.log(`Staged saved notes and selected the already uploaded iOS build: ${ref}`);
console.log(
`Recovered the saved ${plan.destination ?? "app-store"} destination for iOS build: ${ref}`,
);
}
function runCli() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
console.log(
"Usage: node scripts/mobile-release.mjs run --platform ios|android [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\nRun prepares notes and uploads unchanged main source. Stage retries only iOS metadata/build selection for a recorded upload, without uploading again or making Git commits.",
"Usage: node scripts/mobile-release.mjs run --platform ios|android [--destination app-store|testflight] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\nRun prepares notes and uploads unchanged main source. TestFlight is iOS-only. Stage recovers the saved iOS destination without uploading again or making Git commits.",
);
return;
}
const operation = args.shift();
let platform;
let recovery;
let destination;
const releaseArgs = [];
while (args.length) {
const arg = args.shift();
@ -334,7 +488,14 @@ function runCli() {
continue;
}
if (
!["--platform", "--recovery-dir", "--version", "--revision", "--build-number"].includes(arg)
![
"--platform",
"--recovery-dir",
"--destination",
"--version",
"--revision",
"--build-number",
].includes(arg)
) {
throw new Error(`Unknown argument: ${arg}`);
}
@ -346,6 +507,8 @@ function runCli() {
platform = value;
} else if (arg === "--recovery-dir") {
recovery = path.resolve(value);
} else if (arg === "--destination") {
destination = value;
} else {
releaseArgs.push(arg, value);
}
@ -356,6 +519,16 @@ function runCli() {
if (releaseArgs.length && (operation !== "run" || platform !== "ios")) {
throw new Error("Release overrides are accepted only for an iOS run.");
}
if (
destination !== undefined &&
(platform !== "ios" ||
operation !== "run" ||
!["app-store", "testflight"].includes(destination))
) {
throw new Error(
"Choose --destination app-store or testflight for an iOS run; recovery uses the saved destination.",
);
}
if (operation === "stage" && (platform !== "ios" || !recovery)) {
throw new Error("Stage recovery requires --platform ios and --recovery-dir.");
}
@ -377,7 +550,7 @@ function runCli() {
if (operation === "stage") {
stageIos(root, recovery);
} else {
prepareAndUpload(root, platform, recovery, releaseArgs);
prepareAndUpload(root, platform, recovery, releaseArgs, destination ?? "app-store");
}
}

View file

@ -322,6 +322,208 @@ puts JSON.generate(rows)
}
});
it("recovers external TestFlight distribution through the stage lane with conditional review and verified metadata", () => {
const source = String.raw`
require "json"
require "tempfile"
module UI
def self.user_error!(message); raise message; end
def self.success(*); end
def self.important(*); end
end
def default_platform(*); end
def desc(*); end
def platform(*); yield; end
def lane(name, &body); define_singleton_method(name, &body); end
alias private_lane lane
load ARGV.fetch(0)
$LOADED_FEATURES << "pilot.rb"
module Pilot
class BuildManager
def self.sanitize_changelog(notes); notes; end
end
end
module Spaceship
module ConnectAPI
module Platform
IOS = "IOS"
end
class Build
def self.all(**); $builds; end
def self.get(**); $builds.first; end
end
def self.get_beta_app_review_detail(**)
Object.new.tap do |response|
def response.all_pages; [self]; end
def response.to_models; [$review]; end
end
end
end
end
Detail = Struct.new(:external_build_state, :auto_notify_enabled)
Build = Struct.new(:id, :version, :app_version, :processing_state, :expired, :build_beta_detail, :localizations) do
def get_beta_build_localizations; localizations; end
end
Localization = Struct.new(:locale, :description, :feedback_email, :whats_new)
Review = Struct.new(:contact_first_name, :contact_last_name, :contact_email, :contact_phone, :notes, :demo_account_required, :demo_account_name, :demo_account_password)
Group = Struct.new(:id, :name, :is_internal_group, :builds) do
def fetch_builds; builds; end
end
StoreVersion = Struct.new(:version_string, :selected) do
def get_build; selected; end
end
App = Struct.new(:id, :groups, :localizations) do
def get_beta_groups; groups; end
def get_beta_app_localizations; localizations; end
end
def read_ios_version_metadata(**)
{ version: "2026.7.2", short_version: "2026.7.21", app_store_revision: "1" }
end
def render_ios_release_notes(**); "Saved beta notes."; end
def assert_ios_uploaded_release_source!(**)
raise "source mismatch" if $scenario == "source-mismatch"
end
def app_store_connect_api_key_config; :fixture_key; end
def app_store_connect_target_app; $app; end
def stage_ios_app_store_release!(**); raise "App Store staging attempted"; end
def resolve_ios_release_plan!(**); raise "replanning attempted"; end
def upload_to_testflight(**options)
raise "reupload attempted" unless options[:distribute_only] == true
$options = options
raise "submission failed" if $scenario == "submission-failure"
build = $builds.first
build.localizations = [Localization.new("en-US", nil, nil, options.fetch(:localized_build_info).fetch("en-US").fetch(:whats_new))]
build.localizations.first.whats_new = "stale" if $scenario == "notes-readback"
build.build_beta_detail.auto_notify_enabled = options[:notify_external_testers] unless $scenario == "notify-readback"
build.build_beta_detail.external_build_state = "WAITING_FOR_BETA_REVIEW" if options[:submit_beta_review]
$group.builds = [build] unless $scenario == "group-readback"
end
states = {
"submit" => "READY_FOR_BETA_SUBMISSION", "pending" => "WAITING_FOR_BETA_REVIEW",
"reviewing" => "IN_BETA_REVIEW", "approved" => "BETA_APPROVED", "available" => "IN_BETA_TESTING",
"rejected" => "BETA_REJECTED", "compliance" => "MISSING_EXPORT_COMPLIANCE"
}
scenarios = states.keys + %w[source-mismatch group-change internal-group ambiguous-group missing-description missing-contact missing-demo pending-other processing expired submission-failure notes-readback notify-readback group-readback adopt adopt-retry adopt-wrong-id adopt-changed-notes adopt-other-locale]
rows = Tempfile.create(["openclaw-beta-plan", ".json"]) do |plan|
Tempfile.create(["openclaw-beta-result", ".json"]) do |result|
ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] = "1"
ENV["OPENCLAW_IOS_RELEASE_PLAN"] = plan.path
ENV["OPENCLAW_TESTFLIGHT_RESULT_FILE"] = result.path
scenarios.map do |scenario|
$scenario, $options = scenario, nil
ENV["OPENCLAW_TESTFLIGHT_GROUP_ID"] = scenario == "group-change" ? "changed" : "external-group"
beta_plan = { groupId: "external-group" }
beta_plan[:existingBuildId] = scenario == "adopt-wrong-id" ? "different-build" : "uploaded" if scenario.start_with?("adopt")
File.write(plan.path, JSON.generate({ destination: "testflight", appStoreVersion: "2026.7.21", buildNumber: 3, testflight: beta_plan }))
File.write(result.path, "")
build = Build.new("uploaded", "3", "2026.7.21", scenario == "processing" ? "PROCESSING" : "VALID", scenario == "expired", Detail.new(states.fetch(scenario, "READY_FOR_BETA_SUBMISSION"), false), [])
case scenario
when "adopt-retry"
build.localizations = [Localization.new("en-US", nil, nil, "Saved beta notes.")]
when "adopt-changed-notes"
build.localizations = [Localization.new("en-US", nil, nil, "Existing beta attempt notes.")]
when "adopt-other-locale"
build.localizations = [Localization.new("sv-SE", nil, nil, "Existing localized beta notes.")]
end
$builds = [build]
$builds << Build.new("other", "2", "2026.7.21", "VALID", false, Detail.new("IN_BETA_REVIEW", true), []) if scenario == "pending-other"
$group = Group.new("external-group", "External Testing", scenario == "internal-group", [])
groups = [$group]
groups << Group.new("other", "external-group", false, []) if scenario == "ambiguous-group"
$app = App.new("app", groups, [Localization.new("en-US", scenario == "missing-description" ? "" : "Beta description", "feedback@example.invalid")])
$review = Review.new("Review", "Contact", scenario == "missing-contact" ? "" : "review@example.invalid", "+15555550123", "Reviewer access instructions", scenario == "missing-demo" ? true : nil)
facts = testflight_plan_facts(app: $app, group: $group, short_version: "2026.7.21", versions: [StoreVersion.new("2026.7.21", scenario.start_with?("adopt") ? build : nil)])
error = nil
begin
release_stage(destination: "testflight", release_version: "2026.7.2", app_store_revision: "1", build_number: "3")
rescue => failure
error = failure.message
end
{ scenario: scenario, facts: facts.fetch("builds").first, error: error, options: $options, result: File.read(result.path).empty? ? nil : JSON.parse(File.read(result.path)) }
end
end
end
puts JSON.generate(rows)
`;
const result = spawnSync("ruby", ["-e", source, fastfilePath], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
const rows = JSON.parse(result.stdout) as {
scenario: string;
error: string | null;
options: Record<string, unknown> | null;
result: { outcome: string; externalState: string } | null;
facts: { selectedForAppStore: boolean; hasBetaNotes: boolean } | null;
}[];
const outcomes: Record<string, string> = {
submit: "awaiting-review",
pending: "awaiting-review",
reviewing: "awaiting-review",
approved: "approved",
available: "available",
adopt: "awaiting-review",
"adopt-retry": "awaiting-review",
};
const errors: Record<string, string> = {
rejected: "cannot be distributed in state BETA_REJECTED",
compliance: "cannot be distributed in state MISSING_EXPORT_COMPLIANCE",
"source-mismatch": "source mismatch",
"group-change": "group changed after planning",
"internal-group": "must belong to this app and be external",
"ambiguous-group": "also names another group",
"missing-description": "beta description and feedback email",
"missing-contact": "review contact and reviewer instructions",
"missing-demo": "requires a demo account",
"pending-other": "already awaiting review in this train",
processing: "found 0",
expired: "found 0",
"submission-failure": "submission failed",
"notes-readback": "readback did not match",
"notify-readback": "readback did not match",
"group-readback": "readback did not match",
"adopt-wrong-id": "does not match the saved existing build",
"adopt-changed-notes": "already has different TestFlight notes",
"adopt-other-locale": "already has different TestFlight notes",
};
for (const row of rows) {
if (row.scenario.startsWith("adopt") || row.scenario === "submit") {
expect(row.facts).toMatchObject({
selectedForAppStore: row.scenario.startsWith("adopt"),
hasBetaNotes: ["adopt-retry", "adopt-changed-notes", "adopt-other-locale"].includes(
row.scenario,
),
});
}
if (outcomes[row.scenario]) {
expect(row.error, row.scenario).toBeNull();
expect(row.result?.outcome).toBe(outcomes[row.scenario]);
expect(row.options).toMatchObject({
apple_id: "app",
app_platform: "ios",
app_version: "2026.7.21",
build_number: "3",
distribute_only: true,
distribute_external: true,
groups: ["external-group"],
notify_external_testers: true,
submit_beta_review: ["submit", "adopt", "adopt-retry"].includes(row.scenario),
skip_submission: false,
reject_build_waiting_for_review: false,
expire_previous_builds: false,
});
} else {
expect(row.error, row.scenario).toContain(errors[row.scenario]);
expect(row.result).toBeNull();
if (
!["submission-failure", "notes-readback", "notify-readback", "group-readback"].includes(
row.scenario,
)
) {
expect(row.options).toBeNull();
}
}
}
});
it("uploads the planned iOS build without Android preparation and records only accepted uploads", () => {
const source = String.raw`
require "json"
@ -350,6 +552,7 @@ def resolve_ios_release_plan!(**)
"buildNumber" => 3, "appStoreVersion" => "2026.7.21" }
end
def assert_ios_release_notes_baseline!(_plan); step(@plans == 1 ? "baseline" : "baseline-recheck"); end
def assert_testflight_upload_ready!(_plan); step(@plans == 1 ? "beta-preflight" : "beta-recheck"); end
def render_ios_release_notes(**); step("notes"); "Saved notes"; end
def read_ios_version_metadata(**)
{ version: "2026.7.2", short_version: "2026.7.21", app_store_revision: "1" }
@ -403,6 +606,17 @@ rows = %w[success notes screenshots archive recheck baseline-recheck metadata up
end
{ scenario: scenario, events: @events, error: error, xcconfig: ENV["XCODE_XCCONFIG_FILE"] }
end
%w[success beta-preflight archive beta-recheck upload record stage].each do |scenario|
@events, @plans, @failure = [], 0, scenario
ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] = "1"
error = nil
begin
release_upload(destination: "testflight")
rescue => failure
error = failure.message
end
rows << { scenario: "testflight-#{scenario}", events: @events, error: error, xcconfig: ENV["XCODE_XCCONFIG_FILE"] }
end
puts JSON.generate(rows)
`;
const result = spawnSync("ruby", ["-e", source, fastfilePath], {
@ -435,7 +649,29 @@ puts JSON.generate(rows)
];
for (const row of rows) {
expect(row.xcconfig).toBeNull();
if (row.scenario === "success") {
if (row.scenario.startsWith("testflight-")) {
const betaSteps = [
"signing",
"plan",
"baseline",
"beta-preflight",
"notes",
"ref-preflight",
"source",
"archive",
"recheck",
"baseline-recheck",
"beta-recheck",
"upload",
"record",
"stage",
];
const scenario = row.scenario.slice("testflight-".length);
expect(row.error).toBe(scenario === "success" ? null : `failed ${scenario}`);
expect(row.events).toEqual(
scenario === "success" ? betaSteps : betaSteps.slice(0, betaSteps.indexOf(scenario) + 1),
);
} else if (row.scenario === "success") {
expect(row.error).toBeNull();
expect(row.events).toEqual(steps);
} else if (row.scenario === "direct") {
@ -727,38 +963,6 @@ puts JSON.generate(rows)
expect(uploadCall).toBeGreaterThan(validationCall);
});
it("rechecks the plan after local validation and before the first App Store mutation", () => {
const fastfile = readFastfile();
const releaseUpload = laneBody(fastfile, "release_upload");
const build = releaseUpload.indexOf("build = build_app_store_release(context)");
const planRecheck = releaseUpload.lastIndexOf("resolve_ios_release_plan!");
const metadata = releaseUpload.indexOf("\n metadata(");
const upload = releaseUpload.indexOf("upload_to_testflight(");
expect(fastfile).not.toContain("def verify_app_store_binary!");
expect(releaseUpload).not.toContain("verify_only: true");
expect(build).toBeGreaterThanOrEqual(0);
expect(planRecheck).toBeGreaterThan(build);
expect(metadata).toBeGreaterThan(planRecheck);
expect(upload).toBeGreaterThan(planRecheck);
});
it("finishes fallible local release work before mutating App Store metadata", () => {
const fastfile = readFastfile();
const releaseUpload = laneBody(fastfile, "release_upload");
const screenshots = releaseUpload.indexOf(
"screenshots(\n release_version: context[:version]",
);
const sourceCheck = releaseUpload.indexOf("verify_apple_release_source!(release_sha)");
const build = releaseUpload.indexOf("build = build_app_store_release(context)");
const metadata = releaseUpload.indexOf("metadata(\n release_version: context[:version]");
expect(screenshots).toBeGreaterThanOrEqual(0);
expect(sourceCheck).toBeGreaterThan(screenshots);
expect(build).toBeGreaterThan(sourceCheck);
expect(metadata).toBeGreaterThan(build);
});
it("fails from authoritative Xcode results and keeps successful bundles outside screenshots", () => {
const fastfile = readFastfile();
const screenshots = laneBody(fastfile, "screenshots");

View file

@ -24,6 +24,7 @@ function input(overrides: Partial<IosReleasePlanInput> = {}): IosReleasePlanInpu
describe("resolveIosReleasePlan", () => {
it("starts a new gateway at revision zero and build one", () => {
expect(resolveIosReleasePlan(input())).toMatchObject({
destination: "app-store",
appStoreRevision: 0,
appStoreVersion: "2026.7.20",
buildNumber: 1,
@ -87,6 +88,76 @@ describe("resolveIosReleasePlan", () => {
});
});
it.each(["PREPARE_FOR_SUBMISSION", "IN_REVIEW"])(
"continues TestFlight builds alongside a matching %s App Store version",
(state) => {
const plan = resolveIosReleasePlan(
input({
destination: "testflight",
appStoreVersions: [
{ id: "public", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.20" },
{ id: "store", state, versionString: "2026.7.21" },
],
buildUploads: [
{ buildNumber: "7", shortVersion: "2026.7.21", state: "FAILED" },
{ buildNumber: "90", shortVersion: "2026.7.20", state: "COMPLETE" },
],
}),
);
expect(plan).toMatchObject({
destination: "testflight",
appStoreRevision: 1,
appStoreVersion: "2026.7.21",
appStoreVersionId: null,
appStoreVersionState: null,
buildNumber: 8,
decision: "resume-testflight",
releaseNotesBaselines: [{ audience: "ios", version: "2026.7.20", build: "5" }],
});
},
);
it.each(["PREPARE_FOR_SUBMISSION", "IN_REVIEW"])(
"retries the TestFlight train while another gateway's App Store version is %s",
(state) => {
const plan = resolveIosReleasePlan(
input({
destination: "testflight",
appStoreVersions: [{ id: "other", state, versionString: "2026.7.30" }],
buildUploads: [
{ buildNumber: "4", shortVersion: "2026.7.20", state: "PROCESSING" },
{ buildNumber: "90", shortVersion: "2026.7.30", state: "COMPLETE" },
],
}),
);
expect(plan).toMatchObject({
destination: "testflight",
appStoreRevision: 0,
appStoreVersion: "2026.7.20",
appStoreVersionId: null,
appStoreVersionState: null,
buildNumber: 5,
decision: "retry-upload",
});
},
);
it("rejects ambiguous TestFlight revisions across active versions and upload history", () => {
expect(() =>
resolveIosReleasePlan(
input({
destination: "testflight",
appStoreVersions: [
{ id: "store", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" },
],
buildUploads: [{ buildNumber: "4", shortVersion: "2026.7.22", state: "COMPLETE" }],
}),
),
).toThrow("Multiple unreleased TestFlight revisions");
});
it("retries an uploaded but unreleased revision after its version record is removed", () => {
const plan = resolveIosReleasePlan(
input({
@ -165,26 +236,33 @@ describe("resolveIosReleasePlan", () => {
).toThrow("does not belong to gateway 2026.7.2");
});
it("rejects multiple active versions and unknown upload states", () => {
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "one", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" },
{ id: "two", state: "READY_FOR_REVIEW", versionString: "2026.7.22" },
],
}),
),
).toThrow("multiple active iOS versions");
it.each(["app-store", "testflight"] as const)(
"rejects multiple active versions and unknown upload states for %s",
(destination) => {
expect(() =>
resolveIosReleasePlan(
input({
destination,
appStoreVersions: [
{ id: "one", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" },
{ id: "two", state: "READY_FOR_REVIEW", versionString: "2026.7.22" },
],
}),
),
).toThrow("multiple active iOS versions");
expect(() =>
resolveIosReleasePlan(
input({
buildUploads: [{ buildNumber: "1", shortVersion: "2026.7.20", state: "NEW_APPLE_STATE" }],
}),
),
).toThrow("Unknown App Store build upload state");
});
expect(() =>
resolveIosReleasePlan(
input({
destination,
buildUploads: [
{ buildNumber: "1", shortVersion: "2026.7.20", state: "NEW_APPLE_STATE" },
],
}),
),
).toThrow("Unknown App Store build upload state");
},
);
it("fails after revision 9 is distributed", () => {
expect(() =>

View file

@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { runInNewContext } from "node:vm";
import { expectDefined } from "@openclaw/normalization-core";
import { globSync } from "tinyglobby";
import { afterEach, describe, expect, it } from "vitest";
@ -105,6 +106,148 @@ function releaseArtifactFiles(workflowFile: string, artifactPrefix: string, runn
}
describe("mobile release CI tools", () => {
it("routes daily and manual TestFlight through qualification and its unattended environment", () => {
const workflow = parse(fs.readFileSync(".github/workflows/ios-store-release.yml", "utf8")) as {
on: { schedule: Array<{ cron: string; timezone: string }> };
concurrency: { group: string; "cancel-in-progress": boolean };
jobs: {
qualify: { if: string };
release: { if: string; environment: string; steps: WorkflowStep[] };
screenshots: { if: string };
};
};
expect(workflow.on.schedule).toEqual([{ cron: "0 7 * * *", timezone: "America/Los_Angeles" }]);
expect(workflow.concurrency).toMatchObject({
group: "ios-release",
"cancel-in-progress": false,
});
const upload = expectDefined(
workflow.jobs.release.steps.find((step) => step.name === "Prepare and upload iOS release"),
"iOS upload step",
);
const uploadEnvironment = expectDefined(upload.env, "iOS upload environment");
for (const scenario of [
{
event: "schedule",
operation: "",
enabled: "true",
admitted: true,
destination: "testflight",
},
{ event: "schedule", operation: "", enabled: "", admitted: false },
{ event: "schedule", operation: "", enabled: "false", admitted: false },
{
event: "workflow_dispatch",
operation: "testflight",
enabled: "false",
admitted: true,
destination: "testflight",
},
{
event: "workflow_dispatch",
operation: "release",
enabled: "false",
admitted: true,
destination: "app-store",
},
{
event: "workflow_dispatch",
operation: "screenshots",
enabled: "true",
admitted: false,
screenshots: true,
ref: "refs/heads/candidate",
},
{
event: "workflow_dispatch",
operation: "testflight",
enabled: "true",
admitted: false,
ref: "refs/heads/candidate",
},
{
event: "schedule",
operation: "",
enabled: "true",
admitted: false,
repository: "example/fork",
},
{ event: "push", operation: "release", enabled: "true", admitted: false },
]) {
const context = {
github: {
event_name: scenario.event,
ref: scenario.ref ?? "refs/heads/main",
repository: scenario.repository ?? "openclaw/openclaw",
},
inputs: { operation: scenario.operation },
vars: {
IOS_TESTFLIGHT_ENABLED: scenario.enabled,
OPENCLAW_TESTFLIGHT_GROUP_ID: "external-group-id",
},
};
const evaluate = (expression: string) =>
runInNewContext(expression.replace(/^\$\{\{\s*|\s*\}\}$/gu, ""), context);
expect(Boolean(evaluate(workflow.jobs.qualify.if)), JSON.stringify(scenario)).toBe(
scenario.admitted,
);
expect(Boolean(evaluate(workflow.jobs.release.if)), JSON.stringify(scenario)).toBe(
scenario.admitted,
);
expect(Boolean(evaluate(workflow.jobs.screenshots.if))).toBe(scenario.screenshots ?? false);
if (!scenario.admitted) {
continue;
}
expect(evaluate(workflow.jobs.release.environment)).toBe(
scenario.destination === "testflight" ? "ios-testflight" : "ios-store-release",
);
expect(
evaluate(
expectDefined(
uploadEnvironment.OPENCLAW_TESTFLIGHT_GROUP_ID,
"TestFlight group expression",
),
),
).toBe("external-group-id");
const result = spawnSync(
"bash",
[
"-c",
[
"gh() { :; }",
"pnpm() { printf '%s\\n' \"$@\"; }",
expectDefined(upload.run, "iOS upload command"),
].join("\n"),
],
{
encoding: "utf8",
env: {
...process.env,
IOS_RELEASE_DESTINATION: String(
evaluate(
expectDefined(
uploadEnvironment.IOS_RELEASE_DESTINATION,
"iOS release destination expression",
),
),
),
RUNNER_TEMP: "/synthetic-runner-temp",
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim().split("\n")).toEqual([
"ios:release:upload",
"--",
"--destination",
scenario.destination,
"--recovery-dir",
"/synthetic-runner-temp/ios-release-recovery",
]);
}
});
describe.each([
{
platform: "ios",

View file

@ -22,7 +22,27 @@ function write(root: string, relative: string, contents: string): void {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, contents);
}
function fixture(platform = "ios") {
function fixture(
platform = "ios",
testflight?: {
groupId: string;
builds: Array<{
id: string;
shortVersion: string;
buildNumber: string;
externalState: string;
configured: boolean;
hasBetaNotes?: boolean;
selectedForAppStore?: boolean;
}>;
pendingBuild: {
id: string;
shortVersion: string;
buildNumber: string;
externalState: string;
} | null;
},
) {
const directory = tempDirs.make("openclaw-mobile-release-");
const remote = path.join(directory, "origin.git");
const root = path.join(directory, "checkout");
@ -63,7 +83,7 @@ function fixture(platform = "ios") {
write(
root,
"scripts/ios-release-plan.sh",
`echo '{"gatewayVersion":"2026.9.2","appStoreVersion":"2026.9.20","appStoreRevision":0,"buildNumber":8,"releaseNotesBaselines":[{"audience":"ios","version":null,"build":null}]}'\n`,
`echo '${JSON.stringify({ gatewayVersion: "2026.9.2", appStoreVersion: "2026.9.20", appStoreRevision: 0, buildNumber: 8, buildUploads: [], releaseNotesBaselines: [{ audience: "ios", version: null, build: null }], ...(testflight ? { destination: "testflight", testflight } : {}) })}'\n`,
);
write(root, "scripts/ios-release-upload.sh", 'exec node scripts/fixture-upload.mjs "$@"\n');
write(
@ -75,8 +95,10 @@ import { renderMobileReleaseNotes } from "./lib/mobile-release-notes.ts";
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
const sha = git("rev-parse", "HEAD");
const stageOnly = process.argv.includes("--stage-only");
const notes = renderMobileReleaseNotes({ rootDir: process.cwd(), platform: "ios", version: "2026.9.20", build: "8", audience: "ios" });
fs.appendFileSync(process.env.FIXTURE_UPLOAD_AUDIT, JSON.stringify({ sha, stageOnly, notes, stampedSha: process.env.GIT_COMMIT, status: git("status", "--porcelain", "--untracked-files=all"), remoteMain: git("ls-remote", "origin", "refs/heads/main").split(/\\s+/)[0], metadata: fs.readFileSync("apps/ios/CHANGELOG.md", "utf8") }) + "\\n");
const plan = JSON.parse(fs.readFileSync(process.env.OPENCLAW_IOS_RELEASE_PLAN, "utf8"));
const buildNumber = process.argv[process.argv.indexOf("--build-number") + 1];
const notes = renderMobileReleaseNotes({ rootDir: process.cwd(), platform: "ios", version: plan.appStoreVersion, build: buildNumber, audience: "ios" });
fs.appendFileSync(process.env.FIXTURE_UPLOAD_AUDIT, JSON.stringify({ sha, stageOnly, notes, buildNumber, destination: process.argv.includes("--destination") ? process.argv[process.argv.indexOf("--destination") + 1] : "app-store", stampedSha: process.env.GIT_COMMIT, status: git("status", "--porcelain", "--untracked-files=all"), remoteMain: git("ls-remote", "origin", "refs/heads/main").split(/\\s+/)[0], metadata: fs.readFileSync("apps/ios/CHANGELOG.md", "utf8") }) + "\\n");
if (process.env.FIXTURE_UPLOAD_FAIL === "1") {
fs.mkdirSync("apps/ios/fastlane/screenshots/en-US", { recursive: true });
fs.writeFileSync("apps/ios/fastlane/screenshots/en-US/iPhone-01-control-connected.png", "Synthetic fixture screenshot");
@ -357,21 +379,134 @@ describe("mobile release CLI", () => {
expect(git(f.remote, "rev-parse", "main")).toBe(f.base);
});
it("recovers only iOS metadata staging using frozen notes and never uploads twice", () => {
const f = fixture();
const result = f.invoke("run", [], { FIXTURE_STAGE_FAIL: "1" });
it.each(["app-store", "testflight"])(
"recovers the saved %s destination using frozen notes without uploading twice",
(destination) => {
const f = fixture(
"ios",
destination === "testflight"
? { groupId: "external-fixture", builds: [], pendingBuild: null }
: undefined,
);
const result = f.invoke("run", ["--destination", destination], { FIXTURE_STAGE_FAIL: "1" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Synthetic metadata stage refused after upload");
expect(git(f.remote, "rev-parse", uploadRef)).toBe(f.base);
const notes = fs.readFileSync(path.join(f.recovery, "release-notes.json"), "utf8");
const recovery = f.invoke("stage", [], { OPENAI_API_KEY: "" });
expect(recovery.status, recovery.stderr).toBe(0);
expect(f.audit().map((entry) => entry.stageOnly)).toEqual([false, true]);
expect(f.audit().map((entry) => entry.destination)).toEqual([destination, destination]);
expect(fs.readFileSync(path.join(f.recovery, "release-notes.json"), "utf8")).toBe(notes);
expect(fs.existsSync(path.join(f.recovery, "source"))).toBe(false);
expect(git(f.remote, "rev-parse", "main")).toBe(f.base);
},
);
it("admits scheduled TestFlight on the exact main source and rejects scheduled store uploads", () => {
const f = fixture("ios", { groupId: "external-fixture", builds: [], pendingBuild: null });
const ci = {
GITHUB_ACTIONS: "true",
GITHUB_EVENT_NAME: "schedule",
GITHUB_RUN_ATTEMPT: "1",
GITHUB_REF: "refs/heads/main",
GITHUB_SHA: f.base,
};
expect(f.invoke("run", [], ci).status).toBe(1);
expect(fs.existsSync(f.uploadAudit)).toBe(false);
const result = f.invoke("run", ["--destination", "testflight"], ci);
expect(result.status, result.stderr).toBe(0);
expect(f.audit()).toEqual([
expect.objectContaining({ destination: "testflight", sha: f.base, stageOnly: false }),
]);
});
it("stages an existing App Store build for TestFlight and recovers its frozen notes without uploading", () => {
const build = {
id: "store-build",
shortVersion: "2026.9.20",
buildNumber: "7",
externalState: "READY_FOR_BETA_SUBMISSION",
configured: false,
hasBetaNotes: false,
selectedForAppStore: true,
};
const f = fixture("ios", {
groupId: "external-fixture",
builds: [build],
pendingBuild: null,
});
const existingRef = "refs/openclaw/mobile-releases/ios/2026.9.20-7";
git(f.root, "push", "origin", `${f.base}:${existingRef}`);
const result = f.invoke("run", ["--destination", "testflight"], { FIXTURE_STAGE_FAIL: "1" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Synthetic metadata stage refused after upload");
expect(git(f.remote, "rev-parse", uploadRef)).toBe(f.base);
expect(f.audit()).toEqual([
expect.objectContaining({ destination: "testflight", buildNumber: "7", stageOnly: true }),
]);
expect(
JSON.parse(fs.readFileSync(path.join(f.recovery, "ios-plan.json"), "utf8")),
).toMatchObject({
destination: "testflight",
decision: "stage-existing",
buildNumber: 7,
sourceSha: f.base,
testflight: { existingBuildId: build.id },
});
const notes = fs.readFileSync(path.join(f.recovery, "release-notes.json"), "utf8");
const recovery = f.invoke("stage", [], { OPENAI_API_KEY: "" });
expect(recovery.status, recovery.stderr).toBe(0);
expect(f.audit().map((entry) => entry.stageOnly)).toEqual([false, true]);
const recovered = f.invoke("stage", [], { OPENAI_API_KEY: "" });
expect(recovered.status, recovered.stderr).toBe(0);
expect(f.audit().map((entry) => [entry.stageOnly, entry.buildNumber])).toEqual([
[true, "7"],
[true, "7"],
]);
expect(fs.readFileSync(path.join(f.recovery, "release-notes.json"), "utf8")).toBe(notes);
expect(git(f.root, "ls-remote", "--refs", "origin", uploadRef)).toBe("");
expect(git(f.remote, "rev-parse", existingRef)).toBe(f.base);
expect(fs.existsSync(path.join(f.recovery, "source"))).toBe(false);
expect(git(f.remote, "rev-parse", "main")).toBe(f.base);
});
it.each(["unchanged", "deferred-review", "recovery-required"])(
"does not upload or regenerate notes for TestFlight %s",
(outcome) => {
const build = {
id: "fixture-build",
shortVersion: "2026.9.20",
buildNumber: "7",
externalState:
outcome === "deferred-review"
? "WAITING_FOR_BETA_REVIEW"
: outcome === "recovery-required"
? "READY_FOR_BETA_SUBMISSION"
: "IN_BETA_TESTING",
configured: outcome !== "recovery-required",
selectedForAppStore: outcome === "recovery-required",
hasBetaNotes: true,
};
const f = fixture("ios", {
groupId: "external-fixture",
builds: [build],
pendingBuild: outcome === "deferred-review" ? build : null,
});
if (outcome !== "deferred-review") {
git(f.root, "push", "origin", `${f.base}:refs/openclaw/mobile-releases/ios/2026.9.20-7`);
}
const result = f.invoke("run", ["--destination", "testflight"], { OPENAI_API_KEY: "" });
expect(fs.existsSync(f.uploadAudit)).toBe(false);
expect(fs.existsSync(path.join(f.recovery, "release-notes.json"))).toBe(false);
if (outcome === "recovery-required") {
expect(result.status).toBe(1);
expect(result.stderr).toContain("Recover its saved destination");
} else {
expect(result.status, result.stderr).toBe(0);
expect(
JSON.parse(fs.readFileSync(path.join(f.recovery, "testflight-result.json"), "utf8")),
).toMatchObject({ outcome, groupId: "external-fixture", sourceSha: f.base });
expect(fs.existsSync(path.join(f.recovery, "source"))).toBe(false);
}
},
);
it("rejects dirty, non-main, and missing-key releases before creating a worktree or calling Fastlane", () => {
const f = fixture();
write(f.root, "uncommitted.txt", "Unrelated local work.\n");