feat(ios): deterministically plan App Store releases

This commit is contained in:
joshavant
2026-07-23 18:12:42 -05:00
parent af443b4384
commit b7d77b0f21
18 changed files with 1250 additions and 156 deletions
+7 -6
View File
@@ -29,12 +29,13 @@ Root rules still apply. This file adds the iOS release guardrails.
## App Store Releases
- Agent-driven App Store uploads must use only `pnpm ios:release:upload`.
- App Store uploads must include explicit release intent: `pnpm ios:release:upload -- --version <YYYY.M.D> --revision <0-99>` and `--build-number <n>` when a specific build has been chosen.
- `--version` is the gateway version. `--revision` is the public iOS release ordinal encoded into `CFBundleShortVersionString`; never pass the encoded App Store version through `--version`.
- Release selection is deterministic. If the user does not supply a gateway version, resolve it with `node --import tsx scripts/ios-version.ts --field canonicalVersion`. The user must explicitly supply the App Store revision; do not infer it.
- Never derive a gateway version or App Store revision from the current date, `## Unreleased`, mobile-release refs, App Store Connect builds, or existing version records.
- Existing builds for the same gateway version and App Store revision mean upload the next build to that exact App Store version. Start a different App Store revision only when the user explicitly supplies it.
- Release selection belongs to the pipeline. Run `pnpm ios:release:plan -- --json`; if it reports `changelogStatus: needs-cut`, run `pnpm ios:release:cut`, review and commit `apps/ios/CHANGELOG.md`, then run `pnpm ios:release:upload` without release arguments.
- The planner derives the gateway version from the canonical root version, 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.
- 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.
- `--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. `## Unreleased` supplies notes only through the deterministic cutter; it does not select a revision.
- 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 `pnpm ios:release:archive`, `asc builds upload`, `asc release stage`, `asc publish appstore`, `asc review submit`, direct Fastlane lanes, or any manual App Store Connect mutation command.
- After a failed `pnpm ios:release:upload`, do not continue with a lower-level upload path. A human may repair App Store Connect state; the next pipeline run re-plans the same revision and next build automatically.
- 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.
+32 -25
View File
@@ -64,21 +64,21 @@ Release behavior:
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF before archiving and uploading the IPA.
- `pnpm ios:release:upload` generates App Store screenshots, archives and validates the IPA, uploads release notes and the rendered `apps/ios/APP-REVIEW-NOTES.md` attachment, uploads the IPA, and waits for Apple processing.
- Agent-driven App Store uploads must use `pnpm ios:release:upload` as the only release path. If that command fails, stop and fix the failing screenshot, metadata, archive, validation, or upload step before trying again.
- Do not treat `pnpm ios:release:archive`, `asc builds upload`, `asc release stage`, `asc publish appstore`, direct Fastlane lanes, or App Store Connect mutation commands as fallback upload paths after `pnpm ios:release:upload` fails.
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
- Before submitting a HealthKit-enabled build, the release owner must update the public privacy policy and App Store Connect privacy details for the Health & Fitness aggregates shared with the user's configured AI provider.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Release uploads require an explicit gateway CalVer passed with `--version` and an explicit App Store revision passed with `--revision`.
- Release uploads derive the gateway, App Store revision, and build from the canonical repository version plus live App Store Connect state.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
- The gateway version must use CalVer like `2026.7.2`.
- Gateway `2026.7.2`, App Store revision `1` becomes:
- `CFBundleShortVersionString = 2026.7.201`
- `CFBundleVersion = next App Store Connect build number for 2026.7.201`
- Each App Store version has its own build sequence beginning at `1`.
- Local defaults derive from root `package.json`; App Store uploads derive their marketing version from explicit `--version` and `--revision` values.
- Local defaults and release planning derive the gateway from root `package.json`; App Store Connect versions and build uploads determine the release revision and build.
- See `apps/ios/VERSIONING.md` for the full workflow.
Relay behavior for App Store builds:
@@ -120,18 +120,22 @@ pnpm ios:release:archive -- --version 2026.7.2 --revision 1
This command is for local archive validation only. It is not a fallback upload
path after `pnpm ios:release:upload` fails.
Archive and upload to App Store Connect:
Inspect and cut the deterministic release plan:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
pnpm ios:release:cut
```
If you need to force a specific build number:
Review and commit the changelog cut, then archive and upload to App Store Connect:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
pnpm ios:release:upload
```
Explicit `--version`, `--revision`, and `--build-number` values are checked
overrides and must match the live plan.
### Maintainer Quick Release Checklist
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
@@ -164,16 +168,17 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
4. For a new App Store revision, add the exact encoded-version changelog section and validate the release notes:
4. Inspect the plan and cut the exact encoded-version changelog section:
```bash
pnpm ios:version:check -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
pnpm ios:release:cut
```
5. Upload the build with explicit release intent:
5. Review and commit `apps/ios/CHANGELOG.md`, then upload:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
pnpm ios:release:upload
```
6. If `pnpm ios:release:upload` fails, stop at that failure. Do not archive
@@ -181,7 +186,7 @@ pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
step, then rerun `pnpm ios:release:upload`.
7. Expected behavior:
- Fastlane reads the explicit gateway `--version` and App Store `--revision`
- Fastlane resolves the gateway, revision, and next build from repository and App Store Connect state
- validates iOS versioning inputs for that version
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
@@ -189,20 +194,21 @@ pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
- uploads the IPA to App Store Connect for processing and App Review use
- validates the IPA with Apple, uploads it, and waits for App Store Connect processing
- leaves App Review submission for a maintainer to complete manually
8. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
- a complete App Store Connect build-upload record for that version and build
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
- Release gateway version: explicit `--version`
- App Store revision: explicit `--revision`
- Release gateway version: canonical root version, with an optional checked `--version` override
- App Store revision and build: deterministic App Store Connect plan
- Local default version: root `package.json`
- iOS-only changelog: `apps/ios/CHANGELOG.md`
- Generated local artifacts:
@@ -214,7 +220,8 @@ pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
```bash
pnpm ios:version
pnpm ios:version:check
pnpm ios:version -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
pnpm ios:release:cut
pnpm ios:filelist:gen
```
@@ -222,19 +229,19 @@ Recommended flow:
### App Store Connect iteration on an existing train
1. Choose the gateway and App Store revision explicitly, for example `2026.7.2` revision `1`.
2. Update `apps/ios/CHANGELOG.md` under the encoded `## 2026.7.201` heading.
3. Run `pnpm ios:version:check -- --version 2026.7.2 --revision 1` after changelog changes.
4. Upload additional builds with `pnpm ios:release:upload -- --version 2026.7.2 --revision 1`.
5. Let Fastlane bump only the numeric build number.
1. Run `pnpm ios:release:plan -- --json`; the editable revision is selected automatically.
2. Run `pnpm ios:release:cut` when new `## Unreleased` notes need to join that revision.
3. Review and commit `apps/ios/CHANGELOG.md`.
4. Run `pnpm ios:release:upload`.
5. Failed, processing, and complete Apple-visible uploads all advance the next numeric build.
### Starting the next App Store revision
1. Confirm the target gateway version in root `package.json`.
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
3. Run `pnpm ios:version:check -- --version <gateway-version> --revision <revision>`.
4. Submit the first build with `pnpm ios:release:upload -- --version <gateway-version> --revision <revision>`.
5. Keep iterating on that same explicit version until the release candidate is ready.
2. Add release notes under `## Unreleased`.
3. Run `pnpm ios:release:plan -- --json`; released history determines the next revision.
4. Run `pnpm ios:release:cut`, review and commit the changelog, then run `pnpm ios:release:upload`.
5. Keep rerunning the planner-driven upload until the release candidate is ready.
See `apps/ios/VERSIONING.md` for the detailed spec.
+44 -34
View File
@@ -1,15 +1,15 @@
# OpenClaw iOS Versioning
OpenClaw iOS releases retain their gateway association while allowing multiple
public App Store releases for one gateway version. Release commands name the
gateway version and the App Store revision explicitly.
public App Store releases for one gateway version. The release planner derives
the active release identity from the repository and App Store Connect.
## Goals
- keep the associated gateway version recognizable
- support multiple public iOS releases per gateway version
- support multiple candidate builds per App Store version
- make every release identity explicit and deterministic
- make every release identity deterministic and inspectable before upload
- keep Apple bundle fields valid for App Store Connect
- generate version-specific App Store release notes from the iOS changelog
@@ -42,22 +42,23 @@ format, including revision zero.
## Release commands
Release uploads require the gateway version and App Store revision:
Inspect the read-only release plan:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
```
Use `--build-number` only when the exact next remote build number has already
been verified:
Cut `## Unreleased` notes into the planned encoded version, commit the result,
then upload:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
pnpm ios:release:cut
pnpm ios:release:upload
```
During upload, an explicit build number must equal the next App Store Connect
build for the derived App Store version. Offline archive validation can accept
an explicit build number without remote validation:
`--version`, `--revision`, and `--build-number` remain available as checked
overrides. Upload rejects any override that differs from the live plan. Offline
archive validation still requires explicit values:
```bash
pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3
@@ -79,23 +80,29 @@ therefore the packed App Store version.
- A revision is reserved once its App Store version record is created and is
never reused.
- Rejected or replaced candidate builds stay on the same App Store version and
increment only the build number.
- Awaiting, processing, failed, and complete uploads stay on the same App Store
version and increment only the build number.
- After an App Store version is distributed, another public release for the
same gateway uses the next revision and resets its build number to `1`.
- Build numbers are derived from the highest uploaded build for the exact App
Store version plus one. Failed local archives do not consume build numbers;
accepted App Store Connect uploads do.
- Build numbers come from the highest App Store Connect `buildUploads` record
for the exact version plus one. Failed local archives do not consume build
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:
- an absent version may be created during metadata staging
- an editable version is reused
- the one editable version for the current gateway is reused
- a locked or in-review version fails the run
- an unreleased revision present only in build-upload history is retried
- a distributed version requires the next revision
- a missing revision below an existing higher version fails because revisions
are never reused
- multiple active versions, a different active gateway, and unknown upload
states fail closed for human resolution
Only one iOS release uploader may run at a time. The pipeline rechecks the
exact plan after local archive and Transporter validation, immediately before
its first App Store mutation. After upload it waits up to one hour for Apple
processing, then fails the attempt rather than polling indefinitely.
## Release notes
@@ -117,19 +124,19 @@ Production revision builds do not fall back to the gateway heading or
`## Unreleased`. Local version checks without `--revision` retain the existing
gateway/`Unreleased` fallback for development.
Validate exact release notes with:
The cutter moves new notes into that exact heading and is idempotent:
```bash
pnpm ios:version:check -- --version 2026.7.2 --revision 1
pnpm ios:release:cut
```
## Source of truth and generated files
Source files:
- root `package.json`: default gateway version for local builds
- explicit `--version`: gateway version for release commands
- explicit `--revision`: App Store revision for release commands
- root `package.json`: default gateway version for local builds and release planning
- App Store Connect versions and build uploads: revision/build lifecycle state
- explicit release arguments: checked overrides only
- `apps/ios/CHANGELOG.md`: exact App Store release notes
- `apps/ios/VERSIONING.md`: versioning contract
@@ -143,7 +150,11 @@ Generated or derived files:
The canonical implementation is split across:
- `scripts/lib/ios-version.ts`: validation, encoding, and release-note rendering
- `scripts/lib/ios-release-plan.ts`: deterministic revision/build selection and
changelog cutting
- `scripts/ios-version.ts`: JSON, shell, and single-field queries
- `scripts/ios-release-plan.ts`: pure planner CLI used by the Fastlane adapter
- `scripts/ios-release-{plan,cut}.sh`: public planning and cutting entry points
- `scripts/ios-sync-versioning.ts`: release-note validation
- `scripts/ios-release-upload.sh`: guarded upload entry point
- `apps/ios/fastlane/Fastfile`: remote preflight, build allocation, metadata,
@@ -168,24 +179,23 @@ Connect accepts the upload. Existing refs are immutable.
## Normal workflow
1. Choose the gateway version and App Store revision explicitly.
2. Add an exact encoded-version section to `apps/ios/CHANGELOG.md`.
3. Validate it:
1. Inspect the plan:
```bash
pnpm ios:version:check -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
```
4. Upload build `1`, or let Fastlane resolve the next build:
2. Cut and commit release notes when the plan reports `needs-cut`.
3. Upload the planned build:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
pnpm ios:release:upload
```
5. Iterate on the same version for builds `2`, `3`, and so on.
6. Select one processed build and submit it manually in App Store Connect.
7. If another public release is needed after distribution, increment the App
Store revision and start its build count at `1`.
4. If the run fails, stop. After a human repairs App Store Connect, rerun the
same pipeline; it keeps the revision and advances the build automatically.
5. Select one processed build and submit it manually in App Store Connect.
6. After distribution, the next run allocates the next App Store revision.
Agent-driven uploads must use `pnpm ios:release:upload`. A failed upload is
terminal for that attempt: report the failing step rather than switching to a
+191 -19
View File
@@ -65,6 +65,13 @@ APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5
APP_STORE_BUILD_PROCESSING_TIMEOUT_SECONDS = 3600
IOS_BUILD_UPLOAD_STATES = [
"AWAITING_UPLOAD",
"PROCESSING",
"FAILED",
"COMPLETE"
].freeze
EDITABLE_APP_STORE_VERSION_STATES = [
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
@@ -1217,7 +1224,7 @@ def read_ios_version_metadata(release_version: nil, app_store_revision: nil)
version = parsed["canonicalVersion"].to_s.strip
short_version = parsed["marketingVersion"].to_s.strip
revision = parsed["appStoreRevision"].to_s.strip
if !env_present?(version) || !env_present?(short_version) || !env_present?(revision)
if !env_present?(version) || !env_present?(short_version)
UI.user_error!("iOS version helper returned incomplete metadata.")
end
@@ -1230,6 +1237,101 @@ rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
end
def app_store_connect_target_app
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
end
def app_store_build_uploads(app_id:, short_version: nil)
# Build uploads include failed and still-processing attempts before they
# materialize as TestFlight builds, so they own build-number allocation.
filter = { platform: Spaceship::ConnectAPI::Platform::IOS }
filter[:cfBundleShortVersionString] = short_version if env_present?(short_version)
Spaceship::ConnectAPI
.get_build_uploads(app_id: app_id, filter: filter, includes: nil, limit: 200, sort: nil)
.all_pages
.flat_map(&:to_models)
end
def release_source_clean?
stdout, _stderr, status = Open3.capture3(
"git",
"status",
"--porcelain=v1",
"--untracked-files=all",
chdir: repo_root
)
status.success? && stdout.strip.empty?
end
def resolve_ios_release_plan!(release_version: nil, app_store_revision: nil, build_number: nil)
gateway_metadata = read_ios_version_metadata
if env_present?(release_version.to_s)
explicit_gateway = read_ios_version_metadata(release_version: release_version)[:version]
if explicit_gateway != gateway_metadata[:version]
UI.user_error!(
"Explicit iOS gateway version #{explicit_gateway} does not match canonical root version #{gateway_metadata[:version]}."
)
end
end
app = app_store_connect_target_app
versions = app.get_app_store_versions(
filter: { platform: Spaceship::ConnectAPI::Platform::IOS },
includes: nil
)
uploads = app_store_build_uploads(app_id: app.id)
input = {
appStoreVersions: versions.map do |version|
{
id: version.id.to_s,
state: app_store_version_state(version),
versionString: version.version_string.to_s
}
end,
buildUploads: uploads.map do |upload|
{
buildNumber: upload.cf_build_version.to_s,
shortVersion: upload.cf_build_short_version_string.to_s,
state: upload.state.to_s
}
end,
explicitBuildNumber: env_present?(build_number.to_s) ? build_number.to_s : nil,
explicitRevision: env_present?(app_store_revision.to_s) ? app_store_revision.to_s : nil,
gatewayVersion: gateway_metadata[:version],
rootDir: repo_root,
sourceClean: release_source_clean?,
sourceSha: release_git_sha
}
stdout = ""
stderr = ""
status = nil
Tempfile.create(["openclaw-ios-release-plan", ".json"]) do |file|
file.write(JSON.generate(input))
file.flush
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
File.join(repo_root, "scripts", "ios-release-plan.ts"),
"--input",
file.path,
chdir: repo_root
)
end
unless status&.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Unable to resolve deterministic iOS release plan: #{detail}")
end
JSON.parse(stdout)
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from iOS release planner: #{e.message}")
end
def sync_ios_versioning!(release_version: nil, app_store_revision: nil)
script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
args = [
@@ -1290,13 +1392,19 @@ def resolve_release_build_number(api_key:, short_version:, explicit_build_number
end
end
latest_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_STORE_APP_IDENTIFIER,
version: short_version,
initial_build_number: 0
)
next_build = latest_build.to_i + 1
app = app_store_connect_target_app
uploads = app_store_build_uploads(app_id: app.id, short_version: short_version)
unknown_states = uploads.map { |upload| upload.state.to_s }.uniq - IOS_BUILD_UPLOAD_STATES
unless unknown_states.empty?
UI.user_error!("Unknown App Store build upload states for #{short_version}: #{unknown_states.join(', ')}.")
end
uploaded_builds = uploads.map do |upload|
value = upload.cf_build_version.to_s.strip
UI.user_error!("Invalid App Store build number '#{value}' for #{short_version}.") unless value.match?(/\A[1-9]\d*\z/)
value.to_i
end
latest_build = uploaded_builds.max || 0
next_build = latest_build + 1
if env_present?(explicit) && explicit.to_i != next_build
UI.user_error!(
"Invalid iOS release build number #{explicit} for #{short_version}; expected #{next_build} after App Store Connect build #{latest_build}."
@@ -1306,6 +1414,19 @@ def resolve_release_build_number(api_key:, short_version:, explicit_build_number
next_build.to_s
end
def verify_app_store_binary!(api_key:, ipa_path:)
deliver(
api_key: api_key,
app_identifier: APP_STORE_APP_IDENTIFIER,
ipa: ipa_path,
platform: "ios",
verify_only: true,
skip_metadata: true,
skip_screenshots: true,
submit_for_review: false
)
end
def release_build_number_needs_app_store_connect_auth?(explicit_build_number: nil)
explicit = explicit_build_number.to_s.strip
!env_present?(explicit)
@@ -1603,11 +1724,29 @@ platform :ios do
explicit_build_number = options[:build_number].to_s.strip
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?(explicit_build_number: explicit_build_number)
api_key = needs_api_key ? app_store_connect_api_key_config : nil
if release_version.empty?
UI.user_error!("Missing iOS gateway version. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
end
if app_store_revision.empty?
UI.user_error!("Missing iOS App Store revision. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
release_plan = nil
if require_api_key
release_plan = resolve_ios_release_plan!(
release_version: release_version,
app_store_revision: app_store_revision,
build_number: explicit_build_number
)
release_version = release_plan.fetch("gatewayVersion")
app_store_revision = release_plan.fetch("appStoreRevision").to_s
explicit_build_number = release_plan.fetch("buildNumber").to_s
if release_plan.fetch("changelogStatus") != "ready"
UI.user_error!(
"iOS release notes are not cut for #{release_plan.fetch("appStoreVersion")}. " \
"Run `pnpm ios:release:cut`, commit apps/ios/CHANGELOG.md, then rerun the upload."
)
end
else
if release_version.empty?
UI.user_error!("Missing iOS gateway version. Use explicit --version and --revision for local archive validation.")
end
if app_store_revision.empty?
UI.user_error!("Missing iOS App Store revision. Use explicit --version and --revision for local archive validation.")
end
end
sync_ios_versioning!(
release_version: release_version,
@@ -1620,11 +1759,15 @@ platform :ios do
version = version_metadata[:version]
short_version = version_metadata[:short_version]
provenance = pin_release_build_provenance!
build_number = resolve_release_build_number(
api_key: api_key,
short_version: short_version,
explicit_build_number: explicit_build_number
)
build_number = if release_plan
explicit_build_number
else
resolve_release_build_number(
api_key: api_key,
short_version: short_version,
explicit_build_number: explicit_build_number
)
end
release_xcconfig = prepare_app_store_release!(
version: version,
app_store_revision: version_metadata[:app_store_revision],
@@ -1638,11 +1781,30 @@ platform :ios do
build_number: build_number,
git_commit: provenance[:git_commit],
release_xcconfig: release_xcconfig,
release_plan: release_plan,
short_version: short_version,
version: version
}
end
desc "Print the deterministic App Store release plan without mutating remote state"
lane :release_plan do |options|
app_store_connect_api_key_config
plan = resolve_ios_release_plan!(
release_version: options[:release_version],
app_store_revision: options[:app_store_revision],
build_number: options[:build_number]
)
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")} " \
"revision=#{plan.fetch("appStoreRevision")} short=#{plan.fetch("appStoreVersion")} " \
"build=#{plan.fetch("buildNumber")} decision=#{plan.fetch("decision")}"
)
end
desc "Print the App Store signing plan"
lane :signing_plan do
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"]))
@@ -1721,6 +1883,14 @@ platform :ios do
end
verify_apple_release_source!(release_sha)
build = build_app_store_release(context)
verify_app_store_binary!(api_key: context[:api_key], ipa_path: build[:ipa_path])
# Release operations have one active uploader by policy. This final read
# catches human state changes; it is not a cross-uploader lock.
resolve_ios_release_plan!(
release_version: context[:version],
app_store_revision: context[:app_store_revision],
build_number: context[:build_number]
)
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
@@ -1732,7 +1902,9 @@ platform :ios do
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
skip_submission: true,
skip_waiting_for_build_processing: false,
wait_processing_timeout_duration: APP_STORE_BUILD_PROCESSING_TIMEOUT_SECONDS,
uses_non_exempt_encryption: false
)
record_mobile_release_ref!(
+15 -11
View File
@@ -88,7 +88,7 @@ fastlane ios auth_check
App Store Connect API auth is required when:
- uploading to App Store Connect
- auto-resolving the next build number from App Store Connect
- planning the App Store revision and next build from App Store Connect
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need App Store Connect API auth.
@@ -109,7 +109,10 @@ The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters
Upload to App Store Connect:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
pnpm ios:release:cut
# Review and commit apps/ios/CHANGELOG.md.
pnpm ios:release:upload
```
Direct Fastlane upload is disabled. Use the package script so the release
@@ -135,16 +138,17 @@ cd apps/ios
fastlane ios auth_check
```
4. For a new App Store revision, add an exact encoded-version changelog section and validate it:
4. Plan and cut the exact encoded-version changelog section:
```bash
pnpm ios:version:check -- --version 2026.7.2 --revision 1
pnpm ios:release:plan -- --json
pnpm ios:release:cut
```
5. Upload:
5. Review and commit `apps/ios/CHANGELOG.md`, then upload:
```bash
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
pnpm ios:release:upload
```
Quick verification after upload:
@@ -156,18 +160,18 @@ Quick verification after upload:
Versioning rules:
- App Store release uploads require an explicit gateway `--version` and App Store `--revision`
- local defaults derive from root `package.json`
- App Store release uploads derive the gateway from root `package.json` and revision/build state from App Store Connect
- explicit `--version`, `--revision`, and `--build-number` values are checked overrides
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source
- Gateway versions use CalVer: `YYYY.M.D`
- Fastlane derives the App Store version as `YYYY.M.(D * 100 + revision)`
- Gateway `2026.7.2`, revision `1` sets `CFBundleShortVersionString` to `2026.7.201`
- Fastlane resolves `CFBundleVersion` as the next integer build for that exact App Store version
- Run `pnpm ios:version:check -- --version <gateway-version> --revision <revision>` after changing `apps/ios/CHANGELOG.md`
- Fastlane resolves `CFBundleVersion` from the maximum awaiting, processing, failed, or complete build-upload record plus one
- Run `pnpm ios:release:cut` after changing `## Unreleased`, then review and commit the exact encoded heading
- `pnpm ios:version:check` validates that release notes can be generated from the iOS changelog
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- 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` generates and uploads screenshots, release notes, and the App Review PDF attachment before archiving, then uploads the IPA without submitting it for App Review or uploading the App Store Connect `Notes` field
- `pnpm ios:release:upload` generates and uploads screenshots, release notes, and the App Review PDF attachment before uploading the IPA, waits for build processing, and does not submit for App Review or upload the App Store Connect `Notes` field
- See `apps/ios/VERSIONING.md` for the detailed workflow
+2 -2
View File
@@ -12,7 +12,7 @@ DELIVER_METADATA=1 fastlane ios metadata release_version:2026.7.2 app_store_revi
## Release notes and App Review attachment
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes and the App Review PDF attachment without rewriting all metadata:
`pnpm ios:release:upload` uses this mode after local archive validation so the editable App Store version has current release notes and the App Review PDF attachment without rewriting all metadata:
```bash
cd apps/ios
@@ -45,7 +45,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
## Notes
- Locale files live under `metadata/<locale>/`, for example `metadata/en-US/` and `metadata/sv-SE/`. Each locale directory should use the public metadata filenames consumed by the `ios metadata` lane.
- Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; validate them with `pnpm ios:version:check -- --version <gateway-version> --revision <revision>`.
- Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; use `pnpm ios:release:plan -- --json` and `pnpm ios:release:cut` to prepare the exact encoded section.
- Do not check in `release_notes.txt` under locale metadata directories; the lane strips copied release-note files and writes the current generated en-US release notes when requested.
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
- Production release notes require the exact encoded App Store heading, such as `## 2026.7.201`; they do not fall back to the gateway or `## Unreleased` section.
+2
View File
@@ -1520,6 +1520,8 @@
"ios:gen": "bash -c 'export PATH=\"$PATH:/opt/homebrew/bin:/usr/local/bin\"; ./scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && node scripts/ios-write-swift-filelist.mjs && cd apps/ios && xcodegen generate'",
"ios:open": "bash -c 'export PATH=\"$PATH:/opt/homebrew/bin:/usr/local/bin\"; ./scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && node scripts/ios-write-swift-filelist.mjs && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:release:archive": "bash scripts/ios-release-archive.sh",
"ios:release:cut": "bash scripts/ios-release-cut.sh",
"ios:release:plan": "bash scripts/ios-release-plan.sh",
"ios:release:prepare": "bash scripts/ios-release-prepare.sh",
"ios:release:signing:check": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_check'",
"ios:release:signing:plan": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_plan'",
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-cut.sh [--version 2026.7.2] [--revision 1] [--build-number 3]
Resolves the live iOS release plan and moves Unreleased notes into the exact
planned App Store version heading. This does not mutate App Store Connect.
EOF
}
for argument in "$@"; do
if [[ "${argument}" == "-h" || "${argument}" == "--help" ]]; then
usage
exit 0
fi
done
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLAN_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-ios-release-cut.XXXXXX")"
trap 'rm -f "${PLAN_FILE}"' EXIT
bash "${ROOT_DIR}/scripts/ios-release-plan.sh" --json "$@" >"${PLAN_FILE}"
(
cd "${ROOT_DIR}"
node --import tsx scripts/ios-release-cut.ts --plan "${PLAN_FILE}"
)
+29
View File
@@ -0,0 +1,29 @@
// iOS release cutter promotes Unreleased notes into the planned App Store version.
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { cutIosReleaseChangelog, type IosReleasePlan } from "./lib/ios-release-plan.ts";
const planIndex = process.argv.indexOf("--plan");
const planPath = planIndex >= 0 ? process.argv[planIndex + 1] : undefined;
if (!planPath) {
console.error("Usage: node --import tsx scripts/ios-release-cut.ts --plan <plan-json-file>");
process.exit(1);
}
try {
const plan = JSON.parse(readFileSync(planPath, "utf8")) as IosReleasePlan;
const changelogPath = path.resolve("apps/ios/CHANGELOG.md");
const current = readFileSync(changelogPath, "utf8");
const updated = cutIosReleaseChangelog(current, plan.appStoreVersion);
if (updated !== current) {
writeFileSync(changelogPath, updated);
process.stdout.write(`Cut iOS App Store release notes for ${plan.appStoreVersion}.\n`);
} else {
process.stdout.write(
`iOS App Store release notes for ${plan.appStoreVersion} are already cut.\n`,
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-plan.sh [--json] [--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.
EOF
}
BUILD_NUMBER=""
APP_STORE_REVISION=""
RELEASE_VERSION=""
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
require_option_value() {
local option="$1"
local value="${2-}"
if [[ -z "${value}" || "${value}" == --* ]]; then
echo "Missing value for ${option}." >&2
usage >&2
exit 1
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
--json)
shift
;;
--build-number)
require_option_value "$1" "${2-}"
BUILD_NUMBER="${2:-}"
shift 2
;;
--revision)
require_option_value "$1" "${2-}"
APP_STORE_REVISION="${2:-}"
shift 2
;;
--version)
require_option_value "$1" "${2-}"
RELEASE_VERSION="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
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_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}")
if ! (
cd "${ROOT_DIR}/apps/ios"
run_ios_fastlane "${FASTLANE_ARGS[@]}" 1>&2
); then
echo "Failed to resolve the iOS release plan." >&2
exit 1
fi
cat "${PLAN_FILE}"
+18
View File
@@ -0,0 +1,18 @@
// iOS release plan CLI resolves pure App Store state supplied by the Fastlane adapter.
import { readFileSync } from "node:fs";
import { resolveIosReleasePlan, type IosReleasePlanInput } from "./lib/ios-release-plan.ts";
const inputIndex = process.argv.indexOf("--input");
const inputPath = inputIndex >= 0 ? process.argv[inputIndex + 1] : undefined;
if (!inputPath) {
console.error("Usage: node --import tsx scripts/ios-release-plan.ts --input <json-file>");
process.exit(1);
}
try {
const input = JSON.parse(readFileSync(inputPath, "utf8")) as IosReleasePlanInput;
process.stdout.write(`${JSON.stringify(resolveIosReleasePlan(input), null, 2)}\n`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+6 -12
View File
@@ -4,7 +4,7 @@ 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 [--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
@@ -61,19 +61,13 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "${RELEASE_VERSION}" ]]; then
echo "Missing required --version." >&2
usage >&2
exit 1
FASTLANE_ARGS=(ios release_upload)
if [[ -n "${RELEASE_VERSION}" ]]; then
FASTLANE_ARGS+=("release_version:${RELEASE_VERSION}")
fi
if [[ -z "${APP_STORE_REVISION}" ]]; then
echo "Missing required --revision." >&2
usage >&2
exit 1
if [[ -n "${APP_STORE_REVISION}" ]]; then
FASTLANE_ARGS+=("app_store_revision:${APP_STORE_REVISION}")
fi
FASTLANE_ARGS=(ios release_upload "release_version:${RELEASE_VERSION}" "app_store_revision:${APP_STORE_REVISION}")
if [[ -n "${BUILD_NUMBER}" ]]; then
FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")
fi
+9 -9
View File
@@ -3,17 +3,17 @@
run_ios_fastlane() {
if command -v fastlane >/dev/null 2>&1 && fastlane --version >/dev/null 2>&1; then
fastlane "$@"
return
return $?
fi
if command -v rbenv >/dev/null 2>&1; then
local version=""
while IFS= read -r version; do
if RBENV_VERSION="${version}" rbenv which fastlane >/dev/null 2>&1; then
RBENV_VERSION="${version}" rbenv exec fastlane "$@"
return
fi
done < <(rbenv versions --bare)
if command -v rbenv >/dev/null 2>&1; then
local version=""
while IFS= read -r version; do
if RBENV_VERSION="${version}" rbenv which fastlane >/dev/null 2>&1; then
RBENV_VERSION="${version}" rbenv exec fastlane "$@"
return $?
fi
done < <(rbenv versions --bare)
fi
echo "fastlane not found. Install fastlane or select a Ruby version that has the fastlane gem." >&2
+384
View File
@@ -0,0 +1,384 @@
// iOS release planning keeps App Store version and build selection deterministic.
import { readFileSync } from "node:fs";
import path from "node:path";
import {
encodeIosAppStoreVersion,
extractChangelogSection,
normalizeIosAppStoreRevision,
normalizePinnedIosVersion,
} from "./ios-version.ts";
const IOS_BUILD_UPLOAD_STATES = ["AWAITING_UPLOAD", "PROCESSING", "FAILED", "COMPLETE"] as const;
const EDITABLE_APP_STORE_VERSION_STATES = new Set([
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
"REJECTED",
"METADATA_REJECTED",
"INVALID_BINARY",
"READY_FOR_REVIEW",
]);
const RELEASED_APP_STORE_VERSION_STATES = new Set([
"READY_FOR_DISTRIBUTION",
"REPLACED_WITH_NEW_VERSION",
"READY_FOR_SALE",
"REMOVED_FROM_SALE",
"DEVELOPER_REMOVED_FROM_SALE",
]);
export type IosRemoteAppStoreVersion = {
id: string;
state: string;
versionString: string;
};
export type IosRemoteBuildUpload = {
buildNumber: string;
shortVersion: string;
state: string;
};
export type IosReleasePlanInput = {
appStoreVersions: IosRemoteAppStoreVersion[];
buildUploads: IosRemoteBuildUpload[];
explicitBuildNumber?: string | null;
explicitRevision?: string | number | null;
gatewayVersion: string;
rootDir?: string;
sourceClean?: boolean;
sourceSha?: string | null;
};
export type IosReleasePlan = {
appStoreRevision: number;
appStoreVersion: string;
appStoreVersionId: string | null;
appStoreVersionState: string | null;
buildNumber: number;
buildUploads: IosRemoteBuildUpload[];
changelogStatus: "needs-cut" | "ready";
decision: "new-revision" | "resume-editable" | "retry-upload";
gatewayVersion: string;
sourceClean: boolean | null;
sourceSha: string | null;
};
type DecodedVersion = {
legacy: boolean;
revision: number;
};
function parseVersionComponents(version: string): [number, number, number] | null {
const match = /^(\d{4})\.(\d{1,2})\.(\d+)$/u.exec(version.trim());
if (!match) {
return null;
}
const components = match.slice(1).map(Number);
if (components.some((value) => !Number.isSafeInteger(value))) {
return null;
}
return components as [number, number, number];
}
function compareAppStoreVersions(left: string, right: string): number {
const leftComponents = parseVersionComponents(left);
const rightComponents = parseVersionComponents(right);
if (!leftComponents || !rightComponents) {
throw new Error(`Unable to compare App Store versions '${left}' and '${right}'.`);
}
for (let index = 0; index < leftComponents.length; index += 1) {
const difference = (leftComponents[index] ?? 0) - (rightComponents[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}
export function decodeIosAppStoreVersion(
gatewayVersion: string,
appStoreVersion: string,
): DecodedVersion | null {
const gateway = parseVersionComponents(normalizePinnedIosVersion(gatewayVersion));
const candidate = parseVersionComponents(appStoreVersion);
if (!gateway || !candidate || gateway[0] !== candidate[0] || gateway[1] !== candidate[1]) {
return null;
}
if (candidate[2] === gateway[2]) {
return { legacy: true, revision: 0 };
}
const firstPackedPatch = gateway[2] * 100;
const revision = candidate[2] - firstPackedPatch;
if (revision < 0 || revision > 99) {
return null;
}
return { legacy: false, revision };
}
function normalizeBuildNumber(rawBuildNumber: string): number {
const normalized = rawBuildNumber.trim();
if (!/^[1-9]\d*$/u.test(normalized)) {
throw new Error(
`Invalid App Store build number '${rawBuildNumber}'. Expected a positive integer.`,
);
}
const buildNumber = Number(normalized);
if (!Number.isSafeInteger(buildNumber)) {
throw new Error(`Invalid App Store build number '${rawBuildNumber}'. Expected a safe integer.`);
}
return buildNumber;
}
function relevantBuildUploads(
uploads: IosRemoteBuildUpload[],
shortVersion: string,
): IosRemoteBuildUpload[] {
return uploads.filter((upload) => {
if (upload.shortVersion !== shortVersion) {
return false;
}
if (!(IOS_BUILD_UPLOAD_STATES as readonly string[]).includes(upload.state)) {
throw new Error(
`Unknown App Store build upload state '${upload.state}' for ${upload.shortVersion} build ${upload.buildNumber}.`,
);
}
normalizeBuildNumber(upload.buildNumber);
return true;
});
}
function nextBuildNumber(uploads: IosRemoteBuildUpload[], shortVersion: string): number {
const builds = relevantBuildUploads(uploads, shortVersion).map((upload) =>
normalizeBuildNumber(upload.buildNumber),
);
return builds.length === 0 ? 1 : Math.max(...builds) + 1;
}
function assertExplicitSelection(
plan: Pick<IosReleasePlan, "appStoreRevision" | "buildNumber">,
input: IosReleasePlanInput,
): void {
if (input.explicitRevision !== null && input.explicitRevision !== undefined) {
const explicitRevision = normalizeIosAppStoreRevision(input.explicitRevision);
if (explicitRevision !== plan.appStoreRevision) {
throw new Error(
`Explicit App Store revision ${explicitRevision} does not match the deterministic revision ${plan.appStoreRevision}.`,
);
}
}
const explicitBuild = input.explicitBuildNumber?.trim() ?? "";
if (explicitBuild) {
const buildNumber = normalizeBuildNumber(explicitBuild);
if (buildNumber !== plan.buildNumber) {
throw new Error(
`Explicit App Store build ${buildNumber} does not match the deterministic next build ${plan.buildNumber}.`,
);
}
}
}
export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePlan {
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),
);
if (activeVersions.length > 1) {
throw new Error(
`App Store Connect has multiple active iOS versions: ${activeVersions
.map((version) => `${version.versionString} (${version.state})`)
.join(", ")}.`,
);
}
let revision: number;
let decision: IosReleasePlan["decision"];
let selectedVersion: IosRemoteAppStoreVersion | null = null;
if (activeVersions.length === 1) {
selectedVersion = activeVersions[0] ?? null;
if (!selectedVersion || !EDITABLE_APP_STORE_VERSION_STATES.has(selectedVersion.state)) {
throw new Error(
`App Store version ${selectedVersion?.versionString ?? "unknown"} is locked in state ${selectedVersion?.state ?? "UNKNOWN"}.`,
);
}
const decoded = decodeIosAppStoreVersion(gatewayVersion, selectedVersion.versionString);
if (!decoded || decoded.legacy) {
throw new Error(
`Editable App Store version ${selectedVersion.versionString} does not belong to gateway ${gatewayVersion}.`,
);
}
revision = decoded.revision;
decision = "resume-editable";
} 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);
if (!decoded) {
return [];
}
if (!(IOS_BUILD_UPLOAD_STATES as readonly string[]).includes(upload.state)) {
throw new Error(
`Unknown App Store build upload state '${upload.state}' for ${upload.shortVersion} build ${upload.buildNumber}.`,
);
}
normalizeBuildNumber(upload.buildNumber);
if (decoded.legacy) {
hasLegacyUpload = true;
return [];
}
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)),
];
if (unreleasedUploadedRevisions.length > 1) {
throw new Error(
`Multiple unreleased App Store build-upload revisions exist for gateway ${gatewayVersion}: ${unreleasedUploadedRevisions.toSorted((left, right) => left - right).join(", ")}. Resolve App Store Connect state before retrying.`,
);
}
// Build-upload history survives processing failures and a manually removed
// version record, so retry that public revision until it is distributed.
if (highestUploaded > highestReleased) {
revision = highestUploaded;
decision = "retry-upload";
} else {
const historicalRevisions = decodedVersions.flatMap(({ decoded }) =>
decoded ? [decoded.revision] : [],
);
if (hasLegacyUpload) {
historicalRevisions.push(0);
}
const highestHistorical =
historicalRevisions.length === 0 ? -1 : Math.max(...historicalRevisions);
revision = Math.max(highestHistorical, highestUploaded) + 1;
decision = "new-revision";
}
}
if (revision > 99) {
throw new Error(`Gateway ${gatewayVersion} has exhausted App Store revisions 0 through 99.`);
}
const appStoreVersion = encodeIosAppStoreVersion(gatewayVersion, revision);
const releasedVersions = input.appStoreVersions
.filter((version) => RELEASED_APP_STORE_VERSION_STATES.has(version.state))
.map((version) => version.versionString)
.toSorted(compareAppStoreVersions);
const latestReleasedVersion = releasedVersions.at(-1);
if (
latestReleasedVersion &&
compareAppStoreVersions(appStoreVersion, latestReleasedVersion) <= 0
) {
throw new Error(
`Planned App Store version ${appStoreVersion} must be greater than latest released version ${latestReleasedVersion}.`,
);
}
const uploads = relevantBuildUploads(input.buildUploads, appStoreVersion);
const buildNumber = nextBuildNumber(input.buildUploads, appStoreVersion);
const rootDir = path.resolve(input.rootDir ?? ".");
const changelog = readFileSync(path.join(rootDir, "apps/ios/CHANGELOG.md"), "utf8");
const hasReleaseNotes = Boolean(extractChangelogSection(changelog, appStoreVersion));
const hasUnreleasedNotes = Boolean(extractChangelogSection(changelog, "Unreleased"));
const changelogStatus = hasReleaseNotes && !hasUnreleasedNotes ? "ready" : "needs-cut";
const plan: IosReleasePlan = {
appStoreRevision: revision,
appStoreVersion,
appStoreVersionId: selectedVersion?.id ?? null,
appStoreVersionState: selectedVersion?.state ?? null,
buildNumber,
buildUploads: uploads,
changelogStatus,
decision,
gatewayVersion,
sourceClean: input.sourceClean ?? null,
sourceSha: input.sourceSha?.trim() || null,
};
assertExplicitSelection(plan, input);
return plan;
}
type ChangelogSection = {
body: string;
end: number;
heading: string;
headingLine: string;
start: number;
};
function changelogSections(content: string): ChangelogSection[] {
const lines = content.split(/\r?\n/u);
const starts = lines.flatMap((line, index) => (line.startsWith("## ") ? [index] : []));
return starts.map((start, index) => {
const end = starts[index + 1] ?? lines.length;
return {
body: lines
.slice(start + 1, end)
.join("\n")
.trim(),
end,
heading: lines[start]?.slice(3).split(" - ", 1)[0]?.trim() ?? "",
headingLine: lines[start] ?? "",
start,
};
});
}
export function cutIosReleaseChangelog(content: string, appStoreVersion: string): string {
const lines = content.split(/\r?\n/u);
const sections = changelogSections(content);
const unreleased = sections.find((section) => section.heading === "Unreleased");
if (!unreleased) {
throw new Error("Missing ## Unreleased section in apps/ios/CHANGELOG.md.");
}
const target = sections.find((section) => section.heading === appStoreVersion);
if (!unreleased.body && !target?.body) {
throw new Error(`No release notes are available for App Store version ${appStoreVersion}.`);
}
if (!unreleased.body) {
return content;
}
const targetBody = [unreleased.body, target?.body].filter(Boolean).join("\n\n");
// Retry fixes join the same public release notes. Clearing Unreleased makes
// the cut idempotent and keeps the committed heading as upload provenance.
const beforeUnreleased = lines.slice(0, unreleased.start);
const afterUnreleased = lines.slice(unreleased.end);
let nextLines = [...beforeUnreleased, "## Unreleased", ""];
if (target) {
const adjustedTarget = changelogSections(afterUnreleased.join("\n")).find(
(section) => section.heading === appStoreVersion,
);
if (!adjustedTarget) {
throw new Error(`Unable to locate App Store changelog section ${appStoreVersion}.`);
}
nextLines = [
...nextLines,
...afterUnreleased.slice(0, adjustedTarget.start),
adjustedTarget.headingLine,
"",
targetBody,
"",
...afterUnreleased.slice(adjustedTarget.end),
];
} else {
nextLines = [...nextLines, `## ${appStoreVersion}`, "", targetBody, "", ...afterUnreleased];
}
return `${nextLines
.join("\n")
.replace(/\n{3,}/gu, "\n\n")
.trimEnd()}\n`;
}
+6
View File
@@ -1130,6 +1130,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
["apps/android/fastlane/Fastfile", ["test/scripts/android-release-fastlane-gates.test.ts"]],
["scripts/ios-release-archive.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
["scripts/ios-release-cut.sh", ["test/scripts/ios-release-plan.test.ts"]],
["scripts/ios-release-cut.ts", ["test/scripts/ios-release-plan.test.ts"]],
["scripts/ios-release-plan.sh", ["test/scripts/ios-release-plan.test.ts"]],
["scripts/ios-release-plan.ts", ["test/scripts/ios-release-plan.test.ts"]],
[
"scripts/ios-release-prepare.sh",
["test/scripts/ios-release-prepare.test.ts", "test/scripts/ios-release-wrapper-args.test.ts"],
@@ -1305,6 +1309,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
["scripts/lib/format-generated-module.mjs", ["test/scripts/format-generated-module.test.ts"]],
["scripts/lib/ios-version.ts", ["test/scripts/ios-version.test.ts"]],
["scripts/lib/ios-release-plan.ts", ["test/scripts/ios-release-plan.test.ts"]],
["scripts/lib/ios-fastlane.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]],
["scripts/live-docker-stage-private-sdk-exports.mjs", ["test/scripts/live-docker-stage.test.ts"]],
[
@@ -65,6 +65,8 @@ describe("iOS Fastlane release upload gates", () => {
};
expect(packageJson.scripts).toHaveProperty("ios:release:upload");
expect(packageJson.scripts).toHaveProperty("ios:release:plan");
expect(packageJson.scripts).toHaveProperty("ios:release:cut");
expect(packageJson.scripts).not.toHaveProperty("ios:release");
expect(existsSync(legacyReleaseScriptPath)).toBe(false);
});
@@ -73,8 +75,8 @@ describe("iOS Fastlane release upload gates", () => {
const script = readFileSync(uploadScriptPath, "utf8");
expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1");
expect(script).toContain("Missing required --version.");
expect(script).toContain("Missing required --revision.");
expect(script).not.toContain("Missing required --version.");
expect(script).not.toContain("Missing required --revision.");
expect(script).toContain('"release_version:${RELEASE_VERSION}"');
expect(script).toContain('"app_store_revision:${APP_STORE_REVISION}"');
expect(script).toContain('"build_number:${BUILD_NUMBER}"');
@@ -103,8 +105,10 @@ describe("iOS Fastlane release upload gates", () => {
expect(prepareContext).toContain("options[:release_version]");
expect(prepareContext).toContain("options[:app_store_revision]");
expect(prepareContext).toContain("options[:build_number]");
expect(prepareContext).toContain("Missing iOS gateway version");
expect(prepareContext).toContain("Missing iOS App Store revision");
expect(prepareContext).toContain("resolve_ios_release_plan!");
expect(prepareContext).toContain('release_plan.fetch("gatewayVersion")');
expect(prepareContext).toContain('release_plan.fetch("appStoreRevision")');
expect(prepareContext).toContain('release_plan.fetch("buildNumber")');
expect(releaseUpload).toContain("app_store_revision: context[:app_store_revision]");
expect(laneBody(fastfile, "metadata")).toContain("options[:release_version]");
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS gateway version");
@@ -141,10 +145,25 @@ describe("iOS Fastlane release upload gates", () => {
it("validates explicit build numbers against the exact App Store version", () => {
const resolver = functionBody(readFastfile(), "resolve_release_build_number");
expect(resolver).toContain("version: short_version");
expect(resolver).toContain("app_store_build_uploads");
expect(resolver).toContain("IOS_BUILD_UPLOAD_STATES");
expect(resolver).toContain("expected #{next_build}");
expect(resolver).toContain("explicit.to_i != next_build");
expect(resolver).toContain("api_key.nil?");
expect(resolver).not.toContain("latest_testflight_build_number");
});
it("plans revisions and builds from App Store versions and build uploads", () => {
const fastfile = readFastfile();
const planner = functionBody(fastfile, "resolve_ios_release_plan!");
const planLane = laneBody(fastfile, "release_plan");
expect(planner).toContain("get_app_store_versions");
expect(planner).toContain("app_store_build_uploads");
expect(planner).toContain("does not match canonical root version");
expect(planner).toContain('File.join(repo_root, "scripts", "ios-release-plan.ts")');
expect(planLane).toContain("resolve_ios_release_plan!");
expect(planLane).toContain("JSON.pretty_generate(plan)");
});
it("validates the exported IPA before the sole TestFlight upload call", () => {
@@ -156,6 +175,28 @@ describe("iOS Fastlane release upload gates", () => {
expect(uploadCall).toBeGreaterThan(validationCall);
});
it("validates with Apple and rechecks the plan before the first remote mutation", () => {
const releaseUpload = laneBody(readFastfile(), "release_upload");
const binaryValidation = releaseUpload.indexOf("verify_app_store_binary!");
const planRecheck = releaseUpload.lastIndexOf("resolve_ios_release_plan!");
const metadata = releaseUpload.indexOf("\n metadata(");
expect(binaryValidation).toBeGreaterThanOrEqual(0);
expect(planRecheck).toBeGreaterThan(binaryValidation);
expect(metadata).toBeGreaterThan(planRecheck);
});
it("waits for Apple build processing without submitting to TestFlight review", () => {
const releaseUpload = laneBody(readFastfile(), "release_upload");
expect(releaseUpload).toContain("skip_waiting_for_build_processing: false");
expect(releaseUpload).toContain("skip_submission: true");
expect(releaseUpload).toContain(
"wait_processing_timeout_duration: APP_STORE_BUILD_PROCESSING_TIMEOUT_SECONDS",
);
expect(releaseUpload).not.toContain("skip_waiting_for_build_processing: true");
});
it("finishes fallible local release work before mutating App Store metadata", () => {
const fastfile = readFastfile();
const releaseUpload = laneBody(fastfile, "release_upload");
+285
View File
@@ -0,0 +1,285 @@
// iOS release plan tests cover deterministic App Store version and build allocation.
import { describe, expect, it } from "vitest";
import {
cutIosReleaseChangelog,
decodeIosAppStoreVersion,
resolveIosReleasePlan,
type IosReleasePlanInput,
} from "../../scripts/lib/ios-release-plan.ts";
import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-support.ts";
installIosFixtureCleanup();
function input(overrides: Partial<IosReleasePlanInput> = {}): IosReleasePlanInput {
const rootDir = writeIosFixture({
packageVersion: "2026.7.2",
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry notes.\n",
});
return {
appStoreVersions: [],
buildUploads: [],
gatewayVersion: "2026.7.2",
rootDir,
...overrides,
};
}
describe("resolveIosReleasePlan", () => {
it("starts a new gateway at revision zero and build one", () => {
expect(resolveIosReleasePlan(input())).toMatchObject({
appStoreRevision: 0,
appStoreVersion: "2026.7.200",
buildNumber: 1,
changelogStatus: "needs-cut",
decision: "new-revision",
});
});
it("treats a legacy released gateway version as consumed revision zero", () => {
const plan = resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "legacy", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.2" },
],
}),
);
expect(plan).toMatchObject({
appStoreRevision: 1,
appStoreVersion: "2026.7.201",
buildNumber: 1,
decision: "new-revision",
});
});
it("treats legacy build-upload-only history as consumed revision zero", () => {
const plan = resolveIosReleasePlan(
input({
buildUploads: [
{
buildNumber: "4",
shortVersion: "2026.7.2",
state: "COMPLETE",
},
],
}),
);
expect(plan).toMatchObject({
appStoreRevision: 1,
appStoreVersion: "2026.7.201",
buildNumber: 1,
decision: "new-revision",
});
});
it("reuses the one editable revision", () => {
const plan = resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.201" },
],
}),
);
expect(plan).toMatchObject({
appStoreRevision: 1,
appStoreVersionId: "editable",
appStoreVersionState: "PREPARE_FOR_SUBMISSION",
decision: "resume-editable",
});
});
it("retries an uploaded but unreleased revision after its version record is removed", () => {
const plan = resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "legacy", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.2" },
],
buildUploads: [
{
buildNumber: "1",
shortVersion: "2026.7.201",
state: "FAILED",
},
],
}),
);
expect(plan).toMatchObject({
appStoreRevision: 1,
buildNumber: 2,
decision: "retry-upload",
});
});
it("rejects multiple upload-only unreleased revisions", () => {
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "legacy", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.2" },
],
buildUploads: [
{ buildNumber: "1", shortVersion: "2026.7.201", state: "FAILED" },
{ buildNumber: "1", shortVersion: "2026.7.202", state: "FAILED" },
],
}),
),
).toThrow("Multiple unreleased App Store build-upload revisions");
});
it.each(["AWAITING_UPLOAD", "PROCESSING", "FAILED", "COMPLETE"])(
"increments after %s build uploads",
(state) => {
const plan = resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "editable", state: "READY_FOR_REVIEW", versionString: "2026.7.201" },
],
buildUploads: [
{ buildNumber: "7", shortVersion: "2026.7.201", state },
{ buildNumber: "3", shortVersion: "2026.7.201", state: "COMPLETE" },
],
}),
);
expect(plan.buildNumber).toBe(8);
},
);
it("rejects locked and mismatched active versions", () => {
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [{ id: "locked", state: "IN_REVIEW", versionString: "2026.7.201" }],
}),
),
).toThrow("locked in state IN_REVIEW");
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "other", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.300" },
],
}),
),
).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.201" },
{ id: "two", state: "READY_FOR_REVIEW", versionString: "2026.7.202" },
],
}),
),
).toThrow("multiple active iOS versions");
expect(() =>
resolveIosReleasePlan(
input({
buildUploads: [
{ buildNumber: "1", shortVersion: "2026.7.200", state: "NEW_APPLE_STATE" },
],
}),
),
).toThrow("Unknown App Store build upload state");
});
it("fails after revision 99 is distributed", () => {
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "last", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.299" },
],
}),
),
).toThrow("exhausted App Store revisions 0 through 99");
});
it("rejects a planned version older than released history from another gateway", () => {
expect(() =>
resolveIosReleasePlan(
input({
appStoreVersions: [
{ id: "newer", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.2" },
],
gatewayVersion: "2026.6.11",
}),
),
).toThrow("must be greater than latest released version 2026.7.2");
});
it("rejects explicit selections that disagree with remote state", () => {
expect(() => resolveIosReleasePlan(input({ explicitRevision: 4 }))).toThrow(
"does not match the deterministic revision 0",
);
expect(() => resolveIosReleasePlan(input({ explicitBuildNumber: "4" }))).toThrow(
"does not match the deterministic next build 1",
);
});
it("decodes only legacy or packed versions for the selected gateway", () => {
expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.2")).toEqual({
legacy: true,
revision: 0,
});
expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.299")).toEqual({
legacy: false,
revision: 99,
});
expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.300")).toBeNull();
});
it("requires another cut when retry notes remain Unreleased", () => {
const rootDir = writeIosFixture({
packageVersion: "2026.7.2",
changelog:
"# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry notes.\n\n## 2026.7.201\n\nOriginal notes.\n",
});
const plan = resolveIosReleasePlan({
appStoreVersions: [
{ id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.201" },
],
buildUploads: [],
gatewayVersion: "2026.7.2",
rootDir,
});
expect(plan.changelogStatus).toBe("needs-cut");
});
});
describe("cutIosReleaseChangelog", () => {
it("cuts Unreleased notes into a new exact App Store version section", () => {
const current =
"# OpenClaw iOS Changelog\n\n## Unreleased\n\nNew notes.\n\n## 2026.7.2\n\nOld notes.\n";
const updated = cutIosReleaseChangelog(current, "2026.7.201");
expect(updated).toContain("## Unreleased\n\n## 2026.7.201\n\nNew notes.");
expect(updated).toContain("## 2026.7.2\n\nOld notes.");
expect(cutIosReleaseChangelog(updated, "2026.7.201")).toBe(updated);
});
it("merges retry notes into the existing release section", () => {
const current =
"# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.201\n\nOriginal notes.\n";
const updated = cutIosReleaseChangelog(current, "2026.7.201");
expect(updated).toContain("## 2026.7.201\n\nRetry fix.\n\nOriginal notes.");
});
it("preserves an existing release heading suffix", () => {
const current =
"# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.201 - 2026-07-23\n\nOriginal notes.\n";
const updated = cutIosReleaseChangelog(current, "2026.7.201");
expect(updated).toContain("## 2026.7.201 - 2026-07-23\n\nRetry fix.\n\nOriginal notes.");
});
});
+66 -33
View File
@@ -1,10 +1,12 @@
// iOS release wrapper tests keep release args fail-closed before Fastlane work.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
type WrapperCase = readonly [scriptPath: string, args: readonly string[], option: string];
@@ -36,6 +38,9 @@ describe("iOS release shell wrapper arguments", () => {
["scripts/ios-release-upload.sh", ["--build-number", "--bogus"], "--build-number"],
["scripts/ios-release-upload.sh", ["--version", "--bogus"], "--version"],
["scripts/ios-release-upload.sh", ["--revision", "--bogus"], "--revision"],
["scripts/ios-release-plan.sh", ["--build-number", "--bogus"], "--build-number"],
["scripts/ios-release-plan.sh", ["--version", "--bogus"], "--version"],
["scripts/ios-release-plan.sh", ["--revision", "--bogus"], "--revision"],
["scripts/ios-release-archive.sh", ["--build-number", "--bogus"], "--build-number"],
["scripts/ios-release-archive.sh", ["--version", "--bogus"], "--version"],
["scripts/ios-release-archive.sh", ["--revision", "--bogus"], "--revision"],
@@ -65,39 +70,37 @@ describe("iOS release shell wrapper arguments", () => {
},
);
it.each([
"scripts/ios-release-upload.sh",
"scripts/ios-release-archive.sh",
"scripts/ios-release-prepare.sh",
])("requires an explicit gateway version before release work in %s", (scriptPath) => {
const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "3"] : [];
const result = runScript(path.join(process.cwd(), scriptPath), args, {
IOS_RELEASE_VERSION: "2026.6.10",
});
it.each(["scripts/ios-release-archive.sh", "scripts/ios-release-prepare.sh"])(
"requires an explicit gateway version before release work in %s",
(scriptPath) => {
const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "3"] : [];
const result = runScript(path.join(process.cwd(), scriptPath), args, {
IOS_RELEASE_VERSION: "2026.6.10",
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Missing required --version.");
expect(result.stderr).not.toContain("No such file or directory");
expect(result.stderr).not.toContain("fastlane");
expect(result.stdout).toBe("");
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Missing required --version.");
expect(result.stderr).not.toContain("No such file or directory");
expect(result.stderr).not.toContain("fastlane");
expect(result.stdout).toBe("");
},
);
it.each([
"scripts/ios-release-upload.sh",
"scripts/ios-release-archive.sh",
"scripts/ios-release-prepare.sh",
])("requires an explicit App Store revision before release work in %s", (scriptPath) => {
const args = ["--version", "2026.7.2"];
if (scriptPath.endsWith("prepare.sh")) {
args.push("--build-number", "3");
}
const result = runScript(path.join(process.cwd(), scriptPath), args);
it.each(["scripts/ios-release-archive.sh", "scripts/ios-release-prepare.sh"])(
"requires an explicit App Store revision before release work in %s",
(scriptPath) => {
const args = ["--version", "2026.7.2"];
if (scriptPath.endsWith("prepare.sh")) {
args.push("--build-number", "3");
}
const result = runScript(path.join(process.cwd(), scriptPath), args);
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Missing required --revision.");
expect(result.stderr).not.toContain("fastlane");
expect(result.stdout).toBe("");
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Missing required --revision.");
expect(result.stderr).not.toContain("fastlane");
expect(result.stdout).toBe("");
},
);
it.each(["scripts/ios-release-upload.sh", "scripts/ios-release-archive.sh"])(
"does not accept ambient release build numbers in %s",
@@ -109,6 +112,15 @@ describe("iOS release shell wrapper arguments", () => {
},
);
it("lets the guarded upload lane resolve omitted release arguments", () => {
const script = readFileSync(path.join(process.cwd(), "scripts/ios-release-upload.sh"), "utf8");
expect(script).not.toContain("Missing required --version.");
expect(script).not.toContain("Missing required --revision.");
expect(script).toContain('[[ -n "${RELEASE_VERSION}" ]]');
expect(script).toContain('[[ -n "${APP_STORE_REVISION}" ]]');
});
it("rejects App Store release relay URL overrides before release work", () => {
const result = runScript(
path.join(process.cwd(), "scripts/ios-release-prepare.sh"),
@@ -138,4 +150,25 @@ describe("iOS release shell wrapper arguments", () => {
);
expect(script).toContain('export GIT_COMMIT="${RELEASE_GIT_COMMIT}"');
});
it("preserves Fastlane failures through the shared runner", () => {
const binDir = tempDirs.make("openclaw-fastlane-test-");
const fastlane = path.join(binDir, "fastlane");
writeFileSync(
fastlane,
'#!/usr/bin/env bash\n[[ "${1:-}" == "--version" ]] && exit 0\nexit 37\n',
);
chmodSync(fastlane, 0o755);
const result = spawnSync(
BASH_BIN,
["-c", "source scripts/lib/ios-fastlane.sh; run_ios_fastlane ios release_plan"],
{
cwd: process.cwd(),
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` },
encoding: "utf8",
},
);
expect(result.status).toBe(37);
});
});