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
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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" : ""}.`);
|
||||
Reference in New Issue
Block a user