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}
    +
      +
    • ${item}
    • +
    +`; + 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) {