diff --git a/apps/ios/AGENTS.md b/apps/ios/AGENTS.md index fd05aea39a08..723c7cabf13f 100644 --- a/apps/ios/AGENTS.md +++ b/apps/ios/AGENTS.md @@ -29,10 +29,11 @@ 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 ` and `--build-number ` when a specific build has been chosen. -- Release version selection is deterministic. If the user supplies a version, use it. Otherwise resolve the current gateway version with `node --import tsx scripts/ios-version.ts --field canonicalVersion` and pass that value explicitly to `pnpm ios:release:upload`. -- Never derive a release version from the current date, `## Unreleased`, existing mobile-release refs, or App Store Connect builds. -- Existing builds for the current gateway version mean upload the next build to that same release train. Start a different release train only when the user explicitly supplies its version. +- App Store uploads must include explicit release intent: `pnpm ios:release:upload -- --version --revision <0-99>` and `--build-number ` 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. - 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. - 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. diff --git a/apps/ios/README.md b/apps/ios/README.md index 2318486970a8..9613fa682d76 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -71,13 +71,14 @@ Release behavior: - 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 CalVer version passed with `--version`. +- Release uploads require an explicit gateway CalVer passed with `--version` and an explicit App Store revision passed with `--revision`. - `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source. -- The release version must use CalVer like `2026.4.10`. -- That release value becomes: - - `CFBundleShortVersionString = 2026.4.10` - - `CFBundleVersion = next App Store Connect build number for 2026.4.10` -- Local defaults derive from root `package.json`; App Store uploads use the explicit `--version` value. +- 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. - See `apps/ios/VERSIONING.md` for the full workflow. Relay behavior for App Store builds: @@ -107,13 +108,13 @@ Release-owner secrets: Prepare the generated release xcconfig/project without archiving: ```bash -pnpm ios:release:prepare -- --version 2026.6.11 --build-number 7 +pnpm ios:release:prepare -- --version 2026.7.2 --revision 1 --build-number 3 ``` Archive without upload: ```bash -pnpm ios:release:archive -- --version 2026.6.11 +pnpm ios:release:archive -- --version 2026.7.2 --revision 1 ``` This command is for local archive validation only. It is not a fallback upload @@ -122,13 +123,13 @@ path after `pnpm ios:release:upload` fails. Archive and upload to App Store Connect: ```bash -pnpm ios:release:upload -- --version 2026.6.11 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 ``` If you need to force a specific build number: ```bash -pnpm ios:release:upload -- --version 2026.6.11 --build-number 7 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3 ``` ### Maintainer Quick Release Checklist @@ -163,16 +164,16 @@ 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. If you are starting a brand-new production release train, add or update the matching iOS changelog section and validate the release notes: +4. For a new App Store revision, add the exact encoded-version changelog section and validate the release notes: ```bash -pnpm ios:version:check -- --version 2026.6.11 +pnpm ios:version:check -- --version 2026.7.2 --revision 1 ``` 5. Upload the build with explicit release intent: ```bash -pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3 ``` 6. If `pnpm ios:release:upload` fails, stop at that failure. Do not archive @@ -180,7 +181,7 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 step, then rerun `pnpm ios:release:upload`. 7. Expected behavior: - - Fastlane reads the explicit `--version` value + - Fastlane reads the explicit gateway `--version` and App Store `--revision` - validates iOS versioning inputs for that version - resolves the next App Store Connect build number for that short version - generates deterministic App Store screenshots @@ -200,7 +201,8 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 ## iOS Versioning Workflow -- Release upload version: explicit `--version` +- Release gateway version: explicit `--version` +- App Store revision: explicit `--revision` - Local default version: root `package.json` - iOS-only changelog: `apps/ios/CHANGELOG.md` - Generated local artifacts: @@ -212,7 +214,7 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 ```bash pnpm ios:version pnpm ios:version:check -pnpm ios:version -- --version 2026.6.11 +pnpm ios:version -- --version 2026.7.2 --revision 1 pnpm ios:filelist:gen ``` @@ -220,18 +222,18 @@ Recommended flow: ### App Store Connect iteration on an existing train -1. Choose the App Store train explicitly, for example `2026.6.11`. -2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating. -3. Run `pnpm ios:version:check -- --version 2026.6.11` after changelog changes. -4. Upload additional App Store Connect builds with `pnpm ios:release:upload -- --version 2026.6.11`. +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. -### Starting the next production release train +### 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 `. -4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version `. +3. Run `pnpm ios:version:check -- --version --revision `. +4. Submit the first build with `pnpm ios:release:upload -- --version --revision `. 5. Keep iterating on that same explicit version until the release candidate is ready. See `apps/ios/VERSIONING.md` for the detailed spec. diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index e732448da0ca..1215660f4b27 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -1,216 +1,192 @@ # OpenClaw iOS Versioning -OpenClaw iOS release uploads use an explicit CalVer release version. The -committed repo no longer has an iOS-only version manifest; release commands must -name the App Store train they are uploading to. +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. ## Goals -- make App Store release intent explicit at upload time -- avoid stale committed iOS pins +- 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 - keep Apple bundle fields valid for App Store Connect -- keep normal local builds aligned with the current gateway release version -- generate App Store release notes from an iOS-owned changelog +- generate version-specific App Store release notes from the iOS changelog ## Version model -Release uploads require a version argument: +An iOS release has three independent identifiers: -```bash -pnpm ios:release:upload -- --version 2026.6.11 +- gateway version `G = YYYY.M.P`, for example `2026.7.2` +- App Store revision `R`, an integer from `0` through `99` +- build number `B`, a positive integer scoped to the exact App Store version + +The App Store version packs the revision into the third numeric component: + +```text +AppStoreVersion(G, R) = YYYY.M.(P * 100 + R) ``` -Use `--build-number` when the build number is known or has been verified from -App Store Connect: +Examples: + +| Gateway | Revision | App Store version | Candidate builds | +| --- | ---: | --- | --- | +| `2026.7.2` | legacy `0` | `2026.7.2` | closed history | +| `2026.7.2` | `1` | `2026.7.201` | `1`, `2`, `3` | +| `2026.7.2` | `2` | `2026.7.202` | `1`, `2`, ... | +| `2026.7.3` | `0` | `2026.7.300` | `1`, `2`, ... | + +Historical exact versions are grandfathered as read-only release history. The +release tooling does not target them again. All future uploads use the packed +format, including revision zero. + +## Release commands + +Release uploads require the gateway version and App Store revision: ```bash -pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 ``` -The release version must use `YYYY.M.D` CalVer, for example `2026.4.6` or -`2026.6.11`. +Use `--build-number` only when the exact next remote build number has already +been verified: -When no explicit release version is supplied to the version helper, iOS derives -its default version from root `package.json.version` after stripping supported -release suffixes: +```bash +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3 +``` -- gateway `2026.4.10` -> iOS default `2026.4.10` -- gateway `2026.4.10-beta.3` -> iOS default `2026.4.10` -- gateway `2026.4.10-2` -> iOS default `2026.4.10` +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: + +```bash +pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3 +``` ## Apple bundle mapping -Release version `2026.6.11` maps to: +Gateway `2026.7.2`, revision `1`, build `3` maps to: -- `CFBundleShortVersionString = 2026.6.11` -- `CFBundleVersion = numeric build number only` +- `OpenClawCanonicalVersion = 2026.7.2` +- `CFBundleShortVersionString = 2026.7.201` +- `CFBundleVersion = 3` -Fastlane can resolve the next build number by querying App Store Connect for the -explicit short version. Maintainers may still pass `--build-number` to make the -upload fully deterministic. +Local development builds continue using the normalized gateway version as the +marketing version. Release preparation supplies the explicit revision and +therefore the packed App Store version. + +## Revision and build lifecycle + +- 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. +- 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. +- 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 +- a locked or in-review version fails the run +- a distributed version requires the next revision +- a missing revision below an existing higher version fails because revisions + are never reused + +## Release notes + +Production release notes require an exact App Store version heading: + +```markdown +## 2026.7.201 + +- Fixed an iOS issue. +``` + +The generated App Store text automatically starts with: + +```text +Gateway version: 2026.7.2 +``` + +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: + +```bash +pnpm ios:version:check -- --version 2026.7.2 --revision 1 +``` ## Source of truth and generated files -### Source files +Source files: -- `package.json` - - default iOS version source for local builds -- explicit `--version` - - release upload source of truth -- `apps/ios/CHANGELOG.md` - - iOS-only changelog and release-note source -- `apps/ios/VERSIONING.md` - - workflow and constraints +- root `package.json`: default gateway version for local builds +- explicit `--version`: gateway version for release commands +- explicit `--revision`: App Store revision for release commands +- `apps/ios/CHANGELOG.md`: exact App Store release notes +- `apps/ios/VERSIONING.md`: versioning contract -### Generated or derived files +Generated or derived files: - `apps/ios/build/Version.xcconfig` - - local gitignored build override generated per build or release prep +- `apps/ios/build/AppStoreRelease.xcconfig` - `apps/ios/SwiftSources.input.xcfilelist` - - local gitignored Swift lint input file generated before Xcode project generation -- temporary Fastlane metadata - - release notes generated from `apps/ios/CHANGELOG.md` during metadata upload +- temporary Fastlane metadata rendered from `apps/ios/CHANGELOG.md` -## Tooling surfaces +The canonical implementation is split across: -- `scripts/lib/ios-version.ts` - - validates iOS CalVer - - normalizes gateway version -> iOS CalVer - - renders release notes from the iOS changelog -- `scripts/ios-version.ts` - - CLI for JSON, shell, or single-field version reads - - accepts `--version YYYY.M.D` for explicit release queries -- `scripts/ios-sync-versioning.ts` - - validates that release notes can be rendered from the default or explicit iOS version -- `scripts/ios-write-version-xcconfig.sh` - - writes the local numeric build override file in `apps/ios/build/Version.xcconfig` -- `scripts/ios-write-swift-filelist.mjs` - - writes the local Swift file list consumed by Xcode pre-build lint phases -- `scripts/ios-release-prepare.sh` - - requires `--version` and prepares App Store distribution signing and bundle settings -- `apps/ios/fastlane/Fastfile` - - resolves version metadata from the explicit release version - - creates or verifies Developer Portal bundle IDs/services through Fastlane `produce` - - syncs encrypted App Store signing assets with Fastlane `match` - - resolves App Store Connect build numbers for the explicit short version when needed - - uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving - -Agent-driven App Store uploads must use `pnpm ios:release:upload` as the only -release path. If that command fails, stop at the failing screenshot, metadata, -archive, validation, or upload step. Do not continue by archiving and uploading -manually with `pnpm ios:release:archive`, `asc builds upload`, -`asc release stage`, `asc publish appstore`, direct Fastlane lanes, or other App -Store Connect mutation commands. - -## Release-note resolution order - -When generating the temporary Fastlane release notes metadata, the tooling reads -the first available changelog section in this order: - -1. exact release version, for example `## 2026.6.11` -2. `## Unreleased` - -Before production upload, prefer a final `## ` section and -validate with the same version: - -```bash -pnpm ios:version:check -- --version 2026.6.11 -``` - -## Common commands - -```bash -pnpm ios:version -pnpm ios:version -- --version 2026.6.11 -pnpm ios:version:check -pnpm ios:filelist:gen -pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 -``` - -## Normal App Store Connect build iteration workflow - -1. choose the App Store release train explicitly, for example `2026.6.11` -2. update `apps/ios/CHANGELOG.md` under `## ` or `## Unreleased` -3. run `pnpm ios:version:check -- --version ` -4. check App Store Connect for the latest build number when needed -5. upload another build with `pnpm ios:release:upload -- --version --build-number ` - -This keeps the version decision at the release command instead of in a committed -state file. +- `scripts/lib/ios-version.ts`: validation, encoding, and release-note rendering +- `scripts/ios-version.ts`: JSON, shell, and single-field queries +- `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, + archive, validation, and upload ## Release SHA tracking -Successful App Store Connect uploads create a non-tag Git ref that records the -source commit for the uploaded store build: +Successful uploads record the exact App Store version and build: ```text refs/openclaw/mobile-releases/ios/- ``` -Example: +For example: ```text -refs/openclaw/mobile-releases/ios/2026.6.11-3 +refs/openclaw/mobile-releases/ios/2026.7.201-3 ``` -These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do -not appear on GitHub release or tag pages, and they do not participate in the -core OpenClaw release machinery. +The ref is checked before archive/upload work and created only after App Store +Connect accepts the upload. Existing refs are immutable. -`pnpm ios:release:upload` checks the ref before archive/upload work and records -it only after the App Store Connect upload succeeds. Existing refs are -immutable: the same ref at the same SHA is accepted, while the same ref at a -different SHA fails. +## Normal workflow -Do not create this ref after a manual fallback upload. The ref is release-lane -evidence, not a repair mechanism for a failed `pnpm ios:release:upload` run. - -Useful direct commands: +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: ```bash -pnpm mobile:release:preflight -- --platform ios --version 2026.6.11 --build 3 -pnpm mobile:release:resolve -- --platform ios --version 2026.6.11 --build 3 +pnpm ios:version:check -- --version 2026.7.2 --revision 1 ``` -## New release workflow - -When you want the next production iOS release to align with the current gateway -release: - -1. confirm the root gateway version: +4. Upload build `1`, or let Fastlane resolve the next build: ```bash -node -e "console.log(require('./package.json').version)" +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 ``` -2. update `apps/ios/CHANGELOG.md` for that release -3. validate iOS release notes: +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`. -```bash -pnpm ios:version:check -- --version 2026.6.11 -``` - -4. verify live App Store Connect state and choose the next build number -5. upload with explicit release intent: - -```bash -pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 -``` - -6. manually submit the reviewed build for App Review in App Store Connect -7. release the approved build to production - -## Important invariant - -App Store uploads must carry explicit version intent. Do not infer a release -train from generated local files. - -App Review submission remains manual. Automation may create/update the editable -App Store version, upload screenshots, upload release notes, upload the App -Review PDF attachment, and upload builds, but it should not upload the App -Store Connect `Notes` field or submit a build for review. - -For agent-driven releases, a failed `pnpm ios:release:upload` is terminal for -that attempt. Agents must report the failed step and wait for maintainer -direction instead of switching to lower-level App Store Connect upload or -submission commands. +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 +lower-level archive, upload, staging, or submission command. diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 3e7a0c6ccf74..ae21c35ae53f 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -7,6 +7,7 @@ require "tempfile" require "cgi" require "digest/md5" require "time" +require "rubygems/version" default_platform(:ios) @@ -64,6 +65,21 @@ 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 +EDITABLE_APP_STORE_VERSION_STATES = [ + "PREPARE_FOR_SUBMISSION", + "DEVELOPER_REJECTED", + "REJECTED", + "METADATA_REJECTED", + "INVALID_BINARY", + "READY_FOR_REVIEW" +].freeze +RELEASED_APP_STORE_VERSION_STATES = [ + "READY_FOR_DISTRIBUTION", + "REPLACED_WITH_NEW_VERSION", + "READY_FOR_SALE", + "REMOVED_FROM_SALE", + "DEVELOPER_REMOVED_FROM_SALE" +].freeze def load_env_file(path) return unless File.exist?(path) @@ -779,7 +795,7 @@ def release_signing_check! sync_app_store_signing!(readonly: true) end -def render_ios_release_notes(release_version:) +def render_ios_release_notes(release_version:, app_store_revision:) script_path = File.join(repo_root, "scripts", "ios-version.ts") args = [ "node", @@ -790,6 +806,7 @@ def render_ios_release_notes(release_version:) "releaseNotes" ] args.push("--version", release_version) if env_present?(release_version) + args.push("--revision", app_store_revision) if env_present?(app_store_revision) stdout, stderr, status = Open3.capture3( *args, chdir: repo_root @@ -801,11 +818,14 @@ def render_ios_release_notes(release_version:) UI.user_error!("Failed to render iOS release notes: #{detail}") end -def release_notes_metadata_path(release_version:) +def release_notes_metadata_path(release_version:, app_store_revision:) temp_root = Dir.mktmpdir("openclaw-release-notes") target_dir = File.join(temp_root, "en-US") FileUtils.mkdir_p(target_dir) - File.write(File.join(target_dir, "release_notes.txt"), render_ios_release_notes(release_version: release_version)) + File.write( + File.join(target_dir, "release_notes.txt"), + render_ios_release_notes(release_version: release_version, app_store_revision: app_store_revision) + ) temp_root end @@ -840,7 +860,7 @@ def assert_no_app_review_notes_field_metadata!(metadata_path) end end -def public_metadata_path(release_version: nil) +def public_metadata_path(release_version: nil, app_store_revision: nil) source = File.join(__dir__, "metadata") temp_root = Dir.mktmpdir("openclaw-app-store-metadata") Dir.children(source).each do |entry| @@ -854,7 +874,10 @@ def public_metadata_path(release_version: nil) if release_notes_upload_requested? target_dir = File.join(temp_root, "en-US") FileUtils.mkdir_p(target_dir) - File.write(File.join(target_dir, "release_notes.txt"), render_ios_release_notes(release_version: release_version)) + File.write( + File.join(target_dir, "release_notes.txt"), + render_ios_release_notes(release_version: release_version, app_store_revision: app_store_revision) + ) end temp_root end @@ -881,6 +904,62 @@ def resolve_app_store_connect_app(app_identifier:, app_id:) app end +def app_store_version_state(version) + version.app_version_state.to_s.strip.empty? ? version.app_store_state.to_s : version.app_version_state.to_s +end + +def preflight_app_store_version!(short_version:) + 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) + app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id) + versions = app.get_app_store_versions( + filter: { platform: Spaceship::ConnectAPI::Platform::IOS }, + includes: nil + ) + target = versions.find { |candidate| candidate.version_string == short_version } + + if target + state = app_store_version_state(target) + if EDITABLE_APP_STORE_VERSION_STATES.include?(state) + UI.message("App Store version #{short_version} is reusable in state #{state}.") + return target + end + if RELEASED_APP_STORE_VERSION_STATES.include?(state) + UI.user_error!( + "App Store version #{short_version} is already released in state #{state}; choose the next iOS revision." + ) + end + UI.user_error!( + "App Store version #{short_version} is locked in state #{state.empty? ? "UNKNOWN" : state}; wait for it to become editable or reject the submission before uploading another build." + ) + end + + target_version = Gem::Version.new(short_version) + higher_version = versions + .select { |candidate| Gem::Version.new(candidate.version_string) > target_version } + .max_by { |candidate| Gem::Version.new(candidate.version_string) } + if higher_version + UI.user_error!( + "App Store version #{short_version} cannot be created because higher version #{higher_version.version_string} already exists. Revisions are never reused." + ) + end + + active_version = versions.find do |candidate| + !RELEASED_APP_STORE_VERSION_STATES.include?(app_store_version_state(candidate)) + end + if active_version + state = app_store_version_state(active_version) + UI.user_error!( + "App Store version #{short_version} cannot be created while #{active_version.version_string} is active in state #{state.empty? ? "UNKNOWN" : state}." + ) + end + + UI.message("App Store version #{short_version} is available and will be created during metadata staging.") + nil +end + def resolve_app_store_connect_version(app:, short_version:) version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS) UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version @@ -1112,7 +1191,7 @@ def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, sh UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.") end -def read_ios_version_metadata(release_version: nil) +def read_ios_version_metadata(release_version: nil, app_store_revision: nil) script_path = File.join(repo_root, "scripts", "ios-version.ts") args = [ "node", @@ -1122,6 +1201,7 @@ def read_ios_version_metadata(release_version: nil) "--json", ] args.push("--version", release_version) if env_present?(release_version) + args.push("--revision", app_store_revision) if env_present?(app_store_revision) stdout, stderr, status = Open3.capture3( *args, chdir: repo_root @@ -1136,11 +1216,13 @@ def read_ios_version_metadata(release_version: nil) parsed = JSON.parse(stdout) version = parsed["canonicalVersion"].to_s.strip short_version = parsed["marketingVersion"].to_s.strip - if !env_present?(version) || !env_present?(short_version) + revision = parsed["appStoreRevision"].to_s.strip + if !env_present?(version) || !env_present?(short_version) || !env_present?(revision) UI.user_error!("iOS version helper returned incomplete metadata.") end { + app_store_revision: revision, short_version: short_version, version: version } @@ -1148,7 +1230,7 @@ rescue JSON::ParserError => e UI.user_error!("Invalid JSON from iOS version helper: #{e.message}") end -def sync_ios_versioning!(release_version: nil) +def sync_ios_versioning!(release_version: nil, app_store_revision: nil) script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts") args = [ "node", @@ -1158,6 +1240,7 @@ def sync_ios_versioning!(release_version: nil) "--check", ] args.push("--version", release_version) if env_present?(release_version) + args.push("--revision", app_store_revision) if env_present?(app_store_revision) stdout, stderr, status = Open3.capture3( *args, chdir: repo_root @@ -1166,7 +1249,11 @@ def sync_ios_versioning!(release_version: nil) detail = stderr.to_s.strip detail = stdout.to_s.strip if detail.empty? - check_command = env_present?(release_version) ? "pnpm ios:version:check -- --version #{release_version}" : "pnpm ios:version:check" + check_command = if env_present?(release_version) && env_present?(app_store_revision) + "pnpm ios:version:check -- --version #{release_version} --revision #{app_store_revision}" + else + "pnpm ios:version:check" + end UI.user_error!("iOS versioning inputs are invalid. Run `#{check_command}`.\n#{detail}") end @@ -1196,9 +1283,11 @@ end def resolve_release_build_number(api_key:, short_version:, explicit_build_number: nil) explicit = explicit_build_number.to_s.strip if env_present?(explicit) - UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/) - UI.message("Using explicit iOS release build number #{explicit}.") - return explicit + UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected a positive integer.") unless explicit.match?(/\A[1-9]\d*\z/) + if api_key.nil? + UI.message("Using explicit iOS release build number #{explicit} without remote validation.") + return explicit + end end latest_build = latest_testflight_build_number( @@ -1208,6 +1297,11 @@ def resolve_release_build_number(api_key:, short_version:, explicit_build_number initial_build_number: 0 ) next_build = latest_build.to_i + 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}." + ) + end UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).") next_build.to_s end @@ -1217,10 +1311,23 @@ def release_build_number_needs_app_store_connect_auth?(explicit_build_number: ni !env_present?(explicit) end -def prepare_app_store_release!(version:, build_number:) +def prepare_app_store_release!(version:, app_store_revision:, build_number:) script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh") - UI.message("Preparing iOS App Store release #{version} (build #{build_number}).") - sh(shell_join(["bash", script_path, "--version", version, "--build-number", build_number])) + UI.message("Preparing iOS App Store release for gateway #{version} revision #{app_store_revision} (build #{build_number}).") + sh( + shell_join( + [ + "bash", + script_path, + "--version", + version, + "--revision", + app_store_revision, + "--build-number", + build_number + ] + ) + ) release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig") UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig) @@ -1492,14 +1599,24 @@ platform :ios do private_lane :prepare_app_store_context do |options| 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 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 release version. Use `pnpm ios:release:upload -- --version YYYY.M.D` or pass `release_version:YYYY.M.D` to the Fastlane lane.") + UI.user_error!("Missing iOS gateway version. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.") end - sync_ios_versioning!(release_version: release_version) - version_metadata = read_ios_version_metadata(release_version: release_version) + if app_store_revision.empty? + UI.user_error!("Missing iOS App Store revision. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.") + end + sync_ios_versioning!( + release_version: release_version, + app_store_revision: app_store_revision + ) + version_metadata = read_ios_version_metadata( + release_version: release_version, + app_store_revision: app_store_revision + ) version = version_metadata[:version] short_version = version_metadata[:short_version] provenance = pin_release_build_provenance! @@ -1508,10 +1625,15 @@ platform :ios do short_version: short_version, explicit_build_number: explicit_build_number ) - release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number) + release_xcconfig = prepare_app_store_release!( + version: version, + app_store_revision: version_metadata[:app_store_revision], + build_number: build_number + ) { api_key: api_key, + app_store_revision: version_metadata[:app_store_revision], build_timestamp: provenance[:build_timestamp], build_number: build_number, git_commit: provenance[:git_commit], @@ -1557,6 +1679,7 @@ platform :ios do context = prepare_app_store_context( require_api_key: false, release_version: options[:release_version], + app_store_revision: options[:app_store_revision], build_number: options[:build_number] ) build = build_app_store_release(context) @@ -1576,9 +1699,11 @@ platform :ios do context = prepare_app_store_context( require_api_key: true, release_version: options[:release_version], + app_store_revision: options[:app_store_revision], build_number: options[:build_number] ) release_sha = context[:git_commit] + preflight_app_store_version!(short_version: context[:short_version]) ensure_mobile_release_ref_available!( platform: "ios", version: context[:short_version], @@ -1587,7 +1712,11 @@ platform :ios do ) without_xcode_xcconfig_file do preserve_local_signing do - screenshots(release_version: context[:version], build_number: context[:build_number]) + screenshots( + release_version: context[:version], + app_store_revision: context[:app_store_revision], + build_number: context[:build_number] + ) end end verify_apple_release_source!(release_sha) @@ -1595,7 +1724,10 @@ platform :ios do ENV["DELIVER_SCREENSHOTS"] = "1" ENV["DELIVER_RELEASE_NOTES"] = "1" - metadata(release_version: context[:short_version]) + metadata( + release_version: context[:version], + app_store_revision: context[:app_store_revision] + ) upload_to_testflight( api_key: context[:api_key], @@ -1620,12 +1752,23 @@ platform :ios do lane :metadata do |options| install_ready_for_review_edit_state_lookup! release_version = options[:release_version].to_s.strip + app_store_revision = options[:app_store_revision].to_s.strip if release_version.empty? - UI.user_error!("Missing iOS release version. Use `pnpm ios:release:upload -- --version YYYY.M.D` or `fastlane ios metadata release_version:YYYY.M.D`.") + UI.user_error!("Missing iOS gateway version. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.") end - sync_ios_versioning!(release_version: release_version) - version_metadata = read_ios_version_metadata(release_version: release_version) + if app_store_revision.empty? + UI.user_error!("Missing iOS App Store revision. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.") + end + sync_ios_versioning!( + release_version: release_version, + app_store_revision: app_store_revision + ) + version_metadata = read_ios_version_metadata( + release_version: release_version, + app_store_revision: app_store_revision + ) api_key = app_store_connect_api_key_config + preflight_app_store_version!(short_version: version_metadata[:short_version]) clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"] app_id = ENV["APP_STORE_CONNECT_APP_ID"] @@ -1641,10 +1784,16 @@ platform :ios do end assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata")) - metadata_path = public_metadata_path(release_version: release_version) + metadata_path = public_metadata_path( + release_version: release_version, + app_store_revision: app_store_revision + ) skip_metadata = ENV["DELIVER_METADATA"] != "1" if release_notes_upload_requested? && skip_metadata - metadata_path = release_notes_metadata_path(release_version: release_version) + metadata_path = release_notes_metadata_path( + release_version: release_version, + app_store_revision: app_store_revision + ) skip_metadata = false end assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata @@ -1689,8 +1838,10 @@ platform :ios do lane :screenshots do |options| version_args = [] release_version = options[:release_version].to_s.strip + app_store_revision = options[:app_store_revision].to_s.strip build_number = options[:build_number].to_s.strip version_args += ["--version", release_version] unless release_version.empty? + version_args += ["--revision", app_store_revision] unless app_store_revision.empty? version_args += ["--build-number", build_number] unless build_number.empty? sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")])) @@ -1744,15 +1895,21 @@ platform :ios do # evidence in build/ so Deliver sees only locale directories during validation. FileUtils.rm_rf(File.join(output_directory, "test_output")) - watch_screenshot(release_version: release_version, build_number: build_number) + watch_screenshot( + release_version: release_version, + app_store_revision: app_store_revision, + build_number: build_number + ) end desc "Generate deterministic Apple Watch screenshot for App Store metadata" lane :watch_screenshot do |options| version_args = [] release_version = options[:release_version].to_s.strip + app_store_revision = options[:app_store_revision].to_s.strip build_number = options[:build_number].to_s.strip version_args += ["--version", release_version] unless release_version.empty? + version_args += ["--revision", app_store_revision] unless app_store_revision.empty? version_args += ["--build-number", build_number] unless build_number.empty? sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")])) diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 3973e555aeea..314f43ac8e64 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -95,7 +95,7 @@ If you pass `--build-number` to `pnpm ios:release:archive`, the local archive pa Archive locally without upload: ```bash -pnpm ios:release:archive -- --version 2026.6.11 --build-number 3 +pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3 ``` Generate deterministic App Store screenshots: @@ -109,7 +109,7 @@ The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters Upload to App Store Connect: ```bash -pnpm ios:release:upload -- --version 2026.6.11 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 ``` Direct Fastlane upload is disabled. Use the package script so the release @@ -135,16 +135,16 @@ cd apps/ios fastlane ios auth_check ``` -4. If you are starting a brand-new production release train, validate iOS release notes for the release version: +4. For a new App Store revision, add an exact encoded-version changelog section and validate it: ```bash -pnpm ios:version:check -- --version 2026.6.11 +pnpm ios:version:check -- --version 2026.7.2 --revision 1 ``` 5. Upload: ```bash -pnpm ios:release:upload -- --version 2026.6.11 --build-number 3 +pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3 ``` Quick verification after upload: @@ -156,14 +156,14 @@ Quick verification after upload: Versioning rules: -- App Store release uploads require an explicit `--version` +- App Store release uploads require an explicit gateway `--version` and App Store `--revision` - local defaults derive from root `package.json` - `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source -- Supported iOS release versions use CalVer: `YYYY.M.D` -- Fastlane uses the explicit release version for App Store upload -- Fastlane sets `CFBundleShortVersionString` to the release version, for example `2026.4.10` -- Fastlane resolves `CFBundleVersion` as the next integer App Store Connect build number for that short version -- Run `pnpm ios:version:check -- --version ` after changing `apps/ios/CHANGELOG.md` +- 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 --revision ` after changing `apps/ios/CHANGELOG.md` - `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 diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 40279fa208b4..3d0ca85cfff1 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -7,7 +7,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata ```bash cd apps/ios APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \ -DELIVER_METADATA=1 fastlane ios metadata release_version:2026.6.11 +DELIVER_METADATA=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1 ``` ## Release notes and App Review attachment @@ -16,14 +16,14 @@ DELIVER_METADATA=1 fastlane ios metadata release_version:2026.6.11 ```bash cd apps/ios -DELIVER_RELEASE_NOTES=1 fastlane ios metadata release_version:2026.6.11 +DELIVER_RELEASE_NOTES=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1 ``` ## Optional: include screenshots ```bash cd apps/ios -DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata release_version:2026.6.11 +DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1 ``` ## Auth @@ -45,11 +45,11 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`. ## Notes - Locale files live under `metadata//`, 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; after changelog updates, run `pnpm ios:version:check -- --version `. +- Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; validate them with `pnpm ios:version:check -- --version --revision `. - 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. -- Release notes resolve from `## ` first, then fall back to `## Unreleased` while an App Store Connect build train is still in progress. -- When starting a new production release train, validate metadata with `pnpm ios:version:check -- --version `. +- 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. +- Generated App Store release notes begin with the associated gateway version. - The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review. - `privacy_url.txt` is set to `https://openclaw.ai/privacy`. - If app lookup fails in `deliver`, set one of: diff --git a/scripts/ios-release-archive.sh b/scripts/ios-release-archive.sh index 2ba48aed492e..c3b8e302fc93 100755 --- a/scripts/ios-release-archive.sh +++ b/scripts/ios-release-archive.sh @@ -4,13 +4,14 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-release-archive.sh --version 2026.6.11 [--build-number 7] + scripts/ios-release-archive.sh --version 2026.7.2 --revision 1 [--build-number 3] Archives and exports an App Store distribution IPA locally without uploading. EOF } BUILD_NUMBER="" +APP_STORE_REVISION="" RELEASE_VERSION="" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh" @@ -36,6 +37,11 @@ while [[ $# -gt 0 ]]; do 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:-}" @@ -59,7 +65,13 @@ if [[ -z "${RELEASE_VERSION}" ]]; then exit 1 fi -FASTLANE_ARGS=(ios app_store_archive "release_version:${RELEASE_VERSION}") +if [[ -z "${APP_STORE_REVISION}" ]]; then + echo "Missing required --revision." >&2 + usage >&2 + exit 1 +fi + +FASTLANE_ARGS=(ios app_store_archive "release_version:${RELEASE_VERSION}" "app_store_revision:${APP_STORE_REVISION}") if [[ -n "${BUILD_NUMBER}" ]]; then FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}") fi diff --git a/scripts/ios-release-prepare.sh b/scripts/ios-release-prepare.sh index a12285ee7ec2..1f70cfda32c1 100755 --- a/scripts/ios-release-prepare.sh +++ b/scripts/ios-release-prepare.sh @@ -4,10 +4,10 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-release-prepare.sh --version 2026.6.11 --build-number 7 [--team-id TEAMID] + scripts/ios-release-prepare.sh --version 2026.7.2 --revision 1 --build-number 3 [--team-id TEAMID] Prepares local App Store release inputs without touching local signing overrides: -- writes apps/ios/build/Version.xcconfig for the explicit release version +- writes apps/ios/build/Version.xcconfig for the explicit gateway and App Store revision - writes apps/ios/build/AppStoreRelease.xcconfig with canonical bundle IDs - configures the release build for relay-backed APNs registration - configures manual App Store distribution signing with pinned provisioning profiles @@ -28,6 +28,7 @@ RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh" CANONICAL_TEAM_ID="FWJYW4S8P8" BUILD_NUMBER="" +APP_STORE_REVISION="" RELEASE_VERSION="" TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" IOS_VERSION="" @@ -78,6 +79,11 @@ while [[ $# -gt 0 ]]; do 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:-}" @@ -112,6 +118,12 @@ if [[ -z "${RELEASE_VERSION}" ]]; then exit 1 fi +if [[ -z "${APP_STORE_REVISION}" ]]; then + echo "Missing required --revision." >&2 + usage >&2 + exit 1 +fi + if [[ -z "${TEAM_ID}" ]]; then TEAM_ID="$(IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash "${TEAM_HELPER}" --require-canonical)" fi @@ -139,12 +151,12 @@ export GIT_COMMIT="${RELEASE_GIT_COMMIT}" prepare_build_dir ( - cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check --version "${RELEASE_VERSION}" + cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}" ) -IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --version "${RELEASE_VERSION}" --field canonicalVersion)" +IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}" --field marketingVersion)" if [[ -z "${IOS_VERSION}" ]]; then - echo "Unable to resolve iOS release version '${RELEASE_VERSION}'." >&2 + echo "Unable to resolve App Store version for gateway '${RELEASE_VERSION}' revision '${APP_STORE_REVISION}'." >&2 exit 1 fi @@ -156,7 +168,7 @@ fi ( OPENCLAW_REQUIRE_BUILD_METADATA=1 \ - bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}" + bash "${VERSION_HELPER}" --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}" --build-number "${BUILD_NUMBER}" ) node "${ROOT_DIR}/scripts/ios-write-swift-filelist.mjs" diff --git a/scripts/ios-release-upload.sh b/scripts/ios-release-upload.sh index 27e99ba599d6..753c130ec119 100755 --- a/scripts/ios-release-upload.sh +++ b/scripts/ios-release-upload.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-release-upload.sh --version 2026.6.11 [--build-number 7] + 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 @@ -13,6 +13,7 @@ EOF } BUILD_NUMBER="" +APP_STORE_REVISION="" RELEASE_VERSION="" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh" @@ -38,6 +39,11 @@ while [[ $# -gt 0 ]]; do 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:-}" @@ -61,7 +67,13 @@ if [[ -z "${RELEASE_VERSION}" ]]; then exit 1 fi -FASTLANE_ARGS=(ios release_upload "release_version:${RELEASE_VERSION}") +if [[ -z "${APP_STORE_REVISION}" ]]; then + echo "Missing required --revision." >&2 + usage >&2 + exit 1 +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 diff --git a/scripts/ios-sync-versioning.ts b/scripts/ios-sync-versioning.ts index 7a504b1a9fe6..437a75ff20be 100644 --- a/scripts/ios-sync-versioning.ts +++ b/scripts/ios-sync-versioning.ts @@ -3,22 +3,25 @@ import path from "node:path"; import { syncIosVersioning } from "./lib/ios-version.ts"; import { parseVersionSyncArgs } from "./lib/version-script-args.ts"; -export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts"; +export function parseArgs(argv: string[]) { + return parseVersionSyncArgs(argv, { allowAppStoreRevision: true }); +} function printUsage(): void { process.stdout.write( - "Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--root dir]\n\nValidates that iOS versioning inputs can produce generated local artifacts.\n", + "Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--revision 0-99] [--root dir]\n\nValidates that iOS versioning inputs can produce generated local artifacts.\n", ); } function main(argv = process.argv.slice(2)): number { - const options = parseVersionSyncArgs(argv); + const options = parseArgs(argv); if (options.help) { printUsage(); return 0; } const result = syncIosVersioning({ + appStoreRevision: options.appStoreRevision, mode: options.mode, releaseVersion: options.releaseVersion, rootDir: options.rootDir, diff --git a/scripts/ios-version.ts b/scripts/ios-version.ts index bbe0f5014b67..b4873983f6fa 100644 --- a/scripts/ios-version.ts +++ b/scripts/ios-version.ts @@ -4,23 +4,27 @@ import { parseVersionQueryArgs } from "./lib/version-script-args.ts"; function printUsage(): void { process.stdout.write( - "Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--root dir]\n\n", + "Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--revision 0-99] [--root dir]\n\n", ); } function main(argv = process.argv.slice(2)): number { - const options = parseVersionQueryArgs(argv); + const options = parseVersionQueryArgs(argv, { allowAppStoreRevision: true }); if (options.help) { printUsage(); return 0; } - const version = resolveIosVersion(options.rootDir, { releaseVersion: options.releaseVersion }); + const version = resolveIosVersion(options.rootDir, { + appStoreRevision: options.appStoreRevision, + releaseVersion: options.releaseVersion, + }); if (options.field) { if (options.field === "releaseNotes") { process.stdout.write( renderIosReleaseNotesForVersion({ + appStoreRevision: options.appStoreRevision, releaseVersion: options.releaseVersion, rootDir: options.rootDir, }), @@ -40,6 +44,8 @@ function main(argv = process.argv.slice(2)): number { process.stdout.write( [ `OPENCLAW_IOS_VERSION=${version.canonicalVersion}`, + `OPENCLAW_APP_STORE_REVISION=${version.appStoreRevision ?? ""}`, + `OPENCLAW_APP_STORE_VERSION=${version.appStoreVersion ?? ""}`, `OPENCLAW_MARKETING_VERSION=${version.marketingVersion}`, `OPENCLAW_BUILD_VERSION=${version.buildVersion}`, ].join("\n") + "\n", diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh index fd993e72d1b3..9294c3abd4a7 100755 --- a/scripts/ios-write-version-xcconfig.sh +++ b/scripts/ios-write-version-xcconfig.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-write-version-xcconfig.sh [--version 2026.6.11] [--build-number 7] + scripts/ios-write-version-xcconfig.sh [--version 2026.7.2] [--revision 1] [--build-number 3] Writes apps/ios/build/Version.xcconfig from package.json or explicit --version: - OPENCLAW_IOS_VERSION = exact canonical iOS version @@ -22,6 +22,7 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts" IOS_VERSION="" MARKETING_VERSION="" BUILD_NUMBER="" +APP_STORE_REVISION="" RELEASE_VERSION="" RESOLVED_GIT_COMMIT="" RESOLVED_BUILD_TIMESTAMP="" @@ -70,6 +71,11 @@ while [[ $# -gt 0 ]]; do 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:-}" @@ -91,6 +97,13 @@ VERSION_HELPER_ARGS=(--shell) if [[ -n "${RELEASE_VERSION}" ]]; then VERSION_HELPER_ARGS+=(--version "${RELEASE_VERSION}") fi +if [[ -n "${APP_STORE_REVISION}" ]]; then + if [[ -z "${RELEASE_VERSION}" ]]; then + echo "--revision requires an explicit --version." >&2 + exit 1 + fi + VERSION_HELPER_ARGS+=(--revision "${APP_STORE_REVISION}") +fi while IFS='=' read -r key value; do case "${key}" in diff --git a/scripts/lib/ios-version.ts b/scripts/lib/ios-version.ts index e719c79896c5..9a2d62bf19b6 100644 --- a/scripts/lib/ios-version.ts +++ b/scripts/lib/ios-version.ts @@ -4,9 +4,14 @@ import path from "node:path"; import { parseReleaseVersion } from "./release-version.mjs"; const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md"; +const IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION = 100; +const MAX_IOS_APP_STORE_REVISION = IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION - 1; type ResolvedIosVersion = { + appStoreRevision: number | null; + appStoreVersion: string | null; canonicalVersion: string; + gatewayVersion: string; marketingVersion: string; buildVersion: string; changelogPath: string; @@ -38,6 +43,43 @@ export function normalizePinnedIosVersion(rawVersion: string): string { return pinnedVersion; } +export function normalizeIosAppStoreRevision(rawRevision: string | number): number { + const normalized = String(rawRevision).trim(); + if (!/^(?:0|[1-9]\d*)$/u.test(normalized)) { + throw new Error( + `Invalid iOS App Store revision '${rawRevision}'. Expected an integer from 0 to ${MAX_IOS_APP_STORE_REVISION}.`, + ); + } + + const revision = Number(normalized); + if (!Number.isSafeInteger(revision) || revision > MAX_IOS_APP_STORE_REVISION) { + throw new Error( + `Invalid iOS App Store revision '${rawRevision}'. Expected an integer from 0 to ${MAX_IOS_APP_STORE_REVISION}.`, + ); + } + return revision; +} + +export function encodeIosAppStoreVersion( + gatewayVersion: string, + appStoreRevision: string | number, +): string { + const canonicalVersion = normalizePinnedIosVersion(gatewayVersion); + const parsed = parseReleaseVersion(canonicalVersion); + if (!parsed) { + throw new Error(`Unable to encode invalid gateway version '${gatewayVersion}'.`); + } + + const revision = normalizeIosAppStoreRevision(appStoreRevision); + // Exact pre-cutover versions are immutable release history. Revision zero is + // deliberately packed too, so future trains never return to the old shape. + const encodedPatch = parsed.patch * IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION + revision; + if (!Number.isSafeInteger(encodedPatch)) { + throw new Error(`Encoded iOS App Store version is too large for '${gatewayVersion}'.`); + } + return `${parsed.year}.${parsed.month}.${encodedPatch}`; +} + export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): string { const trimmed = rawVersion.trim().replace(/^v/u, ""); if (!trimmed) { @@ -81,17 +123,27 @@ export function resolveGatewayVersionForIosRelease(rootDir = path.resolve(".")): export function resolveIosVersion( rootDir = path.resolve("."), - options?: { releaseVersion?: string | null }, + options?: { appStoreRevision?: string | number | null; releaseVersion?: string | null }, ): ResolvedIosVersion { const changelogPath = path.join(rootDir, IOS_CHANGELOG_FILE); const explicitReleaseVersion = options?.releaseVersion?.trim() ?? ""; const canonicalVersion = explicitReleaseVersion ? normalizePinnedIosVersion(explicitReleaseVersion) : resolveGatewayVersionForIosRelease(rootDir).pinnedIosVersion; + const rawAppStoreRevision = options?.appStoreRevision; + const appStoreRevision = + rawAppStoreRevision === null || rawAppStoreRevision === undefined + ? null + : normalizeIosAppStoreRevision(rawAppStoreRevision); + const appStoreVersion = + appStoreRevision === null ? null : encodeIosAppStoreVersion(canonicalVersion, appStoreRevision); return { + appStoreRevision, + appStoreVersion, canonicalVersion, - marketingVersion: canonicalVersion, + gatewayVersion: canonicalVersion, + marketingVersion: appStoreVersion ?? canonicalVersion, buildVersion: "1", changelogPath, versionSource: explicitReleaseVersion ? "explicit" : "package", @@ -130,21 +182,27 @@ export function renderIosReleaseNotes( version: ResolvedIosVersion, changelogContent: string, ): string { - const candidateHeadings = [version.canonicalVersion, "Unreleased"]; + const candidateHeadings = + version.appStoreRevision === null + ? [version.canonicalVersion, "Unreleased"] + : [version.marketingVersion]; for (const heading of candidateHeadings) { const body = extractChangelogSection(changelogContent, heading); if (body) { - return `${body}\n`; + const gatewayPrefix = + version.appStoreRevision === null ? "" : `Gateway version: ${version.gatewayVersion}\n\n`; + return `${gatewayPrefix}${body}\n`; } } throw new Error( - `Unable to find iOS changelog notes for ${version.canonicalVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`, + `Unable to find iOS changelog notes for ${version.marketingVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`, ); } export function syncIosVersioning(params?: { + appStoreRevision?: string | number | null; mode?: SyncIosVersioningMode; releaseVersion?: string | null; rootDir?: string; @@ -153,7 +211,10 @@ export function syncIosVersioning(params?: { } { const rootDir = path.resolve(params?.rootDir ?? "."); const releaseVersion = params?.releaseVersion; - const version = resolveIosVersion(rootDir, { releaseVersion }); + const version = resolveIosVersion(rootDir, { + appStoreRevision: params?.appStoreRevision, + releaseVersion, + }); const changelogContent = readFileSync(version.changelogPath, "utf8"); renderIosReleaseNotes(version, changelogContent); @@ -161,11 +222,15 @@ export function syncIosVersioning(params?: { } export function renderIosReleaseNotesForVersion(params?: { + appStoreRevision?: string | number | null; releaseVersion?: string | null; rootDir?: string; }): string { const rootDir = path.resolve(params?.rootDir ?? "."); - const version = resolveIosVersion(rootDir, { releaseVersion: params?.releaseVersion }); + const version = resolveIosVersion(rootDir, { + appStoreRevision: params?.appStoreRevision, + releaseVersion: params?.releaseVersion, + }); const changelogContent = readFileSync(version.changelogPath, "utf8"); return renderIosReleaseNotes(version, changelogContent); } diff --git a/scripts/lib/version-script-args.ts b/scripts/lib/version-script-args.ts index ce3939ea1b87..64f54aac4101 100644 --- a/scripts/lib/version-script-args.ts +++ b/scripts/lib/version-script-args.ts @@ -2,6 +2,7 @@ import path from "node:path"; type VersionScriptFormat = "json" | "shell"; type VersionQueryCliOptions = { + appStoreRevision: string | null; field: string | null; format: VersionScriptFormat; help: boolean; @@ -10,13 +11,18 @@ type VersionQueryCliOptions = { }; type VersionSyncMode = "check" | "write"; type VersionSyncCliOptions = { + appStoreRevision: string | null; help: boolean; mode: VersionSyncMode; releaseVersion: string | null; rootDir: string; }; -export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions { +export function parseVersionQueryArgs( + argv: string[], + options?: { allowAppStoreRevision?: boolean }, +): VersionQueryCliOptions { + let appStoreRevision: string | null = null; let field: string | null = null; let format: VersionScriptFormat = "json"; let help = false; @@ -48,6 +54,14 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions { index += 1; break; } + case "--revision": { + if (options?.allowAppStoreRevision !== true) { + throw new Error(`Unknown argument: ${arg}`); + } + appStoreRevision = readOptionValue(argv, index, "--revision"); + index += 1; + break; + } case "--version": { releaseVersion = readOptionValue(argv, index, "--version"); index += 1; @@ -64,10 +78,14 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions { } } - return { field, format, help, releaseVersion, rootDir }; + return { appStoreRevision, field, format, help, releaseVersion, rootDir }; } -export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions { +export function parseVersionSyncArgs( + argv: string[], + options?: { allowAppStoreRevision?: boolean }, +): VersionSyncCliOptions { + let appStoreRevision: string | null = null; let help = false; let mode: VersionSyncMode = "write"; let releaseVersion: string | null = null; @@ -92,6 +110,14 @@ export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions { index += 1; break; } + case "--revision": { + if (options?.allowAppStoreRevision !== true) { + throw new Error(`Unknown argument: ${arg}`); + } + appStoreRevision = readOptionValue(argv, index, "--revision"); + index += 1; + break; + } case "--version": { releaseVersion = readOptionValue(argv, index, "--version"); index += 1; @@ -108,7 +134,7 @@ export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions { } } - return { help, mode, releaseVersion, rootDir }; + return { appStoreRevision, help, mode, releaseVersion, rootDir }; } function readOptionValue(argv: string[], index: number, flag: string): string { diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index e326a412535f..b437f2450ec4 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -74,7 +74,9 @@ describe("iOS Fastlane release upload gates", () => { expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1"); expect(script).toContain("Missing required --version."); + expect(script).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}"'); expect(script).toContain("DELIVER_NUMBER_OF_THREADS=1"); expect(script).toContain("FL_MAX_NUMBER_OF_THREADS=1"); @@ -99,16 +101,52 @@ describe("iOS Fastlane release upload gates", () => { expect(releaseUpload).toContain('ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"'); expect(releaseUpload).toContain("Use `pnpm ios:release:upload`"); expect(prepareContext).toContain("options[:release_version]"); + expect(prepareContext).toContain("options[:app_store_revision]"); expect(prepareContext).toContain("options[:build_number]"); - expect(prepareContext).toContain("Missing iOS release version"); - expect(releaseUpload).toContain("metadata(release_version: context[:short_version])"); + expect(prepareContext).toContain("Missing iOS gateway version"); + expect(prepareContext).toContain("Missing iOS App Store revision"); + 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 release version"); + expect(laneBody(fastfile, "metadata")).toContain("Missing iOS gateway version"); + expect(laneBody(fastfile, "metadata")).toContain("Missing iOS App Store revision"); expect(releaseUpload.indexOf("UI.user_error!")).toBeLessThan( releaseUpload.indexOf("prepare_app_store_context"), ); }); + it("preflights the exact App Store version before screenshots and archive work", () => { + const fastfile = readFastfile(); + const releaseUpload = laneBody(fastfile, "release_upload"); + const preflight = functionBody(fastfile, "preflight_app_store_version!"); + + expect(preflight).toContain("EDITABLE_APP_STORE_VERSION_STATES"); + expect(preflight).toContain("RELEASED_APP_STORE_VERSION_STATES"); + expect(fastfile).toContain('"READY_FOR_SALE"'); + expect(fastfile).toContain('"REMOVED_FROM_SALE"'); + expect(fastfile).toContain('"DEVELOPER_REMOVED_FROM_SALE"'); + expect(fastfile).not.toMatch( + /EDITABLE_APP_STORE_VERSION_STATES = \[[\s\S]*?"WAITING_FOR_REVIEW"[\s\S]*?\]\.freeze/, + ); + expect(preflight).toContain("Revisions are never reused"); + expect(preflight).toContain("higher version"); + expect(releaseUpload).toContain("preflight_app_store_version!"); + expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan( + releaseUpload.indexOf("screenshots("), + ); + expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan( + releaseUpload.indexOf("build = build_app_store_release(context)"), + ); + }); + + 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("expected #{next_build}"); + expect(resolver).toContain("explicit.to_i != next_build"); + expect(resolver).toContain("api_key.nil?"); + }); + it("validates the exported IPA before the sole TestFlight upload call", () => { const fastfile = readFastfile(); const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]"); @@ -122,11 +160,11 @@ describe("iOS Fastlane release upload gates", () => { const fastfile = readFastfile(); const releaseUpload = laneBody(fastfile, "release_upload"); const screenshots = releaseUpload.indexOf( - "screenshots(release_version: context[:version], build_number: context[:build_number])", + "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(release_version: context[:short_version])"); + const metadata = releaseUpload.indexOf("metadata(\n release_version: context[:version]"); expect(screenshots).toBeGreaterThanOrEqual(0); expect(sourceCheck).toBeGreaterThan(screenshots); @@ -277,20 +315,18 @@ describe("iOS Fastlane release upload gates", () => { expect(releaseUpload).toContain("release_sha = context[:git_commit]"); expect(releaseUpload).toContain("ensure_mobile_release_ref_available!"); expect(releaseUpload).toContain("record_mobile_release_ref!"); - expect(releaseUpload).toContain( - "screenshots(release_version: context[:version], build_number: context[:build_number])", - ); + expect(releaseUpload).toContain("screenshots(\n release_version: context[:version]"); expect(fastfile).toContain("def without_xcode_xcconfig_file"); expect(releaseUpload).toContain("without_xcode_xcconfig_file do"); expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2); expect(releaseUpload.indexOf("prepare_app_store_context")).toBeLessThan( - releaseUpload.indexOf("screenshots(release_version: context[:version]"), + releaseUpload.indexOf("screenshots(\n release_version: context[:version]"), ); expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( - releaseUpload.indexOf("screenshots(release_version: context[:version]"), + releaseUpload.indexOf("screenshots(\n release_version: context[:version]"), ); expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( - releaseUpload.indexOf("\n metadata(release_version: context[:short_version])\n"), + releaseUpload.indexOf("\n metadata(\n release_version: context[:version]"), ); expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan( releaseUpload.indexOf("upload_to_testflight("), diff --git a/test/scripts/ios-release-prepare.test.ts b/test/scripts/ios-release-prepare.test.ts index d3b60df5b450..cc00ceb0dff4 100644 --- a/test/scripts/ios-release-prepare.test.ts +++ b/test/scripts/ios-release-prepare.test.ts @@ -38,7 +38,7 @@ function runPrepare(extraArgs: string[]): { ok: boolean; stdout: string; stderr: describe("scripts/ios-release-prepare.sh", () => { it("rejects non-canonical signing teams before generating release inputs", () => { - const result = runPrepare(["--version", "2026.6.11", "--build-number", "7"]); + const result = runPrepare(["--version", "2026.7.2", "--revision", "1", "--build-number", "3"]); expect(result.ok).toBe(false); expect(result.stderr).toContain( diff --git a/test/scripts/ios-release-wrapper-args.test.ts b/test/scripts/ios-release-wrapper-args.test.ts index 252a984daec8..04da48e54da1 100644 --- a/test/scripts/ios-release-wrapper-args.test.ts +++ b/test/scripts/ios-release-wrapper-args.test.ts @@ -35,17 +35,19 @@ describe("iOS release shell wrapper arguments", () => { const missingValueCases: readonly WrapperCase[] = [ ["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-archive.sh", ["--build-number", "--bogus"], "--build-number"], ["scripts/ios-release-archive.sh", ["--version", "--bogus"], "--version"], + ["scripts/ios-release-archive.sh", ["--revision", "--bogus"], "--revision"], ["scripts/ios-release-prepare.sh", ["--build-number", "--team-id"], "--build-number"], [ "scripts/ios-release-prepare.sh", - ["--build-number", "7", "--version", "--bogus"], + ["--build-number", "3", "--version", "--bogus"], "--version", ], [ "scripts/ios-release-prepare.sh", - ["--version", "2026.6.11", "--build-number", "7", "--team-id", "--bogus"], + ["--version", "2026.7.2", "--revision", "1", "--build-number", "3", "--team-id", "--bogus"], "--team-id", ], ]; @@ -67,8 +69,8 @@ describe("iOS release shell wrapper arguments", () => { "scripts/ios-release-upload.sh", "scripts/ios-release-archive.sh", "scripts/ios-release-prepare.sh", - ])("requires an explicit release version before release work in %s", (scriptPath) => { - const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "7"] : []; + ])("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", }); @@ -80,6 +82,23 @@ describe("iOS release shell wrapper arguments", () => { 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); + + 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", (scriptPath) => { @@ -93,7 +112,7 @@ describe("iOS release shell wrapper arguments", () => { it("rejects App Store release relay URL overrides before release work", () => { const result = runScript( path.join(process.cwd(), "scripts/ios-release-prepare.sh"), - ["--version", "2026.6.11", "--build-number", "7"], + ["--version", "2026.7.2", "--revision", "1", "--build-number", "3"], { IOS_DEVELOPMENT_TEAM: "FWJYW4S8P8", OPENCLAW_PUSH_RELAY_BASE_URL: "https://relay.example.com", diff --git a/test/scripts/ios-version.test.ts b/test/scripts/ios-version.test.ts index 416766204d88..19aab97ef19e 100644 --- a/test/scripts/ios-version.test.ts +++ b/test/scripts/ios-version.test.ts @@ -4,8 +4,10 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + encodeIosAppStoreVersion, extractChangelogSection, normalizeGatewayVersionToPinnedIosVersion, + normalizeIosAppStoreRevision, normalizePinnedIosVersion, renderIosReleaseNotes, resolveGatewayVersionForIosRelease, @@ -79,7 +81,7 @@ describe("resolveIosVersion", () => { expect(result.stderr).toBe(""); }); - it("prints explicit release version fields from the CLI", () => { + it("prints explicit gateway version fields from the CLI", () => { const rootDir = writeIosFixture({ packageVersion: "2026.4.6", changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.7\n\nStable notes.\n", @@ -108,6 +110,34 @@ describe("resolveIosVersion", () => { expect(result.stderr).toBe(""); }); + it("prints an encoded App Store version for an explicit gateway revision", () => { + const rootDir = writeIosFixture({ + packageVersion: "2026.7.2", + changelog: "# OpenClaw iOS Changelog\n\n## 2026.7.201\n\nRevision notes.\n", + }); + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/ios-version.ts", + "--root", + rootDir, + "--version", + "2026.7.2", + "--revision", + "1", + "--field", + "marketingVersion", + ], + { cwd: process.cwd(), encoding: "utf8" }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("2026.7.201\n"); + expect(result.stderr).toBe(""); + }); + it("prints derived release notes from the CLI", () => { const rootDir = writeIosFixture({ packageVersion: "2026.4.6", @@ -170,15 +200,33 @@ describe("resolveIosVersion", () => { }); expect(resolveIosVersion(rootDir)).toEqual({ + appStoreRevision: null, + appStoreVersion: null, buildVersion: "1", canonicalVersion: "2026.4.6", changelogPath: path.join(rootDir, "apps/ios/CHANGELOG.md"), + gatewayVersion: "2026.4.6", marketingVersion: "2026.4.6", versionSource: "package", versionSourcePath: path.join(rootDir, "package.json"), }); }); + it("encodes App Store revisions into the gateway patch component", () => { + expect(encodeIosAppStoreVersion("2026.7.2", 0)).toBe("2026.7.200"); + expect(encodeIosAppStoreVersion("2026.7.2", 1)).toBe("2026.7.201"); + expect(encodeIosAppStoreVersion("2026.7.2", 99)).toBe("2026.7.299"); + expect(encodeIosAppStoreVersion("2026.7.3", 0)).toBe("2026.7.300"); + expect(encodeIosAppStoreVersion("2026.12.33", 4)).toBe("2026.12.3304"); + }); + + it("rejects invalid App Store revisions", () => { + expect(() => normalizeIosAppStoreRevision("-1")).toThrow("integer from 0 to 99"); + expect(() => normalizeIosAppStoreRevision("01")).toThrow("integer from 0 to 99"); + expect(() => normalizeIosAppStoreRevision("100")).toThrow("integer from 0 to 99"); + expect(() => normalizeIosAppStoreRevision("1.5")).toThrow("integer from 0 to 99"); + }); + it("rejects semver-only package versions", () => { const rootDir = writeIosFixture({ packageVersion: "1.2.3", @@ -188,7 +236,7 @@ describe("resolveIosVersion", () => { expect(() => resolveIosVersion(rootDir)).toThrow("Expected YYYY.M.PATCH"); }); - it("rejects prerelease suffixes in explicit release versions", () => { + it("rejects prerelease suffixes in explicit gateway versions", () => { const rootDir = writeIosFixture({ packageVersion: "2026.4.6", changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n", @@ -249,6 +297,47 @@ describe("gateway version normalization", () => { }); describe("release note extraction", () => { + it("requires exact App Store version notes and adds the gateway association", () => { + const rootDir = writeIosFixture({ + packageVersion: "2026.7.2", + changelog: `# OpenClaw iOS Changelog + +## Unreleased + +Draft notes. + +## 2026.7.201 + +- App Store revision notes. +`, + }); + const version = resolveIosVersion(rootDir, { + appStoreRevision: 1, + releaseVersion: "2026.7.2", + }); + const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8"); + + expect(renderIosReleaseNotes(version, changelog)).toBe( + "Gateway version: 2026.7.2\n\n- App Store revision notes.\n", + ); + }); + + it("does not fall back to gateway or Unreleased notes for App Store revisions", () => { + const rootDir = writeIosFixture({ + packageVersion: "2026.7.2", + changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nDraft notes.\n", + }); + const version = resolveIosVersion(rootDir, { + appStoreRevision: 1, + releaseVersion: "2026.7.2", + }); + const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8"); + + expect(() => renderIosReleaseNotes(version, changelog)).toThrow( + "Unable to find iOS changelog notes for 2026.7.201", + ); + }); + it("extracts exact pinned version sections first", () => { const rootDir = writeIosFixture({ packageVersion: "2026.4.6",