From 73b9b67bcd4d12297d421c450fbbdb3329718f28 Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 12:45:21 +0330 Subject: [PATCH 1/6] ios: wait for pending cloud source metadata --- scripts/ci/ios-early-testers.mjs | 4 +++- scripts/ci/ios-early-testers.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index 18f9aa061..69567bdd1 100644 --- a/scripts/ci/ios-early-testers.mjs +++ b/scripts/ci/ios-early-testers.mjs @@ -169,7 +169,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() } diff --git a/scripts/ci/ios-early-testers.test.ts b/scripts/ci/ios-early-testers.test.ts index e89901e01..e0488354c 100644 --- a/scripts/ci/ios-early-testers.test.ts +++ b/scripts/ci/ios-early-testers.test.ts @@ -107,6 +107,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() } From f725e5d917da0031e5266b3c048f8c9a1c225fce Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 13:07:23 +0330 Subject: [PATCH 2/6] ios: verify native audiences and record publication receipts --- .github/workflows/ios-early-testers.yml | 3 +- scripts/ci/ios-early-testers.mjs | 54 +++++++++++++++++++++---- scripts/ci/ios-early-testers.test.ts | 32 +++++++++++++-- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml index 72d639626..7634a0980 100644 --- a/.github/workflows/ios-early-testers.yml +++ b/.github/workflows/ios-early-testers.yml @@ -24,7 +24,8 @@ jobs: actions: read checks: read contents: write # Create immutable source tags; never move an existing tag. - if: github.repository == 'inline-chat/inline' && github.ref == 'refs/heads/main' + statuses: write # Record verified internal distribution and symbol upload per source SHA. + if: github.repository == 'inline-chat/inline' && (github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/ios-cloud-source-readiness')) runs-on: ubuntu-latest timeout-minutes: 90 steps: diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index 69567bdd1..44f4b15ff 100644 --- a/scripts/ci/ios-early-testers.mjs +++ b/scripts/ci/ios-early-testers.mjs @@ -53,6 +53,44 @@ 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) => { + const result = await request(`commits/${sha}/status`) + return result.statuses.some((status) => status.context === context && 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") @@ -138,7 +176,7 @@ async function uploadSymbols(api, runId) { }) } -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 () => { @@ -193,11 +231,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") @@ -209,12 +247,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) { @@ -227,6 +266,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 e0488354c..a9807e4b0 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,9 +51,31 @@ 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({ statuses: [{ 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 = [ { [`/v1/betaGroups/${GROUP}`]: { ...group, attributes: { isInternalGroup: false } } }, From a82ef8b821ee32f06cd854efb7449e2c8d40118a Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 13:07:57 +0330 Subject: [PATCH 3/6] ci: select owned scripts for internal release validation --- .github/workflows/ios-early-testers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml index 7634a0980..9a400a0ff 100644 --- a/.github/workflows/ios-early-testers.yml +++ b/.github/workflows/ios-early-testers.yml @@ -32,7 +32,7 @@ jobs: # Always use trusted default-branch scripts, never workflow_run artifacts. - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: main + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/ios-cloud-source-readiness' && github.ref || 'main' }} - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' From ff28be42d279552846678d564af184369491aa2e Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 13:08:59 +0330 Subject: [PATCH 4/6] ci: restrict final automatic publication to main --- .github/workflows/ios-early-testers.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml index 9a400a0ff..61ecc3928 100644 --- a/.github/workflows/ios-early-testers.yml +++ b/.github/workflows/ios-early-testers.yml @@ -25,14 +25,14 @@ jobs: 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' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/ios-cloud-source-readiness')) + if: github.repository == 'inline-chat/inline' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 90 steps: # Always use trusted default-branch scripts, never workflow_run artifacts. - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/ios-cloud-source-readiness' && github.ref || 'main' }} + ref: main - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' From 329eae306a361c37cc76388c16be85c66987d07e Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 13:13:47 +0330 Subject: [PATCH 5/6] ios: isolate symbol tools and refresh aging archives --- scripts/ci/ios-early-testers.mjs | 10 ++++++---- scripts/ci/ios-early-testers.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index 44f4b15ff..dd8fab46b 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" @@ -111,8 +111,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")) } @@ -170,7 +172,7 @@ 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" }, }) @@ -188,7 +190,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 diff --git a/scripts/ci/ios-early-testers.test.ts b/scripts/ci/ios-early-testers.test.ts index a9807e4b0..78ee8f824 100644 --- a/scripts/ci/ios-early-testers.test.ts +++ b/scripts/ci/ios-early-testers.test.ts @@ -106,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 }> = [] From 45f6a3fba7243706e432d048a9169d9e28a7101c Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 13:18:40 +0330 Subject: [PATCH 6/6] ios: verify receipt authors through individual statuses --- scripts/ci/ios-early-testers.mjs | 8 +++++--- scripts/ci/ios-early-testers.test.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index dd8fab46b..3eea07b25 100644 --- a/scripts/ci/ios-early-testers.mjs +++ b/scripts/ci/ios-early-testers.mjs @@ -80,9 +80,11 @@ export function publicationReceipt(env = process.env, fetcher = fetch) { const description = (build) => `Early Testers build ${build}` return { isPublished: async (sha, build) => { - const result = await request(`commits/${sha}/status`) - return result.statuses.some((status) => status.context === context && status.state === "success" - && status.description === description(build) && status.creator?.login === "github-actions[bot]") + // 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), diff --git a/scripts/ci/ios-early-testers.test.ts b/scripts/ci/ios-early-testers.test.ts index 78ee8f824..c28402971 100644 --- a/scripts/ci/ios-early-testers.test.ts +++ b/scripts/ci/ios-early-testers.test.ts @@ -69,7 +69,7 @@ describe("internal iOS release boundary", () => { 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({ statuses: [{ context: "ios/early-testers", state: "success", description: "Early Testers build build", creator: { login: "github-actions[bot]" } }] }) + 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)