mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ios): support App Store release revisions
This commit is contained in:
+5
-4
@@ -29,10 +29,11 @@ Root rules still apply. This file adds the iOS release guardrails.
|
||||
## App Store Releases
|
||||
|
||||
- Agent-driven App Store uploads must use only `pnpm ios:release:upload`.
|
||||
- App Store uploads must include explicit release intent: `pnpm ios:release:upload -- --version <YYYY.M.D>` and `--build-number <n>` when a specific build has been chosen.
|
||||
- Release version selection is deterministic. If the user supplies a version, use it. Otherwise resolve the current gateway version with `node --import tsx scripts/ios-version.ts --field canonicalVersion` and pass that value explicitly to `pnpm ios:release:upload`.
|
||||
- Never derive a release version from the current date, `## Unreleased`, existing mobile-release refs, or App Store Connect builds.
|
||||
- Existing builds for the current gateway version mean upload the next build to that same release train. Start a different release train only when the user explicitly supplies its version.
|
||||
- App Store uploads must include explicit release intent: `pnpm ios:release:upload -- --version <YYYY.M.D> --revision <0-99>` and `--build-number <n>` when a specific build has been chosen.
|
||||
- `--version` is the gateway version. `--revision` is the public iOS release ordinal encoded into `CFBundleShortVersionString`; never pass the encoded App Store version through `--version`.
|
||||
- Release selection is deterministic. If the user does not supply a gateway version, resolve it with `node --import tsx scripts/ios-version.ts --field canonicalVersion`. The user must explicitly supply the App Store revision; do not infer it.
|
||||
- Never derive a gateway version or App Store revision from the current date, `## Unreleased`, mobile-release refs, App Store Connect builds, or existing version records.
|
||||
- Existing builds for the same gateway version and App Store revision mean upload the next build to that exact App Store version. Start a different App Store revision only when the user explicitly supplies it.
|
||||
- If `pnpm ios:release:upload` exits non-zero, stop immediately and report the failing step.
|
||||
- After a failed `pnpm ios:release:upload`, do not continue with `pnpm ios:release:archive`, `asc builds upload`, `asc release stage`, `asc publish appstore`, `asc review submit`, direct Fastlane lanes, or any manual App Store Connect mutation command.
|
||||
- Do not submit an iOS App Store version for App Review. App Review submission stays manual unless the user explicitly asks to submit a specific already-prepared version after the failed state has been reported.
|
||||
|
||||
+25
-23
@@ -71,13 +71,14 @@ Release behavior:
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
|
||||
- Before submitting a HealthKit-enabled build, the release owner must update the public privacy policy and App Store Connect privacy details for the Health & Fitness aggregates shared with the user's configured AI provider.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Release uploads require an explicit CalVer version passed with `--version`.
|
||||
- Release uploads require an explicit gateway CalVer passed with `--version` and an explicit App Store revision passed with `--revision`.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
- The release version must use CalVer like `2026.4.10`.
|
||||
- That release value becomes:
|
||||
- `CFBundleShortVersionString = 2026.4.10`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
|
||||
- Local defaults derive from root `package.json`; App Store uploads use the explicit `--version` value.
|
||||
- The gateway version must use CalVer like `2026.7.2`.
|
||||
- Gateway `2026.7.2`, App Store revision `1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.7.201`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.7.201`
|
||||
- Each App Store version has its own build sequence beginning at `1`.
|
||||
- Local defaults derive from root `package.json`; App Store uploads derive their marketing version from explicit `--version` and `--revision` values.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for App Store builds:
|
||||
@@ -107,13 +108,13 @@ Release-owner secrets:
|
||||
Prepare the generated release xcconfig/project without archiving:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:prepare -- --version 2026.6.11 --build-number 7
|
||||
pnpm ios:release:prepare -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:archive -- --version 2026.6.11
|
||||
pnpm ios:release:archive -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
This command is for local archive validation only. It is not a fallback upload
|
||||
@@ -122,13 +123,13 @@ path after `pnpm ios:release:upload` fails.
|
||||
Archive and upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
If you need to force a specific build number:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 7
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
@@ -163,16 +164,16 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
4. If you are starting a brand-new production release train, add or update the matching iOS changelog section and validate the release notes:
|
||||
4. For a new App Store revision, add the exact encoded-version changelog section and validate the release notes:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:check -- --version 2026.6.11
|
||||
pnpm ios:version:check -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
5. Upload the build with explicit release intent:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
6. If `pnpm ios:release:upload` fails, stop at that failure. Do not archive
|
||||
@@ -180,7 +181,7 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
step, then rerun `pnpm ios:release:upload`.
|
||||
|
||||
7. Expected behavior:
|
||||
- Fastlane reads the explicit `--version` value
|
||||
- Fastlane reads the explicit gateway `--version` and App Store `--revision`
|
||||
- validates iOS versioning inputs for that version
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
@@ -200,7 +201,8 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
- Release upload version: explicit `--version`
|
||||
- Release gateway version: explicit `--version`
|
||||
- App Store revision: explicit `--revision`
|
||||
- Local default version: root `package.json`
|
||||
- iOS-only changelog: `apps/ios/CHANGELOG.md`
|
||||
- Generated local artifacts:
|
||||
@@ -212,7 +214,7 @@ pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
```bash
|
||||
pnpm ios:version
|
||||
pnpm ios:version:check
|
||||
pnpm ios:version -- --version 2026.6.11
|
||||
pnpm ios:version -- --version 2026.7.2 --revision 1
|
||||
pnpm ios:filelist:gen
|
||||
```
|
||||
|
||||
@@ -220,18 +222,18 @@ Recommended flow:
|
||||
|
||||
### App Store Connect iteration on an existing train
|
||||
|
||||
1. Choose the App Store train explicitly, for example `2026.6.11`.
|
||||
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
|
||||
3. Run `pnpm ios:version:check -- --version 2026.6.11` after changelog changes.
|
||||
4. Upload additional App Store Connect builds with `pnpm ios:release:upload -- --version 2026.6.11`.
|
||||
1. Choose the gateway and App Store revision explicitly, for example `2026.7.2` revision `1`.
|
||||
2. Update `apps/ios/CHANGELOG.md` under the encoded `## 2026.7.201` heading.
|
||||
3. Run `pnpm ios:version:check -- --version 2026.7.2 --revision 1` after changelog changes.
|
||||
4. Upload additional builds with `pnpm ios:release:upload -- --version 2026.7.2 --revision 1`.
|
||||
5. Let Fastlane bump only the numeric build number.
|
||||
|
||||
### Starting the next production release train
|
||||
### Starting the next App Store revision
|
||||
|
||||
1. Confirm the target gateway version in root `package.json`.
|
||||
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
|
||||
3. Run `pnpm ios:version:check -- --version <release-version>`.
|
||||
4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version <release-version>`.
|
||||
3. Run `pnpm ios:version:check -- --version <gateway-version> --revision <revision>`.
|
||||
4. Submit the first build with `pnpm ios:release:upload -- --version <gateway-version> --revision <revision>`.
|
||||
5. Keep iterating on that same explicit version until the release candidate is ready.
|
||||
|
||||
See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
|
||||
+136
-160
@@ -1,216 +1,192 @@
|
||||
# OpenClaw iOS Versioning
|
||||
|
||||
OpenClaw iOS release uploads use an explicit CalVer release version. The
|
||||
committed repo no longer has an iOS-only version manifest; release commands must
|
||||
name the App Store train they are uploading to.
|
||||
OpenClaw iOS releases retain their gateway association while allowing multiple
|
||||
public App Store releases for one gateway version. Release commands name the
|
||||
gateway version and the App Store revision explicitly.
|
||||
|
||||
## Goals
|
||||
|
||||
- make App Store release intent explicit at upload time
|
||||
- avoid stale committed iOS pins
|
||||
- keep the associated gateway version recognizable
|
||||
- support multiple public iOS releases per gateway version
|
||||
- support multiple candidate builds per App Store version
|
||||
- make every release identity explicit and deterministic
|
||||
- keep Apple bundle fields valid for App Store Connect
|
||||
- keep normal local builds aligned with the current gateway release version
|
||||
- generate App Store release notes from an iOS-owned changelog
|
||||
- generate version-specific App Store release notes from the iOS changelog
|
||||
|
||||
## Version model
|
||||
|
||||
Release uploads require a version argument:
|
||||
An iOS release has three independent identifiers:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11
|
||||
- gateway version `G = YYYY.M.P`, for example `2026.7.2`
|
||||
- App Store revision `R`, an integer from `0` through `99`
|
||||
- build number `B`, a positive integer scoped to the exact App Store version
|
||||
|
||||
The App Store version packs the revision into the third numeric component:
|
||||
|
||||
```text
|
||||
AppStoreVersion(G, R) = YYYY.M.(P * 100 + R)
|
||||
```
|
||||
|
||||
Use `--build-number` when the build number is known or has been verified from
|
||||
App Store Connect:
|
||||
Examples:
|
||||
|
||||
| Gateway | Revision | App Store version | Candidate builds |
|
||||
| --- | ---: | --- | --- |
|
||||
| `2026.7.2` | legacy `0` | `2026.7.2` | closed history |
|
||||
| `2026.7.2` | `1` | `2026.7.201` | `1`, `2`, `3` |
|
||||
| `2026.7.2` | `2` | `2026.7.202` | `1`, `2`, ... |
|
||||
| `2026.7.3` | `0` | `2026.7.300` | `1`, `2`, ... |
|
||||
|
||||
Historical exact versions are grandfathered as read-only release history. The
|
||||
release tooling does not target them again. All future uploads use the packed
|
||||
format, including revision zero.
|
||||
|
||||
## Release commands
|
||||
|
||||
Release uploads require the gateway version and App Store revision:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
The release version must use `YYYY.M.D` CalVer, for example `2026.4.6` or
|
||||
`2026.6.11`.
|
||||
Use `--build-number` only when the exact next remote build number has already
|
||||
been verified:
|
||||
|
||||
When no explicit release version is supplied to the version helper, iOS derives
|
||||
its default version from root `package.json.version` after stripping supported
|
||||
release suffixes:
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
- gateway `2026.4.10` -> iOS default `2026.4.10`
|
||||
- gateway `2026.4.10-beta.3` -> iOS default `2026.4.10`
|
||||
- gateway `2026.4.10-2` -> iOS default `2026.4.10`
|
||||
During upload, an explicit build number must equal the next App Store Connect
|
||||
build for the derived App Store version. Offline archive validation can accept
|
||||
an explicit build number without remote validation:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
## Apple bundle mapping
|
||||
|
||||
Release version `2026.6.11` maps to:
|
||||
Gateway `2026.7.2`, revision `1`, build `3` maps to:
|
||||
|
||||
- `CFBundleShortVersionString = 2026.6.11`
|
||||
- `CFBundleVersion = numeric build number only`
|
||||
- `OpenClawCanonicalVersion = 2026.7.2`
|
||||
- `CFBundleShortVersionString = 2026.7.201`
|
||||
- `CFBundleVersion = 3`
|
||||
|
||||
Fastlane can resolve the next build number by querying App Store Connect for the
|
||||
explicit short version. Maintainers may still pass `--build-number` to make the
|
||||
upload fully deterministic.
|
||||
Local development builds continue using the normalized gateway version as the
|
||||
marketing version. Release preparation supplies the explicit revision and
|
||||
therefore the packed App Store version.
|
||||
|
||||
## Revision and build lifecycle
|
||||
|
||||
- A revision is reserved once its App Store version record is created and is
|
||||
never reused.
|
||||
- Rejected or replaced candidate builds stay on the same App Store version and
|
||||
increment only the build number.
|
||||
- After an App Store version is distributed, another public release for the
|
||||
same gateway uses the next revision and resets its build number to `1`.
|
||||
- Build numbers are derived from the highest uploaded build for the exact App
|
||||
Store version plus one. Failed local archives do not consume build numbers;
|
||||
accepted App Store Connect uploads do.
|
||||
- App Review submission remains manual.
|
||||
|
||||
Before screenshot or archive work, the upload lane checks App Store Connect:
|
||||
|
||||
- an absent version may be created during metadata staging
|
||||
- an editable version is reused
|
||||
- a locked or in-review version fails the run
|
||||
- a distributed version requires the next revision
|
||||
- a missing revision below an existing higher version fails because revisions
|
||||
are never reused
|
||||
|
||||
## Release notes
|
||||
|
||||
Production release notes require an exact App Store version heading:
|
||||
|
||||
```markdown
|
||||
## 2026.7.201
|
||||
|
||||
- Fixed an iOS issue.
|
||||
```
|
||||
|
||||
The generated App Store text automatically starts with:
|
||||
|
||||
```text
|
||||
Gateway version: 2026.7.2
|
||||
```
|
||||
|
||||
Production revision builds do not fall back to the gateway heading or
|
||||
`## Unreleased`. Local version checks without `--revision` retain the existing
|
||||
gateway/`Unreleased` fallback for development.
|
||||
|
||||
Validate exact release notes with:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:check -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
## Source of truth and generated files
|
||||
|
||||
### Source files
|
||||
Source files:
|
||||
|
||||
- `package.json`
|
||||
- default iOS version source for local builds
|
||||
- explicit `--version`
|
||||
- release upload source of truth
|
||||
- `apps/ios/CHANGELOG.md`
|
||||
- iOS-only changelog and release-note source
|
||||
- `apps/ios/VERSIONING.md`
|
||||
- workflow and constraints
|
||||
- root `package.json`: default gateway version for local builds
|
||||
- explicit `--version`: gateway version for release commands
|
||||
- explicit `--revision`: App Store revision for release commands
|
||||
- `apps/ios/CHANGELOG.md`: exact App Store release notes
|
||||
- `apps/ios/VERSIONING.md`: versioning contract
|
||||
|
||||
### Generated or derived files
|
||||
Generated or derived files:
|
||||
|
||||
- `apps/ios/build/Version.xcconfig`
|
||||
- local gitignored build override generated per build or release prep
|
||||
- `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- `apps/ios/SwiftSources.input.xcfilelist`
|
||||
- local gitignored Swift lint input file generated before Xcode project generation
|
||||
- temporary Fastlane metadata
|
||||
- release notes generated from `apps/ios/CHANGELOG.md` during metadata upload
|
||||
- temporary Fastlane metadata rendered from `apps/ios/CHANGELOG.md`
|
||||
|
||||
## Tooling surfaces
|
||||
The canonical implementation is split across:
|
||||
|
||||
- `scripts/lib/ios-version.ts`
|
||||
- validates iOS CalVer
|
||||
- normalizes gateway version -> iOS CalVer
|
||||
- renders release notes from the iOS changelog
|
||||
- `scripts/ios-version.ts`
|
||||
- CLI for JSON, shell, or single-field version reads
|
||||
- accepts `--version YYYY.M.D` for explicit release queries
|
||||
- `scripts/ios-sync-versioning.ts`
|
||||
- validates that release notes can be rendered from the default or explicit iOS version
|
||||
- `scripts/ios-write-version-xcconfig.sh`
|
||||
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
|
||||
- `scripts/ios-write-swift-filelist.mjs`
|
||||
- writes the local Swift file list consumed by Xcode pre-build lint phases
|
||||
- `scripts/ios-release-prepare.sh`
|
||||
- requires `--version` and prepares App Store distribution signing and bundle settings
|
||||
- `apps/ios/fastlane/Fastfile`
|
||||
- resolves version metadata from the explicit release version
|
||||
- creates or verifies Developer Portal bundle IDs/services through Fastlane `produce`
|
||||
- syncs encrypted App Store signing assets with Fastlane `match`
|
||||
- resolves App Store Connect build numbers for the explicit short version when needed
|
||||
- uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving
|
||||
|
||||
Agent-driven App Store uploads must use `pnpm ios:release:upload` as the only
|
||||
release path. If that command fails, stop at the failing screenshot, metadata,
|
||||
archive, validation, or upload step. Do not continue by archiving and uploading
|
||||
manually with `pnpm ios:release:archive`, `asc builds upload`,
|
||||
`asc release stage`, `asc publish appstore`, direct Fastlane lanes, or other App
|
||||
Store Connect mutation commands.
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
When generating the temporary Fastlane release notes metadata, the tooling reads
|
||||
the first available changelog section in this order:
|
||||
|
||||
1. exact release version, for example `## 2026.6.11`
|
||||
2. `## Unreleased`
|
||||
|
||||
Before production upload, prefer a final `## <release version>` section and
|
||||
validate with the same version:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:check -- --version 2026.6.11
|
||||
```
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
pnpm ios:version
|
||||
pnpm ios:version -- --version 2026.6.11
|
||||
pnpm ios:version:check
|
||||
pnpm ios:filelist:gen
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
```
|
||||
|
||||
## Normal App Store Connect build iteration workflow
|
||||
|
||||
1. choose the App Store release train explicitly, for example `2026.6.11`
|
||||
2. update `apps/ios/CHANGELOG.md` under `## <release version>` or `## Unreleased`
|
||||
3. run `pnpm ios:version:check -- --version <release version>`
|
||||
4. check App Store Connect for the latest build number when needed
|
||||
5. upload another build with `pnpm ios:release:upload -- --version <release version> --build-number <next>`
|
||||
|
||||
This keeps the version decision at the release command instead of in a committed
|
||||
state file.
|
||||
- `scripts/lib/ios-version.ts`: validation, encoding, and release-note rendering
|
||||
- `scripts/ios-version.ts`: JSON, shell, and single-field queries
|
||||
- `scripts/ios-sync-versioning.ts`: release-note validation
|
||||
- `scripts/ios-release-upload.sh`: guarded upload entry point
|
||||
- `apps/ios/fastlane/Fastfile`: remote preflight, build allocation, metadata,
|
||||
archive, validation, and upload
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
Successful App Store Connect uploads create a non-tag Git ref that records the
|
||||
source commit for the uploaded store build:
|
||||
Successful uploads record the exact App Store version and build:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
|
||||
```
|
||||
|
||||
Example:
|
||||
For example:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/2026.6.11-3
|
||||
refs/openclaw/mobile-releases/ios/2026.7.201-3
|
||||
```
|
||||
|
||||
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
|
||||
not appear on GitHub release or tag pages, and they do not participate in the
|
||||
core OpenClaw release machinery.
|
||||
The ref is checked before archive/upload work and created only after App Store
|
||||
Connect accepts the upload. Existing refs are immutable.
|
||||
|
||||
`pnpm ios:release:upload` checks the ref before archive/upload work and records
|
||||
it only after the App Store Connect upload succeeds. Existing refs are
|
||||
immutable: the same ref at the same SHA is accepted, while the same ref at a
|
||||
different SHA fails.
|
||||
## Normal workflow
|
||||
|
||||
Do not create this ref after a manual fallback upload. The ref is release-lane
|
||||
evidence, not a repair mechanism for a failed `pnpm ios:release:upload` run.
|
||||
|
||||
Useful direct commands:
|
||||
1. Choose the gateway version and App Store revision explicitly.
|
||||
2. Add an exact encoded-version section to `apps/ios/CHANGELOG.md`.
|
||||
3. Validate it:
|
||||
|
||||
```bash
|
||||
pnpm mobile:release:preflight -- --platform ios --version 2026.6.11 --build 3
|
||||
pnpm mobile:release:resolve -- --platform ios --version 2026.6.11 --build 3
|
||||
pnpm ios:version:check -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
## New release workflow
|
||||
|
||||
When you want the next production iOS release to align with the current gateway
|
||||
release:
|
||||
|
||||
1. confirm the root gateway version:
|
||||
4. Upload build `1`, or let Fastlane resolve the next build:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('./package.json').version)"
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
2. update `apps/ios/CHANGELOG.md` for that release
|
||||
3. validate iOS release notes:
|
||||
5. Iterate on the same version for builds `2`, `3`, and so on.
|
||||
6. Select one processed build and submit it manually in App Store Connect.
|
||||
7. If another public release is needed after distribution, increment the App
|
||||
Store revision and start its build count at `1`.
|
||||
|
||||
```bash
|
||||
pnpm ios:version:check -- --version 2026.6.11
|
||||
```
|
||||
|
||||
4. verify live App Store Connect state and choose the next build number
|
||||
5. upload with explicit release intent:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
```
|
||||
|
||||
6. manually submit the reviewed build for App Review in App Store Connect
|
||||
7. release the approved build to production
|
||||
|
||||
## Important invariant
|
||||
|
||||
App Store uploads must carry explicit version intent. Do not infer a release
|
||||
train from generated local files.
|
||||
|
||||
App Review submission remains manual. Automation may create/update the editable
|
||||
App Store version, upload screenshots, upload release notes, upload the App
|
||||
Review PDF attachment, and upload builds, but it should not upload the App
|
||||
Store Connect `Notes` field or submit a build for review.
|
||||
|
||||
For agent-driven releases, a failed `pnpm ios:release:upload` is terminal for
|
||||
that attempt. Agents must report the failed step and wait for maintainer
|
||||
direction instead of switching to lower-level App Store Connect upload or
|
||||
submission commands.
|
||||
Agent-driven uploads must use `pnpm ios:release:upload`. A failed upload is
|
||||
terminal for that attempt: report the failing step rather than switching to a
|
||||
lower-level archive, upload, staging, or submission command.
|
||||
|
||||
+183
-26
@@ -7,6 +7,7 @@ require "tempfile"
|
||||
require "cgi"
|
||||
require "digest/md5"
|
||||
require "time"
|
||||
require "rubygems/version"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
@@ -64,6 +65,21 @@ APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
|
||||
APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
|
||||
APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
|
||||
APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5
|
||||
EDITABLE_APP_STORE_VERSION_STATES = [
|
||||
"PREPARE_FOR_SUBMISSION",
|
||||
"DEVELOPER_REJECTED",
|
||||
"REJECTED",
|
||||
"METADATA_REJECTED",
|
||||
"INVALID_BINARY",
|
||||
"READY_FOR_REVIEW"
|
||||
].freeze
|
||||
RELEASED_APP_STORE_VERSION_STATES = [
|
||||
"READY_FOR_DISTRIBUTION",
|
||||
"REPLACED_WITH_NEW_VERSION",
|
||||
"READY_FOR_SALE",
|
||||
"REMOVED_FROM_SALE",
|
||||
"DEVELOPER_REMOVED_FROM_SALE"
|
||||
].freeze
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -779,7 +795,7 @@ def release_signing_check!
|
||||
sync_app_store_signing!(readonly: true)
|
||||
end
|
||||
|
||||
def render_ios_release_notes(release_version:)
|
||||
def render_ios_release_notes(release_version:, app_store_revision:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
||||
args = [
|
||||
"node",
|
||||
@@ -790,6 +806,7 @@ def render_ios_release_notes(release_version:)
|
||||
"releaseNotes"
|
||||
]
|
||||
args.push("--version", release_version) if env_present?(release_version)
|
||||
args.push("--revision", app_store_revision) if env_present?(app_store_revision)
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
*args,
|
||||
chdir: repo_root
|
||||
@@ -801,11 +818,14 @@ def render_ios_release_notes(release_version:)
|
||||
UI.user_error!("Failed to render iOS release notes: #{detail}")
|
||||
end
|
||||
|
||||
def release_notes_metadata_path(release_version:)
|
||||
def release_notes_metadata_path(release_version:, app_store_revision:)
|
||||
temp_root = Dir.mktmpdir("openclaw-release-notes")
|
||||
target_dir = File.join(temp_root, "en-US")
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
File.write(File.join(target_dir, "release_notes.txt"), render_ios_release_notes(release_version: release_version))
|
||||
File.write(
|
||||
File.join(target_dir, "release_notes.txt"),
|
||||
render_ios_release_notes(release_version: release_version, app_store_revision: app_store_revision)
|
||||
)
|
||||
temp_root
|
||||
end
|
||||
|
||||
@@ -840,7 +860,7 @@ def assert_no_app_review_notes_field_metadata!(metadata_path)
|
||||
end
|
||||
end
|
||||
|
||||
def public_metadata_path(release_version: nil)
|
||||
def public_metadata_path(release_version: nil, app_store_revision: nil)
|
||||
source = File.join(__dir__, "metadata")
|
||||
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
|
||||
Dir.children(source).each do |entry|
|
||||
@@ -854,7 +874,10 @@ def public_metadata_path(release_version: nil)
|
||||
if release_notes_upload_requested?
|
||||
target_dir = File.join(temp_root, "en-US")
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
File.write(File.join(target_dir, "release_notes.txt"), render_ios_release_notes(release_version: release_version))
|
||||
File.write(
|
||||
File.join(target_dir, "release_notes.txt"),
|
||||
render_ios_release_notes(release_version: release_version, app_store_revision: app_store_revision)
|
||||
)
|
||||
end
|
||||
temp_root
|
||||
end
|
||||
@@ -881,6 +904,62 @@ def resolve_app_store_connect_app(app_identifier:, app_id:)
|
||||
app
|
||||
end
|
||||
|
||||
def app_store_version_state(version)
|
||||
version.app_version_state.to_s.strip.empty? ? version.app_store_state.to_s : version.app_version_state.to_s
|
||||
end
|
||||
|
||||
def preflight_app_store_version!(short_version:)
|
||||
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
|
||||
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
|
||||
versions = app.get_app_store_versions(
|
||||
filter: { platform: Spaceship::ConnectAPI::Platform::IOS },
|
||||
includes: nil
|
||||
)
|
||||
target = versions.find { |candidate| candidate.version_string == short_version }
|
||||
|
||||
if target
|
||||
state = app_store_version_state(target)
|
||||
if EDITABLE_APP_STORE_VERSION_STATES.include?(state)
|
||||
UI.message("App Store version #{short_version} is reusable in state #{state}.")
|
||||
return target
|
||||
end
|
||||
if RELEASED_APP_STORE_VERSION_STATES.include?(state)
|
||||
UI.user_error!(
|
||||
"App Store version #{short_version} is already released in state #{state}; choose the next iOS revision."
|
||||
)
|
||||
end
|
||||
UI.user_error!(
|
||||
"App Store version #{short_version} is locked in state #{state.empty? ? "UNKNOWN" : state}; wait for it to become editable or reject the submission before uploading another build."
|
||||
)
|
||||
end
|
||||
|
||||
target_version = Gem::Version.new(short_version)
|
||||
higher_version = versions
|
||||
.select { |candidate| Gem::Version.new(candidate.version_string) > target_version }
|
||||
.max_by { |candidate| Gem::Version.new(candidate.version_string) }
|
||||
if higher_version
|
||||
UI.user_error!(
|
||||
"App Store version #{short_version} cannot be created because higher version #{higher_version.version_string} already exists. Revisions are never reused."
|
||||
)
|
||||
end
|
||||
|
||||
active_version = versions.find do |candidate|
|
||||
!RELEASED_APP_STORE_VERSION_STATES.include?(app_store_version_state(candidate))
|
||||
end
|
||||
if active_version
|
||||
state = app_store_version_state(active_version)
|
||||
UI.user_error!(
|
||||
"App Store version #{short_version} cannot be created while #{active_version.version_string} is active in state #{state.empty? ? "UNKNOWN" : state}."
|
||||
)
|
||||
end
|
||||
|
||||
UI.message("App Store version #{short_version} is available and will be created during metadata staging.")
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_app_store_connect_version(app:, short_version:)
|
||||
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version
|
||||
@@ -1112,7 +1191,7 @@ def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, sh
|
||||
UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.")
|
||||
end
|
||||
|
||||
def read_ios_version_metadata(release_version: nil)
|
||||
def read_ios_version_metadata(release_version: nil, app_store_revision: nil)
|
||||
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
||||
args = [
|
||||
"node",
|
||||
@@ -1122,6 +1201,7 @@ def read_ios_version_metadata(release_version: nil)
|
||||
"--json",
|
||||
]
|
||||
args.push("--version", release_version) if env_present?(release_version)
|
||||
args.push("--revision", app_store_revision) if env_present?(app_store_revision)
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
*args,
|
||||
chdir: repo_root
|
||||
@@ -1136,11 +1216,13 @@ def read_ios_version_metadata(release_version: nil)
|
||||
parsed = JSON.parse(stdout)
|
||||
version = parsed["canonicalVersion"].to_s.strip
|
||||
short_version = parsed["marketingVersion"].to_s.strip
|
||||
if !env_present?(version) || !env_present?(short_version)
|
||||
revision = parsed["appStoreRevision"].to_s.strip
|
||||
if !env_present?(version) || !env_present?(short_version) || !env_present?(revision)
|
||||
UI.user_error!("iOS version helper returned incomplete metadata.")
|
||||
end
|
||||
|
||||
{
|
||||
app_store_revision: revision,
|
||||
short_version: short_version,
|
||||
version: version
|
||||
}
|
||||
@@ -1148,7 +1230,7 @@ rescue JSON::ParserError => e
|
||||
UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
|
||||
end
|
||||
|
||||
def sync_ios_versioning!(release_version: nil)
|
||||
def sync_ios_versioning!(release_version: nil, app_store_revision: nil)
|
||||
script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
|
||||
args = [
|
||||
"node",
|
||||
@@ -1158,6 +1240,7 @@ def sync_ios_versioning!(release_version: nil)
|
||||
"--check",
|
||||
]
|
||||
args.push("--version", release_version) if env_present?(release_version)
|
||||
args.push("--revision", app_store_revision) if env_present?(app_store_revision)
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
*args,
|
||||
chdir: repo_root
|
||||
@@ -1166,7 +1249,11 @@ def sync_ios_versioning!(release_version: nil)
|
||||
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
check_command = env_present?(release_version) ? "pnpm ios:version:check -- --version #{release_version}" : "pnpm ios:version:check"
|
||||
check_command = if env_present?(release_version) && env_present?(app_store_revision)
|
||||
"pnpm ios:version:check -- --version #{release_version} --revision #{app_store_revision}"
|
||||
else
|
||||
"pnpm ios:version:check"
|
||||
end
|
||||
UI.user_error!("iOS versioning inputs are invalid. Run `#{check_command}`.\n#{detail}")
|
||||
end
|
||||
|
||||
@@ -1196,10 +1283,12 @@ end
|
||||
def resolve_release_build_number(api_key:, short_version:, explicit_build_number: nil)
|
||||
explicit = explicit_build_number.to_s.strip
|
||||
if env_present?(explicit)
|
||||
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||
UI.message("Using explicit iOS release build number #{explicit}.")
|
||||
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected a positive integer.") unless explicit.match?(/\A[1-9]\d*\z/)
|
||||
if api_key.nil?
|
||||
UI.message("Using explicit iOS release build number #{explicit} without remote validation.")
|
||||
return explicit
|
||||
end
|
||||
end
|
||||
|
||||
latest_build = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
@@ -1208,6 +1297,11 @@ def resolve_release_build_number(api_key:, short_version:, explicit_build_number
|
||||
initial_build_number: 0
|
||||
)
|
||||
next_build = latest_build.to_i + 1
|
||||
if env_present?(explicit) && explicit.to_i != next_build
|
||||
UI.user_error!(
|
||||
"Invalid iOS release build number #{explicit} for #{short_version}; expected #{next_build} after App Store Connect build #{latest_build}."
|
||||
)
|
||||
end
|
||||
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
|
||||
next_build.to_s
|
||||
end
|
||||
@@ -1217,10 +1311,23 @@ def release_build_number_needs_app_store_connect_auth?(explicit_build_number: ni
|
||||
!env_present?(explicit)
|
||||
end
|
||||
|
||||
def prepare_app_store_release!(version:, build_number:)
|
||||
def prepare_app_store_release!(version:, app_store_revision:, build_number:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
|
||||
UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
|
||||
sh(shell_join(["bash", script_path, "--version", version, "--build-number", build_number]))
|
||||
UI.message("Preparing iOS App Store release for gateway #{version} revision #{app_store_revision} (build #{build_number}).")
|
||||
sh(
|
||||
shell_join(
|
||||
[
|
||||
"bash",
|
||||
script_path,
|
||||
"--version",
|
||||
version,
|
||||
"--revision",
|
||||
app_store_revision,
|
||||
"--build-number",
|
||||
build_number
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
|
||||
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
|
||||
@@ -1492,14 +1599,24 @@ platform :ios do
|
||||
private_lane :prepare_app_store_context do |options|
|
||||
require_api_key = options[:require_api_key] == true
|
||||
release_version = options[:release_version].to_s.strip
|
||||
app_store_revision = options[:app_store_revision].to_s.strip
|
||||
explicit_build_number = options[:build_number].to_s.strip
|
||||
needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?(explicit_build_number: explicit_build_number)
|
||||
api_key = needs_api_key ? app_store_connect_api_key_config : nil
|
||||
if release_version.empty?
|
||||
UI.user_error!("Missing iOS release version. Use `pnpm ios:release:upload -- --version YYYY.M.D` or pass `release_version:YYYY.M.D` to the Fastlane lane.")
|
||||
UI.user_error!("Missing iOS gateway version. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
|
||||
end
|
||||
sync_ios_versioning!(release_version: release_version)
|
||||
version_metadata = read_ios_version_metadata(release_version: release_version)
|
||||
if app_store_revision.empty?
|
||||
UI.user_error!("Missing iOS App Store revision. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
|
||||
end
|
||||
sync_ios_versioning!(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
version_metadata = read_ios_version_metadata(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
version = version_metadata[:version]
|
||||
short_version = version_metadata[:short_version]
|
||||
provenance = pin_release_build_provenance!
|
||||
@@ -1508,10 +1625,15 @@ platform :ios do
|
||||
short_version: short_version,
|
||||
explicit_build_number: explicit_build_number
|
||||
)
|
||||
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
|
||||
release_xcconfig = prepare_app_store_release!(
|
||||
version: version,
|
||||
app_store_revision: version_metadata[:app_store_revision],
|
||||
build_number: build_number
|
||||
)
|
||||
|
||||
{
|
||||
api_key: api_key,
|
||||
app_store_revision: version_metadata[:app_store_revision],
|
||||
build_timestamp: provenance[:build_timestamp],
|
||||
build_number: build_number,
|
||||
git_commit: provenance[:git_commit],
|
||||
@@ -1557,6 +1679,7 @@ platform :ios do
|
||||
context = prepare_app_store_context(
|
||||
require_api_key: false,
|
||||
release_version: options[:release_version],
|
||||
app_store_revision: options[:app_store_revision],
|
||||
build_number: options[:build_number]
|
||||
)
|
||||
build = build_app_store_release(context)
|
||||
@@ -1576,9 +1699,11 @@ platform :ios do
|
||||
context = prepare_app_store_context(
|
||||
require_api_key: true,
|
||||
release_version: options[:release_version],
|
||||
app_store_revision: options[:app_store_revision],
|
||||
build_number: options[:build_number]
|
||||
)
|
||||
release_sha = context[:git_commit]
|
||||
preflight_app_store_version!(short_version: context[:short_version])
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "ios",
|
||||
version: context[:short_version],
|
||||
@@ -1587,7 +1712,11 @@ platform :ios do
|
||||
)
|
||||
without_xcode_xcconfig_file do
|
||||
preserve_local_signing do
|
||||
screenshots(release_version: context[:version], build_number: context[:build_number])
|
||||
screenshots(
|
||||
release_version: context[:version],
|
||||
app_store_revision: context[:app_store_revision],
|
||||
build_number: context[:build_number]
|
||||
)
|
||||
end
|
||||
end
|
||||
verify_apple_release_source!(release_sha)
|
||||
@@ -1595,7 +1724,10 @@ platform :ios do
|
||||
|
||||
ENV["DELIVER_SCREENSHOTS"] = "1"
|
||||
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
||||
metadata(release_version: context[:short_version])
|
||||
metadata(
|
||||
release_version: context[:version],
|
||||
app_store_revision: context[:app_store_revision]
|
||||
)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
@@ -1620,12 +1752,23 @@ platform :ios do
|
||||
lane :metadata do |options|
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
release_version = options[:release_version].to_s.strip
|
||||
app_store_revision = options[:app_store_revision].to_s.strip
|
||||
if release_version.empty?
|
||||
UI.user_error!("Missing iOS release version. Use `pnpm ios:release:upload -- --version YYYY.M.D` or `fastlane ios metadata release_version:YYYY.M.D`.")
|
||||
UI.user_error!("Missing iOS gateway version. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
|
||||
end
|
||||
sync_ios_versioning!(release_version: release_version)
|
||||
version_metadata = read_ios_version_metadata(release_version: release_version)
|
||||
if app_store_revision.empty?
|
||||
UI.user_error!("Missing iOS App Store revision. Use `pnpm ios:release:upload -- --version YYYY.M.D --revision N`.")
|
||||
end
|
||||
sync_ios_versioning!(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
version_metadata = read_ios_version_metadata(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
api_key = app_store_connect_api_key_config
|
||||
preflight_app_store_version!(short_version: version_metadata[:short_version])
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
|
||||
app_id = ENV["APP_STORE_CONNECT_APP_ID"]
|
||||
@@ -1641,10 +1784,16 @@ platform :ios do
|
||||
end
|
||||
|
||||
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
|
||||
metadata_path = public_metadata_path(release_version: release_version)
|
||||
metadata_path = public_metadata_path(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
||||
if release_notes_upload_requested? && skip_metadata
|
||||
metadata_path = release_notes_metadata_path(release_version: release_version)
|
||||
metadata_path = release_notes_metadata_path(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision
|
||||
)
|
||||
skip_metadata = false
|
||||
end
|
||||
assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
|
||||
@@ -1689,8 +1838,10 @@ platform :ios do
|
||||
lane :screenshots do |options|
|
||||
version_args = []
|
||||
release_version = options[:release_version].to_s.strip
|
||||
app_store_revision = options[:app_store_revision].to_s.strip
|
||||
build_number = options[:build_number].to_s.strip
|
||||
version_args += ["--version", release_version] unless release_version.empty?
|
||||
version_args += ["--revision", app_store_revision] unless app_store_revision.empty?
|
||||
version_args += ["--build-number", build_number] unless build_number.empty?
|
||||
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
@@ -1744,15 +1895,21 @@ platform :ios do
|
||||
# evidence in build/ so Deliver sees only locale directories during validation.
|
||||
FileUtils.rm_rf(File.join(output_directory, "test_output"))
|
||||
|
||||
watch_screenshot(release_version: release_version, build_number: build_number)
|
||||
watch_screenshot(
|
||||
release_version: release_version,
|
||||
app_store_revision: app_store_revision,
|
||||
build_number: build_number
|
||||
)
|
||||
end
|
||||
|
||||
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
|
||||
lane :watch_screenshot do |options|
|
||||
version_args = []
|
||||
release_version = options[:release_version].to_s.strip
|
||||
app_store_revision = options[:app_store_revision].to_s.strip
|
||||
build_number = options[:build_number].to_s.strip
|
||||
version_args += ["--version", release_version] unless release_version.empty?
|
||||
version_args += ["--revision", app_store_revision] unless app_store_revision.empty?
|
||||
version_args += ["--build-number", build_number] unless build_number.empty?
|
||||
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
|
||||
+11
-11
@@ -95,7 +95,7 @@ If you pass `--build-number` to `pnpm ios:release:archive`, the local archive pa
|
||||
Archive locally without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:archive -- --version 2026.6.11 --build-number 3
|
||||
pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
Generate deterministic App Store screenshots:
|
||||
@@ -109,7 +109,7 @@ The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters
|
||||
Upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
Direct Fastlane upload is disabled. Use the package script so the release
|
||||
@@ -135,16 +135,16 @@ cd apps/ios
|
||||
fastlane ios auth_check
|
||||
```
|
||||
|
||||
4. If you are starting a brand-new production release train, validate iOS release notes for the release version:
|
||||
4. For a new App Store revision, add an exact encoded-version changelog section and validate it:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:check -- --version 2026.6.11
|
||||
pnpm ios:version:check -- --version 2026.7.2 --revision 1
|
||||
```
|
||||
|
||||
5. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
|
||||
pnpm ios:release:upload -- --version 2026.7.2 --revision 1 --build-number 3
|
||||
```
|
||||
|
||||
Quick verification after upload:
|
||||
@@ -156,14 +156,14 @@ Quick verification after upload:
|
||||
|
||||
Versioning rules:
|
||||
|
||||
- App Store release uploads require an explicit `--version`
|
||||
- App Store release uploads require an explicit gateway `--version` and App Store `--revision`
|
||||
- local defaults derive from root `package.json`
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source
|
||||
- Supported iOS release versions use CalVer: `YYYY.M.D`
|
||||
- Fastlane uses the explicit release version for App Store upload
|
||||
- Fastlane sets `CFBundleShortVersionString` to the release version, for example `2026.4.10`
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer App Store Connect build number for that short version
|
||||
- Run `pnpm ios:version:check -- --version <release-version>` after changing `apps/ios/CHANGELOG.md`
|
||||
- Gateway versions use CalVer: `YYYY.M.D`
|
||||
- Fastlane derives the App Store version as `YYYY.M.(D * 100 + revision)`
|
||||
- Gateway `2026.7.2`, revision `1` sets `CFBundleShortVersionString` to `2026.7.201`
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer build for that exact App Store version
|
||||
- Run `pnpm ios:version:check -- --version <gateway-version> --revision <revision>` after changing `apps/ios/CHANGELOG.md`
|
||||
- `pnpm ios:version:check` validates that release notes can be generated from the iOS changelog
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
|
||||
@@ -7,7 +7,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
|
||||
```bash
|
||||
cd apps/ios
|
||||
APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||
DELIVER_METADATA=1 fastlane ios metadata release_version:2026.6.11
|
||||
DELIVER_METADATA=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1
|
||||
```
|
||||
|
||||
## Release notes and App Review attachment
|
||||
@@ -16,14 +16,14 @@ DELIVER_METADATA=1 fastlane ios metadata release_version:2026.6.11
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
DELIVER_RELEASE_NOTES=1 fastlane ios metadata release_version:2026.6.11
|
||||
DELIVER_RELEASE_NOTES=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1
|
||||
```
|
||||
|
||||
## Optional: include screenshots
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata release_version:2026.6.11
|
||||
DELIVER_METADATA=1 DELIVER_SCREENSHOTS=1 fastlane ios metadata release_version:2026.7.2 app_store_revision:1
|
||||
```
|
||||
|
||||
## Auth
|
||||
@@ -45,11 +45,11 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
## Notes
|
||||
|
||||
- Locale files live under `metadata/<locale>/`, for example `metadata/en-US/` and `metadata/sv-SE/`. Each locale directory should use the public metadata filenames consumed by the `ios metadata` lane.
|
||||
- Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; after changelog updates, run `pnpm ios:version:check -- --version <release-version>`.
|
||||
- Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; validate them with `pnpm ios:version:check -- --version <gateway-version> --revision <revision>`.
|
||||
- Do not check in `release_notes.txt` under locale metadata directories; the lane strips copied release-note files and writes the current generated en-US release notes when requested.
|
||||
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
|
||||
- Release notes resolve from `## <release version>` first, then fall back to `## Unreleased` while an App Store Connect build train is still in progress.
|
||||
- When starting a new production release train, validate metadata with `pnpm ios:version:check -- --version <release-version>`.
|
||||
- Production release notes require the exact encoded App Store heading, such as `## 2026.7.201`; they do not fall back to the gateway or `## Unreleased` section.
|
||||
- Generated App Store release notes begin with the associated gateway version.
|
||||
- The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
|
||||
@@ -4,13 +4,14 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-release-archive.sh --version 2026.6.11 [--build-number 7]
|
||||
scripts/ios-release-archive.sh --version 2026.7.2 --revision 1 [--build-number 3]
|
||||
|
||||
Archives and exports an App Store distribution IPA locally without uploading.
|
||||
EOF
|
||||
}
|
||||
|
||||
BUILD_NUMBER=""
|
||||
APP_STORE_REVISION=""
|
||||
RELEASE_VERSION=""
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
|
||||
@@ -36,6 +37,11 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILD_NUMBER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--revision)
|
||||
require_option_value "$1" "${2-}"
|
||||
APP_STORE_REVISION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
require_option_value "$1" "${2-}"
|
||||
RELEASE_VERSION="${2:-}"
|
||||
@@ -59,7 +65,13 @@ if [[ -z "${RELEASE_VERSION}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FASTLANE_ARGS=(ios app_store_archive "release_version:${RELEASE_VERSION}")
|
||||
if [[ -z "${APP_STORE_REVISION}" ]]; then
|
||||
echo "Missing required --revision." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FASTLANE_ARGS=(ios app_store_archive "release_version:${RELEASE_VERSION}" "app_store_revision:${APP_STORE_REVISION}")
|
||||
if [[ -n "${BUILD_NUMBER}" ]]; then
|
||||
FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")
|
||||
fi
|
||||
|
||||
@@ -4,10 +4,10 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-release-prepare.sh --version 2026.6.11 --build-number 7 [--team-id TEAMID]
|
||||
scripts/ios-release-prepare.sh --version 2026.7.2 --revision 1 --build-number 3 [--team-id TEAMID]
|
||||
|
||||
Prepares local App Store release inputs without touching local signing overrides:
|
||||
- writes apps/ios/build/Version.xcconfig for the explicit release version
|
||||
- writes apps/ios/build/Version.xcconfig for the explicit gateway and App Store revision
|
||||
- writes apps/ios/build/AppStoreRelease.xcconfig with canonical bundle IDs
|
||||
- configures the release build for relay-backed APNs registration
|
||||
- configures manual App Store distribution signing with pinned provisioning profiles
|
||||
@@ -28,6 +28,7 @@ RELEASE_SOURCE_HELPER="${ROOT_DIR}/scripts/apple-release-source-check.sh"
|
||||
CANONICAL_TEAM_ID="FWJYW4S8P8"
|
||||
|
||||
BUILD_NUMBER=""
|
||||
APP_STORE_REVISION=""
|
||||
RELEASE_VERSION=""
|
||||
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
|
||||
IOS_VERSION=""
|
||||
@@ -78,6 +79,11 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILD_NUMBER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--revision)
|
||||
require_option_value "$1" "${2-}"
|
||||
APP_STORE_REVISION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
require_option_value "$1" "${2-}"
|
||||
RELEASE_VERSION="${2:-}"
|
||||
@@ -112,6 +118,12 @@ if [[ -z "${RELEASE_VERSION}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${APP_STORE_REVISION}" ]]; then
|
||||
echo "Missing required --revision." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${TEAM_ID}" ]]; then
|
||||
TEAM_ID="$(IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash "${TEAM_HELPER}" --require-canonical)"
|
||||
fi
|
||||
@@ -139,12 +151,12 @@ export GIT_COMMIT="${RELEASE_GIT_COMMIT}"
|
||||
prepare_build_dir
|
||||
|
||||
(
|
||||
cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check --version "${RELEASE_VERSION}"
|
||||
cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}"
|
||||
)
|
||||
|
||||
IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --version "${RELEASE_VERSION}" --field canonicalVersion)"
|
||||
IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}" --field marketingVersion)"
|
||||
if [[ -z "${IOS_VERSION}" ]]; then
|
||||
echo "Unable to resolve iOS release version '${RELEASE_VERSION}'." >&2
|
||||
echo "Unable to resolve App Store version for gateway '${RELEASE_VERSION}' revision '${APP_STORE_REVISION}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -156,7 +168,7 @@ fi
|
||||
|
||||
(
|
||||
OPENCLAW_REQUIRE_BUILD_METADATA=1 \
|
||||
bash "${VERSION_HELPER}" --version "${IOS_VERSION}" --build-number "${BUILD_NUMBER}"
|
||||
bash "${VERSION_HELPER}" --version "${RELEASE_VERSION}" --revision "${APP_STORE_REVISION}" --build-number "${BUILD_NUMBER}"
|
||||
)
|
||||
node "${ROOT_DIR}/scripts/ios-write-swift-filelist.mjs"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-release-upload.sh --version 2026.6.11 [--build-number 7]
|
||||
scripts/ios-release-upload.sh --version 2026.7.2 --revision 1 [--build-number 3]
|
||||
|
||||
Generates App Store screenshots, updates release metadata, archives, and uploads
|
||||
an App Store distribution build to App Store Connect. This does not submit the
|
||||
@@ -13,6 +13,7 @@ EOF
|
||||
}
|
||||
|
||||
BUILD_NUMBER=""
|
||||
APP_STORE_REVISION=""
|
||||
RELEASE_VERSION=""
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "${ROOT_DIR}/scripts/lib/ios-fastlane.sh"
|
||||
@@ -38,6 +39,11 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILD_NUMBER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--revision)
|
||||
require_option_value "$1" "${2-}"
|
||||
APP_STORE_REVISION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
require_option_value "$1" "${2-}"
|
||||
RELEASE_VERSION="${2:-}"
|
||||
@@ -61,7 +67,13 @@ if [[ -z "${RELEASE_VERSION}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FASTLANE_ARGS=(ios release_upload "release_version:${RELEASE_VERSION}")
|
||||
if [[ -z "${APP_STORE_REVISION}" ]]; then
|
||||
echo "Missing required --revision." >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FASTLANE_ARGS=(ios release_upload "release_version:${RELEASE_VERSION}" "app_store_revision:${APP_STORE_REVISION}")
|
||||
if [[ -n "${BUILD_NUMBER}" ]]; then
|
||||
FASTLANE_ARGS+=("build_number:${BUILD_NUMBER}")
|
||||
fi
|
||||
|
||||
@@ -3,22 +3,25 @@ import path from "node:path";
|
||||
import { syncIosVersioning } from "./lib/ios-version.ts";
|
||||
import { parseVersionSyncArgs } from "./lib/version-script-args.ts";
|
||||
|
||||
export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts";
|
||||
export function parseArgs(argv: string[]) {
|
||||
return parseVersionSyncArgs(argv, { allowAppStoreRevision: true });
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
"Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--root dir]\n\nValidates that iOS versioning inputs can produce generated local artifacts.\n",
|
||||
"Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--revision 0-99] [--root dir]\n\nValidates that iOS versioning inputs can produce generated local artifacts.\n",
|
||||
);
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)): number {
|
||||
const options = parseVersionSyncArgs(argv);
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = syncIosVersioning({
|
||||
appStoreRevision: options.appStoreRevision,
|
||||
mode: options.mode,
|
||||
releaseVersion: options.releaseVersion,
|
||||
rootDir: options.rootDir,
|
||||
|
||||
@@ -4,23 +4,27 @@ import { parseVersionQueryArgs } from "./lib/version-script-args.ts";
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
"Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--root dir]\n\n",
|
||||
"Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--revision 0-99] [--root dir]\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)): number {
|
||||
const options = parseVersionQueryArgs(argv);
|
||||
const options = parseVersionQueryArgs(argv, { allowAppStoreRevision: true });
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const version = resolveIosVersion(options.rootDir, { releaseVersion: options.releaseVersion });
|
||||
const version = resolveIosVersion(options.rootDir, {
|
||||
appStoreRevision: options.appStoreRevision,
|
||||
releaseVersion: options.releaseVersion,
|
||||
});
|
||||
|
||||
if (options.field) {
|
||||
if (options.field === "releaseNotes") {
|
||||
process.stdout.write(
|
||||
renderIosReleaseNotesForVersion({
|
||||
appStoreRevision: options.appStoreRevision,
|
||||
releaseVersion: options.releaseVersion,
|
||||
rootDir: options.rootDir,
|
||||
}),
|
||||
@@ -40,6 +44,8 @@ function main(argv = process.argv.slice(2)): number {
|
||||
process.stdout.write(
|
||||
[
|
||||
`OPENCLAW_IOS_VERSION=${version.canonicalVersion}`,
|
||||
`OPENCLAW_APP_STORE_REVISION=${version.appStoreRevision ?? ""}`,
|
||||
`OPENCLAW_APP_STORE_VERSION=${version.appStoreVersion ?? ""}`,
|
||||
`OPENCLAW_MARKETING_VERSION=${version.marketingVersion}`,
|
||||
`OPENCLAW_BUILD_VERSION=${version.buildVersion}`,
|
||||
].join("\n") + "\n",
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/ios-write-version-xcconfig.sh [--version 2026.6.11] [--build-number 7]
|
||||
scripts/ios-write-version-xcconfig.sh [--version 2026.7.2] [--revision 1] [--build-number 3]
|
||||
|
||||
Writes apps/ios/build/Version.xcconfig from package.json or explicit --version:
|
||||
- OPENCLAW_IOS_VERSION = exact canonical iOS version
|
||||
@@ -22,6 +22,7 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
|
||||
IOS_VERSION=""
|
||||
MARKETING_VERSION=""
|
||||
BUILD_NUMBER=""
|
||||
APP_STORE_REVISION=""
|
||||
RELEASE_VERSION=""
|
||||
RESOLVED_GIT_COMMIT=""
|
||||
RESOLVED_BUILD_TIMESTAMP=""
|
||||
@@ -70,6 +71,11 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILD_NUMBER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--revision)
|
||||
require_option_value "$1" "${2-}"
|
||||
APP_STORE_REVISION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
require_option_value "$1" "${2-}"
|
||||
RELEASE_VERSION="${2:-}"
|
||||
@@ -91,6 +97,13 @@ VERSION_HELPER_ARGS=(--shell)
|
||||
if [[ -n "${RELEASE_VERSION}" ]]; then
|
||||
VERSION_HELPER_ARGS+=(--version "${RELEASE_VERSION}")
|
||||
fi
|
||||
if [[ -n "${APP_STORE_REVISION}" ]]; then
|
||||
if [[ -z "${RELEASE_VERSION}" ]]; then
|
||||
echo "--revision requires an explicit --version." >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION_HELPER_ARGS+=(--revision "${APP_STORE_REVISION}")
|
||||
fi
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
case "${key}" in
|
||||
|
||||
@@ -4,9 +4,14 @@ import path from "node:path";
|
||||
import { parseReleaseVersion } from "./release-version.mjs";
|
||||
|
||||
const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md";
|
||||
const IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION = 100;
|
||||
const MAX_IOS_APP_STORE_REVISION = IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION - 1;
|
||||
|
||||
type ResolvedIosVersion = {
|
||||
appStoreRevision: number | null;
|
||||
appStoreVersion: string | null;
|
||||
canonicalVersion: string;
|
||||
gatewayVersion: string;
|
||||
marketingVersion: string;
|
||||
buildVersion: string;
|
||||
changelogPath: string;
|
||||
@@ -38,6 +43,43 @@ export function normalizePinnedIosVersion(rawVersion: string): string {
|
||||
return pinnedVersion;
|
||||
}
|
||||
|
||||
export function normalizeIosAppStoreRevision(rawRevision: string | number): number {
|
||||
const normalized = String(rawRevision).trim();
|
||||
if (!/^(?:0|[1-9]\d*)$/u.test(normalized)) {
|
||||
throw new Error(
|
||||
`Invalid iOS App Store revision '${rawRevision}'. Expected an integer from 0 to ${MAX_IOS_APP_STORE_REVISION}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const revision = Number(normalized);
|
||||
if (!Number.isSafeInteger(revision) || revision > MAX_IOS_APP_STORE_REVISION) {
|
||||
throw new Error(
|
||||
`Invalid iOS App Store revision '${rawRevision}'. Expected an integer from 0 to ${MAX_IOS_APP_STORE_REVISION}.`,
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
export function encodeIosAppStoreVersion(
|
||||
gatewayVersion: string,
|
||||
appStoreRevision: string | number,
|
||||
): string {
|
||||
const canonicalVersion = normalizePinnedIosVersion(gatewayVersion);
|
||||
const parsed = parseReleaseVersion(canonicalVersion);
|
||||
if (!parsed) {
|
||||
throw new Error(`Unable to encode invalid gateway version '${gatewayVersion}'.`);
|
||||
}
|
||||
|
||||
const revision = normalizeIosAppStoreRevision(appStoreRevision);
|
||||
// Exact pre-cutover versions are immutable release history. Revision zero is
|
||||
// deliberately packed too, so future trains never return to the old shape.
|
||||
const encodedPatch = parsed.patch * IOS_APP_STORE_REVISIONS_PER_GATEWAY_VERSION + revision;
|
||||
if (!Number.isSafeInteger(encodedPatch)) {
|
||||
throw new Error(`Encoded iOS App Store version is too large for '${gatewayVersion}'.`);
|
||||
}
|
||||
return `${parsed.year}.${parsed.month}.${encodedPatch}`;
|
||||
}
|
||||
|
||||
export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): string {
|
||||
const trimmed = rawVersion.trim().replace(/^v/u, "");
|
||||
if (!trimmed) {
|
||||
@@ -81,17 +123,27 @@ export function resolveGatewayVersionForIosRelease(rootDir = path.resolve(".")):
|
||||
|
||||
export function resolveIosVersion(
|
||||
rootDir = path.resolve("."),
|
||||
options?: { releaseVersion?: string | null },
|
||||
options?: { appStoreRevision?: string | number | null; releaseVersion?: string | null },
|
||||
): ResolvedIosVersion {
|
||||
const changelogPath = path.join(rootDir, IOS_CHANGELOG_FILE);
|
||||
const explicitReleaseVersion = options?.releaseVersion?.trim() ?? "";
|
||||
const canonicalVersion = explicitReleaseVersion
|
||||
? normalizePinnedIosVersion(explicitReleaseVersion)
|
||||
: resolveGatewayVersionForIosRelease(rootDir).pinnedIosVersion;
|
||||
const rawAppStoreRevision = options?.appStoreRevision;
|
||||
const appStoreRevision =
|
||||
rawAppStoreRevision === null || rawAppStoreRevision === undefined
|
||||
? null
|
||||
: normalizeIosAppStoreRevision(rawAppStoreRevision);
|
||||
const appStoreVersion =
|
||||
appStoreRevision === null ? null : encodeIosAppStoreVersion(canonicalVersion, appStoreRevision);
|
||||
|
||||
return {
|
||||
appStoreRevision,
|
||||
appStoreVersion,
|
||||
canonicalVersion,
|
||||
marketingVersion: canonicalVersion,
|
||||
gatewayVersion: canonicalVersion,
|
||||
marketingVersion: appStoreVersion ?? canonicalVersion,
|
||||
buildVersion: "1",
|
||||
changelogPath,
|
||||
versionSource: explicitReleaseVersion ? "explicit" : "package",
|
||||
@@ -130,21 +182,27 @@ export function renderIosReleaseNotes(
|
||||
version: ResolvedIosVersion,
|
||||
changelogContent: string,
|
||||
): string {
|
||||
const candidateHeadings = [version.canonicalVersion, "Unreleased"];
|
||||
const candidateHeadings =
|
||||
version.appStoreRevision === null
|
||||
? [version.canonicalVersion, "Unreleased"]
|
||||
: [version.marketingVersion];
|
||||
|
||||
for (const heading of candidateHeadings) {
|
||||
const body = extractChangelogSection(changelogContent, heading);
|
||||
if (body) {
|
||||
return `${body}\n`;
|
||||
const gatewayPrefix =
|
||||
version.appStoreRevision === null ? "" : `Gateway version: ${version.gatewayVersion}\n\n`;
|
||||
return `${gatewayPrefix}${body}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to find iOS changelog notes for ${version.canonicalVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`,
|
||||
`Unable to find iOS changelog notes for ${version.marketingVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function syncIosVersioning(params?: {
|
||||
appStoreRevision?: string | number | null;
|
||||
mode?: SyncIosVersioningMode;
|
||||
releaseVersion?: string | null;
|
||||
rootDir?: string;
|
||||
@@ -153,7 +211,10 @@ export function syncIosVersioning(params?: {
|
||||
} {
|
||||
const rootDir = path.resolve(params?.rootDir ?? ".");
|
||||
const releaseVersion = params?.releaseVersion;
|
||||
const version = resolveIosVersion(rootDir, { releaseVersion });
|
||||
const version = resolveIosVersion(rootDir, {
|
||||
appStoreRevision: params?.appStoreRevision,
|
||||
releaseVersion,
|
||||
});
|
||||
const changelogContent = readFileSync(version.changelogPath, "utf8");
|
||||
renderIosReleaseNotes(version, changelogContent);
|
||||
|
||||
@@ -161,11 +222,15 @@ export function syncIosVersioning(params?: {
|
||||
}
|
||||
|
||||
export function renderIosReleaseNotesForVersion(params?: {
|
||||
appStoreRevision?: string | number | null;
|
||||
releaseVersion?: string | null;
|
||||
rootDir?: string;
|
||||
}): string {
|
||||
const rootDir = path.resolve(params?.rootDir ?? ".");
|
||||
const version = resolveIosVersion(rootDir, { releaseVersion: params?.releaseVersion });
|
||||
const version = resolveIosVersion(rootDir, {
|
||||
appStoreRevision: params?.appStoreRevision,
|
||||
releaseVersion: params?.releaseVersion,
|
||||
});
|
||||
const changelogContent = readFileSync(version.changelogPath, "utf8");
|
||||
return renderIosReleaseNotes(version, changelogContent);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
|
||||
type VersionScriptFormat = "json" | "shell";
|
||||
type VersionQueryCliOptions = {
|
||||
appStoreRevision: string | null;
|
||||
field: string | null;
|
||||
format: VersionScriptFormat;
|
||||
help: boolean;
|
||||
@@ -10,13 +11,18 @@ type VersionQueryCliOptions = {
|
||||
};
|
||||
type VersionSyncMode = "check" | "write";
|
||||
type VersionSyncCliOptions = {
|
||||
appStoreRevision: string | null;
|
||||
help: boolean;
|
||||
mode: VersionSyncMode;
|
||||
releaseVersion: string | null;
|
||||
rootDir: string;
|
||||
};
|
||||
|
||||
export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
|
||||
export function parseVersionQueryArgs(
|
||||
argv: string[],
|
||||
options?: { allowAppStoreRevision?: boolean },
|
||||
): VersionQueryCliOptions {
|
||||
let appStoreRevision: string | null = null;
|
||||
let field: string | null = null;
|
||||
let format: VersionScriptFormat = "json";
|
||||
let help = false;
|
||||
@@ -48,6 +54,14 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--revision": {
|
||||
if (options?.allowAppStoreRevision !== true) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
appStoreRevision = readOptionValue(argv, index, "--revision");
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--version": {
|
||||
releaseVersion = readOptionValue(argv, index, "--version");
|
||||
index += 1;
|
||||
@@ -64,10 +78,14 @@ export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
|
||||
}
|
||||
}
|
||||
|
||||
return { field, format, help, releaseVersion, rootDir };
|
||||
return { appStoreRevision, field, format, help, releaseVersion, rootDir };
|
||||
}
|
||||
|
||||
export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
|
||||
export function parseVersionSyncArgs(
|
||||
argv: string[],
|
||||
options?: { allowAppStoreRevision?: boolean },
|
||||
): VersionSyncCliOptions {
|
||||
let appStoreRevision: string | null = null;
|
||||
let help = false;
|
||||
let mode: VersionSyncMode = "write";
|
||||
let releaseVersion: string | null = null;
|
||||
@@ -92,6 +110,14 @@ export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--revision": {
|
||||
if (options?.allowAppStoreRevision !== true) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
appStoreRevision = readOptionValue(argv, index, "--revision");
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
case "--version": {
|
||||
releaseVersion = readOptionValue(argv, index, "--version");
|
||||
index += 1;
|
||||
@@ -108,7 +134,7 @@ export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
|
||||
}
|
||||
}
|
||||
|
||||
return { help, mode, releaseVersion, rootDir };
|
||||
return { appStoreRevision, help, mode, releaseVersion, rootDir };
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, flag: string): string {
|
||||
|
||||
@@ -74,7 +74,9 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
|
||||
expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1");
|
||||
expect(script).toContain("Missing required --version.");
|
||||
expect(script).toContain("Missing required --revision.");
|
||||
expect(script).toContain('"release_version:${RELEASE_VERSION}"');
|
||||
expect(script).toContain('"app_store_revision:${APP_STORE_REVISION}"');
|
||||
expect(script).toContain('"build_number:${BUILD_NUMBER}"');
|
||||
expect(script).toContain("DELIVER_NUMBER_OF_THREADS=1");
|
||||
expect(script).toContain("FL_MAX_NUMBER_OF_THREADS=1");
|
||||
@@ -99,16 +101,52 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
expect(releaseUpload).toContain('ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"');
|
||||
expect(releaseUpload).toContain("Use `pnpm ios:release:upload`");
|
||||
expect(prepareContext).toContain("options[:release_version]");
|
||||
expect(prepareContext).toContain("options[:app_store_revision]");
|
||||
expect(prepareContext).toContain("options[:build_number]");
|
||||
expect(prepareContext).toContain("Missing iOS release version");
|
||||
expect(releaseUpload).toContain("metadata(release_version: context[:short_version])");
|
||||
expect(prepareContext).toContain("Missing iOS gateway version");
|
||||
expect(prepareContext).toContain("Missing iOS App Store revision");
|
||||
expect(releaseUpload).toContain("app_store_revision: context[:app_store_revision]");
|
||||
expect(laneBody(fastfile, "metadata")).toContain("options[:release_version]");
|
||||
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS release version");
|
||||
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS gateway version");
|
||||
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS App Store revision");
|
||||
expect(releaseUpload.indexOf("UI.user_error!")).toBeLessThan(
|
||||
releaseUpload.indexOf("prepare_app_store_context"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preflights the exact App Store version before screenshots and archive work", () => {
|
||||
const fastfile = readFastfile();
|
||||
const releaseUpload = laneBody(fastfile, "release_upload");
|
||||
const preflight = functionBody(fastfile, "preflight_app_store_version!");
|
||||
|
||||
expect(preflight).toContain("EDITABLE_APP_STORE_VERSION_STATES");
|
||||
expect(preflight).toContain("RELEASED_APP_STORE_VERSION_STATES");
|
||||
expect(fastfile).toContain('"READY_FOR_SALE"');
|
||||
expect(fastfile).toContain('"REMOVED_FROM_SALE"');
|
||||
expect(fastfile).toContain('"DEVELOPER_REMOVED_FROM_SALE"');
|
||||
expect(fastfile).not.toMatch(
|
||||
/EDITABLE_APP_STORE_VERSION_STATES = \[[\s\S]*?"WAITING_FOR_REVIEW"[\s\S]*?\]\.freeze/,
|
||||
);
|
||||
expect(preflight).toContain("Revisions are never reused");
|
||||
expect(preflight).toContain("higher version");
|
||||
expect(releaseUpload).toContain("preflight_app_store_version!");
|
||||
expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan(
|
||||
releaseUpload.indexOf("screenshots("),
|
||||
);
|
||||
expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan(
|
||||
releaseUpload.indexOf("build = build_app_store_release(context)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates explicit build numbers against the exact App Store version", () => {
|
||||
const resolver = functionBody(readFastfile(), "resolve_release_build_number");
|
||||
|
||||
expect(resolver).toContain("version: short_version");
|
||||
expect(resolver).toContain("expected #{next_build}");
|
||||
expect(resolver).toContain("explicit.to_i != next_build");
|
||||
expect(resolver).toContain("api_key.nil?");
|
||||
});
|
||||
|
||||
it("validates the exported IPA before the sole TestFlight upload call", () => {
|
||||
const fastfile = readFastfile();
|
||||
const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]");
|
||||
@@ -122,11 +160,11 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
const fastfile = readFastfile();
|
||||
const releaseUpload = laneBody(fastfile, "release_upload");
|
||||
const screenshots = releaseUpload.indexOf(
|
||||
"screenshots(release_version: context[:version], build_number: context[:build_number])",
|
||||
"screenshots(\n release_version: context[:version]",
|
||||
);
|
||||
const sourceCheck = releaseUpload.indexOf("verify_apple_release_source!(release_sha)");
|
||||
const build = releaseUpload.indexOf("build = build_app_store_release(context)");
|
||||
const metadata = releaseUpload.indexOf("metadata(release_version: context[:short_version])");
|
||||
const metadata = releaseUpload.indexOf("metadata(\n release_version: context[:version]");
|
||||
|
||||
expect(screenshots).toBeGreaterThanOrEqual(0);
|
||||
expect(sourceCheck).toBeGreaterThan(screenshots);
|
||||
@@ -277,20 +315,18 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
expect(releaseUpload).toContain("release_sha = context[:git_commit]");
|
||||
expect(releaseUpload).toContain("ensure_mobile_release_ref_available!");
|
||||
expect(releaseUpload).toContain("record_mobile_release_ref!");
|
||||
expect(releaseUpload).toContain(
|
||||
"screenshots(release_version: context[:version], build_number: context[:build_number])",
|
||||
);
|
||||
expect(releaseUpload).toContain("screenshots(\n release_version: context[:version]");
|
||||
expect(fastfile).toContain("def without_xcode_xcconfig_file");
|
||||
expect(releaseUpload).toContain("without_xcode_xcconfig_file do");
|
||||
expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2);
|
||||
expect(releaseUpload.indexOf("prepare_app_store_context")).toBeLessThan(
|
||||
releaseUpload.indexOf("screenshots(release_version: context[:version]"),
|
||||
releaseUpload.indexOf("screenshots(\n release_version: context[:version]"),
|
||||
);
|
||||
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
||||
releaseUpload.indexOf("screenshots(release_version: context[:version]"),
|
||||
releaseUpload.indexOf("screenshots(\n release_version: context[:version]"),
|
||||
);
|
||||
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
||||
releaseUpload.indexOf("\n metadata(release_version: context[:short_version])\n"),
|
||||
releaseUpload.indexOf("\n metadata(\n release_version: context[:version]"),
|
||||
);
|
||||
expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
|
||||
releaseUpload.indexOf("upload_to_testflight("),
|
||||
|
||||
@@ -38,7 +38,7 @@ function runPrepare(extraArgs: string[]): { ok: boolean; stdout: string; stderr:
|
||||
|
||||
describe("scripts/ios-release-prepare.sh", () => {
|
||||
it("rejects non-canonical signing teams before generating release inputs", () => {
|
||||
const result = runPrepare(["--version", "2026.6.11", "--build-number", "7"]);
|
||||
const result = runPrepare(["--version", "2026.7.2", "--revision", "1", "--build-number", "3"]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain(
|
||||
|
||||
@@ -35,17 +35,19 @@ describe("iOS release shell wrapper arguments", () => {
|
||||
const missingValueCases: readonly WrapperCase[] = [
|
||||
["scripts/ios-release-upload.sh", ["--build-number", "--bogus"], "--build-number"],
|
||||
["scripts/ios-release-upload.sh", ["--version", "--bogus"], "--version"],
|
||||
["scripts/ios-release-upload.sh", ["--revision", "--bogus"], "--revision"],
|
||||
["scripts/ios-release-archive.sh", ["--build-number", "--bogus"], "--build-number"],
|
||||
["scripts/ios-release-archive.sh", ["--version", "--bogus"], "--version"],
|
||||
["scripts/ios-release-archive.sh", ["--revision", "--bogus"], "--revision"],
|
||||
["scripts/ios-release-prepare.sh", ["--build-number", "--team-id"], "--build-number"],
|
||||
[
|
||||
"scripts/ios-release-prepare.sh",
|
||||
["--build-number", "7", "--version", "--bogus"],
|
||||
["--build-number", "3", "--version", "--bogus"],
|
||||
"--version",
|
||||
],
|
||||
[
|
||||
"scripts/ios-release-prepare.sh",
|
||||
["--version", "2026.6.11", "--build-number", "7", "--team-id", "--bogus"],
|
||||
["--version", "2026.7.2", "--revision", "1", "--build-number", "3", "--team-id", "--bogus"],
|
||||
"--team-id",
|
||||
],
|
||||
];
|
||||
@@ -67,8 +69,8 @@ describe("iOS release shell wrapper arguments", () => {
|
||||
"scripts/ios-release-upload.sh",
|
||||
"scripts/ios-release-archive.sh",
|
||||
"scripts/ios-release-prepare.sh",
|
||||
])("requires an explicit release version before release work in %s", (scriptPath) => {
|
||||
const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "7"] : [];
|
||||
])("requires an explicit gateway version before release work in %s", (scriptPath) => {
|
||||
const args = scriptPath.endsWith("prepare.sh") ? ["--build-number", "3"] : [];
|
||||
const result = runScript(path.join(process.cwd(), scriptPath), args, {
|
||||
IOS_RELEASE_VERSION: "2026.6.10",
|
||||
});
|
||||
@@ -80,6 +82,23 @@ describe("iOS release shell wrapper arguments", () => {
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"scripts/ios-release-upload.sh",
|
||||
"scripts/ios-release-archive.sh",
|
||||
"scripts/ios-release-prepare.sh",
|
||||
])("requires an explicit App Store revision before release work in %s", (scriptPath) => {
|
||||
const args = ["--version", "2026.7.2"];
|
||||
if (scriptPath.endsWith("prepare.sh")) {
|
||||
args.push("--build-number", "3");
|
||||
}
|
||||
const result = runScript(path.join(process.cwd(), scriptPath), args);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("Missing required --revision.");
|
||||
expect(result.stderr).not.toContain("fastlane");
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it.each(["scripts/ios-release-upload.sh", "scripts/ios-release-archive.sh"])(
|
||||
"does not accept ambient release build numbers in %s",
|
||||
(scriptPath) => {
|
||||
@@ -93,7 +112,7 @@ describe("iOS release shell wrapper arguments", () => {
|
||||
it("rejects App Store release relay URL overrides before release work", () => {
|
||||
const result = runScript(
|
||||
path.join(process.cwd(), "scripts/ios-release-prepare.sh"),
|
||||
["--version", "2026.6.11", "--build-number", "7"],
|
||||
["--version", "2026.7.2", "--revision", "1", "--build-number", "3"],
|
||||
{
|
||||
IOS_DEVELOPMENT_TEAM: "FWJYW4S8P8",
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: "https://relay.example.com",
|
||||
|
||||
@@ -4,8 +4,10 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
encodeIosAppStoreVersion,
|
||||
extractChangelogSection,
|
||||
normalizeGatewayVersionToPinnedIosVersion,
|
||||
normalizeIosAppStoreRevision,
|
||||
normalizePinnedIosVersion,
|
||||
renderIosReleaseNotes,
|
||||
resolveGatewayVersionForIosRelease,
|
||||
@@ -79,7 +81,7 @@ describe("resolveIosVersion", () => {
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("prints explicit release version fields from the CLI", () => {
|
||||
it("prints explicit gateway version fields from the CLI", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.4.6",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## 2026.4.7\n\nStable notes.\n",
|
||||
@@ -108,6 +110,34 @@ describe("resolveIosVersion", () => {
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("prints an encoded App Store version for an explicit gateway revision", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.7.2",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## 2026.7.201\n\nRevision notes.\n",
|
||||
});
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/ios-version.ts",
|
||||
"--root",
|
||||
rootDir,
|
||||
"--version",
|
||||
"2026.7.2",
|
||||
"--revision",
|
||||
"1",
|
||||
"--field",
|
||||
"marketingVersion",
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("2026.7.201\n");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("prints derived release notes from the CLI", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.4.6",
|
||||
@@ -170,15 +200,33 @@ describe("resolveIosVersion", () => {
|
||||
});
|
||||
|
||||
expect(resolveIosVersion(rootDir)).toEqual({
|
||||
appStoreRevision: null,
|
||||
appStoreVersion: null,
|
||||
buildVersion: "1",
|
||||
canonicalVersion: "2026.4.6",
|
||||
changelogPath: path.join(rootDir, "apps/ios/CHANGELOG.md"),
|
||||
gatewayVersion: "2026.4.6",
|
||||
marketingVersion: "2026.4.6",
|
||||
versionSource: "package",
|
||||
versionSourcePath: path.join(rootDir, "package.json"),
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes App Store revisions into the gateway patch component", () => {
|
||||
expect(encodeIosAppStoreVersion("2026.7.2", 0)).toBe("2026.7.200");
|
||||
expect(encodeIosAppStoreVersion("2026.7.2", 1)).toBe("2026.7.201");
|
||||
expect(encodeIosAppStoreVersion("2026.7.2", 99)).toBe("2026.7.299");
|
||||
expect(encodeIosAppStoreVersion("2026.7.3", 0)).toBe("2026.7.300");
|
||||
expect(encodeIosAppStoreVersion("2026.12.33", 4)).toBe("2026.12.3304");
|
||||
});
|
||||
|
||||
it("rejects invalid App Store revisions", () => {
|
||||
expect(() => normalizeIosAppStoreRevision("-1")).toThrow("integer from 0 to 99");
|
||||
expect(() => normalizeIosAppStoreRevision("01")).toThrow("integer from 0 to 99");
|
||||
expect(() => normalizeIosAppStoreRevision("100")).toThrow("integer from 0 to 99");
|
||||
expect(() => normalizeIosAppStoreRevision("1.5")).toThrow("integer from 0 to 99");
|
||||
});
|
||||
|
||||
it("rejects semver-only package versions", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "1.2.3",
|
||||
@@ -188,7 +236,7 @@ describe("resolveIosVersion", () => {
|
||||
expect(() => resolveIosVersion(rootDir)).toThrow("Expected YYYY.M.PATCH");
|
||||
});
|
||||
|
||||
it("rejects prerelease suffixes in explicit release versions", () => {
|
||||
it("rejects prerelease suffixes in explicit gateway versions", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.4.6",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
|
||||
@@ -249,6 +297,47 @@ describe("gateway version normalization", () => {
|
||||
});
|
||||
|
||||
describe("release note extraction", () => {
|
||||
it("requires exact App Store version notes and adds the gateway association", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.7.2",
|
||||
changelog: `# OpenClaw iOS Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Draft notes.
|
||||
|
||||
## 2026.7.201
|
||||
|
||||
- App Store revision notes.
|
||||
`,
|
||||
});
|
||||
const version = resolveIosVersion(rootDir, {
|
||||
appStoreRevision: 1,
|
||||
releaseVersion: "2026.7.2",
|
||||
});
|
||||
const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8");
|
||||
|
||||
expect(renderIosReleaseNotes(version, changelog)).toBe(
|
||||
"Gateway version: 2026.7.2\n\n- App Store revision notes.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to gateway or Unreleased notes for App Store revisions", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.7.2",
|
||||
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nDraft notes.\n",
|
||||
});
|
||||
const version = resolveIosVersion(rootDir, {
|
||||
appStoreRevision: 1,
|
||||
releaseVersion: "2026.7.2",
|
||||
});
|
||||
const changelog = fs.readFileSync(path.join(rootDir, "apps", "ios", "CHANGELOG.md"), "utf8");
|
||||
|
||||
expect(() => renderIosReleaseNotes(version, changelog)).toThrow(
|
||||
"Unable to find iOS changelog notes for 2026.7.201",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts exact pinned version sections first", () => {
|
||||
const rootDir = writeIosFixture({
|
||||
packageVersion: "2026.4.6",
|
||||
|
||||
Reference in New Issue
Block a user