diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md index e7a25dedb..da770402b 100644 --- a/.claude/commands/audit-code.md +++ b/.claude/commands/audit-code.md @@ -221,11 +221,12 @@ Update the documentation site for users: - Do not add RC or npm `next` publications from the `next` branch. Preserve the change evidence and add one grouped entry when the train reaches stable `main`. - Verify every package version from its real metadata before writing the release list: - `openiap-versions.json` only for `spec`, `google`, and `apple`; + `openiap-versions.json` only for `clientProtocol`, `google`, and `apple`; framework versions come from each library's package metadata -- Derive planned versions from the explicit release plan and stable metadata; +- Derive expected versions from the explicit release plan and stable metadata; release workflows own package-version commits -- Add GitHub Release links only after `gh release view ` confirms the tag exists +- Link each package to its expected GitHub Release tag, per "Docs Ship With The + Change" in `knowledge/internal/05-docs-patterns.md` - Document ALL changes: new features, bug fixes, breaking changes - Add entry at the TOP of `allNotes` array (newest first) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index e160a9c6c..3dec65b42 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -168,11 +168,15 @@ Train rules (mistake guards): Maven Central POMs publicly fetchable) and its package metadata is synchronized on `main`. Workflow success is not deployment; poll the registry. -- **Release notes last.** After every package in the train is - registry-verified, add the consolidated entry to - `packages/docs/src/pages/docs/updates/releases.tsx` (see `generate-doc`), +- **Release notes ship in the PR.** The consolidated card in + `packages/docs/src/pages/docs/updates/releases.tsx` merged with the change, + written ahead of the release (see `generate-doc`). After every package in the + train is registry-verified, check the card's versions and links against the + published releases and correct any that differ; if that needs an edit, commit it directly to `main` together with any release-process doc updates, - and do not open a PR for that post-release docs-only commit; then run the docs + and do not open a PR for that post-release docs-only commit. The deploy + refuses a page that links an unpublished release, so a train that stops + partway resumes or trims its card to what published first. Then run the docs deployment. There is no Docs release workflow and no docs tag; if a Docs GitHub Release is requested, explain that the docs site is not a versioned artifact. @@ -236,9 +240,9 @@ independent version edits: tag. A branch-ref checkout of an existing tag does not align npm's OIDC event SHA. If the tag predates this publisher lane, do not retrofit provenance; release a new reviewed version. -9. After every affected artifact is publicly available, use `generate-doc` to - add one consolidated release entry with the actual published versions and - GitHub Release links, then deploy docs last. A docs deployment creates no +9. After every affected artifact is publicly available, check the release card + that merged with the PR against the published versions and GitHub Release + links (see `generate-doc`), correct what differs, then deploy docs last. A docs deployment creates no tag and no GitHub Release. Every `current` retry that finds an existing tag must run diff --git a/.claude/skills/generate-doc/SKILL.md b/.claude/skills/generate-doc/SKILL.md index 55ccdd562..df839de4a 100644 --- a/.claude/skills/generate-doc/SKILL.md +++ b/.claude/skills/generate-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-doc -description: Use for OpenIAP documentation generation work, especially pre-deployment release-note entries in packages/docs/src/pages/docs/updates/releases.tsx that must name the expected native and framework versions, link their future GitHub Releases, and update an existing unreleased train instead of creating a duplicate. +description: Use for OpenIAP documentation generation work, especially the release-note card each PR carries in packages/docs/src/pages/docs/updates/releases.tsx, written as already published with the expected native and framework versions and their future GitHub Release links, updating an existing unreleased train instead of creating a duplicate. --- # Generate OpenIAP Docs (Claude Code) diff --git a/.claude/skills/loop-review/SKILL.md b/.claude/skills/loop-review/SKILL.md index 1ccaeaae1..975ad4c4c 100644 --- a/.claude/skills/loop-review/SKILL.md +++ b/.claude/skills/loop-review/SKILL.md @@ -1,6 +1,6 @@ --- name: loop-review -description: "Run OpenIAP's complete latest-main-to-production loop: review-self, PR review and merge, exact-main cleanup, sequential stable releases, consolidated release docs, and production docs deployment." +description: "Run OpenIAP's complete latest-main-to-production loop: review-self, PR review and merge, exact-main cleanup, sequential stable releases, release-note verification, and production docs deployment." --- # Loop Review (Claude Code) @@ -18,8 +18,9 @@ Use Claude Code's matching skills or commands for each delegated phase: - `/e2e-tests` for the device-regression gate — hand back to the user to run it rather than merging, since it needs real devices and store accounts. - `/ship-release` for affected-only sequential stable publication, registry - verification, consolidated release docs, and production docs deployment. -- `/generate-doc` for the release-note entry and exact package links. + verification, release-note verification, and production docs deployment. +- `/generate-doc` for the release card the PR carries and its expected package + links. - `ScheduleWakeup` for every five-minute re-entry; never use a shell sleep loop. Do not merge until the canonical exact-head clean gate is satisfied, and stop diff --git a/.claude/skills/ship-release/SKILL.md b/.claude/skills/ship-release/SKILL.md index 02d0f917d..bc50d79b6 100644 --- a/.claude/skills/ship-release/SKILL.md +++ b/.claude/skills/ship-release/SKILL.md @@ -1,6 +1,6 @@ --- name: ship-release -description: Merge a verified OpenIAP PR, release affected stable packages sequentially with public verification, publish release docs, run review-self to stability, and deploy production docs when the user explicitly requests the full shipping workflow. +description: Merge a verified OpenIAP PR, release affected stable packages sequentially with public verification, verify the release note the PR carried, stabilize any correction with review-self, and deploy production docs when the user explicitly requests the full shipping workflow. --- # Ship an OpenIAP Release (Claude Code) diff --git a/.codex/skills/generate-doc/SKILL.md b/.codex/skills/generate-doc/SKILL.md index 9b863bbaf..125594074 100644 --- a/.codex/skills/generate-doc/SKILL.md +++ b/.codex/skills/generate-doc/SKILL.md @@ -1,13 +1,12 @@ --- name: generate-doc -description: Use for OpenIAP documentation generation work, especially pre-deployment release-note entries in packages/docs/src/pages/docs/updates/releases.tsx that must name the expected native and framework versions, link their future GitHub Releases, and update an existing unreleased train instead of creating a duplicate. +description: Use for OpenIAP documentation generation work, especially the release-note card each PR carries in packages/docs/src/pages/docs/updates/releases.tsx, written as already published with the expected native and framework versions and their future GitHub Release links, updating an existing unreleased train instead of creating a duplicate. --- # Generate OpenIAP Docs -Use this skill when the user asks to generate or update OpenIAP docs, especially -release notes that should be written as if package releases are already -published. +Use this skill when the user asks to generate or update OpenIAP docs, and for +the release card every PR into `main` that changes a published package carries. ## Required Reading @@ -24,15 +23,16 @@ read the package or library convention file before editing that code. ## Release Note Mode -Current scope: pre-deployment notes written in assumed-published form. +A PR into `main` writes its release card before the release, as already +published; "Docs Ship With The Change" in +`knowledge/internal/05-docs-patterns.md` is the canonical rule. RC and npm `next` releases live on the on-demand `next` branch and do not get a release-history entry. Gather their changes as source material, but add the consolidated docs entry only when the train is promoted to a stable release on `main`. Production `npm run deploy` is stable-only. -Use shipped wording only when the user explicitly says to assume deployment or -write the docs as already released. In that mode: +Write every card this way: - Use `Package Releases`, not `Planned Package Releases`. - Link expected release/package URLs exactly as the release will publish them. @@ -41,9 +41,8 @@ write the docs as already released. In that mode: - State in your response that the links are expected release links until actual deployment is complete. -For non-assumed releases, follow the release-note verification rules in -`knowledge/internal/05-docs-patterns.md` and -`knowledge/internal/06-git-deployment.md`; do not invent shipped links. +After the train publishes, compare each version and link with the published +releases and correct only what differs. ## Existing Unreleased Train @@ -128,7 +127,7 @@ remain available. Write every resolved target into the release card with its expected tag link. Do not leave versionless package bullets, `(planned)` labels, or a -`Planned Package Releases` list in assumed-published mode. Ask for confirmation +`Planned Package Releases` list. Ask for confirmation only when repository evidence names conflicting target versions or it is unclear whether work belongs to the existing train. @@ -144,8 +143,7 @@ Follow the existing card pattern: - Use a stable kebab-case `id` with the date. - Use `new Date('YYYY-MM-DD')`. - Use `AnchorLink` for the heading. -- Keep package links in a `Package Releases` list when using assumed-published - mode. +- Keep package links in a `Package Releases` list. - Name the expected version in each package-specific bullet, as well as in the linked `Package Releases` list. - Link issues and PRs when they exist. diff --git a/.codex/skills/loop-review/SKILL.md b/.codex/skills/loop-review/SKILL.md index 23cedfcc2..dcd166fc7 100644 --- a/.codex/skills/loop-review/SKILL.md +++ b/.codex/skills/loop-review/SKILL.md @@ -1,6 +1,6 @@ --- name: loop-review -description: "Run OpenIAP's complete change-to-production loop from the latest origin/main: implement and verify, stabilize with review-self, open and review a PR until its exact head is clean, merge, return to an exact clean main, release affected stable packages sequentially, publish the consolidated release note, and deploy production docs." +description: "Run OpenIAP's complete change-to-production loop from the latest origin/main: implement and verify with the docs and release note in the PR, stabilize with review-self, open and review a PR until its exact head is clean, merge, return to an exact clean main, release affected stable packages sequentially, verify the release note, and deploy production docs." --- # Loop Review @@ -67,8 +67,10 @@ work. Implement the requested scope and run the checks required by each touched path. Keep generated files, documentation, previews, and knowledge context in sync -through their canonical workflows. Do not proceed while the working diff has a -known failing required check. +through their canonical workflows. A change to a published package also updates +the guides it affects and the release card for the next version, written as +already published with `$generate-doc`. Do not proceed while the working diff +has a known failing required check. Once it works, clean it up before review: apply "Clean Up Once It Works" in `knowledge/internal/03-coding-style.md` to the diff and to smells met along the @@ -199,11 +201,11 @@ Follow `.codex/skills/ship-release/SKILL.md` as the release SSOT: Before each release, require an exact clean `main`; after each release-bot commit, fast-forward `main` again. Do not start the next release until the GitHub Release and public registry or downloadable artifact are verified. -3. Use `$generate-doc` to add one consolidated release note with the exact - published versions and GitHub Release links. Update the existing unreleased - train instead of creating a duplicate when one exists. -4. Run `$review-self` over the complete docs and workflow diff until two - consecutive five-minute snapshots are clean. Any edit resets the count. +3. Check the release card that merged with the PR against the exact published + versions and GitHub Release links. If nothing differs, go to step 6. +4. Correct what differs with `$generate-doc`, then run `$review-self` over that + docs diff until two consecutive five-minute snapshots are clean. Any edit + resets the count. 5. Commit and push the reviewed release note and process-documentation changes directly to `main`. If review finds a product-code fix, return it to the PR loop instead of committing that fix directly to `main`. Do not open a PR for @@ -229,4 +231,6 @@ describe a pending or partially reviewed PR as clean. After merge, stop the shipping phase when an affected release fails, its public artifact cannot be verified, production docs cannot be verified, or continuing would require a code change outside the reviewed PR. Preserve every successful -release and report the exact resume point. +release and report the exact resume point. Do not deploy docs while the card +links an unpublished release; if the train will not resume, trim the card to +what published through steps 4 and 5 first. diff --git a/.codex/skills/review-self/SKILL.md b/.codex/skills/review-self/SKILL.md index f4c344649..e12e935ee 100644 --- a/.codex/skills/review-self/SKILL.md +++ b/.codex/skills/review-self/SKILL.md @@ -52,7 +52,8 @@ result is stable. - public contracts, naming, compatibility, generated-file rules, and cross-package or SDK parity; - missing or weak tests, documentation, examples, migrations, and operational - safeguards required by the change; + safeguards required by the change, including, for a published package, + the guides it affects and its release card for the next version; - the canonical KISS/SSOT release rules in `knowledge/internal/03-coding-style.md`; - code smells in the diff, the code it touches, and any code read during diff --git a/.codex/skills/ship-release/SKILL.md b/.codex/skills/ship-release/SKILL.md index 86a09d421..94a967eb7 100644 --- a/.codex/skills/ship-release/SKILL.md +++ b/.codex/skills/ship-release/SKILL.md @@ -1,6 +1,6 @@ --- name: ship-release -description: Merge a verified OpenIAP PR, release every affected stable package one at a time, verify each public registry, publish the consolidated release note, stabilize it with review-self, and deploy production docs. Use when the user explicitly asks for this full post-review shipping workflow. +description: Merge a verified OpenIAP PR, release every affected stable package one at a time, verify each public registry, verify the release note the PR carried, stabilize any correction with review-self, and deploy production docs. Use when the user explicitly asks for this full post-review shipping workflow. --- # Ship an OpenIAP Release @@ -67,7 +67,10 @@ For each package: the new version before starting the next package. Do not run package releases concurrently. Stop on the first failed gate and -report the exact workflow job and package state. +report the exact workflow job and package state. Leave production docs +undeployed while the card links a release that has not published; if the train +will not resume, trim the card to the packages that published, through §4, +before deploying. Godot releases also require the authenticated Godot Asset Library listing to be updated. Prepare the edit when possible, request action-time confirmation before @@ -75,22 +78,24 @@ the public form submission, and report it as an explicit remaining manual step when authentication is unavailable. Never reuse credentials supplied for a different service. -## 3. Write the shipped release note +## 3. Verify the release note -After every package version and public URL is known, use `generate-doc` to add -or update the consolidated release card in -`packages/docs/src/pages/docs/updates/releases.tsx`. +The merged PR carried the consolidated release card in +`packages/docs/src/pages/docs/updates/releases.tsx` (see `generate-doc`). After +every package version and public URL is known: -- Read versions from current package metadata, not from the release plan. -- Link the real package tags. The docs/spec tag may be the expected tag until - the docs release is created. +- Compare each version and link on the card with current package metadata and + the published tags, and correct any that differ. +- If the PR carried no card, add it with `generate-doc` and report the gap. - Lead with user-visible behavior, include required migration or platform caveats once, and omit version-bump mechanics. - Add no versioned IAPKit entry; it is a service. -## 4. Stabilize and commit +If nothing differs, go to §5. -Run `review-self` against the complete docs and workflow diff until two +## 4. Stabilize and commit a correction + +When §3 changed the card, run `review-self` against the docs diff until two consecutive full snapshots are clean at least five minutes apart. A material change resets the clean count. Run all path-specific validation, including the docs build, docs and release-state audits, skill validation, and diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index 685d4a7fe..72f1e4662 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -35,7 +35,7 @@ jobs: compile-check: name: Compile Check runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -66,6 +66,17 @@ jobs: :example:composeApp:assembleHorizonDebug \ :example:composeApp:assembleAmazonDebug + # R8 runs only in an app's release build, over kmp-iap and openiap-google. + - name: Build minified release apps per store + run: | + "$GITHUB_WORKSPACE/scripts/ci/retry-gradle.sh" ./gradlew \ + --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx8192M \ + -Dkotlin.daemon.jvm.options=-Xmx4096M \ + :example:composeApp:assemblePlayRelease \ + :example:composeApp:assembleHorizonRelease \ + :example:composeApp:assembleAmazonRelease + ios-compile-check: name: iOS Compile Check runs-on: macos-26 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b74d7f5d..01a614e7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -599,6 +599,11 @@ jobs: working-directory: packages/google run: bash scripts/verify-store-plugin.sh + # Nothing above runs R8; an app's release build does. + - name: Verify minified release builds per store + working-directory: packages/google + run: bash scripts/verify-release-consumer.sh + # Run every store flavor so flavor-specific API-23 regressions cannot # bypass lint coverage. - name: Lint Android API compatibility diff --git a/AGENTS.md b/AGENTS.md index 6b214411d..69cd502b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,14 @@ canonical standard in [`knowledge/internal/05-docs-patterns.md`](knowledge/internal/05-docs-patterns.md#reader-first-writing-standard), including its stricter release-note limits. +### Docs Ship With The Change + +A PR into `main` that changes a published package also updates the guides it +affects and the release card for the next version, written as already +published; after the release, only verify the versions and deploy. Canonical +rule in +[`knowledge/internal/05-docs-patterns.md`](knowledge/internal/05-docs-patterns.md#docs-ship-with-the-change). + ### GitHub Writing Style Write pull request bodies, review replies, issue comments, and release text the diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index 06a11f966..91c3422dc 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-25T17:11:40.022Z +> Last updated: 2026-09-26T17:36:18.906Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -1453,11 +1453,6 @@ a Horizon build link the Play SDK and now fail at the task-graph check: cd packages/google && bash scripts/verify-store-resolver.sh ``` -`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved -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. - It applies the real resolver to the fixture in `packages/google/compatibility/store-resolver`, so no Android SDK, device, or network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and @@ -1468,6 +1463,21 @@ covers pins and their aliases, the legacy flags and their conflicts, the for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached devices, release builds, `clean`, and the configuration cache. +`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved +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. + **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 cannot serve that billing SDK, which is after release. The suite is the only @@ -1505,6 +1515,11 @@ the runtime routes by install source; nothing is guessed at build time. no connection to drop, so its implementation is a documented no-op ([#408](https://github.com/hyodotdev/openiap/issues/408)). `bun audit:parity` pins the notification in both flavors. +8. **Call openiap directly, never by reflection.** R8 removes what only + reflection reaches, so the call works in debug and silently does nothing in + a minified release. A method that only some flavors support belongs on + `OpenIapProtocol`, with a no-op in the others. `bun audit:parity` rejects + reflective lookups in the shared source and the framework bridges. ### Build Commands @@ -2178,6 +2193,21 @@ Framework implementation listings must be derived from Release notes are located at `packages/docs/src/pages/docs/updates/releases.tsx`. +### Docs Ship With The Change + +A PR into `main` that changes a published package carries its documentation: +the guides the change affects and the release card for the next version. Write +the card as already published, because the train ships right after the merge: a +`Package Releases` block with the expected versions and their future GitHub +Release links, and shipped wording such as "fixes" or "adds". When an +unreleased card for the same train exists, update it instead of adding another. +After the train publishes, the release only verifies each version and link and +corrects the card on `main` where one differs. + +Production docs wait for the train: `npm run deploy` refuses a release page that +links a release not yet published. If a train stops partway and will not +resume, trim its card on `main` to the packages that published before deploying. + ### Release Note Writing Limits Apply the project-wide Reader-First Writing Standard above. Release notes are a @@ -2284,21 +2314,17 @@ Before adding or editing a `Package Releases` list: 1. `git fetch origin main --tags` (or `git fetch --no-tags origin main` if local stale tags would fail). 2. Read the current package metadata from `origin/main`, not from memory. -3. For planned patch releases, add exactly one patch version to each affected - framework package and label the block `Planned Package Releases`. -4. If the user explicitly asks to write the note as already released, says to - "assume it will be deployed/published", or asks to follow the existing linked - release-note style, do **not** use `Planned Package Releases` or - `(planned)`. Write the block as `Package Releases`, add the expected GitHub - Release tag link (for example `godot-iap-2.2.8`), and use shipped wording - such as "Publishes" / "Ships" instead of "Prepares". -5. For links to releases that should already exist in GitHub, confirm each tag - exists with `gh release view --repo hyodotdev/openiap` before adding an - ``. This existence check is skipped only when step 4 applies because - the user explicitly requested an assumed post-release note. -6. If a release workflow is still running and the user has not requested an - already-released note, keep the entry as plain text with planned wording. Add - links only after the GitHub Release exists. +3. Give each affected package its expected next version: the next patch for a + backward-compatible fix, the next minor for a backward-compatible feature, + the next major for a breaking change. Reuse the targets on an unreleased + card for the same train. +4. Write the block as `Package Releases` with each expected tag link (for + example `godot-iap-2.2.8`), per "Docs Ship With The Change". Do not use + `Planned Package Releases` or `(planned)`. +5. When editing a card whose train already published, confirm each tag exists + 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 `Package Releases` block contains a package/version item without a GitHub Release link. @@ -2686,11 +2712,15 @@ 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. +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. Production documentation is stable-only and must deploy from a clean `main` checkout that exactly matches `origin/main`. The script rejects prerelease spec -versions, other branches, and stale or unpublished local snapshots. +versions, other branches, stale or unpublished local snapshots, and a release +page that links a GitHub Release not yet published, which it lists with an +authenticated `gh`. On a fresh checkout, first run `cd packages/docs && vercel link` and select the existing OpenIAP project. Deployment stops when that local project link is @@ -2779,10 +2809,11 @@ Use these checks before writing a release list: | KMP | `sed -n 's/^libraryVersion=//p' libraries/kmp-iap/gradle.properties`; tag `kmp-iap-{version}` | | MAUI | read `` from `libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj`; tag `maui-iap-{version}` | -If the release is not published yet, use planned wording and plain text. If the -release is published, verify the tag exists with `gh release view ` before -linking it. This prevents stale Package Releases tables such as documenting -`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. +A PR writes its card ahead of the release with the expected tag links, per +"Docs Ship With The Change" in `05-docs-patterns.md`. After the release +publishes, verify each tag with `gh release view ` and correct the card +where a version differs. This prevents stale Package Releases tables such as +documenting `maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. Do not add RC or npm `next` releases to the stable release history. Collect their user-facing changes and write one package-grouped entry when the release @@ -3043,12 +3074,13 @@ strips the `Android` suffix from method names. ### R9 — Published package release lists use links When a release-note block is labeled `Package Releases`, every package/version -item in that list must link to the corresponding GitHub Release. Use -`Planned Package Releases` only while the release workflow is still running or -the GitHub Release does not exist yet. +item in that list must link to the corresponding GitHub Release. A card written +in a PR ahead of its release links the expected tags instead of using `Planned +Package Releases`, per "Docs Ship With The Change" in `05-docs-patterns.md`. -`bun run audit:docs` fails bare package/version entries under published -`Package Releases` blocks so link regressions are caught before publishing. +`bun run audit:docs` fails bare package/version entries under `Package Releases` +blocks and any `Planned Package Releases` heading, so link regressions are +caught before publishing. RC and npm `next` releases are managed on the on-demand `next` branch and do not get release-history entries. Add one grouped entry only when the train is diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 9506f37b7..71c764be8 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -371,11 +371,6 @@ a Horizon build link the Play SDK and now fail at the task-graph check: cd packages/google && bash scripts/verify-store-resolver.sh ``` -`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved -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. - It applies the real resolver to the fixture in `packages/google/compatibility/store-resolver`, so no Android SDK, device, or network is needed; `compatibility/store-resolver/fake-adb` stands in for adb and @@ -386,6 +381,21 @@ covers pins and their aliases, the legacy flags and their conflicts, the for Quest, Fire and everything else, `ANDROID_SERIAL`, several attached devices, release builds, `clean`, and the configuration cache. +`scripts/verify-store-plugin.sh` covers what the plugin adds: that the resolved +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. + **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 cannot serve that billing SDK, which is after release. The suite is the only @@ -423,6 +433,11 @@ the runtime routes by install source; nothing is guessed at build time. no connection to drop, so its implementation is a documented no-op ([#408](https://github.com/hyodotdev/openiap/issues/408)). `bun audit:parity` pins the notification in both flavors. +8. **Call openiap directly, never by reflection.** R8 removes what only + reflection reaches, so the call works in debug and silently does nothing in + a minified release. A method that only some flavors support belongs on + `OpenIapProtocol`, with a no-op in the others. `bun audit:parity` rejects + reflective lookups in the shared source and the framework bridges. ### Build Commands diff --git a/knowledge/internal/05-docs-patterns.md b/knowledge/internal/05-docs-patterns.md index 3d54f606d..f5254f290 100644 --- a/knowledge/internal/05-docs-patterns.md +++ b/knowledge/internal/05-docs-patterns.md @@ -302,6 +302,21 @@ Framework implementation listings must be derived from Release notes are located at `packages/docs/src/pages/docs/updates/releases.tsx`. +### Docs Ship With The Change + +A PR into `main` that changes a published package carries its documentation: +the guides the change affects and the release card for the next version. Write +the card as already published, because the train ships right after the merge: a +`Package Releases` block with the expected versions and their future GitHub +Release links, and shipped wording such as "fixes" or "adds". When an +unreleased card for the same train exists, update it instead of adding another. +After the train publishes, the release only verifies each version and link and +corrects the card on `main` where one differs. + +Production docs wait for the train: `npm run deploy` refuses a release page that +links a release not yet published. If a train stops partway and will not +resume, trim its card on `main` to the packages that published before deploying. + ### Release Note Writing Limits Apply the project-wide Reader-First Writing Standard above. Release notes are a @@ -408,21 +423,17 @@ Before adding or editing a `Package Releases` list: 1. `git fetch origin main --tags` (or `git fetch --no-tags origin main` if local stale tags would fail). 2. Read the current package metadata from `origin/main`, not from memory. -3. For planned patch releases, add exactly one patch version to each affected - framework package and label the block `Planned Package Releases`. -4. If the user explicitly asks to write the note as already released, says to - "assume it will be deployed/published", or asks to follow the existing linked - release-note style, do **not** use `Planned Package Releases` or - `(planned)`. Write the block as `Package Releases`, add the expected GitHub - Release tag link (for example `godot-iap-2.2.8`), and use shipped wording - such as "Publishes" / "Ships" instead of "Prepares". -5. For links to releases that should already exist in GitHub, confirm each tag - exists with `gh release view --repo hyodotdev/openiap` before adding an - ``. This existence check is skipped only when step 4 applies because - the user explicitly requested an assumed post-release note. -6. If a release workflow is still running and the user has not requested an - already-released note, keep the entry as plain text with planned wording. Add - links only after the GitHub Release exists. +3. Give each affected package its expected next version: the next patch for a + backward-compatible fix, the next minor for a backward-compatible feature, + the next major for a breaking change. Reuse the targets on an unreleased + card for the same train. +4. Write the block as `Package Releases` with each expected tag link (for + example `godot-iap-2.2.8`), per "Docs Ship With The Change". Do not use + `Planned Package Releases` or `(planned)`. +5. When editing a card whose train already published, confirm each tag exists + 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 `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 23fb0de32..e30b77b0a 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -368,11 +368,15 @@ 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. +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. Production documentation is stable-only and must deploy from a clean `main` checkout that exactly matches `origin/main`. The script rejects prerelease spec -versions, other branches, and stale or unpublished local snapshots. +versions, other branches, stale or unpublished local snapshots, and a release +page that links a GitHub Release not yet published, which it lists with an +authenticated `gh`. On a fresh checkout, first run `cd packages/docs && vercel link` and select the existing OpenIAP project. Deployment stops when that local project link is @@ -461,10 +465,11 @@ Use these checks before writing a release list: | KMP | `sed -n 's/^libraryVersion=//p' libraries/kmp-iap/gradle.properties`; tag `kmp-iap-{version}` | | MAUI | read `` from `libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj`; tag `maui-iap-{version}` | -If the release is not published yet, use planned wording and plain text. If the -release is published, verify the tag exists with `gh release view ` before -linking it. This prevents stale Package Releases tables such as documenting -`maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. +A PR writes its card ahead of the release with the expected tag links, per +"Docs Ship With The Change" in `05-docs-patterns.md`. After the release +publishes, verify each tag with `gh release view ` and correct the card +where a version differs. This prevents stale Package Releases tables such as +documenting `maui-iap 1.0.1` when the actual release tag is `maui-iap-1.0.3`. Do not add RC or npm `next` releases to the stable release history. Collect their user-facing changes and write one package-grouped entry when the release diff --git a/knowledge/internal/07-docs-consistency.md b/knowledge/internal/07-docs-consistency.md index 80fa45843..50cf7b043 100644 --- a/knowledge/internal/07-docs-consistency.md +++ b/knowledge/internal/07-docs-consistency.md @@ -196,12 +196,13 @@ strips the `Android` suffix from method names. ### R9 — Published package release lists use links When a release-note block is labeled `Package Releases`, every package/version -item in that list must link to the corresponding GitHub Release. Use -`Planned Package Releases` only while the release workflow is still running or -the GitHub Release does not exist yet. +item in that list must link to the corresponding GitHub Release. A card written +in a PR ahead of its release links the expected tags instead of using `Planned +Package Releases`, per "Docs Ship With The Change" in `05-docs-patterns.md`. -`bun run audit:docs` fails bare package/version entries under published -`Package Releases` blocks so link regressions are caught before publishing. +`bun run audit:docs` fails bare package/version entries under `Package Releases` +blocks and any `Planned Package Releases` heading, so link regressions are +caught before publishing. RC and npm `next` releases are managed on the on-demand `next` branch and do not get release-history entries. Add one grouped entry only when the train is diff --git a/libraries/kmp-iap/example/composeApp/build.gradle.kts b/libraries/kmp-iap/example/composeApp/build.gradle.kts index 330ca4967..7d96bbfa9 100644 --- a/libraries/kmp-iap/example/composeApp/build.gradle.kts +++ b/libraries/kmp-iap/example/composeApp/build.gradle.kts @@ -161,12 +161,19 @@ android { } } + // Shrunk like a shipped app, so CI's release builds run R8 over kmp-iap. buildTypes { getByName("release") { - isMinifyEnabled = false + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } + // CI builds release to check R8; lint has its own checks. + lint { + checkReleaseBuilds = false + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index 136ab56a6..6fe37ed8c 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -312,10 +312,12 @@ android { create("horizon") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") + consumerProguardFiles("consumer-rules-non-play.pro") } create("amazon") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"amazon\"") + consumerProguardFiles("consumer-rules-non-play.pro") } } buildFeatures { diff --git a/libraries/kmp-iap/library/consumer-rules-non-play.pro b/libraries/kmp-iap/library/consumer-rules-non-play.pro new file mode 100644 index 000000000..fac209de3 --- /dev/null +++ b/libraries/kmp-iap/library/consumer-rules-non-play.pro @@ -0,0 +1,3 @@ +# The shared Android code holds the Play Billing implementation, which Horizon +# and Amazon builds never run and don't ship Play Billing for. +-dontwarn com.android.billingclient.** diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt index 246cab5a2..a13e75cf6 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt @@ -10,6 +10,7 @@ import android.app.Application import android.content.Context import android.os.Bundle import dev.hyo.openiap.OpenIapError as AndroidOpenIapError +import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.OpenIapProtocol as AndroidOpenIapProtocol import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener @@ -374,11 +375,7 @@ internal class OpenIapDelegateInAppPurchaseAndroid( private fun requireModule(): AndroidOpenIapProtocol = module ?: failWith(PurchaseError(code = ErrorCode.NotPrepared, message = "$storeName billing module not initialized")) - private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol { - val clazz = Class.forName("dev.hyo.openiap.OpenIapModule") - val constructor = clazz.getConstructor(Context::class.java) - return constructor.newInstance(ctx) as AndroidOpenIapProtocol - } + private fun buildOpenIapModule(ctx: Context): AndroidOpenIapProtocol = OpenIapModule(ctx) private fun registerListeners(openModule: AndroidOpenIapProtocol) { val purchaseUpdate = OpenIapPurchaseUpdateListener { purchase -> diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index feabdcb66..a33f0c55a 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -327,19 +327,10 @@ class HybridRnIap : HybridRnIapSpec() { "userChoiceBillingListener", mapOf("products" to details.products, "token" to details.externalTransactionToken) ) - val originalTransactionId = runCatching { - details.javaClass - .getMethod("getOriginalExternalTransactionId") - .invoke(details) as? String - }.getOrNull() - val productDetails = runCatching { - (details.javaClass.getMethod("getProductDetailsAndroid").invoke(details) as? List<*>) - ?.mapNotNull { it as? dev.hyo.openiap.DeveloperProvidedBillingProductAndroid } - }.getOrNull() val nitroDetails = UserChoiceBillingDetails( externalTransactionToken = details.externalTransactionToken, - originalExternalTransactionId = originalTransactionId.wrapVariant(), - productDetailsAndroid = productDetails?.map { product -> + originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant(), + productDetailsAndroid = details.productDetailsAndroid?.map { product -> DeveloperProvidedBillingProductAndroid( id = product.id, offerToken = product.offerToken.wrapVariant(), diff --git a/packages/docs/src/pages/docs/android-setup.tsx b/packages/docs/src/pages/docs/android-setup.tsx index f681dd22b..7f7ac2178 100644 --- a/packages/docs/src/pages/docs/android-setup.tsx +++ b/packages/docs/src/pages/docs/android-setup.tsx @@ -243,14 +243,15 @@ dependencies {

