Require explicit iOS release versions

This commit is contained in:
joshavant 2026-07-01 20:04:57 -05:00
parent c1eee1a41a
commit 080481fec9
No known key found for this signature in database
GPG key ID: 4463B60B0DD49BC4
33 changed files with 523 additions and 577 deletions

View file

@ -5,6 +5,7 @@ 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.
- 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.

View file

@ -1,5 +1,11 @@
# OpenClaw iOS Changelog
## 2026.6.11 - 2026-07-01
Maintenance update for the current OpenClaw beta release.
- Refreshed iOS 26 visual styling, Talk controls, Gateway recovery, localization, and App Store screenshots.
## 2026.6.10 - 2026-06-21
Maintenance update for the current OpenClaw beta release.

View file

@ -1,9 +1,9 @@
// Shared iOS version defaults.
// Source of truth: apps/ios/version.json
// Source of truth: package.json or explicit release --version.
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.6.10
OPENCLAW_MARKETING_VERSION = 2026.6.10
OPENCLAW_IOS_VERSION = 2026.6.11
OPENCLAW_MARKETING_VERSION = 2026.6.11
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View file

@ -74,13 +74,13 @@ Release behavior:
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- Release uploads require an explicit CalVer version passed with `--version`.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
- The pinned iOS version must use CalVer like `2026.4.10`.
- That pinned value becomes:
- 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`
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
- Local defaults derive from root `package.json`; App Store uploads use the explicit `--version` value.
- See `apps/ios/VERSIONING.md` for the full workflow.
Relay behavior for App Store builds:
@ -110,13 +110,13 @@ Release-owner secrets:
Prepare the generated release xcconfig/project without archiving:
```bash
pnpm ios:release:prepare -- --build-number 7
pnpm ios:release:prepare -- --version 2026.6.11 --build-number 7
```
Archive without upload:
```bash
pnpm ios:release:archive
pnpm ios:release:archive -- --version 2026.6.11
```
This command is for local archive validation only. It is not a fallback upload
@ -125,13 +125,13 @@ path after `pnpm ios:release:upload` fails.
Archive and upload to App Store Connect:
```bash
pnpm ios:release:upload
pnpm ios:release:upload -- --version 2026.6.11
```
If you need to force a specific build number:
```bash
pnpm ios:release:upload -- --build-number 7
pnpm ios:release:upload -- --version 2026.6.11 --build-number 7
```
### Maintainer Quick Release Checklist
@ -166,16 +166,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, pin iOS to the current gateway version first:
4. If you are starting a brand-new production release train, add or update the matching iOS changelog section and sync generated metadata:
```bash
pnpm ios:version:pin -- --from-gateway
pnpm ios:version:sync -- --version 2026.6.11
```
5. Upload the build:
5. Upload the build with explicit release intent:
```bash
pnpm ios:release:upload
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
```
6. If `pnpm ios:release:upload` fails, stop at that failure. Do not archive
@ -183,8 +183,8 @@ pnpm ios:release:upload
step, then rerun `pnpm ios:release:upload`.
7. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- Fastlane reads the explicit `--version` value
- verifies synced iOS versioning artifacts for that version
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
@ -203,7 +203,8 @@ pnpm ios:release:upload
## iOS Versioning Workflow
- Pinned iOS release version: `apps/ios/version.json`
- Release upload version: explicit `--version`
- Local default version: root `package.json`
- iOS-only changelog: `apps/ios/CHANGELOG.md`
- Generated checked-in artifacts:
- `apps/ios/Config/Version.xcconfig`
@ -213,33 +214,27 @@ pnpm ios:release:upload
```bash
pnpm ios:version
pnpm ios:version:check
pnpm ios:version:sync
pnpm ios:version:pin -- --from-gateway
pnpm ios:version:pin -- --version 2026.4.10
pnpm ios:version -- --version 2026.6.11
pnpm ios:version:sync -- --version 2026.6.11
```
Recommended flow:
### TestFlight iteration on an existing train
1. Keep `apps/ios/version.json` pinned to the current train version.
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:sync` after changelog changes.
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
3. Run `pnpm ios:version:sync -- --version 2026.6.11` after changelog changes.
4. Upload more TestFlight builds with `pnpm ios:release:upload -- --version 2026.6.11`.
5. Let Fastlane bump only the numeric build number.
### Starting the next production release train
1. Pin iOS to the current gateway version:
```bash
pnpm ios:version:pin -- --from-gateway
```
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:sync`.
4. Submit the first App Store Connect build for that newly pinned version.
5. Keep iterating on that same version until the release candidate is ready.
3. Run `pnpm ios:version:sync -- --version <release-version>`.
4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version <release-version>`.
5. Keep iterating on that same explicit version until the release candidate is ready.
See `apps/ios/VERSIONING.md` for the detailed spec.

View file

