Add paywall preload benchmark CI for release PRs#432
Merged
Conversation
On devices without the Play Store, the billing client reports BILLING_UNAVAILABLE and product fetches throw BillingNotAvailable, which failed the whole paywall request even when test mode was active and products should have come from the test catalog instead. Two gaps caused this: - Race on activation: the test product catalog is fetched over the network when test mode activates, but a paywall requested right after config landed read an empty catalog and fell through to Play billing. StoreManager now waits (up to 5s) for the catalog to load before resolving products while test mode is active. - No test-mode exemption: any product id not resolved from the test catalog hit Play billing, and a BillingNotAvailable error failed the entire load, discarding the test products that did resolve. In test mode, BillingNotAvailable is now swallowed and whatever resolved from the test catalog and caches is served. Also stop re-evaluations from replacing an active test-mode session, which wiped the loaded catalog and entitlement selections and left the new session's products forever unloaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117xhsmLu2YmArN8632vmtF
…egration test When test mode re-activates for a different reason (e.g. another test user matched after identify), the simulated entitlements, selections and overridden subscription status are now reset while the user-independent product catalog is kept. Also add a JVM integration test that wires the real GoogleBillingWrapper to a BillingClient whose setup finishes with BILLING_UNAVAILABLE — the exact signal a device without the Play Store produces — and verifies the StoreManager + TestMode chain serves test-catalog products (including after the billing failure is permanently cached), while non-test-mode loads still fail as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117xhsmLu2YmArN8632vmtF
Adds a CI benchmark that measures the time from preloadAllPaywalls() until every paywall for the dev app's embedded API key reaches PaywallLoadingState.Ready. - PaywallPreloadBenchmark instrumented test in :app runs N iterations (cold + warm), polls the SDK view store for Ready state, and writes JSON results to the device's Downloads collection - preload-benchmark.yml workflow runs on PRs into main (release PRs) across 3 parallel emulators (LOW/MID/HIGH tiers) pinned to the same Play Store device image (pixel_6, API 34, google_apis_playstore), differing only in cores/RAM, with a 5s settle after boot - scripts/benchmark_compare.py aggregates tier results, writes the report to .benchmark/, and fails the run when a tier's mean regresses more than the delta limit (default 10%) over its committed baseline - Baselines are recorded via workflow_dispatch with update_baseline; each result records the SDK's own DeviceClassifier tier so the emulator configs can be verified against real tier classification Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…ting workflows - Also trigger on push to main/develop so merged branches keep fresh benchmark reports - Opt in to ExperimentalCoroutinesApi for resetReplayCache (verified :app:compileDebugAndroidTestKotlin passes) - Document that the emulator setup mirrors pr-tests.yml/build+test.yml and that the pixel_6 Play Store device matches maestro-run.yml's DeviceCloud config Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Each emulator now invokes connectedDebugAndroidTest runsPerEmulator times (default 3); every invocation reinstalls the app and clears its data, so each run's first iteration is a true cold start. The test tags results with a run index and writes one JSON per run, and the compare script merges all runs per tier, computing the gated mean (plus cold/warm means, median, stddev, CV) across all runs x iterations. The AVD snapshot prewarm stays separate from pr-tests.yml on purpose: those AVDs are API 33 google_apis pixel_7_pro without Play Store, while the benchmark needs the API 34 google_apis_playstore pixel_6 image, and per-tier cores/RAM are baked into each snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…d benchmark on develop PRs test_preload_active_paywall in :superwall androidTest still asserted the pre-#430 behavior where updatePaywall mutated the cached paywall, leaking presentationSourceType onto the instance returned by the preload-of-active-paywall path. Since ad31f3a returns a copy, the cached paywall no longer carries the presenter's source type, so the test now verifies the presented copy kept its request-specific values and that preloading didn't clobber the original experiment (mirroring the unit test updated in #430). Verified by replicating the scenario as a JVM test: old assertion fails on presentationSourceType, new assertions pass. The benchmark workflow now also runs on PRs into develop when the PR carries the 'benchmark' label (release PRs into main still always run), so the pipeline can be exercised before merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…te shells android-emulator-runner passes each line of its script input to its own sh -c, so the multi-line for-loop died with 'Syntax error: end of file unexpected' on all three tiers. Move the settle/loop/pull sequence into scripts/run_preload_benchmark.sh and invoke it as a single line. Verified with bash -n and a stubbed adb/gradlew dry run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…per run The gradle build previously ran inside the emulator session, competing with the emulator for the runner's cores; assembling :app debug + androidTest APKs beforehand means the three in-session connectedDebugAndroidTest invocations only install and run (the class filter already restricts execution to the benchmark test). Iterations per run drop from 3 to 2 — still 3 fully cold runs per emulator, averaged over 6 samples. The Play Store system image download and AVD snapshot creation were first-run-only costs; subsequent runs restore the per-tier cached AVD. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
… am instrument A single build-apks job assembles both APKs and shares them as an artifact; the three tier jobs download them, adb install once, and per run do pm clear + am instrument directly. This removes the JDK/gradle setup and the ~2-3 min of gradle configuration+install overhead from each of the 3 in-session runs while keeping the cold-start guarantee (pm clear wipes app data before every run). am instrument exits 0 through adb even on failure, so the script parses the instrumentation output for OK/FAILURES/crash markers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
All three benchmark tiers hung for 90+ minutes because the wait for ConfigurationStatus.Configured sat outside the iteration timeout: when configuration fails or stalls on the emulator the status never becomes Configured and the test blocked forever. The wait is now bounded (benchmarkConfigureTimeoutSec, default 120s), fails fast with the actual status on ConfigurationStatus.Failed, and the tier jobs carry timeout-minutes: 30 so a hang can never ride to GitHub's 6h default. On a failed run the script now dumps filtered logcat so the root cause is visible in the CI log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
… runners tee failed with 'No such device or address' in the runner's sh -c context; combined with set -e + pipefail this killed the script after am instrument completed and swallowed its output, so all three tiers failed without diagnostics. Capture plain, print after, tolerate adb's exit status, and log per-run wall time. Result upload downgraded to if-no-files-found: warn so failed runs don't stack a misleading artifact error on top (the report job already fails on missing tiers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
All three tiers failed fast with 'SDK configuration timed out (status still Pending)': GitHub runners resolve via systemd-resolved on 127.0.0.53, which the emulator guest cannot use, so api.superwall.me never resolves and the config fetch retries forever. Nothing else in CI does real network from a GH-hosted emulator (superwall tests mock it; real-network app tests run on DeviceCloud), which is why this never surfaced. Pass -dns-server 8.8.8.8 to both emulator steps, add a guest/host connectivity pre-flight to the script, and dump an unfiltered logcat tail on failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…s a network DNS now resolves in the guest (the -dns-server fix worked: the pre-flight printed api.superwall.me's IP, and the host reaches the API with HTTP 200), yet configuration still timed out at Pending. The SDK's awaitUntilNetworkExists() requires an active network with a WIFI or CELLULAR transport, and netsim WiFi does not reliably come back after the AVD snapshot restore — the guest believes it is offline even though the NAT path works, so the config fetch never starts. Toggle WiFi off/on after boot, wait bounded for an active default network, and print the connectivity state in the pre-flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…rors ConnectivityManager now reports a validated LTE network 2s after the WiFi cycle and DNS resolves in the guest, yet configuration still never leaves Pending — the config request is failing and silently retrying (getConfig's retry callback flips state to Retrying, which maps to Pending externally, and nothing logs at error level until retries are exhausted). Run the benchmark with LogLevel.debug and widen the failure logcat window (+System.err, since HandleFetchFailure uses printStackTrace) so the next run shows the actual exception. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The emulator logs verbosely enough that a 4000-line tail covers ~30s and missed the entire configure window, so the debug run still showed no SDK output. Clear logcat per run and dump tag-filtered (SWPreloadBenchmark/System.out/System.err/TestRunner) over the whole per-run buffer instead. Also bump the AVD cache key to v2 — AVDs cached before the -dns-server change fail snapshot load and force a slow second cold boot with an adb-offline loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…explicit status
The debug trace showed the real blocker: CI emulators have no
signed-in Google account, so Play Billing reports BILLING_UNAVAILABLE
and never connects ('Billing client not ready' retry loop), stalling
configuration and leaving entitlement status unknown. The benchmark
now configures with a stub external PurchaseController and sets
SubscriptionStatus.Inactive immediately, taking the billing subsystem
out of the measured path entirely — the benchmark's subject is paywall
preloading, not Play handshakes on an accountless emulator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…ound The complete SDK trace showed guest HTTPS working (collector requests succeed) while no request to api.superwall.me was ever issued. Network.getConfig starts with awaitUntilAppInForeground() — with no resumed activity in the instrumented process, the config fetch suspends indefinitely, which is the true cause of the Pending hangs (production apps are always foregrounded when configuring, so this never surfaces there). Launch MainActivity via ActivityScenario for the duration of the benchmark; its onCreate doesn't touch Superwall.instance, so launching before configure is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The full pipeline now works end-to-end (config fetched, preload ran, paywalls reached Ready) but the embedded key has 47 paywalls: the low tier got 4 Ready in ~16s then stalled for nearly 5 minutes under the concurrent-WebView load and hit the per-iteration timeout. Preloading everything 6x per tier can never fit 15 minutes on a 2-core emulator, and it couples the metric to unrelated dashboard campaign edits. Preload the placements pinned in .benchmark/config.json (default campaign_trigger) via the public preloadPaywalls() API instead — same SDK code path and Ready criterion, stable workload. Placements are recorded in the result JSON; set placements to [] to measure preloadAllPaywalls(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
campaign_trigger is not a placement on CONSTANT_API_KEY (preload started with paywall_count=0 and failed fast; configure itself now completes in ~3.6s). Use present_data, present_products and present_urls — placements this key actually has, kept stable by the repo's own UI tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
First real preload run: configure 2.8s, 3 paywalls resolved, first Ready in 3.3s — then the process died silently ~40s in with 'Process crashed' and no AndroidRuntime trace, which is the signature of MainApplication's VmPolicy penaltyDeath (detectActivityLeaks) that is installed unconditionally in onCreate. The benchmark now resets StrictMode policies at start (the app's leak watchdog is not the subject under measurement), and the failure logcat dump includes StrictMode/DEBUG/CRASH/libc tags so any further process death is attributable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…er SDK-accurate RAM The process died ~35s in on every tier with no crash trace in any runtime tag — a system kill. With config now working and an activity foregrounded, the key's implicit placements (app_launch/on_start/...) present real paywalls over MainActivity mid-benchmark: main-thread WebView work on a throttled emulator that can ANR-kill the process. Set subscription status active with entitlements so CHECK_USER_SUBSCRIPTION presentations skip (the preload path doesn't gate on subscription). LOW tier gets 3072M — per the SDK's own DeviceClassifier docs LOW is 3-4GB, and 2048M models ULTRA_LOW, which OOMs webview-heavy workloads. AVD cache bumped (snapshots don't survive RAM changes) and the failure dump now includes ActivityManager/Zygote for kill attribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The ActivityManager trace finally named the killer: ~40s after boot the Play Store self-updates (installPackageLI), the install force-stops Google Play Services, and the OS kills our app because it holds a GMS FontsProvider connection (appcompat downloadable fonts) — 'Killing com.superwall.superapp (adj 0): depends on provider com.google.android.gms/.fonts.provider.FontsProvider in dying proc'. Same timing on every tier because it's a timed background event, not load. Disable com.android.vending for the benchmark session (the image remains a Play Store device; billing is already bypassed via the external purchase controller). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Disabling the Play Store didn't stop GMS from restarting (~30s after app start, same as before — self-update/accountless checkin), and the OS kept killing the benchmark app for holding a dying provider connection. Cut the dependency at the source instead: emoji2's auto-initializer establishes the FontsProvider connection, so the debug manifest removes it (system emoji font is used; release builds unchanged, and paywall rendering happens in WebView which doesn't use GMS fonts). The vending disable is reverted — it destabilizes GMS and the emulator should remain a genuine Play Store device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Removing emoji2's startup initializer was not enough: appcompat lazily configures EmojiCompat when any AppCompatTextView inflates, opening the same GMS FontsProvider connection, and MainActivity is full of appcompat/Compose text — the kill recurred identically. The benchmark now launches a plain android.app.Activity declared in the test APK: it satisfies the SDK's foreground gate while touching no appcompat and no GMS, so Play services churn can no longer kill the process. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
startActivitySync refuses activities declared in the instrumentation
APK ('Intent resolved to different process com.superwall.superapp.test'),
so the bare activity now lives in app/src/debug (debug-only, absent
from release) and launches into the app's process.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…kill The FontsProvider connection survives every in-app mitigation because Chromium WebView queries GMS downloadable fonts from inside the embedding app's process — unavoidable on a production Play image. GMS persistent reliably restarts during the first ~3 minutes after boot on accountless devices, killing the app mid-run. Two defenses: wait until GMS persistent has kept the same pid for 45s (max 4 min) before measuring, and retry a run exactly once when the instrumentation reports 'Process crashed' without a test failure (external kill); genuine test failures are never retried. Verified both paths with a stubbed dry run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The GMS stability gate worked (no external kill this time) but two of the three pinned paywalls never reached Ready: their product fetches fail (Play Billing is permanently unavailable on accountless CI emulators) and the SDK by design does not finish preloading a paywall whose products fail to load. Pin present_urls + no_products — stable UI-test placements whose paywalls reference no products — and drop the per-iteration timeout to 180s so a stall costs 3 minutes, not 5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The measurement itself finally completed — 'all 2 paywalls Ready in 5531ms' on mid — and the remaining crash was between iterations: after Superwall.teardown() a stray SDK coroutine touches Superwall.instance and the uncaught IllegalStateException kills the process before results are written. Make runs the sample unit instead: iterations=1 (no teardown ever — each am-instrument run is already cold via pm clear) and runsPerEmulator 3->4 to keep 4 samples per tier. Teardown still exists between iterations for local use but is skipped after the last one so results always get persisted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
66d2818 to
d0b07bb
Compare
-PbenchmarkTestMode=true (passed by the workflow's build-apks job) bakes BuildConfig.BENCHMARK_TEST_MODE into the app; when set, the benchmark configures the SDK with TestModeBehavior.ALWAYS. On top of the rebased BILLING_UNAVAILABLE test-mode fix this makes product fetches non-fatal on CI emulators (no Play account), paving the way to benchmark product-bearing paywalls. Defaults to false for every other build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
With TestModeBehavior.ALWAYS active (and the rebased BILLING_UNAVAILABLE fix underneath), product fetches are non-fatal on CI emulators, so the pinned set grows back to four placements: present_urls, no_products, present_data, present_products — covering both product-less and product-bearing paywalls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Adds present_video, present_localized, present_free_trial, present_data_v4, scroll_test and price_readout to the existing four — covering video, localized, free-trial, long-scroll and price-heavy paywalls alongside the product-less and product-bearing ones. Known traps stay excluded: no_paywalljs never reaches Ready by design, holdout placements resolve to no paywall, incorrect_product_identifier is deliberately broken. The SDK dedupes placements sharing a paywall, so the report's paywall count is the source of truth for set size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Ten new placements (surveys, state/style/theme tests, subscription variants, consumable, non-recurring) join the existing ten. Ten cold runs per tier give the statistics room to settle at this workload size; per-iteration timeout rises to 240s and the tier job cap to 40 minutes to accommodate the larger set on the low tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
At 20 placements (~25 paywalls) the concurrent WebView load saturated every tier: HIGH measured slower than MID, CVs hit 39-49%, and runner noise drowned the hardware signal the delta gate exists to protect. The 10-paywall set kept tier ordering monotonic with markedly lower variance. Keeps 10 cold runs per tier and the enlarged timeouts as headroom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Deltas derived from measured spread of the 10x10 configuration (CV: mid 9.5%, high 17.6%, low 38.3% -> ~3-sigma on run-to-run mean comparisons): low 30%, mid 15%, high 20%, default 20%. The gated metric switches to medianMs since low/high means are dragged by outlier runs the median shrugs off. Baselines now update automatically on every push to main/develop, so release PRs always compare against the latest mainline numbers; manual update_baseline dispatch remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Feature PRs into develop no longer run the benchmark (the label-gated opt-in is removed); release PRs into main, merges to main/develop (which roll baselines forward) and manual dispatch remain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes in this pull request
PaywallPreloadBenchmark, an instrumented test in:appthat measures the time fromSuperwall.preloadAllPaywalls()(using the dev app's embeddedCONSTANT_API_KEY) until every paywall announced bypaywallPreload_startreachesPaywallLoadingState.Ready. Runs 5 iterations (cold + warm), records per-paywall ready times and mean/median/stddev/CV, and writes JSON results to the device's shared Downloads folder for CI to pull..github/workflows/preload-benchmark.yml, which runs on release PRs (PRs intomain), merges tomain/develop, andworkflow_dispatch. It benchmarks on 3 emulators in parallel for LOW/MID/HIGH device tiers — all pinned to the same Play Store image (pixel_6, API 34,google_apis_playstore, x86_64) with only cores/RAM differing (2/2GB, 3/4GB, 4/6GB), a 5s settle after boot, and the same KVM/free-disk/AVD-snapshot-cache setup aspr-tests.yml.scripts/benchmark_compare.py, which aggregates the tier results into.benchmark/results/+.benchmark/REPORT.md(committed back to the branch and mirrored to the job summary) and fails the workflow when a tier's mean regresses more than the delta limit — default 10% in.benchmark/config.json, per-tier overrides supported..benchmark/baseline/<tier>.jsonand are recorded by dispatching the workflow withupdate_baseline=true; until then runs are informational. Each result also records the SDK's ownDeviceClassifiertier so the emulator configs can be validated against real tier classification and tuned.:app:pullBenchmarkResults/:app:clearBenchmarkResultsGradle tasks for local runs (docs in.benchmark/README.md).Checklist
:app:compileDebugAndroidTestKotlinpasses. The workflow's first live run on a release PR will exercise it end-to-end.)CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes. (CI/tooling only — no SDK behavior change.)ktlintin the main directory and fixed any issues. (ktlint isn't wired into this Gradle build; new code follows the repo's.editorconfigstyle.).benchmark/README.md.)