feat(ios): pin calver release versioning (#63001)
* feat(ios): decouple app versioning from gateway * feat(ios): pin calver release versioning * refactor(ios): drop prerelease version helper fields * docs(changelog): note pinned ios release versioning (#63001) (thanks @ngutman)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
+62
-9
@@ -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-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<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
|
||||
|
||||
|
||||
@@ -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 "".
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -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 `## <pinned version>` 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.
|
||||
+49
-21
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
|
||||
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `ASC_APP_IDENTIFIER` (bundle ID)
|
||||
|
||||
@@ -119,6 +119,7 @@ targets:
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.6"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 <YYYY.M.D>) [--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 <YYYY.M.D>.");
|
||||
}
|
||||
|
||||
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<number> {
|
||||
try {
|
||||
const options = parseArgs(argv);
|
||||
const result = pinIosVersion(options);
|
||||
const sourceText = result.packageVersion
|
||||
? ` from gateway version ${result.packageVersion}`
|
||||
: "";
|
||||
process.stdout.write(`Pinned iOS version to ${result.nextVersion}${sourceText}.\n`);
|
||||
if (result.previousVersion && result.previousVersion !== result.nextVersion) {
|
||||
process.stdout.write(`Previous pinned iOS version: ${result.previousVersion}.\n`);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Updated version manifest: ${path.relative(process.cwd(), result.versionFilePath)}\n`,
|
||||
);
|
||||
if (options.sync) {
|
||||
if (result.syncedPaths.length === 0) {
|
||||
process.stdout.write("iOS versioning artifacts already up to date.\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Updated iOS versioning artifacts:\n- ${result.syncedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const exitCode = await main(process.argv.slice(2));
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}" <<EOF
|
||||
// Auto-generated by scripts/ios-write-version-xcconfig.sh.
|
||||
// Local version override; do not commit.
|
||||
OPENCLAW_GATEWAY_VERSION = ${PACKAGE_VERSION}
|
||||
OPENCLAW_IOS_VERSION = ${IOS_VERSION}
|
||||
OPENCLAW_MARKETING_VERSION = ${MARKETING_VERSION}
|
||||
OPENCLAW_BUILD_VERSION = ${BUILD_NUMBER}
|
||||
EOF
|
||||
|
||||
echo "Prepared iOS version settings: gateway=${PACKAGE_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER}"
|
||||
echo "Prepared iOS version settings: ios=${IOS_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER}"
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const IOS_VERSION_FILE = "apps/ios/version.json";
|
||||
export const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md";
|
||||
export const IOS_VERSION_XCCONFIG_FILE = "apps/ios/Config/Version.xcconfig";
|
||||
export const IOS_RELEASE_NOTES_FILE = "apps/ios/fastlane/metadata/en-US/release_notes.txt";
|
||||
|
||||
const PINNED_IOS_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})$/u;
|
||||
const GATEWAY_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})(?:-(?:beta\.\d+|\d+))?$/u;
|
||||
|
||||
export type IosVersionManifest = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type ResolvedIosVersion = {
|
||||
canonicalVersion: string;
|
||||
marketingVersion: string;
|
||||
buildVersion: string;
|
||||
versionFilePath: string;
|
||||
changelogPath: string;
|
||||
versionXcconfigPath: string;
|
||||
releaseNotesPath: string;
|
||||
};
|
||||
|
||||
export type SyncIosVersioningMode = "check" | "write";
|
||||
|
||||
function normalizeTrailingNewline(value: string): string {
|
||||
return value.endsWith("\n") ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
export function normalizePinnedIosVersion(rawVersion: string): string {
|
||||
const trimmed = rawVersion.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Missing iOS version in ${IOS_VERSION_FILE}.`);
|
||||
}
|
||||
|
||||
const match = PINNED_IOS_VERSION_PATTERN.exec(trimmed);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid iOS version '${rawVersion}'. Expected pinned CalVer like 2026.4.6.`);
|
||||
}
|
||||
|
||||
return match[1] ?? trimmed;
|
||||
}
|
||||
|
||||
export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): string {
|
||||
const trimmed = rawVersion.trim().replace(/^v/u, "");
|
||||
if (!trimmed) {
|
||||
throw new Error("Missing root package.json version.");
|
||||
}
|
||||
|
||||
const match = GATEWAY_VERSION_PATTERN.exec(trimmed);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid gateway version '${rawVersion}'. Expected YYYY.M.D, YYYY.M.D-beta.N, or YYYY.M.D-N.`,
|
||||
);
|
||||
}
|
||||
|
||||
return match[1] ?? trimmed;
|
||||
}
|
||||
|
||||
export function readRootPackageVersion(rootDir = path.resolve(".")): string {
|
||||
const packageJsonPath = path.join(rootDir, "package.json");
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
|
||||
if (!version) {
|
||||
throw new Error(`Missing package.json version in ${packageJsonPath}.`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
export function resolveGatewayVersionForIosRelease(rootDir = path.resolve(".")): {
|
||||
packageVersion: string;
|
||||
pinnedIosVersion: string;
|
||||
} {
|
||||
const packageVersion = readRootPackageVersion(rootDir);
|
||||
return {
|
||||
packageVersion,
|
||||
pinnedIosVersion: normalizeGatewayVersionToPinnedIosVersion(packageVersion),
|
||||
};
|
||||
}
|
||||
|
||||
export 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);
|
||||
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 ?? "");
|
||||
|
||||
return {
|
||||
canonicalVersion,
|
||||
marketingVersion: canonicalVersion,
|
||||
buildVersion: "1",
|
||||
versionFilePath,
|
||||
changelogPath,
|
||||
versionXcconfigPath,
|
||||
releaseNotesPath,
|
||||
};
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function matchChangelogHeading(line: string, heading: string): boolean {
|
||||
const normalized = line.trim();
|
||||
return normalized === `## ${heading}` || normalized.startsWith(`## ${heading} - `);
|
||||
}
|
||||
|
||||
export function extractChangelogSection(content: string, heading: string): string | null {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const startIndex = lines.findIndex((line) => 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 };
|
||||
}
|
||||
@@ -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 <YYYY.M.D>",
|
||||
);
|
||||
expect(() => parseArgs(["--from-gateway", "--version", "2026.4.7"])).toThrow(
|
||||
"Choose exactly one of --from-gateway or --version <YYYY.M.D>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user