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); });