From 89b67e0d97f663d0fec42e315fbd572bacd3947e Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 19 Aug 2026 23:18:31 +0900 Subject: [PATCH 01/21] ci: skip native matrices for docs-only changes Closes #362. Docs-only follow-up commits restarted the native build and Swift CodeQL matrices because the path filters matched whole package directories. PR #361 left six Swift CodeQL jobs running after the functional checks had finished. Subtract one negation, `!**/*.md`, from every filter that gates a compile or Swift CodeQL job, and add `predicate-quantifier: some-with-excludes` to the three dorny/paths-filter steps. The quantifier is load-bearing: under the default `some`, patterns are OR-ed and picomatch's inverted matcher returns true for every non-markdown file, so the filter would match nearly the whole repository. The broad positive globs stay, so new source still triggers automatically. Closes three real holes found on the way: `openiap-versions.json`, `libraries/kmp-iap/openiap-versions.json`, and `libraries-versions.jsonc` are Swift build inputs that previously selected no Swift job on a pull request. `scripts/audit-ci-path-filters.mjs` reads the filters out of the workflows themselves, asserts a 33-row changed-file table, and guards the quantifier, the negation vocabulary, filter-to-job wiring, and full push/schedule coverage. Its matcher was checked against picomatch@2.3.1 over 46 patterns and 2240 tracked files with zero mismatches. `ci.yml` and `codeql.yml` keep gating at the job level so their checks always report. Only the six library workflows use workflow-level paths, so a required status check may be drawn from `ci.yml` and `codeql.yml` only. Other work in this change: - Rebalance the README sponsor logos. `meta.svg` carries ~44% built-in padding while `amazon.webp` is cropped to its ink, so equal heights rendered Amazon about twice as large. `align="middle"` centres them instead of sharing a baseline. - Declare the `[os.version]` block and `/com.amazon.vega.os@IVega_1_2` module that Amazon Vega SDK 0.24 requires, in all four manifest emitters. Correct the docs install pin to `~2.13.0` and the release-notes link to 0.24, and guard both in the parity audit. - Draw the ecosystem diagram's optional-backend arrow from Core as well as from Libraries; both packages ship an IAPKit client directly. - Fix IAPKit site documentation that contradicted the implementation: the Apple page had the verification order backwards, Google documented two unreachable error codes and a superseded retry rule, the quickstart pointed new users at email OTP that the server rejects, and the AI-assistants page claimed the repository is private. - Write OpenIAP as one word across the kit landing page and library READMEs. Co-Authored-By: Claude Opus 5 --- .claude/commands/verify-all.md | 4 + .github/workflows/ci-expo-iap.yml | 4 + .../workflows/ci-flutter-inapp-purchase.yml | 4 + .github/workflows/ci-godot-iap.yml | 4 + .github/workflows/ci-kmp-iap.yml | 2 + .github/workflows/ci-maui-iap.yml | 2 + .github/workflows/ci-react-native-iap.yml | 4 + .github/workflows/ci.yml | 8 + .github/workflows/codeql.yml | 26 + .husky/pre-commit | 7 + README.md | 5 +- libraries/expo-iap/README.md | 4 +- .../example/scripts/vega-build-config.mjs | 7 + .../expo-iap/plugin/__tests__/withIAP.test.ts | 2 + libraries/expo-iap/plugin/src/withVega.ts | 10 + libraries/flutter_inapp_purchase/README.md | 4 +- libraries/godot-iap/README.md | 4 +- libraries/kmp-iap/README.md | 4 +- libraries/react-native-iap/README.md | 4 +- .../react-native-iap/example/manifest.toml | 7 + package.json | 1 + packages/docs/README.md | 5 +- .../docs/src/components/EcosystemDiagram.tsx | 10 +- .../src/pages/docs/setup/store/amazon.tsx | 13 +- .../docs/src/styles/ecosystem-diagram.css | 23 +- packages/kit/src/components/Footer.tsx | 2 +- .../src/pages/docs/sections/ai-assistants.tsx | 18 +- packages/kit/src/pages/docs/sections/api.tsx | 25 +- .../src/pages/docs/sections/claude-plugin.tsx | 2 +- .../src/pages/docs/sections/codex-plugin.tsx | 2 +- .../src/pages/docs/sections/compatibility.tsx | 2 +- .../src/pages/docs/sections/introduction.tsx | 2 +- .../src/pages/docs/sections/operations.tsx | 15 +- .../kit/src/pages/docs/sections/projects.tsx | 11 +- .../src/pages/docs/sections/quickstart.tsx | 13 +- .../docs/sections/verification-apple.tsx | 30 +- .../docs/sections/verification-google.tsx | 42 +- packages/kit/src/pages/landing.tsx | 4 +- packages/kit/src/utils/constants.ts | 18 - packages/mcp-server/src/mcp.ts | 6 +- scripts/audit-ci-path-filters.mjs | 621 ++++++++++++++++++ scripts/audit-ci-path-filters.test.mjs | 210 ++++++ scripts/audit-non-godot-parity.mjs | 13 + 43 files changed, 1089 insertions(+), 115 deletions(-) create mode 100644 scripts/audit-ci-path-filters.mjs create mode 100644 scripts/audit-ci-path-filters.test.mjs diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 75574b723..b21794bb9 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -20,6 +20,9 @@ bun run audit:parity # Stable main / prerelease next branch contract. bun run audit:release-state node --test scripts/release-branch-policy.test.mjs + +# Native build / Swift CodeQL path filters. +bun run audit:ci-paths ``` This fails if a new non-Godot library, Expo example route/product ID, generated @@ -318,6 +321,7 @@ set -euo pipefail (cd scripts/agent && bun run compile:ai && bun test && bun run typecheck) bun run audit:parity bun run audit:release-state +bun run audit:ci-paths bun test \ --path-ignore-patterns='**/build/**' \ --path-ignore-patterns='**/.build/**' \ diff --git a/.github/workflows/ci-expo-iap.yml b/.github/workflows/ci-expo-iap.yml index 532a67496..b477871e2 100644 --- a/.github/workflows/ci-expo-iap.yml +++ b/.github/workflows/ci-expo-iap.yml @@ -9,8 +9,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-expo-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,8 +21,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-expo-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index e3fe4ec85..88600782a 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -9,9 +9,11 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -20,9 +22,11 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-flutter-inapp-purchase.yml" - ".github/workflows/release-flutter.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index eb6d3af65..77873dc20 100644 --- a/.github/workflows/ci-godot-iap.yml +++ b/.github/workflows/ci-godot-iap.yml @@ -10,6 +10,8 @@ on: - "libraries/godot-iap/**" - "packages/gql/codegen/plugins/gdscript.ts" - "packages/gql/src/generated/types.gd" + - "openiap-versions.json" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,6 +21,8 @@ on: - "libraries/godot-iap/**" - "packages/gql/codegen/plugins/gdscript.ts" - "packages/gql/src/generated/types.gd" + - "openiap-versions.json" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index a1775cb15..a65f609f1 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -9,6 +9,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-kmp-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -17,6 +18,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-kmp-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index c68d26109..60d08f107 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -13,6 +13,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-maui-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -25,6 +26,7 @@ on: - "openiap-versions.json" - "scripts/ci/retry-gradle.sh" - ".github/workflows/ci-maui-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci-react-native-iap.yml b/.github/workflows/ci-react-native-iap.yml index 3bc563935..2509e9724 100644 --- a/.github/workflows/ci-react-native-iap.yml +++ b/.github/workflows/ci-react-native-iap.yml @@ -9,8 +9,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-react-native-iap.yml" + - "!**/*.md" push: branches: [main, next] paths: @@ -19,8 +21,10 @@ on: - "packages/apple/Sources/**" - "packages/apple/Package.swift" - "openiap-versions.json" + - "libraries-versions.jsonc" - "codecov.yml" - ".github/workflows/ci-react-native-iap.yml" + - "!**/*.md" permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b7dc833..0a2512d55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,8 @@ jobs: uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: + # Without this, '!' patterns are OR-ed positives that match nearly everything. + predicate-quantifier: some-with-excludes filters: | gql: - 'packages/gql/**' @@ -163,18 +165,21 @@ jobs: - 'openiap-versions.json' - '.github/workflows/ci.yml' - 'libraries/maui-iap/src/OpenIap.Maui/Types.cs' + - '!**/*.md' android: - 'packages/google/**' - 'packages/gql/**' - 'scripts/**' - 'openiap-versions.json' - '.github/workflows/ci.yml' + - '!**/*.md' ios: - 'packages/apple/**' - 'packages/gql/**' - 'scripts/**' - 'openiap-versions.json' - '.github/workflows/ci.yml' + - '!**/*.md' docs: - 'packages/docs/**' - 'packages/gql/src/generated/**' @@ -291,6 +296,9 @@ jobs: - name: Run IAPKit spec contract audit run: node scripts/audit-kit-spec-contract.mjs + - name: Audit CI path filters + run: npm run audit:ci-paths + test-gql: name: Test GQL Types runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b2256a93f..f41d4beb9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,27 +45,53 @@ jobs: id: core uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 with: + # Without this, '!' patterns are OR-ed positives that match nearly everything. + predicate-quantifier: some-with-excludes filters: | swift_core: - 'packages/apple/**' - 'libraries/kmp-iap/native/InAppPurchaseBridge/**' + - 'libraries/kmp-iap/openiap-versions.json' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' - name: Detect Swift wrapper changes if: github.event_name == 'pull_request' id: wrappers uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 with: + predicate-quantifier: some-with-excludes filters: | react-native: - 'libraries/react-native-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' expo: - 'libraries/expo-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' expo-onside: - 'libraries/expo-iap/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' flutter: - 'libraries/flutter_inapp_purchase/**' + - 'libraries-versions.jsonc' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' godot: - 'libraries/godot-iap/**' + - 'openiap-versions.json' + - '.github/workflows/codeql.yml' + - '!**/*.md' analyze: name: Analyze (${{ matrix.language }}) diff --git a/.husky/pre-commit b/.husky/pre-commit index c837dcda0..f2c5df647 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -61,6 +61,13 @@ echo "πŸ”Ž IAPKit spec contract audit β€” running CI mirror…" node --test scripts/audit-kit-spec-contract.test.mjs node scripts/audit-kit-spec-contract.mjs +# Path filters gate native builds; a filter edit must not silently skip them. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(\.github/workflows/|scripts/audit-ci-path-filters(\.test)?\.mjs$)'; then + echo "🧭 CI path filter audit…" + bun run audit:ci-paths +fi + # Paths-aware kit pre-commit gate. Only runs when staged changes touch # packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries # aren't blocked. diff --git a/README.md b/README.md index b0112306c..f8af1588f 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,16 @@ For bug reports, please [open an issue](https://github.com/hyodotdev/openiap/iss ## Sponsors +

- Meta + Meta        - Amazon Developer + Amazon Developer

