| new file mode 100755 |
| index 0000000..73f0729 |
| --- /dev/null |
| +++ b/ios/asc-testflight.mjs |
| @@ -0,0 +1,193 @@ |
| +#!/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 <marketing-version> <build-number> [--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 <marketing-version> <build-number> [--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); |
| + 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`); |
| +console.log(" Internal testers have it now; external testers once Apple's review clears."); |