#!/usr/bin/env node // 🤖 The App Store Connect half of an iPhone release: everything that happens // AFTER ios/release-ios.sh has uploaded the archive. Split out of the shell // script because all of it is REST + JSON, and because a failed upload and a // failed group assignment want different retries — re-run this alone (same // version + build) and it picks up where it left off; every step is idempotent. // // Usage: node ios/asc-testflight.mjs [--external] // // Auth is the same App Store Connect API key the rest of the release uses: // LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID // Optional: LCHAT_RELEASE_NOTES_MD (becomes "What to Test"), // LCHAT_ASC_APP_ID (default: the Eleven Messenger app record). import { createSign } from "node:crypto"; import { readFileSync } from "node:fs"; const [version, build, ...flags] = process.argv.slice(2); if (!version || !build) { console.error("usage: asc-testflight.mjs [--external]"); process.exit(2); } const external = flags.includes("--external"); const APP_ID = process.env.LCHAT_ASC_APP_ID || "6787060127"; const KEY_ID = need("LCHAT_ASC_KEY_ID"); const ISSUER = need("LCHAT_ASC_ISSUER_ID"); const KEY = readFileSync(need("LCHAT_ASC_KEY_PATH"), "utf8"); function need(name) { const v = process.env[name]; if (!v) { console.error(`error: ${name} is required`); process.exit(2); } return v; } // ── auth ──────────────────────────────────────────────────────────────────── // ASC wants an ES256 JWT. Node signs ECDSA in DER; JOSE wants raw r‖s, so the // signature is unpacked by hand — no dependency, no build step (CLAUDE.md). function token() { const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); const now = Math.floor(Date.now() / 1000); const head = b64({ alg: "ES256", kid: KEY_ID, typ: "JWT" }); const body = b64({ iss: ISSUER, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" }); const signer = createSign("SHA256"); signer.update(`${head}.${body}`); signer.end(); const der = signer.sign(KEY); let i = 2; if (der[1] & 0x80) i = 2 + (der[1] & 0x7f); // long-form length const rLen = der[i + 1]; const r = der.subarray(i + 2, i + 2 + rLen); const j = i + 2 + rLen; const s = der.subarray(j + 2, j + 2 + der[j + 1]); const pad = (b) => { const t = b[0] === 0 ? b.subarray(1) : b; // strip DER sign byte return Buffer.concat([Buffer.alloc(32 - t.length), t]); }; return `${head}.${body}.${Buffer.concat([pad(r), pad(s)]).toString("base64url")}`; } let JWT = token(); async function api(path, { method = "GET", body } = {}) { const res = await fetch("https://api.appstoreconnect.apple.com" + path, { method, headers: { Authorization: `Bearer ${JWT}`, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); const json = text ? JSON.parse(text) : null; if (res.status >= 400) { const detail = (json?.errors || []).map((e) => `${e.title}: ${e.detail}`).join("; "); throw new Error(`${method} ${path} → ${res.status} ${detail || text}`); } return json; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ── 1. wait for the upload to finish processing ───────────────────────────── // Apple's processing runs minutes, occasionally much longer; the build simply // isn't addressable by the API until it lands, so a missing build early on is // normal rather than an error. async function waitForBuild() { const deadline = Date.now() + 45 * 60 * 1000; let announced = false; while (Date.now() < deadline) { JWT = token(); const q = `/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${version}` + `&filter[version]=${build}&limit=1`; const found = (await api(q)).data?.[0]; if (found) { const state = found.attributes.processingState; if (state === "VALID") return found; if (state === "FAILED" || state === "INVALID") { throw new Error(`build ${version} (${build}) came back ${state} — check the email from Apple`); } if (!announced) { console.log(` build found, ${state}…`); announced = true; } } else if (!announced) { console.log(" waiting for Apple to accept the upload…"); announced = true; } await sleep(30_000); } throw new Error(`timed out waiting for ${version} (${build}) to process`); } async function groupNamed(name) { const groups = await api(`/v1/apps/${APP_ID}/betaGroups?limit=50`); const g = groups.data.find((x) => x.attributes.name === name); if (!g) throw new Error(`no beta group named "${name}" on this app`); return g; } // ── main ──────────────────────────────────────────────────────────────────── console.log(`App Store Connect: Eleven ${version} (${build})`); const built = await waitForBuild(); console.log(`✓ processed — build id ${built.id}`); // 2. Export compliance. Standard OS crypto only, so the app is exempt; without // this answer the build sits un-distributable and testers never see it. if (built.attributes.usesNonExemptEncryption !== false) { await api(`/v1/builds/${built.id}`, { method: "PATCH", body: { data: { type: "builds", id: built.id, attributes: { usesNonExemptEncryption: false } } }, }); console.log("✓ export compliance declared (exempt — OS-provided crypto)"); } // 3. "What to Test" — the same note members read in the changelog, so a tester // opening TestFlight sees what a member would see. const notesPath = process.env.LCHAT_RELEASE_NOTES_MD; if (notesPath) { const whatsNew = readFileSync(notesPath, "utf8").replace(/^🤖\s*/, "").trim().slice(0, 4000); const locales = await api(`/v1/builds/${built.id}/betaBuildLocalizations`); const existing = locales.data.find((l) => l.attributes.locale === "en-US"); if (existing) { await api(`/v1/betaBuildLocalizations/${existing.id}`, { method: "PATCH", body: { data: { type: "betaBuildLocalizations", id: existing.id, attributes: { whatsNew } } }, }); } else { await api("/v1/betaBuildLocalizations", { method: "POST", body: { data: { type: "betaBuildLocalizations", attributes: { locale: "en-US", whatsNew }, relationships: { build: { data: { type: "builds", id: built.id } } }, }, }, }); } console.log("✓ what-to-test written"); } // 4. Tester groups. Internal testers get it immediately; the external group is // what puts the build in front of Apple's beta review. for (const name of external ? ["Internal", "External Testers"] : ["Internal"]) { const group = await groupNamed(name); // A group with access to all builds (our "Internal") already has this one — // ASC refuses an explicit assignment with a 422. if (group.attributes.hasAccessToAllBuilds) { console.log(`✓ "${name}" has access to all builds — nothing to add`); continue; } await api(`/v1/betaGroups/${group.id}/relationships/builds`, { method: "POST", body: { data: [{ type: "builds", id: built.id }] }, }); console.log(`✓ added to "${name}"`); } // 5. External review. The notes Apple reads live on the APP, not the build, so // they carry over from the last submission — print them, because stale notes // (a dead invite link, a sign-in screen that has since changed) are the usual // cause of a beta rejection. if (external) { const detail = await api(`/v1/apps/${APP_ID}/betaAppReviewDetail`); console.log("\n── review notes Apple will read (app-level, unchanged by this script) ──"); console.log(detail.data.attributes.notes); console.log("──────────────────────────────────────────────────────────────────────\n"); const submissions = await api(`/v1/betaAppReviewSubmissions?filter[build]=${built.id}`); if (submissions.data.length) { console.log(`✓ already submitted for review (${submissions.data[0].attributes.betaReviewState})`); } else { const sub = await api("/v1/betaAppReviewSubmissions", { method: "POST", body: { data: { type: "betaAppReviewSubmissions", relationships: { build: { data: { type: "builds", id: built.id } } }, }, }, }); console.log(`✓ submitted for external review (${sub.data.attributes.betaReviewState})`); } } console.log(`\n✅ Eleven ${version} (${build}) is on TestFlight`); // Say what actually happened. Without --external nothing is submitted for // review, and claiming external testers will get it sends the next operator // looking for a review that was never filed (0.9.6, a Mac-only release minted // on iOS purely for version parity). console.log(external ? " Internal testers have it now; external testers once Apple's review clears." : " Internal testers only — no external review was submitted (pass --external for that).");