From 080481fec95a0c987f39bd784f85a335e793351b Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:04:57 -0500 Subject: [PATCH] Require explicit iOS release versions --- apps/ios/AGENTS.md | 1 + apps/ios/CHANGELOG.md | 6 + apps/ios/Config/Version.xcconfig | 6 +- apps/ios/README.md | 55 +++--- apps/ios/VERSIONING.md | 169 +++++++++--------- apps/ios/fastlane/Fastfile | 118 ++++++++---- apps/ios/fastlane/SETUP.md | 22 +-- apps/ios/fastlane/metadata/README.md | 12 +- .../fastlane/metadata/en-US/release_notes.txt | 2 +- apps/ios/version.json | 3 - package.json | 1 - scripts/changed-lanes.mjs | 1 - scripts/check-release-metadata-only.mjs | 1 - scripts/ios-pin-version.ts | 154 ---------------- scripts/ios-release-archive.sh | 23 ++- scripts/ios-release-prepare.sh | 26 ++- scripts/ios-release-upload.sh | 23 ++- scripts/ios-sync-versioning.ts | 8 +- scripts/ios-version.ts | 4 +- scripts/ios-write-version-xcconfig.sh | 31 +++- scripts/lib/ios-version.ts | 59 +++--- scripts/lib/version-script-args.ts | 86 +++++---- scripts/test-projects.test-support.mjs | 5 +- src/scripts/test-projects.test.ts | 1 - test/scripts/changed-lanes.test.ts | 1 - .../check-release-metadata-only.test.ts | 4 +- test/scripts/ios-pin-version.test.ts | 125 ------------- .../ios-release-fastlane-gates.test.ts | 31 +++- test/scripts/ios-release-prepare.test.ts | 2 +- test/scripts/ios-release-wrapper-args.test.ts | 39 +++- test/scripts/ios-version.test-support.ts | 9 +- test/scripts/ios-version.test.ts | 67 ++++--- test/scripts/test-projects.test.ts | 5 +- 33 files changed, 523 insertions(+), 577 deletions(-) delete mode 100644 apps/ios/version.json delete mode 100644 scripts/ios-pin-version.ts delete mode 100644 test/scripts/ios-pin-version.test.ts diff --git a/apps/ios/AGENTS.md b/apps/ios/AGENTS.md index 6a34b53238a..9f6de38b1d9 100644 --- a/apps/ios/AGENTS.md +++ b/apps/ios/AGENTS.md @@ -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 ` and `--build-number ` 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. diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index 6ebc8755926..84ddfca3a5f 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -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. diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index f0b22d4d73e..37bf698cd3d 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -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" diff --git a/apps/ios/README.md b/apps/ios/README.md index 77debe8a157..8741935c98f 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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 `. +4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version `. +5. Keep iterating on that same explicit version until the release candidate is ready. See `apps/ios/VERSIONING.md` for the detailed spec. diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index a525c47735d..3ea9f2913c4 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -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 `## ` 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 `## ` 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 `## ` or `## Unreleased` +3. run `pnpm ios:version:sync -- --version ` +4. check App Store Connect for the latest build number when needed +5. upload another build with `pnpm ios:release:upload -- --version --build-number ` -This keeps the 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/- 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 diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 748fa185552..50df8c00001 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -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 `.") 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 diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 2e11913ce59..8ce9c1e3eef 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -95,7 +95,7 @@ If you pass `--build-number` to `pnpm ios:release:archive`, the local archive pa Archive locally without upload: ```bash -pnpm ios:release:archive +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 ` 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 diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 98e1bc27738..1c914c14e01 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -7,7 +7,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata ```bash cd apps/ios APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \ -DELIVER_METADATA=1 fastlane ios metadata +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//`, 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 `. - `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded. -- Release notes resolve from `## ` first, then fall back to `## Unreleased` while 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 `## ` 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 `. - The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review. - `privacy_url.txt` is set to `https://openclaw.ai/privacy`. - If app lookup fails in `deliver`, set one of: diff --git a/apps/ios/fastlane/metadata/en-US/release_notes.txt b/apps/ios/fastlane/metadata/en-US/release_notes.txt index 1c6aa3675a9..99279328bfc 100644 --- a/apps/ios/fastlane/metadata/en-US/release_notes.txt +++ b/apps/ios/fastlane/metadata/en-US/release_notes.txt @@ -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. diff --git a/apps/ios/version.json b/apps/ios/version.json deleted file mode 100644 index 0d582710f6d..00000000000 --- a/apps/ios/version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "2026.6.10" -} diff --git a/package.json b/package.json index edc60511b4e..80410daf462 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index f6e174f7fec..e972eeea43a 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -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", diff --git a/scripts/check-release-metadata-only.mjs b/scripts/check-release-metadata-only.mjs index c53795e30b0..30d01d0ffef 100644 --- a/scripts/check-release-metadata-only.mjs +++ b/scripts/check-release-metadata-only.mjs @@ -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", ]); diff --git a/scripts/ios-pin-version.ts b/scripts/ios-pin-version.ts deleted file mode 100644 index 5ce4bb089cd..00000000000 --- a/scripts/ios-pin-version.ts +++ /dev/null @@ -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 ) [--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 ."); - } - - 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 { - 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); - } -} diff --git a/scripts/ios-release-archive.sh b/scripts/ios-release-archive.sh index 24edc097443..2ba48aed492 100755 --- a/scripts/ios-release-archive.sh +++ b/scripts/ios-release-archive.sh @@ -4,13 +4,14 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-release-archive.sh [--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[@]}" ) diff --git a/scripts/ios-release-prepare.sh b/scripts/ios-release-prepare.sh index 70ead19b640..018bcad171c 100755 --- a/scripts/ios-release-prepare.sh +++ b/scripts/ios-release-prepare.sh @@ -4,10 +4,10 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/ios-release-prepare.sh --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}" <&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[@]}" ) diff --git a/scripts/ios-sync-versioning.ts b/scripts/ios-sync-versioning.ts index cf3f201bf26..ca6b3a29570 100644 --- a/scripts/ios-sync-versioning.ts +++ b/scripts/ios-sync-versioning.ts @@ -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"); diff --git a/scripts/ios-version.ts b/scripts/ios-version.ts index fd315af6c3a..f1de18a3539 100644 --- a/scripts/ios-version.ts +++ b/scripts/ios-version.ts @@ -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]; diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh index 847078ef90d..2734ffd9f76 100755 --- a/scripts/ios-write-version-xcconfig.sh +++ b/scripts/ios-write-version-xcconfig.sh @@ -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 diff --git a/scripts/lib/ios-version.ts b/scripts/lib/ios-version.ts index 0184c686877..47b98c8b38c 100644 --- a/scripts/lib/ios-version.ts +++ b/scripts/lib/ios-version.ts @@ -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); diff --git a/scripts/lib/version-script-args.ts b/scripts/lib/version-script-args.ts index a9ea4f438b7..9ec5d844667 100644 --- a/scripts/lib/version-script-args.ts +++ b/scripts/lib/version-script-args.ts @@ -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 { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 184cd708166..72c23fdb57d 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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"]], diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 60d116b6a6a..a622b237dc5 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -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", diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index b0405970efd..b57cc54c6bf 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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", diff --git a/test/scripts/check-release-metadata-only.test.ts b/test/scripts/check-release-metadata-only.test.ts index da057c5f375..6568d159953 100644 --- a/test/scripts/check-release-metadata-only.test.ts +++ b/test/scripts/check-release-metadata-only.test.ts @@ -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"], }); }); diff --git a/test/scripts/ios-pin-version.test.ts b/test/scripts/ios-pin-version.test.ts deleted file mode 100644 index cb9ea8be6c0..00000000000 --- a/test/scripts/ios-pin-version.test.ts +++ /dev/null @@ -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 ", - ); - expect(() => parseArgs(["--from-gateway", "--version", "2026.4.7"])).toThrow( - "Choose exactly one of --from-gateway or --version ", - ); - }); - - 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"); - }); -}); diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index c545bebdc39..ab781f7d0ee 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -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"); diff --git a/test/scripts/ios-release-prepare.test.ts b/test/scripts/ios-release-prepare.test.ts index ae0d47261e8..d3b60df5b45 100644 --- a/test/scripts/ios-release-prepare.test.ts +++ b/test/scripts/ios-release-prepare.test.ts @@ -38,7 +38,7 @@ function runPrepare(extraArgs: string[]): { ok: boolean; stdout: string; stderr: describe("scripts/ios-release-prepare.sh", () => { it("rejects non-canonical signing teams before generating release inputs", () => { - const result = runPrepare(["--build-number", "7"]); + const result = runPrepare(["--version", "2026.6.11", "--build-number", "7"]); expect(result.ok).toBe(false); expect(result.stderr).toContain( diff --git a/test/scripts/ios-release-wrapper-args.test.ts b/test/scripts/ios-release-wrapper-args.test.ts index 9f46e6e8b26..ccaf95c8fff 100644 --- a/test/scripts/ios-release-wrapper-args.test.ts +++ b/test/scripts/ios-release-wrapper-args.test.ts @@ -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", diff --git a/test/scripts/ios-version.test-support.ts b/test/scripts/ios-version.test-support.ts index 640c40583a0..e3cfd38d7db 100644 --- a/test/scripts/ios-version.test-support.ts +++ b/test/scripts/ios-version.test-support.ts @@ -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"); diff --git a/test/scripts/ios-version.test.ts b/test/scripts/ios-version.test.ts index 0c93dcd3dae..c633c946ae1 100644 --- a/test/scripts/ios-version.test.ts +++ b/test/scripts/ios-version.test.ts @@ -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 diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 067fc129f57..0d35552b1b4 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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"],