## 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
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
|
import { appendFileSync } from "node:fs";
|
|
import { argv, env, exit } from "node:process";
|
|
|
|
const command = argv[2];
|
|
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
|
|
const required = (name) => {
|
|
const value = env[name];
|
|
if (!value) throw new Error(`Missing ${name}`);
|
|
return value;
|
|
};
|
|
const base64url = (value) => Buffer.from(value).toString("base64url");
|
|
|
|
const token = () => {
|
|
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
|
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
|
if (!key.includes("PRIVATE KEY"))
|
|
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
|
|
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
|
|
else payload.sub = "user";
|
|
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
|
const encodedPayload = base64url(JSON.stringify(payload));
|
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
const signature = sign("sha256", Buffer.from(signingInput), {
|
|
key: createPrivateKey(key),
|
|
dsaEncoding: "ieee-p1363",
|
|
});
|
|
return `${signingInput}.${base64url(signature)}`;
|
|
};
|
|
|
|
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
|
|
const request = async (path, options = {}, attempt = 1) => {
|
|
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
|
|
...options,
|
|
headers: {
|
|
Authorization: `Bearer ${token()}`,
|
|
"Content-Type": "application/json",
|
|
...(options.headers ?? {}),
|
|
},
|
|
});
|
|
const text = await response.text();
|
|
let body = null;
|
|
try {
|
|
body = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
body = { raw: text };
|
|
}
|
|
if (!response.ok) {
|
|
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
|
|
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
|
|
return request(path, options, attempt + 1);
|
|
}
|
|
const detail =
|
|
body?.errors
|
|
?.map((error) => error.detail || error.title)
|
|
.filter(Boolean)
|
|
.join("; ") ||
|
|
body?.raw ||
|
|
response.statusText;
|
|
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
|
|
}
|
|
return body;
|
|
};
|
|
|
|
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
|
const bundleId = () => env.IOS_BUNDLE_ID || "io.truckwash.app";
|
|
const version = () => required("IOS_MARKETING_VERSION");
|
|
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
|
|
|
const writeOutput = (key, value) => {
|
|
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|
else console.log(`${key}=${value}`);
|
|
};
|
|
|
|
const verifyCredentials = async () => {
|
|
const app = await request(`/apps/${encodeURIComponent(appId())}`);
|
|
const actualBundleId = app?.data?.attributes?.bundleId;
|
|
if (actualBundleId !== bundleId()) {
|
|
throw new Error(
|
|
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
|
|
);
|
|
}
|
|
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
|
};
|
|
|
|
const allBuildsForVersion = async () => {
|
|
const params = new URLSearchParams({
|
|
"filter[app]": appId(),
|
|
"filter[preReleaseVersion.version]": version(),
|
|
limit: "200",
|
|
});
|
|
let url = `${baseUrl}/builds?${params}`;
|
|
const builds = [];
|
|
while (url) {
|
|
const page = await request(url);
|
|
builds.push(...(page?.data ?? []));
|
|
url = page?.links?.next ?? null;
|
|
}
|
|
return builds;
|
|
};
|
|
|
|
const findExactBuild = async () => {
|
|
const builds = await allBuildsForVersion();
|
|
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
|
|
};
|
|
|
|
const nextBuildNumber = async () => {
|
|
await verifyCredentials();
|
|
const storeVersionParams = new URLSearchParams({
|
|
"filter[app]": appId(),
|
|
"filter[platform]": "IOS",
|
|
"filter[versionString]": version(),
|
|
limit: "10",
|
|
});
|
|
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
|
|
const storeVersion = (storeVersions?.data ?? []).find(
|
|
(candidate) => candidate?.attributes?.versionString === version()
|
|
);
|
|
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
|
|
throw new Error(
|
|
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
|
|
);
|
|
}
|
|
const builds = await allBuildsForVersion();
|
|
const numbers = builds
|
|
.map((build) => Number.parseInt(build?.attributes?.version, 10))
|
|
.filter((number) => Number.isSafeInteger(number) && number > 0);
|
|
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
|
writeOutput("build_number", next);
|
|
console.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
|
};
|
|
|
|
const waitForBuild = async () => {
|
|
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
|
let build = null;
|
|
while (Date.now() < deadline) {
|
|
build = await findExactBuild();
|
|
const state = build?.attributes?.processingState;
|
|
if (state === "VALID") return build;
|
|
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
|
|
console.log(
|
|
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
|
);
|
|
await sleep(30_000);
|
|
}
|
|
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
|
};
|
|
|
|
const waitAndDistribute = async () => {
|
|
const build = await waitForBuild();
|
|
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
|
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
|
|
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
|
const existingLocalization = (localizations?.data ?? [])[0];
|
|
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
|
if (existingLocalization) {
|
|
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
|
|
}),
|
|
});
|
|
} else {
|
|
await request("/betaBuildLocalizations", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
data: {
|
|
type: "betaBuildLocalizations",
|
|
attributes: { locale: "da-DK", whatsNew },
|
|
relationships: { build: { data: { type: "builds", id: build.id } } },
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
|
|
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
|
|
if (!alreadyAssigned) {
|
|
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
|
|
});
|
|
}
|
|
writeOutput("app_store_build_id", build.id);
|
|
console.log(
|
|
`${
|
|
alreadyAssigned ? "Verified" : "Assigned"
|
|
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
|
);
|
|
};
|
|
|
|
const verifyCandidate = async () => {
|
|
await verifyCredentials();
|
|
const build = await findExactBuild();
|
|
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
|
|
if (build.attributes?.processingState !== "VALID") {
|
|
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
|
|
}
|
|
if (env.EXPECTED_APP_STORE_BUILD_ID && build.id !== env.EXPECTED_APP_STORE_BUILD_ID) {
|
|
throw new Error(
|
|
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
|
|
);
|
|
}
|
|
writeOutput("app_store_build_id", build.id);
|
|
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
|
};
|
|
|
|
const verifyStoreVersion = async () => {
|
|
const params = new URLSearchParams({
|
|
"filter[app]": appId(),
|
|
"filter[platform]": "IOS",
|
|
"filter[versionString]": version(),
|
|
include: "build",
|
|
limit: "10",
|
|
});
|
|
const response = await request(`/appStoreVersions?${params}`);
|
|
const storeVersion = (response?.data ?? []).find((candidate) => candidate?.attributes?.versionString === version());
|
|
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
|
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
|
|
const includedBuild = (response?.included ?? []).find(
|
|
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
|
|
);
|
|
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
|
|
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
|
|
}
|
|
writeOutput("app_store_version_id", storeVersion.id);
|
|
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
|
|
console.log(
|
|
`Verified App Store version ${version()} with exact build ${buildNumber()} in ${
|
|
storeVersion.attributes?.appStoreState || "unknown state"
|
|
}.`
|
|
);
|
|
};
|
|
|
|
const selfTestJwt = async () => {
|
|
const original = {
|
|
keyId: env.APP_STORE_CONNECT_API_KEY_ID,
|
|
issuer: env.APP_STORE_CONNECT_ISSUER_ID,
|
|
key: env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64,
|
|
};
|
|
try {
|
|
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
|
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
|
privateKey.export({ type: "pkcs8", format: "pem" })
|
|
).toString("base64");
|
|
delete env.APP_STORE_CONNECT_ISSUER_ID;
|
|
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
|
if (individual.sub !== "user" || individual.iss !== undefined)
|
|
throw new Error("Individual API JWT claim test failed");
|
|
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
|
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
|
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
|
|
throw new Error("Team API JWT claim test failed");
|
|
console.log("App Store Connect individual and team JWT claim tests passed.");
|
|
} finally {
|
|
if (original.keyId === undefined) delete env.APP_STORE_CONNECT_API_KEY_ID;
|
|
else env.APP_STORE_CONNECT_API_KEY_ID = original.keyId;
|
|
if (original.issuer === undefined) delete env.APP_STORE_CONNECT_ISSUER_ID;
|
|
else env.APP_STORE_CONNECT_ISSUER_ID = original.issuer;
|
|
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
|
|
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
|
|
}
|
|
};
|
|
|
|
const commands = {
|
|
"verify-credentials": verifyCredentials,
|
|
"next-build-number": nextBuildNumber,
|
|
"wait-and-distribute": waitAndDistribute,
|
|
"verify-candidate": verifyCandidate,
|
|
"verify-store-version": verifyStoreVersion,
|
|
"self-test-jwt": selfTestJwt,
|
|
};
|
|
|
|
if (!commands[command]) {
|
|
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
|
exit(2);
|
|
}
|
|
|
|
commands[command]().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
exit(1);
|
|
});
|