{`-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.
-
+ 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.
+
+ Android release builds shrunk with R8 no longer lose billing
+ features on any store, and no ProGuard rules are needed in your app.
+ See{' '}
+
+ PR #490
+
+ .
+
+
+
+ Protocols and native packages
+
+
+
+ openiap-google 3.6.1 - fixes minified Horizon
+ builds that failed on javax.annotation.Nullable, and
+ keeps the Amazon Appstore SDK classes it fills by reflection, so
+ Amazon purchases work in release builds.
+
+
+ openiap-google 3.6.1 - keeps the Play Billing
+ APIs behind External Payments, Billing Choice, external links,
+ user choice billing, automatic reconnection, and{' '}
+ isSuspended, which R8 used to strip.
+
+
+ openiap-google 3.6.1 -{' '}
+ OpenIapStore.enableBillingProgram now takes effect in
+ release builds; Horizon and Amazon ignore it with a warning.
+
+
+
+
Framework libraries
+
+
+ react-native-iap 16.7.1 -{' '}
+ enableBillingProgramAndroid takes effect in release
+ builds, and user choice billing events keep{' '}
+ originalExternalTransactionId and{' '}
+ productDetailsAndroid.
+
+
+ kmp-iap 3.6.1 - fixes minified Horizon and Amazon
+ release builds, which failed on Play Billing classes those stores
+ don't ship or could not start billing.
+
+ ),
+ },
{
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/);