diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index d115a0a7d8f8..cacc2874e067 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2026.7.21 + - Fixed background Gateway sessions restarting while waiting for pairing or authentication attention. - Prevented failed offline-data resets from reusing retired chat database handles and kept byte-distinct Gateway owners isolated. - Made Apple Watch message acknowledgments wait for persisted handling and preserved pending voice turns across navigation and relaunches. diff --git a/apps/ios/README.md b/apps/ios/README.md index a60adcf55a82..1835b39c73e9 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -75,8 +75,8 @@ Release behavior: - `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source. - 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` + - `CFBundleShortVersionString = 2026.7.21` + - `CFBundleVersion = next App Store Connect build number for 2026.7.21` - Each App Store version has its own build sequence beginning at `1`. - Local defaults and release planning derive the gateway from root `package.json`; App Store Connect versions and build uploads determine the release revision and build. - See `apps/ios/VERSIONING.md` for the full workflow. diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index 5c3f45993c06..87210c69e1e4 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -18,13 +18,13 @@ the active release identity from the repository and App Store Connect. An iOS release has three independent identifiers: - gateway version `G = YYYY.M.P`, for example `2026.7.2` -- App Store revision `R`, an integer from `0` through `99` +- App Store revision `R`, a single digit from `0` through `9` - 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: +The App Store version appends the revision directly to the gateway patch with no padding: ```text -AppStoreVersion(G, R) = YYYY.M.(P * 100 + R) +AppStoreVersion(G, R) = YYYY.M.concat(P, R) ``` Examples: @@ -32,13 +32,15 @@ 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`, ... | +| `2026.7.2` | `1` | `2026.7.21` | `1`, `2`, `3` | +| `2026.7.2` | `2` | `2026.7.22` | `1`, `2`, ... | +| `2026.7.3` | `0` | `2026.7.30` | `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. +Historical exact versions through `2026.7.2` are grandfathered as read-only +release history and consume revision zero for their gateway. That explicit +cutover keeps later appended versions such as `2026.7.21` from being mistaken +for a future gateway's exact legacy release. The release tooling does not target +exact versions again; all future uploads use the appended single-digit format. ## Release commands @@ -69,12 +71,12 @@ pnpm ios:release:archive -- --version 2026.7.2 --revision 1 --build-number 3 Gateway `2026.7.2`, revision `1`, build `3` maps to: - `OpenClawCanonicalVersion = 2026.7.2` -- `CFBundleShortVersionString = 2026.7.201` +- `CFBundleShortVersionString = 2026.7.21` - `CFBundleVersion = 3` 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. +therefore the appended App Store version. ## Revision and build lifecycle @@ -109,7 +111,7 @@ processing, then fails the attempt rather than polling indefinitely. Production release notes require an exact App Store version heading: ```markdown -## 2026.7.201 +## 2026.7.21 - Fixed an iOS issue. ``` @@ -171,7 +173,7 @@ refs/openclaw/mobile-releases/ios/- For example: ```text -refs/openclaw/mobile-releases/ios/2026.7.201-3 +refs/openclaw/mobile-releases/ios/2026.7.21-3 ``` The ref is checked before archive/upload work and created only after App Store diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index e197e1c6ca42..6f0001f46488 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -164,8 +164,8 @@ Versioning rules: - explicit `--version`, `--revision`, and `--build-number` values are checked overrides - `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source - 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 appends one unpadded revision digit: gateway `YYYY.M.D`, revision `R`, becomes `YYYY.M.DR` +- Gateway `2026.7.2`, revision `1` sets `CFBundleShortVersionString` to `2026.7.21` - Fastlane resolves `CFBundleVersion` from the maximum awaiting, processing, failed, or complete build-upload record plus one - Run `pnpm ios:release:cut` after changing `## Unreleased`, then review and commit the exact encoded heading - `pnpm ios:version:check` validates that release notes can be generated from the iOS changelog diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index 43e886aaad49..ef3824700826 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -48,7 +48,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`. - Release notes are generated from `apps/ios/CHANGELOG.md` into temporary Fastlane metadata during upload; use `pnpm ios:release:plan -- --json` and `pnpm ios:release:cut` to prepare the exact encoded section. - 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. -- 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. +- Production release notes require the exact encoded App Store heading, such as `## 2026.7.21`; 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`. diff --git a/scripts/ios-sync-versioning.ts b/scripts/ios-sync-versioning.ts index 437a75ff20be..870ce132bbd4 100644 --- a/scripts/ios-sync-versioning.ts +++ b/scripts/ios-sync-versioning.ts @@ -9,7 +9,7 @@ export function parseArgs(argv: string[]) { function printUsage(): void { process.stdout.write( - "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", + "Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--version YYYY.M.D] [--revision 0-9] [--root dir]\n\nValidates that iOS versioning inputs can produce generated local artifacts.\n", ); } diff --git a/scripts/ios-version.ts b/scripts/ios-version.ts index b4873983f6fa..a469c5558540 100644 --- a/scripts/ios-version.ts +++ b/scripts/ios-version.ts @@ -4,7 +4,7 @@ import { parseVersionQueryArgs } from "./lib/version-script-args.ts"; function printUsage(): void { process.stdout.write( - "Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--revision 0-99] [--root dir]\n\n", + "Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--version YYYY.M.D] [--revision 0-9] [--root dir]\n\n", ); } diff --git a/scripts/lib/ios-release-plan.ts b/scripts/lib/ios-release-plan.ts index cfef8a2df896..3073fbd157fc 100644 --- a/scripts/lib/ios-release-plan.ts +++ b/scripts/lib/ios-release-plan.ts @@ -4,11 +4,15 @@ import path from "node:path"; import { encodeIosAppStoreVersion, extractChangelogSection, + MAX_IOS_APP_STORE_REVISION, normalizeIosAppStoreRevision, normalizePinnedIosVersion, } from "./ios-version.ts"; const IOS_BUILD_UPLOAD_STATES = ["AWAITING_UPLOAD", "PROCESSING", "FAILED", "COMPLETE"] as const; +// 2026.7.2 is the last exact-version iOS release in App Store Connect. +// Later exact CalVer values must not be confused with appended revision versions. +const LAST_LEGACY_IOS_APP_STORE_VERSION = "2026.7.2"; const EDITABLE_APP_STORE_VERSION_STATES = new Set([ "PREPARE_FOR_SUBMISSION", @@ -100,20 +104,27 @@ export function decodeIosAppStoreVersion( gatewayVersion: string, appStoreVersion: string, ): DecodedVersion | null { - const gateway = parseVersionComponents(normalizePinnedIosVersion(gatewayVersion)); + const canonicalGatewayVersion = normalizePinnedIosVersion(gatewayVersion); + const gateway = parseVersionComponents(canonicalGatewayVersion); const candidate = parseVersionComponents(appStoreVersion); if (!gateway || !candidate || gateway[0] !== candidate[0] || gateway[1] !== candidate[1]) { return null; } if (candidate[2] === gateway[2]) { - return { legacy: true, revision: 0 }; + return compareAppStoreVersions(canonicalGatewayVersion, LAST_LEGACY_IOS_APP_STORE_VERSION) <= 0 + ? { legacy: true, revision: 0 } + : null; } - const firstPackedPatch = gateway[2] * 100; - const revision = candidate[2] - firstPackedPatch; - if (revision < 0 || revision > 99) { + const gatewayPatch = gateway[2].toString(); + const candidatePatch = candidate[2].toString(); + if (!candidatePatch.startsWith(gatewayPatch)) { return null; } - return { legacy: false, revision }; + const revision = candidatePatch.slice(gatewayPatch.length); + if (!/^\d$/u.test(revision)) { + return null; + } + return { legacy: false, revision: Number(revision) }; } function normalizeBuildNumber(rawBuildNumber: string): number { @@ -269,8 +280,10 @@ export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePla } } - if (revision > 99) { - throw new Error(`Gateway ${gatewayVersion} has exhausted App Store revisions 0 through 99.`); + if (revision > MAX_IOS_APP_STORE_REVISION) { + throw new Error( + `Gateway ${gatewayVersion} has exhausted App Store revisions 0 through ${MAX_IOS_APP_STORE_REVISION}.`, + ); } const appStoreVersion = encodeIosAppStoreVersion(gatewayVersion, revision); diff --git a/scripts/lib/ios-version.ts b/scripts/lib/ios-version.ts index 9a2d62bf19b6..460b3f6783f9 100644 --- a/scripts/lib/ios-version.ts +++ b/scripts/lib/ios-version.ts @@ -4,8 +4,7 @@ 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; +export const MAX_IOS_APP_STORE_REVISION = 9; type ResolvedIosVersion = { appStoreRevision: number | null; @@ -71,9 +70,9 @@ export function encodeIosAppStoreVersion( } 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; + // Append one revision digit without padding. Keeping the revision to one + // digit preserves App Store ordering when the gateway patch increments. + const encodedPatch = Number(`${parsed.patch}${revision}`); if (!Number.isSafeInteger(encodedPatch)) { throw new Error(`Encoded iOS App Store version is too large for '${gatewayVersion}'.`); } diff --git a/test/scripts/ios-release-plan.test.ts b/test/scripts/ios-release-plan.test.ts index 4615ae2d69ce..b1375f1f76bc 100644 --- a/test/scripts/ios-release-plan.test.ts +++ b/test/scripts/ios-release-plan.test.ts @@ -28,7 +28,7 @@ describe("resolveIosReleasePlan", () => { it("starts a new gateway at revision zero and build one", () => { expect(resolveIosReleasePlan(input())).toMatchObject({ appStoreRevision: 0, - appStoreVersion: "2026.7.200", + appStoreVersion: "2026.7.20", buildNumber: 1, changelogStatus: "needs-cut", decision: "new-revision", @@ -46,7 +46,7 @@ describe("resolveIosReleasePlan", () => { expect(plan).toMatchObject({ appStoreRevision: 1, - appStoreVersion: "2026.7.201", + appStoreVersion: "2026.7.21", buildNumber: 1, decision: "new-revision", }); @@ -67,7 +67,7 @@ describe("resolveIosReleasePlan", () => { expect(plan).toMatchObject({ appStoreRevision: 1, - appStoreVersion: "2026.7.201", + appStoreVersion: "2026.7.21", buildNumber: 1, decision: "new-revision", }); @@ -77,7 +77,7 @@ describe("resolveIosReleasePlan", () => { const plan = resolveIosReleasePlan( input({ appStoreVersions: [ - { id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.201" }, + { id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" }, ], }), ); @@ -99,7 +99,7 @@ describe("resolveIosReleasePlan", () => { buildUploads: [ { buildNumber: "1", - shortVersion: "2026.7.201", + shortVersion: "2026.7.21", state: "FAILED", }, ], @@ -121,8 +121,8 @@ describe("resolveIosReleasePlan", () => { { id: "legacy", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.2" }, ], buildUploads: [ - { buildNumber: "1", shortVersion: "2026.7.201", state: "FAILED" }, - { buildNumber: "1", shortVersion: "2026.7.202", state: "FAILED" }, + { buildNumber: "1", shortVersion: "2026.7.21", state: "FAILED" }, + { buildNumber: "1", shortVersion: "2026.7.22", state: "FAILED" }, ], }), ), @@ -135,11 +135,11 @@ describe("resolveIosReleasePlan", () => { const plan = resolveIosReleasePlan( input({ appStoreVersions: [ - { id: "editable", state: "READY_FOR_REVIEW", versionString: "2026.7.201" }, + { id: "editable", state: "READY_FOR_REVIEW", versionString: "2026.7.21" }, ], buildUploads: [ - { buildNumber: "7", shortVersion: "2026.7.201", state }, - { buildNumber: "3", shortVersion: "2026.7.201", state: "COMPLETE" }, + { buildNumber: "7", shortVersion: "2026.7.21", state }, + { buildNumber: "3", shortVersion: "2026.7.21", state: "COMPLETE" }, ], }), ); @@ -152,7 +152,7 @@ describe("resolveIosReleasePlan", () => { expect(() => resolveIosReleasePlan( input({ - appStoreVersions: [{ id: "locked", state: "IN_REVIEW", versionString: "2026.7.201" }], + appStoreVersions: [{ id: "locked", state: "IN_REVIEW", versionString: "2026.7.21" }], }), ), ).toThrow("locked in state IN_REVIEW"); @@ -161,7 +161,7 @@ describe("resolveIosReleasePlan", () => { resolveIosReleasePlan( input({ appStoreVersions: [ - { id: "other", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.300" }, + { id: "other", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.30" }, ], }), ), @@ -173,8 +173,8 @@ describe("resolveIosReleasePlan", () => { resolveIosReleasePlan( input({ appStoreVersions: [ - { id: "one", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.201" }, - { id: "two", state: "READY_FOR_REVIEW", versionString: "2026.7.202" }, + { id: "one", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" }, + { id: "two", state: "READY_FOR_REVIEW", versionString: "2026.7.22" }, ], }), ), @@ -183,24 +183,22 @@ describe("resolveIosReleasePlan", () => { expect(() => resolveIosReleasePlan( input({ - buildUploads: [ - { buildNumber: "1", shortVersion: "2026.7.200", state: "NEW_APPLE_STATE" }, - ], + buildUploads: [{ buildNumber: "1", shortVersion: "2026.7.20", state: "NEW_APPLE_STATE" }], }), ), ).toThrow("Unknown App Store build upload state"); }); - it("fails after revision 99 is distributed", () => { + it("fails after revision 9 is distributed", () => { expect(() => resolveIosReleasePlan( input({ appStoreVersions: [ - { id: "last", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.299" }, + { id: "last", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.29" }, ], }), ), - ).toThrow("exhausted App Store revisions 0 through 99"); + ).toThrow("exhausted App Store revisions 0 through 9"); }); it("rejects a planned version older than released history from another gateway", () => { @@ -225,27 +223,59 @@ describe("resolveIosReleasePlan", () => { ); }); - it("decodes only legacy or packed versions for the selected gateway", () => { + it("decodes only legacy or single-digit revision versions for the selected gateway", () => { expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.2")).toEqual({ legacy: true, revision: 0, }); - expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.299")).toEqual({ + expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.20")).toEqual({ legacy: false, - revision: 99, + revision: 0, + }); + expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.21")).toEqual({ + legacy: false, + revision: 1, + }); + expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.29")).toEqual({ + legacy: false, + revision: 9, + }); + expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.201")).toBeNull(); + expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.30")).toBeNull(); + expect(decodeIosAppStoreVersion("2026.7.3", "2026.7.3")).toBeNull(); + expect(decodeIosAppStoreVersion("2026.7.21", "2026.7.21")).toBeNull(); + expect(decodeIosAppStoreVersion("2026.7.21", "2026.7.210")).toEqual({ + legacy: false, + revision: 0, + }); + }); + + it("does not mistake an appended version for a future gateway's legacy release", () => { + const plan = resolveIosReleasePlan( + input({ + appStoreVersions: [ + { id: "older", state: "READY_FOR_DISTRIBUTION", versionString: "2026.7.21" }, + ], + gatewayVersion: "2026.7.21", + }), + ); + + expect(plan).toMatchObject({ + appStoreRevision: 0, + appStoreVersion: "2026.7.210", + decision: "new-revision", }); - expect(decodeIosAppStoreVersion("2026.7.2", "2026.7.300")).toBeNull(); }); it("requires another cut when retry notes remain Unreleased", () => { const rootDir = writeIosFixture({ packageVersion: "2026.7.2", changelog: - "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry notes.\n\n## 2026.7.201\n\nOriginal notes.\n", + "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry notes.\n\n## 2026.7.21\n\nOriginal notes.\n", }); const plan = resolveIosReleasePlan({ appStoreVersions: [ - { id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.201" }, + { id: "editable", state: "PREPARE_FOR_SUBMISSION", versionString: "2026.7.21" }, ], buildUploads: [], gatewayVersion: "2026.7.2", @@ -260,26 +290,26 @@ describe("cutIosReleaseChangelog", () => { it("cuts Unreleased notes into a new exact App Store version section", () => { const current = "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNew notes.\n\n## 2026.7.2\n\nOld notes.\n"; - const updated = cutIosReleaseChangelog(current, "2026.7.201"); + const updated = cutIosReleaseChangelog(current, "2026.7.21"); - expect(updated).toContain("## Unreleased\n\n## 2026.7.201\n\nNew notes."); + expect(updated).toContain("## Unreleased\n\n## 2026.7.21\n\nNew notes."); expect(updated).toContain("## 2026.7.2\n\nOld notes."); - expect(cutIosReleaseChangelog(updated, "2026.7.201")).toBe(updated); + expect(cutIosReleaseChangelog(updated, "2026.7.21")).toBe(updated); }); it("merges retry notes into the existing release section", () => { const current = - "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.201\n\nOriginal notes.\n"; - const updated = cutIosReleaseChangelog(current, "2026.7.201"); + "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.21\n\nOriginal notes.\n"; + const updated = cutIosReleaseChangelog(current, "2026.7.21"); - expect(updated).toContain("## 2026.7.201\n\nRetry fix.\n\nOriginal notes."); + expect(updated).toContain("## 2026.7.21\n\nRetry fix.\n\nOriginal notes."); }); it("preserves an existing release heading suffix", () => { const current = - "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.201 - 2026-07-23\n\nOriginal notes.\n"; - const updated = cutIosReleaseChangelog(current, "2026.7.201"); + "# OpenClaw iOS Changelog\n\n## Unreleased\n\nRetry fix.\n\n## 2026.7.21 - 2026-07-23\n\nOriginal notes.\n"; + const updated = cutIosReleaseChangelog(current, "2026.7.21"); - expect(updated).toContain("## 2026.7.201 - 2026-07-23\n\nRetry fix.\n\nOriginal notes."); + expect(updated).toContain("## 2026.7.21 - 2026-07-23\n\nRetry fix.\n\nOriginal notes."); }); }); diff --git a/test/scripts/ios-version.test.ts b/test/scripts/ios-version.test.ts index 19aab97ef19e..5bf234ffdd92 100644 --- a/test/scripts/ios-version.test.ts +++ b/test/scripts/ios-version.test.ts @@ -113,7 +113,7 @@ describe("resolveIosVersion", () => { 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", + changelog: "# OpenClaw iOS Changelog\n\n## 2026.7.21\n\nRevision notes.\n", }); const result = spawnSync( process.execPath, @@ -134,7 +134,7 @@ describe("resolveIosVersion", () => { ); expect(result.status).toBe(0); - expect(result.stdout).toBe("2026.7.201\n"); + expect(result.stdout).toBe("2026.7.21\n"); expect(result.stderr).toBe(""); }); @@ -212,19 +212,19 @@ describe("resolveIosVersion", () => { }); }); - 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("appends one unpadded App Store revision digit to the gateway patch", () => { + expect(encodeIosAppStoreVersion("2026.7.2", 0)).toBe("2026.7.20"); + expect(encodeIosAppStoreVersion("2026.7.2", 1)).toBe("2026.7.21"); + expect(encodeIosAppStoreVersion("2026.7.2", 9)).toBe("2026.7.29"); + expect(encodeIosAppStoreVersion("2026.7.3", 0)).toBe("2026.7.30"); + expect(encodeIosAppStoreVersion("2026.12.33", 4)).toBe("2026.12.334"); }); 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"); + expect(() => normalizeIosAppStoreRevision("-1")).toThrow("integer from 0 to 9"); + expect(() => normalizeIosAppStoreRevision("01")).toThrow("integer from 0 to 9"); + expect(() => normalizeIosAppStoreRevision("10")).toThrow("integer from 0 to 9"); + expect(() => normalizeIosAppStoreRevision("1.5")).toThrow("integer from 0 to 9"); }); it("rejects semver-only package versions", () => { @@ -306,7 +306,7 @@ describe("release note extraction", () => { Draft notes. -## 2026.7.201 +## 2026.7.21 - App Store revision notes. `, @@ -334,7 +334,7 @@ Draft notes. 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", + "Unable to find iOS changelog notes for 2026.7.21", ); });