From 9eb751b5135fbe0dadda9df73a1c2dceb0a9d036 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 26 Sep 2026 18:10:41 +0000
Subject: [PATCH 1/2] fix: tighten the docs deploy guard and settle the #490
review notes
The docs deploy guard passed when it could not read the release page; it now
stops, and its test covers a literal release link. A library whose pinned
native version moves counts as affected in a release train, so bump-only
library releases are no longer skipped by the affected-set rule. The Planned
Package Releases rejection gets tests, and stale lines in the knowledge docs
and the new release card's heading are corrected.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_017jh5cR6NE24PUBfP9fAEF9
---
.claude/commands/release.md | 6 ++-
knowledge/_agent-context/context.md | 13 +++----
knowledge/internal/04-platform-packages.md | 4 +-
knowledge/internal/05-docs-patterns.md | 2 +-
knowledge/internal/06-git-deployment.md | 5 +--
.../docs/src/pages/docs/updates/releases.tsx | 4 +-
.../google/scripts/verify-release-consumer.sh | 6 +--
scripts/audit-docs.test.ts | 39 +++++++++++++++++++
scripts/audit-docs.ts | 14 +++----
scripts/deploy.sh | 24 ++++++++----
scripts/release-branch-policy.test.mjs | 6 ++-
11 files changed, 84 insertions(+), 39 deletions(-)
diff --git a/.claude/commands/release.md b/.claude/commands/release.md
index 3dec65b42..947a00eeb 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 91c3422dc..ee8bc890a 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-26T17:36:18.906Z
+> Last updated: 2026-09-26T18:10:10.634Z
>
> Canonical file: `knowledge/_agent-context/context.md`
@@ -1468,8 +1468,8 @@ 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
+`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
@@ -2325,7 +2325,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.
@@ -2712,9 +2712,8 @@ This matters most for a PR that changes both `packages/kit/` and
`packages/docs/`: the kit server auto-deploys from `main` while the docs half
stays on the previously deployed build. Server behavior can therefore go live
while the documentation describing it is still unpublished. After merging such a
-PR, deploy the docs and verify both surfaces. If the PR also carries a release
-card, deploy once its train publishes; the deploy refuses unpublished release
-links.
+PR, deploy the docs and verify both surfaces. The deploy refuses while any card
+links an unpublished release, so it waits for a train that is still publishing.
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md
index 71c764be8..d9e8c8904 100644
--- a/knowledge/internal/04-platform-packages.md
+++ b/knowledge/internal/04-platform-packages.md
@@ -386,8 +386,8 @@ 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
+`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
diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md
index f5254f290..e1d350ed2 100644
--- a/knowledge/internal/05-docs-patterns.md
+++ b/knowledge/internal/05-docs-patterns.md
@@ -434,7 +434,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/06-git-deployment.md b/knowledge/internal/06-git-deployment.md
index e30b77b0a..7f3cbe2bb 100644
--- a/knowledge/internal/06-git-deployment.md
+++ b/knowledge/internal/06-git-deployment.md
@@ -368,9 +368,8 @@ This matters most for a PR that changes both `packages/kit/` and
`packages/docs/`: the kit server auto-deploys from `main` while the docs half
stays on the previously deployed build. Server behavior can therefore go live
while the documentation describing it is still unpublished. After merging such a
-PR, deploy the docs and verify both surfaces. If the PR also carries a release
-card, deploy once its train publishes; the deploy refuses unpublished release
-links.
+PR, deploy the docs and verify both surfaces. The deploy refuses while any card
+links an unpublished release, so it waits for a train that is still publishing.
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index 5273584f2..23c88ded7 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 e5140e9e2..90ebce869 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -64,12 +64,14 @@ if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
exit 1
fi
-# A release card merges with its PR, before its packages publish, so production
-# docs wait until every release the page links is out. Release workflows push the
-# tag before publishing and create the GitHub Release last, so a tag alone is not
-# proof.
+# Release cards merge before their packages publish, so deploy only once every
+# linked GitHub Release exists; workflows push the tag first and release last.
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 \
@@ -77,12 +79,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 "${RED}❌ 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 099b08810..ac4b5722d 100644
--- a/scripts/release-branch-policy.test.mjs
+++ b/scripts/release-branch-policy.test.mjs
@@ -1457,7 +1457,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,
});
@@ -1469,6 +1469,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"),
);
@@ -1498,7 +1499,7 @@ test("production docs require a verified Vercel deployment result", (context) =>
delete environment.VERCEL_PROJECT_ID;
delete environment.VERCEL_ORG_ID;
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 = {}) =>
spawnSync("bash", ["scripts/deploy.sh"], {
cwd: temporaryRoot,
@@ -1518,6 +1519,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/);
From 003af9fe7bf448f573434cb9cd7b92fa6ef80b28 Mon Sep 17 00:00:00 2001
From: Muse
Date: Sun, 27 Sep 2026 22:35:03 +0900
Subject: [PATCH 2/2] test: cover the deploy guard's missing-page and no-links
failures
---
scripts/release-branch-policy.test.mjs | 35 ++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs
index 94301b2c7..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,
@@ -1671,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) {