Files
pleno-vue/scripts/mobile/check-store-upload-env.mjs
T
Jeppe B 88eda43560 Automate signed iOS App Store releases (#192)
## What changed

- adds production iOS identity, localized storefront metadata, native
privacy declarations, App Store-safe artwork, and account-deletion UX
- mirrors the live Danish Google Play title, short description, and long
description in the App Store metadata source
- generates Android launcher/store icons from the opaque iOS marketing
master so both platforms use the same white background
- adds guarded GitHub Actions workflows for storefront readiness,
credential health, signed TestFlight uploads, and App Store candidate
preparation
- adds pinned Fastlane configuration with a committed dependency lock,
release manifest tooling, and an operational App Store runbook
- preserves the upstream iOS safe-area implementation while retaining
opaque App Store icon assets

## Why

The repository previously supported development-signed device bundles
but had no production App Store identity, reproducible storefront source
of truth, or protected signed-release pipeline. Apple also requires
in-app account deletion for apps that support account creation. The
Android icon master was transparent, which rendered as black on dark
store/device surfaces.

## Impact

Automation remains fail-closed behind
`APP_STORE_AUTOMATION_ENABLED=false`. No build can upload to TestFlight
or change App Store metadata until the switch is deliberately enabled
after merge and the remaining release gates are satisfied.

## Validation

- focused App Store, iOS icon, and cross-platform icon-background tests
pass
- every generated Android store/launcher icon is opaque with pure-white
corners; iOS marketing artwork is checked the same way
- Android icon drift check passes for all 19 generated files
- production Vite build and the broader focused release checks completed
successfully
- storefront metadata is valid; only the two expected screenshot-set
warnings remain
- App Store Readiness is green at head `4445fecc`
- Apple Distribution certificate and App Store profile were
independently verified for `HP3FJ4GVL7.io.truckwash.app`
- live App Store Connect API authentication succeeded for app
`6792777794`
- App Store record, free Denmark-only availability, and automatic
`Internal QA` TestFlight group are configured
- EU trader status, Content Rights, 4+ age rating, and the published App
Privacy label are completed in App Store Connect
- iPhone and iPad accessibility declarations are configured honestly as
pre-release drafts

## Remaining external gates

- reviewed iPhone and iPad screenshot sets are still required
- an App Review login must be supplied without creating or exposing
customer credentials
- the first signed TestFlight candidate must run after merge and
deliberate automation enablement
2026-07-20 17:59:43 +02:00

169 lines
5.3 KiB
JavaScript

import { existsSync } from "node:fs";
import { argv, env, exit } from "node:process";
const args = new Set(argv.slice(2));
const failures = [];
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const requireOneVariable = (names, label) => {
if (!names.some((name) => env[name])) {
failures.push(`Missing ${label}: set one of ${names.join(", ")}`);
}
};
const decodeBase64 = (name) => {
if (!env[name]) {
return null;
}
try {
const encoded = env[name].replace(/\s/g, "");
if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
failures.push(`${name} is not valid base64`);
return null;
}
const decoded = Buffer.from(encoded, "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
if (decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
failures.push(`${name} is not canonical base64`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
return null;
}
};
const decodeBase64Json = (name) => {
const decoded = decodeBase64(name);
if (!decoded) {
return null;
}
try {
return JSON.parse(decoded.toString("utf8"));
} catch {
failures.push(`${name} is not base64-encoded JSON`);
return null;
}
};
const isEnabled = (name) => !["false", "0", "no"].includes(String(env[name] ?? "true").toLowerCase());
const checkAndroid = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireOneVariable(["ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_FILE"], "Android release keystore");
requireVariable("ANDROID_KEYSTORE_PASSWORD");
requireVariable("ANDROID_KEY_ALIAS");
requireVariable("ANDROID_KEY_PASSWORD");
if (env.ANDROID_KEYSTORE_FILE && !existsSync(env.ANDROID_KEYSTORE_FILE)) {
failures.push("ANDROID_KEYSTORE_FILE does not point to an existing file");
}
decodeBase64("ANDROID_KEYSTORE_BASE64");
if (!isEnabled("UPLOAD_ANDROID_TO_PLAY")) {
return;
}
requireVariable("ANDROID_PACKAGE_NAME");
requireVariable("ANDROID_AAB_PATH");
requireVariable("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
}
if (!validStatuses.has(status)) {
failures.push(`PLAY_STORE_RELEASE_STATUS must be one of ${Array.from(validStatuses).join(", ")}`);
}
if (status === "inProgress") {
const fraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(fraction > 0 && fraction < 1)) {
failures.push("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress");
}
}
const serviceAccount = decodeBase64Json("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
if (serviceAccount) {
if (!serviceAccount.client_email) {
failures.push("Google Play service account JSON is missing client_email");
}
if (!serviceAccount.private_key) {
failures.push("Google Play service account JSON is missing private_key");
}
}
};
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_PASSWORD");
requireVariable("IOS_APP_STORE_PROFILE_BASE64");
requireVariable("APPLE_TEAM_ID");
requireVariable("IOS_BUNDLE_ID");
if (env.MOBILE_VERSION_NAME && !/^\d+\.\d+\.\d+$/.test(env.MOBILE_VERSION_NAME)) {
failures.push("MOBILE_VERSION_NAME must be numeric SemVer (X.Y.Z)");
}
if (env.MOBILE_VERSION_CODE && !/^[1-9][0-9]*$/.test(env.MOBILE_VERSION_CODE)) {
failures.push("MOBILE_VERSION_CODE must be a positive integer");
}
if (env.APPLE_TEAM_ID && !/^[A-Z0-9]{10}$/.test(env.APPLE_TEAM_ID)) {
failures.push("APPLE_TEAM_ID must be a 10-character Apple team identifier");
}
decodeBase64("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
decodeBase64("IOS_APP_STORE_PROFILE_BASE64");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_APP_ID");
requireVariable("TESTFLIGHT_INTERNAL_GROUP_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
if (privateKey && !privateKey.toString("utf8").includes("PRIVATE KEY")) {
failures.push("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 does not look like a .p8 private key");
}
};
if (args.has("--android")) {
checkAndroid();
}
if (args.has("--ios")) {
checkIos();
}
if (!args.has("--android") && !args.has("--ios")) {
failures.push("Pass --android or --ios");
}
if (failures.length > 0) {
console.error("Mobile store upload environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("Mobile store upload environment is configured.");