Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,10 @@ Train rules (mistake guards):
- **Affected packages only.** Before dispatching anything, compute the
affected set per package with
`git log <last-release-tag>..origin/main -- <package-path>`. 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
Expand Down
22 changes: 11 additions & 11 deletions knowledge/_agent-context/context.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down Expand Up @@ -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-<store>.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-<store>.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
Expand Down Expand Up @@ -2330,7 +2330,7 @@ Before adding or editing a `Package Releases` list:
with `gh release view <tag> --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.

Expand Down
18 changes: 9 additions & 9 deletions knowledge/internal/04-platform-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<store>.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-<store>.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
Expand Down
2 changes: 1 addition & 1 deletion knowledge/internal/05-docs-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ Before adding or editing a `Package Releases` list:
with `gh release view <tag> --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.

Expand Down
4 changes: 1 addition & 3 deletions packages/docs/src/pages/docs/updates/releases.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,9 +472,7 @@ function Releases() {
.
</p>

<h5 style={{ margin: '0 0 0.5rem 0' }}>
Protocols and native packages
</h5>
<h5 style={{ margin: '0 0 0.5rem 0' }}>Native packages</h5>
<ul
style={{
marginBottom: '1rem',
Expand Down
6 changes: 2 additions & 4 deletions packages/google/scripts/verify-release-consumer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ mapping_has_package() {
awk -v prefix="$2" 'index($0, prefix) == 1 { found = 1; exit } END { exit !found }' "$1"
}

# The Play module reaches newer Play Billing APIs by name, looking up classes and
# methods and matching listener callbacks by method name, so it still runs when
# an app pins an older billing version. Reading the names from its source checks
# a new lookup as soon as it lands.
# The Play module reaches newer Play Billing APIs by name so it runs on older
# billing versions; reading those names from its source checks each new lookup.
play_source=$(find "$google_root/openiap/src/play" -name '*.kt' -exec cat {} + | tr -s '[:space:]' ' ')
play_classes=$(grep -oE 'Class\.forName\( ?"com\.android\.billingclient\.api\.[^"]+"' <<< "$play_source" \
| sed -E 's/.*"(.*)"/\1/; s/\\\$/$/g' | sort -u || true)
Expand Down
39 changes: 39 additions & 0 deletions scripts/audit-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
auditActiveCodeExampleSource,
auditCanonicalOfferDocs,
auditReleaseNotePackageLinks,
auditReleaseNoteVersionAnchors,
auditSubscriptionFailureDocs,
auditVerifyPurchaseDocs,
Expand Down Expand Up @@ -1533,3 +1534,41 @@ ${extra} date: new Date('2026-09-17'),
).toEqual([]);
});
});

describe("release note package links", () => {
const block = (heading: string, item: string) => `
<h5>${heading}</h5>
<ul>
<li>${item}</li>
</ul>
`;
const linked =
'<a href="https://github.com/hyodotdev/openiap/releases/tag/google-9.9.9">openiap-google 9.9.9</a>';

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");
});
});
14 changes: 7 additions & 7 deletions scripts/audit-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
/<h5[^>]*>\s*(Planned Package Releases|Package Releases)\s*<\/h5>/g;
Expand Down
18 changes: 14 additions & 4 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,29 @@ 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 \
--exclude-drafts --json tagName --jq '.[].tagName'); then
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"
Expand Down
41 changes: 39 additions & 2 deletions scripts/release-branch-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand All @@ -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"),
);
Expand Down Expand Up @@ -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,
Expand All @@ -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/);
Expand Down Expand Up @@ -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) {
Expand Down
Loading