From d2f440840b0a06a017d6bb9607aebab47a764f87 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 17 Aug 2026 14:00:29 +0000 Subject: [PATCH] ci(mobile): enforce Android targetSdk 36 in App Store readiness check (TRU-141) Google Play Store requires new apps and updates to target API 36 (Android 16, "Baklava") as of Aug 2026. The current Android configuration already meets the policy, but nothing prevented a regression. Add a CI guard that fails the App Store readiness job if targetSdkVersion, compileSdkVersion, or minSdkVersion drop below the Play Store floors. - scripts/mobile/verify-android-target-sdk.mjs: reads android/variables.gradle, parses min/compile/target SDK, fails if any are below the policy floors (24/36/36). - tests/node/verify-android-target-sdk.test.mjs: 7 unit tests covering happy path, each failure mode, missing keys, and non-integer values. - .github/workflows/app-store-readiness.yml: run the new check alongside the existing native mobile permission check, run the new unit tests, syntax-check both new files, and add android/** + the new test file to the path filter so PRs touching Android config trigger the workflow. Refs TRU-141. --- .github/workflows/app-store-readiness.yml | 10 ++ scripts/mobile/verify-android-target-sdk.mjs | 118 ++++++++++++++++ tests/node/verify-android-target-sdk.test.mjs | 133 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 scripts/mobile/verify-android-target-sdk.mjs create mode 100644 tests/node/verify-android-target-sdk.test.mjs diff --git a/.github/workflows/app-store-readiness.yml b/.github/workflows/app-store-readiness.yml index 88478eac..3611cf02 100644 --- a/.github/workflows/app-store-readiness.yml +++ b/.github/workflows/app-store-readiness.yml @@ -7,8 +7,10 @@ on: paths: - "fastlane/**" - "ios/**" + - "android/**" - "scripts/mobile/**" - "tests/node/app-store-connect.test.mjs" + - "tests/node/verify-android-target-sdk.test.mjs" - ".github/workflows/app-store-readiness.yml" - "Gemfile*" workflow_dispatch: @@ -73,9 +75,15 @@ jobs: - name: Validate native mobile permissions run: node scripts/mobile/check-permissions.mjs + - name: Verify Android target SDK meets Play Store policy (TRU-141) + run: node scripts/mobile/verify-android-target-sdk.mjs + - name: Test App Store Connect automation run: node --test tests/node/app-store-connect.test.mjs + - name: Test Android target SDK guard + run: node --test tests/node/verify-android-target-sdk.test.mjs + - name: Validate Fastlane configuration run: bundle exec fastlane lanes @@ -84,5 +92,7 @@ jobs: node --check scripts/mobile/validate-app-store.mjs node --check scripts/mobile/app-store-connect.mjs node --check scripts/mobile/create-ios-release-manifest.mjs + node --check scripts/mobile/verify-android-target-sdk.mjs node --check tests/node/app-store-connect.test.mjs + node --check tests/node/verify-android-target-sdk.test.mjs node scripts/mobile/app-store-connect.mjs self-test-jwt diff --git a/scripts/mobile/verify-android-target-sdk.mjs b/scripts/mobile/verify-android-target-sdk.mjs new file mode 100644 index 00000000..74b0a265 --- /dev/null +++ b/scripts/mobile/verify-android-target-sdk.mjs @@ -0,0 +1,118 @@ +// verify-android-target-sdk.mjs +// +// Verifies the Android build configuration meets the Google Play Store +// target-API policy. As of Aug 2026, new apps and updates MUST target +// API 36 (Android 16, "Baklava") or newer, or the build is rejected +// at upload time with the warning: +// +// > Appen skal være målrettet mod Android 16 (API-niveau 36) eller nyere +// > Løs problemet inden den 31. aug. (om 14 dage) +// +// We enforce this in CI so a regression in android/variables.gradle +// can't reintroduce the Play Console warning on the next release. +// +// Tracking: TRU-141 ("Android: ensure app targets API 36 (Android 16) +// per Play Store policy"). +// +// Exit codes: +// 0 — all checks passed +// 1 — one or more checks failed +// 2 — the variables file could not be read +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { cwd, exit } from "node:process"; + +const projectRoot = cwd(); +const variablesPath = join(projectRoot, "android/variables.gradle"); + +// The Google Play Store target-API floor for new apps and updates. +// See: https://support.google.com/googleplay/android-developer/answer/11926878 +// 36 = Android 16 (Baklava). Bump in lockstep with the next Play +// Store deadline (typically one year after a new Android release). +const REQUIRED_TARGET_SDK = 36; +const REQUIRED_COMPILE_SDK = 36; +// minSdkVersion is not policy-mandated; 24 (Android 7.0) is the floor +// Play Store currently accepts and is well below the app's own target. +const MIN_ACCEPTABLE_MIN_SDK = 24; + +const SDK_NAMES = { + 24: "7.0 Nougat", + 25: "7.1 Nougat", + 26: "8.0 Oreo", + 27: "8.1 Oreo", + 28: "9.0 Pie", + 29: "10", + 30: "11", + 31: "12", + 32: "12L", + 33: "13", + 34: "14", + 35: "15", + 36: "16 (Baklava)", +}; + +const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`; + +if (!existsSync(variablesPath)) { + console.error(`Cannot find ${variablesPath}`); + exit(2); +} + +const text = readFileSync(variablesPath, "utf8"); + +const extract = (key) => { + // Matches `key = 36` (with optional whitespace) on its own line. + const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m")); + return match ? Number.parseInt(match[1], 10) : null; +}; + +const minSdk = extract("minSdkVersion"); +const compileSdk = extract("compileSdkVersion"); +const targetSdk = extract("targetSdkVersion"); + +const failures = []; + +if (minSdk === null) { + failures.push("minSdkVersion is missing or non-integer in android/variables.gradle"); +} else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) { + failures.push( + `minSdkVersion (${minSdk}, Android ${androidName(minSdk)}) is below Play Store floor of ${MIN_ACCEPTABLE_MIN_SDK}`, + ); +} + +if (compileSdk === null) { + failures.push("compileSdkVersion is missing or non-integer in android/variables.gradle"); +} else if (compileSdk < REQUIRED_COMPILE_SDK) { + failures.push( + `compileSdkVersion (${compileSdk}, Android ${androidName(compileSdk)}) is below the required ${REQUIRED_COMPILE_SDK} (Android ${androidName(REQUIRED_COMPILE_SDK)})`, + ); +} + +if (targetSdk === null) { + failures.push("targetSdkVersion is missing or non-integer in android/variables.gradle"); +} else if (targetSdk < REQUIRED_TARGET_SDK) { + failures.push( + `targetSdkVersion (${targetSdk}, Android ${androidName(targetSdk)}) is below Play Store requirement of ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`, + ); +} + +console.log("=== Android target SDK verification (TRU-141) ==="); +console.log(` Source: ${variablesPath}`); +console.log(` minSdkVersion: ${minSdk ?? "?"} (Android ${androidName(minSdk ?? 0)})`); +console.log(` compileSdkVersion: ${compileSdk ?? "?"} (Android ${androidName(compileSdk ?? 0)})`); +console.log(` targetSdkVersion: ${targetSdk ?? "?"} (Android ${androidName(targetSdk ?? 0)})`); +console.log(); +console.log(` Play Store target-API requirement: ${REQUIRED_TARGET_SDK} (Android ${androidName(REQUIRED_TARGET_SDK)})`); +console.log(); + +if (failures.length > 0) { + console.error("❌ Android target SDK verification FAILED:"); + for (const f of failures) console.error(` - ${f}`); + console.error(); + console.error(" Fix: edit android/variables.gradle and bump targetSdkVersion (and compileSdkVersion) to 36 or newer."); + console.error(" Then re-run this check."); + exit(1); +} + +console.log(`✅ targetSdkVersion (${targetSdk}) meets Play Store requirement (>= ${REQUIRED_TARGET_SDK})`); +console.log(`✅ compileSdkVersion (${compileSdk}) meets minimum (>= ${REQUIRED_COMPILE_SDK})`); diff --git a/tests/node/verify-android-target-sdk.test.mjs b/tests/node/verify-android-target-sdk.test.mjs new file mode 100644 index 00000000..1d98b1f7 --- /dev/null +++ b/tests/node/verify-android-target-sdk.test.mjs @@ -0,0 +1,133 @@ +// verify-android-target-sdk.test.mjs +// +// Unit tests for the Android target-SDK guard. We don't read the +// real android/variables.gradle from disk; we re-implement the +// extraction + validation logic in a way that lets us test the +// failure paths without rewriting the file. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +const REQUIRED_TARGET_SDK = 36; +const REQUIRED_COMPILE_SDK = 36; +const MIN_ACCEPTABLE_MIN_SDK = 24; + +const SDK_NAMES = { + 24: "7.0 Nougat", + 35: "15", + 36: "16 (Baklava)", +}; + +const androidName = (v) => SDK_NAMES[v] ?? `API ${v}`; + +const extract = (text, key) => { + const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m")); + return match ? Number.parseInt(match[1], 10) : null; +}; + +const validate = (text) => { + const minSdk = extract(text, "minSdkVersion"); + const compileSdk = extract(text, "compileSdkVersion"); + const targetSdk = extract(text, "targetSdkVersion"); + const failures = []; + if (minSdk === null) failures.push("minSdkVersion missing"); + else if (minSdk < MIN_ACCEPTABLE_MIN_SDK) failures.push("minSdk too low"); + if (compileSdk === null) failures.push("compileSdkVersion missing"); + else if (compileSdk < REQUIRED_COMPILE_SDK) failures.push("compileSdk too low"); + if (targetSdk === null) failures.push("targetSdkVersion missing"); + else if (targetSdk < REQUIRED_TARGET_SDK) failures.push("targetSdk too low"); + return { minSdk, compileSdk, targetSdk, failures }; +}; + +test("extracts the three SDK values from a typical variables.gradle", () => { + const text = ` +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 +} +`; + const { minSdk, compileSdk, targetSdk, failures } = validate(text); + assert.equal(minSdk, 24); + assert.equal(compileSdk, 36); + assert.equal(targetSdk, 36); + assert.deepEqual(failures, []); +}); + +test("flags targetSdkVersion below 36 as a failure", () => { + const text = ` +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 35 +} +`; + const { failures } = validate(text); + assert.ok( + failures.includes("targetSdk too low"), + `expected targetSdk failure, got: ${JSON.stringify(failures)}`, + ); +}); + +test("flags compileSdkVersion below 36 as a failure", () => { + const text = ` +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 36 +} +`; + const { failures } = validate(text); + assert.ok( + failures.includes("compileSdk too low"), + `expected compileSdk failure, got: ${JSON.stringify(failures)}`, + ); +}); + +test("flags minSdkVersion below 24 as a failure", () => { + const text = ` +ext { + minSdkVersion = 23 + compileSdkVersion = 36 + targetSdkVersion = 36 +} +`; + const { failures } = validate(text); + assert.ok( + failures.includes("minSdk too low"), + `expected minSdk failure, got: ${JSON.stringify(failures)}`, + ); +}); + +test("flags a missing key", () => { + const text = ` +ext { + minSdkVersion = 24 + compileSdkVersion = 36 +} +`; + const { failures } = validate(text); + assert.ok( + failures.includes("targetSdkVersion missing"), + `expected targetSdk missing failure, got: ${JSON.stringify(failures)}`, + ); +}); + +test("androidName handles known and unknown APIs", () => { + assert.equal(androidName(24), "7.0 Nougat"); + assert.equal(androidName(36), "16 (Baklava)"); + assert.equal(androidName(99), "API 99"); +}); + +test("rejects non-integer values (extract returns null)", () => { + const text = ` +ext { + minSdkVersion = 24 + compileSdkVersion = "36" + targetSdkVersion = 36 +} +`; + // Anchored to start of line + only digits; quoted values won't match. + const compileSdk = extract(text, "compileSdkVersion"); + assert.equal(compileSdk, null); +});