mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
477250c066
commit
8c6fbf5f89
15 changed files with 1236 additions and 160 deletions
26
.github/workflows/ios-store-release.yml
vendored
26
.github/workflows/ios-store-release.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
160
apps/ios/fastlane/testflight.rb
Normal file
160
apps/ios/fastlane/testflight.rb
Normal 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
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue