diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml index 72d639626..61ecc3928 100644 --- a/.github/workflows/ios-early-testers.yml +++ b/.github/workflows/ios-early-testers.yml @@ -24,6 +24,7 @@ jobs: actions: read checks: read contents: write # Create immutable source tags; never move an existing tag. + statuses: write # Record verified internal distribution and symbol upload per source SHA. if: github.repository == 'inline-chat/inline' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 90 diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index 18f9aa061..3eea07b25 100644 --- a/scripts/ci/ios-early-testers.mjs +++ b/scripts/ci/ios-early-testers.mjs @@ -4,7 +4,7 @@ import { createPrivateKey, sign } from "node:crypto" import { execFileSync } from "node:child_process" import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" import { pathToFileURL } from "node:url" export const APP = "6736995294" @@ -53,6 +53,46 @@ export async function list(api, path) { return items } +export async function buildGroups(api, buildId) { + const groups = await list(api, `/v1/apps/${APP}/betaGroups?limit=200`) + const members = [] + for (const group of groups) { + // Apple's documented filter[builds] is rejected for some builds. + const builds = await list(api, `/v1/betaGroups/${group.id}/relationships/builds?limit=200`) + if (builds.some((build) => build.id === buildId)) members.push(group) + } + return members +} + +export function publicationReceipt(env = process.env, fetcher = fetch) { + if (env.GITHUB_REPOSITORY !== "inline-chat/inline" || !env.GH_TOKEN) throw new Error("Trusted GitHub release token required") + const root = "https://api.github.com/repos/inline-chat/inline" + const request = async (path, data) => { + const response = await fetcher(`${root}/${path}`, { + method: data ? "POST" : "GET", redirect: "error", signal: AbortSignal.timeout(30_000), + headers: { Authorization: `Bearer ${env.GH_TOKEN}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" }, + ...(data ? { body: JSON.stringify(data) } : {}), + }) + if (!response.ok) throw new Error(`GitHub release receipt failed: ${response.status}`) + return response.json() + } + const context = "ios/early-testers" + const description = (build) => `Early Testers build ${build}` + return { + isPublished: async (sha, build) => { + // The combined-status endpoint omits creator; individual statuses retain it. + const statuses = await request(`commits/${sha}/statuses?per_page=100`) + const status = statuses.find((item) => item.context === context) + return status?.state === "success" && status.description === description(build) + && status.creator?.login === "github-actions[bot]" + }, + markPublished: async (result) => request(`statuses/${result.sha}`, { + state: "success", context, description: description(result.build), + target_url: `https://github.com/inline-chat/inline/actions/runs/${env.GITHUB_RUN_ID}`, + }), + } +} + export function validateGroup(group, app) { if (group.id !== GROUP || group.attributes?.isInternalGroup !== true || app.id !== APP) { throw new Error("Refusing distribution outside Inline internal Early Testers") @@ -73,8 +113,10 @@ export function validateBuild(build, app, version) { } } -export function reusableRun(runs, sha) { +export function reusableRun(runs, sha, now = Date.now()) { return runs.find((run) => run.attributes?.sourceCommit?.commitSha === sha + // Refresh before TestFlight's 90-day expiry even if main has been inactive. + && (!run.attributes.createdDate || Date.parse(run.attributes.createdDate) > now - 84 * 86_400_000) && (run.attributes.executionProgress !== "COMPLETE" || run.attributes.completionStatus === "SUCCEEDED")) } @@ -132,13 +174,13 @@ async function uploadSymbols(api, runId) { const archive = join(mkdtempSync(join(tmpdir(), "inline-ios-symbols-")), "archive.zip") writeFileSync(archive, Buffer.from(await response.arrayBuffer())) execFileSync("npx", ["--yes", "--package", "@sentry/cli@3.8.0", "sentry-cli", "debug-files", "upload", "--wait", archive], { - stdio: "inherit", timeout: 600_000, + stdio: "inherit", timeout: 600_000, cwd: dirname(archive), env: { PATH: process.env.PATH, HOME: process.env.HOME, SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SENTRY_ORG: "usenoor", SENTRY_PROJECT: "inline-ios-macos", SENTRY_URL: "https://us.sentry.io" }, }) } -export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, symbols = async () => {}, pause = sleep, now = Date.now, log = console.log }) { +export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, symbols = async () => {}, isPublished = async () => false, markPublished = async () => {}, pause = sleep, now = Date.now, log = console.log }) { if (!/^[0-9a-f]{40}$/.test(sha || "")) throw new Error("Expected a full qualified main SHA") const deadline = now() + 75 * 60_000 const wait = async () => { @@ -150,7 +192,7 @@ export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, validateGroup(group, groupApp) await qualify() const runs = await list(api, `/v1/ciWorkflows/${WORKFLOW}/buildRuns?sort=-number&limit=100`) - let run = reusableRun(runs, sha) + let run = reusableRun(runs, sha, now()) if (!run) { const canonicalName = await sourceTag(sha) let source @@ -169,7 +211,9 @@ export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, } else log(`Resuming Xcode Cloud run ${run.id} for ${sha}`) while (true) { run = (await api(`/v1/ciBuildRuns/${run.id}`)).data - if (run.attributes.sourceCommit) validateRun(run, sha) + // Apple may return an empty sourceCommit while a new run is pending. + // Reject a resolved mismatch immediately; require an exact SHA at completion. + if (run.attributes.sourceCommit?.commitSha) validateRun(run, sha) if (run.attributes.executionProgress === "COMPLETE") break await wait() } @@ -191,11 +235,11 @@ export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, const app = (await api(`/v1/builds/${build.id}/app`)).data const version = (await api(`/v1/builds/${build.id}/preReleaseVersion`)).data validateBuild(build, app, version) - const groupsPath = `/v1/betaGroups?filter[app]=${APP}&filter[builds]=${build.id}&limit=200` - const groups = await list(api, groupsPath) - if (groups.some((item) => item.id === GROUP)) { + const result = { sha, run: run.id, build: build.id, version: build.attributes.version } + const groups = await buildGroups(api, build.id) + if (groups.some((item) => item.id === GROUP) && await isPublished(sha, build.id)) { log(`Already available to internal Early Testers: ${sha}, build ${build.attributes.version}`) - return { sha, run: run.id, build: build.id, version: build.attributes.version } + return result } if (groups.some((item) => item.attributes.isInternalGroup !== true)) { throw new Error("Build already has external distribution; refusing automatic promotion") @@ -207,12 +251,13 @@ export async function publish({ api, sha, qualify, sourceTag = ensureSourceTag, if (!groups.some((item) => item.id === GROUP)) { await api(`/v1/betaGroups/${GROUP}/relationships/builds`, { data: [{ type: "builds", id: build.id }] }) } - const final = await list(api, groupsPath) + const final = await buildGroups(api, build.id) if (!final.some((item) => item.id === GROUP) || final.some((item) => !item.attributes.isInternalGroup)) { throw new Error("Internal distribution readback failed") } + await markPublished(result) log(`Verified iOS ${version.attributes.version} (${build.attributes.version}) for internal Early Testers: ${sha}`) - return { sha, run: run.id, build: build.id, version: build.attributes.version } + return result } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { @@ -225,6 +270,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const result = await publish({ api: appleClient(process.env), sha: process.env.EXPECTED_SHA, qualify: () => execFileSync("python3", ["scripts/ci/nightly-tip-gate.py", "qualify"], { stdio: "inherit" }), symbols: uploadSymbols, + ...publicationReceipt(), }) if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Internal Early Testers: build **${result.version}**, source \`${result.sha}\`, Xcode Cloud run \`${result.run}\`.\n`) diff --git a/scripts/ci/ios-early-testers.test.ts b/scripts/ci/ios-early-testers.test.ts index e89901e01..c28402971 100644 --- a/scripts/ci/ios-early-testers.test.ts +++ b/scripts/ci/ios-early-testers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import { generateKeyPairSync, verify } from "node:crypto" -import { APP, GROUP, WORKFLOW, appleClient, ensureSourceTag, makeToken, publish, reusableRun } from "./ios-early-testers.mjs" +import { APP, GROUP, WORKFLOW, appleClient, ensureSourceTag, makeToken, publicationReceipt, publish, reusableRun } from "./ios-early-testers.mjs" const sha = "a".repeat(40) const run = { id: "run", attributes: { sourceCommit: { commitSha: sha }, executionProgress: "COMPLETE", completionStatus: "SUCCEEDED" } } @@ -28,7 +28,11 @@ function fixture(overrides: Record = {}) { groups = [group] return {} } - if (path.startsWith("/v1/betaGroups?")) return { data: groups } + if (path === `/v1/apps/${APP}/betaGroups?limit=200`) return { data: [group, ...groups.filter((item: any) => item.id !== GROUP)] } + if (path.includes("/relationships/builds?")) { + const id = path.split("/")[3] + return { data: groups.some((item: any) => item.id === id) ? [{ id: "build" }] : [] } + } if (!(path in routes)) throw new Error(`Unexpected request: ${path}`) return { data: routes[path] } } @@ -47,8 +51,30 @@ describe("internal iOS release boundary", () => { it("is idempotent when the exact build is already internal", async () => { const f = fixture() f.setGroups([group]) - await publish({ api: f.api, sha, qualify: () => {}, log: () => {} }) + let uploads = 0 + await publish({ api: f.api, sha, qualify: () => {}, isPublished: async () => true, symbols: async () => { uploads++ }, log: () => {} }) expect(f.mutations).toEqual([]) + expect(uploads).toBe(0) + }) + it("uploads symbols even when all-build access precedes the first publication receipt", async () => { + const f = fixture() + f.setGroups([group]) + const order: string[] = [] + await publish({ api: f.api, sha, qualify: () => {}, symbols: async () => { order.push("symbols") }, markPublished: async () => { order.push("receipt") }, log: () => {} }) + expect(order).toEqual(["symbols", "receipt"]) + expect(f.mutations).toEqual([]) + }) + it("accepts only an exact-build receipt issued by GitHub Actions", async () => { + const env = { GITHUB_REPOSITORY: "inline-chat/inline", GH_TOKEN: "test-token", GITHUB_RUN_ID: "123" } + const bodies: unknown[] = [] + const receipt = publicationReceipt(env, async (_url: string, options: { body?: string }) => { + if (options.body) { bodies.push(JSON.parse(options.body)); return Response.json({}) } + return Response.json([{ context: "ios/early-testers", state: "success", description: "Early Testers build build", creator: { login: "github-actions[bot]" } }]) + }) + expect(await receipt.isPublished(sha, "build")).toBe(true) + expect(await receipt.isPublished(sha, "other")).toBe(false) + await receipt.markPublished({ sha, build: "build" }) + expect(bodies).toEqual([{ context: "ios/early-testers", state: "success", description: "Early Testers build build", target_url: "https://github.com/inline-chat/inline/actions/runs/123" }]) }) it("rejects external groups, wrong app/platform/source, expired builds and individual assignments", async () => { const cases = [ @@ -80,6 +106,13 @@ describe("internal iOS release boundary", () => { expect(reusableRun([run], "b".repeat(40))).toBeUndefined() expect(reusableRun([{ ...run, attributes: { ...run.attributes, executionProgress: "RUNNING" } }], sha)).toBeDefined() }) + it("refreshes successful archives before their TestFlight expiry", () => { + const now = Date.parse("2026-09-30T00:00:00Z") + const old = { ...run, attributes: { ...run.attributes, createdDate: "2026-06-01T00:00:00Z" } } + const fresh = { ...run, attributes: { ...run.attributes, createdDate: "2026-09-29T00:00:00Z" } } + expect(reusableRun([old, fresh], sha, now)).toBe(fresh) + expect(reusableRun([old], sha, now)).toBeUndefined() + }) it("creates an immutable source tag and refuses to move an existing tag", async () => { const env = { GITHUB_REPOSITORY: "inline-chat/inline", GH_TOKEN: "test-token" } const calls: Array<{ url: string; body?: string }> = [] @@ -107,6 +140,25 @@ describe("internal iOS release boundary", () => { } } }]) expect(f.mutations.length).toBe(1) }) + it("waits for a pending run's source to resolve and rejects unresolved completed runs", async () => { + const f = fixture() + let reads = 0 + let pauses = 0 + const api = async (path: string, data?: unknown) => { + if (path === "/v1/ciBuildRuns/run" && ++reads === 1) return { data: { + id: "run", attributes: { sourceCommit: {}, executionProgress: "PENDING" }, + } } + return f.api(path, data) + } + await publish({ api, sha, qualify: () => {}, pause: async () => { pauses++ }, log: () => {} }) + expect(pauses).toBe(1) + expect(f.mutations.length).toBe(1) + const unresolved = fixture({ "/v1/ciBuildRuns/run": { id: "run", attributes: { + sourceCommit: {}, executionProgress: "COMPLETE", completionStatus: "SUCCEEDED", + } } }) + await expect(publish({ api: unresolved.api, sha, qualify: () => {}, log: () => {} })).rejects.toThrow("source differs") + expect(unresolved.mutations).toEqual([]) + }) it("signs short lived Apple JWTs without disclosing credentials to pagination hosts", async () => { const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) const env = { ASC_KEY_ID: "id", ASC_ISSUER_ID: "issuer", ASC_PRIVATE_KEY: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString() }