Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f440840b |
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user