@ -1,57 +1,62 @@
# OpenClaw iOS Versioning
OpenClaw iOS uses a **pinned CalVer release version** instead of reading the current gateway version automatically on every build.
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.
## Goals
- keep TestFlight submissions on one stable app version while iterating
- change only `CFBundleVersion` during normal TestFlight iteration
- promote the iOS release version to the current gateway version only when a maintainer chooses to do that
- make App Store release intent explicit at upload time
- avoid stale committed iOS pins
- 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
## Version model
The pinned iOS release version lives in `apps/ios/version.json`.
Release uploads require a version argument:
Supported pinned format:
```bash
pnpm ios:release:upload -- --version 2026.6.11
```
- `YYYY.M.D`
Use `--build-number` when the build number is known or has been verified from
App Store Connect:
Examples:
```bash
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
```
- `2026.4.6`
- `2026.4.10`
The release version must use `YYYY.M.D` CalVer, for example `2026.4.6` or
`2026.6.11`.
The root gateway version in `package.json` may still be one of:
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:
- `YYYY.M.D`
- `YYYY.M.D-beta.N`
- `YYYY.M.D-N`
When you pin iOS from the gateway version, the iOS tooling strips the gateway suffix and keeps only the base CalVer.
Examples:
- gateway `2026.4.10` -> iOS `2026.4.10`
- gateway `2026.4.10-beta.3` -> iOS `2026.4.10`
- gateway `2026.4.10-2` -> iOS `2026.4.10`
- 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`
## Apple bundle mapping
Pinned iOS version `2026.4.10` maps to:
Release version `2026.6.11` maps to:
- `CFBundleShortVersionString = 2026.4.10`
- `CFBundleShortVersionString = 2026.6.11`
- `CFBundleVersion = numeric build number only`
`CFBundleShortVersionString` stays fixed for a TestFlight train until you intentionally pin a newer iOS release version.
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.
## Source of truth and generated files
### Source files
- `apps/ios/version.json`
- pinned iOS release version
- `package.json`
- default iOS version source for local builds and checked-in defaults
- explicit `--version`
- release upload source of truth
- `apps/ios/CHANGELOG.md`
- iOS-only changelog and release-note source
- `apps/ios/VERSIONING.md`
@ -60,7 +65,7 @@ Pinned iOS version `2026.4.10` maps to:
### Generated or derived files
- `apps/ios/Config/Version.xcconfig`
- checked-in defaults derived from `apps/ios/version.json`
- checked-in defaults derived from `package.json` or `pnpm ios:version:sync -- --version ...`
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
- generated from `apps/ios/CHANGELOG.md`
- `apps/ios/build/Version.xcconfig`
@ -68,35 +73,25 @@ Pinned iOS version `2026.4.10` maps to:
## Tooling surfaces
### Version parsing and sync tooling
- `scripts/lib/ios-version.ts`
- validates pinned iOS CalVer
- normalizes gateway version -> pinned iOS CalVer
- validates iOS CalVer
- normalizes gateway version -> iOS CalVer
- renders checked-in xcconfig and release notes
- `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`
- syncs checked-in derived files from the pinned iOS version
- `scripts/ios-pin-version.ts`
- explicitly pins iOS to a chosen release version or the current gateway version
### Build and App Store release flow
- syncs checked-in derived files from the default or explicit iOS version
- `scripts/ios-write-version-xcconfig.sh`
- reads the pinned iOS version
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
- `scripts/ios-release-prepare.sh`
- prepares App Store distribution signing and bundle settings against the pinned iOS version
- `scripts/ios-release-signing.mjs`
- validates the checked-in App Store signing manifest
- renders the temporary release xcconfig profile pins
- requires `--version` and prepares App Store distribution signing and bundle settings
- `apps/ios/fastlane/Fastfile`
- resolves version metadata from the pinned iOS helper
- 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`
- increments App Store Connect build numbers for the pinned short version
- uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving a release build
- 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,
@ -107,34 +102,39 @@ Store Connect mutation commands.
## Release-note resolution order
When generating `apps/ios/fastlane/metadata/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
When generating `apps/ios/fastlane/metadata/en-US/release_notes.txt`, the
tooling reads the first available changelog section in this order:
1. exact pinned version, for example `## 2026.4.10`
1. exact release version, for example `## 2026.6.11`
2. `## Unreleased`
Recommended workflow:
Before production upload, prefer a final `## <release version>` section and run
sync with the same version:
- while iterating on a TestFlight train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
```bash
pnpm ios:version:sync -- --version 2026.6.11
```
## Common commands
```bash
pnpm ios:version
pnpm ios:version -- --version 2026.6.11
pnpm ios:version:check
pnpm ios:version:sync
pnpm ios:version:pin -- --from-gateway
pnpm ios:version:pin -- --version 2026.4.10
pnpm ios:version:sync -- --version 2026.6.11
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
```
## Normal TestFlight iteration workflow
1. keep `apps/ios/version.json` pinned to the current TestFlight train version
2. update `apps/ios/CHANGELOG.md` under `## Unreleased` while iterating
3. upload more App Store Connect builds with `pnpm ios:release:upload`
4. let Fastlane increment only `CFBundleVersion`
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:sync -- --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 TestFlight version stable while review is in flight.
This keeps the version decision at the release command instead of in a committed
state file.
## Release SHA tracking
@ -148,7 +148,7 @@ refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
Example:
```text
refs/openclaw/mobile-releases/ios/2026.6.10-8
refs/openclaw/mobile-releases/ios/2026.6.11-3
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
@ -166,38 +166,47 @@ evidence, not a repair mechanism for a failed `pnpm ios:release:upload` run.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8
pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8
pnpm mobile:release:preflight -- --platform ios --version 2026.6.11 --build 3
pnpm mobile:release:resolve -- --platform ios --version 2026.6.11 --build 3
```
## New release promotion workflow
## New release workflow
When you want the next production iOS release to align with the current gateway release:
When you want the next production iOS release to align with the current gateway
release:
1. pin iOS from the root gateway version:
1. confirm the root gateway version:
```bash
pnpm ios:version:pin -- --from-gateway
node -e "console.log(require('./package.json').version)"
```
2. review the generated changes in:
- `apps/ios/version.json`
- `apps/ios/Config/Version.xcconfig`
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
3. update `apps/ios/CHANGELOG.md` for the new release if needed
4. run `pnpm ios:version:sync` again if the changelog changed
5. upload the first App Store Connect build for that newly pinned version
6. keep iterating only by build number until the release candidate is ready
7. manually submit the reviewed build for App Review in App Store Connect
8. release the approved build to production
2. update `apps/ios/CHANGELOG.md` for that release
3. sync iOS generated files:
```bash
pnpm ios:version:sync -- --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
Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`.
App Store uploads must carry explicit version intent. Do not infer a release
train from checked-in generated files.
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
App Review submission must remain 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.
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

View file

