From eb9f4ad8dd6355ada1fd5f78436a428c33def5a4 Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 02:52:34 +0330 Subject: [PATCH 1/2] ios: add green main internal testflight publisher --- .github/workflows/integrations.yml | 2 +- .github/workflows/ios-early-testers.yml | 21 +++ scripts/ci/ios-early-testers.mjs | 176 ++++++++++++++++++++++++ scripts/ci/ios-early-testers.test.ts | 95 +++++++++++++ scripts/ci/nightly-tip-gate.py | 6 +- scripts/ci/workflow-policy.test.ts | 16 ++- 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ios-early-testers.yml create mode 100644 scripts/ci/ios-early-testers.mjs create mode 100644 scripts/ci/ios-early-testers.test.ts diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml index f16514e8b..79b6b79b5 100644 --- a/.github/workflows/integrations.yml +++ b/.github/workflows/integrations.yml @@ -280,7 +280,7 @@ jobs: - name: Validate active workflows run: | go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.10 -color - bun test scripts/ci/workflow-policy.test.ts + bun test scripts/ci/workflow-policy.test.ts scripts/ci/ios-early-testers.test.ts python3 -m unittest discover -s scripts/ci -p 'test_nightly_tip_gate.py' - name: Check CI shell scripts run: | diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml new file mode 100644 index 000000000..d30950fb5 --- /dev/null +++ b/.github/workflows/ios-early-testers.yml @@ -0,0 +1,21 @@ +name: iOS Early Testers credential preflight +on: + push: + branches: [codex/ios-early-testers] +permissions: + contents: read +jobs: + preflight: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + - name: Check Apple credentials and internal group without mutations + env: + ASC_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER }} + ASC_PRIVATE_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + run: node scripts/ci/ios-early-testers.mjs --preflight diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs new file mode 100644 index 000000000..e4808aca3 --- /dev/null +++ b/scripts/ci/ios-early-testers.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// The only distribution mutation allowed here is adding the internal Early Testers group. +import { createPrivateKey, sign } from "node:crypto" +import { execFileSync } from "node:child_process" +import { appendFileSync } from "node:fs" +import { pathToFileURL } from "node:url" + +export const APP = "6736995294" +export const GROUP = "6b986a51-e0b9-437f-b0e5-208b05caee18" +export const WORKFLOW = "a39d4e34-0912-4454-a728-ac1549929c9c" +const REPOSITORY = "e8a64afc-f77d-488f-b7fb-1aadbe4a092f" +const ROOT = "https://api.appstoreconnect.apple.com" +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function makeToken(env, now = Math.floor(Date.now() / 1000)) { + if (!env.ASC_KEY_ID || !env.ASC_ISSUER_ID || !env.ASC_PRIVATE_KEY) { + throw new Error("App Store Connect team key is required") + } + const key = createPrivateKey(env.ASC_PRIVATE_KEY) + if (key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error("Expected a P-256 team key") + const encode = (data) => Buffer.from(JSON.stringify(data)).toString("base64url") + const body = `${encode({ alg: "ES256", kid: env.ASC_KEY_ID, typ: "JWT" })}.${encode({ + iss: env.ASC_ISSUER_ID, iat: now, exp: now + 600, aud: "appstoreconnect-v1", + })}` + return `${body}.${sign("sha256", Buffer.from(body), { key, dsaEncoding: "ieee-p1363" }).toString("base64url")}` +} + +export function appleClient(env, fetcher = fetch) { + return async (path, data) => { + const url = new URL(path, ROOT) + if (url.origin !== ROOT || !url.pathname.startsWith("/v1/")) throw new Error("Unexpected Apple API URL") + // Do not log JWTs, private keys, request headers, or provider error bodies. + const response = await fetcher(url, { + method: data ? "POST" : "GET", redirect: "error", signal: AbortSignal.timeout(60_000), + headers: { Authorization: `Bearer ${makeToken(env)}`, "Content-Type": "application/json" }, + ...(data ? { body: JSON.stringify(data) } : {}), + }) + if (!response.ok) throw new Error(`Apple API ${response.status}: ${url.pathname}`) + return response.status === 204 ? {} : response.json() + } +} + +export async function list(api, path) { + const items = [] + for (let page = 0; path && page < 20; page++) { + const result = await api(path) + items.push(...result.data) + path = result.links?.next + } + if (path) throw new Error("Apple pagination limit exceeded") + return items +} + +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") + } +} + +export function validateRun(run, sha) { + if (run.attributes?.sourceCommit?.commitSha !== sha || run.attributes?.isPullRequestBuild === true) { + throw new Error("Xcode Cloud source differs from the qualified main commit") + } +} + +export function validateBuild(build, app, version) { + if (app.id !== APP || version.attributes?.platform !== "IOS" || build.attributes?.expired + || build.attributes?.processingState !== "VALID" + || build.attributes?.buildAudienceType !== "APP_STORE_ELIGIBLE") { + throw new Error("Expected a valid, unexpired Inline iOS archive") + } +} + +export function reusableRun(runs, sha) { + return runs.find((run) => run.attributes?.sourceCommit?.commitSha === sha + && (run.attributes.executionProgress !== "COMPLETE" || run.attributes.completionStatus === "SUCCEEDED")) +} + +export async function preflight(api) { + const group = (await api(`/v1/betaGroups/${GROUP}`)).data + const groupApp = (await api(`/v1/betaGroups/${GROUP}/app`)).data + validateGroup(group, groupApp) + const workflow = (await api(`/v1/ciWorkflows/${WORKFLOW}`)).data + if (!workflow.attributes.isEnabled || workflow.attributes.actions.length !== 1 + || workflow.attributes.actions[0].platform !== "IOS" + || workflow.attributes.actions[0].buildDistributionAudience !== "APP_STORE_ELIGIBLE") { + throw new Error("Expected the enabled, manual iOS archive workflow") + } + const repo = (await api(`/v1/ciWorkflows/${WORKFLOW}/repository`)).data + if (repo.id !== REPOSITORY) throw new Error("Unexpected Xcode Cloud repository") +} + +export async function publish({ api, sha, qualify, 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 () => { + if (now() >= deadline) throw new Error("Timed out; the next run will resume this Xcode Cloud build") + await pause(30_000) + } + const group = (await api(`/v1/betaGroups/${GROUP}`)).data + const groupApp = (await api(`/v1/betaGroups/${GROUP}/app`)).data + validateGroup(group, groupApp) + await qualify() + const runs = await list(api, `/v1/ciWorkflows/${WORKFLOW}/buildRuns?sort=-number&limit=100`) + let run = reusableRun(runs, sha) + if (!run) { + const refs = await list(api, `/v1/scmRepositories/${REPOSITORY}/gitReferences?limit=200`) + const main = refs.find((ref) => ref.attributes.canonicalName === "refs/heads/main" && !ref.attributes.isDeleted) + if (!main) throw new Error("Xcode Cloud main reference is missing") + // Apple selects a branch rather than accepting a SHA. Check its resolved source before distributing. + run = (await api("/v1/ciBuildRuns", { data: { type: "ciBuildRuns", relationships: { + workflow: { data: { type: "ciWorkflows", id: WORKFLOW } }, + sourceBranchOrTag: { data: { type: "scmGitReferences", id: main.id } }, + } } })).data + log(`Started Xcode Cloud run ${run.id} for ${sha}`) + } 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) + if (run.attributes.executionProgress === "COMPLETE") break + await wait() + } + validateRun(run, sha) + if (run.attributes.completionStatus !== "SUCCEEDED") throw new Error("Xcode Cloud archive failed") + let builds + while (true) { + builds = await list(api, `/v1/ciBuildRuns/${run.id}/builds?limit=200`) + if (builds.length) break + await wait() + } + if (builds.length !== 1) throw new Error("Expected exactly one iOS archive for the run") + let build + while (true) { + build = (await api(`/v1/builds/${builds[0].id}`)).data + if (build.attributes.processingState !== "PROCESSING") break + await wait() + } + 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.attributes.isInternalGroup !== true)) { + throw new Error("Build already has external distribution; refusing automatic promotion") + } + const testers = await list(api, `/v1/builds/${build.id}/individualTesters?limit=200`) + if (testers.length) throw new Error("Build has individual tester assignments; manual review required") + await qualify() + 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) + if (!final.some((item) => item.id === GROUP) || final.some((item) => !item.attributes.isInternalGroup)) { + throw new Error("Internal distribution readback failed") + } + 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 } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + if (process.argv.includes("--preflight")) { + await preflight(appleClient(process.env)) + console.log("Verified Apple credentials, Inline iOS workflow and internal Early Testers group (read only)") + process.exit(0) + } + 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" }), + }) + 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`) + } catch (error) { + console.error(error instanceof Error ? error.message : "iOS early tester release failed") + process.exitCode = 1 + } +} diff --git a/scripts/ci/ios-early-testers.test.ts b/scripts/ci/ios-early-testers.test.ts new file mode 100644 index 000000000..d7fc1da63 --- /dev/null +++ b/scripts/ci/ios-early-testers.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test" +import { generateKeyPairSync, verify } from "node:crypto" +import { APP, GROUP, WORKFLOW, appleClient, makeToken, publish, reusableRun } from "./ios-early-testers.mjs" + +const sha = "a".repeat(40) +const run = { id: "run", attributes: { sourceCommit: { commitSha: sha }, executionProgress: "COMPLETE", completionStatus: "SUCCEEDED" } } +const group = { id: GROUP, attributes: { isInternalGroup: true } } +const build = { id: "build", attributes: { version: "42", processingState: "VALID", buildAudienceType: "APP_STORE_ELIGIBLE", expired: false } } + +function fixture(overrides: Record = {}) { + let groups: unknown[] = [] + const mutations: Array<{ path: string; data: unknown }> = [] + const routes: Record = { + [`/v1/betaGroups/${GROUP}`]: group, + [`/v1/betaGroups/${GROUP}/app`]: { id: APP }, + [`/v1/ciWorkflows/${WORKFLOW}/buildRuns?sort=-number&limit=100`]: [run], + "/v1/ciBuildRuns/run": run, + "/v1/ciBuildRuns/run/builds?limit=200": [build], + "/v1/builds/build": build, + "/v1/builds/build/app": { id: APP }, + "/v1/builds/build/preReleaseVersion": { attributes: { platform: "IOS", version: "0.1" } }, + "/v1/builds/build/individualTesters?limit=200": [], + ...overrides, + } + const api = async (path: string, data?: unknown) => { + if (data) { + mutations.push({ path, data }) + groups = [group] + return {} + } + if (path.startsWith("/v1/betaGroups?")) return { data: groups } + if (!(path in routes)) throw new Error(`Unexpected request: ${path}`) + return { data: routes[path] } + } + return { api, mutations, setGroups: (value: unknown[]) => { groups = value } } +} + +describe("internal iOS release boundary", () => { + it("reuses a successful exact-source build and only adds Early Testers", async () => { + const f = fixture() + let gates = 0 + const result = await publish({ api: f.api, sha, qualify: () => { gates++ }, log: () => {} }) + expect(result.build).toBe("build") + expect(gates).toBe(2) + expect(f.mutations).toEqual([{ path: `/v1/betaGroups/${GROUP}/relationships/builds`, data: { data: [{ type: "builds", id: "build" }] } }]) + }) + 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: () => {} }) + expect(f.mutations).toEqual([]) + }) + it("rejects external groups, wrong app/platform/source, expired builds and individual assignments", async () => { + const cases = [ + { [`/v1/betaGroups/${GROUP}`]: { ...group, attributes: { isInternalGroup: false } } }, + { "/v1/builds/build/app": { id: "other" } }, + { "/v1/builds/build/preReleaseVersion": { attributes: { platform: "MAC_OS" } } }, + { "/v1/ciBuildRuns/run": { ...run, attributes: { ...run.attributes, sourceCommit: { commitSha: "b".repeat(40) } } } }, + { "/v1/builds/build": { ...build, attributes: { ...build.attributes, expired: true } } }, + { "/v1/builds/build/individualTesters?limit=200": [{ id: "tester" }] }, + ] + for (const overrides of cases) { + const f = fixture(overrides) + await expect(publish({ api: f.api, sha, qualify: () => {}, log: () => {} })).rejects.toThrow() + expect(f.mutations).toEqual([]) + } + const f = fixture() + f.setGroups([{ id: "external", attributes: { isInternalGroup: false } }]) + await expect(publish({ api: f.api, sha, qualify: () => {}, log: () => {} })).rejects.toThrow("external distribution") + expect(f.mutations).toEqual([]) + }) + it("does not distribute when main advances during the build", async () => { + const f = fixture() + let gates = 0 + await expect(publish({ api: f.api, sha, qualify: () => { if (++gates === 2) throw new Error("main moved") }, log: () => {} })).rejects.toThrow("main moved") + expect(f.mutations).toEqual([]) + }) + it("does not reuse failed runs or runs from another commit", () => { + expect(reusableRun([{ ...run, attributes: { ...run.attributes, completionStatus: "FAILED" } }], sha)).toBeUndefined() + expect(reusableRun([run], "b".repeat(40))).toBeUndefined() + expect(reusableRun([{ ...run, attributes: { ...run.attributes, executionProgress: "RUNNING" } }], sha)).toBeDefined() + }) + 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() } + const jwt = makeToken(env, 1000) + const [header, payload, signature] = jwt.split(".") + expect(JSON.parse(Buffer.from(payload, "base64url").toString())).toMatchObject({ iat: 1000, exp: 1600, aud: "appstoreconnect-v1" }) + expect(verify("sha256", Buffer.from(`${header}.${payload}`), { key: pair.publicKey, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"))).toBe(true) + let requests = 0 + const api = appleClient(env, async () => { requests++; return new Response("", { status: 204 }) }) + await expect(api("https://example.com/v1/stolen")).rejects.toThrow("Unexpected Apple API URL") + expect(requests).toBe(0) + }) +}) diff --git a/scripts/ci/nightly-tip-gate.py b/scripts/ci/nightly-tip-gate.py index dbbea05e5..bf778ce84 100644 --- a/scripts/ci/nightly-tip-gate.py +++ b/scripts/ci/nightly-tip-gate.py @@ -201,7 +201,11 @@ def output(**values): def main(): github = GitHub(os.environ.get("GITHUB_REPOSITORY"), os.environ.get("GH_TOKEN")) mode = sys.argv[1] if len(sys.argv) > 1 else "select" - if mode == "select": + if mode == "select-green": + sha, problem = inspect_main(github) + print(f"Latest main: {sha}; gate: {problem or 'green'}") + output(sha=sha, should_release=str(problem is None).lower()) + elif mode == "select": sha, problem = inspect_main(github) if problem: print(f"Skipping nightly tip: {problem} ({sha})") diff --git a/scripts/ci/workflow-policy.test.ts b/scripts/ci/workflow-policy.test.ts index dce520529..2a7d0f1eb 100644 --- a/scripts/ci/workflow-policy.test.ts +++ b/scripts/ci/workflow-policy.test.ts @@ -51,7 +51,7 @@ describe("public CI contracts", () => { }) it("does not expose publication workflows to pull requests", () => { - for (const name of ["npm-publish.yml", "cli-release.yml", "server-deploy.yml", "macos-tip-nightly.yml"]) { + for (const name of ["npm-publish.yml", "cli-release.yml", "server-deploy.yml", "macos-tip-nightly.yml", "ios-early-testers.yml"]) { expect(workflow(name).on.pull_request, name).toBeUndefined() } }) @@ -67,6 +67,20 @@ describe("public CI contracts", () => { expect(source).toContain("INLINE_NIGHTLY_MAIN_SHA: ${{ needs.select.outputs.sha }}") }) + it("gates automatic internal iOS releases with trusted main scripts", () => { + const ios = workflow("ios-early-testers.yml") + expect(ios.on.schedule).toBeDefined() + expect(ios.on.workflow_run).toEqual({ workflows: ["CI", "Apple Validation", "Server Tests", "CodeQL", "CLI Build"], types: ["completed"], branches: ["main"] }) + expect(ios.permissions.contents).toBe("read") + const source = read(".github/workflows/ios-early-testers.yml") + expect(source).toContain("github.ref == 'refs/heads/main'") + expect(source).toContain("ref: main") + expect(source).toContain("scripts/ci/nightly-tip-gate.py select-green") + expect(source).toContain("EXPECTED_SHA: ${{ steps.gate.outputs.sha }}") + expect(source).not.toContain("download-artifact") + expect(read("scripts/ci/ios-early-testers.mjs")).not.toContain("betaAppReviewSubmissions") + }) + it("tests published Hermes compatibility regularly and gates the exact release artifact", () => { const scheduled = workflow("hermes-compatibility.yml") expect(scheduled.on.schedule).toBeDefined() From c7b0a50a78d3405cceb0adb0f1376105fb30749e Mon Sep 17 00:00:00 2001 From: Mohammad Rajabi Date: Wed, 30 Sep 2026 02:59:42 +0330 Subject: [PATCH 2/2] ios: pin automatic archives and enable internal release triggers --- .github/workflows/ios-early-testers.yml | 45 ++++++++++++--- scripts/ci/ios-early-testers.mjs | 75 ++++++++++++++++++++++--- scripts/ci/ios-early-testers.test.ts | 29 +++++++++- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ios-early-testers.yml b/.github/workflows/ios-early-testers.yml index d30950fb5..72d639626 100644 --- a/.github/workflows/ios-early-testers.yml +++ b/.github/workflows/ios-early-testers.yml @@ -1,21 +1,52 @@ -name: iOS Early Testers credential preflight +name: iOS Early Testers + on: - push: - branches: [codex/ios-early-testers] + workflow_run: + workflows: [CI, Apple Validation, Server Tests, CodeQL, CLI Build] + types: [completed] + branches: [main] + schedule: + - cron: '43 * * * *' + workflow_dispatch: + +concurrency: + group: ios-early-testers + cancel-in-progress: false + permissions: + actions: read + checks: read contents: read + jobs: - preflight: + release: + permissions: + 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' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 90 steps: + # Always use trusted default-branch scripts, never workflow_run artifacts. - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' - - name: Check Apple credentials and internal group without mutations + - name: Select latest green main using the macOS tip gate + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci/nightly-tip-gate.py select-green + - name: Build and distribute to internal Early Testers + if: steps.gate.outputs.should_release == 'true' env: + GH_TOKEN: ${{ github.token }} + EXPECTED_SHA: ${{ steps.gate.outputs.sha }} ASC_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER }} ASC_PRIVATE_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} - run: node scripts/ci/ios-early-testers.mjs --preflight + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: node scripts/ci/ios-early-testers.mjs diff --git a/scripts/ci/ios-early-testers.mjs b/scripts/ci/ios-early-testers.mjs index e4808aca3..18f9aa061 100644 --- a/scripts/ci/ios-early-testers.mjs +++ b/scripts/ci/ios-early-testers.mjs @@ -2,12 +2,14 @@ // The only distribution mutation allowed here is adding the internal Early Testers group. import { createPrivateKey, sign } from "node:crypto" import { execFileSync } from "node:child_process" -import { appendFileSync } from "node:fs" +import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { pathToFileURL } from "node:url" export const APP = "6736995294" export const GROUP = "6b986a51-e0b9-437f-b0e5-208b05caee18" -export const WORKFLOW = "a39d4e34-0912-4454-a728-ac1549929c9c" +export const WORKFLOW = "4f8a5391-2131-4b3f-9306-49d04390d5c8" const REPOSITORY = "e8a64afc-f77d-488f-b7fb-1aadbe4a092f" const ROOT = "https://api.appstoreconnect.apple.com" const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -76,6 +78,31 @@ export function reusableRun(runs, sha) { && (run.attributes.executionProgress !== "COMPLETE" || run.attributes.completionStatus === "SUCCEEDED")) } +export async function ensureSourceTag(sha, env = process.env, fetcher = fetch) { + if (env.GITHUB_REPOSITORY !== "inline-chat/inline" || !env.GH_TOKEN || !/^[0-9a-f]{40}$/.test(sha)) { + throw new Error("Source tags require the trusted Inline repository and qualified SHA") + } + const tag = `ios-early-testers/${sha}` + const root = "https://api.github.com/repos/inline-chat/inline" + const request = (path, data) => 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) } : {}), + }) + const response = await request(`git/ref/tags/${tag}`) + if (response.status === 404) { + const created = await request("git/refs", { ref: `refs/tags/${tag}`, sha }) + if (!created.ok) throw new Error(`GitHub source tag creation failed: ${created.status}`) + const ref = await created.json() + if (ref.object?.sha !== sha || ref.object?.type !== "commit") throw new Error("Created source tag differs from qualified SHA") + } else { + if (!response.ok) throw new Error(`GitHub source tag lookup failed: ${response.status}`) + const ref = await response.json() + if (ref.object?.sha !== sha || ref.object?.type !== "commit") throw new Error("Existing source tag differs from qualified SHA") + } + return `refs/tags/${tag}` +} + export async function preflight(api) { const group = (await api(`/v1/betaGroups/${GROUP}`)).data const groupApp = (await api(`/v1/betaGroups/${GROUP}/app`)).data @@ -90,7 +117,28 @@ export async function preflight(api) { if (repo.id !== REPOSITORY) throw new Error("Unexpected Xcode Cloud repository") } -export async function publish({ api, sha, qualify, pause = sleep, now = Date.now, log = console.log }) { +async function uploadSymbols(api, runId) { + if (!process.env.SENTRY_AUTH_TOKEN) throw new Error("SENTRY_AUTH_TOKEN is required for release symbols") + const actions = await list(api, `/v1/ciBuildRuns/${runId}/actions?limit=200`) + const artifacts = [] + for (const action of actions) artifacts.push(...await list(api, `/v1/ciBuildActions/${action.id}/artifacts?limit=200`)) + const archives = artifacts.filter((item) => item.attributes.fileType === "ARCHIVE") + if (archives.length !== 1) throw new Error("Expected one exact-run archive for Sentry symbols") + const url = new URL(archives[0].attributes.downloadUrl) + if (url.protocol !== "https:" || !url.hostname.endsWith(".icloud-content.com")) throw new Error("Unexpected Apple archive download host") + // The signed artifact URL receives no Apple or GitHub bearer token. + const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(300_000) }) + if (!response.ok) throw new Error(`Apple archive download failed: ${response.status}`) + 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, + 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 }) { 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 () => { @@ -104,13 +152,18 @@ export async function publish({ api, sha, qualify, pause = sleep, now = Date.now const runs = await list(api, `/v1/ciWorkflows/${WORKFLOW}/buildRuns?sort=-number&limit=100`) let run = reusableRun(runs, sha) if (!run) { - const refs = await list(api, `/v1/scmRepositories/${REPOSITORY}/gitReferences?limit=200`) - const main = refs.find((ref) => ref.attributes.canonicalName === "refs/heads/main" && !ref.attributes.isDeleted) - if (!main) throw new Error("Xcode Cloud main reference is missing") - // Apple selects a branch rather than accepting a SHA. Check its resolved source before distributing. + const canonicalName = await sourceTag(sha) + let source + while (!source) { + const refs = await list(api, `/v1/scmRepositories/${REPOSITORY}/gitReferences?limit=200`) + source = refs.find((ref) => ref.attributes.canonicalName === canonicalName && !ref.attributes.isDeleted) + if (!source) await wait() + } + await qualify() + // Pin an immutable tag: main may advance before Apple clones the source. run = (await api("/v1/ciBuildRuns", { data: { type: "ciBuildRuns", relationships: { workflow: { data: { type: "ciWorkflows", id: WORKFLOW } }, - sourceBranchOrTag: { data: { type: "scmGitReferences", id: main.id } }, + sourceBranchOrTag: { data: { type: "scmGitReferences", id: source.id } }, } } })).data log(`Started Xcode Cloud run ${run.id} for ${sha}`) } else log(`Resuming Xcode Cloud run ${run.id} for ${sha}`) @@ -140,11 +193,16 @@ export async function publish({ api, sha, qualify, pause = sleep, now = Date.now 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)) { + log(`Already available to internal Early Testers: ${sha}, build ${build.attributes.version}`) + return { sha, run: run.id, build: build.id, version: build.attributes.version } + } if (groups.some((item) => item.attributes.isInternalGroup !== true)) { throw new Error("Build already has external distribution; refusing automatic promotion") } const testers = await list(api, `/v1/builds/${build.id}/individualTesters?limit=200`) if (testers.length) throw new Error("Build has individual tester assignments; manual review required") + await symbols(api, run.id) await qualify() if (!groups.some((item) => item.id === GROUP)) { await api(`/v1/betaGroups/${GROUP}/relationships/builds`, { data: [{ type: "builds", id: build.id }] }) @@ -166,6 +224,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, }) 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 d7fc1da63..e89901e01 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, makeToken, publish, reusableRun } from "./ios-early-testers.mjs" +import { APP, GROUP, WORKFLOW, appleClient, ensureSourceTag, makeToken, publish, reusableRun } from "./ios-early-testers.mjs" const sha = "a".repeat(40) const run = { id: "run", attributes: { sourceCommit: { commitSha: sha }, executionProgress: "COMPLETE", completionStatus: "SUCCEEDED" } } @@ -80,6 +80,33 @@ describe("internal iOS release boundary", () => { expect(reusableRun([run], "b".repeat(40))).toBeUndefined() expect(reusableRun([{ ...run, attributes: { ...run.attributes, executionProgress: "RUNNING" } }], sha)).toBeDefined() }) + 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 }> = [] + const fetcher = async (url: string, options: { body?: string }) => { + calls.push({ url, body: options.body }) + return calls.length === 1 ? new Response("", { status: 404 }) + : Response.json({ object: { sha, type: "commit" } }, { status: 201 }) + } + expect(await ensureSourceTag(sha, env, fetcher)).toBe(`refs/tags/ios-early-testers/${sha}`) + expect(JSON.parse(calls[1].body!)).toEqual({ ref: `refs/tags/ios-early-testers/${sha}`, sha }) + await expect(ensureSourceTag(sha, env, async () => Response.json({ object: { sha: "b".repeat(40), type: "commit" } }))).rejects.toThrow("Existing source tag differs") + }) + it("starts Apple from the immutable tag and resumes until the exact archive is ready", async () => { + const f = fixture({ [`/v1/ciWorkflows/${WORKFLOW}/buildRuns?sort=-number&limit=100`]: [] }) + const mutations: unknown[] = [] + const api = async (path: string, data?: unknown) => { + if (path.includes("/gitReferences?")) return { data: [{ id: "tag-ref", attributes: { canonicalName: `refs/tags/ios-early-testers/${sha}` } }] } + if (path === "/v1/ciBuildRuns" && data) { mutations.push(data); return { data: run } } + return f.api(path, data) + } + await publish({ api, sha, qualify: () => {}, sourceTag: async () => `refs/tags/ios-early-testers/${sha}`, log: () => {} }) + expect(mutations).toEqual([{ data: { type: "ciBuildRuns", relationships: { + workflow: { data: { type: "ciWorkflows", id: WORKFLOW } }, + sourceBranchOrTag: { data: { type: "scmGitReferences", id: "tag-ref" } }, + } } }]) + expect(f.mutations.length).toBe(1) + }) 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() }