diff --git a/libraries/expo-iap/README.md b/libraries/expo-iap/README.md index facc3cae1..8617e10a8 100644 --- a/libraries/expo-iap/README.md +++ b/libraries/expo-iap/README.md @@ -5,11 +5,11 @@ [![Version](http://img.shields.io/npm/v/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![Download](http://img.shields.io/npm/dm/expo-iap.svg?style=flat-square)](https://npmjs.org/package/expo-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![CI](https://github.com/hyodotdev/openiap/actions/workflows/ci-expo-iap.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-expo-iap.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=expo-iap)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/expo-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Fexpo-iap?ref=badge_shield&issueType=license) -Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the Open IAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. +Expo IAP is a powerful in-app purchase solution for Expo and React Native applications that conforms to the OpenIAP specification. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. If you're shipping an app with expo-iap, we’d love to hear about itβ€”please share your product and feedback in [expo-iap Q&A Discussions](https://github.com/hyodotdev/openiap/discussions/categories/expo-iap). Community stories help us keep improving the ecosystem. -Open IAP +OpenIAP diff --git a/libraries/expo-iap/example/scripts/vega-build-config.mjs b/libraries/expo-iap/example/scripts/vega-build-config.mjs index 2399ee801..666dc2072 100644 --- a/libraries/expo-iap/example/scripts/vega-build-config.mjs +++ b/libraries/expo-iap/example/scripts/vega-build-config.mjs @@ -65,4 +65,11 @@ id = "/com.amazonappstore.iap.tester@IIAPTesterUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2" `; diff --git a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts index 137d261de..7c502ac65 100644 --- a/libraries/expo-iap/plugin/__tests__/withIAP.test.ts +++ b/libraries/expo-iap/plugin/__tests__/withIAP.test.ts @@ -896,6 +896,8 @@ describe('vega project generation', () => { expect(manifest).toContain( 'id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"', ); + expect(manifest).toContain('id = "/com.amazon.vega.os@IVega_1_2"'); + expect(manifest).toContain('[os.version]\ntarget = "1.2"\nmin = "1.2"'); expect(createVegaEntryPoint()).toContain( 'AppRegistry.registerComponent(appName, () => App);', ); diff --git a/libraries/expo-iap/plugin/src/withVega.ts b/libraries/expo-iap/plugin/src/withVega.ts index 64dd240c9..ab5fe64be 100644 --- a/libraries/expo-iap/plugin/src/withVega.ts +++ b/libraries/expo-iap/plugin/src/withVega.ts @@ -13,6 +13,9 @@ const DEFAULT_ICON_FILE = 'icon.png'; const DEFAULT_BUILD_TYPE = 'Release'; const DEFAULT_RUNTIME_MODULE = '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0'; +// Vega SDK 0.24 requires both the OS module and an [os.version] block. +const VEGA_OS_MODULE = '/com.amazon.vega.os@IVega_1_2'; +const VEGA_OS_VERSION = '1.2'; const logOnce = (() => { const printed = new Set(); @@ -155,6 +158,13 @@ id = "/com.amazon.iap.core@IIAPCoreUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = ${escapeTomlString(VEGA_OS_MODULE)} + +[os.version] +target = ${escapeTomlString(VEGA_OS_VERSION)} +min = ${escapeTomlString(VEGA_OS_VERSION)} `; export const createVegaEntryPoint = (): string => `${GENERATED_JS_MARKER} diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index e5a0ead59..160362778 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -5,9 +5,9 @@ [![Pub Version](https://img.shields.io/pub/v/flutter_inapp_purchase.svg?style=flat-square)](https://pub.dartlang.org/packages/flutter_inapp_purchase) [![Flutter CI](https://github.com/hyodotdev/openiap/actions/workflows/ci-flutter-inapp-purchase.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-flutter-inapp-purchase.yml?query=branch%3Amain) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Coverage Status](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=flutter-inapp-purchase)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) ![License](https://img.shields.io/badge/license-MIT-blue.svg) - A comprehensive Flutter plugin for implementing in-app purchases that conforms to the [Open IAP specification](https://openiap.dev) + A comprehensive Flutter plugin for implementing in-app purchases that conforms to the [OpenIAP specification](https://openiap.dev) -Open IAP +OpenIAP diff --git a/libraries/godot-iap/README.md b/libraries/godot-iap/README.md index 0f324f2da..2720f44d1 100644 --- a/libraries/godot-iap/README.md +++ b/libraries/godot-iap/README.md @@ -9,11 +9,11 @@ [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) -A comprehensive in-app purchase plugin for Godot 4.x that conforms to the Open IAP specification +A comprehensive in-app purchase plugin for Godot 4.x that conforms to the OpenIAP specification Requires Godot 4.3+, iOS 17+ for the Swift GDExtension, or Android API 24+. -Open IAP +OpenIAP diff --git a/libraries/kmp-iap/README.md b/libraries/kmp-iap/README.md index b82e6beeb..93cbb615e 100644 --- a/libraries/kmp-iap/README.md +++ b/libraries/kmp-iap/README.md @@ -8,9 +8,9 @@ OpenIAP Compliant License - A comprehensive Kotlin Multiplatform library for in-app purchases on Android and iOS platforms that conforms to the Open IAP specification + A comprehensive Kotlin Multiplatform library for in-app purchases on Android and iOS platforms that conforms to the OpenIAP specification - Open IAP + OpenIAP ## πŸ“š Documentation diff --git a/libraries/react-native-iap/README.md b/libraries/react-native-iap/README.md index 607b9b609..11630c533 100644 --- a/libraries/react-native-iap/README.md +++ b/libraries/react-native-iap/README.md @@ -5,9 +5,9 @@ [![Version](http://img.shields.io/npm/v/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![Download](http://img.shields.io/npm/dm/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Backers and Sponsors](https://img.shields.io/opencollective/all/openiap.svg)](https://opencollective.com/openiap) [![CI - Test](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=react-native-iap)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/react-native-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap?ref=badge_shield&issueType=license) -**React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [Open IAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. +**React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [OpenIAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support. -Open IAP +OpenIAP ## πŸ“š Documentation diff --git a/libraries/react-native-iap/example/manifest.toml b/libraries/react-native-iap/example/manifest.toml index 3c911981b..2a1f4d328 100644 --- a/libraries/react-native-iap/example/manifest.toml +++ b/libraries/react-native-iap/example/manifest.toml @@ -34,3 +34,10 @@ id = "/com.amazonappstore.iap.tester@IIAPTesterUI" [needs] [[needs.module]] id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2" diff --git a/package.json b/package.json index 9659db3a6..c48048c2e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "e2e:web": "node scripts/e2e-web-sites.mjs", "audit:deprecations": "node --test scripts/audit-deprecation-schedule.test.mjs && node scripts/audit-deprecation-schedule.mjs", "audit:layout": "node --test scripts/audit-repo-layout.test.mjs && node scripts/audit-repo-layout.mjs", + "audit:ci-paths": "node --test scripts/audit-ci-path-filters.test.mjs && node scripts/audit-ci-path-filters.mjs", "audit:parity": "node scripts/audit-non-godot-parity.mjs", "audit:kit-contract": "node --test scripts/audit-kit-spec-contract.test.mjs && node scripts/audit-kit-spec-contract.mjs", "audit:docs": "bun run scripts/audit-docs.ts", diff --git a/packages/docs/README.md b/packages/docs/README.md index 25918f118..0da7bb6f3 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -16,15 +16,16 @@ Visit [openiap.dev](https://openiap.dev) for full documentation. ## Sponsors +

- Meta + Meta        - Amazon Developer + Amazon Developer

diff --git a/packages/docs/src/components/EcosystemDiagram.tsx b/packages/docs/src/components/EcosystemDiagram.tsx index f80efd36c..6bca0d829 100644 --- a/packages/docs/src/components/EcosystemDiagram.tsx +++ b/packages/docs/src/components/EcosystemDiagram.tsx @@ -199,12 +199,12 @@ function Rail({ variant, label, }: { - variant: 'a' | 'b' | 'bypass' | 'iapkit'; - label: string; + variant: 'a' | 'b' | 'bypass' | 'iapkit' | 'iapkit-core'; + label?: string; }) { return ( ); } @@ -336,6 +336,7 @@ function EcosystemDiagram() { +
and for every framework library, and the core packages are bundled into each library. IAPKit is the optional hosted layer for purchase verification, entitlements, store - notifications, and product operations. Select any node to open its + notifications, and product operations, and the core packages can use it + directly without a framework library. Select any node to open its documentation or project. diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx index 2183a3e97..4d5dbe127 100644 --- a/packages/docs/src/pages/docs/setup/store/amazon.tsx +++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx @@ -359,7 +359,7 @@ dotnet build -f net10.0-android -p:OpenIapAndroidStore=amazon`}

Check the{' '}

{`# In the Vega-only React Native target yarn add react-native-iap -yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.12.13 @amazon-devices/react-native-kepler@^2.0.0 +yarn add @amazon-devices/keplerscript-appstore-iap-lib@~2.13.0 @amazon-devices/react-native-kepler@^2.0.0 yarn add -D @amazon-devices/kepler-cli-platform@~0.22.0 @react-native-community/cli@ @react-native/metro-config@`} {`schema-version = 1 @@ -428,7 +428,14 @@ categories = ["com.amazon.category.main"] [needs] [[needs.module]] -id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"`} +id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService" + +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[os.version] +target = "1.2" +min = "1.2"`}
diff --git a/packages/docs/src/styles/ecosystem-diagram.css b/packages/docs/src/styles/ecosystem-diagram.css index 6dd0a955f..48e3443c9 100644 --- a/packages/docs/src/styles/ecosystem-diagram.css +++ b/packages/docs/src/styles/ecosystem-diagram.css @@ -370,9 +370,13 @@ /* ---------- hosted infrastructure --------------------------------------- */ +.eco-rail--iapkit, +.eco-rail--iapkit-core { + opacity: 0.72; +} + .eco-rail--iapkit { height: 42px; - opacity: 0.72; } /* ---------- artwork ------------------------------------------------------- @@ -463,6 +467,12 @@ display: none; } +/* Stacked, there is no second channel beside Core -> Libraries -> IAPKit to + draw this in, so the figcaption is what states it there. */ +.eco-rail--iapkit-core { + display: none; +} + .eco-rail-label { position: relative; z-index: 1; @@ -601,8 +611,17 @@ grid-row: 7; } + /* Core reaches IAPKit without a framework library. Spanning the filler row + keeps the line attached to the Core band however tall Libraries grows. */ + .eco-rail--iapkit-core { + display: flex; + grid-column: 1; + grid-row: 6 / 8; + height: auto; + } + .eco-band--iapkit { - grid-column: 3; + grid-column: 1 / -1; grid-row: 8; } diff --git a/packages/kit/src/components/Footer.tsx b/packages/kit/src/components/Footer.tsx index fbacca5c0..5db39b41e 100644 --- a/packages/kit/src/components/Footer.tsx +++ b/packages/kit/src/components/Footer.tsx @@ -142,7 +142,7 @@ export default function Footer() { - +

- The IAPKit repository is private, which normally prevents - code-assistants from reasoning about it. Serving the reference as - plain text at a stable URL means any LLM-powered editor (Claude Code, - Cursor, Zed, Continue, etc.) that supports URL loaders can still pull - in accurate context without repo access. + IAPKit is open source, but pointing an assistant at the monorepo costs + a lot of tokens to answer a one-line API question. Serving a condensed + reference as plain text at a stable URL lets any LLM-powered editor + (Claude Code, Cursor, Zed, Continue, etc.) that supports URL loaders + pull accurate context in one fetch.

@@ -105,7 +105,7 @@ export default function AiAssistantsPage() { > Claude Code plugin guide {" "} - for the setup flow, self-hosted option, and tool list. + for the IAPKit endpoint and key details.

Using the files

diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index f1a2abd3a..fc7a4acb8 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -144,8 +144,8 @@ export default function ApiReferencePage() {

- Grant or fulfill only when isValid === true, the harmonized - state permits that operation, and the store-verified + Grant or fulfill only when isValid === true, the harmonized{" "} + state permits that operation, and the store-verified{" "} productId is present and matches the product your app expected. For Meta Horizon, productId is the SKU IAPKit checked. Amazon responses also identify the server-selected{" "} @@ -433,10 +433,12 @@ async function refreshEntitlements( originalTransactionId explicitly.

- Administrative subscription endpoints use{" "} - Authorization: Bearer openiap-kit_sk_.... Compatibility - routes with a key in the path remain available, but new server-side and - MCP integrations should keep secret keys out of URLs. + Administrative subscription endpoints require{" "} + Authorization: Bearer openiap-kit_sk_.... IAPKit never + accepts a secret key in a URL β€” a secret key in a path returns{" "} + 410 SECRET_API_KEY_IN_URL. The compatibility routes that + keep a key in the path accept publishable keys only, for SDK runtimes + that strip request headers.

{`{ @@ -450,7 +452,7 @@ async function refreshEntitlements( that explicitly ask for a JWS. Do not log or publish JWS values.

-
+
@@ -557,7 +559,7 @@ async function refreshEntitlements(

Status codes

-
+
@@ -706,6 +708,13 @@ async function refreshEntitlements( className="text-primary underline" > Horizon + + ,{" "} + + Amazon {" "} β€” per-store error codes and edge cases. diff --git a/packages/kit/src/pages/docs/sections/claude-plugin.tsx b/packages/kit/src/pages/docs/sections/claude-plugin.tsx index 4f9ee4301..543a4d7cc 100644 --- a/packages/kit/src/pages/docs/sections/claude-plugin.tsx +++ b/packages/kit/src/pages/docs/sections/claude-plugin.tsx @@ -14,7 +14,7 @@ export default function ClaudePluginPage() { >

The OpenIAP plugin connects Claude Code to this IAPKit project through - the hosted /mcp endpoint. Use this page for the Kit-local + the hosted /mcp endpoint. Use this page for the IAPKit endpoint and key details; use the OpenIAP MCP Server guide for the full installation flow, local PR testing, tool list, safety rules, and Example App walkthrough. diff --git a/packages/kit/src/pages/docs/sections/codex-plugin.tsx b/packages/kit/src/pages/docs/sections/codex-plugin.tsx index 55cd582af..1e80c7c24 100644 --- a/packages/kit/src/pages/docs/sections/codex-plugin.tsx +++ b/packages/kit/src/pages/docs/sections/codex-plugin.tsx @@ -13,7 +13,7 @@ export default function CodexPluginPage() { >

The OpenIAP Codex plugin connects Codex to this IAPKit project through - the hosted /mcp endpoint. Use this page for the Kit-local + the hosted /mcp endpoint. Use this page for the IAPKit endpoint and key details; use the OpenIAP MCP Server guide for the full installation flow, local PR testing, tool list, safety rules, and Example App walkthrough. diff --git a/packages/kit/src/pages/docs/sections/compatibility.tsx b/packages/kit/src/pages/docs/sections/compatibility.tsx index eee860a4c..15f1ef47c 100644 --- a/packages/kit/src/pages/docs/sections/compatibility.tsx +++ b/packages/kit/src/pages/docs/sections/compatibility.tsx @@ -62,7 +62,7 @@ X-OpenIAP-Spec: 3.2.0`}

Contract enforcement

- CI compares Kit's response enums with the OpenIAP schema used to + CI compares IAPKit's response enums with the OpenIAP schema used to generate every SDK. Runtime response validation and SDK parser tests then verify that unknown optional metadata degrades without weakening required fields. diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index aed20caba..4311889a9 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -46,7 +46,7 @@ export default function IntroductionPage() {

IAPKit itself is the managed receipt-verification server. Your app can call it directly with an openiap-kit_pk_ publishable key, - so you do not need to build a proxy just to verify a purchase. Secret + so you do not need to build a proxy just to verify a purchase. Secret{" "} openiap-kit_sk_ keys are only for administrative work such as MCP, catalog or payload writes, analytics, and store sync. You still need your own authenticated backend when resources on that diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index beee952bb..9ab6fd3aa 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -166,7 +166,7 @@ X-RateLimit-Remaining: 599`}

/health endpoint

GET /health returns public service, API contract version, - deployment revision, environment, and response-time metadata without + deployment revision, environment, and a response timestamp without hitting Convex or any external store. Point Fly.io readiness / liveness probes at it; point your own uptime monitors at it too. The response uses Cache-Control: no-store and remains intentionally @@ -204,12 +204,13 @@ X-RateLimit-Remaining: 599`}

Outbound retries

- Calls to Google Play's Android Publisher API and Meta Graph API are - wrapped in an exponential-backoff retry (max 3 attempts, base 200 ms, - cap 2 s, full jitter) that fires on HTTP 5xx and Node network errors ( - ECONNRESET, ETIMEDOUT, EAI_AGAIN, - …). 4xx responses β€” including 404 and 410, which are deterministic β€” are{" "} - not retried. + Calls to Apple's App Store Server API, Google Play's Android Publisher + API, Amazon RVS, and the Meta Graph API are wrapped in an + exponential-backoff retry (max 3 attempts, base 200 ms, cap 2 s, + jittered to 50–100% of the capped delay) that fires on HTTP 5xx and Node + network errors (ECONNRESET, ETIMEDOUT,{" "} + EAI_AGAIN, …). 4xx responses β€” including 404 and 410, which + are deterministic β€” are not retried.

Sentry

diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx index 0c54976b4..da86d5b06 100644 --- a/packages/kit/src/pages/docs/sections/projects.tsx +++ b/packages/kit/src/pages/docs/sections/projects.tsx @@ -39,11 +39,12 @@ export default function ProjectsPage() {

Creating a project

From the organization dashboard, open the Projects tab - and click New project. You supply a display name and - pick the client platform (React Native, Flutter, Kotlin Multiplatform, - native iOS / Android, web, …). The platform tag is informational β€” it - drives which setup guides the dashboard highlights; it doesn't affect - the verify API itself. + and click Create Project. Enter a{" "} + Project Name, optionally edit the generated{" "} + Project URL slug, and optionally pick a{" "} + Platform or Language. The platform tag is informational + β€” it drives which setup guides the dashboard highlights; it doesn't + affect the verify API itself.

1. Create your account

- Sign in with GitHub or email OTP on{" "} + Sign in with GitHub on{" "} kit.openiap.dev - . The onboarding flow asks you to name your first organization before - opening its dashboard. The hosted service is free under fair-use - safeguards on shared community infrastructure; no plan or credit card is - required. + . New accounts are created through GitHub; email one-time codes work + only for accounts created before April 2026. The onboarding flow asks + you to name your first organization before opening its dashboard. The + hosted service is free under fair-use safeguards on shared community + infrastructure; no plan or credit card is required.

2. Create a project

diff --git a/packages/kit/src/pages/docs/sections/verification-apple.tsx b/packages/kit/src/pages/docs/sections/verification-apple.tsx index f760176f3..6eeea1558 100644 --- a/packages/kit/src/pages/docs/sections/verification-apple.tsx +++ b/packages/kit/src/pages/docs/sections/verification-apple.tsx @@ -12,11 +12,13 @@ export default function VerificationApplePage() { >

Apple verification uses a signed JWS transaction produced by StoreKit 2 - on the device. IAPKit verifies the signature against Apple's root CA, - then calls the App Store Server API with your project's .p8{" "} - key to pull the transaction's current state (refund, revocation, grace - period). Both steps are required to catch refunds issued after the - purchase. + on the device. IAPKit decodes the JWS to read its transaction id, bundle + id, and environment, then calls the App Store Server API with your + project's .p8 key and cryptographically verifies the signed + transaction Apple returns against Apple's root CA. The device's copy of + the JWS is only a lookup key β€” the authoritative record is the one Apple + signs in its response, which is what catches refunds and revocations + issued after the purchase.

What you'll need

@@ -87,9 +89,11 @@ export default function VerificationApplePage() {

IAPKit reads the JWS environment field off the decoded payload, so the same project can verify both sandbox and production - receipts without a toggle. Just make sure the App Apple ID is set - before you go to production β€” the JWS signature is bound to it for - production-environment receipts. + receipts without a toggle. Set the App Apple ID before you ship to + production β€” a production-environment JWS is rejected with{" "} + PROJECT_APP_STORE_APPLE_ID_NOT_CONFIGURED before IAPKit + contacts Apple, because Apple's verification library refuses to run in + the production environment without it. Sandbox does not need it.

@@ -111,11 +115,11 @@ export default function VerificationApplePage() {

How refunds are detected

- After verifying the JWS signature, IAPKit calls the App Store Server - API's getTransactionInfo endpoint to fetch the current - state of that transaction β€” signature-valid means "the purchase once - happened"; getTransactionInfo tells you if it's still valid - right now. No extra flag on the request is required. + IAPKit calls the App Store Server API's getTransactionInfo{" "} + endpoint to fetch the current state of that transaction β€” the device's + JWS only proves a purchase once existed; getTransactionInfo{" "} + tells you if it's still valid right now. No extra flag on the request is + required.

For an active transaction, the decoded JWS payload looks like:

diff --git a/packages/kit/src/pages/docs/sections/verification-google.tsx b/packages/kit/src/pages/docs/sections/verification-google.tsx index e2458d46f..425df0ce1 100644 --- a/packages/kit/src/pages/docs/sections/verification-google.tsx +++ b/packages/kit/src/pages/docs/sections/verification-google.tsx @@ -136,10 +136,28 @@ export default function VerificationGooglePage() { Both v2 calls are wrapped in a 3-attempt exponential-backoff retry (200ms base, 2s cap, full jitter). The retry fires on HTTP 5xx and Node network errors (ECONNRESET, ETIMEDOUT,{" "} - EAI_AGAIN, …). 4xx responses β€” including 404 ("not a - product") and 410 ("token no longer valid") β€” are not{" "} - retried because re-issuing the call won't help and would only waste - quota. + EAI_AGAIN, …). A 404 from the product lookup is not an + error β€” it just means the token is a subscription, so IAPKit falls + through to subscriptionsv2. When neither catalog knows the + token, IAPKit retries the whole pair up to 3 times over roughly 750 ms, + because a purchase verified within a second of completing can still be + propagating inside Play. Every other 4xx response, including 410 ("token + no longer valid"), is not retried because re-issuing + the call won't help and would only waste quota. +

+ +

+ Negative verdicts that return 200 +

+

+ Not every rejection is an error. A revoked or purged token (Play 410) + returns 200 with isValid: false and{" "} + state: UNKNOWN β€” Google returns the same 410 for a token it + never issued and for a subscription purged 60 days after expiry, so + IAPKit cannot distinguish them; do not retry it. A token that belongs to + a different package returns 200 with{" "} + isValid: false and state: INAUTHENTIC. Gate + entitlement on isValid, not on the HTTP status.

Error codes

@@ -162,20 +180,12 @@ export default function VerificationGooglePage() {
- - - - diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index f7197375b..73ea6ba36 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -51,7 +51,7 @@ export default function LandingPage() { style={{ lineHeight: "1.2" }} > - {"Open IAP foundation for your"} + {"OpenIAP foundation for your"}

{ - "We contact each supported store, verify authoritative purchase state, and flag risky transactions before you deliver the item." + "We contact each supported store, verify authoritative purchase state, and return a single isValid answer before you deliver the item." }

diff --git a/packages/kit/src/utils/constants.ts b/packages/kit/src/utils/constants.ts index 953a5a558..49c461f87 100644 --- a/packages/kit/src/utils/constants.ts +++ b/packages/kit/src/utils/constants.ts @@ -1,19 +1 @@ -// App info -export const APP_NAME = "IAPKit"; -export const APP_URL = "https://openiap-kit.com"; -export const APP_DESCRIPTION = "Next-generation IAP integration solution"; - -// Contact export const SUPPORT_EMAIL = "hyo@hyo.dev"; -export const CONTACT_EMAIL = "hyo@hyo.dev"; - -if (!SUPPORT_EMAIL || !CONTACT_EMAIL) { - throw new Error("SUPPORT_EMAIL and CONTACT_EMAIL must be set"); -} - -// Social links -export const SOCIAL_LINKS = { - twitter: "https://twitter.com/openiap-kit", - github: "https://github.com/openiap-kit", - discord: "https://discord.gg/5AQd8BbxWT", -}; diff --git a/packages/mcp-server/src/mcp.ts b/packages/mcp-server/src/mcp.ts index a1fab866f..4d7312ad4 100644 --- a/packages/mcp-server/src/mcp.ts +++ b/packages/mcp-server/src/mcp.ts @@ -659,7 +659,7 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "revenue_analytics", - "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so Codex can answer questions like 'how many purchases happened this month?'.", + "Summarize IAPKit subscription purchase and revenue analytics for a date range. Defaults to the current UTC month so an assistant can answer questions like 'how many purchases happened this month?'.", { period: z .enum(["this_month", "last_30_days", "last_90_days", "custom"]) @@ -847,7 +847,7 @@ function registerIapKitTools(server: McpServer) { registerTool( server, "sync_products", - "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what Codex would change; set dryRun=false only when the user explicitly asks to apply the store sync.", + "Enqueue an IAPKit product sync job for App Store Connect or Google Play. Use dryRun=true first to inspect what the sync would change; set dryRun=false only when the user explicitly asks to apply the store sync.", { platform: z.enum(["IOS", "Android"]), direction: z @@ -859,7 +859,7 @@ function registerIapKitTools(server: McpServer) { dryRun: z .boolean() .optional() - .describe("Defaults to true so Codex previews store changes first."), + .describe("Defaults to true so store changes are previewed first."), apiKey: OPTIONAL_API_KEY, baseUrl: OPTIONAL_BASE_URL, }, diff --git a/scripts/audit-ci-path-filters.mjs b/scripts/audit-ci-path-filters.mjs new file mode 100644 index 000000000..dd68c8d8d --- /dev/null +++ b/scripts/audit-ci-path-filters.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node + +// Guards the path filters that gate native builds and Swift CodeQL. +// Filters are read from the workflows themselves, so there is no second copy. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parse as parseYaml } from "yaml"; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = path.resolve(path.dirname(scriptPath), ".."); + +const DOCS_EXCLUDE = "!**/*.md"; + +export const nativeWorkflows = Object.freeze([ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-godot-iap.yml", + "ci-kmp-iap.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", +]); + +export const ciFilterJobs = Object.freeze({ + android: "ci:test-android", + docs: "ci:test-docs", + gql: "ci:test-gql", + ios: "ci:test-ios", + web: "ci:web-e2e", +}); + +export const unconditionalCiJobs = Object.freeze([ + "audit-lockfile", + "audit-parity", + "audit-release-state", + "changes", + "test-agent", + "test-conformance", +]); + +function readWorkflow(root, name) { + return parseYaml( + fs.readFileSync(path.join(root, ".github/workflows", name), "utf8"), + ); +} + +function dornyStep(document, jobId, stepId) { + const step = document.jobs[jobId].steps.find((entry) => entry.id === stepId); + return { step, filters: parseYaml(step.with.filters) }; +} + +// The audited vocabulary is exactly three forms, so ordered (GitHub native) +// and polarity-based (dorny some-with-excludes) evaluation coincide. +export function matchesPattern(pattern, file) { + if (pattern === "**/*.md") { + return file.endsWith(".md"); + } + + if (pattern.endsWith("/**")) { + return file.startsWith(pattern.slice(0, -2)); + } + + return file === pattern; +} + +export function matchesFilter(patterns, files) { + const includes = patterns.filter((entry) => !entry.startsWith("!")); + const excludes = patterns + .filter((entry) => entry.startsWith("!")) + .map((entry) => entry.slice(1)); + + return files.some( + (file) => + includes.some((pattern) => matchesPattern(pattern, file)) && + !excludes.some((pattern) => matchesPattern(pattern, file)), + ); +} + +export function readScopes(root = repositoryRoot) { + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + const core = dornyStep(codeql, "codeql-scope", "core"); + const wrappers = dornyStep(codeql, "codeql-scope", "wrappers"); + const changes = dornyStep(ci, "changes", "filter"); + const scopes = new Map(); + + scopes.set("codeql:analyze-swift", core.filters.swift_core); + + for (const [component, patterns] of Object.entries(wrappers.filters)) { + scopes.set(`codeql:analyze-swift-wrappers/${component}`, patterns); + } + + for (const [name, patterns] of Object.entries(changes.filters)) { + const jobId = ciFilterJobs[name]; + + if (!jobId) { + throw new Error( + `ci.yml: filter '${name}' has no entry in ciFilterJobs; map it to the job it gates`, + ); + } + + scopes.set(jobId, patterns); + } + + for (const name of nativeWorkflows) { + scopes.set(name, readWorkflow(root, name).on.pull_request.paths); + } + + return scopes; +} + +export function selectJobs(files, root = repositoryRoot) { + return [...readScopes(root)] + .filter(([, patterns]) => matchesFilter(patterns, files)) + .map(([jobId]) => jobId) + .sort(); +} + +const ALL_SWIFT_WRAPPERS = [ + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/flutter", + "codeql:analyze-swift-wrappers/godot", + "codeql:analyze-swift-wrappers/react-native", +]; + +export const cases = Object.freeze([ + { + name: "apple-core-docs-only", + files: [ + "packages/apple/README.md", + "packages/apple/CONVENTION.md", + "packages/apple/CONTRIBUTING.md", + ], + jobs: [], + }, + { + name: "apple-core-source", + files: ["packages/apple/Sources/OpenIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-generated-types", + files: ["packages/apple/Sources/Models/Types.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-maui-iap.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-manifest", + files: ["packages/apple/Package.swift"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-react-native-iap.yml", + "ci:test-ios", + "codeql:analyze-swift", + ], + }, + { + name: "apple-core-build-script", + files: ["packages/apple/scripts/build-xcframework.sh"], + jobs: ["ci-maui-iap.yml", "ci:test-ios", "codeql:analyze-swift"], + }, + { + name: "kmp-swift-bridge-source", + files: [ + "libraries/kmp-iap/native/InAppPurchaseBridge/Sources/InAppPurchaseBridge/InAppPurchaseBridge.swift", + ], + jobs: ["ci-kmp-iap.yml", "codeql:analyze-swift"], + }, + { + name: "kmp-docs-only", + files: [ + "libraries/kmp-iap/AGENTS.md", + "libraries/kmp-iap/CLAUDE.md", + "libraries/kmp-iap/.vscode/README_IOS_DEVICE.md", + ], + jobs: [], + }, + { + name: "kmp-vscode-launch-script", + files: ["libraries/kmp-iap/.vscode/run_ios.sh"], + jobs: ["ci-kmp-iap.yml"], + }, + { + name: "react-native-docs-only", + files: [ + "libraries/react-native-iap/AGENTS.md", + "libraries/react-native-iap/CLAUDE.md", + "libraries/react-native-iap/.claude/commands/commit.md", + "libraries/react-native-iap/LICENSE.md", + "libraries/react-native-iap/example/README.md", + ], + jobs: [], + }, + { + name: "react-native-swift-source", + files: ["libraries/react-native-iap/ios/HybridRnIap.swift"], + jobs: [ + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "react-native-nitro-spec", + files: [ + "libraries/react-native-iap/src/specs/RnIap.nitro.ts", + "libraries/react-native-iap/nitro.json", + ], + jobs: [ + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "expo-docs-only", + files: [ + "libraries/expo-iap/README.md", + "libraries/expo-iap/CHANGELOG.md", + "libraries/expo-iap/GEMINI.md", + "libraries/expo-iap/example/README.md", + ], + jobs: [], + }, + { + name: "expo-swift-source", + files: ["libraries/expo-iap/ios/ExpoIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "expo-onside-swift-source", + files: ["libraries/expo-iap/ios/onside/OnsideIapModule.swift"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "expo-onside-podfile-plugin", + files: ["libraries/expo-iap/plugin/src/onsidePodfile.ts"], + jobs: [ + "ci-expo-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + ], + }, + { + name: "flutter-docs-only", + files: [ + "libraries/flutter_inapp_purchase/CHANGELOG.md", + "libraries/flutter_inapp_purchase/CONVENTION.md", + "libraries/flutter_inapp_purchase/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md", + ], + jobs: [], + }, + { + name: "flutter-build-script", + files: [ + "libraries/flutter_inapp_purchase/scripts/verify-apple-swiftpm-consumer-build.sh", + ], + jobs: [ + "ci-flutter-inapp-purchase.yml", + "codeql:analyze-swift-wrappers/flutter", + ], + }, + { + name: "godot-docs-only", + files: [ + "libraries/godot-iap/.claude/guides/03-ios-plugin.md", + "libraries/godot-iap/EXAMPLES.md", + ], + jobs: [], + }, + { + name: "godot-swift-source", + files: ["libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift"], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "godot-addon-behind-symlink", + files: ["libraries/godot-iap/addons/godot-iap/godot_iap.gd"], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "google-docs-only", + files: [ + "packages/google/README.md", + "packages/google/ALTERNATIVE_BILLING.md", + ], + jobs: [], + }, + { + name: "google-source", + files: ["packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt"], + jobs: ["ci-kmp-iap.yml", "ci-maui-iap.yml", "ci:test-android"], + }, + { + name: "root-version-manifest", + files: ["openiap-versions.json"], + jobs: [ + ...nativeWorkflows, + "ci:test-android", + "ci:test-gql", + "ci:test-ios", + "codeql:analyze-swift", + ...ALL_SWIFT_WRAPPERS, + ], + }, + { + name: "libraries-versions-manifest", + files: ["libraries-versions.jsonc"], + jobs: [ + "ci-expo-iap.yml", + "ci-flutter-inapp-purchase.yml", + "ci-react-native-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/flutter", + "codeql:analyze-swift-wrappers/react-native", + ], + }, + { + name: "codeql-workflow-edit", + files: [".github/workflows/codeql.yml"], + jobs: ["codeql:analyze-swift", ...ALL_SWIFT_WRAPPERS], + }, + { + name: "maui-binding-source", + files: ["libraries/maui-iap/src/OpenIap.Maui/Types.cs"], + jobs: ["ci-maui-iap.yml", "ci:test-gql"], + }, + { + // kit ships .md as bundled site content, so `web` keeps every markdown path. + name: "kit-content-markdown", + files: ["packages/kit/src/content/privacy-policy.md"], + jobs: ["ci:web-e2e"], + }, + { + name: "docs-site-source", + files: ["packages/docs/src/pages/docs/index.tsx"], + jobs: ["ci:test-docs", "ci:web-e2e"], + }, + { + // docs and web intentionally keep markdown; both are cheap ubuntu jobs. + name: "docs-markdown", + files: ["packages/docs/README.md"], + jobs: ["ci:test-docs", "ci:web-e2e"], + }, + { + name: "scripts-docs-only", + files: ["scripts/agent/README.md"], + jobs: [], + }, + { + name: "mixed-docs-and-source", + files: [ + "libraries/expo-iap/README.md", + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/Binder.swift", + ], + jobs: ["ci-godot-iap.yml", "codeql:analyze-swift-wrappers/godot"], + }, + { + name: "mixed-two-wrappers", + files: [ + "libraries/expo-iap/ios/ExpoIapLog.swift", + "libraries/godot-iap/ios-gdextension/Package.swift", + ], + jobs: [ + "ci-expo-iap.yml", + "ci-godot-iap.yml", + "codeql:analyze-swift-wrappers/expo", + "codeql:analyze-swift-wrappers/expo-onside", + "codeql:analyze-swift-wrappers/godot", + ], + }, + { + name: "pr-361-docs-replay", + files: [ + "AGENTS.md", + "libraries/expo-iap/AGENTS.md", + "libraries/godot-iap/CLAUDE.md", + "libraries/maui-iap/CONVENTION.md", + "libraries/react-native-iap/GEMINI.md", + "knowledge/internal/02-architecture.md", + "packages/apple/CONTRIBUTING.md", + ], + jobs: [], + }, +]); + +function findVocabularyViolations(scopes) { + const findings = []; + + for (const [scope, patterns] of scopes) { + const hasExclude = patterns.some((pattern) => pattern.startsWith("!")); + + for (const pattern of patterns) { + if (pattern.startsWith("!") && pattern !== DOCS_EXCLUDE) { + findings.push( + `${scope}: unsupported negation '${pattern}'; only '${DOCS_EXCLUDE}' is proven equivalent across both matchers`, + ); + continue; + } + + if ( + hasExclude && + !pattern.startsWith("!") && + (pattern.endsWith(".md") || pattern.endsWith(".mdx")) + ) { + findings.push( + `${scope}: positive '${pattern}' targets markdown and would be unreachable behind '${DOCS_EXCLUDE}'`, + ); + } + + const body = pattern.startsWith("!") ? pattern.slice(1) : pattern; + const literal = body.endsWith("/**") ? body.slice(0, -3) : body; + + if (body !== "**/*.md" && /[*?+[\]{}]/u.test(literal)) { + findings.push( + `${scope}: pattern '${pattern}' is outside the audited vocabulary`, + ); + } + } + } + + return findings; +} + +function findQuantifierViolations(root) { + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + const steps = [ + ["codeql.yml", "codeql-scope", "core", dornyStep(codeql, "codeql-scope", "core")], + [ + "codeql.yml", + "codeql-scope", + "wrappers", + dornyStep(codeql, "codeql-scope", "wrappers"), + ], + ["ci.yml", "changes", "filter", dornyStep(ci, "changes", "filter")], + ]; + + return steps.flatMap(([file, jobId, stepId, { step, filters }]) => { + const negates = Object.values(filters).some((patterns) => + patterns.some((pattern) => pattern.startsWith("!")), + ); + + if (!negates || step.with["predicate-quantifier"] === "some-with-excludes") { + return []; + } + + return [ + `${file}:${jobId}/${stepId}: negated patterns require predicate-quantifier: some-with-excludes`, + ]; + }); +} + +function findCoverageViolations(root) { + const findings = []; + const codeql = readWorkflow(root, "codeql.yml"); + const ci = readWorkflow(root, "ci.yml"); + + for (const [name, document] of [ + ["codeql.yml", codeql], + ["ci.yml", ci], + ]) { + for (const event of ["pull_request", "push"]) { + const trigger = document.on[event] ?? {}; + + if (trigger.paths || trigger["paths-ignore"]) { + findings.push( + `${name}: ${event} must stay unfiltered; job-level gating keeps skipped checks reportable`, + ); + } + } + } + + for (const event of ["schedule", "workflow_dispatch"]) { + if (!(event in codeql.on)) { + findings.push(`codeql.yml: ${event} coverage was removed`); + } + } + + for (const output of ["swift_core", "swift_wrappers", "swift_components"]) { + const expression = codeql.jobs["codeql-scope"].outputs[output] ?? ""; + + if (!expression.includes("github.event_name != 'pull_request'")) { + findings.push(`codeql.yml: ${output} lost its non-pull_request fallback`); + } + } + + for (const stepId of ["core", "wrappers"]) { + const { step } = dornyStep(codeql, "codeql-scope", stepId); + + if (step.if !== "github.event_name == 'pull_request'") { + findings.push( + `codeql.yml: ${stepId} step must stay pull_request-only so other events fall back to full scope`, + ); + } + } + + for (const jobId of unconditionalCiJobs) { + const job = ci.jobs[jobId]; + + if (!job) { + findings.push(`ci.yml: job ${jobId} is missing`); + continue; + } + + if ("needs" in job || "if" in job) { + findings.push(`ci.yml: job ${jobId} must stay unconditional`); + } + } + + // A filter only means something through the job it gates, so pin that wiring. + const { filters: ciFilters } = dornyStep(ci, "changes", "filter"); + + for (const [name, label] of Object.entries(ciFilterJobs)) { + const jobId = label.replace(/^ci:/u, ""); + const job = ci.jobs[jobId]; + + if (!(name in ciFilters)) { + findings.push( + `ci.yml: filter '${name}' was removed but job ${jobId} still gates on it`, + ); + continue; + } + + if (!job) { + findings.push(`ci.yml: job ${jobId} is missing; update ciFilterJobs`); + continue; + } + + if (job.if !== `needs.changes.outputs.${name} == 'true'`) { + findings.push( + `ci.yml: job ${jobId} must gate on needs.changes.outputs.${name}`, + ); + } + + if ( + ci.jobs.changes.outputs[name] !== `\${{ steps.filter.outputs.${name} }}` + ) { + findings.push(`ci.yml: changes job must publish the ${name} filter output`); + } + } + + for (const name of nativeWorkflows) { + const document = readWorkflow(root, name); + const pullRequest = document.on.pull_request.paths; + const push = document.on.push.paths; + + if (JSON.stringify(pullRequest) !== JSON.stringify(push)) { + findings.push(`${name}: push.paths must equal pull_request.paths`); + } + + if (pullRequest.at(-1) !== DOCS_EXCLUDE) { + findings.push(`${name}: '${DOCS_EXCLUDE}' must be the last paths entry`); + } + + if (!pullRequest.some((pattern) => !pattern.startsWith("!"))) { + findings.push(`${name}: paths needs at least one non-negated pattern`); + } + } + + return findings; +} + +export function findPolicyViolations(root = repositoryRoot) { + return [ + ...findQuantifierViolations(root), + ...findVocabularyViolations(readScopes(root)), + ...findCoverageViolations(root), + ]; +} + +export function auditCiPathFilters(root = repositoryRoot) { + const selectionFindings = cases.flatMap(({ name, files, jobs }) => { + const expected = [...jobs].sort(); + const actual = selectJobs(files, root); + + return JSON.stringify(actual) === JSON.stringify(expected) + ? [] + : [`${name}: expected [${expected}], got [${actual}]`]; + }); + + return [...selectionFindings, ...findPolicyViolations(root)].sort(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const errors = auditCiPathFilters(); + + if (errors.length === 0) { + console.log("CI path filter audit: clean."); + } else { + console.error("CI path filter audit failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } +} diff --git a/scripts/audit-ci-path-filters.test.mjs b/scripts/audit-ci-path-filters.test.mjs new file mode 100644 index 000000000..a82a92e86 --- /dev/null +++ b/scripts/audit-ci-path-filters.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + auditCiPathFilters, + cases, + findPolicyViolations, + matchesFilter, + selectJobs, +} from "./audit-ci-path-filters.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +function withPatchedWorkflows(patch, run) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-ci-paths-")); + + try { + fs.cpSync( + path.join(repositoryRoot, ".github/workflows"), + path.join(root, ".github/workflows"), + { recursive: true }, + ); + patch(path.join(root, ".github/workflows")); + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +for (const { name, files, jobs } of cases) { + test(`selects the expected jobs for ${name}`, () => { + assert.deepEqual(selectJobs(files), [...jobs].sort()); + }); +} + +test("workflow path filters satisfy the audited policy", () => { + assert.deepEqual(auditCiPathFilters(), []); +}); + +test("markdown exclusion is final and cannot be re-included", () => { + const patterns = ["packages/apple/**", "!**/*.md"]; + + assert.equal(matchesFilter(patterns, ["packages/apple/README.md"]), false); + assert.equal(matchesFilter(patterns, ["packages/apple/Sources/A.swift"]), true); + // Order must not change the answer; that is what makes one list safe in both + // dorny (polarity) and GitHub native paths (last match wins). + assert.equal( + matchesFilter(["!**/*.md", "packages/apple/**"], ["packages/apple/README.md"]), + false, + ); +}); + +test("rejects negation without the some-with-excludes quantifier", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "codeql.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replaceAll(" predicate-quantifier: some-with-excludes\n", ""), + ); + }, + (root) => { + assert.deepEqual( + findPolicyViolations(root).filter((finding) => + finding.includes("predicate-quantifier"), + ), + [ + "codeql.yml:codeql-scope/core: negated patterns require predicate-quantifier: some-with-excludes", + "codeql.yml:codeql-scope/wrappers: negated patterns require predicate-quantifier: some-with-excludes", + ], + ); + }, + ); +}); + +test("rejects negation forms outside the audited vocabulary", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "codeql.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " - 'libraries/expo-iap/**'\n - 'libraries-versions.jsonc'", + " - 'libraries/expo-iap/**'\n - '!libraries/expo-iap/docs/**'\n - 'libraries-versions.jsonc'", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).some((finding) => + finding.includes("unsupported negation '!libraries/expo-iap/docs/**'"), + ), + ); + }, + ); +}); + +test("rejects a workflow-level paths filter on ci.yml or codeql.yml", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + "on:\n pull_request:\n branches:\n - main\n - next\n", + "on:\n pull_request:\n branches:\n - main\n - next\n paths:\n - 'packages/**'\n", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).some( + (finding) => finding === "ci.yml: pull_request must stay unfiltered; job-level gating keeps skipped checks reportable", + ), + ); + }, + ); +}); + +test("rejects rewiring a gated job onto the wrong filter", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " if: needs.changes.outputs.ios == 'true'", + " if: needs.changes.outputs.docs == 'true'", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: job test-ios must gate on needs.changes.outputs.ios", + ), + ); + }, + ); +}); + +test("rejects deleting a filter that a job still gates on", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + ` docs: + - 'packages/docs/**' + - 'packages/gql/src/generated/**' + - 'packages/gql/generated-sync-manifest.mjs' + - 'scripts/audit-docs.ts' + - 'scripts/audit-docs.test.ts' + - '.github/workflows/ci.yml' +`, + "", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: filter 'docs' was removed but job test-docs still gates on it", + ), + ); + }, + ); +}); + +test("rejects gating a job that guards the markdown corpus", () => { + withPatchedWorkflows( + (workflows) => { + const file = path.join(workflows, "ci.yml"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replace( + " audit-parity:\n name: Audit SDK Parity\n", + " audit-parity:\n name: Audit SDK Parity\n needs: changes\n", + ), + ); + }, + (root) => { + assert.ok( + findPolicyViolations(root).includes( + "ci.yml: job audit-parity must stay unconditional", + ), + ); + }, + ); +}); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index ce3573baf..8bc017055 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -7447,6 +7447,7 @@ function checkFrameworkDependencyHygiene() { "libraries/expo-iap/plugin/src/withVega.ts", "libraries/expo-iap/example/vega/package.json", "libraries/react-native-iap/example/vega/package.json", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", ]) { expectIncludes( vegaDependencyFile, @@ -7464,6 +7465,18 @@ function checkFrameworkDependencyHygiene() { ["^0.0.7"], "Expo Vega plugin must install the current compatibility Metro config", ); + for (const vegaManifestFile of [ + "libraries/expo-iap/plugin/src/withVega.ts", + "libraries/expo-iap/example/scripts/vega-build-config.mjs", + "libraries/react-native-iap/example/manifest.toml", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", + ]) { + expectIncludes( + vegaManifestFile, + ["/com.amazon.vega.os@IVega_1_2", "[os.version]"], + "Vega manifests must declare the OS module and version required since Vega SDK 0.24", + ); + } expectOptionalIncludes( "libraries/expo-iap/example/android/settings.gradle", [ From 217f142852d18887d7f8a3b3d404f93867b3949f Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 19 Aug 2026 23:56:03 +0900 Subject: [PATCH 02/21] docs(kit): reconcile the IAPKit surface with OpenIAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the eleven items the kit site audit could not decide mechanically, and adds the workflow that finds this class of drift next time. IAPKit is a deployable SaaS, so it sits outside the GQL type-sync chain that keeps the SDKs aligned. Nothing regenerates its site copy when the spec, the stores, or the SDKs move, which is why the drift below accumulated unnoticed. `/audit-iapkit` makes that check repeatable, with OpenIAP and the kit implementation as the source of truth. Facts corrected against the implementation: - Amazon RVS has no Fire OS or Vega branch, so Vega verifies identically today. The store page, the introduction card, and the blog FAQ all claimed Fire OS only or called Vega future work. - `userId` is required by `route-input-schemas.ts`; omitting it returns 400 before RVS is called. The setup guide called it optional. - The FAQ said consumable consumption cannot be detected. Google Play reports it, so a consumed Google consumable returns `CONSUMED` with `isValid: false`; Apple and Amazon do not report it. - The FAQ credited fraud heuristics that do not exist, and described lifecycle handling as outbound webhooks, which the direction guardrail forbids. - The April blog roadmap listed shipped integrations as future work and advertised outbound notifications. It is now marked as a dated snapshot with that bullet removed. Documented what was missing: - Subscription endpoints had no path, key type, or shape anywhere on the site, even though `bind-user` is the prerequisite for every read. - `state` has two vocabularies. Gating a subscription snapshot on `state === "ENTITLED"` never matches, and nothing said so. Removed what contradicted the text: `signup.webp` shows the Email/GitHub tab layout dropped in April, and `project-create.webp` shows a modal that is now an inline form. Both were checked by opening them. An absent figure beats one that disagrees with the prose. Also: the FAQ had no route and was unreachable, so it rotted unseen β€” it is now served at `/faq` and linked from the footer; About and Contact still read like the paid-tier era; the binding legal documents carried no effective date; the Codex plugin manifest pointed at a page with no setup steps; and nineteen nav summaries were maintained but never rendered. Co-Authored-By: Claude Opus 5 --- .claude/commands/audit-iapkit.md | 125 ++++++++++++++++++ AGENTS.md | 1 + .../src/pages/docs/setup/store/amazon.tsx | 2 +- .../docs/screenshots/project-create.webp | Bin 29754 -> 0 bytes .../kit/public/docs/screenshots/signup.webp | Bin 41594 -> 0 bytes packages/kit/src/components/Footer.tsx | 8 ++ packages/kit/src/content/about.md | 6 +- packages/kit/src/content/contact.md | 7 +- packages/kit/src/content/faq.md | 8 +- packages/kit/src/content/privacy-policy.md | 3 + packages/kit/src/content/terms-of-service.md | 3 + .../src/pages/blog/iapkit-joins-openiap.tsx | 23 ++-- packages/kit/src/pages/docs/DocsLayout.tsx | 1 + packages/kit/src/pages/docs/sections/api.tsx | 74 ++++++++++- .../src/pages/docs/sections/introduction.tsx | 2 +- .../kit/src/pages/docs/sections/projects.tsx | 7 +- .../src/pages/docs/sections/quickstart.tsx | 18 ++- .../src/pages/docs/sections/release-notes.tsx | 2 +- .../docs/sections/verification-amazon.tsx | 12 +- packages/kit/src/pages/faq.tsx | 14 ++ packages/kit/src/pages/index.tsx | 17 +++ plugins/openiap/.codex-plugin/plugin.json | 2 +- 22 files changed, 291 insertions(+), 44 deletions(-) create mode 100644 .claude/commands/audit-iapkit.md delete mode 100644 packages/kit/public/docs/screenshots/project-create.webp delete mode 100644 packages/kit/public/docs/screenshots/signup.webp create mode 100644 packages/kit/src/pages/faq.tsx diff --git a/.claude/commands/audit-iapkit.md b/.claude/commands/audit-iapkit.md new file mode 100644 index 000000000..a75a74606 --- /dev/null +++ b/.claude/commands/audit-iapkit.md @@ -0,0 +1,125 @@ +--- +name: audit-iapkit +description: Audit the IAPKit product surface against OpenIAP as the source of truth, then fix the drift it finds. Use when the user asks to check whether IAPKit reflects OpenIAP updates, audit kit docs, or reconcile kit.openiap.dev with openiap.dev. +--- + +# Audit IAPKit Against OpenIAP + +IAPKit is a deployable SaaS, not a library, so it sits outside the GQL type-sync +chain that keeps the SDKs aligned. Nothing regenerates its site copy when the +spec, the stores, or the SDKs move, so its documentation drifts silently. This +workflow finds that drift and fixes it. + +Read `packages/kit/CONVENTION.md` before editing anything under `packages/kit`. + +## Direction of truth + +```text +OpenIAP spec + packages/kit implementation β†’ IAPKit site copy + (authoritative) (must follow) +``` + +When two surfaces disagree, the implementation wins over any prose, and +`packages/docs` wins over `packages/kit` for shared product claims. Never +"fix" the code to match a doc without saying so explicitly. + +## Workflow + +```text +1. Establish what changed upstream + ↓ +2. Check every prose claim against the implementation + ↓ +3. Check kit against packages/docs for contradictions + ↓ +4. Apply mechanical fixes; escalate product calls + ↓ +5. Verify +``` + +## Steps + +### 1. Establish what changed upstream + +```bash +# Spec and SDK movement since the kit surface was last reviewed. +git log --oneline -20 -- packages/gql/src/type.graphql openiap-versions.json +# Least recently reviewed kit files first β€” that is where drift concentrates. +for f in $(git ls-files packages/kit/src/pages/docs/sections packages/kit/src/content); do + echo "$(git log -1 --format='%ad' --date=short -- "$f") $f" +done | sort +``` + +Also check upstream store documentation for anything the kit pages describe: +App Store Server API, Google Play Developer API, Amazon RVS, Meta Horizon, and +the Vega SDK release notes. + +### 2. Check prose against the implementation + +This is the highest-value pass. For every checkable claim on the kit site, find +the code that implements it and confirm the claim matches. Cite `file:line` for +both sides. + +Highest-yield targets, in order: + +- **Verification order and cryptography** β€” `packages/kit/convex/purchases/*.ts`. + A page saying IAPKit verifies something it does not verify is the worst class + of error. +- **Error codes** β€” confirm each documented code can actually reach a caller. + Codes raised internally and re-wrapped before the response must not be listed. +- **Endpoints, fields, and limits** β€” `packages/kit/server/api/v1/**`, + especially `route-input-schemas.ts` for which fields are required. A field the + server requires but the docs call optional makes every following example 400. +- **Negative verdicts that return 200** β€” outcomes that are not errors but are + documented as if they were, or not documented at all. +- **Numbers** β€” retry counts, rate limits, size caps, file sizes, retention + windows. These rot silently; recompute rather than trusting the page. + +### 3. Check kit against `packages/docs` + +The two sites describe one product. Find statements that contradict each other +and decide which side is right from the code, then fix the wrong side. + +```bash +bun run audit:docs +``` + +### 4. Apply fixes, escalate decisions + +Fix mechanically when the correct text is determined by the code: a wrong fact, +an unreachable error code, a stale number, a broken link, a naming violation. + +Escalate to the user, do not guess, when the fix requires a product call: +what the product officially claims to support, support channels, pricing or +plan statements, legal document content, restructuring a page, or consolidating +pages that have published URLs. + +Constraints that override any finding: + +- **Webhook direction.** The only supported direction is store β†’ IAPKit. Never + document an IAPKit β†’ SDK/mobile webhook, SSE, WebSocket, push relay, or + long-poll feed. See the root `AGENTS.md`. +- **Brand.** `OpenIAP` and `IAPKit`, never `Open IAP`, `IAP Kit`, or bare `Kit`. +- **Reader-first standard.** `knowledge/internal/05-docs-patterns.md`. Remove + filler and state each fact once; do not restyle prose that is already clear. +- **Screenshots.** A figure that contradicts corrected text is worse than no + figure. Open the image before trusting its caption. + +### 5. Verify + +```bash +bun run --filter @hyodotdev/openiap-kit lint +bun run --filter @hyodotdev/openiap-kit test +bun run --filter @hyodotdev/openiap-kit smoke:server +bun run audit:kit-contract +bun run audit:docs +``` + +`packages/kit` changes also trigger the CI-equivalent gate in +`.husky/pre-commit`, which mirrors `deploy-kit.yml`. + +## Report + +Group findings as **fixed** (with file:line), **needs a decision** (with the +options and your recommendation), and **rejected** (with the reason). Say +plainly when a surface is in good shape rather than manufacturing work. diff --git a/AGENTS.md b/AGENTS.md index a1ed985c5..c6356a7f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -303,6 +303,7 @@ Cursor-specific files. | `/review-pr` | Review PR comments, fix issues, resolve threads | `/review-pr 65` or `/review-pr ` | | `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | | `/audit-security` | Audit SBOM, provenance, and supply-chain posture | `/audit-security` | +| `/audit-iapkit` | Reconcile the IAPKit surface with OpenIAP updates | `/audit-iapkit` | | `/compile-knowledge` | Compile the shared AI agent context | `/compile-knowledge` | | `/resolve-issue` | Analyze an issue, label it, and fix/comment | `/resolve-issue 88` | | `/verify-all` | Run the full monorepo health check | `/verify-all` | diff --git a/packages/docs/src/pages/docs/setup/store/amazon.tsx b/packages/docs/src/pages/docs/setup/store/amazon.tsx index 4d5dbe127..f92ce77bf 100644 --- a/packages/docs/src/pages/docs/setup/store/amazon.tsx +++ b/packages/docs/src/pages/docs/setup/store/amazon.tsx @@ -445,7 +445,7 @@ min = "1.2"`}

Fire OS and Vega OS both use the{' '} IAPKit Amazon payload. Pass the - Amazon user id when available, the Amazon receipt id, and{' '} + Amazon user id (required), the Amazon receipt id, and{' '} expectedProductId for server-side product binding. For Amazon App Tester, first enable{' '} Allow Amazon App Tester / RVS Cloud Sandbox in the diff --git a/packages/kit/public/docs/screenshots/project-create.webp b/packages/kit/public/docs/screenshots/project-create.webp deleted file mode 100644 index 51095bc75ef49770c4d6e6442343e1a6850ce7b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29754 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 R<6X91?4R`iFtz`y`hOmVWi$W) diff --git a/packages/kit/public/docs/screenshots/signup.webp b/packages/kit/public/docs/screenshots/signup.webp deleted file mode 100644 index 6132d98912db7e7a45912fc7315154086315451b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41594 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 +

  • + + {"FAQ"} + +
  • = [ }, { q: "Which platforms does IAPKit support?", - a: "IAPKit validates receipts for Apple App Store, Google Play, Meta Horizon, and Amazon Appstore. Vega OS receipt-validation support is on the roadmap.", + a: "IAPKit validates receipts for Apple App Store, Google Play, Meta Horizon, and Amazon Appstore. Fire OS and Vega OS both verify through the same Amazon RVS payload.", }, ]; @@ -250,21 +250,16 @@ export default function IapkitJoinsOpenIap() {

    What's next

    -

    This transition frees us to invest in what actually matters:

    +

    + This transition frees us to invest in what actually matters. The list + below is the roadmap as it stood in April 2026 β€” the platform + integrations and Fire OS / Vega OS validation have since shipped, and + store notifications now flow into IAPKit and keep its subscription + snapshot current. See the documentation for what + ships today. +

      -
    • - Deeper platform integrations β€” App Store Server API v2, Google Play - Billing v7+, Meta Horizon. -
    • -
    • - New receipt-validation support coming β€” Fire OS, - Vega OS. -
    • -
    • - Server-side webhooks and real-time notifications β€” renewals, - refunds, purchase events. -
    • Observability into purchase flows β€” see exactly where payments break. diff --git a/packages/kit/src/pages/docs/DocsLayout.tsx b/packages/kit/src/pages/docs/DocsLayout.tsx index 7f4040618..b90c8db91 100644 --- a/packages/kit/src/pages/docs/DocsLayout.tsx +++ b/packages/kit/src/pages/docs/DocsLayout.tsx @@ -194,6 +194,7 @@ function DocsNavRow({ +

      + state has two distinct vocabularies. The table below is the + verification vocabulary returned by /v1/purchase/verify. + The subscription snapshot endpoints use a lifecycle vocabulary instead β€”{" "} + Active, InGracePeriod,{" "} + InBillingRetry, Expired, Revoked,{" "} + Refunded, Paused, Unknown β€” so + gating a snapshot on state === "ENTITLED" never + matches. +

      +
      -
  • - PLAY_STORE_PURCHASE_NOT_FOUND - - Token doesn't resolve to a product or subscription β€” usually a - replay or a subscription purged after 60 days of inactivity. -
    - PLAY_STORE_PURCHASE_VERIFICATION_FAILED + PLAY_STORE_VERIFICATION_ERROR - Auth failure, permission mismatch, or Google returned a shape - IAPKit couldn't interpret. + Every store-side failure after credentials load: token not + found, auth or permission failure, or a response IAPKit could + not interpret. The originating reason is in the message.
    +
    @@ -525,6 +536,67 @@ async function refreshEntitlements(
    State
    +

    Subscription endpoints

    +

    + Bind first, then read. POST /v1/subscriptions/bind-user{" "} + associates a store transaction with your own user id; until a purchase + is bound, the read endpoints resolve userId against rows + that were never linked and return an empty snapshot. +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    EndpointKeyNotes
    + POST /v1/subscriptions/bind-user + Publishable + Links a purchase to your user id. Body up to 32 KB. +
    + GET /v1/subscriptions/status + Publishable + Current snapshot for one userId (≀256 chars). + Supports ETag / If-None-Match. +
    + GET /v1/subscriptions/entitlements + Publishable + Entitled product ids for one userId. Already + filtered to non-expired rows, so everything returned is + currently entitled. +
    + GET /v1/subscriptions/list + Secret + Project-wide administrative listing. limit capped + at 200. +
    +
    +

    Response headers

    Verification requests that pass bearer-token shape validation carry a diff --git a/packages/kit/src/pages/docs/sections/introduction.tsx b/packages/kit/src/pages/docs/sections/introduction.tsx index 4311889a9..cb8e5affd 100644 --- a/packages/kit/src/pages/docs/sections/introduction.tsx +++ b/packages/kit/src/pages/docs/sections/introduction.tsx @@ -77,7 +77,7 @@ export default function IntroductionPage() { } title="Amazon Appstore" - detail="Fire OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in." + detail="Fire OS and Vega OS receipts verified and periodically refreshed through Amazon RVS. Cloud Sandbox is disabled by default and requires an explicit project opt-in." slug="verification/amazon" />

    diff --git a/packages/kit/src/pages/docs/sections/projects.tsx b/packages/kit/src/pages/docs/sections/projects.tsx index da86d5b06..a1642854c 100644 --- a/packages/kit/src/pages/docs/sections/projects.tsx +++ b/packages/kit/src/pages/docs/sections/projects.tsx @@ -46,11 +46,6 @@ export default function ProjectsPage() { β€” it drives which setup guides the dashboard highlights; it doesn't affect the verify API itself.

    -

    Store credentials

    Each project's Settings tab has two store configuration cards:

    @@ -155,7 +150,7 @@ export default function ProjectsPage() { diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index a82dff32a..5fb9309a8 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -29,11 +29,6 @@ export default function QuickstartPage() { hosted service is free under fair-use safeguards on shared community infrastructure; no plan or credit card is required.

    -

    2. Create a project

    @@ -202,6 +197,19 @@ export default function QuickstartPage() { Amazon RVS shared secret.

    +

    + React Native IAP and Expo IAP ship a kitApi helper so you + do not have to build these requests by hand β€” see the sample on{" "} + + Products + {" "} + and the SDK matrix on{" "} + + Compatibility + + . +

    +

    Expected response:

    {`{ diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index 6e5f92378..ae0fd64d7 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -464,7 +464,7 @@ export default function ReleaseNotesPage() { className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1 font-mono text-xs hover:border-primary/50 hover:text-primary" > - {release.version ? `v${release.version}` : "Hosted update"} + {release.version ? `v${release.version}` : release.tagline} {formatDate(release.date)} diff --git a/packages/kit/src/pages/docs/sections/verification-amazon.tsx b/packages/kit/src/pages/docs/sections/verification-amazon.tsx index 6d255c7c5..b87270470 100644 --- a/packages/kit/src/pages/docs/sections/verification-amazon.tsx +++ b/packages/kit/src/pages/docs/sections/verification-amazon.tsx @@ -9,21 +9,23 @@ export default function VerificationAmazonPage() {

    IAPKit verifies Amazon Appstore receipts through the Receipt Verification Service (RVS). Your app sends the Amazon{" "} - userId and receiptId; IAPKit selects the RVS - environment and supplies the project's credential without exposing - it to the app. + userId (required) and receiptId; IAPKit + selects the RVS environment and supplies the project's credential + without exposing it to the app.

    Amazon settings live beside Google Play and Meta Horizon because Fire OS apps use the Android project surface. Google Play configuration is - not required for an Amazon-only project. + not required for an Amazon-only project. Vega OS apps send the same + userId / receiptId payload to the same endpoint and need no separate + configuration.

    diff --git a/packages/kit/src/pages/faq.tsx b/packages/kit/src/pages/faq.tsx new file mode 100644 index 000000000..f24d02b25 --- /dev/null +++ b/packages/kit/src/pages/faq.tsx @@ -0,0 +1,14 @@ +import { FAQSection } from "@/components/FAQSection"; +import faqContent from "@/content/faq.md?raw"; +import { parseFaqMarkdown } from "@/utils/faq"; + +export default function FaqPage() { + return ( + + ); +} diff --git a/packages/kit/src/pages/index.tsx b/packages/kit/src/pages/index.tsx index fc5c0311e..f188862e4 100644 --- a/packages/kit/src/pages/index.tsx +++ b/packages/kit/src/pages/index.tsx @@ -14,6 +14,7 @@ import Terms from "./terms-of-service"; import Privacy from "./privacy-policy"; import About from "./about"; import Contact from "./contact"; +import Faq from "./faq"; import NotFound from "./404"; // Public Layout Component (for unauthenticated users) @@ -73,6 +74,22 @@ export default function PublicPages() { } /> + + + + } + /> }> } /> } /> diff --git a/plugins/openiap/.codex-plugin/plugin.json b/plugins/openiap/.codex-plugin/plugin.json index 772b6c59a..f21e4d837 100644 --- a/plugins/openiap/.codex-plugin/plugin.json +++ b/plugins/openiap/.codex-plugin/plugin.json @@ -6,7 +6,7 @@ "name": "OpenIAP", "url": "https://openiap.dev" }, - "homepage": "https://kit.openiap.dev/docs/ai-assistants/codex-plugin", + "homepage": "https://openiap.dev/docs/guides/mcp-server", "repository": "https://github.com/hyodotdev/openiap", "license": "MIT", "keywords": ["openiap", "iapkit", "in-app-purchases", "mcp", "codex"], From f568614a7f2297e2f7076ca7108f031834477942 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 00:19:47 +0900 Subject: [PATCH 03/21] feat(kit): sunset email sign-in on 2026-09-30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New signups have been GitHub-only since 2026-04, but the Resend OTP provider stayed live indefinitely so the remaining email-only accounts could still get in. Give that an end date instead of leaving it open. `convex/authWindow.ts` holds the cutoff and `createOrUpdateUser` now rejects every resend-otp sign-in past it, not just new ones. `canSignInWithEmail` returns false once closed, so the modal stops paying Resend for a send that would be rejected. While the window is open the modal names the deadline and says an existing account carries over; once it closes the email link is not rendered at all, because the server would dead-end it. Merging already worked and is unchanged: `createOrUpdateUser` looks the user up by email and returns the existing id, so signing in with GitHub on the same address lands on the same account. The cutoff is written twice on purpose. The sign-in modal is the one screen that must render even mid-deploy, so gating it on a freshly deployed Convex query is a bad trade β€” an earlier revision did exactly that and crashed the modal into the error boundary when the query was not yet registered. `src/utils/constants.ts` mirrors the date and `constants.test.ts` asserts the two agree at every boundary, so the copy cannot drift. Known limit, deliberately accepted: merging keys on email, so an account whose GitHub email differs from its IAPKit email loses access when the window shuts. Worth confirming the overlap for the ~110 affected accounts before 2026-09-30. Co-Authored-By: Claude Opus 5 --- packages/kit/convex/auth.ts | 10 ++++ packages/kit/convex/authWindow.test.ts | 24 ++++++++ packages/kit/convex/authWindow.ts | 15 +++++ packages/kit/convex/users/query.ts | 2 + .../kit/src/components/AuthModal/index.tsx | 59 +++++++++++-------- .../src/pages/docs/sections/quickstart.tsx | 13 ++-- packages/kit/src/utils/constants.test.ts | 28 +++++++++ packages/kit/src/utils/constants.ts | 9 +++ 8 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 packages/kit/convex/authWindow.test.ts create mode 100644 packages/kit/convex/authWindow.ts create mode 100644 packages/kit/src/utils/constants.test.ts diff --git a/packages/kit/convex/auth.ts b/packages/kit/convex/auth.ts index 8d892ce3d..e411cd05d 100644 --- a/packages/kit/convex/auth.ts +++ b/packages/kit/convex/auth.ts @@ -6,6 +6,7 @@ import { } from "./ResendOTP"; import GitHub, { type GitHubProfile } from "@auth/core/providers/github"; import { api, internal } from "./_generated/api"; +import { EMAIL_SIGN_IN_CLOSES_ON, isEmailSignInOpen } from "./authWindow"; const CustomAuth = convexAuth({ providers: [ @@ -28,6 +29,15 @@ const CustomAuth = convexAuth({ ], callbacks: { async createOrUpdateUser(ctx, args) { + // Grace period, then GitHub-only. Existing email accounts are merged by + // matching email on GitHub sign-in, so closing this path costs access + // only to someone whose GitHub email differs from their IAPKit email. + if (args.provider.id.startsWith("resend-otp") && !isEmailSignInOpen()) { + throw new Error( + `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON}. Sign in with GitHub using the same email address.`, + ); + } + // Check if user exists with the same email const email = args.profile.email; const profileName = diff --git a/packages/kit/convex/authWindow.test.ts b/packages/kit/convex/authWindow.test.ts new file mode 100644 index 000000000..2bd1274ae --- /dev/null +++ b/packages/kit/convex/authWindow.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + EMAIL_SIGN_IN_CLOSES_AT, + EMAIL_SIGN_IN_CLOSES_ON, + isEmailSignInOpen, +} from "./authWindow"; + +describe("email sign-in grace period", () => { + it("names the same day the timestamp encodes", () => { + expect(new Date(EMAIL_SIGN_IN_CLOSES_AT).toISOString()).toContain( + EMAIL_SIGN_IN_CLOSES_ON, + ); + }); + + it("stays open through the whole closing day in UTC", () => { + expect(isEmailSignInOpen(Date.UTC(2026, 8, 30, 0, 0, 0, 0))).toBe(true); + expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT)).toBe(true); + }); + + it("closes at the first moment of the next day", () => { + expect(isEmailSignInOpen(EMAIL_SIGN_IN_CLOSES_AT + 1)).toBe(false); + expect(isEmailSignInOpen(Date.UTC(2026, 9, 1, 0, 0, 0, 0))).toBe(false); + }); +}); diff --git a/packages/kit/convex/authWindow.ts b/packages/kit/convex/authWindow.ts new file mode 100644 index 000000000..e399671fb --- /dev/null +++ b/packages/kit/convex/authWindow.ts @@ -0,0 +1,15 @@ +// Sunset for the Resend OTP provider. New signups have been GitHub-only since +// 2026-04; this is the grace period in which the remaining email-only accounts +// can still sign in and get merged onto GitHub by matching email. +// +// Not a Convex function module β€” plain constants shared by auth.ts and +// users/query.ts so the cutoff is written down once. + +export const EMAIL_SIGN_IN_CLOSES_ON = "2026-09-30"; + +// Inclusive of the whole closing day, in UTC. +export const EMAIL_SIGN_IN_CLOSES_AT = Date.UTC(2026, 8, 30, 23, 59, 59, 999); + +export function isEmailSignInOpen(now: number = Date.now()): boolean { + return now <= EMAIL_SIGN_IN_CLOSES_AT; +} diff --git a/packages/kit/convex/users/query.ts b/packages/kit/convex/users/query.ts index 0009c0dfd..535fcd817 100644 --- a/packages/kit/convex/users/query.ts +++ b/packages/kit/convex/users/query.ts @@ -1,5 +1,6 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; +import { isEmailSignInOpen } from "../authWindow"; /** * Pre-sign-in gate: returns true if a user with the given email @@ -20,6 +21,7 @@ export const canSignInWithEmail = query({ handler: async (ctx, args) => { const normalized = args.email.trim().toLowerCase(); if (normalized.length === 0) return false; + if (!isEmailSignInOpen()) return false; const user = await ctx.db .query("users") .withIndex("email", (q) => q.eq("email", normalized)) diff --git a/packages/kit/src/components/AuthModal/index.tsx b/packages/kit/src/components/AuthModal/index.tsx index 3e8e9f469..b4264a1d3 100644 --- a/packages/kit/src/components/AuthModal/index.tsx +++ b/packages/kit/src/components/AuthModal/index.tsx @@ -6,6 +6,7 @@ import { SiGithub } from "@icons-pack/react-simple-icons"; import { useNavigate } from "react-router-dom"; import { api } from "@/convex"; import { Modal } from "@/components/Modal"; +import { EMAIL_SIGN_IN_CLOSES_ON, isEmailSignInOpen } from "@/utils/constants"; interface AuthModalProps { isOpen: boolean; @@ -68,6 +69,8 @@ export function AuthModal({ isOpen, onClose }: AuthModalProps) { onClose(); }, [isAuthenticating, handleReset, onClose]); + const emailSignInOpen = isEmailSignInOpen(); + const getOtpProvider = () => "resend-otp-en"; const handleSendOtp = async (e: React.FormEvent) => { @@ -229,31 +232,39 @@ export function AuthModal({ isOpen, onClose }: AuthModalProps) { {"You'll be redirected to GitHub to authorize IAPKit"}

    - {/* Divider + email-legacy escape hatch. Kept low-key so new - users gravitate toward GitHub, while the ~110 existing - email-only accounts still have an obvious path in. */} -
    -
    -
    -
    -
    - - {"or"} - -
    -
    + {/* Divider + email-legacy escape hatch, shown only while the + grace period is open. After it closes the server rejects + resend-otp outright, so offering the link would dead-end. */} + {emailSignInOpen && ( + <> +
    +
    +
    +
    +
    + + {"or"} + +
    +
    - + + +

    + {`Email sign-in ends ${EMAIL_SIGN_IN_CLOSES_ON}. After that IAPKit supports GitHub sign-in only β€” sign in with GitHub using the same email address and your account carries over.`} +

    + + )}
    )} diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 5fb9309a8..937d9ecd9 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -23,11 +23,14 @@ export default function QuickstartPage() { > kit.openiap.dev - . New accounts are created through GitHub; email one-time codes work - only for accounts created before April 2026. The onboarding flow asks - you to name your first organization before opening its dashboard. The - hosted service is free under fair-use safeguards on shared community - infrastructure; no plan or credit card is required. + . New accounts are created through GitHub. Accounts created before April + 2026 can still sign in with an email one-time code until{" "} + 2026-09-30; after that IAPKit supports GitHub sign-in + only. Signing in with GitHub using the same email address carries an + existing account over. The onboarding flow asks you to name your first + organization before opening its dashboard. The hosted service is free + under fair-use safeguards on shared community infrastructure; no plan or + credit card is required.

    2. Create a project

    diff --git a/packages/kit/src/utils/constants.test.ts b/packages/kit/src/utils/constants.test.ts new file mode 100644 index 000000000..cea9d8cca --- /dev/null +++ b/packages/kit/src/utils/constants.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + EMAIL_SIGN_IN_CLOSES_AT, + EMAIL_SIGN_IN_CLOSES_ON as SERVER_CLOSES_ON, + isEmailSignInOpen as serverIsOpen, +} from "../../convex/authWindow"; +import { + EMAIL_SIGN_IN_CLOSES_ON as CLIENT_CLOSES_ON, + isEmailSignInOpen as clientIsOpen, +} from "./constants"; + +describe("email sign-in cutoff mirror", () => { + it("agrees with the server on the date", () => { + expect(CLIENT_CLOSES_ON).toBe(SERVER_CLOSES_ON); + }); + + it("agrees with the server on every boundary", () => { + for (const t of [ + Date.UTC(2026, 8, 29), + Date.UTC(2026, 8, 30), + EMAIL_SIGN_IN_CLOSES_AT, + EMAIL_SIGN_IN_CLOSES_AT + 1, + Date.UTC(2026, 9, 1), + ]) { + expect(clientIsOpen(t)).toBe(serverIsOpen(t)); + } + }); +}); diff --git a/packages/kit/src/utils/constants.ts b/packages/kit/src/utils/constants.ts index 49c461f87..eaab5cb51 100644 --- a/packages/kit/src/utils/constants.ts +++ b/packages/kit/src/utils/constants.ts @@ -1 +1,10 @@ export const SUPPORT_EMAIL = "hyo@hyo.dev"; + +// Mirrors convex/authWindow.ts, which is what actually enforces the cutoff. +// Duplicated deliberately: the sign-in modal must render without depending on +// a freshly deployed Convex function. constants.test.ts asserts they agree. +export const EMAIL_SIGN_IN_CLOSES_ON = "2026-09-30"; + +export function isEmailSignInOpen(now: number = Date.now()): boolean { + return now <= Date.UTC(2026, 8, 30, 23, 59, 59, 999); +} From def1bbe52b6e427eab94db33f5757e5be3106d63 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 00:42:31 +0900 Subject: [PATCH 04/21] docs(agents): guard production data and keep agent surfaces in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate rots, both found by trying to use the notes rather than reading them. PRODUCTION IS READ-ONLY Nothing in the repository said so. Opening the Convex dashboard's function runner against production preselects the last function used, which was `drainAccountDeletionBatch` β€” a destructive mutation, one click from running on real customer data. The rule now lives in AGENTS.md, in packages/kit's CONVENTION.md, and in the audit-iapkit workflow: never run a mutation or action against production from anywhere, never hand-edit production documents, keep reads to aggregates rather than copying customer emails, and use dev for anything that needs a new function. AGENT SURFACES DRIFTED Codex reaches the slash-command workflows only through the `openiap-workflows` router, so a command missing from it is invisible to Codex and Grok no matter that Claude lists it automatically. Found by adding audit-iapkit and noticing it reached exactly one of the three agents: - audit-iapkit was in neither router. - audit-security was missing from the Claude adapter β€” pre-existing. - Five skills that ship for both agents (generate-doc, add-showcase-app, opencollective-steward, iapkit-e2e-petgu, iapkit-e2e-martie) were absent from the AGENTS.md table, which is what Codex and Grok actually read. `scripts/audit-agent-surfaces.mjs` now derives every surface from disk and fails when they disagree: a command no router mentions, a skill that exists for one agent only, a Claude adapter that stops pointing at its canonical `.codex/skills` body, a command or skill missing from the AGENTS.md table, or a CLAUDE.md / GEMINI.md that stops resolving to AGENTS.md. Wired into `audit:agents`, ci.yml, a path-scoped pre-commit block, and verify-all. Co-Authored-By: Claude Opus 5 --- .claude/commands/audit-iapkit.md | 4 + .claude/commands/verify-all.md | 2 + .claude/skills/openiap-workflows/SKILL.md | 4 +- .codex/skills/openiap-workflows/SKILL.md | 4 +- .github/workflows/ci.yml | 3 + .husky/pre-commit | 7 + AGENTS.md | 26 ++++ package.json | 1 + packages/kit/CONVENTION.md | 14 ++ scripts/audit-agent-surfaces.mjs | 158 ++++++++++++++++++++++ scripts/audit-agent-surfaces.test.mjs | 142 +++++++++++++++++++ 11 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 scripts/audit-agent-surfaces.mjs create mode 100644 scripts/audit-agent-surfaces.test.mjs diff --git a/.claude/commands/audit-iapkit.md b/.claude/commands/audit-iapkit.md index a75a74606..4c3aec814 100644 --- a/.claude/commands/audit-iapkit.md +++ b/.claude/commands/audit-iapkit.md @@ -96,6 +96,10 @@ pages that have published URLs. Constraints that override any finding: +- **Production is read-only.** Never run a mutation or action against the + production Convex deployment, from the dashboard runner or anywhere else, and + never hand-edit production documents. Reads are fine when the user asks; + report aggregates, not customer emails. Full rule in the root `AGENTS.md`. - **Webhook direction.** The only supported direction is store β†’ IAPKit. Never document an IAPKit β†’ SDK/mobile webhook, SSE, WebSocket, push relay, or long-poll feed. See the root `AGENTS.md`. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index b21794bb9..5a5034775 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -23,6 +23,7 @@ node --test scripts/release-branch-policy.test.mjs # Native build / Swift CodeQL path filters. bun run audit:ci-paths +bun run audit:agents ``` This fails if a new non-Godot library, Expo example route/product ID, generated @@ -322,6 +323,7 @@ set -euo pipefail bun run audit:parity bun run audit:release-state bun run audit:ci-paths +bun run audit:agents bun test \ --path-ignore-patterns='**/build/**' \ --path-ignore-patterns='**/.build/**' \ diff --git a/.claude/skills/openiap-workflows/SKILL.md b/.claude/skills/openiap-workflows/SKILL.md index 532d57266..2e544ad7e 100644 --- a/.claude/skills/openiap-workflows/SKILL.md +++ b/.claude/skills/openiap-workflows/SKILL.md @@ -1,6 +1,6 @@ --- name: openiap-workflows -description: Use for OpenIAP monorepo work that should follow the repository's slash-command workflows when the user asks in natural language instead of typing a slash command, including review-pr, audit-code, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. +description: Use for OpenIAP monorepo work that should follow the repository's slash-command workflows when the user asks in natural language instead of typing a slash command, including review-pr, audit-code, audit-security, audit-iapkit, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. --- # OpenIAP Workflows (Claude Code) @@ -20,6 +20,8 @@ reading the command file (or invoke the slash command directly when available): bots, and remove temporary CodeRabbit trigger and terminal skip/unavailable top-level comments when the loop is clean - Audit code against knowledge rules β†’ `.claude/commands/audit-code.md` (`/audit-code`) +- Audit supply-chain security / SBOM β†’ `.claude/commands/audit-security.md` (`/audit-security`) +- Reconcile IAPKit with OpenIAP β†’ `.claude/commands/audit-iapkit.md` (`/audit-iapkit`) - Compile knowledge / rebuild AI context β†’ `.claude/commands/compile-knowledge.md` (`/compile-knowledge`) - Resolve a GitHub issue β†’ `.claude/commands/resolve-issue.md` (`/resolve-issue`) - Verify all / monorepo health check β†’ `.claude/commands/verify-all.md` (`/verify-all`) diff --git a/.codex/skills/openiap-workflows/SKILL.md b/.codex/skills/openiap-workflows/SKILL.md index 5f07bdfdb..66f75e7b1 100644 --- a/.codex/skills/openiap-workflows/SKILL.md +++ b/.codex/skills/openiap-workflows/SKILL.md @@ -1,6 +1,6 @@ --- name: openiap-workflows -description: Use for OpenIAP monorepo work that should follow the repository's shared agent workflows, including review-pr, audit-code, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. +description: Use for OpenIAP monorepo work that should follow the repository's shared agent workflows, including review-pr, audit-code, audit-security, audit-iapkit, compile-knowledge, verify-all, e2e-tests, stable or prerelease package releases, resolve-issue, commit/push/PR, generated type sync, package-specific checks, GitHub review threads, and project conventions from AGENTS.md. --- # OpenIAP Workflows @@ -40,6 +40,8 @@ natural-language requests, execute the matching workflow: `.claude/commands/audit-code.md`. - Audit SBOM quality, release provenance, workflow permissions, or supply-chain/security posture: read `.claude/commands/audit-security.md`. +- Reconcile the IAPKit site with OpenIAP, audit kit docs, or check whether + IAPKit reflects a spec/store update: read `.claude/commands/audit-iapkit.md`. - Compile knowledge or rebuild AI context: read `.claude/commands/compile-knowledge.md`. - Resolve a GitHub issue: read `.claude/commands/resolve-issue.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a2512d55..d4f5e7ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,6 +299,9 @@ jobs: - name: Audit CI path filters run: npm run audit:ci-paths + - name: Audit agent surfaces + run: npm run audit:agents + test-gql: name: Test GQL Types runs-on: ubuntu-latest diff --git a/.husky/pre-commit b/.husky/pre-commit index f2c5df647..9da6bb07a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -61,6 +61,13 @@ echo "πŸ”Ž IAPKit spec contract audit β€” running CI mirror…" node --test scripts/audit-kit-spec-contract.test.mjs node scripts/audit-kit-spec-contract.mjs +# Codex, Claude, and Grok must stay pointed at the same workflows. +if git diff --cached --name-only --diff-filter=ACMRD \ + | grep -qE '^(\.claude/|\.codex/|AGENTS\.md$|scripts/audit-agent-surfaces(\.test)?\.mjs$)'; then + echo "🀝 agent surface audit…" + bun run audit:agents +fi + # Path filters gate native builds; a filter edit must not silently skip them. if git diff --cached --name-only --diff-filter=ACMR \ | grep -qE '^(\.github/workflows/|scripts/audit-ci-path-filters(\.test)?\.mjs$)'; then diff --git a/AGENTS.md b/AGENTS.md index c6356a7f4..7aef50bf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,27 @@ including its stricter release-note limits. - **Android functions in packages/google**: NO `Android` suffix (it's Android-only) - **Cross-platform functions**: NO suffix +### Production Data Guardrail + +- **Never run a mutation, action, or any write against a production + deployment.** This includes the Convex dashboard function runner, the Convex + CLI (`npx convex run --prod`), and any HTTP call to a production endpoint that + is not a plain read. The dashboard's runner preselects whatever function it + used last β€” which has included destructive mutations such as + `drainAccountDeletionBatch` β€” so confirm the selected function before you + press Run, and prefer not opening the runner at all. +- Reading production is allowed when the user asks for it: table views, row + counts, logs, and health. Report aggregates and never copy customer emails or + other personal data into a transcript, commit, or issue. +- Never edit, insert, or delete a document from the production data browser. + Schema and data changes ship through a reviewed deploy, not by hand. +- The **dev** deployment is the place to try things. If a check genuinely needs + a query that is not deployed, add it to `packages/kit/convex`, exercise it on + dev, and let it reach production through the normal deploy. +- If you are unsure which deployment is selected, stop and confirm. The Convex + dashboard shows it next to the project name, for example + `Production β€’ healthy-kudu-836`. + ### Webhook Direction Guardrail - The only supported webhook direction is **store β†’ IAPKit**: Apple App Store @@ -300,6 +321,11 @@ Cursor-specific files. | `$review-self` | Review and improve current work until stable | `$review-self` or `$review-self ` | | `$loop-review` | Start from current main, review, PR, and merge | `$loop-review` | | `$rebase-main` | Pull main and safely rebase the current branch | `$rebase-main` | +| `$generate-doc` | Write OpenIAP docs and pre-release release notes | `$generate-doc` | +| `$add-showcase-app` | Add apps to the "Who uses OpenIAP?" showcase | `$add-showcase-app` | +| `$opencollective-steward` | Manage OpenCollective profile and updates | `$opencollective-steward` | +| `$iapkit-e2e-petgu` | IAPKit product-sync E2E with the Petgu app | `$iapkit-e2e-petgu` | +| `$iapkit-e2e-martie` | IAPKit local receipt-validation E2E with Martie | `$iapkit-e2e-martie` | | `/review-pr` | Review PR comments, fix issues, resolve threads | `/review-pr 65` or `/review-pr ` | | `/audit-code` | Audit code against knowledge rules and latest APIs | `/audit-code` | | `/audit-security` | Audit SBOM, provenance, and supply-chain posture | `/audit-security` | diff --git a/package.json b/package.json index c48048c2e..7c0400a17 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "audit:deprecations": "node --test scripts/audit-deprecation-schedule.test.mjs && node scripts/audit-deprecation-schedule.mjs", "audit:layout": "node --test scripts/audit-repo-layout.test.mjs && node scripts/audit-repo-layout.mjs", "audit:ci-paths": "node --test scripts/audit-ci-path-filters.test.mjs && node scripts/audit-ci-path-filters.mjs", + "audit:agents": "node --test scripts/audit-agent-surfaces.test.mjs && node scripts/audit-agent-surfaces.mjs", "audit:parity": "node scripts/audit-non-godot-parity.mjs", "audit:kit-contract": "node --test scripts/audit-kit-spec-contract.test.mjs && node scripts/audit-kit-spec-contract.mjs", "audit:docs": "bun run scripts/audit-docs.ts", diff --git a/packages/kit/CONVENTION.md b/packages/kit/CONVENTION.md index 3eb7e5ebd..e3f345bce 100644 --- a/packages/kit/CONVENTION.md +++ b/packages/kit/CONVENTION.md @@ -10,6 +10,20 @@ Convex schema as the source of truth for purchase-validation models. For setup, operations, and deploy details, see [`README.md`](./README.md). +## Production Is Read-Only For Agents + +`healthy-kudu-836` is the production deployment and holds real customer data. +Never run a mutation or action against it β€” not from the Convex dashboard +function runner, not from `npx convex run --prod`, not from anywhere. The +dashboard runner reopens with the last function selected, which has included +`drainAccountDeletionBatch`; check what is selected before running anything. + +Reads are fine when asked for. Report counts and aggregates rather than copying +customer emails or other personal data anywhere. Use the dev deployment for +anything that needs a new function. + +See the root `AGENTS.md` for the full guardrail. + ## Naming - **Brand name in user-facing text/titles**: `IAPKit` (no space). diff --git a/scripts/audit-agent-surfaces.mjs b/scripts/audit-agent-surfaces.mjs new file mode 100644 index 000000000..d21cbb2e3 --- /dev/null +++ b/scripts/audit-agent-surfaces.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +// Keeps Codex, Claude, and Grok pointed at the same workflows. Every surface is +// discovered from disk, so adding a command or skill without registering it +// everywhere fails here instead of silently working for one agent only. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = path.resolve(path.dirname(scriptPath), ".."); + +const COMMANDS_DIR = ".claude/commands"; +const CODEX_SKILLS_DIR = ".codex/skills"; +const CLAUDE_SKILLS_DIR = ".claude/skills"; +const CODEX_ROUTER = ".codex/skills/openiap-workflows/SKILL.md"; +const CLAUDE_ROUTER = ".claude/skills/openiap-workflows/SKILL.md"; +const INSTRUCTIONS = "AGENTS.md"; + +// Grok and Codex read AGENTS.md directly; these must resolve to it. +export const instructionSymlinks = Object.freeze(["CLAUDE.md", "GEMINI.md"]); + +function read(root, relative) { + return fs.readFileSync(path.join(root, relative), "utf8"); +} + +function listDirectories(root, relative) { + const dir = path.join(root, relative); + + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +export function listCommands(root = repositoryRoot) { + const dir = path.join(root, COMMANDS_DIR); + + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir) + .filter((name) => name.endsWith(".md")) + .map((name) => name.replace(/\.md$/u, "")) + .sort(); +} + +export function listSkills(root = repositoryRoot) { + return { + codex: listDirectories(root, CODEX_SKILLS_DIR), + claude: listDirectories(root, CLAUDE_SKILLS_DIR), + }; +} + +export function auditAgentSurfaces(root = repositoryRoot) { + const findings = []; + const commands = listCommands(root); + const { codex, claude } = listSkills(root); + + for (const name of codex) { + if (!claude.includes(name)) { + findings.push( + `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md is missing; Claude cannot use the ${name} skill`, + ); + } + } + + for (const name of claude) { + if (!codex.includes(name)) { + findings.push( + `${CODEX_SKILLS_DIR}/${name}/SKILL.md is missing; Codex cannot use the ${name} skill`, + ); + } + } + + for (const name of codex.filter((entry) => claude.includes(entry))) { + const adapter = read(root, `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md`); + + if (!adapter.includes(`${CODEX_SKILLS_DIR}/${name}/SKILL.md`)) { + findings.push( + `${CLAUDE_SKILLS_DIR}/${name}/SKILL.md must point at its canonical ${CODEX_SKILLS_DIR} body`, + ); + } + } + + // A command only reaches Codex through the router, so an unrouted command is + // invisible to every agent that does not read .claude/commands directly. + const routers = [ + [CODEX_ROUTER, read(root, CODEX_ROUTER)], + [CLAUDE_ROUTER, read(root, CLAUDE_ROUTER)], + ]; + + for (const [file, text] of routers) { + for (const command of commands) { + if (!text.includes(`${COMMANDS_DIR}/${command}.md`)) { + findings.push(`${file} does not route ${command}`); + } + } + } + + const instructions = read(root, INSTRUCTIONS); + + for (const command of commands) { + if (!instructions.includes(`\`/${command}\``)) { + findings.push(`${INSTRUCTIONS} skills table is missing /${command}`); + } + } + + for (const skill of codex) { + if (skill === "openiap-workflows") { + continue; + } + + if (!instructions.includes(`\`$${skill}\``)) { + findings.push(`${INSTRUCTIONS} skills table is missing $${skill}`); + } + } + + for (const link of instructionSymlinks) { + const target = path.join(root, link); + + if (!fs.existsSync(target)) { + findings.push(`${link} is missing; it must symlink to ${INSTRUCTIONS}`); + continue; + } + + if ( + !fs.lstatSync(target).isSymbolicLink() || + fs.readlinkSync(target) !== INSTRUCTIONS + ) { + findings.push(`${link} must be a symlink to ${INSTRUCTIONS}`); + } + } + + return findings.sort(); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const errors = auditAgentSurfaces(); + + if (errors.length === 0) { + console.log("Agent surface audit: clean."); + } else { + console.error("Agent surface audit failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } +} diff --git a/scripts/audit-agent-surfaces.test.mjs b/scripts/audit-agent-surfaces.test.mjs new file mode 100644 index 000000000..a6dea01e1 --- /dev/null +++ b/scripts/audit-agent-surfaces.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + auditAgentSurfaces, + instructionSymlinks, + listCommands, + listSkills, +} from "./audit-agent-surfaces.mjs"; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const MIRRORED = [ + ".claude/commands", + ".claude/skills", + ".codex/skills", + "AGENTS.md", +]; + +function withMirroredRepository(mutate, run) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openiap-agents-")); + + try { + for (const entry of MIRRORED) { + const source = path.join(repositoryRoot, entry); + const target = path.join(root, entry); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.cpSync(source, target, { recursive: true }); + } + + for (const link of instructionSymlinks) { + fs.symlinkSync("AGENTS.md", path.join(root, link)); + } + + mutate(root); + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("the repository's agent surfaces agree", () => { + assert.deepEqual(auditAgentSurfaces(), []); +}); + +test("every command is discovered and every skill exists for both agents", () => { + const commands = listCommands(); + const { codex, claude } = listSkills(); + + assert.ok(commands.includes("audit-iapkit")); + assert.deepEqual(codex, claude); +}); + +test("rejects a command no router mentions", () => { + withMirroredRepository( + (root) => { + fs.writeFileSync( + path.join(root, ".claude/commands/audit-orphan.md"), + "---\nname: audit-orphan\n---\n", + ); + }, + (root) => { + const findings = auditAgentSurfaces(root); + assert.ok( + findings.some((f) => + f.includes(".codex/skills/openiap-workflows/SKILL.md does not route audit-orphan"), + ), + ); + assert.ok( + findings.some((f) => + f.includes(".claude/skills/openiap-workflows/SKILL.md does not route audit-orphan"), + ), + ); + assert.ok( + findings.some((f) => f.includes("AGENTS.md skills table is missing /audit-orphan")), + ); + }, + ); +}); + +test("rejects a skill that exists for only one agent", () => { + withMirroredRepository( + (root) => { + fs.rmSync(path.join(root, ".claude/skills/rebase-main"), { + recursive: true, + force: true, + }); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some((f) => + f.includes(".claude/skills/rebase-main/SKILL.md is missing"), + ), + ); + }, + ); +}); + +test("rejects a Claude adapter that drops its canonical pointer", () => { + withMirroredRepository( + (root) => { + const file = path.join(root, ".claude/skills/rebase-main/SKILL.md"); + fs.writeFileSync( + file, + fs + .readFileSync(file, "utf8") + .replaceAll(".codex/skills/rebase-main/SKILL.md", "somewhere else"), + ); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some((f) => + f.includes("must point at its canonical .codex/skills body"), + ), + ); + }, + ); +}); + +test("rejects instruction files that stop resolving to AGENTS.md", () => { + withMirroredRepository( + (root) => { + const link = path.join(root, "CLAUDE.md"); + fs.unlinkSync(link); + fs.writeFileSync(link, "# not a symlink\n"); + }, + (root) => { + assert.ok( + auditAgentSurfaces(root).some( + (f) => f === "CLAUDE.md must be a symlink to AGENTS.md", + ), + ); + }, + ); +}); From 11d60e540cbc7c82c2ca9228bbdf32d5f85ee167 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 01:00:14 +0900 Subject: [PATCH 05/21] docs: correct the alternative billing dialog migration target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration table pointed showAlternativeBillingDialogAndroid β€” Google's in-app information dialog β€” at launchExternalLinkAndroid, which navigates the user out of the app and belongs to the external-link programs only. The correct successor is showBillingProgramInformationDialogAndroid, the Billing Programs in-app dialog (Play Billing 8.2+, wired since react-native-iap 15.4.0 via PR #212). The availability and reporting rows also hardcoded 'external-offer', which silently reroutes alternative-billing users into a different Play program; they now say to pass the program the app is actually enrolled in. Reported in #364. Co-Authored-By: Claude Opus 5 --- packages/docs/src/pages/docs/updates/migration.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/docs/src/pages/docs/updates/migration.tsx b/packages/docs/src/pages/docs/updates/migration.tsx index 970e1afc9..9e7edd6eb 100644 --- a/packages/docs/src/pages/docs/updates/migration.tsx +++ b/packages/docs/src/pages/docs/updates/migration.tsx @@ -38,12 +38,15 @@ const migrationGroups = [ ], [ 'checkAlternativeBillingAvailabilityAndroid', - "isBillingProgramAvailableAndroid with the 'external-offer' BillingProgramAndroid value", + 'isBillingProgramAvailableAndroid with the BillingProgramAndroid value your app is enrolled in', + ], + [ + 'showAlternativeBillingDialogAndroid', + 'showBillingProgramInformationDialogAndroid (in-app dialog); launchExternalLinkAndroid is only for external-link programs', ], - ['showAlternativeBillingDialogAndroid', 'launchExternalLinkAndroid'], [ 'createAlternativeBillingTokenAndroid', - "createBillingProgramReportingDetailsAndroid with the 'external-offer' BillingProgramAndroid value", + 'createBillingProgramReportingDetailsAndroid with the BillingProgramAndroid value your app is enrolled in', ], ], }, From 1621426e94db5b8af343cad8a460ea47b51a6d1f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 01:29:37 +0900 Subject: [PATCH 06/21] fix: address CodeRabbit review on PR #363 and hide announcement thumbs on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes, each verified against the implementation: - Email OTP eligibility is now enforced at the boundary, not just in the UI: both `canSignInWithEmail` and the auth callback require an existing resend-otp authAccount, so a GitHub-created account cannot start email OTP during the grace window. Shared lookup in `users/internal.ts`. - The sign-in cutoff has one implementation: `convex/authWindow.ts`. `src/utils/constants.ts` re-exports it (plain constants, no convex imports, bundles fine), replacing the mirrored copy and its drift test. - User-facing cutoff text states UTC; the enforcement always was. - The parity guard now pins the Vega OS version value itself: `VEGA_OS_VERSION = '1.2'` in the interpolating plugin and the literal `target`/`min` pair in the emitted manifests. - Landing describes the full verdict (isValid, state, store-verified productId), not a lone boolean. - Google retry text and the retry helper comment say jitter in 50–100% of the capped delay; the sampler is `[0.5, 1.0)`, which "full jitter" overstated. - Migration row scopes `launchExternalLinkAndroid` to the external-link flows (External Offer, External Content Link, Billing Choice external links) and names `showBillingProgramInformationDialogAndroid` as the in-app dialog. - audit-iapkit states one precedence order: implementation > packages/docs > kit prose. - Pre-commit agent-surface trigger also fires on CLAUDE.md / GEMINI.md. Also, from a mobile report: the 48px announcement header thumbnails wrap onto their own line above the title on narrow screens and read as noise; they are hidden under 640px and unchanged on desktop. Co-Authored-By: Claude Opus 5 --- .claude/commands/audit-iapkit.md | 5 ++-- .husky/pre-commit | 2 +- .../src/pages/docs/updates/announcements.tsx | 4 +++ .../docs/src/pages/docs/updates/migration.tsx | 2 +- packages/docs/src/styles/base.css | 7 +++++ packages/kit/convex/auth.ts | 16 ++++++++++- packages/kit/convex/purchases/retry.ts | 2 +- packages/kit/convex/users/internal.ts | 24 ++++++++++++++++ packages/kit/convex/users/query.ts | 15 +++++++++- .../kit/src/components/AuthModal/index.tsx | 2 +- .../src/pages/docs/sections/quickstart.tsx | 12 ++++---- .../docs/sections/verification-google.tsx | 21 +++++++------- packages/kit/src/pages/landing.tsx | 2 +- packages/kit/src/utils/constants.test.ts | 28 ------------------- packages/kit/src/utils/constants.ts | 13 ++++----- scripts/audit-non-godot-parity.mjs | 18 ++++++++++++ 16 files changed, 112 insertions(+), 61 deletions(-) delete mode 100644 packages/kit/src/utils/constants.test.ts diff --git a/.claude/commands/audit-iapkit.md b/.claude/commands/audit-iapkit.md index 4c3aec814..4839e7708 100644 --- a/.claude/commands/audit-iapkit.md +++ b/.claude/commands/audit-iapkit.md @@ -19,8 +19,9 @@ OpenIAP spec + packages/kit implementation β†’ IAPKit site copy (authoritative) (must follow) ``` -When two surfaces disagree, the implementation wins over any prose, and -`packages/docs` wins over `packages/kit` for shared product claims. Never +Precedence when surfaces disagree: implementation > `packages/docs` > +`packages/kit` prose. `packages/docs` outranks kit prose only where the code +does not decide the question (product positioning, support claims). Never "fix" the code to match a doc without saying so explicitly. ## Workflow diff --git a/.husky/pre-commit b/.husky/pre-commit index 9da6bb07a..6bb8da91c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -63,7 +63,7 @@ node scripts/audit-kit-spec-contract.mjs # Codex, Claude, and Grok must stay pointed at the same workflows. if git diff --cached --name-only --diff-filter=ACMRD \ - | grep -qE '^(\.claude/|\.codex/|AGENTS\.md$|scripts/audit-agent-surfaces(\.test)?\.mjs$)'; then + | grep -qE '^(\.claude/|\.codex/|AGENTS\.md$|CLAUDE\.md$|GEMINI\.md$|scripts/audit-agent-surfaces(\.test)?\.mjs$)'; then echo "🀝 agent surface audit…" bun run audit:agents fi diff --git a/packages/docs/src/pages/docs/updates/announcements.tsx b/packages/docs/src/pages/docs/updates/announcements.tsx index a413672fa..b848aea9d 100644 --- a/packages/docs/src/pages/docs/updates/announcements.tsx +++ b/packages/docs/src/pages/docs/updates/announcements.tsx @@ -256,6 +256,7 @@ function Announcements() { Amazon Fire OS and Vega OS support

    @@ -430,6 +431,7 @@ function Announcements() { maui-iap

    @@ -735,6 +737,7 @@ function Announcements() { godot-iap

    @@ -838,6 +841,7 @@ function Announcements() { IAPKit

    diff --git a/packages/docs/src/pages/docs/updates/migration.tsx b/packages/docs/src/pages/docs/updates/migration.tsx index 9e7edd6eb..d683d76f1 100644 --- a/packages/docs/src/pages/docs/updates/migration.tsx +++ b/packages/docs/src/pages/docs/updates/migration.tsx @@ -42,7 +42,7 @@ const migrationGroups = [ ], [ 'showAlternativeBillingDialogAndroid', - 'showBillingProgramInformationDialogAndroid (in-app dialog); launchExternalLinkAndroid is only for external-link programs', + 'showBillingProgramInformationDialogAndroid (the in-app Billing Programs dialog); launchExternalLinkAndroid covers the external-link flows (External Offer, External Content Link, Billing Choice external links)', ], [ 'createAlternativeBillingTokenAndroid', diff --git a/packages/docs/src/styles/base.css b/packages/docs/src/styles/base.css index d6dc4b6b4..b3af3bdd3 100644 --- a/packages/docs/src/styles/base.css +++ b/packages/docs/src/styles/base.css @@ -88,6 +88,13 @@ img[src='/sponsors/meta.webp'] { height: 2.75rem; } +/* Wrapped onto its own line above the title, the tiny banner reads as noise. */ +@media (max-width: 640px) { + .announcement-thumb { + display: none; + } +} + img[src='/frameworks/apple.svg'] { filter: var(--apple-logo-filter); } diff --git a/packages/kit/convex/auth.ts b/packages/kit/convex/auth.ts index e411cd05d..e064df5af 100644 --- a/packages/kit/convex/auth.ts +++ b/packages/kit/convex/auth.ts @@ -34,7 +34,7 @@ const CustomAuth = convexAuth({ // only to someone whose GitHub email differs from their IAPKit email. if (args.provider.id.startsWith("resend-otp") && !isEmailSignInOpen()) { throw new Error( - `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON}. Sign in with GitHub using the same email address.`, + `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). Sign in with GitHub using the same email address.`, ); } @@ -66,6 +66,20 @@ const CustomAuth = convexAuth({ ); if (existingUser) { + // Email OTP is only for accounts that already used it; the UI gate + // (canSignInWithEmail) mirrors this, but the boundary enforces it. + if (args.provider.id.startsWith("resend-otp")) { + const legacy = await ctx.runQuery( + internal.users.internal.hasLegacyEmailAccount, + { userId: existingUser._id }, + ); + if (!legacy) { + throw new Error( + "This account uses GitHub sign-in. Please continue with GitHub.", + ); + } + } + // User exists - update auth user const userId = existingUser._id; diff --git a/packages/kit/convex/purchases/retry.ts b/packages/kit/convex/purchases/retry.ts index f035828bd..be8da133b 100644 --- a/packages/kit/convex/purchases/retry.ts +++ b/packages/kit/convex/purchases/retry.ts @@ -135,7 +135,7 @@ export async function retryOnTransient( const exponent = attempt - 1; const raw = baseDelayMs * Math.pow(2, exponent); const capped = Math.min(raw, maxDelayMs); - // Full jitter in [0.5, 1.0) of the capped delay β€” smooths retry + // Jitter in [0.5, 1.0) of the capped delay β€” smooths retry // bursts without extending worst-case wait beyond the cap. const jittered = capped * (0.5 + Math.random() * 0.5); await sleep(jittered); diff --git a/packages/kit/convex/users/internal.ts b/packages/kit/convex/users/internal.ts index af25d5c95..eb49edff6 100644 --- a/packages/kit/convex/users/internal.ts +++ b/packages/kit/convex/users/internal.ts @@ -13,6 +13,30 @@ export const findByEmail = internalQuery({ }, }); +// Grace-period gate: email OTP is only for accounts that already used it. +// A GitHub-created account must keep using GitHub even before the cutoff. +export const RESEND_PROVIDER_IDS = [ + "resend-otp-en", + "resend-otp-ko", + "resend-otp-ja", +] as const; + +export const hasLegacyEmailAccount = internalQuery({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + for (const provider of RESEND_PROVIDER_IDS) { + const account = await ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (q) => + q.eq("userId", args.userId).eq("provider", provider), + ) + .first(); + if (account) return true; + } + return false; + }, +}); + // Read budget per cron tick. We walk this many candidate users (oldest // first, capped at the 24h boundary) before yielding to the next tick; // keeps Convex's per-transaction read budget bounded. diff --git a/packages/kit/convex/users/query.ts b/packages/kit/convex/users/query.ts index 535fcd817..afa84a5ea 100644 --- a/packages/kit/convex/users/query.ts +++ b/packages/kit/convex/users/query.ts @@ -1,6 +1,7 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; import { isEmailSignInOpen } from "../authWindow"; +import { RESEND_PROVIDER_IDS } from "./internal"; /** * Pre-sign-in gate: returns true if a user with the given email @@ -26,6 +27,18 @@ export const canSignInWithEmail = query({ .query("users") .withIndex("email", (q) => q.eq("email", normalized)) .first(); - return user !== null; + if (!user) return false; + // OTP stays limited to accounts that already used it; GitHub-created + // accounts keep using GitHub even during the grace period. + for (const provider of RESEND_PROVIDER_IDS) { + const account = await ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (q) => + q.eq("userId", user._id).eq("provider", provider), + ) + .first(); + if (account) return true; + } + return false; }, }); diff --git a/packages/kit/src/components/AuthModal/index.tsx b/packages/kit/src/components/AuthModal/index.tsx index b4264a1d3..57703dd4d 100644 --- a/packages/kit/src/components/AuthModal/index.tsx +++ b/packages/kit/src/components/AuthModal/index.tsx @@ -261,7 +261,7 @@ export function AuthModal({ isOpen, onClose }: AuthModalProps) {

    - {`Email sign-in ends ${EMAIL_SIGN_IN_CLOSES_ON}. After that IAPKit supports GitHub sign-in only β€” sign in with GitHub using the same email address and your account carries over.`} + {`Email sign-in ends ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). After that IAPKit supports GitHub sign-in only β€” sign in with GitHub using the same email address and your account carries over.`}

    )} diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 937d9ecd9..f6fcfb8b0 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -25,12 +25,12 @@ export default function QuickstartPage() { . New accounts are created through GitHub. Accounts created before April 2026 can still sign in with an email one-time code until{" "} - 2026-09-30; after that IAPKit supports GitHub sign-in - only. Signing in with GitHub using the same email address carries an - existing account over. The onboarding flow asks you to name your first - organization before opening its dashboard. The hosted service is free - under fair-use safeguards on shared community infrastructure; no plan or - credit card is required. + 2026-09-30 (UTC); after that IAPKit supports GitHub + sign-in only. Signing in with GitHub using the same email address + carries an existing account over. The onboarding flow asks you to name + your first organization before opening its dashboard. The hosted service + is free under fair-use safeguards on shared community infrastructure; no + plan or credit card is required.

    2. Create a project

    diff --git a/packages/kit/src/pages/docs/sections/verification-google.tsx b/packages/kit/src/pages/docs/sections/verification-google.tsx index 425df0ce1..328a7409f 100644 --- a/packages/kit/src/pages/docs/sections/verification-google.tsx +++ b/packages/kit/src/pages/docs/sections/verification-google.tsx @@ -134,16 +134,17 @@ export default function VerificationGooglePage() {

    Transient retries

    Both v2 calls are wrapped in a 3-attempt exponential-backoff retry - (200ms base, 2s cap, full jitter). The retry fires on HTTP 5xx and Node - network errors (ECONNRESET, ETIMEDOUT,{" "} - EAI_AGAIN, …). A 404 from the product lookup is not an - error β€” it just means the token is a subscription, so IAPKit falls - through to subscriptionsv2. When neither catalog knows the - token, IAPKit retries the whole pair up to 3 times over roughly 750 ms, - because a purchase verified within a second of completing can still be - propagating inside Play. Every other 4xx response, including 410 ("token - no longer valid"), is not retried because re-issuing - the call won't help and would only waste quota. + (200ms base, 2s cap, jitter to 50–100% of the capped delay). The retry + fires on HTTP 5xx and Node network errors (ECONNRESET,{" "} + ETIMEDOUT, EAI_AGAIN, …). A 404 from the + product lookup is not an error β€” it just means the token is a + subscription, so IAPKit falls through to subscriptionsv2. + When neither catalog knows the token, IAPKit retries the whole pair up + to 3 times over roughly 750 ms, because a purchase verified within a + second of completing can still be propagating inside Play. Every other + 4xx response, including 410 ("token no longer valid"), is{" "} + not retried because re-issuing the call won't help and + would only waste quota.

    diff --git a/packages/kit/src/pages/landing.tsx b/packages/kit/src/pages/landing.tsx index 73ea6ba36..b9dc1f2b6 100644 --- a/packages/kit/src/pages/landing.tsx +++ b/packages/kit/src/pages/landing.tsx @@ -136,7 +136,7 @@ export default function LandingPage() {

    { - "We contact each supported store, verify authoritative purchase state, and return a single isValid answer before you deliver the item." + "We contact each supported store, verify authoritative purchase state, and return one normalized verdict β€” isValid, state, and the store-verified productId β€” before you deliver the item." }

    diff --git a/packages/kit/src/utils/constants.test.ts b/packages/kit/src/utils/constants.test.ts deleted file mode 100644 index cea9d8cca..000000000 --- a/packages/kit/src/utils/constants.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - EMAIL_SIGN_IN_CLOSES_AT, - EMAIL_SIGN_IN_CLOSES_ON as SERVER_CLOSES_ON, - isEmailSignInOpen as serverIsOpen, -} from "../../convex/authWindow"; -import { - EMAIL_SIGN_IN_CLOSES_ON as CLIENT_CLOSES_ON, - isEmailSignInOpen as clientIsOpen, -} from "./constants"; - -describe("email sign-in cutoff mirror", () => { - it("agrees with the server on the date", () => { - expect(CLIENT_CLOSES_ON).toBe(SERVER_CLOSES_ON); - }); - - it("agrees with the server on every boundary", () => { - for (const t of [ - Date.UTC(2026, 8, 29), - Date.UTC(2026, 8, 30), - EMAIL_SIGN_IN_CLOSES_AT, - EMAIL_SIGN_IN_CLOSES_AT + 1, - Date.UTC(2026, 9, 1), - ]) { - expect(clientIsOpen(t)).toBe(serverIsOpen(t)); - } - }); -}); diff --git a/packages/kit/src/utils/constants.ts b/packages/kit/src/utils/constants.ts index eaab5cb51..712b238ed 100644 --- a/packages/kit/src/utils/constants.ts +++ b/packages/kit/src/utils/constants.ts @@ -1,10 +1,7 @@ export const SUPPORT_EMAIL = "hyo@hyo.dev"; -// Mirrors convex/authWindow.ts, which is what actually enforces the cutoff. -// Duplicated deliberately: the sign-in modal must render without depending on -// a freshly deployed Convex function. constants.test.ts asserts they agree. -export const EMAIL_SIGN_IN_CLOSES_ON = "2026-09-30"; - -export function isEmailSignInOpen(now: number = Date.now()): boolean { - return now <= Date.UTC(2026, 8, 30, 23, 59, 59, 999); -} +// The cutoff lives with its enforcement; the modal just renders it. +export { + EMAIL_SIGN_IN_CLOSES_ON, + isEmailSignInOpen, +} from "../../convex/authWindow"; diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 8bc017055..2be29f55c 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -7477,6 +7477,24 @@ function checkFrameworkDependencyHygiene() { "Vega manifests must declare the OS module and version required since Vega SDK 0.24", ); } + // withVega.ts interpolates the version, so pin the constant there and the + // literal target/min pair in the emitted manifests. + expectIncludes( + "libraries/expo-iap/plugin/src/withVega.ts", + ["const VEGA_OS_VERSION = '1.2'"], + "Expo Vega plugin must pin the Vega OS version constant", + ); + for (const literalVegaManifest of [ + "libraries/expo-iap/example/scripts/vega-build-config.mjs", + "libraries/react-native-iap/example/manifest.toml", + "packages/docs/src/pages/docs/setup/store/amazon.tsx", + ]) { + expectIncludes( + literalVegaManifest, + ['target = "1.2"', 'min = "1.2"'], + "Vega manifests must pin the OS target and minimum version", + ); + } expectOptionalIncludes( "libraries/expo-iap/example/android/settings.gradle", [ From cc392767e9ab2f43631f286a72fcdb4a135bd0d6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 01:44:44 +0900 Subject: [PATCH 07/21] fix(kit): bind the Apple verify response to the requesting JWS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #365. The device JWS is decode-only, so its claims are attacker-writable; the authoritative record is the transaction Apple signs in its response. The verifier already pins bundleId, environment, and appAppleId on that response, but nothing tied the response back to the request. A tampered payload could therefore select a different transactionId of the same app and receive that transaction's verdict. `assertVerifiedTransactionBinding` now rejects any drift between the verified transaction and the request on transactionId, the project bundleId, and the requested environment, before anything is persisted. Also extracts the resend-account eligibility loop into a pure helper shared by `canSignInWithEmail` and the auth callback's boundary check, with unit tests for both helpers β€” which is what the codecov patch gate on iapkit-convex was failing for. Co-Authored-By: Claude Opus 5 --- packages/kit/convex/purchases/ios.test.ts | 57 +++++++++++++++++++++- packages/kit/convex/purchases/ios.ts | 42 ++++++++++++++++ packages/kit/convex/users/internal.test.ts | 27 ++++++++++ packages/kit/convex/users/internal.ts | 22 ++++++--- packages/kit/convex/users/query.ts | 12 ++--- 5 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 packages/kit/convex/users/internal.test.ts diff --git a/packages/kit/convex/purchases/ios.test.ts b/packages/kit/convex/purchases/ios.test.ts index d6e61891b..6539bc327 100644 --- a/packages/kit/convex/purchases/ios.test.ts +++ b/packages/kit/convex/purchases/ios.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { recordAppStoreVerifiedSubscription } from "./ios"; +import { + assertVerifiedTransactionBinding, + recordAppStoreVerifiedSubscription, +} from "./ios"; import { applyExpectedProductId, AppStoreProductType, @@ -252,3 +255,55 @@ describe("recordAppStoreVerifiedSubscription", () => { expect(calls).toHaveLength(0); }); }); + +describe("assertVerifiedTransactionBinding", () => { + const verified = { + transactionId: "2000001177054625", + bundleId: "dev.hyo.martie", + environment: "Production" as const, + }; + + it("accepts a response matching the request on all three fields", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Production", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).not.toThrow(); + }); + + it("rejects a transaction id drift", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "9999999999999999", + requestedEnvironment: "Production", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).toThrow(/transactionId/); + }); + + it("rejects a bundle id that is not the project's", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Production", + expectedBundleId: "dev.other.app", + verified, + }), + ).toThrow(/bundleId/); + }); + + it("rejects an environment drift", () => { + expect(() => + assertVerifiedTransactionBinding({ + requestedTransactionId: "2000001177054625", + requestedEnvironment: "Sandbox", + expectedBundleId: "dev.hyo.martie", + verified, + }), + ).toThrow(/environment/); + }); +}); diff --git a/packages/kit/convex/purchases/ios.ts b/packages/kit/convex/purchases/ios.ts index 4b8150b32..c37a96ca1 100644 --- a/packages/kit/convex/purchases/ios.ts +++ b/packages/kit/convex/purchases/ios.ts @@ -131,6 +131,13 @@ export const verifyAppStoreReceiptInternalV1 = action({ throw error; } + assertVerifiedTransactionBinding({ + requestedTransactionId: decodedPayload.transactionId, + requestedEnvironment: environment, + expectedBundleId: project.iosBundleId, + verified: transactionData, + }); + const remoteId = transactionData.originalTransactionId || transactionData.transactionId || @@ -345,6 +352,41 @@ export async function getAppStoreServerCredentials( }; } +// The device JWS is decode-only (its claims are attacker-writable); Apple's +// response is what SignedDataVerifier proves. Reject any drift between the +// two so a tampered payload cannot select another transaction's verdict. +export function assertVerifiedTransactionBinding(params: { + requestedTransactionId: unknown; + requestedEnvironment: string; + expectedBundleId: string; + verified: Pick< + AppStoreReceiptData, + "transactionId" | "bundleId" | "environment" + >; +}): void { + const { requestedTransactionId, requestedEnvironment, expectedBundleId } = + params; + const verified = params.verified; + + if (verified.transactionId !== requestedTransactionId) { + throw new AppStoreTransactionVerificationFailedError( + `verified transactionId ${String(verified.transactionId)} does not match the requested ${String(requestedTransactionId)}`, + ); + } + + if (verified.bundleId !== expectedBundleId) { + throw new AppStoreTransactionVerificationFailedError( + `verified bundleId ${String(verified.bundleId)} does not match the project's ${expectedBundleId}`, + ); + } + + if (verified.environment !== requestedEnvironment) { + throw new AppStoreTransactionVerificationFailedError( + `verified environment ${String(verified.environment)} does not match the requested ${requestedEnvironment}`, + ); + } +} + async function verifyTransactionWithServerApi(params: { ctx: ActionCtx; decodedJwsPayload: JWSTransactionDecodedPayload; diff --git a/packages/kit/convex/users/internal.test.ts b/packages/kit/convex/users/internal.test.ts new file mode 100644 index 000000000..c21679665 --- /dev/null +++ b/packages/kit/convex/users/internal.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { RESEND_PROVIDER_IDS, hasAnyResendAccount } from "./internal"; + +describe("hasAnyResendAccount", () => { + it("finds an account on the first provider", async () => { + await expect( + hasAnyResendAccount(async (provider) => + provider === "resend-otp-en" ? { _id: "a" } : null, + ), + ).resolves.toBe(true); + }); + + it("keeps looking past earlier providers", async () => { + const asked: string[] = []; + await expect( + hasAnyResendAccount(async (provider) => { + asked.push(provider); + return provider === "resend-otp-ja" ? { _id: "a" } : null; + }), + ).resolves.toBe(true); + expect(asked).toEqual([...RESEND_PROVIDER_IDS]); + }); + + it("returns false when no provider has an account", async () => { + await expect(hasAnyResendAccount(async () => null)).resolves.toBe(false); + }); +}); diff --git a/packages/kit/convex/users/internal.ts b/packages/kit/convex/users/internal.ts index eb49edff6..df58a2de3 100644 --- a/packages/kit/convex/users/internal.ts +++ b/packages/kit/convex/users/internal.ts @@ -21,20 +21,26 @@ export const RESEND_PROVIDER_IDS = [ "resend-otp-ja", ] as const; +export async function hasAnyResendAccount( + findAccount: (provider: string) => Promise, +): Promise { + for (const provider of RESEND_PROVIDER_IDS) { + if ((await findAccount(provider)) !== null) return true; + } + return false; +} + export const hasLegacyEmailAccount = internalQuery({ args: { userId: v.id("users") }, - handler: async (ctx, args) => { - for (const provider of RESEND_PROVIDER_IDS) { - const account = await ctx.db + handler: async (ctx, args) => + hasAnyResendAccount((provider) => + ctx.db .query("authAccounts") .withIndex("userIdAndProvider", (q) => q.eq("userId", args.userId).eq("provider", provider), ) - .first(); - if (account) return true; - } - return false; - }, + .first(), + ), }); // Read budget per cron tick. We walk this many candidate users (oldest diff --git a/packages/kit/convex/users/query.ts b/packages/kit/convex/users/query.ts index afa84a5ea..f1450e2f2 100644 --- a/packages/kit/convex/users/query.ts +++ b/packages/kit/convex/users/query.ts @@ -1,7 +1,7 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; import { isEmailSignInOpen } from "../authWindow"; -import { RESEND_PROVIDER_IDS } from "./internal"; +import { hasAnyResendAccount } from "./internal"; /** * Pre-sign-in gate: returns true if a user with the given email @@ -30,15 +30,13 @@ export const canSignInWithEmail = query({ if (!user) return false; // OTP stays limited to accounts that already used it; GitHub-created // accounts keep using GitHub even during the grace period. - for (const provider of RESEND_PROVIDER_IDS) { - const account = await ctx.db + return hasAnyResendAccount((provider) => + ctx.db .query("authAccounts") .withIndex("userIdAndProvider", (q) => q.eq("userId", user._id).eq("provider", provider), ) - .first(); - if (account) return true; - } - return false; + .first(), + ); }, }); From 30478613281e0c67419f115ed095f4aa4c5d5f4d Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 01:53:14 +0900 Subject: [PATCH 08/21] test(kit): move email sign-in gates into pure, fully covered functions The window and legacy-account gates now live in authWindow.ts beside the cutoff they enforce, with every branch tested; auth.ts keeps only the call sites. Raises the iapkit-convex patch coverage the codecov gate flagged. Co-Authored-By: Claude Opus 5 --- packages/kit/convex/auth.ts | 35 +++++++++-------------- packages/kit/convex/authWindow.test.ts | 39 ++++++++++++++++++++++++++ packages/kit/convex/authWindow.ts | 30 ++++++++++++++++++++ 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/packages/kit/convex/auth.ts b/packages/kit/convex/auth.ts index e064df5af..ec1bf7bc2 100644 --- a/packages/kit/convex/auth.ts +++ b/packages/kit/convex/auth.ts @@ -6,7 +6,11 @@ import { } from "./ResendOTP"; import GitHub, { type GitHubProfile } from "@auth/core/providers/github"; import { api, internal } from "./_generated/api"; -import { EMAIL_SIGN_IN_CLOSES_ON, isEmailSignInOpen } from "./authWindow"; +import { + assertEmailSignInWindowOpen, + assertLegacyEmailAccount, + isResendProviderId, +} from "./authWindow"; const CustomAuth = convexAuth({ providers: [ @@ -29,14 +33,7 @@ const CustomAuth = convexAuth({ ], callbacks: { async createOrUpdateUser(ctx, args) { - // Grace period, then GitHub-only. Existing email accounts are merged by - // matching email on GitHub sign-in, so closing this path costs access - // only to someone whose GitHub email differs from their IAPKit email. - if (args.provider.id.startsWith("resend-otp") && !isEmailSignInOpen()) { - throw new Error( - `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). Sign in with GitHub using the same email address.`, - ); - } + assertEmailSignInWindowOpen(args.provider.id); // Check if user exists with the same email const email = args.profile.email; @@ -66,18 +63,14 @@ const CustomAuth = convexAuth({ ); if (existingUser) { - // Email OTP is only for accounts that already used it; the UI gate - // (canSignInWithEmail) mirrors this, but the boundary enforces it. - if (args.provider.id.startsWith("resend-otp")) { - const legacy = await ctx.runQuery( - internal.users.internal.hasLegacyEmailAccount, - { userId: existingUser._id }, + // The UI gate (canSignInWithEmail) mirrors this; the boundary enforces it. + if (isResendProviderId(args.provider.id)) { + assertLegacyEmailAccount( + args.provider.id, + await ctx.runQuery(internal.users.internal.hasLegacyEmailAccount, { + userId: existingUser._id, + }), ); - if (!legacy) { - throw new Error( - "This account uses GitHub sign-in. Please continue with GitHub.", - ); - } } // User exists - update auth user @@ -117,7 +110,7 @@ const CustomAuth = convexAuth({ // is created. OAuth providers (github) are exempt β€” that's the // path we want new users on. const providerId = args.provider.id; - const isResendProvider = providerId.startsWith("resend-otp"); + const isResendProvider = isResendProviderId(providerId); if (isResendProvider) { throw new Error( "New email signups are disabled. Please sign in with GitHub instead.", diff --git a/packages/kit/convex/authWindow.test.ts b/packages/kit/convex/authWindow.test.ts index 2bd1274ae..fef5d8a7b 100644 --- a/packages/kit/convex/authWindow.test.ts +++ b/packages/kit/convex/authWindow.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { EMAIL_SIGN_IN_CLOSES_AT, EMAIL_SIGN_IN_CLOSES_ON, + assertEmailSignInWindowOpen, + assertLegacyEmailAccount, isEmailSignInOpen, + isResendProviderId, } from "./authWindow"; describe("email sign-in grace period", () => { @@ -22,3 +25,39 @@ describe("email sign-in grace period", () => { expect(isEmailSignInOpen(Date.UTC(2026, 9, 1, 0, 0, 0, 0))).toBe(false); }); }); + +describe("email sign-in gates", () => { + it("recognizes every resend provider id and nothing else", () => { + expect(isResendProviderId("resend-otp-en")).toBe(true); + expect(isResendProviderId("resend-otp-ko")).toBe(true); + expect(isResendProviderId("github")).toBe(false); + }); + + it("lets email sign-in through while the window is open", () => { + expect(() => + assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT), + ).not.toThrow(); + }); + + it("rejects email sign-in after the window closes", () => { + expect(() => + assertEmailSignInWindowOpen("resend-otp-en", EMAIL_SIGN_IN_CLOSES_AT + 1), + ).toThrow(/closed on 2026-09-30 \(UTC\)/); + }); + + it("never blocks GitHub, even after the window closes", () => { + expect(() => + assertEmailSignInWindowOpen("github", EMAIL_SIGN_IN_CLOSES_AT + 1), + ).not.toThrow(); + }); + + it("accepts OTP for accounts that already used email", () => { + expect(() => assertLegacyEmailAccount("resend-otp-en", true)).not.toThrow(); + }); + + it("rejects OTP for GitHub-created accounts", () => { + expect(() => assertLegacyEmailAccount("resend-otp-en", false)).toThrow( + /continue with GitHub/i, + ); + }); +}); diff --git a/packages/kit/convex/authWindow.ts b/packages/kit/convex/authWindow.ts index e399671fb..dbe0fde85 100644 --- a/packages/kit/convex/authWindow.ts +++ b/packages/kit/convex/authWindow.ts @@ -13,3 +13,33 @@ export const EMAIL_SIGN_IN_CLOSES_AT = Date.UTC(2026, 8, 30, 23, 59, 59, 999); export function isEmailSignInOpen(now: number = Date.now()): boolean { return now <= EMAIL_SIGN_IN_CLOSES_AT; } + +export function isResendProviderId(providerId: string): boolean { + return providerId.startsWith("resend-otp"); +} + +// Grace period, then GitHub-only. Existing email accounts merge onto GitHub +// by matching email, so closing costs access only when the emails differ. +export function assertEmailSignInWindowOpen( + providerId: string, + now: number = Date.now(), +): void { + if (isResendProviderId(providerId) && !isEmailSignInOpen(now)) { + throw new Error( + `Email sign-in closed on ${EMAIL_SIGN_IN_CLOSES_ON} (UTC). Sign in with GitHub using the same email address.`, + ); + } +} + +// Email OTP is only for accounts that already used it; a GitHub-created +// account keeps using GitHub even while the window is open. +export function assertLegacyEmailAccount( + providerId: string, + hasLegacyEmailAccount: boolean, +): void { + if (isResendProviderId(providerId) && !hasLegacyEmailAccount) { + throw new Error( + "This account uses GitHub sign-in. Please continue with GitHub.", + ); + } +} From d81929bcf4a8ef855c7ff34553cb6900170c9201 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 02:17:21 +0900 Subject: [PATCH 09/21] ci: cancel superseded PR runs of the main CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeql.yml and the six library workflows already cancel in-progress runs on a new PR push; ci.yml did not, so every push stacked another full run β€” three pushes meant three concurrent macos test-ios jobs competing for runners. Main pushes still run to completion. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4f5e7ad9..99288f6ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,11 @@ on: permissions: contents: read +# Cancel the superseded run on every PR push; main pushes always run to completion. +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: XCODE_VERSION: 16.4 From d4d62994acc540c63ed92f040a9c6d918dfb6b9e Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 03:06:41 +0900 Subject: [PATCH 10/21] ci: route four mac lanes to a self-hosted runner behind a heartbeat gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-repo runs of ci.yml test-ios, flutter apple-cocoapods, kmp ios-compile-check, and maui app-store-artifact can now run on the Mac Mini. Routing is decided per run by a pick-mac-runner gate: the Mac refreshes a MAC_CI heartbeat variable every five minutes while its Actions runner is alive (scripts/ci/mac-runner-heartbeat.sh), and the gate picks the self-mac label only when the heartbeat is under fifteen minutes old. A powered-off or sleeping Mac therefore degrades to the hosted runners within one window instead of leaving jobs queued against an offline label, which is also why a plain runs-on expression was not enough. Fork pull requests never route to the self-hosted runner: the gate requires head.repo to equal the repository, the machine holds production credentials, and the repository is public. The Swift CodeQL matrix stays on hosted runners β€” six legs against one local runner would serialize into something slower than the five hosted slots. The parity guard's macOS image pins now read the gate fallbacks for these lanes; the release workflows keep their literal pins. Co-Authored-By: Claude Opus 5 --- .../workflows/ci-flutter-inapp-purchase.yml | 28 +++++++++++++++++- .github/workflows/ci-kmp-iap.yml | 28 +++++++++++++++++- .github/workflows/ci-maui-iap.yml | 28 +++++++++++++++++- .github/workflows/ci.yml | 29 +++++++++++++++++-- scripts/audit-non-godot-parity.mjs | 20 +++++++++++-- scripts/ci/mac-runner-heartbeat.sh | 13 +++++++++ 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100755 scripts/ci/mac-runner-heartbeat.sh diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 88600782a..49ab5b7c8 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -114,10 +114,36 @@ jobs: - name: Build iOS and macOS examples with SwiftPM run: bash scripts/verify-apple-swiftpm-consumer-build.sh + pick-mac-runner: + # Same-repo runs go to the self-hosted Mac only while its heartbeat + # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; + # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + env: + HEARTBEAT: ${{ vars.MAC_CI }} + SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + runner='macos-15' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$SAME_REPO" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + apple-cocoapods: name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 16.4) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 + needs: pick-mac-runner + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 60 env: XCODE_VERSION: 16.4 diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index a65f609f1..9bf12dcf8 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -66,9 +66,35 @@ jobs: :example:composeApp:assembleHorizonDebug \ :example:composeApp:assembleAmazonDebug + pick-mac-runner: + # Same-repo runs go to the self-hosted Mac only while its heartbeat + # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; + # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + env: + HEARTBEAT: ${{ vars.MAC_CI }} + SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + runner='macos-15' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$SAME_REPO" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + ios-compile-check: name: iOS Compile Check - runs-on: macos-15 + needs: pick-mac-runner + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 60d08f107..61dcbf3c9 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -152,9 +152,35 @@ jobs: dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0-android -p:OpenIapAndroidStore="$store" -p:BuildProjectReferences=false "${DOTNET_BUILD_ARGS[@]}" done + pick-mac-runner: + # Same-repo runs go to the self-hosted Mac only while its heartbeat + # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; + # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + env: + HEARTBEAT: ${{ vars.MAC_CI }} + SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + runner='macos-26' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$SAME_REPO" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + app-store-artifact: name: App Store artifact (Xcode 26.6) - runs-on: macos-26 + needs: pick-mac-runner + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99288f6ac..878b01fb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -402,11 +402,36 @@ jobs: :openiap:lintHorizonDebug \ :openiap:lintAmazonDebug + pick-mac-runner: + # Same-repo runs go to the self-hosted Mac only while its heartbeat + # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; + # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + env: + HEARTBEAT: ${{ vars.MAC_CI }} + SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + run: | + runner='macos-15' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$SAME_REPO" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + test-ios: name: Test iOS - runs-on: macos-15 - needs: changes + needs: [changes, pick-mac-runner] if: needs.changes.outputs.ios == 'true' + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 2be29f55c..a1d8d2532 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6026,8 +6026,20 @@ function checkFrameworkDependencyHygiene() { `${releaseNotesWorkflow} release notes must use the release tag when it already exists`, ); } - for (const xcodeReleaseWorkflow of [ + // ci.yml routes test-ios through the pick-mac-runner gate, so its hosted + // image pin lives in the gate's fallback rather than a literal runs-on. + expectIncludes( ".github/workflows/ci.yml", + [ + "runner='macos-15'", + "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", + "XCODE_VERSION: 16.4", + "maxim-lobanov/setup-xcode@", + "xcode-version: ${{ env.XCODE_VERSION }}", + ], + ".github/workflows/ci.yml must pin the macOS/Xcode release image", + ); + for (const xcodeReleaseWorkflow of [ ".github/workflows/release-apple.yml", ".github/workflows/release-kmp.yml", ]) { @@ -6067,7 +6079,8 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/ci-maui-iap.yml", [ "app-store-artifact:", - "runs-on: macos-26", + "runner='macos-26'", + "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', @@ -8975,7 +8988,8 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", - "runs-on: macos-15", + "runner='macos-15'", + "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", "XCODE_VERSION: 16.4", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", diff --git a/scripts/ci/mac-runner-heartbeat.sh b/scripts/ci/mac-runner-heartbeat.sh new file mode 100755 index 000000000..366b37c54 --- /dev/null +++ b/scripts/ci/mac-runner-heartbeat.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Refreshes the MAC_CI heartbeat (unix epoch) while the Actions runner on this +# machine is alive. Workflow gate jobs treat a heartbeat older than 15 minutes +# as "Mac is off" and fall back to GitHub-hosted runners, so nothing hangs when +# the machine sleeps or shuts down. +# +# Install on the Mac Mini (once), as the user that runs the Actions runner: +# crontab -e β†’ */5 * * * * /path/to/openiap/scripts/ci/mac-runner-heartbeat.sh +# or a LaunchAgent with StartInterval 300. Requires `gh auth` with repo admin. +set -eu + +pgrep -q "Runner.Listener" || exit 0 +exec gh variable set MAC_CI --repo hyodotdev/openiap --body "$(date +%s)" From 78c9aa4f3d9e3d424622199e0bc7497cfd3ad5d6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 03:09:15 +0900 Subject: [PATCH 11/21] ci: run the mac-runner gate from runner.temp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flutter and kmp workflows default run steps into their library folder, which does not exist in the gate job because it never checks out β€” bash failed to start. runner.temp always exists. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-flutter-inapp-purchase.yml | 3 +++ .github/workflows/ci-kmp-iap.yml | 3 +++ .github/workflows/ci-maui-iap.yml | 3 +++ .github/workflows/ci.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 49ab5b7c8..17cf8fc1e 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -123,6 +123,9 @@ jobs: runner: ${{ steps.pick.outputs.runner }} steps: - id: pick + # No checkout here, so the workflow's default working-directory may + # not exist; runner.temp always does. + working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index 9bf12dcf8..046b93bbd 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -75,6 +75,9 @@ jobs: runner: ${{ steps.pick.outputs.runner }} steps: - id: pick + # No checkout here, so the workflow's default working-directory may + # not exist; runner.temp always does. + working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 61dcbf3c9..3d463605b 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -161,6 +161,9 @@ jobs: runner: ${{ steps.pick.outputs.runner }} steps: - id: pick + # No checkout here, so the workflow's default working-directory may + # not exist; runner.temp always does. + working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 878b01fb1..db0eb0ba7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -411,6 +411,9 @@ jobs: runner: ${{ steps.pick.outputs.runner }} steps: - id: pick + # No checkout here, so the workflow's default working-directory may + # not exist; runner.temp always does. + working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} From 0bf24fb2563cf8e0cc7cf1c6c470dbfa97dfba5c Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 03:23:43 +0900 Subject: [PATCH 12/21] ci: raise the pinned Xcode toolchain to 26.6 and gate mac routing to PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every lane that pinned Xcode 16.4 now pins 26.6 on macos-26 images: ci.yml test-ios, the CodeQL Swift core analysis, the Flutter CocoaPods consumer lane, and the apple, kmp, and flutter release builds. The repo had already started leaving 16.4 behind β€” the expo release refuses it because its Swift 6.1 toolchain cannot resolve the manifest β€” and nothing supported remains on it. Release artifacts are now built with the 26.6 toolchain, which is worth a line in the next release notes since it raises the effective consumer floor. iOS deployment targets and the StoreKit compiler-guard comments are unrelated version strings and are untouched. The pick-mac-runner gates now route pull requests only. Push events always build on hosted runners, so a merge commit can never land on the Mac Mini. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-flutter-inapp-purchase.yml | 8 ++++---- .github/workflows/ci-kmp-iap.yml | 4 ++-- .github/workflows/ci-maui-iap.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/codeql.yml | 4 ++-- .github/workflows/release-apple.yml | 6 +++--- .github/workflows/release-flutter.yml | 4 ++-- .github/workflows/release-kmp.yml | 6 +++--- scripts/audit-non-godot-parity.mjs | 12 ++++++------ scripts/ci/mac-runner-heartbeat.sh | 3 +++ 10 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 17cf8fc1e..307207011 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -128,9 +128,9 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} run: | - runner='macos-15' + runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) @@ -143,13 +143,13 @@ jobs: echo "runner=$runner" >> "$GITHUB_OUTPUT" apple-cocoapods: - name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 16.4) + name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 26.6) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository needs: pick-mac-runner runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index 046b93bbd..8f37f2069 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -80,9 +80,9 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} run: | - runner='macos-15' + runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 3d463605b..3f917a2aa 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -166,7 +166,7 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} run: | runner='macos-26' case "$HEARTBEAT" in diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0eb0ba7..6d7cfec16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 jobs: audit-release-state: @@ -416,9 +416,9 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} run: | - runner='macos-15' + runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f41d4beb9..66ef18e39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,7 +17,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 jobs: codeql-scope: @@ -304,7 +304,7 @@ jobs: name: Analyze (swift) needs: codeql-scope if: needs.codeql-scope.outputs.swift_core == 'true' - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 45 permissions: contents: read diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 4e723afc2..17f78dde6 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -28,7 +28,7 @@ concurrency: cancel-in-progress: false env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 COCOAPODS_VERSION: 1.15.2 RELEASE_BRANCH: ${{ github.ref_name }} @@ -57,7 +57,7 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 30 steps: - name: Checkout @@ -83,7 +83,7 @@ jobs: permissions: actions: write contents: write - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout diff --git a/.github/workflows/release-flutter.yml b/.github/workflows/release-flutter.yml index 241c2f74a..9c420cba3 100644 --- a/.github/workflows/release-flutter.yml +++ b/.github/workflows/release-flutter.yml @@ -96,10 +96,10 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 defaults: run: working-directory: libraries/flutter_inapp_purchase diff --git a/.github/workflows/release-kmp.yml b/.github/workflows/release-kmp.yml index a2b6080a7..b6ee16143 100644 --- a/.github/workflows/release-kmp.yml +++ b/.github/workflows/release-kmp.yml @@ -25,7 +25,7 @@ concurrency: env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4g -XX:+UseParallelGC" - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 RELEASE_BRANCH: ${{ github.ref_name }} permissions: @@ -85,7 +85,7 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 defaults: run: @@ -130,7 +130,7 @@ jobs: permissions: actions: write contents: write - runs-on: macos-15 + runs-on: macos-26 defaults: run: working-directory: libraries/kmp-iap diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index a1d8d2532..5545eff44 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6031,9 +6031,9 @@ function checkFrameworkDependencyHygiene() { expectIncludes( ".github/workflows/ci.yml", [ - "runner='macos-15'", + "runner='macos-26'", "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", - "XCODE_VERSION: 16.4", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6046,8 +6046,8 @@ function checkFrameworkDependencyHygiene() { expectIncludes( xcodeReleaseWorkflow, [ - "runs-on: macos-15", - "XCODE_VERSION: 16.4", + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -8988,9 +8988,9 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", - "runner='macos-15'", + "runner='macos-26'", "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", - "XCODE_VERSION: 16.4", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], diff --git a/scripts/ci/mac-runner-heartbeat.sh b/scripts/ci/mac-runner-heartbeat.sh index 366b37c54..f0db626ce 100755 --- a/scripts/ci/mac-runner-heartbeat.sh +++ b/scripts/ci/mac-runner-heartbeat.sh @@ -9,5 +9,8 @@ # or a LaunchAgent with StartInterval 300. Requires `gh auth` with repo admin. set -eu +# cron ships a minimal PATH without Homebrew. +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + pgrep -q "Runner.Listener" || exit 0 exec gh variable set MAC_CI --repo hyodotdev/openiap --body "$(date +%s)" From d8161664f9098b3800c3a9222a759b447600737a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 03:39:13 +0900 Subject: [PATCH 13/21] ci: move the react-native release lane to Xcode 26.6 as well Missed in the floor raise; it was the last workflow pinning 16.4. The heartbeat install notes now describe the LaunchAgent, since macOS TCC can hang crontab edits. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-react-native.yml | 4 ++-- scripts/ci/mac-runner-heartbeat.sh | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-react-native.yml b/.github/workflows/release-react-native.yml index 4c88c246a..008b8c606 100644 --- a/.github/workflows/release-react-native.yml +++ b/.github/workflows/release-react-native.yml @@ -108,10 +108,10 @@ jobs: validate-ios: needs: [release-branch] - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 env: - XCODE_VERSION: 16.4 + XCODE_VERSION: 26.6 defaults: run: working-directory: libraries/react-native-iap diff --git a/scripts/ci/mac-runner-heartbeat.sh b/scripts/ci/mac-runner-heartbeat.sh index f0db626ce..2e267a80b 100755 --- a/scripts/ci/mac-runner-heartbeat.sh +++ b/scripts/ci/mac-runner-heartbeat.sh @@ -4,9 +4,11 @@ # as "Mac is off" and fall back to GitHub-hosted runners, so nothing hangs when # the machine sleeps or shuts down. # -# Install on the Mac Mini (once), as the user that runs the Actions runner: -# crontab -e β†’ */5 * * * * /path/to/openiap/scripts/ci/mac-runner-heartbeat.sh -# or a LaunchAgent with StartInterval 300. Requires `gh auth` with repo admin. +# Install on the Mac Mini (once) as a LaunchAgent (cron edits can hang on +# macOS TCC): ~/Library/LaunchAgents/dev.openiap.mac-ci-heartbeat.plist running +# this script with StartInterval 300 + RunAtLoad, then +# launchctl bootstrap gui/$(id -u) +# Requires `gh auth` with repo admin. set -eu # cron ships a minimal PATH without Homebrew. From f9b4cdd284a8a29eb69d559303a8e7c4893a0ad3 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 04:25:09 +0900 Subject: [PATCH 14/21] ci: let the Mac take the SPM-heavy CodeQL legs as a sixth slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit analyze-swift (core) and the godot wrapper leg ride the self-hosted runner while its heartbeat is fresh, so the remaining four wrapper legs fit one hosted five-slot round instead of two β€” roughly halving the Swift matrix tail. Pushes, forks, and a stale heartbeat keep today's hosted runners; the push-event xcode-27 split is preserved verbatim. Running all six legs serially on the single local runner was measured against this and rejected: six legs at 8-15 warm minutes each is no better than the hosted two-round tail, and it pins the machine for an hour. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 38 ++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 66ef18e39..25845fc39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -300,11 +300,36 @@ jobs: with: category: /language:java-kotlin/component:${{ matrix.component }} + pick-mac-runner: + # Same-repo PRs may offload the SPM-heavy Swift legs to the self-hosted + # Mac while its heartbeat (MAC_CI) is fresh; anything else stays hosted. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + working-directory: ${{ runner.temp }} + env: + HEARTBEAT: ${{ vars.MAC_CI }} + SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + run: | + runner='macos-26' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$SAME_REPO" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + analyze-swift: name: Analyze (swift) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: needs.codeql-scope.outputs.swift_core == 'true' - runs-on: macos-26 + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 45 permissions: contents: read @@ -346,12 +371,17 @@ jobs: analyze-swift-wrappers: name: Analyze (swift / ${{ matrix.component }}) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: >- needs.codeql-scope.outputs.swift_wrappers == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ (github.event_name == 'pull_request' || matrix.component == 'godot') && 'macos-26' || 'xcode-27' }} + runs-on: >- + ${{ github.event_name != 'pull_request' + && (matrix.component == 'godot' && 'macos-26' || 'xcode-27') + || (matrix.component == 'godot' + && needs.pick-mac-runner.outputs.runner + || 'macos-26') }} timeout-minutes: 90 permissions: contents: read From ba39e8000f3327cc173742b130113b6c5918fd04 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 04:26:58 +0900 Subject: [PATCH 15/21] ci: restrict Mac routing to the owner's own pull requests The Mac is Hyo's personal machine. The gates now require both the pull request author and the pushing actor to be hyochan, so collaborator PRs and collaborator pushes onto Hyo's branches build on hosted runners like fork PRs always have. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-flutter-inapp-purchase.yml | 6 ++++-- .github/workflows/ci-kmp-iap.yml | 6 ++++-- .github/workflows/ci-maui-iap.yml | 6 ++++-- .github/workflows/ci.yml | 6 ++++-- .github/workflows/codeql.yml | 6 ++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 307207011..4665f9fcf 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -128,13 +128,15 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} run: | runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) - if [ "$SAME_REPO" = "true" ] \ + if [ "$OWNER_PR" = "true" ] \ && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then runner='self-mac' fi diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index 8f37f2069..a7dbef9b8 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -80,13 +80,15 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} run: | runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) - if [ "$SAME_REPO" = "true" ] \ + if [ "$OWNER_PR" = "true" ] \ && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then runner='self-mac' fi diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 3f917a2aa..9528c3c3d 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -166,13 +166,15 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} run: | runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) - if [ "$SAME_REPO" = "true" ] \ + if [ "$OWNER_PR" = "true" ] \ && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then runner='self-mac' fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d7cfec16..70fa0ab44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,13 +416,15 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} run: | runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) - if [ "$SAME_REPO" = "true" ] \ + if [ "$OWNER_PR" = "true" ] \ && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then runner='self-mac' fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 25845fc39..1bd2a6bc1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -311,13 +311,15 @@ jobs: working-directory: ${{ runner.temp }} env: HEARTBEAT: ${{ vars.MAC_CI }} - SAME_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} run: | runner='macos-26' case "$HEARTBEAT" in ''|*[!0-9]*) ;; *) - if [ "$SAME_REPO" = "true" ] \ + if [ "$OWNER_PR" = "true" ] \ && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then runner='self-mac' fi From bb6afd7c9a91d7edaff5c9a3aebf5abafeddf72a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 04:35:39 +0900 Subject: [PATCH 16/21] test: teach the CodeQL runner guard the owner-gated Mac policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard pinned the old rule β€” Swift pull requests only ever on hosted macOS images. The rule it protects has changed deliberately: the pick-mac-runner gate may hand the core and godot legs to the self-hosted Mac, but only for the owner's own pull requests with a fresh heartbeat, and it always falls back to macos-26. The assertions now pin exactly that, including the owner check and the hosted fallback, and still forbid PR legs from reaching xcode-27. Co-Authored-By: Claude Opus 5 --- scripts/audit-security.test.mjs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 0ec5bb9df..4c3717d7c 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -593,11 +593,28 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u, ); assert.match(scope, /swift_core:/u); - assert.match(swiftCore, /needs: codeql-scope/u); + assert.match(swiftCore, /needs: \[codeql-scope, pick-mac-runner\]/u); assert.match( swiftCore, /if: needs\.codeql-scope\.outputs\.swift_core == 'true'/u, ); + assert.match( + swiftCore, + /runs-on: \$\{\{ needs\.pick-mac-runner\.outputs\.runner \}\}/u, + ); + // The gate may hand a job to the self-hosted Mac only for the owner's own + // pull requests, and it always falls back to the hosted image. + const gate = workflow.slice( + workflow.indexOf(" pick-mac-runner:"), + workflow.indexOf(" analyze-swift:"), + ); + assert.match( + gate, + /github\.event\.pull_request\.user\.login == 'hyochan' && github\.actor == 'hyochan'/u, + ); + assert.match(gate, /github\.event_name == 'pull_request'/u); + assert.match(gate, /runner='macos-26'/u); + assert.match(gate, /-lt 900/u); assert.match(scope, /react-native:/u); assert.match(scope, /expo-onside:/u); assert.match(scope, /flutter:/u); @@ -614,9 +631,15 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { wrappers, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u, ); + // Pushes keep the xcode-27 split; PR legs stay hosted except godot, which + // may ride the owner-gated Mac. + assert.match( + wrappers, + /github\.event_name != 'pull_request'\s+&& \(matrix\.component == 'godot' && 'macos-26' \|\| 'xcode-27'\)/u, + ); assert.match( wrappers, - /runs-on: \$\{\{ \(github\.event_name == 'pull_request' \|\| matrix\.component == 'godot'\) && 'macos-26' \|\| 'xcode-27' \}\}/u, + /\(matrix\.component == 'godot'\s+&& needs\.pick-mac-runner\.outputs\.runner\s+\|\| 'macos-26'\)/u, ); assert.match( wrappers, From 45454ef8d9a4b61e8e7ed2f0a7c0a6f4963c7eea Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 05:07:17 +0900 Subject: [PATCH 17/21] ci: keep the CodeQL Swift legs on hosted runners The sixth-slot experiment failed on real hardware: CodeQL's Swift tracer drives the Rosetta/x86_64 path while the self-hosted Apple Silicon runner builds native arm64, so the godot test bundle loaded zero tests ('have arm64, need x86_64'). Installing Rosetta would force emulated builds and erase the machine's speed advantage, so the matrix returns to the hosted five-slot pool exactly as before the split; the owner-gated Mac keeps the four single lanes, where it is measurably faster. The runner-policy guard returns verbatim with the workflow it pins. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 40 ++++----------------------------- scripts/audit-security.test.mjs | 27 ++-------------------- 2 files changed, 6 insertions(+), 61 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1bd2a6bc1..66ef18e39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -300,38 +300,11 @@ jobs: with: category: /language:java-kotlin/component:${{ matrix.component }} - pick-mac-runner: - # Same-repo PRs may offload the SPM-heavy Swift legs to the self-hosted - # Mac while its heartbeat (MAC_CI) is fresh; anything else stays hosted. - runs-on: ubuntu-latest - outputs: - runner: ${{ steps.pick.outputs.runner }} - steps: - - id: pick - working-directory: ${{ runner.temp }} - env: - HEARTBEAT: ${{ vars.MAC_CI }} - # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs - # and pushes may use it; everyone else builds on hosted runners. - OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} - run: | - runner='macos-26' - case "$HEARTBEAT" in - ''|*[!0-9]*) ;; - *) - if [ "$OWNER_PR" = "true" ] \ - && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then - runner='self-mac' - fi - ;; - esac - echo "runner=$runner" >> "$GITHUB_OUTPUT" - analyze-swift: name: Analyze (swift) - needs: [codeql-scope, pick-mac-runner] + needs: codeql-scope if: needs.codeql-scope.outputs.swift_core == 'true' - runs-on: ${{ needs.pick-mac-runner.outputs.runner }} + runs-on: macos-26 timeout-minutes: 45 permissions: contents: read @@ -373,17 +346,12 @@ jobs: analyze-swift-wrappers: name: Analyze (swift / ${{ matrix.component }}) - needs: [codeql-scope, pick-mac-runner] + needs: codeql-scope if: >- needs.codeql-scope.outputs.swift_wrappers == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: >- - ${{ github.event_name != 'pull_request' - && (matrix.component == 'godot' && 'macos-26' || 'xcode-27') - || (matrix.component == 'godot' - && needs.pick-mac-runner.outputs.runner - || 'macos-26') }} + runs-on: ${{ (github.event_name == 'pull_request' || matrix.component == 'godot') && 'macos-26' || 'xcode-27' }} timeout-minutes: 90 permissions: contents: read diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 4c3717d7c..0ec5bb9df 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -593,28 +593,11 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u, ); assert.match(scope, /swift_core:/u); - assert.match(swiftCore, /needs: \[codeql-scope, pick-mac-runner\]/u); + assert.match(swiftCore, /needs: codeql-scope/u); assert.match( swiftCore, /if: needs\.codeql-scope\.outputs\.swift_core == 'true'/u, ); - assert.match( - swiftCore, - /runs-on: \$\{\{ needs\.pick-mac-runner\.outputs\.runner \}\}/u, - ); - // The gate may hand a job to the self-hosted Mac only for the owner's own - // pull requests, and it always falls back to the hosted image. - const gate = workflow.slice( - workflow.indexOf(" pick-mac-runner:"), - workflow.indexOf(" analyze-swift:"), - ); - assert.match( - gate, - /github\.event\.pull_request\.user\.login == 'hyochan' && github\.actor == 'hyochan'/u, - ); - assert.match(gate, /github\.event_name == 'pull_request'/u); - assert.match(gate, /runner='macos-26'/u); - assert.match(gate, /-lt 900/u); assert.match(scope, /react-native:/u); assert.match(scope, /expo-onside:/u); assert.match(scope, /flutter:/u); @@ -631,15 +614,9 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { wrappers, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u, ); - // Pushes keep the xcode-27 split; PR legs stay hosted except godot, which - // may ride the owner-gated Mac. - assert.match( - wrappers, - /github\.event_name != 'pull_request'\s+&& \(matrix\.component == 'godot' && 'macos-26' \|\| 'xcode-27'\)/u, - ); assert.match( wrappers, - /\(matrix\.component == 'godot'\s+&& needs\.pick-mac-runner\.outputs\.runner\s+\|\| 'macos-26'\)/u, + /runs-on: \$\{\{ \(github\.event_name == 'pull_request' \|\| matrix\.component == 'godot'\) && 'macos-26' \|\| 'xcode-27' \}\}/u, ); assert.match( wrappers, From 2fe7b11231de85f7e2caf715fa7f3f9cb7e44507 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 06:04:09 +0900 Subject: [PATCH 18/21] ci: run Swift CodeQL legs on the Mac by tracing builds, not tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier failure was not the machine: natively the godot wrapper passes 10/10 tests on arm64, and the runner's own arm64 CodeQL CLI creates the database cleanly when it traces `swift build --build-tests`. Only running tests UNDER the tracer breaks, because the tracer drives the x86_64 loader path, which cannot load arm64 test bundles on self-hosted Apple Silicon. So the core and godot legs now validate first β€” `swift test` runs natively before codeql-action/init exists β€” and then extract, with `swift package clean && swift build --build-tests` under the tracer. The clean is mandatory: a SwiftPM cache hit after the native test build would leave the CodeQL database empty. Hosted runners execute the same steps unchanged in behavior. This restores the owner-gated sixth-slot routing (core + godot on the Mac, four wrapper legs in one hosted round) and the guard assertions that pin it. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 65 +++++++++++++++++++++++++++++---- scripts/audit-security.test.mjs | 27 +++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 66ef18e39..b6c8d2822 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -300,11 +300,38 @@ jobs: with: category: /language:java-kotlin/component:${{ matrix.component }} + pick-mac-runner: + # Same-repo PRs may offload the SPM-heavy Swift legs to the self-hosted + # Mac while its heartbeat (MAC_CI) is fresh; anything else stays hosted. + runs-on: ubuntu-latest + outputs: + runner: ${{ steps.pick.outputs.runner }} + steps: + - id: pick + working-directory: ${{ runner.temp }} + env: + HEARTBEAT: ${{ vars.MAC_CI }} + # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs + # and pushes may use it; everyone else builds on hosted runners. + OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} + run: | + runner='macos-26' + case "$HEARTBEAT" in + ''|*[!0-9]*) ;; + *) + if [ "$OWNER_PR" = "true" ] \ + && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then + runner='self-mac' + fi + ;; + esac + echo "runner=$runner" >> "$GITHUB_OUTPUT" + analyze-swift: name: Analyze (swift) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: needs.codeql-scope.outputs.swift_core == 'true' - runs-on: macos-26 + runs-on: ${{ needs.pick-mac-runner.outputs.runner }} timeout-minutes: 45 permissions: contents: read @@ -320,6 +347,13 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} + # Tests run natively, before CodeQL's tracer exists: the tracer forces + # the x86_64 loader path, which cannot load arm64 test bundles on + # self-hosted Apple Silicon. Extraction only needs a traced compile. + - name: Test Apple core + working-directory: packages/apple + run: swift test + - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: @@ -327,11 +361,13 @@ jobs: build-mode: manual queries: security-extended - - name: Build and test Apple core + # Clean so the traced build recompiles everything; a cache hit would + # leave the CodeQL database empty. + - name: Build Apple core for analysis working-directory: packages/apple run: | swift package clean - swift test + swift build --build-tests - name: Build KMP Swift bridge working-directory: libraries/kmp-iap/native/InAppPurchaseBridge @@ -346,12 +382,17 @@ jobs: analyze-swift-wrappers: name: Analyze (swift / ${{ matrix.component }}) - needs: codeql-scope + needs: [codeql-scope, pick-mac-runner] if: >- needs.codeql-scope.outputs.swift_wrappers == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ${{ (github.event_name == 'pull_request' || matrix.component == 'godot') && 'macos-26' || 'xcode-27' }} + runs-on: >- + ${{ github.event_name != 'pull_request' + && (matrix.component == 'godot' && 'macos-26' || 'xcode-27') + || (matrix.component == 'godot' + && needs.pick-mac-runner.outputs.runner + || 'macos-26') }} timeout-minutes: 90 permissions: contents: read @@ -399,6 +440,15 @@ jobs: with: xcode-version: "26.6" + # Native test pass before the tracer exists; see the core job's note. + - name: Test Godot Swift wrapper + if: matrix.component == 'godot' + working-directory: libraries/godot-iap + run: | + make setup-swiftgodot + cd ios-gdextension + swift test + - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: @@ -460,7 +510,8 @@ jobs: run: | make setup-swiftgodot cd ios-gdextension - swift test + swift package clean + swift build --build-tests - name: Analyze uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 0ec5bb9df..4c3717d7c 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -593,11 +593,28 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/u, ); assert.match(scope, /swift_core:/u); - assert.match(swiftCore, /needs: codeql-scope/u); + assert.match(swiftCore, /needs: \[codeql-scope, pick-mac-runner\]/u); assert.match( swiftCore, /if: needs\.codeql-scope\.outputs\.swift_core == 'true'/u, ); + assert.match( + swiftCore, + /runs-on: \$\{\{ needs\.pick-mac-runner\.outputs\.runner \}\}/u, + ); + // The gate may hand a job to the self-hosted Mac only for the owner's own + // pull requests, and it always falls back to the hosted image. + const gate = workflow.slice( + workflow.indexOf(" pick-mac-runner:"), + workflow.indexOf(" analyze-swift:"), + ); + assert.match( + gate, + /github\.event\.pull_request\.user\.login == 'hyochan' && github\.actor == 'hyochan'/u, + ); + assert.match(gate, /github\.event_name == 'pull_request'/u); + assert.match(gate, /runner='macos-26'/u); + assert.match(gate, /-lt 900/u); assert.match(scope, /react-native:/u); assert.match(scope, /expo-onside:/u); assert.match(scope, /flutter:/u); @@ -614,9 +631,15 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { wrappers, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/u, ); + // Pushes keep the xcode-27 split; PR legs stay hosted except godot, which + // may ride the owner-gated Mac. + assert.match( + wrappers, + /github\.event_name != 'pull_request'\s+&& \(matrix\.component == 'godot' && 'macos-26' \|\| 'xcode-27'\)/u, + ); assert.match( wrappers, - /runs-on: \$\{\{ \(github\.event_name == 'pull_request' \|\| matrix\.component == 'godot'\) && 'macos-26' \|\| 'xcode-27' \}\}/u, + /\(matrix\.component == 'godot'\s+&& needs\.pick-mac-runner\.outputs\.runner\s+\|\| 'macos-26'\)/u, ); assert.match( wrappers, From 708faa91349936d773c581562c2814d0a38bde22 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 06:26:42 +0900 Subject: [PATCH 19/21] ci: send every Swift CodeQL leg to the Mac when it is alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyo asked for this outright, twice: hosted legs were taking 25-40 minutes each with queue waits, while the Mac's warm-cache runs are minutes. All six legs now follow the pick-mac-runner gate on pull requests β€” serialized on the single runner by choice, with the hosted five-slot pool as the automatic fallback whenever the heartbeat is stale. Push events keep the existing xcode-27 split. The other four legs only compile under the tracer, so the arm64 test-loader pitfall the godot leg hit does not apply to them. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 4 +--- scripts/audit-security.test.mjs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b6c8d2822..09ef33a4c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -390,9 +390,7 @@ jobs: runs-on: >- ${{ github.event_name != 'pull_request' && (matrix.component == 'godot' && 'macos-26' || 'xcode-27') - || (matrix.component == 'godot' - && needs.pick-mac-runner.outputs.runner - || 'macos-26') }} + || needs.pick-mac-runner.outputs.runner }} timeout-minutes: 90 permissions: contents: read diff --git a/scripts/audit-security.test.mjs b/scripts/audit-security.test.mjs index 4c3717d7c..e45c7129a 100644 --- a/scripts/audit-security.test.mjs +++ b/scripts/audit-security.test.mjs @@ -639,7 +639,7 @@ test("CodeQL scopes Swift pull requests to public macOS runners", () => { ); assert.match( wrappers, - /\(matrix\.component == 'godot'\s+&& needs\.pick-mac-runner\.outputs\.runner\s+\|\| 'macos-26'\)/u, + /\|\| needs\.pick-mac-runner\.outputs\.runner \}\}/u, ); assert.match( wrappers, From 6854111f98971e7caa2836feee68ed745098d8db Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 06:56:59 +0900 Subject: [PATCH 20/21] ci: keep the Mac runner for CodeQL only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyo narrowed the routing: the four single mac lanes go back to hosted macos-26 unconditionally β€” they finish in minutes anyway and freeing them keeps the mini available for the legs that actually hurt, the six Swift CodeQL analyses. Only codeql.yml keeps the pick-mac-runner gate. Co-Authored-By: Claude Opus 5 --- .../workflows/ci-flutter-inapp-purchase.yml | 33 +----------------- .github/workflows/ci-kmp-iap.yml | 33 +----------------- .github/workflows/ci-maui-iap.yml | 33 +----------------- .github/workflows/ci.yml | 34 ++----------------- scripts/audit-non-godot-parity.mjs | 11 ++---- 5 files changed, 8 insertions(+), 136 deletions(-) diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 4665f9fcf..7b30c791a 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -114,41 +114,10 @@ jobs: - name: Build iOS and macOS examples with SwiftPM run: bash scripts/verify-apple-swiftpm-consumer-build.sh - pick-mac-runner: - # Same-repo runs go to the self-hosted Mac only while its heartbeat - # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; - # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. - runs-on: ubuntu-latest - outputs: - runner: ${{ steps.pick.outputs.runner }} - steps: - - id: pick - # No checkout here, so the workflow's default working-directory may - # not exist; runner.temp always does. - working-directory: ${{ runner.temp }} - env: - HEARTBEAT: ${{ vars.MAC_CI }} - # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs - # and pushes may use it; everyone else builds on hosted runners. - OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} - run: | - runner='macos-26' - case "$HEARTBEAT" in - ''|*[!0-9]*) ;; - *) - if [ "$OWNER_PR" = "true" ] \ - && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then - runner='self-mac' - fi - ;; - esac - echo "runner=$runner" >> "$GITHUB_OUTPUT" - apple-cocoapods: name: iOS CocoaPods consumer (Flutter 3.44 + Xcode 26.6) if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - needs: pick-mac-runner - runs-on: ${{ needs.pick-mac-runner.outputs.runner }} + runs-on: macos-26 timeout-minutes: 60 env: XCODE_VERSION: 26.6 diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index a7dbef9b8..10af6af23 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -66,40 +66,9 @@ jobs: :example:composeApp:assembleHorizonDebug \ :example:composeApp:assembleAmazonDebug - pick-mac-runner: - # Same-repo runs go to the self-hosted Mac only while its heartbeat - # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; - # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. - runs-on: ubuntu-latest - outputs: - runner: ${{ steps.pick.outputs.runner }} - steps: - - id: pick - # No checkout here, so the workflow's default working-directory may - # not exist; runner.temp always does. - working-directory: ${{ runner.temp }} - env: - HEARTBEAT: ${{ vars.MAC_CI }} - # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs - # and pushes may use it; everyone else builds on hosted runners. - OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} - run: | - runner='macos-26' - case "$HEARTBEAT" in - ''|*[!0-9]*) ;; - *) - if [ "$OWNER_PR" = "true" ] \ - && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then - runner='self-mac' - fi - ;; - esac - echo "runner=$runner" >> "$GITHUB_OUTPUT" - ios-compile-check: name: iOS Compile Check - needs: pick-mac-runner - runs-on: ${{ needs.pick-mac-runner.outputs.runner }} + runs-on: macos-26 timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/ci-maui-iap.yml b/.github/workflows/ci-maui-iap.yml index 9528c3c3d..60d08f107 100644 --- a/.github/workflows/ci-maui-iap.yml +++ b/.github/workflows/ci-maui-iap.yml @@ -152,40 +152,9 @@ jobs: dotnet build src/OpenIap.Maui/OpenIap.Maui.csproj -p:TargetFrameworks=net10.0-android -p:OpenIapAndroidStore="$store" -p:BuildProjectReferences=false "${DOTNET_BUILD_ARGS[@]}" done - pick-mac-runner: - # Same-repo runs go to the self-hosted Mac only while its heartbeat - # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; - # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. - runs-on: ubuntu-latest - outputs: - runner: ${{ steps.pick.outputs.runner }} - steps: - - id: pick - # No checkout here, so the workflow's default working-directory may - # not exist; runner.temp always does. - working-directory: ${{ runner.temp }} - env: - HEARTBEAT: ${{ vars.MAC_CI }} - # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs - # and pushes may use it; everyone else builds on hosted runners. - OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} - run: | - runner='macos-26' - case "$HEARTBEAT" in - ''|*[!0-9]*) ;; - *) - if [ "$OWNER_PR" = "true" ] \ - && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then - runner='self-mac' - fi - ;; - esac - echo "runner=$runner" >> "$GITHUB_OUTPUT" - app-store-artifact: name: App Store artifact (Xcode 26.6) - needs: pick-mac-runner - runs-on: ${{ needs.pick-mac-runner.outputs.runner }} + runs-on: macos-26 timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70fa0ab44..61239f385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -402,41 +402,11 @@ jobs: :openiap:lintHorizonDebug \ :openiap:lintAmazonDebug - pick-mac-runner: - # Same-repo runs go to the self-hosted Mac only while its heartbeat - # (MAC_CI, refreshed by scripts/ci/mac-runner-heartbeat.sh) is fresh; - # a stale heartbeat, a fork PR, or any parse doubt falls back to cloud. - runs-on: ubuntu-latest - outputs: - runner: ${{ steps.pick.outputs.runner }} - steps: - - id: pick - # No checkout here, so the workflow's default working-directory may - # not exist; runner.temp always does. - working-directory: ${{ runner.temp }} - env: - HEARTBEAT: ${{ vars.MAC_CI }} - # Owner-only: the Mac is Hyo's machine, so only Hyo's own PRs - # and pushes may use it; everyone else builds on hosted runners. - OWNER_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login == 'hyochan' && github.actor == 'hyochan' }} - run: | - runner='macos-26' - case "$HEARTBEAT" in - ''|*[!0-9]*) ;; - *) - if [ "$OWNER_PR" = "true" ] \ - && [ $(( $(date +%s) - HEARTBEAT )) -lt 900 ]; then - runner='self-mac' - fi - ;; - esac - echo "runner=$runner" >> "$GITHUB_OUTPUT" - test-ios: name: Test iOS - needs: [changes, pick-mac-runner] + needs: changes if: needs.changes.outputs.ios == 'true' - runs-on: ${{ needs.pick-mac-runner.outputs.runner }} + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 5545eff44..0d3c9e546 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6026,13 +6026,10 @@ function checkFrameworkDependencyHygiene() { `${releaseNotesWorkflow} release notes must use the release tag when it already exists`, ); } - // ci.yml routes test-ios through the pick-mac-runner gate, so its hosted - // image pin lives in the gate's fallback rather than a literal runs-on. expectIncludes( ".github/workflows/ci.yml", [ - "runner='macos-26'", - "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", + "runs-on: macos-26", "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", @@ -6079,8 +6076,7 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/ci-maui-iap.yml", [ "app-store-artifact:", - "runner='macos-26'", - "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", + "runs-on: macos-26", 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', @@ -8988,8 +8984,7 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", - "runner='macos-26'", - "runs-on: ${{ needs.pick-mac-runner.outputs.runner }}", + "runs-on: macos-26", "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", From 8ecd30f456613e3b1bbaeec0860d89f51fe4cd7f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 07:19:42 +0900 Subject: [PATCH 21/21] ci: run the react-native CodeQL leg with the Mac's own Ruby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruby/setup-ruby installs prebuilt rubies into a hard-coded /Users/runner tool cache, which does not exist on the self-hosted Mac (EACCES). Skip it there and put Homebrew Ruby plus its gem bindir on PATH instead β€” the machine's 3.4.2 satisfies the example Gemfile's >= 3.3 and the exact bundle install + bundle exec pod sequence was verified locally. Also set LANG at the job level: CocoaPods needs a UTF-8 locale and the launchd service environment ships none. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 09ef33a4c..244668826 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -395,6 +395,8 @@ jobs: permissions: contents: read security-events: write + env: + LANG: en_US.UTF-8 strategy: fail-fast: false matrix: @@ -412,12 +414,24 @@ jobs: node-version: 20 - name: Setup Ruby - if: matrix.component == 'react-native' + if: >- + matrix.component == 'react-native' && + needs.pick-mac-runner.outputs.runner != 'self-mac' uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" working-directory: libraries/react-native-iap/example + # setup-ruby hard-codes /Users/runner, which does not exist on the + # self-hosted Mac; its Homebrew Ruby already satisfies the Gemfile. + - name: Use Homebrew Ruby (self-mac) + if: >- + matrix.component == 'react-native' && + needs.pick-mac-runner.outputs.runner == 'self-mac' + run: | + echo "/opt/homebrew/opt/ruby/bin" >> "$GITHUB_PATH" + echo "$(/opt/homebrew/opt/ruby/bin/gem environment gemdir)/bin" >> "$GITHUB_PATH" + - name: Setup Bun if: startsWith(matrix.component, 'expo') uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2