@ -521,6 +521,18 @@ def preserve_local_signing
end
end
def without_xcode_xcconfig_file
existing = ENV["XCODE_XCCONFIG_FILE"]
ENV.delete("XCODE_XCCONFIG_FILE")
yield
ensure
if env_present?(existing)
ENV["XCODE_XCCONFIG_FILE"] = existing
else
ENV.delete("XCODE_XCCONFIG_FILE")
end
end
def app_store_signing_manifest
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end
@ -728,7 +740,7 @@ end
def release_notes_metadata_path
source = release_notes_path
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync -- --version <release-version>`.") unless File.exist?(source)
temp_root = Dir.mktmpdir("openclaw-release-notes")
target_dir = File.join(temp_root, "en-US")
@ -1034,14 +1046,18 @@ 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
def read_ios_version_metadata(release_version: nil)
script_path = File.join(repo_root, "scripts", "ios-version.ts")
stdout, stderr, status = Open3.capture3(
args = [
"node",
"--import",
"tsx",
script_path,
"--json",
]
args.push("--version", release_version) if env_present?(release_version)
stdout, stderr, status = Open3.capture3(
*args,
chdir: repo_root
)
@ -1066,21 +1082,26 @@ rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
end
def sync_ios_versioning!
def sync_ios_versioning!(release_version: nil)
script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
stdout, stderr, status = Open3.capture3(
args = [
"node",
"--import",
"tsx",
script_path,
"--check",
]
args.push("--version", release_version) if env_present?(release_version)
stdout, stderr, status = Open3.capture3(
*args,
chdir: repo_root
)
return if status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("iOS versioning artifacts are stale. Run `pnpm ios:version:sync`.\n#{detail}")
sync_command = env_present?(release_version) ? "pnpm ios:version:sync -- --version #{release_version}" : "pnpm ios:version:sync"
UI.user_error!("iOS versioning artifacts are stale. Run `#{sync_command}`.\n#{detail}")
end
def shell_join(parts)
@ -1092,8 +1113,8 @@ def xcodebuild_shell_join(parts)
shell_join(["env", "PATH=#{xcode_path}", *parts])
end
def resolve_release_build_number(api_key:, short_version:)
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
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}.")
@ -1111,15 +1132,15 @@ def resolve_release_build_number(api_key:, short_version:)
next_build.to_s
end
def release_build_number_needs_app_store_connect_auth?
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
def release_build_number_needs_app_store_connect_auth?(explicit_build_number: nil)
explicit = explicit_build_number.to_s.strip
!env_present?(explicit)
end
def prepare_app_store_release!(version:, 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, "--build-number", build_number]))
sh(shell_join(["bash", script_path, "--version", version, "--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)
@ -1296,13 +1317,22 @@ end
platform :ios do
private_lane :prepare_app_store_context do |options|
require_api_key = options[:require_api_key] == true
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?
release_version = options[:release_version].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
sync_ios_versioning!
version_metadata = read_ios_version_metadata
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.")
end
sync_ios_versioning!(release_version: release_version)
version_metadata = read_ios_version_metadata(release_version: release_version)
version = version_metadata[:version]
short_version = version_metadata[:short_version]
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
build_number = resolve_release_build_number(
api_key: api_key,
short_version: short_version,
explicit_build_number: explicit_build_number
)
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
{
@ -1346,8 +1376,12 @@ platform :ios do
end
desc "Build an App Store distribution archive locally without uploading"
lane :app_store_archive do
context = prepare_app_store_context(require_api_key: false)
lane :app_store_archive do |options|
context = prepare_app_store_context(
require_api_key: false,
release_version: options[:release_version],
build_number: options[:build_number]
)
build = build_app_store_release(context)
UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
build
@ -1356,26 +1390,32 @@ platform :ios do
end
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
lane :release_upload do
lane :release_upload do |options|
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_sha = release_git_sha
release_signing_check!
preserve_local_signing do
screenshots
end
context = prepare_app_store_context(require_api_key: true)
context = prepare_app_store_context(
require_api_key: true,
release_version: options[:release_version],
build_number: options[:build_number]
)
ensure_mobile_release_ref_available!(
platform: "ios",
version: context[:short_version],
build: context[:build_number],
sha: release_sha
)
without_xcode_xcconfig_file do
preserve_local_signing do
screenshots(release_version: context[:version], build_number: context[:build_number])
end
end
ENV["DELIVER_SCREENSHOTS"] = "1"
ENV["DELIVER_RELEASE_NOTES"] = "1"
metadata
metadata(release_version: context[:short_version])
build = build_app_store_release(context)
@ -1399,10 +1439,14 @@ platform :ios do
end
desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
lane :metadata do
lane :metadata do |options|
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
release_version = options[:release_version].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`.")
end
sync_ios_versioning!(release_version: release_version)
version_metadata = read_ios_version_metadata(release_version: release_version)
api_key = app_store_connect_api_key_config
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
@ -1464,9 +1508,15 @@ platform :ios do
end
desc "Generate deterministic iOS screenshots for App Store metadata"
lane :screenshots do
lane :screenshots do |options|
version_args = []
release_version = options[:release_version].to_s.strip
build_number = options[:build_number].to_s.strip
version_args += ["--version", release_version] unless release_version.empty?
version_args += ["--build-number", build_number] unless build_number.empty?
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_ios_screenshots(
@ -1486,13 +1536,19 @@ platform :ios do
xcargs: "-allowProvisioningUpdates"
)
watch_screenshot
watch_screenshot(release_version: release_version, build_number: build_number)
end
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
lane :watch_screenshot do
lane :watch_screenshot do |options|
version_args = []
release_version = options[:release_version].to_s.strip
build_number = options[:build_number].to_s.strip
version_args += ["--version", release_version] unless release_version.empty?
version_args += ["--build-number", build_number] unless build_number.empty?
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args]))
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
capture_watch_screenshot
end

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
pnpm ios:release:archive -- --version 2026.6.11 --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
pnpm ios:release:upload -- --version 2026.6.11
```
Direct Fastlane TestFlight upload is disabled. Use the package script so the
@ -135,16 +135,16 @@ cd apps/ios
fastlane ios auth_check
```
4. If you are starting a brand-new production release train, pin iOS to the current gateway version:
4. If you are starting a brand-new production release train, sync iOS generated metadata for the release version:
```bash
pnpm ios:version:pin -- --from-gateway
pnpm ios:version:sync -- --version 2026.6.11
```
5. Upload:
```bash
pnpm ios:release:upload
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
```
Quick verification after upload:
@ -156,14 +156,14 @@ Quick verification after upload:
Versioning rules:
- `apps/ios/version.json` is the pinned iOS release version source
- App Store release uploads require an explicit `--version`
- local defaults derive from root `package.json`
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source
- Supported pinned iOS versions use CalVer: `YYYY.M.D`
- `pnpm ios:version:pin -- --from-gateway` promotes the current root gateway version into the pinned iOS release version
- Fastlane uses the pinned iOS version only; changing `package.json.version` alone does not change the iOS app version
- Fastlane sets `CFBundleShortVersionString` to the pinned iOS version, for example `2026.4.10`
- 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:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md`
- Run `pnpm ios:version:sync -- --version <release-version>` after changing `apps/ios/CHANGELOG.md`
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
- 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

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
DELIVER_METADATA=1 fastlane ios metadata release_version:2026.6.11
```
## Release notes and App Review attachment
@ -16,14 +16,14 @@ DELIVER_METADATA=1 fastlane ios metadata
```bash
cd apps/ios
DELIVER_RELEASE_NOTES=1 fastlane ios metadata
DELIVER_RELEASE_NOTES=1 fastlane ios metadata release_version:2026.6.11
```
## Optional: include screenshots
```bash
cd apps/ios
DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata
DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata release_version:2026.6.11
```
## Auth
@ -45,10 +45,10 @@ 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.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync -- --version <release-version>`.
- `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 `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
- Release notes resolve from `## <release version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
- When starting a new production release train, sync metadata with `pnpm ios:version:sync -- --version <release-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:

View file

@ -1,3 +1,3 @@
Maintenance update for the current OpenClaw beta release.
- Improved notification cleanup, Watch app compatibility, and native file input handling.
- Refreshed iOS 26 visual styling, Talk controls, Gateway recovery, localization, and App Store screenshots.

View file

@ -1,3 +0,0 @@
{
"version": "2026.6.10"
}

View file

@ -1638,7 +1638,6 @@
"ios:screenshots": "bash scripts/ios-screenshots.sh",
"ios:version": "node --import tsx scripts/ios-version.ts --json",
"ios:version:check": "node --import tsx scripts/ios-sync-versioning.ts --check",
"ios:version:pin": "node --import tsx scripts/ios-pin-version.ts",
"ios:version:sync": "node --import tsx scripts/ios-sync-versioning.ts --write",
"leak:embedded-run": "node --import tsx --expose-gc scripts/embedded-run-abort-leak.ts",
"lint": "node scripts/run-oxlint-shards.mjs",

View file

@ -37,7 +37,6 @@ export const RELEASE_METADATA_PATHS = new Set([
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",
"apps/ios/fastlane/metadata/en-US/release_notes.txt",
"apps/ios/version.json",
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
"docs/.generated/config-baseline.sha256",
"docs/install/updating.md",

View file

@ -9,7 +9,6 @@ const VERSION_ONLY_TEXT_PATHS = new Set([
"apps/android/Config/Version.properties",
"apps/android/version.json",
"apps/ios/Config/Version.xcconfig",
"apps/ios/version.json",
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
]);

View file

@ -1,154 +0,0 @@
// Ios Pin Version script supports OpenClaw repository automation.
import path from "node:path";
import {
normalizePinnedIosVersion,
resolveGatewayVersionForIosRelease,
resolveIosVersion,
syncIosVersioning,
writeIosVersionManifest,
} from "./lib/ios-version.ts";
type CliOptions = {
explicitVersion: string | null;
fromGateway: boolean;
rootDir: string;
sync: boolean;
};
export type PinIosVersionResult = {
previousVersion: string | null;
nextVersion: string;
packageVersion: string | null;
versionFilePath: string;
syncedPaths: string[];
};
function usage(): string {
return [
"Usage: node --import tsx scripts/ios-pin-version.ts (--from-gateway | --version <YYYY.M.PATCH>) [--no-sync] [--root dir]",
"",
"Examples:",
" node --import tsx scripts/ios-pin-version.ts --from-gateway",
" node --import tsx scripts/ios-pin-version.ts --version 2026.4.10",
].join("\n");
}
export function parseArgs(argv: string[]): CliOptions {
let explicitVersion: string | null = null;
let fromGateway = false;
let rootDir = path.resolve(".");
let sync = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--from-gateway": {
fromGateway = true;
break;
}
case "--version": {
explicitVersion = readOptionValue(argv, index, "--version");
index += 1;
break;
}
case "--no-sync": {
sync = false;
break;
}
case "--root": {
const value = readOptionValue(argv, index, "--root");
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(`${usage()}\n`);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
if (fromGateway === (explicitVersion !== null)) {
throw new Error("Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>.");
}
if (explicitVersion !== null && !explicitVersion.trim()) {
throw new Error("Missing value for --version.");
}
return { explicitVersion, fromGateway, rootDir, sync };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (value === undefined || value === "" || value.startsWith("-")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
export function pinIosVersion(params: CliOptions): PinIosVersionResult {
const rootDir = path.resolve(params.rootDir);
let previousVersion: string | null;
try {
previousVersion = resolveIosVersion(rootDir).canonicalVersion;
} catch {
previousVersion = null;
}
const gatewayVersion = params.fromGateway ? resolveGatewayVersionForIosRelease(rootDir) : null;
const packageVersion = gatewayVersion?.packageVersion ?? null;
const nextVersion =
gatewayVersion?.pinnedIosVersion ?? normalizePinnedIosVersion(params.explicitVersion ?? "");
const versionFilePath = writeIosVersionManifest(nextVersion, rootDir);
const syncedPaths = params.sync ? syncIosVersioning({ mode: "write", rootDir }).updatedPaths : [];
return {
previousVersion,
nextVersion,
packageVersion,
versionFilePath,
syncedPaths,
};
}
export async function main(argv: string[]): Promise<number> {
try {
const options = parseArgs(argv);
const result = pinIosVersion(options);
const sourceText = result.packageVersion
? ` from gateway version ${result.packageVersion}`
: "";
process.stdout.write(`Pinned iOS version to ${result.nextVersion}${sourceText}.\n`);
if (result.previousVersion && result.previousVersion !== result.nextVersion) {
process.stdout.write(`Previous pinned iOS version: ${result.previousVersion}.\n`);
}
process.stdout.write(
`Updated version manifest: ${path.relative(process.cwd(), result.versionFilePath)}\n`,
);
if (options.sync) {
if (result.syncedPaths.length === 0) {
process.stdout.write("iOS versioning artifacts already up to date.\n");
} else {
process.stdout.write(
`Updated iOS versioning artifacts:\n- ${result.syncedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
);
}
}
return 0;
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const exitCode = await main(process.argv.slice(2));
if (exitCode !== 0) {
process.exit(exitCode);
}
}

View file

@ -4,13 +4,14 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-archive.sh [--build-number 7]
scripts/ios-release-archive.sh --version 2026.6.11 [--build-number 7]
Archives and exports an App Store distribution IPA locally without uploading.
EOF
}
BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"
BUILD_NUMBER=""
RELEASE_VERSION=""
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
@ -35,6 +36,11 @@ while [[ $# -gt 0 ]]; do
BUILD_NUMBER="${2:-}"
shift 2
;;
--version)
require_option_value "$1" "${2-}"
RELEASE_VERSION="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
@ -47,7 +53,18 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "${RELEASE_VERSION}" ]]; then
echo "Missing required --version." >&2
usage >&2
exit 1
fi
FASTLANE_ARGS=(ios app_store_archive "release_version:${RELEASE_VERSION}")
if [[ -n "${BUILD_NUMBER}" ]]; then
FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")
fi
(
cd "${ROOT_DIR}/apps/ios"
IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" run_ios_fastlane ios app_store_archive
run_ios_fastlane "${FASTLANE_ARGS[@]}"
)

View file

@ -4,10 +4,10 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-prepare.sh --build-number 7 [--team-id TEAMID]
scripts/ios-release-prepare.sh --version 2026.6.11 --build-number 7 [--team-id TEAMID]
Prepares local App Store release inputs without touching local signing overrides:
- reads apps/ios/version.json and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/Version.xcconfig for the explicit release version
- 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
@ -27,6 +27,7 @@ RELEASE_SIGNING_HELPER="${ROOT_DIR}/scripts/ios-release-signing.mjs"
CANONICAL_TEAM_ID="FWJYW4S8P8"
BUILD_NUMBER=""
RELEASE_VERSION=""
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
IOS_VERSION=""
RELEASE_SIGNING_XCCONFIG=""
@ -75,6 +76,11 @@ while [[ $# -gt 0 ]]; do
BUILD_NUMBER="${2:-}"
shift 2
;;
--version)
require_option_value "$1" "${2-}"
RELEASE_VERSION="${2:-}"
shift 2
;;
--team-id)
require_option_value "$1" "${2-}"
TEAM_ID="${2:-}"
@ -94,7 +100,13 @@ done
if [[ -z "${BUILD_NUMBER}" ]]; then
echo "Missing required --build-number." >&2
usage
usage >&2
exit 1
fi
if [[ -z "${RELEASE_VERSION}" ]]; then
echo "Missing required --version." >&2
usage >&2
exit 1
fi
@ -120,12 +132,12 @@ fi
prepare_build_dir
(
cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check
cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check --version "${RELEASE_VERSION}"
)
IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --field canonicalVersion)"
IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --version "${RELEASE_VERSION}" --field canonicalVersion)"
if [[ -z "${IOS_VERSION}" ]]; then
echo "Unable to resolve iOS version from ${ROOT_DIR}/apps/ios/version.json." >&2
echo "Unable to resolve iOS release version '${RELEASE_VERSION}'." >&2
exit 1
fi
@ -136,7 +148,7 @@ if [[ -z "${RELEASE_SIGNING_XCCONFIG}" ]]; then
fi
(
bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}"
bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}"
)
write_generated_file "${RELEASE_XCCONFIG}" <<EOF

View file

@ -4,7 +4,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-release-upload.sh [--build-number 7]
scripts/ios-release-upload.sh --version 2026.6.11 [--build-number 7]
Generates App Store screenshots, updates release metadata, archives, and uploads
an App Store distribution build to App Store Connect. This does not submit the
@ -12,7 +12,8 @@ build for App Review.
EOF
}
BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"
BUILD_NUMBER=""
RELEASE_VERSION=""
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
@ -37,6 +38,11 @@ while [[ $# -gt 0 ]]; do
BUILD_NUMBER="${2:-}"
shift 2
;;
--version)
require_option_value "$1" "${2-}"
RELEASE_VERSION="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
@ -49,8 +55,19 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "${RELEASE_VERSION}" ]]; then
echo "Missing required --version." >&2
usage >&2
exit 1
fi
FASTLANE_ARGS=(ios release_upload "release_version:${RELEASE_VERSION}")
if [[ -n "${BUILD_NUMBER}" ]]; then
FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")
fi
(
cd "${ROOT_DIR}/apps/ios"
# App Store Connect screenshot reservations can fail with 500s under parallel deliver uploads.
DELIVER_NUMBER_OF_THREADS=1 FL_MAX_NUMBER_OF_THREADS=1 OPENCLAW_IOS_RELEASE_WRAPPER=1 IOS_RELEASE_BUILD_NUMBER="${BUILD_NUMBER}" run_ios_fastlane ios release_upload
DELIVER_NUMBER_OF_THREADS=1 FL_MAX_NUMBER_OF_THREADS=1 OPENCLAW_IOS_RELEASE_WRAPPER=1 run_ios_fastlane "${FASTLANE_ARGS[@]}"
)

View file

@ -7,7 +7,7 @@ export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts"
function printUsage(): void {
process.stdout.write(
"Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--root dir]\n",
"Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--root dir]\n",
);
}
@ -18,7 +18,11 @@ function main(argv = process.argv.slice(2)): number {
return 0;
}
const result = syncIosVersioning({ mode: options.mode, rootDir: options.rootDir });
const result = syncIosVersioning({
mode: options.mode,
releaseVersion: options.releaseVersion,
rootDir: options.rootDir,
});
if (options.mode === "check") {
process.stdout.write("iOS versioning artifacts are up to date.\n");

View file

@ -4,7 +4,7 @@ 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] [--root dir]\n\n",
"Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--root dir]\n\n",
);
}
@ -15,7 +15,7 @@ function main(argv = process.argv.slice(2)): number {
return 0;
}
const version = resolveIosVersion(options.rootDir);
const version = resolveIosVersion(options.rootDir, { releaseVersion: options.releaseVersion });
if (options.field) {
const value = version[options.field as keyof typeof version];

View file

@ -4,9 +4,9 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/ios-write-version-xcconfig.sh [--build-number 7]
scripts/ios-write-version-xcconfig.sh [--version 2026.6.11] [--build-number 7]
Writes apps/ios/build/Version.xcconfig from apps/ios/version.json:
Writes apps/ios/build/Version.xcconfig from package.json or explicit --version:
- OPENCLAW_IOS_VERSION = exact canonical iOS version
- OPENCLAW_MARKETING_VERSION = short iOS/App Store version
- OPENCLAW_BUILD_VERSION = explicit build number or local numeric fallback
@ -21,6 +21,18 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
IOS_VERSION=""
MARKETING_VERSION=""
BUILD_NUMBER=""
RELEASE_VERSION=""
require_option_value() {
local option="$1"
local value="${2-}"
if [[ -z "${value}" || "${value}" == --* ]]; then
echo "Missing value for ${option}." >&2
usage >&2
exit 1
fi
}
prepare_build_dir() {
if [[ -L "${BUILD_DIR}" ]]; then
@ -51,9 +63,15 @@ while [[ $# -gt 0 ]]; do
shift
;;
--build-number)
require_option_value "$1" "${2-}"
BUILD_NUMBER="${2:-}"
shift 2
;;
--version)
require_option_value "$1" "${2-}"
RELEASE_VERSION="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
@ -66,6 +84,11 @@ while [[ $# -gt 0 ]]; do
esac
done
VERSION_HELPER_ARGS=(--shell)
if [[ -n "${RELEASE_VERSION}" ]]; then
VERSION_HELPER_ARGS+=(--version "${RELEASE_VERSION}")
fi
while IFS='=' read -r key value; do
case "${key}" in
OPENCLAW_IOS_VERSION)
@ -75,10 +98,10 @@ while IFS='=' read -r key value; do
MARKETING_VERSION="${value}"
;;
esac
done < <(cd "${ROOT_DIR}" && node --import tsx "${VERSION_HELPER}" --shell)
done < <(cd "${ROOT_DIR}" && node --import tsx "${VERSION_HELPER}" "${VERSION_HELPER_ARGS[@]}")
if [[ -z "${IOS_VERSION}" || -z "${MARKETING_VERSION}" ]]; then
echo "Unable to resolve iOS version metadata from ${ROOT_DIR}/apps/ios/version.json." >&2
echo "Unable to resolve iOS version metadata." >&2
exit 1
fi

View file

@ -3,23 +3,19 @@ import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parseReleaseVersion } from "./npm-publish-plan.mjs";
const IOS_VERSION_FILE = "apps/ios/version.json";
const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md";
const IOS_VERSION_XCCONFIG_FILE = "apps/ios/Config/Version.xcconfig";
const IOS_RELEASE_NOTES_FILE = "apps/ios/fastlane/metadata/en-US/release_notes.txt";
type IosVersionManifest = {
version: string;
};
type ResolvedIosVersion = {
canonicalVersion: string;
marketingVersion: string;
buildVersion: string;
versionFilePath: string;
changelogPath: string;
versionXcconfigPath: string;
releaseNotesPath: string;
versionSource: "explicit" | "package";
versionSourcePath: string | null;
};
type SyncIosVersioningMode = "check" | "write";
@ -39,14 +35,12 @@ function parsePinnedReleaseVersion(rawVersion: string): string | null {
export function normalizePinnedIosVersion(rawVersion: string): string {
const trimmed = rawVersion.trim();
if (!trimmed) {
throw new Error(`Missing iOS version in ${IOS_VERSION_FILE}.`);
throw new Error("Missing iOS release version.");
}
const pinnedVersion = parsePinnedReleaseVersion(trimmed);
if (!pinnedVersion) {
throw new Error(
`Invalid iOS version '${rawVersion}'. Expected pinned release version like 2026.6.5.`,
);
throw new Error(`Invalid iOS version '${rawVersion}'. Expected release version like 2026.6.5.`);
}
return pinnedVersion;
@ -68,8 +62,12 @@ export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): s
return parsed.baseVersion;
}
function rootPackageJsonPath(rootDir = path.resolve(".")): string {
return path.join(rootDir, "package.json");
}
function readRootPackageVersion(rootDir = path.resolve(".")): string {
const packageJsonPath = path.join(rootDir, "package.json");
const packageJsonPath = rootPackageJsonPath(rootDir);
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
if (!version) {
@ -89,40 +87,32 @@ export function resolveGatewayVersionForIosRelease(rootDir = path.resolve(".")):
};
}
function readIosVersionManifest(rootDir = path.resolve(".")): IosVersionManifest {
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
return JSON.parse(readFileSync(versionFilePath, "utf8")) as IosVersionManifest;
}
export function writeIosVersionManifest(version: string, rootDir = path.resolve(".")): string {
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
const normalizedVersion = normalizePinnedIosVersion(version);
const nextContent = `${JSON.stringify({ version: normalizedVersion }, null, 2)}\n`;
writeFileSync(versionFilePath, nextContent, "utf8");
return versionFilePath;
}
export function resolveIosVersion(rootDir = path.resolve(".")): ResolvedIosVersion {
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
export function resolveIosVersion(
rootDir = path.resolve("."),
options?: { releaseVersion?: string | null },
): ResolvedIosVersion {
const changelogPath = path.join(rootDir, IOS_CHANGELOG_FILE);
const versionXcconfigPath = path.join(rootDir, IOS_VERSION_XCCONFIG_FILE);
const releaseNotesPath = path.join(rootDir, IOS_RELEASE_NOTES_FILE);
const manifest = readIosVersionManifest(rootDir);
const canonicalVersion = normalizePinnedIosVersion(manifest.version ?? "");
const explicitReleaseVersion = options?.releaseVersion?.trim() ?? "";
const canonicalVersion = explicitReleaseVersion
? normalizePinnedIosVersion(explicitReleaseVersion)
: resolveGatewayVersionForIosRelease(rootDir).pinnedIosVersion;
return {
canonicalVersion,
marketingVersion: canonicalVersion,
buildVersion: "1",
versionFilePath,
changelogPath,
versionXcconfigPath,
releaseNotesPath,
versionSource: explicitReleaseVersion ? "explicit" : "package",
versionSourcePath: explicitReleaseVersion ? null : rootPackageJsonPath(rootDir),
};
}
export function renderIosVersionXcconfig(version: ResolvedIosVersion): string {
return `// Shared iOS version defaults.\n// Source of truth: apps/ios/version.json\n// Generated by scripts/ios-sync-versioning.ts.\n\nOPENCLAW_IOS_VERSION = ${version.canonicalVersion}\nOPENCLAW_MARKETING_VERSION = ${version.marketingVersion}\nOPENCLAW_BUILD_VERSION = ${version.buildVersion}\n\n#include? "../build/Version.xcconfig"\n`;
return `// Shared iOS version defaults.\n// Source of truth: package.json or explicit release --version.\n// Generated by scripts/ios-sync-versioning.ts.\n\nOPENCLAW_IOS_VERSION = ${version.canonicalVersion}\nOPENCLAW_MARKETING_VERSION = ${version.marketingVersion}\nOPENCLAW_BUILD_VERSION = ${version.buildVersion}\n\n#include? "../build/Version.xcconfig"\n`;
}
function matchChangelogHeading(line: string, heading: string): boolean {
@ -190,12 +180,17 @@ function syncFile(params: {
return true;
}
export function syncIosVersioning(params?: { mode?: SyncIosVersioningMode; rootDir?: string }): {
export function syncIosVersioning(params?: {
mode?: SyncIosVersioningMode;
releaseVersion?: string | null;
rootDir?: string;
}): {
updatedPaths: string[];
} {
const mode = params?.mode ?? "write";
const rootDir = path.resolve(params?.rootDir ?? ".");
const version = resolveIosVersion(rootDir);
const releaseVersion = params?.releaseVersion;
const version = resolveIosVersion(rootDir, { releaseVersion });
const changelogContent = readFileSync(version.changelogPath, "utf8");
const nextVersionXcconfig = renderIosVersionXcconfig(version);
const nextReleaseNotes = renderIosReleaseNotes(version, changelogContent);

View file

@ -5,12 +5,14 @@ export type VersionQueryCliOptions = {
field: string | null;
format: VersionScriptFormat;
help: boolean;
releaseVersion: string | null;
rootDir: string;
};
export type VersionSyncMode = "check" | "write";
export type VersionSyncCliOptions = {
help: boolean;
mode: VersionSyncMode;
releaseVersion: string | null;
rootDir: string;
};
@ -18,11 +20,15 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
let field: string | null = null;
let format: VersionScriptFormat = "json";
let help = false;
let releaseVersion: string | null = null;
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--": {
break;
}
case "--field": {
field = readOptionValue(argv, index, "--field");
index += 1;
@ -42,38 +48,8 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, help, rootDir };
}
export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
let help = false;
let mode: VersionSyncMode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, "--root"));
case "--version": {
releaseVersion = readOptionValue(argv, index, "--version");
index += 1;
break;
}
@ -88,7 +64,51 @@ export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
}
}
return { help, mode, rootDir };
return { field, format, help, releaseVersion, rootDir };
}
export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
let help = false;
let mode: VersionSyncMode = "write";
let releaseVersion: string | null = null;
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--": {
break;
}
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, "--root"));
index += 1;
break;
}
case "--version": {
releaseVersion = readOptionValue(argv, index, "--version");
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { help, mode, releaseVersion, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {

View file

@ -1169,10 +1169,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
],
["scripts/lib/format-generated-module.mjs", ["test/scripts/format-generated-module.test.ts"]],
[
"scripts/lib/ios-version.ts",
["test/scripts/ios-version.test.ts", "test/scripts/ios-pin-version.test.ts"],
],
["scripts/lib/ios-version.ts", ["test/scripts/ios-version.test.ts"]],
["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]],
["scripts/lib/local-heavy-check-runtime.mjs", ["test/scripts/local-heavy-check-runtime.test.ts"]],
["scripts/lib/kova-report-gate.mjs", ["test/scripts/kova-report-gate.test.ts"]],

View file

@ -907,7 +907,6 @@ describe("test-projects args", () => {
"test/scripts/fixture-plugin-commands.test.ts",
"test/scripts/incremental-line-reader.test.ts",
"test/scripts/ios-configure-signing.test.ts",
"test/scripts/ios-pin-version.test.ts",
"test/scripts/ios-team-id.test.ts",
"test/scripts/ios-version.test.ts",
"test/scripts/kitchen-sink-rpc-walk.test.ts",

View file

@ -1330,7 +1330,6 @@ describe("scripts/changed-lanes", () => {
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",
"apps/ios/fastlane/metadata/en-US/release_notes.txt",
"apps/ios/version.json",
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
"docs/.generated/config-baseline.sha256",
"package.json",

View file

@ -10,13 +10,13 @@ describe("check-release-metadata-only", () => {
"--head",
"HEAD",
"./package.json",
"apps\\ios\\version.json",
"apps\\ios\\Config\\Version.xcconfig",
]),
).toEqual({
staged: false,
base: "origin/release",
head: "HEAD",
paths: ["package.json", "apps/ios/version.json"],
paths: ["package.json", "apps/ios/Config/Version.xcconfig"],
});
});

View file

@ -1,125 +0,0 @@
// Ios Pin Version tests cover ios pin version script behavior.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { pinIosVersion, parseArgs } from "../../scripts/ios-pin-version.ts";
import { resolveIosVersion } from "../../scripts/lib/ios-version.ts";
import { installIosFixtureCleanup, writeIosFixture } from "./ios-version.test-support.ts";
installIosFixtureCleanup();
describe("parseArgs", () => {
it("requires exactly one pin source", () => {
expect(() => parseArgs([])).toThrow(
"Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>",
);
expect(() => parseArgs(["--from-gateway", "--version", "2026.4.7"])).toThrow(
"Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>",
);
});
it("rejects flags where option values are required", () => {
for (const { args, message } of [
{ args: ["--version", "--no-sync"], message: "Missing value for --version." },
{
args: ["--version", "2026.4.7", "--root", "--no-sync"],
message: "Missing value for --root.",
},
]) {
expect(() => parseArgs(args)).toThrow(message);
}
});
});
describe("pinIosVersion", () => {
it("pins an explicit iOS release version and syncs generated artifacts", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
changelog: `# OpenClaw iOS Changelog
## Unreleased
- Draft release notes.
`,
prefix: "openclaw-ios-pin-",
});
const result = pinIosVersion({
explicitVersion: "2026.4.7",
fromGateway: false,
rootDir,
sync: true,
});
expect(result.previousVersion).toBe("2026.4.6");
expect(result.nextVersion).toBe("2026.4.7");
expect(result.packageVersion).toBeNull();
expect(resolveIosVersion(rootDir).canonicalVersion).toBe("2026.4.7");
expect(fs.readFileSync(path.join(rootDir, "apps", "ios", "version.json"), "utf8")).toContain(
'"version": "2026.4.7"',
);
expect(
fs.readFileSync(path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), "utf8"),
).toContain("OPENCLAW_MARKETING_VERSION = 2026.4.7");
expect(
fs.readFileSync(
path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US", "release_notes.txt"),
"utf8",
),
).toContain("- Draft release notes.");
expect(result.syncedPaths).toHaveLength(2);
});
it("pins from the current gateway version without carrying prerelease suffixes", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.10-beta.3",
changelog: `# OpenClaw iOS Changelog
## Unreleased
- Candidate release notes.
`,
prefix: "openclaw-ios-pin-",
});
const result = pinIosVersion({
explicitVersion: null,
fromGateway: true,
rootDir,
sync: true,
});
expect(result.previousVersion).toBe("2026.4.6");
expect(result.nextVersion).toBe("2026.4.10");
expect(result.packageVersion).toBe("2026.4.10-beta.3");
expect(resolveIosVersion(rootDir).marketingVersion).toBe("2026.4.10");
});
it("can skip syncing checked-in artifacts when requested", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
changelog: `# OpenClaw iOS Changelog
## Unreleased
- Candidate release notes.
`,
versionXcconfig: "stale\n",
releaseNotes: "stale\n",
prefix: "openclaw-ios-pin-",
});
const result = pinIosVersion({
explicitVersion: "2026.4.8",
fromGateway: false,
rootDir,
sync: false,
});
expect(result.syncedPaths).toHaveLength(0);
expect(
fs.readFileSync(path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), "utf8"),
).toBe("stale\n");
});
});