- 4. Configure ProGuard (if using) + 4. R8 and ProGuard #

-

Add to your proguard-rules.pro:

-
{`-keep class com.android.billingclient.** { *; }
--keep class com.android.vending.billing.** { *; }`}
+

+ No rules to add. From 3.6.1, each store artifact ships the keep rules + its billing SDK needs, so minified release builds work as they are. +

diff --git a/packages/docs/src/pages/docs/setup/flutter.tsx b/packages/docs/src/pages/docs/setup/flutter.tsx index 43de78bb7..28f250d9a 100644 --- a/packages/docs/src/pages/docs/setup/flutter.tsx +++ b/packages/docs/src/pages/docs/setup/flutter.tsx @@ -235,16 +235,12 @@ function FlutterSetup() { "no pin", so the opt-out beside it still applies.

-

ProGuard Rules (if using ProGuard)

+

R8 and ProGuard

- Add to your android/app/proguard-rules.pro: + No rules to add. From 10.7.1, the Android store artifact ships the + keep rules its billing SDK needs, so minified release builds work as + they are.

- - {`# In-App Purchase --keep class dev.hyo.** { *; } --keep class com.android.vending.billing.** --keepattributes *Annotation*`} -
diff --git a/packages/docs/src/pages/docs/setup/kmp.tsx b/packages/docs/src/pages/docs/setup/kmp.tsx index edbfb5cba..c977e5d61 100644 --- a/packages/docs/src/pages/docs/setup/kmp.tsx +++ b/packages/docs/src/pages/docs/setup/kmp.tsx @@ -248,13 +248,12 @@ openiapStore=horizon`} for the full rule.

-

ProGuard Rules (if using ProGuard)

- - {`# In-App Purchase --keep class com.android.billingclient.** { *; } --keep class io.github.hyochan.kmpiap.** { *; } --keepattributes *Annotation*`} - +

R8 and ProGuard

+

+ No rules to add. From 3.6.1, kmp-iap and the openiap store artifact + ship the R8 rules each store needs, so minified release builds work as + they are. +

diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index a8eed3397..5273584f2 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -72,6 +72,24 @@ const FRAMEWORK_PLAY_FIX_RELEASES: readonly ReleaseMetadata[] = [ { name: 'expo-iap', version: '5.6.3', tag: 'expo-iap-5.6.3' }, ]; +const MINIFIED_RELEASE_BUILD_RELEASES: readonly ReleaseMetadata[] = [ + { name: 'openiap-google', version: '3.6.1', tag: 'google-3.6.1' }, + { + name: 'react-native-iap', + version: '16.7.1', + tag: 'react-native-iap-16.7.1', + }, + { name: 'expo-iap', version: '5.8.1', tag: 'expo-iap-5.8.1' }, + { + name: 'flutter_inapp_purchase', + version: '10.7.1', + tag: 'flutter-iap-10.7.1', + }, + { name: 'godot-iap', version: '3.6.1', tag: 'godot-iap-3.6.1' }, + { name: 'kmp-iap', version: '3.6.1', tag: 'kmp-iap-3.6.1' }, + { name: 'maui-iap', version: '2.6.1', tag: 'maui-iap-2.6.1' }, +]; + const BUILD_TIME_STORE_RELEASES: readonly ReleaseMetadata[] = [ { name: 'openiap-apple', version: '3.6.0', tag: '3.6.0' }, { name: 'openiap-google', version: '3.6.0', tag: 'google-3.6.0' }, @@ -427,6 +445,109 @@ function Releases() { } const allNotes: Note[] = [ + { + id: 'minified-release-builds-2026-09-26', + aliases: MINIFIED_RELEASE_BUILD_RELEASES.map((release) => release.tag), + date: new Date('2026-09-26'), + element: ( +
+ {MINIFIED_RELEASE_BUILD_RELEASES.map((release) => ( +
+ ), + }, { id: 'build-time-store-resolution-2026-09-26', aliases: BUILD_TIME_STORE_RELEASES.map((release) => release.tag), diff --git a/packages/google/compatibility/release-consumer/build.gradle b/packages/google/compatibility/release-consumer/build.gradle new file mode 100644 index 000000000..243a57f7f --- /dev/null +++ b/packages/google/compatibility/release-consumer/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'com.android.application' version '8.13.2' +} + +layout.buildDirectory.set(file(providers.gradleProperty('consumerBuildDirectory').get())) + +// Picks the artifact the way the React Native, Expo, and Flutter wrappers do. +apply from: new File(rootDir, '../../gradle/openiap-store.gradle') +def store = openIapResolveStore('release-consumer').store +def artifact = store == 'play' ? 'openiap-google' : "openiap-google-${store}" + +android { + namespace = 'dev.hyo.openiap.compatibility.release' + compileSdk = 36 + + defaultConfig { + applicationId = 'dev.hyo.openiap.compatibility.release' + minSdk = 23 + targetSdk = 36 + } + + buildTypes { + release { + minifyEnabled = true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + // Lint has its own CI step; this build checks what R8 links. + lint { + checkReleaseBuilds = false + } +} + +dependencies { + implementation "io.github.hyochan.openiap:${artifact}:${providers.gradleProperty('openIapVersion').get()}" +} diff --git a/packages/google/compatibility/release-consumer/gradle.properties b/packages/google/compatibility/release-consumer/gradle.properties new file mode 100644 index 000000000..1edd5f5a3 --- /dev/null +++ b/packages/google/compatibility/release-consumer/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/packages/google/compatibility/release-consumer/proguard-rules.pro b/packages/google/compatibility/release-consumer/proguard-rules.pro new file mode 100644 index 000000000..c0e55dfe0 --- /dev/null +++ b/packages/google/compatibility/release-consumer/proguard-rules.pro @@ -0,0 +1,3 @@ +# Stand-ins for an app that calls the whole API, so R8 traces every store path. +-keep class dev.hyo.openiap.OpenIapModule { public *; } +-keep class dev.hyo.openiap.store.OpenIapStore { public *; } diff --git a/packages/google/compatibility/release-consumer/settings.gradle b/packages/google/compatibility/release-consumer/settings.gradle new file mode 100644 index 000000000..5c931b00c --- /dev/null +++ b/packages/google/compatibility/release-consumer/settings.gradle @@ -0,0 +1,21 @@ +// Driven by scripts/verify-release-consumer.sh, which publishes the artifacts first. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + maven { + url = uri(providers.gradleProperty('openIapRepository').get()) + } + mavenCentral() + } +} + +rootProject.name = 'openiap-google-release-consumer' diff --git a/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml b/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml new file mode 100644 index 000000000..11bed8604 --- /dev/null +++ b/packages/google/compatibility/release-consumer/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index ecfbb8969..26bf09dfe 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -108,17 +108,20 @@ android { create("play") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"play\"") + consumerProguardFiles("consumer-rules-play.pro") isDefault = true } // Horizon flavor - Meta Horizon Billing only create("horizon") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") + consumerProguardFiles("consumer-rules-horizon.pro") } // Amazon flavor - Amazon Appstore SDK IAP only create("amazon") { dimension = "platform" buildConfigField("String", "OPENIAP_STORE", "\"amazon\"") + consumerProguardFiles("consumer-rules-amazon.pro") } } diff --git a/packages/google/openiap/consumer-rules-amazon.pro b/packages/google/openiap/consumer-rules-amazon.pro new file mode 100644 index 000000000..f8f88d33d --- /dev/null +++ b/packages/google/openiap/consumer-rules-amazon.pro @@ -0,0 +1,6 @@ +# The Appstore SDK fills fields of its own com.amazon.* classes by reflection, +# so R8 must keep it whole. These are Amazon's documented rules; keeping the +# whole SDK also keeps code that references optional libraries it doesn't ship. +-dontwarn com.amazon.** +-keep class com.amazon.** { *; } +-keepattributes *Annotation* diff --git a/packages/google/openiap/consumer-rules-horizon.pro b/packages/google/openiap/consumer-rules-horizon.pro new file mode 100644 index 000000000..51093b678 --- /dev/null +++ b/packages/google/openiap/consumer-rules-horizon.pro @@ -0,0 +1,3 @@ +# The Horizon platform SDK annotates with JSR-305 without shipping it; R8 stops +# the build on the missing annotation, which nothing needs at runtime. +-dontwarn javax.annotation.Nullable diff --git a/packages/google/openiap/consumer-rules-play.pro b/packages/google/openiap/consumer-rules-play.pro new file mode 100644 index 000000000..dbece6f2f --- /dev/null +++ b/packages/google/openiap/consumer-rules-play.pro @@ -0,0 +1,3 @@ +# OpenIapModule reaches newer Play Billing APIs by reflection so it still runs +# when an app pins an older billing version; R8 must keep their public names. +-keep public class com.android.billingclient.api.** { public *; } diff --git a/packages/google/openiap/consumer-rules.pro b/packages/google/openiap/consumer-rules.pro index 19a112ae2..08c20cacf 100644 --- a/packages/google/openiap/consumer-rules.pro +++ b/packages/google/openiap/consumer-rules.pro @@ -1,4 +1,2 @@ -# Keep OpenIAP public API --keep public class dev.hyo.openiap.OpenIAP { public *; } --keep public class dev.hyo.openiap.models.** { *; } +# Keep the listener interfaces that framework bridges implement. -keep public class dev.hyo.openiap.listener.** { *; } diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index 6b2052e75..1231c90e5 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1076,6 +1076,10 @@ class OpenIapModule( override fun removeConnectionStateListener(listener: OpenIapConnectionStateListener) = Unit + override fun enableBillingProgram(program: BillingProgramAndroid) { + OpenIapLog.warn("enableBillingProgram is not supported on Amazon Appstore (no-op)", TAG) + } + override suspend fun isBillingProgramAvailable( program: BillingProgramAndroid ): BillingProgramAvailabilityResultAndroid = BillingProgramAvailabilityResultAndroid( diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 959579c69..6acf617b4 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1887,6 +1887,10 @@ class OpenIapModule( } // Google Play billing programs are not supported on Horizon. + override fun enableBillingProgram(program: BillingProgramAndroid) { + OpenIapLog.warn("enableBillingProgram is not supported on Meta Horizon (no-op)", TAG) + } + override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid { // No-op: Billing Programs is a Google Play 8.2.0+ feature, not supported on Meta Horizon OpenIapLog.warn("isBillingProgramAvailable is not supported on Meta Horizon (no-op)", TAG) diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt index e15faebc1..98a45f0ff 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -68,6 +68,12 @@ interface OpenIapProtocol { fun removeConnectionStateListener(listener: OpenIapConnectionStateListener) // Billing Programs (Google Play Billing Library 8.2.0+) + /** + * Enable a billing program for the next connection; call before initConnection. + * Only Google Play acts on it, so other implementations need not override it. + */ + fun enableBillingProgram(program: BillingProgramAndroid) {} + /** * Check if a billing program is available for this user/device. * Checks whether the selected billing program is available. diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index ceb956722..459223bbd 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -632,19 +632,7 @@ class OpenIapStore(private val module: OpenIapProtocol) { * * @param program The billing program to enable */ - fun enableBillingProgram(program: BillingProgramAndroid) { - // Use reflection to call enableBillingProgram on the module - // This is needed because the method is only available in the Play flavor - try { - val method = module.javaClass.getMethod("enableBillingProgram", BillingProgramAndroid::class.java) - method.invoke(module, program) - OpenIapLog.debug("Billing program enabled via store: $program", "OpenIapStore") - } catch (e: NoSuchMethodException) { - OpenIapLog.warn("enableBillingProgram not available (Horizon flavor or older library)", "OpenIapStore") - } catch (e: Exception) { - OpenIapLog.error("Failed to enable billing program: ${e.message}", e, "OpenIapStore") - } - } + fun enableBillingProgram(program: BillingProgramAndroid) = module.enableBillingProgram(program) // ------------------------------------------------------------------------- // Event listeners passthrough diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 34e643cef..1a80c2f0c 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -1590,7 +1590,7 @@ class OpenIapModule( * * @param program The billing program to enable */ - fun enableBillingProgram(program: BillingProgramAndroid) { + override fun enableBillingProgram(program: BillingProgramAndroid) { if (program != BillingProgramAndroid.Unspecified) { synchronized(connectionLifecycleLock) { pendingBillingPrograms.add(program) diff --git a/packages/google/scripts/verify-release-consumer.sh b/packages/google/scripts/verify-release-consumer.sh new file mode 100755 index 000000000..3effb6f77 --- /dev/null +++ b/packages/google/scripts/verify-release-consumer.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +# No other build here runs R8. Build a minified release app per store from the +# published artifacts and check which store SDK it linked and what R8 kept. + +google_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +repo_root=$(cd "$google_root/../.." && pwd) +consumer_root="$google_root/compatibility/release-consumer" +consumer_temp=$(mktemp -d) + +cleanup() { + find "$consumer_temp" -type f -delete + find "$consumer_temp" -type l -delete + find "$consumer_temp" -depth -type d -empty -delete +} +trap cleanup EXIT + +openiap_version=$(node -e \ + "const versions = require(process.argv[1]); process.stdout.write(versions.google)" \ + "$repo_root/openiap-versions.json") +local_repository="$consumer_temp/repository" + +# store, openiap artifact, store SDK module, and the SDK's class prefix +stores=( + "play openiap-google com.android.billingclient:billing com.android.billingclient." + "horizon openiap-google-horizon com.meta.horizon.billingclient.api:horizon-billing-compatibility com.meta.horizon.billingclient." + "amazon openiap-google-amazon com.amazon.device:amazon-appstore-sdk com.amazon.device.iap." +) + +cd "$google_root" +for entry in "${stores[@]}"; do + ./gradlew :openiap:publishMavenPublicationToMavenLocal \ + -POPENIAP_PUBLISH_VARIANT="${entry%% *}" \ + -Dmaven.repo.local="$local_repository" \ + --no-daemon +done + +failures=0 + +fail() { + echo "FAIL [$1] $2" >&2 + failures=$((failures + 1)) +} + +# Class lines in R8's mapping start at column one with the original name. +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. +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) +play_methods=$(grep -oE 'get(Declared)?Method\( ?"[A-Za-z0-9_]+"|method\.name == "[A-Za-z0-9_]+"' <<< "$play_source" \ + | sed -E 's/.*"(.*)"/\1/' | sort -u || true) +if [ -z "$play_classes" ] || [ -z "$play_methods" ]; then + echo "Found no Play Billing lookups in the Play module; update this check." >&2 + exit 1 +fi + +dexdump=$(find "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}/build-tools" -name dexdump -type f 2>/dev/null \ + | sort -V | tail -n 1 || true) +if [ -z "$dexdump" ]; then + echo "No dexdump under \$ANDROID_HOME/build-tools to read the Play APK with." >&2 + exit 1 +fi + +# Prints each looked-up Play Billing class or method that the APK lacks. +missing_play_lookups() { + local defined name + defined=$("$dexdump" "$1" | awk -F "'" ' + /^ Class descriptor/ { c = substr($2, 2, length($2) - 2); gsub("/", ".", c); print "class " c; next } + /^ (Direct|Virtual) methods/ { m = 1; next } + /^ (Static|Instance) fields/ { m = 0; next } + m && /^ name +:/ { print "method " c " " $2 }') + for name in $play_classes; do + grep -qxF "class $name" <<< "$defined" || echo "$name" + done + for name in $play_methods; do + grep -qE "^method com\.android\.billingclient\.api\.[^ ]+ $name\$" <<< "$defined" || echo "$name()" + done +} + +# check [gradle args...] +check() { + local name=$1 store=$2 source=$3 + shift 3 + local build="$consumer_temp/build/$name" + local log="$consumer_temp/$name.log" + local mapping="$build/outputs/mapping/release/mapping.txt" + local failures_before=$failures + echo "== $name: expecting store=$store (source=$source)" + + if ! "$repo_root/scripts/ci/retry-gradle.sh" ./gradlew -p "$consumer_root" \ + dependencies --configuration releaseRuntimeClasspath assembleRelease \ + -PopenIapRepository="$local_repository" \ + -PopenIapVersion="$openiap_version" \ + -PconsumerBuildDirectory="$build" \ + --project-cache-dir "$consumer_temp/project-cache/$name" \ + --no-daemon "$@" > "$log" 2>&1; then + tail -n 80 "$log" >&2 + fail "$name" "the minified release build failed" + return 0 + fi + + if ! grep -qF "openiap: store=$store (source=$source;" "$log"; then + grep -F "openiap: store=" "$log" >&2 || true + fail "$name" "the resolver did not pick $store from $source" + fi + if [ ! -f "$mapping" ]; then + fail "$name" "R8 wrote no mapping at $mapping" + return 0 + fi + + local entry other artifact sdk package linked kept + for entry in "${stores[@]}"; do + read -r other artifact sdk package <<< "$entry" + if [ "$other" = "$store" ]; then + linked=$artifact kept=$package + if ! grep -qF "io.github.hyochan.openiap:$artifact:$openiap_version" "$log"; then + fail "$name" "releaseRuntimeClasspath lacks $artifact:$openiap_version" + fi + if ! grep -qF "$sdk:" "$log"; then + fail "$name" "releaseRuntimeClasspath lacks $sdk" + fi + if ! mapping_has_package "$mapping" "$package"; then + fail "$name" "R8 kept no $package class" + fi + else + if grep -qF "io.github.hyochan.openiap:$artifact:" "$log"; then + fail "$name" "releaseRuntimeClasspath also links $artifact" + fi + if grep -qF "$sdk:" "$log"; then + fail "$name" "releaseRuntimeClasspath also links $sdk" + fi + if mapping_has_package "$mapping" "$package"; then + fail "$name" "the release APK carries $package classes" + fi + fi + done + + # The Appstore SDK finds and fills its own classes by their com.amazon. names, + # and the Appstore broadcasts to its receiver by name. R8's own synthesized + # classes are not the SDK's. + if [ "$store" = amazon ]; then + local renamed + renamed=$(awk '/^com\.amazon\./ && !index($1, "$$ExternalSynthetic") && $1 ":" != $3 { print $1; exit }' "$mapping") + if [ -n "$renamed" ]; then + fail "$name" "R8 renamed Amazon SDK classes such as $renamed" + fi + fi + + if [ "$store" = play ]; then + local apk missing + apk=("$build"/outputs/apk/release/*.apk) + missing=$(missing_play_lookups "${apk[0]}") + if [ -n "$missing" ]; then + fail "$name" "the release APK lacks Play Billing names the Play module looks up: $(echo $missing)" + fi + fi + + if [ "$failures" -eq "$failures_before" ]; then + echo " ok: $name linked $linked and R8 kept $kept" + fi +} + +# No pin and no store flavor: Play. +check play play default +check horizon horizon explicit -PopeniapStore=horizon +# The channel CI and EAS builds use. +ORG_GRADLE_PROJECT_openiapStore=amazon check amazon amazon explicit + +if [ "$failures" -gt 0 ]; then + echo "$failures release-build check(s) failed" >&2 + exit 1 +fi +echo "Minified release builds link the resolved store for play, horizon, and amazon." diff --git a/scripts/audit-docs.ts b/scripts/audit-docs.ts index a721fd7d8..68bdba765 100644 --- a/scripts/audit-docs.ts +++ b/scripts/audit-docs.ts @@ -1456,10 +1456,9 @@ function formatQuotedList(values: string[]): string { } /** - * Released `Package Releases` blocks should link every package/version item to - * the GitHub Release. If a workflow is still publishing, keep the heading as - * `Planned Package Releases`; once it is changed to `Package Releases`, bare - * package text is a docs regression. + * `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 @@ -1558,15 +1557,22 @@ function auditReleaseNotePackageLinks(filePath: string): Drift[] { let headingMatch: RegExpExecArray | null; while ((headingMatch = headingRe.exec(src)) !== null) { - const heading = headingMatch[1]; + if (headingMatch[1] !== "Package Releases") { + drifts.push({ + file: filePath, + line: lineNumberAt(src, headingMatch.index), + rule: "R9", + message: + "Write the card as published: use `Package Releases` with each expected GitHub Release link, not `Planned Package Releases`.", + }); + continue; + } const ulStart = src.indexOf("", ulStart); if (ulEnd === -1) continue; const ul = src.slice(ulStart, ulEnd); - if (heading !== "Package Releases") continue; - const liRe = /]*>([\s\S]*?)<\/li>/g; let liMatch: RegExpExecArray | null; while ((liMatch = liRe.exec(ul)) !== null) { @@ -1579,7 +1585,7 @@ function auditReleaseNotePackageLinks(filePath: string): Drift[] { line: lineNumberAt(src, ulStart + liMatch.index), rule: "R9", message: - "`Package Releases` entries must link package/version items to their GitHub Release URL. Use `Planned Package Releases` only while a release is not published.", + "`Package Releases` entries must link package/version items to their GitHub Release URL, the expected one for a release that has not published yet.", }); } } diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 4ab64b895..b3915630f 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -292,6 +292,33 @@ function listTrackedFiles(relativePath) { .filter((file) => file.length > 0 && exists(file)); } +// The shared store and the framework bridges call openiap through its API, so R8 +// sees every call; a reflective lookup hides its target and a release build then +// strips it. +function checkNoReflectionIntoOpenIap() { + const sources = [ + "packages/google/openiap/src/main", + "libraries/react-native-iap/android/src/main", + "libraries/expo-iap/android/src/main", + "libraries/flutter_inapp_purchase/android/src", + "libraries/maui-iap/android", + "libraries/godot-iap/android", + "libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt", + ]; + const reflective = + /\b(?:getMethod|getDeclaredMethod|getField|getDeclaredField|getConstructor|getDeclaredConstructor)\(|Class\.forName\(/; + for (const source of sources) { + for (const file of listTrackedFiles(source)) { + if (!/\.(?:kt|java)$/.test(file) || /\/(?:[a-z]+Test|test)[A-Za-z]*\//.test(file)) continue; + if (reflective.test(read(file))) { + fail( + `${file} looks up code by reflection, which R8 removes from release builds; call openiap directly`, + ); + } + } + } +} + function checkNoOutboundWebhookStream() { const forbiddenFiles = [ "packages/kit/server/api/v1/webhookStreamDrain.ts", @@ -3374,14 +3401,18 @@ function checkBillingChoiceFieldBindings() { "externalTransactionToken = params.externalTransactionToken.unwrapString()", "linkUri = details.linkUri.wrapVariant()", "originalExternalTransactionId = details.originalExternalTransactionId.wrapVariant()", - 'getMethod("getOriginalExternalTransactionId")', - 'getMethod("getProductDetailsAndroid")', - "productDetailsAndroid = productDetails?.map", + "productDetailsAndroid = details.productDetailsAndroid?.map", "products = details.products.map", "subResponseCode = mapSubResponseCode(result.subResponseCode)", ], "RN Billing Choice Android bridge fields", ); + // The developer-provided listener maps the same field, so pin the user-choice one. + expectMatch( + "libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt", + /UserChoiceBillingDetails\(\s*externalTransactionToken = details\.externalTransactionToken,\s*originalExternalTransactionId = details\.originalExternalTransactionId\.wrapVariant\(\),/, + "RN user choice billing bridge", + ); expectIncludes( "libraries/react-native-iap/src/__tests__/index.test.ts", [ @@ -6268,7 +6299,7 @@ function checkFrameworkDependencyHygiene() { [ "currently every five minutes", "`npm run deploy`. The docs site is not versioned", - "add the consolidated entry to", + "Release notes ship in the PR", "commit it directly to `main` together with any release-process doc updates", "do not open a PR for that post-release docs-only commit", "There is no Docs release workflow and no docs tag", @@ -9641,6 +9672,7 @@ checkLibraryCoverageRegistry(); checkClientProtocol(); checkDeprecationSchedule(); checkNoOutboundWebhookStream(); +checkNoReflectionIntoOpenIap(); checkExpoSsotRegistry(); checkE2eExampleIds(); checkGeneratedTypeSync(); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 05ef7bf3f..e5140e9e2 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -64,6 +64,32 @@ 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. +echo -e "${BLUE}🔗 Checking release links...${NC}" +RELEASES_PAGE="packages/docs/src/pages/docs/updates/releases.tsx" +# 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=$( + { + 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 +) +if [ -n "$UNPUBLISHED_LINKS" ]; then + echo -e "${RED}❌ The release page links releases that are not published yet:${NC}" + echo "$UNPUBLISHED_LINKS" + echo -e "${YELLOW}Finish the release train, or trim its card to the packages that published.${NC}" + exit 1 +fi + # Check if Vercel CLI is installed if ! command -v vercel &> /dev/null; then echo -e "${YELLOW}⚠️ Vercel CLI not found. Installing v${VERCEL_CLI_VERSION} globally...${NC}" diff --git a/scripts/release-branch-policy.test.mjs b/scripts/release-branch-policy.test.mjs index 3ab34079f..099b08810 100644 --- a/scripts/release-branch-policy.test.mjs +++ b/scripts/release-branch-policy.test.mjs @@ -1445,6 +1445,33 @@ test("production docs require a verified Vercel deployment result", (context) => "", ].join("\n"), ); + // Stands in for `gh release list`, printing the published release tags. + writeExecutable( + resolve(temporaryRoot, "mock-bin/gh"), + [ + "#!/bin/sh", + 'if [ -n "${MOCK_GH_FAIL:-}" ]; then', + " exit 1", + "fi", + "printf '%s\\n' ${MOCK_GH_RELEASES:-}", + "", + ].join("\n"), + ); + // A card for an unreleased train, and a historical link the deploy knows never published. + mkdirSync(resolve(temporaryRoot, "packages/docs/src/pages/docs/updates"), { + recursive: true, + }); + writeFileSync( + resolve( + temporaryRoot, + "packages/docs/src/pages/docs/updates/releases.tsx", + ), + [ + "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";', + "", + ].join("\n"), + ); execFileSync("git", ["init", "-q", "-b", "main"], { cwd: temporaryRoot, @@ -1471,6 +1498,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"; const runDeploy = (mockOutput = "", environmentOverrides = {}) => spawnSync("bash", ["scripts/deploy.sh"], { cwd: temporaryRoot, @@ -1483,6 +1511,20 @@ test("production docs require a verified Vercel deployment result", (context) => input: "y\n", }); + const unpublished = runDeploy("", { MOCK_GH_RELEASES: "" }); + assert.notEqual(unpublished.status, 0); + assert.match( + unpublished.stdout, + /links releases that are not published yet/, + ); + assert.match(unpublished.stdout, /google-9\.9\.9/); + assert.doesNotMatch(unpublished.stdout, /google-3\.5\.3/); + assert.doesNotMatch(unpublished.stdout, /Successfully deployed to Vercel/); + + const noReleaseList = runDeploy("", { MOCK_GH_FAIL: "1" }); + assert.notEqual(noReleaseList.status, 0); + assert.match(noReleaseList.stdout, /Could not list GitHub Releases/); + const unlinked = runDeploy(); assert.notEqual(unlinked.status, 0); assert.match(unlinked.stdout, /not linked to the OpenIAP Vercel project/);