diff --git a/CHANGELOG.md b/CHANGELOG.md index 018812d44e96..dad5ff45a453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Changes + +- iOS: pin release versioning to an explicit CalVer in `apps/ios/version.json`, keep TestFlight iteration on the same short version until maintainers intentionally promote the next gateway version, and add the documented `pnpm ios:version:pin -- --from-gateway` workflow for release trains. (#63001) Thanks @ngutman. + ### Fixes - Slack/media: preserve bearer auth across same-origin `files.slack.com` redirects while still stripping it on cross-origin Slack CDN hops, so `url_private_download` image attachments load again. (#62960) Thanks @vincentkoc. diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md new file mode 100644 index 000000000000..b96d1f824622 --- /dev/null +++ b/apps/ios/CHANGELOG.md @@ -0,0 +1,13 @@ +# OpenClaw iOS Changelog + +## Unreleased + +### Added + +### Changed + +### Fixed + +## 2026.4.6 - 2026-04-06 + +First App Store release of OpenClaw for iPhone. Pair with your OpenClaw Gateway to use chat, voice, sharing, and device actions from iOS. diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index 19206f585f3f..7c58570ef404 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -1,8 +1,9 @@ // Shared iOS version defaults. -// Generated overrides live in build/Version.xcconfig (git-ignored). +// Source of truth: apps/ios/version.json +// Generated by scripts/ios-sync-versioning.ts. -OPENCLAW_GATEWAY_VERSION = 2026.4.9 -OPENCLAW_MARKETING_VERSION = 2026.4.9 -OPENCLAW_BUILD_VERSION = 2026040901 +OPENCLAW_IOS_VERSION = 2026.4.6 +OPENCLAW_MARKETING_VERSION = 2026.4.6 +OPENCLAW_BUILD_VERSION = 1 #include? "../build/Version.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index 26e039925eae..e7948bf42e2e 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -64,10 +64,14 @@ Release behavior: - Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`. - Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`. - The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`. -- Root `package.json.version` is the only version source for iOS. -- A root version like `2026.4.1-beta.1` becomes: - - `CFBundleShortVersionString = 2026.4.1` - - `CFBundleVersion = next TestFlight build number for 2026.4.1` +- `apps/ios/version.json` is the pinned iOS release version source. +- `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: + - `CFBundleShortVersionString = 2026.4.10` + - `CFBundleVersion = next TestFlight 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. +- See `apps/ios/VERSIONING.md` for the full workflow. Required env for beta builds: @@ -120,25 +124,74 @@ This should create `apps/ios/fastlane/.env` with the non-secret ASC variables wh export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com ``` -4. Upload the beta: +4. If you are starting a brand-new production release train, pin iOS to the current gateway version first: + +```bash +pnpm ios:version:pin -- --from-gateway +``` + +5. Upload the beta: ```bash pnpm ios:beta ``` -5. Expected behavior: - - Fastlane reads `package.json.version` +6. Expected behavior: + - Fastlane reads `apps/ios/version.json` + - verifies synced iOS versioning artifacts - resolves the next TestFlight build number for that short version - generates `apps/ios/build/BetaRelease.xcconfig` - archives `OpenClaw` - uploads the IPA to TestFlight -6. Expected outputs after a successful run: +7. Expected outputs after a successful run: - `apps/ios/build/beta/OpenClaw-.ipa` - `apps/ios/build/beta/OpenClaw-.app.dSYM.zip` - Fastlane log line like `Uploaded iOS beta: version= short= build=` -7. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo. +8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo. + +## iOS Versioning Workflow + +- Pinned iOS release version: `apps/ios/version.json` +- iOS-only changelog: `apps/ios/CHANGELOG.md` +- Generated checked-in artifacts: + - `apps/ios/Config/Version.xcconfig` + - `apps/ios/fastlane/metadata/en-US/release_notes.txt` +- Useful commands: + +```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 +``` + +Recommended flow: + +### TestFlight iteration on an existing train + +1. Keep `apps/ios/version.json` pinned to the current train version. +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:beta`. +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 +``` + +2. Update `apps/ios/CHANGELOG.md` for the new release as needed. +3. Run `pnpm ios:version:sync`. +4. Submit the first TestFlight build for that newly pinned version. +5. Keep iterating on that same version until the release candidate is ready. + +See `apps/ios/VERSIONING.md` for the detailed spec. ## APNs Expectations For Local/Manual Builds diff --git a/apps/ios/Sources/Device/DeviceInfoHelper.swift b/apps/ios/Sources/Device/DeviceInfoHelper.swift index 7067d70d7e40..28302c889ba1 100644 --- a/apps/ios/Sources/Device/DeviceInfoHelper.swift +++ b/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -50,9 +50,11 @@ enum DeviceInfoHelper { return trimmed.isEmpty ? "unknown" : trimmed } - /// App marketing version only, e.g. "2026.2.0" or "dev". + /// Canonical app version when present, otherwise the Apple marketing version. static func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + (Bundle.main.infoDictionary?["OpenClawCanonicalVersion"] as? String) + ?? (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + ?? "dev" } /// App build string, e.g. "123" or "". diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 5908021fad3b..94ef26d355c1 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -24,6 +24,8 @@ APPL CFBundleShortVersionString $(OPENCLAW_MARKETING_VERSION) + OpenClawCanonicalVersion + $(OPENCLAW_IOS_VERSION) CFBundleURLTypes diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md new file mode 100644 index 000000000000..6d7b7f49c80e --- /dev/null +++ b/apps/ios/VERSIONING.md @@ -0,0 +1,150 @@ +# OpenClaw iOS Versioning + +OpenClaw iOS uses a **pinned CalVer release version** instead of reading the current gateway version automatically on every build. + +## 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 +- keep Apple bundle fields valid for App Store Connect +- generate App Store release notes from an iOS-owned changelog + +## Version model + +The pinned iOS release version lives in `apps/ios/version.json`. + +Supported pinned format: + +- `YYYY.M.D` + +Examples: + +- `2026.4.6` +- `2026.4.10` + +The root gateway version in `package.json` may still be one of: + +- `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` + +## Apple bundle mapping + +Pinned iOS version `2026.4.10` maps to: + +- `CFBundleShortVersionString = 2026.4.10` +- `CFBundleVersion = numeric build number only` + +`CFBundleShortVersionString` stays fixed for a TestFlight train until you intentionally pin a newer iOS release version. + +## Source of truth and generated files + +### Source files + +- `apps/ios/version.json` + - pinned iOS release version +- `apps/ios/CHANGELOG.md` + - iOS-only changelog and release-note source +- `apps/ios/VERSIONING.md` + - workflow and constraints + +### Generated or derived files + +- `apps/ios/Config/Version.xcconfig` + - checked-in defaults derived from `apps/ios/version.json` +- `apps/ios/fastlane/metadata/en-US/release_notes.txt` + - generated from `apps/ios/CHANGELOG.md` +- `apps/ios/build/Version.xcconfig` + - local gitignored build override generated per build or beta prep + +## Tooling surfaces + +### Version parsing and sync tooling + +- `scripts/lib/ios-version.ts` + - validates pinned iOS CalVer + - normalizes gateway version -> pinned iOS CalVer + - renders checked-in xcconfig and release notes +- `scripts/ios-version.ts` + - CLI for JSON, shell, or single-field version reads +- `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 beta flow + +- `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-beta-prepare.sh` + - prepares beta signing and bundle settings against the pinned iOS version +- `apps/ios/fastlane/Fastfile` + - resolves version metadata from the pinned iOS helper + - increments TestFlight build numbers for the pinned short version + +## 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: + +1. exact pinned version, for example `## 2026.4.10` +2. `## Unreleased` + +Recommended workflow: + +- 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 + +## Common commands + +```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 +``` + +## 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 betas with the usual flow +4. let Fastlane increment only `CFBundleVersion` + +This keeps the TestFlight version stable while review is in flight. + +## New release promotion workflow + +When you want the next production iOS release to align with the current gateway release: + +1. pin iOS from the root gateway version: + +```bash +pnpm ios:version:pin -- --from-gateway +``` + +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. submit the first TestFlight build for that newly pinned version +6. keep iterating only by build number until the release candidate is ready +7. release that reviewed TestFlight build to production + +## Important invariant + +Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`. + +Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step. diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index a1276e7aa712..ca3e495ce8ae 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -95,35 +95,60 @@ def ios_root File.expand_path("..", __dir__) end -def normalize_release_version(raw_value) - version = raw_value.to_s.strip.sub(/\Av/, "") - UI.user_error!("Missing root package.json version.") unless env_present?(version) - unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i) - UI.user_error!("Invalid package.json version '#{raw_value}'. Expected YYYY.M.D or YYYY.M.D-beta.N.") +def read_ios_version_metadata + script_path = File.join(repo_root, "scripts", "ios-version.ts") + stdout, stderr, status = Open3.capture3( + "node", + "--import", + "tsx", + script_path, + "--json", + chdir: repo_root + ) + + unless status.success? + detail = stderr.to_s.strip + detail = stdout.to_s.strip if detail.empty? + UI.user_error!("Failed to read iOS version metadata: #{detail}") end - version -end + parsed = JSON.parse(stdout) + version = parsed["canonicalVersion"].to_s.strip + short_version = parsed["marketingVersion"].to_s.strip + if !env_present?(version) || !env_present?(short_version) + UI.user_error!("iOS version helper returned incomplete metadata.") + end -def read_root_package_version - package_json_path = File.join(repo_root, "package.json") - UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path) - - parsed = JSON.parse(File.read(package_json_path)) - normalize_release_version(parsed["version"]) + { + short_version: short_version, + version: version + } rescue JSON::ParserError => e - UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}") + UI.user_error!("Invalid JSON from iOS version helper: #{e.message}") end -def short_release_version(version) - normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "") +def sync_ios_versioning! + script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts") + stdout, stderr, status = Open3.capture3( + "node", + "--import", + "tsx", + script_path, + "--check", + 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}") end def shell_join(parts) Shellwords.join(parts.compact) end -def resolve_beta_build_number(api_key:, version:) +def resolve_beta_build_number(api_key:, short_version:) explicit = ENV["IOS_BETA_BUILD_NUMBER"] if env_present?(explicit) UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/) @@ -131,7 +156,6 @@ def resolve_beta_build_number(api_key:, version:) return explicit end - short_version = short_release_version(version) latest_build = latest_testflight_build_number( api_key: api_key, app_identifier: BETA_APP_IDENTIFIER, @@ -244,15 +268,18 @@ platform :ios do require_api_key = options[:require_api_key] == true needs_api_key = require_api_key || beta_build_number_needs_asc_auth? api_key = needs_api_key ? asc_api_key : nil - version = read_root_package_version - build_number = resolve_beta_build_number(api_key: api_key, version: version) + sync_ios_versioning! + version_metadata = read_ios_version_metadata + version = version_metadata[:version] + short_version = version_metadata[:short_version] + build_number = resolve_beta_build_number(api_key: api_key, short_version: short_version) beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number) { api_key: api_key, beta_xcconfig: beta_xcconfig, build_number: build_number, - short_version: short_release_version(version), + short_version: short_version, version: version } end @@ -286,6 +313,7 @@ platform :ios do desc "Upload App Store metadata (and optionally screenshots)" lane :metadata do + sync_ios_versioning! api_key = asc_api_key clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH") app_identifier = ENV["ASC_APP_IDENTIFIER"] diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 863d7d53d1d6..136282c3bc23 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -109,13 +109,19 @@ cd apps/ios fastlane ios auth_check ``` -4. Set the official/TestFlight relay URL before release: +4. If you are starting a brand-new production release train, pin iOS to the current gateway version: + +```bash +pnpm ios:version:pin -- --from-gateway +``` + +5. Set the official/TestFlight relay URL before release: ```bash export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com ``` -5. Upload: +6. Upload: ```bash pnpm ios:beta @@ -129,9 +135,15 @@ Quick verification after upload: Versioning rules: -- Root `package.json.version` is the single source of truth for iOS -- Use `YYYY.M.D` for stable versions and `YYYY.M.D-beta.N` for beta versions -- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D` +- `apps/ios/version.json` is the pinned iOS release version source +- `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` - Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version +- Run `pnpm ios:version:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md` +- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync - The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving - Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched +- See `apps/ios/VERSIONING.md` for the detailed workflow diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 07e7824311f9..7c408ccc3983 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -36,6 +36,9 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`. ## Notes - Locale files live under `metadata/en-US/`. +- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`. +- 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`. - `privacy_url.txt` is set to `https://openclaw.ai/privacy`. - If app lookup fails in `deliver`, set one of: - `ASC_APP_IDENTIFIER` (bundle ID) diff --git a/apps/ios/project.yml b/apps/ios/project.yml index f851350339ff..ea6ad0d09c5a 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -119,6 +119,7 @@ targets: CFBundleURLSchemes: - openclaw CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)" + OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)" CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)" UILaunchScreen: {} UIApplicationSceneManifest: diff --git a/apps/ios/version.json b/apps/ios/version.json new file mode 100644 index 000000000000..52c3cfc28062 --- /dev/null +++ b/apps/ios/version.json @@ -0,0 +1,3 @@ +{ + "version": "2026.4.6" +} diff --git a/package.json b/package.json index 9a952adcc0d1..34a20c3c5bc1 100644 --- a/package.json +++ b/package.json @@ -1135,6 +1135,10 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", + "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", "lint": "node scripts/run-oxlint.mjs", "lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs", "lint:all": "pnpm lint && pnpm lint:swift", diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh index 9dd0d891c9e4..44cb531f8251 100755 --- a/scripts/ios-beta-prepare.sh +++ b/scripts/ios-beta-prepare.sh @@ -8,7 +8,7 @@ Usage: scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID] Prepares local beta-release inputs without touching local signing overrides: -- reads package.json.version and writes apps/ios/build/Version.xcconfig +- reads apps/ios/version.json and writes apps/ios/build/Version.xcconfig - writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs - configures the beta build for relay-backed APNs registration - regenerates apps/ios/OpenClaw.xcodeproj via xcodegen @@ -21,12 +21,14 @@ BUILD_DIR="${IOS_DIR}/build" BETA_XCCONFIG="${IOS_DIR}/build/BetaRelease.xcconfig" TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh" VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh" +IOS_VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts" +VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts" BUILD_NUMBER="" TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-}}" PUSH_RELAY_BASE_URL_XCCONFIG="" -PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)" +IOS_VERSION="" prepare_build_dir() { if [[ -L "${BUILD_DIR}" ]]; then @@ -132,6 +134,16 @@ PUSH_RELAY_BASE_URL_XCCONFIG="$( prepare_build_dir +( + cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check +) + +IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --field canonicalVersion)" +if [[ -z "${IOS_VERSION}" ]]; then + echo "Unable to resolve iOS version from ${ROOT_DIR}/apps/ios/version.json." >&2 + exit 1 +fi + ( bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}" ) @@ -161,5 +173,5 @@ EOF xcodegen generate ) -echo "Prepared iOS beta release: version=${PACKAGE_VERSION} build=${BUILD_NUMBER} team=${TEAM_ID}" +echo "Prepared iOS beta release: version=${IOS_VERSION} build=${BUILD_NUMBER} team=${TEAM_ID}" echo "XCODE_XCCONFIG_FILE=${BETA_XCCONFIG}" diff --git a/scripts/ios-pin-version.ts b/scripts/ios-pin-version.ts new file mode 100644 index 000000000000..2669f815d30a --- /dev/null +++ b/scripts/ios-pin-version.ts @@ -0,0 +1,148 @@ +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 = argv[index + 1] ?? null; + index += 1; + break; + } + case "--no-sync": { + sync = false; + break; + } + case "--root": { + const value = argv[index + 1]; + if (!value) { + throw new Error("Missing value for --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 }; +} + +export function pinIosVersion(params: CliOptions): PinIosVersionResult { + const rootDir = path.resolve(params.rootDir); + let previousVersion: string | null = 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-sync-versioning.ts b/scripts/ios-sync-versioning.ts new file mode 100644 index 000000000000..9ae669fa59ce --- /dev/null +++ b/scripts/ios-sync-versioning.ts @@ -0,0 +1,57 @@ +import path from "node:path"; +import { syncIosVersioning } from "./lib/ios-version.ts"; + +type Mode = "check" | "write"; + +export function parseArgs(argv: string[]): { mode: Mode; rootDir: string } { + let mode: Mode = "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": { + const value = argv[index + 1]; + if (!value) { + throw new Error("Missing value for --root."); + } + rootDir = path.resolve(value); + index += 1; + break; + } + case "-h": + case "--help": { + console.log( + "Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--root dir]", + ); + process.exit(0); + } + default: { + throw new Error(`Unknown argument: ${arg}`); + } + } + } + + return { mode, rootDir }; +} + +const options = parseArgs(process.argv.slice(2)); +const result = syncIosVersioning({ mode: options.mode, rootDir: options.rootDir }); + +if (options.mode === "check") { + process.stdout.write("iOS versioning artifacts are up to date.\n"); +} else if (result.updatedPaths.length === 0) { + process.stdout.write("iOS versioning artifacts already up to date.\n"); +} else { + process.stdout.write( + `Updated iOS versioning artifacts:\n- ${result.updatedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`, + ); +} diff --git a/scripts/ios-version.ts b/scripts/ios-version.ts new file mode 100644 index 000000000000..ed55d6cd2264 --- /dev/null +++ b/scripts/ios-version.ts @@ -0,0 +1,78 @@ +import path from "node:path"; +import { resolveIosVersion } from "./lib/ios-version.ts"; + +type CliOptions = { + field: string | null; + format: "json" | "shell"; + rootDir: string; +}; + +function parseArgs(argv: string[]): CliOptions { + let field: string | null = null; + let format: "json" | "shell" = "json"; + let rootDir = path.resolve("."); + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--field": { + field = argv[index + 1] ?? null; + index += 1; + break; + } + case "--json": { + format = "json"; + break; + } + case "--shell": { + format = "shell"; + break; + } + case "--root": { + const value = argv[index + 1]; + if (!value) { + throw new Error("Missing value for --root."); + } + rootDir = path.resolve(value); + index += 1; + break; + } + case "-h": + case "--help": { + console.log( + `Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--root dir]\n`, + ); + process.exit(0); + } + default: { + throw new Error(`Unknown argument: ${arg}`); + } + } + } + + return { field, format, rootDir }; +} + +const options = parseArgs(process.argv.slice(2)); +const version = resolveIosVersion(options.rootDir); + +if (options.field) { + const value = version[options.field as keyof typeof version]; + if (value === undefined) { + throw new Error(`Unknown iOS version field '${options.field}'.`); + } + process.stdout.write(`${String(value)}\n`); + process.exit(0); +} + +if (options.format === "shell") { + process.stdout.write( + [ + `OPENCLAW_IOS_VERSION=${version.canonicalVersion}`, + `OPENCLAW_MARKETING_VERSION=${version.marketingVersion}`, + `OPENCLAW_BUILD_VERSION=${version.buildVersion}`, + ].join("\n") + "\n", + ); +} else { + process.stdout.write(`${JSON.stringify(version, null, 2)}\n`); +} diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh index e38044814bfe..847078ef90d0 100755 --- a/scripts/ios-write-version-xcconfig.sh +++ b/scripts/ios-write-version-xcconfig.sh @@ -6,8 +6,8 @@ usage() { Usage: scripts/ios-write-version-xcconfig.sh [--build-number 7] -Writes apps/ios/build/Version.xcconfig from root package.json.version: -- OPENCLAW_GATEWAY_VERSION = exact package.json version +Writes apps/ios/build/Version.xcconfig from apps/ios/version.json: +- 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 EOF @@ -17,7 +17,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IOS_DIR="${ROOT_DIR}/apps/ios" BUILD_DIR="${IOS_DIR}/build" VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig" -PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)" +VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts" +IOS_VERSION="" +MARKETING_VERSION="" BUILD_NUMBER="" prepare_build_dir() { @@ -64,16 +66,19 @@ while [[ $# -gt 0 ]]; do esac done -PACKAGE_VERSION="$(printf '%s' "${PACKAGE_VERSION}" | tr -d '\n' | xargs)" -if [[ -z "${PACKAGE_VERSION}" ]]; then - echo "Unable to read package.json.version from ${ROOT_DIR}/package.json." >&2 - exit 1 -fi +while IFS='=' read -r key value; do + case "${key}" in + OPENCLAW_IOS_VERSION) + IOS_VERSION="${value}" + ;; + OPENCLAW_MARKETING_VERSION) + MARKETING_VERSION="${value}" + ;; + esac +done < <(cd "${ROOT_DIR}" && node --import tsx "${VERSION_HELPER}" --shell) -if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then - MARKETING_VERSION="${BASH_REMATCH[1]}" -else - echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.13 or 2026.3.13-beta.1." >&2 +if [[ -z "${IOS_VERSION}" || -z "${MARKETING_VERSION}" ]]; then + echo "Unable to resolve iOS version metadata from ${ROOT_DIR}/apps/ios/version.json." >&2 exit 1 fi @@ -91,9 +96,9 @@ prepare_build_dir write_generated_file "${VERSION_XCCONFIG}" < matchChangelogHeading(line, heading)); + if (startIndex === -1) { + return null; + } + + let endIndex = lines.length; + for (let index = startIndex + 1; index < lines.length; index += 1) { + if (lines[index]?.startsWith("## ")) { + endIndex = index; + break; + } + } + + const body = lines + .slice(startIndex + 1, endIndex) + .join("\n") + .trim(); + return body || null; +} + +export function renderIosReleaseNotes( + version: ResolvedIosVersion, + changelogContent: string, +): string { + const candidateHeadings = [version.canonicalVersion, "Unreleased"]; + + for (const heading of candidateHeadings) { + const body = extractChangelogSection(changelogContent, heading); + if (body) { + return `${body}\n`; + } + } + + throw new Error( + `Unable to find iOS changelog notes for ${version.canonicalVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`, + ); +} + +function syncFile(params: { + mode: SyncIosVersioningMode; + path: string; + nextContent: string; + label: string; +}): boolean { + const nextContent = normalizeTrailingNewline(params.nextContent); + const currentContent = readFileSync(params.path, "utf8"); + if (currentContent === nextContent) { + return false; + } + + if (params.mode === "check") { + throw new Error(`${params.label} is stale: ${path.relative(process.cwd(), params.path)}`); + } + + writeFileSync(params.path, nextContent, "utf8"); + return true; +} + +export function syncIosVersioning(params?: { mode?: SyncIosVersioningMode; rootDir?: string }): { + updatedPaths: string[]; +} { + const mode = params?.mode ?? "write"; + const rootDir = path.resolve(params?.rootDir ?? "."); + const version = resolveIosVersion(rootDir); + const changelogContent = readFileSync(version.changelogPath, "utf8"); + const nextVersionXcconfig = renderIosVersionXcconfig(version); + const nextReleaseNotes = renderIosReleaseNotes(version, changelogContent); + const updatedPaths: string[] = []; + + if ( + syncFile({ + mode, + path: version.versionXcconfigPath, + nextContent: nextVersionXcconfig, + label: "iOS version xcconfig", + }) + ) { + updatedPaths.push(version.versionXcconfigPath); + } + + if ( + syncFile({ + mode, + path: version.releaseNotesPath, + nextContent: nextReleaseNotes, + label: "iOS release notes", + }) + ) { + updatedPaths.push(version.releaseNotesPath); + } + + return { updatedPaths }; +} diff --git a/test/scripts/ios-pin-version.test.ts b/test/scripts/ios-pin-version.test.ts new file mode 100644 index 000000000000..b4f206fcfed6 --- /dev/null +++ b/test/scripts/ios-pin-version.test.ts @@ -0,0 +1,149 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { pinIosVersion, parseArgs } from "../../scripts/ios-pin-version.ts"; +import { resolveIosVersion } from "../../scripts/lib/ios-version.ts"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const tempDirs: string[] = []; + +function writeIosFixture(params: { + version: string; + changelog: string; + packageVersion?: string; + releaseNotes?: string; + versionXcconfig?: string; +}) { + const rootDir = makeTempDir(tempDirs, "openclaw-ios-pin-"); + fs.mkdirSync(path.join(rootDir, "apps", "ios", "Config"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US"), { + recursive: true, + }); + 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`, + "utf8", + ); + fs.writeFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), params.changelog, "utf8"); + fs.writeFileSync( + path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), + params.versionXcconfig ?? "", + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US", "release_notes.txt"), + params.releaseNotes ?? "", + "utf8", + ); + return rootDir; +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +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 ", + ); + }); +}); + +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. +`, + }); + + 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. +`, + }); + + 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", + }); + + 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-version.test.ts b/test/scripts/ios-version.test.ts new file mode 100644 index 000000000000..3fe8e7d0c608 --- /dev/null +++ b/test/scripts/ios-version.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + extractChangelogSection, + normalizeGatewayVersionToPinnedIosVersion, + renderIosReleaseNotes, + renderIosVersionXcconfig, + resolveGatewayVersionForIosRelease, + resolveIosVersion, +} from "../../scripts/lib/ios-version.ts"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const tempDirs: string[] = []; + +function writeIosFixture(params: { version: string; changelog: string; packageVersion?: string }) { + const rootDir = makeTempDir(tempDirs, "openclaw-ios-version-"); + fs.mkdirSync(path.join(rootDir, "apps", "ios", "Config"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US"), { + recursive: true, + }); + 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`, + "utf8", + ); + fs.writeFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), params.changelog, "utf8"); + fs.writeFileSync(path.join(rootDir, "apps", "ios", "Config", "Version.xcconfig"), "", "utf8"); + fs.writeFileSync( + path.join(rootDir, "apps", "ios", "fastlane", "metadata", "en-US", "release_notes.txt"), + "", + "utf8", + ); + return rootDir; +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("resolveIosVersion", () => { + it("parses pinned CalVer versions and derives Apple marketing fields", () => { + const rootDir = writeIosFixture({ + version: "2026.4.6", + changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.6\n\nStable notes.\n", + }); + + expect(resolveIosVersion(rootDir)).toMatchObject({ + canonicalVersion: "2026.4.6", + marketingVersion: "2026.4.6", + buildVersion: "1", + }); + }); + + it("rejects semver-only versions", () => { + const rootDir = writeIosFixture({ + version: "1.2.3", + changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n", + }); + + expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned CalVer like 2026.4.6"); + }); + + it("rejects prerelease suffixes in the pinned iOS version file", () => { + const rootDir = writeIosFixture({ + version: "2026.4.6-beta.1", + changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n", + }); + + expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned CalVer like 2026.4.6"); + }); +}); + +describe("gateway version normalization", () => { + it("keeps stable gateway CalVer values", () => { + expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6")).toBe("2026.4.6"); + }); + + it("strips beta suffixes when pinning from gateway version", () => { + expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-beta.2")).toBe("2026.4.6"); + }); + + it("strips fallback correction suffixes when pinning from gateway version", () => { + expect(normalizeGatewayVersionToPinnedIosVersion("2026.4.6-3")).toBe("2026.4.6"); + }); + + 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", + }); + + expect(resolveGatewayVersionForIosRelease(rootDir)).toEqual({ + packageVersion: "2026.4.7-beta.5", + pinnedIosVersion: "2026.4.7", + }); + }); +}); + +describe("renderIosVersionXcconfig", () => { + it("renders checked-in defaults from the pinned iOS version", () => { + const rootDir = writeIosFixture({ + version: "2026.4.8", + changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.8\n\nNotes.\n", + }); + const version = resolveIosVersion(rootDir); + + expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_IOS_VERSION = 2026.4.8"); + expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_MARKETING_VERSION = 2026.4.8"); + expect(renderIosVersionXcconfig(version)).toContain("OPENCLAW_BUILD_VERSION = 1"); + }); +}); + +describe("release note extraction", () => { + it("extracts exact pinned version sections first", () => { + const rootDir = writeIosFixture({ + version: "2026.4.6", + changelog: `# OpenClaw iOS Changelog + +## Unreleased + +Draft notes. + +## 2026.4.6 + +- Exact release notes. +`, + }); + const version = resolveIosVersion(rootDir); + const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8"); + + expect(renderIosReleaseNotes(version, changelog)).toBe("- Exact release notes.\n"); + }); + + it("falls back to Unreleased when the release section does not exist yet", () => { + const rootDir = writeIosFixture({ + version: "2026.4.6", + changelog: `# OpenClaw iOS Changelog + +## Unreleased + +### Added + +- New iOS feature. +`, + }); + const version = resolveIosVersion(rootDir); + const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8"); + + expect(renderIosReleaseNotes(version, changelog)).toContain("### Added"); + expect(renderIosReleaseNotes(version, changelog)).toContain("- New iOS feature."); + }); + + it("extracts markdown bodies without the version heading", () => { + expect( + extractChangelogSection( + `# OpenClaw iOS Changelog\n\n## 2026.4.6 - 2026-04-06\n\nLine one.\n\n## 2026.4.5\n`, + "2026.4.6", + ), + ).toBe("Line one."); + }); +});