eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// πŸ€– The App Store Connect half of an iPhone release: everything that happens
3// AFTER ios/release-ios.sh has uploaded the archive. Split out of the shell
4// script because all of it is REST + JSON, and because a failed upload and a
5// failed group assignment want different retries β€” re-run this alone (same
6// version + build) and it picks up where it left off; every step is idempotent.
7//
8// Usage: node ios/asc-testflight.mjs <marketing-version> <build-number> [--external]
9//
10// Auth is the same App Store Connect API key the rest of the release uses:
11// LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID
12// Optional: LCHAT_RELEASE_NOTES_MD (becomes "What to Test"),
13// LCHAT_ASC_APP_ID (default: the Eleven Messenger app record).
14import { createSign } from "node:crypto";
15import { readFileSync } from "node:fs";
16
17const [version, build, ...flags] = process.argv.slice(2);
18if (!version || !build) {
19 console.error("usage: asc-testflight.mjs <marketing-version> <build-number> [--external]");
20 process.exit(2);
21}
22const external = flags.includes("--external");
23const APP_ID = process.env.LCHAT_ASC_APP_ID || "6787060127";
24const KEY_ID = need("LCHAT_ASC_KEY_ID");
25const ISSUER = need("LCHAT_ASC_ISSUER_ID");
26const KEY = readFileSync(need("LCHAT_ASC_KEY_PATH"), "utf8");
27
28function need(name) {
29 const v = process.env[name];
30 if (!v) { console.error(`error: ${name} is required`); process.exit(2); }
31 return v;
32}
33
34// ── auth ────────────────────────────────────────────────────────────────────
35// ASC wants an ES256 JWT. Node signs ECDSA in DER; JOSE wants raw rβ€–s, so the
36// signature is unpacked by hand β€” no dependency, no build step (CLAUDE.md).
37function token() {
38 const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
39 const now = Math.floor(Date.now() / 1000);
40 const head = b64({ alg: "ES256", kid: KEY_ID, typ: "JWT" });
41 const body = b64({ iss: ISSUER, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" });
42 const signer = createSign("SHA256");
43 signer.update(`${head}.${body}`);
44 signer.end();
45 const der = signer.sign(KEY);
46 let i = 2;
47 if (der[1] & 0x80) i = 2 + (der[1] & 0x7f); // long-form length
48 const rLen = der[i + 1];
49 const r = der.subarray(i + 2, i + 2 + rLen);
50 const j = i + 2 + rLen;
51 const s = der.subarray(j + 2, j + 2 + der[j + 1]);
52 const pad = (b) => {
53 const t = b[0] === 0 ? b.subarray(1) : b; // strip DER sign byte
54 return Buffer.concat([Buffer.alloc(32 - t.length), t]);
55 };
56 return `${head}.${body}.${Buffer.concat([pad(r), pad(s)]).toString("base64url")}`;
57}
58
59let JWT = token();
60async function api(path, { method = "GET", body } = {}) {
61 const res = await fetch("https://api.appstoreconnect.apple.com" + path, {
62 method,
63 headers: { Authorization: `Bearer ${JWT}`, "Content-Type": "application/json" },
64 body: body ? JSON.stringify(body) : undefined,
65 });
66 const text = await res.text();
67 const json = text ? JSON.parse(text) : null;
68 if (res.status >= 400) {
69 const detail = (json?.errors || []).map((e) => `${e.title}: ${e.detail}`).join("; ");
70 throw new Error(`${method} ${path} β†’ ${res.status} ${detail || text}`);
71 }
72 return json;
73}
74
75const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
77// ── 1. wait for the upload to finish processing ─────────────────────────────
78// Apple's processing runs minutes, occasionally much longer; the build simply
79// isn't addressable by the API until it lands, so a missing build early on is
80// normal rather than an error.
81async function waitForBuild() {
82 const deadline = Date.now() + 45 * 60 * 1000;
83 let announced = false;
84 while (Date.now() < deadline) {
85 JWT = token();
86 const q = `/v1/builds?filter[app]=${APP_ID}&filter[preReleaseVersion.version]=${version}` +
87 `&filter[version]=${build}&limit=1`;
88 const found = (await api(q)).data?.[0];
89 if (found) {
90 const state = found.attributes.processingState;
91 if (state === "VALID") return found;
92 if (state === "FAILED" || state === "INVALID") {
93 throw new Error(`build ${version} (${build}) came back ${state} β€” check the email from Apple`);
94 }
95 if (!announced) { console.log(` build found, ${state}…`); announced = true; }
96 } else if (!announced) {
97 console.log(" waiting for Apple to accept the upload…");
98 announced = true;
99 }
100 await sleep(30_000);
101 }
102 throw new Error(`timed out waiting for ${version} (${build}) to process`);
103}
104
105async function groupNamed(name) {
106 const groups = await api(`/v1/apps/${APP_ID}/betaGroups?limit=50`);
107 const g = groups.data.find((x) => x.attributes.name === name);
108 if (!g) throw new Error(`no beta group named "${name}" on this app`);
109 return g;
110}
111
112// ── main ────────────────────────────────────────────────────────────────────
113console.log(`App Store Connect: Eleven ${version} (${build})`);
114const built = await waitForBuild();
115console.log(`βœ“ processed β€” build id ${built.id}`);
116
117// 2. Export compliance. Standard OS crypto only, so the app is exempt; without
118// this answer the build sits un-distributable and testers never see it.
119if (built.attributes.usesNonExemptEncryption !== false) {
120 await api(`/v1/builds/${built.id}`, {
121 method: "PATCH",
122 body: { data: { type: "builds", id: built.id, attributes: { usesNonExemptEncryption: false } } },
123 });
124 console.log("βœ“ export compliance declared (exempt β€” OS-provided crypto)");
125}
126
127// 3. "What to Test" β€” the same note members read in the changelog, so a tester
128// opening TestFlight sees what a member would see.
129const notesPath = process.env.LCHAT_RELEASE_NOTES_MD;
130if (notesPath) {
131 const whatsNew = readFileSync(notesPath, "utf8").replace(/^πŸ€–\s*/, "").trim().slice(0, 4000);
132 const locales = await api(`/v1/builds/${built.id}/betaBuildLocalizations`);
133 const existing = locales.data.find((l) => l.attributes.locale === "en-US");
134 if (existing) {
135 await api(`/v1/betaBuildLocalizations/${existing.id}`, {
136 method: "PATCH",
137 body: { data: { type: "betaBuildLocalizations", id: existing.id, attributes: { whatsNew } } },
138 });
139 } else {
140 await api("/v1/betaBuildLocalizations", {
141 method: "POST",
142 body: {
143 data: {
144 type: "betaBuildLocalizations",
145 attributes: { locale: "en-US", whatsNew },
146 relationships: { build: { data: { type: "builds", id: built.id } } },
147 },
148 },
149 });
150 }
151 console.log("βœ“ what-to-test written");
152}
153
154// 4. Tester groups. Internal testers get it immediately; the external group is
155// what puts the build in front of Apple's beta review.
156for (const name of external ? ["Internal", "External Testers"] : ["Internal"]) {
157 const group = await groupNamed(name);
158 // A group with access to all builds (our "Internal") already has this one β€”
159 // ASC refuses an explicit assignment with a 422.
160 if (group.attributes.hasAccessToAllBuilds) {
161 console.log(`βœ“ "${name}" has access to all builds β€” nothing to add`);
162 continue;
163 }
164 await api(`/v1/betaGroups/${group.id}/relationships/builds`, {
165 method: "POST",
166 body: { data: [{ type: "builds", id: built.id }] },
167 });
168 console.log(`βœ“ added to "${name}"`);
169}
170
171// 5. External review. The notes Apple reads live on the APP, not the build, so
172// they carry over from the last submission β€” print them, because stale notes
173// (a dead invite link, a sign-in screen that has since changed) are the usual
174// cause of a beta rejection.
175if (external) {
176 const detail = await api(`/v1/apps/${APP_ID}/betaAppReviewDetail`);
177 console.log("\n── review notes Apple will read (app-level, unchanged by this script) ──");
178 console.log(detail.data.attributes.notes);
179 console.log("──────────────────────────────────────────────────────────────────────\n");
180
181 const submissions = await api(`/v1/betaAppReviewSubmissions?filter[build]=${built.id}`);
182 if (submissions.data.length) {
183 console.log(`βœ“ already submitted for review (${submissions.data[0].attributes.betaReviewState})`);
184 } else {
185 const sub = await api("/v1/betaAppReviewSubmissions", {
186 method: "POST",
187 body: {
188 data: {
189 type: "betaAppReviewSubmissions",
190 relationships: { build: { data: { type: "builds", id: built.id } } },
191 },
192 },
193 });
194 console.log(`βœ“ submitted for external review (${sub.data.attributes.betaReviewState})`);
195 }
196}
197
198console.log(`\nβœ… Eleven ${version} (${build}) is on TestFlight`);
199// Say what actually happened. Without --external nothing is submitted for
200// review, and claiming external testers will get it sends the next operator
201// looking for a review that was never filed (0.9.6, a Mac-only release minted
202// on iOS purely for version parity).
203console.log(external
204 ? " Internal testers have it now; external testers once Apple's review clears."
205 : " Internal testers only β€” no external review was submitted (pass --external for that).");
206