Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ios-early-testers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 58 additions & 12 deletions scripts/ci/ios-early-testers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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"))
}

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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")
Expand All @@ -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) {
Expand All @@ -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`)
Expand Down
58 changes: 55 additions & 3 deletions scripts/ci/ios-early-testers.test.ts
Original file line number Diff line number Diff line change
@@ -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" } }
Expand Down Expand Up @@ -28,7 +28,11 @@ function fixture(overrides: Record<string, unknown> = {}) {
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] }
}
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 }> = []
Expand Down Expand Up @@ -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() }
Expand Down
Loading