refactor(ios): append unpadded App Store revisions

This commit is contained in:
joshavant
2026-07-23 18:42:16 -05:00
parent 0558831b92
commit ba09e05e97
11 changed files with 128 additions and 82 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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/<CFBundleShortVersionString>-<CFBundleVersion>
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

View File

@@ -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

View File

@@ -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`.

View File

@@ -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",
);
}

View File

@@ -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",
);
}

View File

@@ -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);

View File

@@ -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}'.`);
}

View File

@@ -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.");
});
});

View File

@@ -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",
);
});