eleven / ocho Public

ios: mint native 0.9.0 β€” a committed TestFlight path, not a runbook recipe (#1441)

The Mac has had one release script since Sparkle landed; the iPhone's
TestFlight upload only ever existed as prose in the operator runbook, which
is exactly the hand-run cookbook CLAUDE.md closes off. This adds the missing
half β€” ios/release-ios.sh (archive + upload) and ios/asc-testflight.mjs (the
App Store Connect side: wait out processing, declare export compliance, seed
what-to-test, place the build with the tester groups, submit for beta review).

Every ASC step is idempotent, so a release that dies after the upload is
resumed by re-running the node script alone rather than rebuilding.

Also the changelog entry for the 0.9.0 build, held (per convention) until the
release is actually minted: it covers everything both apps gained since
0.8.1 β€” including the whole space-apps stack the Mac missed, because Mac
0.8.1 was cut about an hour before it landed.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 543cb88e4176515232ae7a47f73c7f7efa00c783 parent bb58a29
4 files changed, +315 −0
  • changelog/2026-08-20-native-0-9-0.md +15 −0
  • ios/README.md +23 −0
  • ios/asc-testflight.mjs +193 −0
  • ios/release-ios.sh +84 −0
diff --git a/changelog/2026-08-20-native-0-9-0.md b/changelog/2026-08-20-native-0-9-0.md
new file mode 100644
index 0000000..ab77283
--- /dev/null
+++ b/changelog/2026-08-20-native-0-9-0.md
@@ -0,0 +1,15 @@
+πŸ€– New iPhone and Mac apps (0.9.0). Messages you send now appear the instant
+you hit send, instead of waiting on the round trip to the server β€” they
+settle quietly once they're away, and tell you if they didn't. The Mac app
+catches up with the iPhone on your space's shared apps: a band above the
+chat list, their slash commands available in every chat, and their updates
+narrated properly in your conversations. @mentions on the Mac render as
+tappable chips too β€” click one to open that person's profile card. Two new
+Mac shortcuts: ⌘R replies to the latest message, ⌘T reacts to it. On the
+iPhone, the plus button in the composer now offers Camera, so you can take
+a photo without leaving the chat, and joining a space lands you in Eleven's
+welcome conversation rather than an empty room. Elsewhere: opening an app
+from App Studio takes you where that app actually lives, the chat list
+shows a trailing reaction ("reacted πŸ‘") the way it shows a message, and
+group previews name the right person. The Mac's About panel now shows the
+exact build it came from, which makes bug reports much easier to chase.
diff --git a/ios/README.md b/ios/README.md
index 47589c8..f726a73 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -183,6 +183,29 @@ To go live (needs a paid Apple Developer membership):
4. Dev builds register with `sandbox: true` (`#if DEBUG`) and are delivered via
Apple's sandbox gateway; TestFlight/App Store builds use production.
+## Releasing
+
+Both apps are one product and mint together, from the same version and build
+number, off `main` (never a feature branch β€” a shipped build must match the
+repo). Each has one committed script; neither takes a secret on argv:
+
+```sh
+export LCHAT_ASC_KEY_PATH=... LCHAT_ASC_KEY_ID=... LCHAT_ASC_ISSUER_ID=...
+export LCHAT_RELEASE_NOTES_MD=changelog/<the entry for this build>.md
+
+ios/release-ios.sh 0.9.0 1 --external # archive -> TestFlight -> review
+LCHAT_SPARKLE_KEY_FILE=... ios/release-mac.sh 0.9.0 1 # notarize -> DMG -> appcast
+```
+
+The iPhone script's App Store Connect half (processing, export compliance,
+what-to-test, tester groups, beta review) is `ios/asc-testflight.mjs`, and
+every step of it is idempotent β€” if the upload lands but a later step fails,
+re-run that script alone with the same version and build.
+
+The changelog entry is part of the mint, not of the feature PRs: a native
+change ships *without* one, and the PR that mints the release adds a single
+entry covering everything in the build (CLAUDE.md Β§ Conventions).
+
## Building on a device as a team member (not the account holder)
Once the account holder invites you to their Apple Developer team (App Store
diff --git a/ios/asc-testflight.mjs b/ios/asc-testflight.mjs
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.");
diff --git a/ios/release-ios.sh b/ios/release-ios.sh
new file mode 100755
index 0000000..db84265
--- /dev/null
+++ b/ios/release-ios.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Build, sign, and upload a TestFlight release of the iPhone app (LChat), then
+# hand the uploaded build to ios/asc-testflight.mjs, which does the App Store
+# Connect side: wait out processing, declare export compliance, seed "What to
+# Test", and place the build with the tester groups.
+#
+# The Mac's counterpart is ios/release-mac.sh (Developer ID + Sparkle). Mint
+# both from the same version/build so the two apps stay in step β€” they are one
+# product (CLAUDE.md Β§ Conventions).
+#
+# Usage: ios/release-ios.sh <marketing-version> <build-number> [--external]
+# ios/release-ios.sh 0.9.0 1 # internal testers only
+# ios/release-ios.sh 0.9.0 1 --external # …and submit for external review
+#
+# Required env:
+# LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID
+# The App Store Connect API key (see the operator runbook). The key must
+# have Admin role β€” an App Manager key can't mint the cloud distribution
+# certificate and dies with "Cloud signing permission error".
+# Optional env:
+# LCHAT_RELEASE_NOTES_MD Path to the release notes; becomes the build's
+# "What to Test" text for testers.
+# LCHAT_ASC_APP_ID Default 6787060127 (the Eleven Messenger app record).
+#
+# ALWAYS pass the -authenticationKey* flags (they're built in below): without
+# them xcodebuild scans Xcode's stored ASC accounts, and one dead account
+# session aborts the whole export with "Error Downloading App Information".
+set -euo pipefail
+
+VERSION="${1:?usage: release-ios.sh <marketing-version> <build-number> [--external]}"
+BUILD="${2:?usage: release-ios.sh <marketing-version> <build-number> [--external]}"
+EXTERNAL="${3:-}"
+: "${LCHAT_ASC_KEY_PATH:?LCHAT_ASC_KEY_PATH is required (ASC API key .p8 path)}"
+: "${LCHAT_ASC_KEY_ID:?LCHAT_ASC_KEY_ID is required}"
+: "${LCHAT_ASC_ISSUER_ID:?LCHAT_ASC_ISSUER_ID is required}"
+TEAM_ID="J5NDF5PCD8"
+
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+IOS="$REPO/ios"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+ASC_AUTH=(-authenticationKeyPath "$LCHAT_ASC_KEY_PATH" -authenticationKeyID "$LCHAT_ASC_KEY_ID" -authenticationKeyIssuerID "$LCHAT_ASC_ISSUER_ID")
+
+echo "Building Eleven $VERSION ($BUILD) β€” App Store Connect / TestFlight"
+
+cd "$IOS"
+xcodegen generate
+
+xcodebuild archive \
+ -project LChat.xcodeproj -scheme LChat -destination 'generic/platform=iOS' \
+ -archivePath "$TMP/LChat.xcarchive" -allowProvisioningUpdates \
+ CODE_SIGN_STYLE=Automatic MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD"
+
+# -exportOptionsPlist has no CLI flag form, so template a throwaway one.
+# manageAppVersionAndBuildNumber is deliberately FALSE: it would override the
+# numbers we just archived with whatever ASC thinks comes next, and the whole
+# point is that the Mac and the iPhone carry the same version.
+cat > "$TMP/export-options.plist" <<PLIST
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>method</key>
+ <string>app-store-connect</string>
+ <key>destination</key>
+ <string>upload</string>
+ <key>teamID</key>
+ <string>$TEAM_ID</string>
+ <key>signingStyle</key>
+ <string>automatic</string>
+ <key>uploadSymbols</key>
+ <true/>
+ <key>manageAppVersionAndBuildNumber</key>
+ <false/>
+</dict>
+</plist>
+PLIST
+
+xcodebuild -exportArchive -archivePath "$TMP/LChat.xcarchive" -exportPath "$TMP/export" \
+ -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}"
+
+echo "Uploaded. Handing over to App Store Connect…"
+exec node "$IOS/asc-testflight.mjs" "$VERSION" "$BUILD" $EXTERNAL