Files
pleno-vue/scripts/mobile/validate-app-store.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

166 lines
6.2 KiB
JavaScript

import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, extname, join, relative } from "node:path";
import { argv, exit } from "node:process";
const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
const readText = (path) => {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
return "";
}
const value = readFileSync(path, "utf8").trim();
if (!value) fail(`${relative(root, path)} must not be empty`);
return value;
};
let release;
try {
release = JSON.parse(readFileSync(join(root, "ios/release.json"), "utf8"));
} catch {
fail("ios/release.json must be valid JSON");
release = {};
}
if (!/^\d+\.\d+\.\d+$/.test(release.marketingVersion ?? "")) {
fail("ios/release.json marketingVersion must be numeric SemVer (X.Y.Z)");
}
if (release.bundleId !== "io.truckwash.app") fail("ios/release.json bundleId must be io.truckwash.app");
if (release.minimumIosVersion !== "15.0")
fail("ios/release.json minimumIosVersion must remain 15.0 unless compatibility is intentionally changed");
const name = readText(join(metadataRoot, "name.txt"));
const subtitle = readText(join(metadataRoot, "subtitle.txt"));
const promotionalText = readText(join(metadataRoot, "promotional_text.txt"));
const keywords = readText(join(metadataRoot, "keywords.txt"));
const description = readText(join(metadataRoot, "description.txt"));
readText(join(metadataRoot, "release_notes.txt"));
readText(join(root, "fastlane/metadata/copyright.txt"));
if ([...name].length > 30) fail("App Store name exceeds 30 characters");
if ([...subtitle].length > 30) fail("App Store subtitle exceeds 30 characters");
if ([...promotionalText].length > 170) fail("Promotional text exceeds 170 characters");
if (Buffer.byteLength(keywords, "utf8") > 100) fail("Keywords exceed Apple's 100-byte limit");
if ([...description].length > 4000) fail("Description exceeds 4,000 characters");
for (const file of ["support_url.txt", "privacy_url.txt", "marketing_url.txt"]) {
const value = readText(join(metadataRoot, file));
try {
const url = new URL(value);
if (url.protocol !== "https:") fail(`${file} must use HTTPS`);
} catch {
fail(`${file} must contain a valid URL`);
}
}
const knownCapacitorArtwork = new Set([
"29e4777e319de3ee5a52c3a8004ec19d0568414004257e36d7c94a077d71c93b",
"1b5002b74a5500e697298ced06ca2811ac33f2771f236f3c720ff23243890530",
]);
for (const path of [
join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"),
join(root, "ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png"),
]) {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
continue;
}
const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
if (knownCapacitorArtwork.has(digest)) {
fail(`${relative(root, path)} is still the default Capacitor artwork`);
}
}
const pngInfo = (buffer) => {
if (buffer.length < 33 || buffer.subarray(1, 4).toString("ascii") !== "PNG") return null;
const colorType = buffer[25];
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
hasAlpha: colorType === 4 || colorType === 6,
};
};
const jpegInfo = (buffer) => {
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === 0xd8 || marker === 0xd9) {
offset += 2;
continue;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > buffer.length) break;
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5), hasAlpha: false };
}
offset += 2 + length;
}
return null;
};
const screenshots = existsSync(screenshotRoot)
? readdirSync(screenshotRoot)
.filter((file) => [".png", ".jpg", ".jpeg"].includes(extname(file).toLowerCase()))
.map((file) => join(screenshotRoot, file))
: [];
const groups = { iphone: [], ipad: [] };
const hashes = new Map();
for (const path of screenshots) {
const filename = basename(path);
const group = filename.startsWith("iphone-6.9-") ? "iphone" : filename.startsWith("ipad-13-") ? "ipad" : null;
if (!group) {
fail(`${filename} must start with iphone-6.9- or ipad-13-`);
continue;
}
if (statSync(path).size === 0) {
fail(`${filename} is empty`);
continue;
}
const buffer = readFileSync(path);
const info = pngInfo(buffer) ?? jpegInfo(buffer);
if (!info) {
fail(`${filename} is not a readable PNG or JPEG`);
continue;
}
const expected = group === "iphone" ? [1320, 2868] : [2064, 2752];
if (info.width !== expected[0] || info.height !== expected[1]) {
fail(`${filename} is ${info.width}x${info.height}; expected ${expected[0]}x${expected[1]}`);
}
if (info.hasAlpha) fail(`${filename} has an alpha channel, which App Store screenshots must not use`);
const digest = createHash("sha256").update(buffer).digest("hex");
if (hashes.has(digest)) fail(`${filename} duplicates ${hashes.get(digest)}`);
hashes.set(digest, filename);
groups[group].push(filename);
}
for (const [group, files] of Object.entries(groups)) {
if (files.length !== 6) {
const message = `Expected 6 ${group === "iphone" ? "iPhone 6.9-inch" : "iPad 13-inch"} screenshots; found ${
files.length
}`;
if (strict) fail(message);
else warn(message);
}
}
for (const message of warnings) console.warn(`Storefront readiness warning: ${message}`);
if (failures.length > 0) {
console.error("App Store validation failed:");
for (const message of failures) console.error(`- ${message}`);
exit(1);
}
console.log(`App Store metadata is valid${strict ? " and candidate assets are complete" : ""}.`);