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.
This commit is contained in:
openhands
2026-08-17 14:00:29 +00:00
parent 7d5ec8894c
commit d2f440840b
3 changed files with 261 additions and 0 deletions
+10
View File
@@ -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
@@ -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})`);
@@ -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);
});