diff --git a/.claude/commands/release.md b/.claude/commands/release.md
index 43bc619cd..6f65a1cee 100644
--- a/.claude/commands/release.md
+++ b/.claude/commands/release.md
@@ -161,8 +161,10 @@ Train rules (mistake guards):
- **Affected packages only.** Before dispatching anything, compute the
affected set per package with
`git log ..origin/main -- `. Skip any
- package with no unreleased commits. A library-only train releases only the
- libraries the merged PRs actually touched and skips Apple/Google entirely.
+ package with no unreleased commits. A library whose pinned native version
+ moves in the train is affected too, even with no commits of its own. A
+ library-only train releases only the libraries the merged PRs actually
+ touched and skips Apple/Google entirely.
- **Native gate.** Do not dispatch any library workflow until every affected
native release (Apple, Google) is registry-verified (CocoaPods trunk /
Maven Central POMs publicly fetchable) and its package metadata is
diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md
index c4e258cbf..4a438b3cb 100644
--- a/knowledge/_agent-context/context.md
+++ b/knowledge/_agent-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated shared context for AI assistants**
-> Last updated: 2026-09-26T18:25:30.977Z
+> Last updated: 2026-09-26T18:35:25.842Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -1468,15 +1468,15 @@ store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
a KMP library module, and a module with its own `platform` flavors (which the
plugin leaves alone). It needs an Android SDK and the network.
-`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
-app's release build does. It builds a minified release app per store from the
-locally published artifacts and asserts that each links only its store's SDK
-and that R8 keeps what runs by name: every Play Billing class and method the
-Play module looks up by reflection (read from its source), and every Amazon SDK
-class, because that SDK fills its own classes by reflection. It also needs an
-Android SDK and the network. A store SDK that needs R8 rules gets them in its
-flavor's consumer file (`openiap/consumer-rules-.pro`), so apps never add
-them by hand.
+`scripts/verify-release-consumer.sh` runs R8 as an app's release build does,
+and kmp CI does the same for its example app. It builds a minified release app
+per store from the locally published artifacts and asserts that each links only
+its store's SDK and that R8 keeps what runs by name: every Play Billing class
+and method the Play module looks up by reflection (read from its source), and
+every Amazon SDK class, because that SDK fills its own classes by reflection. It
+also needs an Android SDK and the network. A store SDK that needs R8 rules gets
+them in its flavor's consumer file (`openiap/consumer-rules-.pro`), so
+apps never add them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
@@ -2330,7 +2330,7 @@ Before adding or editing a `Package Releases` list:
with `gh release view --repo hyodotdev/openiap` before changing a link.
6. After the train publishes, compare every version and link on its card with
the published releases and correct any that differ.
-7. Run `bun run audit:docs`; the audit fails when a published
+7. Run `bun run audit:docs`; the audit fails when a
`Package Releases` block contains a package/version item without a GitHub
Release link.
diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md
index 71c764be8..0e8a43fac 100644
--- a/knowledge/internal/04-platform-packages.md
+++ b/knowledge/internal/04-platform-packages.md
@@ -386,15 +386,15 @@ store reaches the published `openiap-google` and `kmp-iap` artifacts in an app,
a KMP library module, and a module with its own `platform` flavors (which the
plugin leaves alone). It needs an Android SDK and the network.
-`scripts/verify-release-consumer.sh` is the only check that runs R8, as an
-app's release build does. It builds a minified release app per store from the
-locally published artifacts and asserts that each links only its store's SDK
-and that R8 keeps what runs by name: every Play Billing class and method the
-Play module looks up by reflection (read from its source), and every Amazon SDK
-class, because that SDK fills its own classes by reflection. It also needs an
-Android SDK and the network. A store SDK that needs R8 rules gets them in its
-flavor's consumer file (`openiap/consumer-rules-.pro`), so apps never add
-them by hand.
+`scripts/verify-release-consumer.sh` runs R8 as an app's release build does,
+and kmp CI does the same for its example app. It builds a minified release app
+per store from the locally published artifacts and asserts that each links only
+its store's SDK and that R8 keeps what runs by name: every Play Billing class
+and method the Play module looks up by reflection (read from its source), and
+every Amazon SDK class, because that SDK fills its own classes by reflection. It
+also needs an Android SDK and the network. A store SDK that needs R8 rules gets
+them in its flavor's consumer file (`openiap/consumer-rules-.pro`), so
+apps never add them by hand.
**Add a case whenever the rule changes.** A wrong store is invisible on the
machine that built it — it only appears when the artifact reaches a device that
diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md
index 76735aa88..9165958cc 100644
--- a/knowledge/internal/05-docs-patterns.md
+++ b/knowledge/internal/05-docs-patterns.md
@@ -439,7 +439,7 @@ Before adding or editing a `Package Releases` list:
with `gh release view --repo hyodotdev/openiap` before changing a link.
6. After the train publishes, compare every version and link on its card with
the published releases and correct any that differ.
-7. Run `bun run audit:docs`; the audit fails when a published
+7. Run `bun run audit:docs`; the audit fails when a
`Package Releases` block contains a package/version item without a GitHub
Release link.
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index bc0404fa4..fd7a34bb4 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -472,9 +472,7 @@ function Releases() {
.
-
- Protocols and native packages
-
+ Native packages
{
+ const block = (heading: string, item: string) => `
+ ${heading}
+
+`;
+ const linked =
+ 'openiap-google 9.9.9';
+
+ test("accepts a linked package release", () => {
+ expect(
+ auditReleaseNotePackageLinks(
+ "releases.tsx",
+ block("Package Releases", linked),
+ ),
+ ).toEqual([]);
+ });
+
+ test("flags a package release without its GitHub Release link", () => {
+ const drifts = auditReleaseNotePackageLinks(
+ "releases.tsx",
+ block("Package Releases", "openiap-google 9.9.9"),
+ );
+ expect(drifts).toHaveLength(1);
+ expect(drifts[0].rule).toBe("R9");
+ });
+
+ test("rejects a Planned Package Releases heading", () => {
+ const drifts = auditReleaseNotePackageLinks(
+ "releases.tsx",
+ block("Planned Package Releases", linked),
+ );
+ expect(drifts).toHaveLength(1);
+ expect(drifts[0].message).toContain("Planned Package Releases");
+ });
+});
diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts
index 68bdba765..317d4a5d9 100644
--- a/scripts/audit-docs.ts
+++ b/scripts/audit-docs.ts
@@ -1455,11 +1455,6 @@ function formatQuotedList(values: string[]): string {
return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`;
}
-/**
- * `Package Releases` blocks link every package/version item to its GitHub
- * Release. A card written in a PR ahead of its release links the expected tags,
- * so `Planned Package Releases` is no longer used.
- */
// Release workflows link a version's own anchor, e.g.
// /docs/updates/releases#godot-iap-3.5.1. The page paginates and resolves a
// hash only against a note's id or aliases, so a card that lists package
@@ -1549,8 +1544,13 @@ export function auditReleaseNoteVersionAnchors(
return drifts;
}
-function auditReleaseNotePackageLinks(filePath: string): Drift[] {
- const src = readFileSync(filePath, "utf8");
+// Every `Package Releases` item links its GitHub Release; a card written ahead of
+// its release links the expected tag, so `Planned Package Releases` is rejected.
+export function auditReleaseNotePackageLinks(
+ filePath: string,
+ source?: string,
+): Drift[] {
+ const src = source ?? readFileSync(filePath, "utf8");
const drifts: Drift[] = [];
const headingRe =
/]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g;
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 75c19a289..20f53c666 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -76,6 +76,10 @@ fi
# Release cards merge before publication; require an explicit flag to deploy them early.
echo -e "${BLUE}🔗 Checking release links...${NC}"
RELEASES_PAGE="packages/docs/src/pages/docs/updates/releases.tsx"
+if [ ! -f "$RELEASES_PAGE" ]; then
+ echo -e "${RED}❌ $RELEASES_PAGE is missing; update this check${NC}"
+ exit 1
+fi
# Older cards that link releases which never published; drop each once its card is fixed.
UNPUBLISHED_HISTORY=(2.1.6 2.2.2 3.5.0 apple-2.0.0 flutter-iap-10.6.2 google-3.5.3 kmp-iap-3.5.2 maui-iap-1.0.1 maui-iap-2.5.1)
if ! PUBLISHED_RELEASES=$(gh release list --repo hyodotdev/openiap --limit 5000 \
@@ -83,12 +87,18 @@ if ! PUBLISHED_RELEASES=$(gh release list --repo hyodotdev/openiap --limit 5000
echo -e "${RED}❌ Could not list GitHub Releases; install gh and run gh auth login${NC}"
exit 1
fi
-UNPUBLISHED_LINKS=$(
+LINKED_TAGS=$(
{
- grep -oE "hyodotdev/openiap/releases/tag/[A-Za-z0-9._-]+" "$RELEASES_PAGE" | sed 's|.*/tag/||'
- grep -oE "tag: '[^']+'" "$RELEASES_PAGE" | sed -E "s/tag: '(.*)'/\1/"
- } | sort -u | grep -vxF -f <(printf '%s\n' "$PUBLISHED_RELEASES" "${UNPUBLISHED_HISTORY[@]}") || true
+ grep -oE "hyodotdev/openiap/releases/tag/[A-Za-z0-9._-]+" "$RELEASES_PAGE" | sed 's|.*/tag/||' || true
+ grep -oE "tag: '[^']+'" "$RELEASES_PAGE" | sed -E "s/tag: '(.*)'/\1/" || true
+ } | sort -u
)
+if [ -z "$LINKED_TAGS" ]; then
+ echo -e "${RED}❌ Found no release links in $RELEASES_PAGE; update this check${NC}"
+ exit 1
+fi
+UNPUBLISHED_LINKS=$(grep -vxF -f <(printf '%s\n' "$PUBLISHED_RELEASES" "${UNPUBLISHED_HISTORY[@]}") \
+ <<< "$LINKED_TAGS" || true)
if [ -n "$UNPUBLISHED_LINKS" ]; then
echo -e "${YELLOW}⚠️ The release page links releases that are not published yet:${NC}"
echo "$UNPUBLISHED_LINKS"
diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs
index 23a092e42..c689ebf09 100644
--- a/scripts/release-branch-policy.test.mjs
+++ b/scripts/release-branch-policy.test.mjs
@@ -1344,6 +1344,8 @@ test("the docs site deploys without a version of its own", () => {
assert.match(deployScript, /release-branch-policy\.mjs assert-client-protocol/);
assert.match(deployScript, /must deploy from the stable main branch/);
assert.match(deployScript, /requires a clean worktree/);
+ assert.match(deployScript, /is missing; update this check/);
+ assert.match(deployScript, /Found no release links/);
assert.match(deployScript, /packages\/docs\/\.vercel\/project\.json/);
assert.match(
deployScript,
@@ -1474,7 +1476,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
"",
].join("\n"),
);
- // A card for an unreleased train, and a historical link the deploy knows never published.
+ // Unreleased links in both page forms, and a historical one the deploy knows never published.
mkdirSync(resolve(temporaryRoot, "packages/docs/src/pages/docs/updates"), {
recursive: true,
});
@@ -1486,6 +1488,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
[
"const RELEASES = [{ name: 'openiap-google', version: '9.9.9', tag: 'google-9.9.9' }];",
'const OLD = "https://github.com/hyodotdev/openiap/releases/tag/google-3.5.3";',
+ 'const NEW = "https://github.com/hyodotdev/openiap/releases/tag/expo-iap-9.9.9";',
"",
].join("\n"),
);
@@ -1517,7 +1520,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
delete environment.npm_config_force;
environment.MOCK_REAL_NODE = process.execPath;
environment.PATH = `${resolve(temporaryRoot, "mock-bin")}:${process.env.PATH}`;
- environment.MOCK_GH_RELEASES = "google-9.9.9";
+ environment.MOCK_GH_RELEASES = "google-9.9.9 expo-iap-9.9.9";
const runDeploy = (mockOutput = "", environmentOverrides = {}, args = []) =>
spawnSync("bash", ["scripts/deploy.sh", ...args], {
cwd: temporaryRoot,
@@ -1537,6 +1540,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
/links releases that are not published yet/,
);
assert.match(unpublished.stdout, /google-9\.9\.9/);
+ assert.match(unpublished.stdout, /expo-iap-9\.9\.9/);
assert.doesNotMatch(unpublished.stdout, /google-3\.5\.3/);
assert.doesNotMatch(unpublished.stdout, /Successfully deployed to Vercel/);
assert.match(unpublished.stdout, /npm run deploy --force/);
@@ -1669,6 +1673,39 @@ test("production docs require a verified Vercel deployment result", (context) =>
/Successfully deployed to Vercel: https:\/\/openiap-test\.vercel\.app/,
);
+ // A renamed or unlinked releases page fails loudly instead of deploying
+ // with the link check silently skipped.
+ const releasesPage = resolve(
+ temporaryRoot,
+ "packages/docs/src/pages/docs/updates/releases.tsx",
+ );
+ const releasesSource = readFileSync(releasesPage, "utf8");
+ const commitPage = (message) => {
+ execFileSync("git", ["add", "-A"], { cwd: temporaryRoot });
+ execFileSync("git", ["commit", "-q", "-m", message], {
+ cwd: temporaryRoot,
+ });
+ execFileSync("git", ["push", "-q", "origin", "main"], {
+ cwd: temporaryRoot,
+ });
+ };
+ rmSync(releasesPage);
+ commitPage("drop releases page");
+ const missingPage = runDeploy(readyOutput);
+ assert.notEqual(missingPage.status, 0);
+ assert.match(missingPage.stdout, /releases\.tsx is missing; update this check/);
+ assert.doesNotMatch(missingPage.stdout, /Successfully deployed to Vercel/);
+
+ writeFileSync(releasesPage, "export const notes: string[] = [];\n");
+ commitPage("strip release links");
+ const noLinks = runDeploy(readyOutput);
+ assert.notEqual(noLinks.status, 0);
+ assert.match(noLinks.stdout, /Found no release links/);
+ assert.doesNotMatch(noLinks.stdout, /Successfully deployed to Vercel/);
+
+ writeFileSync(releasesPage, releasesSource);
+ commitPage("restore releases page");
+
for (const args of [[], ["--force"]]) {
const syncDirty = runDeploy(readyOutput, { MOCK_SYNC_DIRTY: "1" }, args);
if (args.length === 0) {