View file

@ -39,9 +39,12 @@ describe("iOS Fastlane release upload gates", () => {
const script = readFileSync(uploadScriptPath, "utf8");
expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1");
expect(script).toContain("Missing required --version.");
expect(script).toContain('"release_version:${RELEASE_VERSION}"');
expect(script).toContain('"build_number:${BUILD_NUMBER}"');
expect(script).toContain("DELIVER_NUMBER_OF_THREADS=1");
expect(script).toContain("FL_MAX_NUMBER_OF_THREADS=1");
expect(script).toContain("run_ios_fastlane ios release_upload");
expect(script).toContain('run_ios_fastlane "${FASTLANE_ARGS[@]}"');
});
it("keeps release_upload as the only Fastlane TestFlight upload implementation", () => {
@ -57,9 +60,16 @@ describe("iOS Fastlane release upload gates", () => {
it("rejects direct Fastlane upload before release work", () => {
const fastfile = readFastfile();
const releaseUpload = laneBody(fastfile, "release_upload");
const prepareContext = laneBody(fastfile, "prepare_app_store_context");
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[:build_number]");
expect(prepareContext).toContain("Missing iOS release version");
expect(releaseUpload).toContain("metadata(release_version: context[:short_version])");
expect(laneBody(fastfile, "metadata")).toContain("options[:release_version]");
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS release version");
expect(releaseUpload.indexOf("UI.user_error!")).toBeLessThan(
releaseUpload.indexOf("prepare_app_store_context"),
);
@ -86,9 +96,20 @@ describe("iOS Fastlane release upload gates", () => {
expect(releaseUpload).toContain("release_sha = release_git_sha");
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(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]"),
);
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
releaseUpload.indexOf("\n metadata\n"),
releaseUpload.indexOf("screenshots(release_version: context[:version]"),
);
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
releaseUpload.indexOf("\n metadata(release_version: context[:short_version])\n"),
);
expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
releaseUpload.indexOf("upload_to_testflight("),
@ -98,6 +119,12 @@ describe("iOS Fastlane release upload gates", () => {
it("normalizes Watch screenshots as opaque RGB PNGs for App Store upload", () => {
const fastfile = readFastfile();
expect(laneBody(fastfile, "screenshots")).toContain(
'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args',
);
expect(laneBody(fastfile, "watch_screenshot")).toContain(
'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args',
);
expect(fastfile).toContain("def normalize_watch_screenshot_status_bar(path)");
expect(fastfile).toContain("CGImageAlphaInfo.noneSkipLast.rawValue");
expect(fastfile).toContain("CGImageDestinationCreateWithURL");

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(["--build-number", "7"]);
const result = runPrepare(["--version", "2026.6.11", "--build-number", "7"]);
expect(result.ok).toBe(false);
expect(result.stderr).toContain(

View file

@ -1,5 +1,6 @@
// iOS release wrapper tests keep release args fail-closed before Fastlane work.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
@ -33,11 +34,18 @@ function runScript(
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-archive.sh", ["--build-number", "--bogus"], "--build-number"],
["scripts/ios-release-archive.sh", ["--version", "--bogus"], "--version"],
["scripts/ios-release-prepare.sh", ["--build-number", "--team-id"], "--build-number"],
[
"scripts/ios-release-prepare.sh",
["--build-number", "7", "--team-id", "--bogus"],
["--build-number", "7", "--version", "--bogus"],
"--version",
],
[
"scripts/ios-release-prepare.sh",
["--version", "2026.6.11", "--build-number", "7", "--team-id", "--bogus"],
"--team-id",
],
];
@ -55,10 +63,37 @@ describe("iOS release shell wrapper arguments", () => {
},
);
it.each([
"scripts/ios-release-upload.sh",
"scripts/ios-release-archive.sh",
"scripts/ios-release-prepare.sh",
])("requires an explicit release version before release work in %s", (scriptPath) => {
const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "7"] : [];
const result = runScript(path.join(process.cwd(), scriptPath), args, {
IOS_RELEASE_VERSION: "2026.6.10",
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Missing required --version.");
expect(result.stderr).not.toContain("No such file or directory");
expect(result.stderr).not.toContain("fastlane");
expect(result.stdout).toBe("");
});
it.each(["scripts/ios-release-upload.sh", "scripts/ios-release-archive.sh"])(
"does not accept ambient release build numbers in %s",
(scriptPath) => {
const script = readFileSync(path.join(process.cwd(), scriptPath), "utf8");
expect(script).toContain('BUILD_NUMBER=""');
expect(script).not.toContain('BUILD_NUMBER="${IOS_RELEASE_BUILD_NUMBER:-}"');
},
);
it("rejects App Store release relay URL overrides before release work", () => {
const result = runScript(
path.join(process.cwd(), "scripts/ios-release-prepare.sh"),
["--build-number", "7"],
["--version", "2026.6.11", "--build-number", "7"],
{
IOS_DEVELOPMENT_TEAM: "FWJYW4S8P8",
OPENCLAW_PUSH_RELAY_BASE_URL: "https://relay.example.com",

View file

@ -13,7 +13,7 @@ export function installIosFixtureCleanup(): void {
}
export function writeIosFixture(params: {
version: string;
version?: string;
changelog: string;
packageVersion?: string;
releaseNotes?: string;
@ -27,12 +27,7 @@ export function writeIosFixture(params: {
});
fs.writeFileSync(
path.join(rootDir, "package.json"),
`${JSON.stringify({ version: params.packageVersion ?? "2026.4.6" }, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "apps", "ios", "version.json"),
`${JSON.stringify({ version: params.version }, null, 2)}\n`,
`${JSON.stringify({ version: params.packageVersion ?? params.version ?? "2026.4.6" }, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), params.changelog, "utf8");

View file

@ -45,7 +45,7 @@ describe("resolveIosVersion", () => {
it("prints selected fields from the CLI", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.6",
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.6\n\nStable notes.\n",
});
const result = spawnSync(
@ -70,6 +70,35 @@ describe("resolveIosVersion", () => {
expect(result.stderr).toBe("");
});
it("prints explicit release 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",
});
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
"scripts/ios-version.ts",
"--root",
rootDir,
"--version",
"2026.4.7",
"--field",
"canonicalVersion",
],
{
cwd: process.cwd(),
encoding: "utf8",
},
);
expect(result.status).toBe(0);
expect(result.stdout).toBe("2026.4.7\n");
expect(result.stderr).toBe("");
});
it("rejects missing iOS sync CLI root values before reading version files", () => {
const result = spawnSync(
process.execPath,
@ -96,9 +125,9 @@ describe("resolveIosVersion", () => {
expect(shortFlagResult.stderr).toBe("Missing value for --root.\n");
});
it("parses pinned release versions and derives Apple marketing fields", () => {
it("derives Apple marketing fields from the root package release version", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.6",
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.6\n\nStable notes.\n",
});
@ -108,39 +137,38 @@ describe("resolveIosVersion", () => {
changelogPath: path.join(rootDir, "apps/ios/CHANGELOG.md"),
marketingVersion: "2026.4.6",
releaseNotesPath: path.join(rootDir, "apps/ios/fastlane/metadata/en-US/release_notes.txt"),
versionFilePath: path.join(rootDir, "apps/ios/version.json"),
versionSource: "package",
versionSourcePath: path.join(rootDir, "package.json"),
versionXcconfigPath: path.join(rootDir, "apps/ios/Config/Version.xcconfig"),
});
});
it("rejects semver-only versions", () => {
it("rejects semver-only package versions", () => {
const rootDir = writeIosFixture({
version: "1.2.3",
packageVersion: "1.2.3",
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
});
expect(() => resolveIosVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
);
expect(() => resolveIosVersion(rootDir)).toThrow("Expected YYYY.M.PATCH");
});
it("rejects prerelease suffixes in the pinned iOS version file", () => {
it("rejects prerelease suffixes in explicit release versions", () => {
const rootDir = writeIosFixture({
version: "2026.4.6-beta.1",
packageVersion: "2026.4.6",
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
});
expect(() => resolveIosVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
expect(() => resolveIosVersion(rootDir, { releaseVersion: "2026.4.6-beta.1" })).toThrow(
"Expected release version like 2026.6.5",
);
});
it("rejects impossible pinned release versions", () => {
expect(() => normalizePinnedIosVersion("2026.13.6")).toThrow(
"Expected pinned release version like 2026.6.5",
"Expected release version like 2026.6.5",
);
expect(() => normalizePinnedIosVersion("2026.4.9007199254740993")).toThrow(
"Expected pinned release version like 2026.6.5",
"Expected release version like 2026.6.5",
);
});
});
@ -173,7 +201,6 @@ describe("gateway version normalization", () => {
it("reads and normalizes the root package version for iOS releases", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.7-beta.5",
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
});
@ -186,9 +213,9 @@ describe("gateway version normalization", () => {
});
describe("renderIosVersionXcconfig", () => {
it("renders checked-in defaults from the pinned iOS version", () => {
it("renders checked-in defaults from the package-derived iOS version", () => {
const rootDir = writeIosFixture({
version: "2026.4.8",
packageVersion: "2026.4.8",
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.8\n\nNotes.\n",
});
const version = resolveIosVersion(rootDir);
@ -202,7 +229,7 @@ describe("renderIosVersionXcconfig", () => {
describe("release note extraction", () => {
it("extracts exact pinned version sections first", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.6",
changelog: `# OpenClaw iOS Changelog
## Unreleased
@ -222,7 +249,7 @@ Draft notes.
it("falls back to Unreleased when the release section does not exist yet", () => {
const rootDir = writeIosFixture({
version: "2026.4.6",
packageVersion: "2026.4.6",
changelog: `# OpenClaw iOS Changelog
## Unreleased

View file

@ -1772,10 +1772,7 @@ describe("scripts/test-projects changed-target routing", () => {
"scripts/lib/android-version.ts",
["test/scripts/android-version.test.ts", "test/scripts/android-pin-version.test.ts"],
],
[
"scripts/lib/ios-version.ts",
["test/scripts/ios-version.test.ts", "test/scripts/ios-pin-version.test.ts"],
],
["scripts/lib/ios-version.ts", ["test/scripts/ios-version.test.ts"]],
[
".github/images/live-media-runner/Dockerfile",
["test/scripts/package-acceptance-workflow.test.ts"],