feat(ios): support App Store release revisions

This commit is contained in:
joshavant
2026-07-23 16:24:12 -05:00
parent 06776e707b
commit 30395ba31b
18 changed files with 707 additions and 278 deletions
+5 -4
View File
@@ -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 <YYYY.M.D>` and `--build-number <n>` 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 <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.
- 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.
+25 -23
View File
@@ -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 <release-version>`.
4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version <release-version>`.
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.
See `apps/ios/VERSIONING.md` for the detailed spec.
+136 -160
View File
@@ -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 `## <release version>` 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 `## <release version>` or `## Unreleased`
3. run `pnpm ios:version:check -- --version <release version>`
4. check App Store Connect for the latest build number when needed
5. upload another build with `pnpm ios:release:upload -- --version <release version> --build-number <next>`
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/<CFBundleShortVersionString>-<CFBundleVersion>
```
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.
+184 -27
View File
@@ -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")]))
+11 -11
View File
@@ -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 <release-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 <gateway-version> --revision <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
+6 -6
View File
@@ -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/<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; after changelog updates, run `pnpm ios:version:check -- --version <release-version>`.
- 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>`.
- 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 `## <release version>` 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 <release-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:
+14 -2
View File
@@ -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
+18 -6
View File
@@ -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"
+14 -2
View File
@@ -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
+6 -3
View File
@@ -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,
+9 -3
View File
@@ -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",
+14 -1
View File
@@ -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
+72 -7
View File
@@ -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);
}
+30 -4
View File
@@ -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 {
+47 -11
View File
@@ -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("),
+1 -1
View File
@@ -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(
+24 -5
View File
@@ -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",
+91 -2
View File
@@ -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",