From bb812693f347aa0d6579a8d24445ad4de5bbefab Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:50:38 -0500 Subject: [PATCH] fix(ios): qualify releases against the stable Gateway (#161682) --- .github/workflows/ci.yml | 1 + .github/workflows/ios-release-e2e.yml | 75 ++++- .github/workflows/ios-store-release.yml | 3 +- apps/ios/fastlane/SETUP.md | 43 ++- scripts/ios-release-e2e.ts | 6 +- scripts/ios-release-gateway.ts | 38 +++ scripts/lib/ios-release-e2e-native.ts | 93 ++++++- scripts/lib/ios-release-gateway.ts | 302 ++++++++++++++++++++ test/scripts/ios-release-e2e.test.ts | 262 +++++++++++++++++- test/scripts/ios-release-gateway.test.ts | 338 +++++++++++++++++++++++ 10 files changed, 1121 insertions(+), 40 deletions(-) create mode 100644 scripts/ios-release-gateway.ts create mode 100644 scripts/lib/ios-release-gateway.ts create mode 100644 test/scripts/ios-release-gateway.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6983711bef1c..95dd06627a3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5365,6 +5365,7 @@ jobs: ios-release-e2e: permissions: + actions: read contents: read needs: [preflight] if: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.validation_tier == 'full' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.checkout_revision == github.sha && needs.preflight.outputs.compatibility_target != 'true' && needs.preflight.outputs.run_ios_build == 'true' }} diff --git a/.github/workflows/ios-release-e2e.yml b/.github/workflows/ios-release-e2e.yml index 96e3f527c727..c0f53be4986f 100644 --- a/.github/workflows/ios-release-e2e.yml +++ b/.github/workflows/ios-release-e2e.yml @@ -18,6 +18,7 @@ on: default: stock permissions: + actions: read contents: read jobs: @@ -35,6 +36,7 @@ jobs: set -euo pipefail export PROOF_PATH="$RUNNER_TEMP/ios-release-e2e-proof.json" echo "PROOF_PATH=$PROOF_PATH" >> "$GITHUB_ENV" + echo "GATEWAY_SELECTION_DIR=$RUNNER_TEMP/ios-release-gateway-selection" >> "$GITHUB_ENV" echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT" python3 - <<'PY' import json, os, re @@ -66,6 +68,48 @@ jobs: test -f scripts/lib/ios-release-e2e-native.ts test -f test/helpers/openclaw-test-instance.ts + - name: Find this run's stable Gateway selection + id: gateway-selection + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const name = `ios-release-gateway-selection-${context.runId}`; + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: context.runId, + name, + per_page: 100, + }); + const matches = artifacts.filter((artifact) => artifact.name === name); + if (matches.length > 1) { + throw new Error('Multiple stable Gateway selections exist for this run.'); + } + if (matches.length === 0) { + if (Number(process.env.GITHUB_RUN_ATTEMPT) !== 1) { + throw new Error('This rerun has no saved stable Gateway selection. Start a new workflow run.'); + } + core.setOutput('create', 'true'); + core.setOutput('artifact-id', ''); + return; + } + const artifact = matches[0]; + if (artifact.expired || artifact.workflow_run?.id !== context.runId || + artifact.workflow_run?.head_sha !== process.env.TARGET_SHA) { + throw new Error('The saved stable Gateway selection is expired or belongs to another source/run. Start a new workflow run.'); + } + core.setOutput('create', 'false'); + core.setOutput('artifact-id', String(artifact.id)); + + - name: Restore this run's stable Gateway selection + if: steps.gateway-selection.outputs.artifact-id != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ steps.gateway-selection.outputs.artifact-id }} + github-token: ${{ github.token }} + run-id: ${{ github.run_id }} + path: ${{ env.GATEWAY_SELECTION_DIR }} + digest-mismatch: error + - name: Start shared toolchain installation clock id: install-start run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT" @@ -77,6 +121,32 @@ jobs: install-bun: "false" cache-mode: "off" + - name: Prepare stable Gateway selection + env: + RESTORED_SELECTION: ${{ steps.gateway-selection.outputs.artifact-id != '' }} + shell: bash + run: | + set -euo pipefail + if [[ "$RESTORED_SELECTION" == true ]]; then + test -f "$GATEWAY_SELECTION_DIR/selection.json" + test -f "$GATEWAY_SELECTION_DIR/package.json" + test -f "$GATEWAY_SELECTION_DIR/package-lock.json" + fi + node --import ./scripts/tsx.mjs scripts/ios-release-gateway.ts \ + --target-sha "$TARGET_SHA" --selection-dir "$GATEWAY_SELECTION_DIR" + + - name: Save this run's stable Gateway selection + if: steps.gateway-selection.outputs.create == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ios-release-gateway-selection-${{ github.run_id }} + path: | + ${{ env.GATEWAY_SELECTION_DIR }}/selection.json + ${{ env.GATEWAY_SELECTION_DIR }}/package.json + ${{ env.GATEWAY_SELECTION_DIR }}/package-lock.json + if-no-files-found: error + retention-days: 30 + - name: Select Xcode and install pinned native tools shell: bash run: | @@ -112,7 +182,8 @@ jobs: OPENCLAW_CI_SIMSLIM_BINARY: ${{ steps.simslim.outputs.binary }} run: | node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \ - --mode "$E2E_MODE" --target-sha "$TARGET_SHA" --output "$PROOF_PATH" + --mode "$E2E_MODE" --target-sha "$TARGET_SHA" --output "$PROOF_PATH" \ + --gateway-selection "$GATEWAY_SELECTION_DIR" - name: Complete sanitized timing proof if: always() @@ -139,7 +210,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ios-release-e2e-${{ inputs.mode }} + name: ios-release-e2e-${{ inputs.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/ios-release-e2e-proof.json if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/ios-store-release.yml b/.github/workflows/ios-store-release.yml index 214aee56249e..3ccb459b7037 100644 --- a/.github/workflows/ios-store-release.yml +++ b/.github/workflows/ios-store-release.yml @@ -109,8 +109,9 @@ jobs: ((github.event_name == 'schedule' && vars.IOS_TESTFLIGHT_ENABLED == 'true') || (github.event_name == 'workflow_dispatch' && (inputs.operation == 'release' || inputs.operation == 'testflight'))) permissions: + actions: read contents: read - # Qualification rebuilds tracked plugin manifests; keep its workspace separate + # Keep native qualification products and disposable Gateway state separate # from the clean checkout that admits the release. uses: ./.github/workflows/ios-release-e2e.yml with: diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index a91bd61e602e..d3d9296afda4 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -158,19 +158,31 @@ runtime supporting iPhone 17 Pro and arm64, and the repository's pinned native t ```bash node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \ - --mode stock --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-stock.json + --mode stock --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-stock.json \ + --gateway-selection /tmp/ios-e2e-gateway-selection ./scripts/install-simslim.sh /tmp/ios-e2e-tools OPENCLAW_CI_SIMSLIM_BINARY=/tmp/ios-e2e-tools/simslim \ node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \ - --mode compare --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-compare.json + --mode compare --target-sha "$(git rev-parse HEAD)" --output /tmp/ios-e2e-compare.json \ + --gateway-selection /tmp/ios-e2e-gateway-selection ``` The gate requires a clean tracked and untracked source tree at the exact SHA; gitignored build outputs are allowed. It selects the newest available iOS runtime that supports the test device and architecture, and records that runtime in its proof. -It builds the Gateway runtime and ad-hoc-signed -Debug `OpenClawUITests` simulator products once. Ad-hoc signing preserves Keychain +It qualifies the candidate iOS app against the published stable Gateway selected +from npm's `latest` tag. The first invocation saves the exact Gateway version, +package integrity, dependency lock, source SHA, and Node/npm versions in the +selection directory. Later invocations using that directory validate and reuse +the saved selection without resolving `latest` again. Without `--gateway-selection`, +the directory defaults to the output path with `.gateway` appended. Keep it for +replay; use a new directory to select a newer stable Gateway. Selection replay +requires the same source SHA and Node/npm versions. + +The harness installs the selected package in an isolated directory using the saved +dependency lock and builds ad-hoc-signed Debug `OpenClawUITests` simulator products +once. Ad-hoc signing preserves Keychain entitlements without certificates or provisioning profiles; this is not a signed Release build. Each arm starts an isolated real Gateway, then prepares its setup handler and state worker with `device.pair.setupStatus` before booting one new @@ -207,12 +219,13 @@ node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \ # Exercise Gateway startup, setup-status preparation, and code issuance without native resources. node --import ./scripts/tsx.mjs scripts/ios-release-e2e.ts \ --mode stock --target-sha "$(git rev-parse HEAD)" \ - --gateway-only --output /tmp/ios-e2e-gateway.json + --gateway-only --output /tmp/ios-e2e-gateway.json \ + --gateway-selection /tmp/ios-e2e-gateway-selection ``` These diagnostics produce `native-build`/`built` or `gateway-probe`/`probe-passed` -proofs, respectively. Neither is release qualification. Gateway runtime preparation -continues to use the existing build owner's cache in every mode. +proofs, respectively. Neither is release qualification. The Gateway probe uses the +same published-package selection and installation path as full qualification. The stock gate runs for both upload destinations in **iOS Store Release** after native tool setup and before signing assets are accessed. It qualifies the checked-out `main` commit used for release @@ -228,6 +241,16 @@ Manual dispatch of `iOS Release E2E` qualifies the selected workflow revision; it does not accept an alternate target SHA. CI callers must also use their own revision. +Each fresh workflow run resolves the stable Gateway once and saves +`selection.json`, `package.json`, and `package-lock.json` in the +`ios-release-gateway-selection-RUN_ID` artifact before native tool installation +and qualification. The artifact is retained for 30 days. All qualification arms +and reruns, including **Re-run all jobs**, reuse that run's selection. A missing, +expired, invalid, or source-mismatched selection stops a rerun; start a new +workflow run to make a fresh selection. No workflow input is needed. To replay +locally, download and extract that artifact and pass its directory with +`--gateway-selection` at the same source SHA and Node/npm versions. + Compare runs four serial matched pairs in stock/slim, slim/stock, stock/slim, slim/stock order, for eight independently prepared arms. SimSlim keeps the existing conservative search/family-only profile. A preparation or live-test failure stops @@ -247,8 +270,10 @@ peak, or reboot-preparation memory. Missing/invalid samples or gaps over three seconds fail measurement. A stock gate without the meter requires no measurements. Raw XCTest bundles and fixture logs stay private and are cleaned with owned resources. If owned cleanup cannot be confirmed, the working root is retained. -Only sanitized JSON proof is uploaded, including on failure, with fixed operation -labels, phase durations, setup RPC progress, and bounded exit/error diagnostics. +Alongside the Gateway selection artifact, sanitized JSON proof is uploaded, +including on failure, as `ios-release-e2e-MODE-RUN_ID-RUN_ATTEMPT`. It records +the candidate source and selected Gateway identities, fixed operation labels, +phase durations, setup RPC progress, and bounded exit/error diagnostics. Raw logs and setup codes are excluded. Setup-code timeouts are preparation failures and prevent native test execution. diff --git a/scripts/ios-release-e2e.ts b/scripts/ios-release-e2e.ts index 8ebd845b8211..382f3b1cf342 100644 --- a/scripts/ios-release-e2e.ts +++ b/scripts/ios-release-e2e.ts @@ -29,7 +29,8 @@ export type Operation = | "xcode-version" | "simslim-version" | "simulator-runtime" - | "gateway-build" + | "gateway-install" + | "gateway-preflight" | "native-generate" | "native-build" | "native-build-reuse" @@ -468,6 +469,7 @@ async function main() { "build-dir": { type: "string" }, "build-only": { type: "boolean", default: false }, "gateway-only": { type: "boolean", default: false }, + "gateway-selection": { type: "string" }, }, }); if ( @@ -521,6 +523,8 @@ async function main() { proof, buildDir: values["build-dir"], gatewayOnly: values["gateway-only"], + buildOnly: values["build-only"], + gatewaySelectionDir: path.resolve(values["gateway-selection"] ?? `${values.output}.gateway`), onProgress: writeProof, }); cleanup = native.cleanup; diff --git a/scripts/ios-release-gateway.ts b/scripts/ios-release-gateway.ts new file mode 100644 index 000000000000..d58b8798268b --- /dev/null +++ b/scripts/ios-release-gateway.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { selectIOSReleaseGateway } from "./lib/ios-release-gateway.js"; + +async function main() { + const { values } = parseArgs({ + options: { + "target-sha": { type: "string" }, + "selection-dir": { type: "string" }, + }, + }); + if (!values["target-sha"] || !values["selection-dir"]) { + throw new Error("usage: --target-sha --selection-dir "); + } + const abort = new AbortController(); + const cancel = () => abort.abort(); + process.on("SIGINT", cancel); + process.on("SIGTERM", cancel); + try { + const selection = await selectIOSReleaseGateway({ + targetSha: values["target-sha"], + selectionDir: path.resolve(values["selection-dir"]), + signal: abort.signal, + }); + console.log(`Selected stable Gateway ${selection.version} (${selection.sourceSha}).`); + } finally { + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "Stable Gateway selection failed."); + process.exitCode = 1; + }); +} diff --git a/scripts/lib/ios-release-e2e-native.ts b/scripts/lib/ios-release-e2e-native.ts index b5e4a2cec5e3..10307b1c0b4b 100644 --- a/scripts/lib/ios-release-e2e-native.ts +++ b/scripts/lib/ios-release-e2e-native.ts @@ -22,6 +22,7 @@ import { type Operation, type TrialDependencies, } from "../ios-release-e2e.js"; +import { prepareIOSReleaseGateway } from "./ios-release-gateway.js"; import { hasUnjoinedWork, runManagedCommand } from "./managed-child-process.mjs"; const DEVICE_TYPE = "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro"; @@ -38,6 +39,8 @@ export async function createNativeDependencies(options: { signal: AbortSignal; proof: Record; buildDir?: string; + gatewaySelectionDir?: string; + buildOnly?: boolean; gatewayOnly?: boolean; onProgress?: () => Promise; }): Promise<{ @@ -246,14 +249,78 @@ export async function createNativeDependencies(options: { await rm(root, { recursive: true, force: true }); }; try { - const buildStarted = performance.now(); - await command( - "gateway-build", - process.execPath, - ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"], - { timeoutMs: 1_200_000 }, - ); - options.proof.gatewayBuildMs = performance.now() - buildStarted; + const { createOpenClawTestInstance } = + await import("../../test/helpers/openclaw-test-instance.js"); + const { callGateway } = await import("../../src/gateway/call.js"); + let gateway: Awaited> | undefined; + if (!options.buildOnly) { + const installStarted = performance.now(); + gateway = await phase("gateway-install", async () => { + if (!options.gatewaySelectionDir) { + throw new OperationError("gateway-install", "not-found"); + } + try { + return await prepareIOSReleaseGateway({ + selectionDir: options.gatewaySelectionDir, + installDir: path.join(root, "gateway"), + targetSha: options.targetSha, + signal: options.signal, + }); + } catch (error) { + if (hasUnjoinedWork(error)) { + preserveResources(); + } + throw operationError("gateway-install", error); + } + }); + options.proof.gateway = gateway.identity; + options.proof.gatewayInstallMs = performance.now() - installStarted; + const installedGateway = gateway; + await phase("gateway-preflight", async () => { + let preflight: OpenClawTestInstance | undefined; + let failure: OperationError | undefined; + try { + preflight = await createOpenClawTestInstance({ + name: "ios-release-e2e-preflight", + cwd: installedGateway.cwd, + entrypoint: installedGateway.entrypoint, + config: { gateway: { controlUi: { enabled: false } } }, + env: gatewayEnv, + signal: options.signal, + }); + await preflight.startGateway(); + await callGateway({ + config: {}, + configPath: preflight.configPath, + url: preflight.url, + token: preflight.gatewayToken, + ignoreEnvUrlOverride: true, + deviceIdentity: null, + sharedStateMode: "read-only", + method: "device.pair.setupStatus", + params: { setupId: randomUUID() }, + // Pairing methods are intentionally unadvertised; prove setupStatus by calling it. + requiredMethods: ["chat.send", "chat.history"], + timeoutMs: 30_000, + signal: options.signal, + }); + } catch (error) { + if (hasUnjoinedWork(error)) { + preserveResources(); + } + failure = operationError("gateway-preflight", error); + } + try { + await preflight?.cleanup(); + } catch { + preserveResources(); + failure = new OperationError("cleanup", "cleanup-unconfirmed"); + } + if (failure) { + throw failure; + } + }); + } const nativeStarted = performance.now(); const buildArgs = [ "-project", @@ -323,9 +390,6 @@ export async function createNativeDependencies(options: { options.proof.nativeBuildReused = build.reused; options.proof.nativeBuildMs = performance.now() - nativeStarted; } - const { createOpenClawTestInstance } = - await import("../../test/helpers/openclaw-test-instance.js"); - const { callGateway } = await import("../../src/gateway/call.js"); return { assertCurrentSource, cleanup, @@ -337,6 +401,10 @@ export async function createNativeDependencies(options: { }, measure: Boolean(binary), async create(arm, index) { + if (!gateway) { + throw new OperationError("gateway-install", "not-found"); + } + const installedGateway = gateway; currentTrial = index; const fixtureEvidence: Record = { trial: index }; const fixtures = (options.proof.fixtures ??= []) as Record[]; @@ -492,7 +560,8 @@ export async function createNativeDependencies(options: { try { instance = await createOpenClawTestInstance({ name: `ios-release-e2e-${index}`, - cwd, + cwd: installedGateway.cwd, + entrypoint: installedGateway.entrypoint, config, env: gatewayEnv, }); diff --git a/scripts/lib/ios-release-gateway.ts b/scripts/lib/ios-release-gateway.ts new file mode 100644 index 000000000000..2570ebd800e2 --- /dev/null +++ b/scripts/lib/ios-release-gateway.ts @@ -0,0 +1,302 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { hasUnjoinedWork, runManagedCommand } from "./managed-child-process.mjs"; +import { parseReleaseVersion } from "./release-version.mjs"; + +const REGISTRY = "https://registry.npmjs.org"; +const GITHUB = "https://api.github.com/repos/openclaw/openclaw/git"; +const SHA = /^[a-f0-9]{40}$/u; +const DIGEST = /^[a-f0-9]{64}$/u; +const selectionSchema = z + .object({ + schema: z.literal(1), + targetSha: z.string().regex(SHA), + version: z.string(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/u), + tarball: z.string().url(), + sourceSha: z.string().regex(SHA), + packageSha256: z.string().regex(DIGEST), + lockSha256: z.string().regex(DIGEST), + nodeVersion: z.string(), + npmVersion: z.string(), + platform: z.string(), + arch: z.string(), + }) + .strict(); +type Selection = z.infer; +type SelectionOptions = { selectionDir: string; targetSha: string; signal: AbortSignal }; + +function digest(bytes: string | Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function requireStable(version: string): void { + const parsed = parseReleaseVersion(version); + if (!parsed || parsed.channel !== "stable" || parsed.patch >= 33) { + throw new Error("The stable Gateway must be a regular stable OpenClaw release."); + } +} + +async function metadata(url: string, signal: AbortSignal): Promise { + const response = await fetch(url, { + signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]), + headers: { Accept: "application/json", "User-Agent": "openclaw-ios-qualification" }, + }); + if (!response.ok) { + throw new Error(`Stable Gateway metadata request failed (HTTP ${response.status}).`); + } + return response.json(); +} + +async function sourceSha(version: string, signal: AbortSignal): Promise { + const objectSchema = z.object({ + object: z.object({ type: z.string(), sha: z.string().regex(SHA) }), + }); + let { object } = objectSchema.parse(await metadata(`${GITHUB}/ref/tags/v${version}`, signal)); + if (object.type === "tag") { + ({ object } = objectSchema.parse(await metadata(`${GITHUB}/tags/${object.sha}`, signal))); + } + if (object.type !== "commit") { + throw new Error("Stable Gateway release tag must resolve to a commit."); + } + return object.sha; +} + +async function npm(args: string[], cwd: string, signal: AbortSignal): Promise { + // Package lifecycle scripts get task-owned state and public-registry access, never operator credentials. + const home = await mkdtemp(path.join(path.dirname(cwd), ".ios-gateway-npm-")); + let unjoined = false; + try { + await Promise.all([ + writeFile(path.join(home, "user.npmrc"), ""), + writeFile(path.join(home, "global.npmrc"), ""), + mkdir(path.join(home, "tmp")), + ]); + let stdout = ""; + const overflow = new AbortController(); + let bytes = 0; + const code = await runManagedCommand({ + bin: "npm", + args, + cwd, + env: { + PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}`, + HOME: home, + TMPDIR: path.join(home, "tmp"), + LANG: "en_US.UTF-8", + CI: "true", + npm_config_userconfig: path.join(home, "user.npmrc"), + npm_config_globalconfig: path.join(home, "global.npmrc"), + npm_config_registry: REGISTRY, + npm_config_cache: path.join(home, "cache"), + npm_config_update_notifier: "false", + }, + stdio: ["ignore", "pipe", "pipe"], + timeoutMs: 900_000, + signal: AbortSignal.any([signal, overflow.signal]), + requireProcessTreeExit: true, + onReady(child) { + for (const stream of [child.stdout, child.stderr]) { + stream?.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > 16 * 1024 * 1024) { + overflow.abort(); + } else if (stream === child.stdout) { + stdout += chunk.toString("utf8"); + } else { + process.stderr.write(chunk); + } + }); + } + }, + }); + if (code !== 0 || overflow.signal.aborted) { + throw new Error(`Stable Gateway npm ${args[0]} failed (exit ${code}).`); + } + return stdout.trim(); + } catch (error) { + unjoined = hasUnjoinedWork(error); + throw error; + } finally { + if (!unjoined) { + await rm(home, { recursive: true, force: true }); + } + } +} + +function validateLock(selection: Selection, packageBytes: string, lockBytes: string): void { + if ( + digest(packageBytes) !== selection.packageSha256 || + digest(lockBytes) !== selection.lockSha256 + ) { + throw new Error("Stable Gateway selection manifests changed."); + } + const wrapper = z + .object({ private: z.literal(true), dependencies: z.object({ openclaw: z.string() }).strict() }) + .parse(JSON.parse(packageBytes)); + const lock = z + .object({ + lockfileVersion: z.literal(3), + packages: z.record(z.string(), z.unknown()), + }) + .parse(JSON.parse(lockBytes)); + const root = z + .object({ dependencies: z.object({ openclaw: z.string() }) }) + .parse(lock.packages[""]); + const installed = z + .object({ version: z.string(), resolved: z.string(), integrity: z.string() }) + .parse(lock.packages["node_modules/openclaw"]); + if ( + wrapper.dependencies.openclaw !== selection.version || + root.dependencies.openclaw !== selection.version || + installed.version !== selection.version || + installed.resolved !== selection.tarball || + installed.integrity !== selection.integrity + ) { + throw new Error("Stable Gateway package lock does not match the selected release."); + } +} + +async function readSelection(options: SelectionOptions): Promise { + const selection = selectionSchema.parse( + JSON.parse(await readFile(path.join(options.selectionDir, "selection.json"), "utf8")), + ); + requireStable(selection.version); + if ( + selection.targetSha !== options.targetSha || + selection.tarball !== `${REGISTRY}/openclaw/-/openclaw-${selection.version}.tgz` + ) { + throw new Error("Stable Gateway selection does not match this qualification target."); + } + if ( + selection.nodeVersion !== process.version || + selection.platform !== process.platform || + selection.arch !== process.arch + ) { + throw new Error( + "Replay requires the Node version and platform recorded in the Gateway selection.", + ); + } + const [packageBytes, lockBytes] = await Promise.all([ + readFile(path.join(options.selectionDir, "package.json"), "utf8"), + readFile(path.join(options.selectionDir, "package-lock.json"), "utf8"), + ]); + validateLock(selection, packageBytes, lockBytes); + if ((await npm(["--version"], options.selectionDir, options.signal)) !== selection.npmVersion) { + throw new Error("Replay requires the npm version recorded in the Gateway selection."); + } + return selection; +} + +/** The directory is the replayable input artifact; an incomplete existing selection never floats to latest. */ +export async function selectIOSReleaseGateway(options: SelectionOptions): Promise { + options.signal.throwIfAborted(); + if (!SHA.test(options.targetSha)) { + throw new Error("Stable Gateway selection requires a full candidate source SHA."); + } + const selectionDir = path.resolve(options.selectionDir); + const { lstat } = await import("node:fs/promises"); + const exists = await lstat(selectionDir).then( + () => true, + (error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return false; + } + throw error; + }, + ); + if (exists) { + return readSelection({ ...options, selectionDir }); + } + await mkdir(path.dirname(selectionDir), { recursive: true }); + const staging = await mkdtemp(path.join(path.dirname(selectionDir), ".ios-gateway-selection-")); + let unjoined = false; + try { + const info = z + .object({ + name: z.literal("openclaw"), + version: z.string(), + dist: z.object({ tarball: z.string(), integrity: z.string() }), + }) + .parse(await metadata(`${REGISTRY}/openclaw/latest`, options.signal)); + requireStable(info.version); + if (info.dist.tarball !== `${REGISTRY}/openclaw/-/openclaw-${info.version}.tgz`) { + throw new Error("Stable Gateway metadata has an unexpected tarball URL."); + } + const sha = await sourceSha(info.version, options.signal); + const npmVersion = await npm(["--version"], staging, options.signal); + const packageBytes = `${JSON.stringify({ name: "openclaw-ios-qualification", version: "0.0.0", private: true, dependencies: { openclaw: info.version } }, null, 2)}\n`; + await writeFile(path.join(staging, "package.json"), packageBytes); + await npm( + ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], + staging, + options.signal, + ); + const lockBytes = await readFile(path.join(staging, "package-lock.json"), "utf8"); + const selection = selectionSchema.parse({ + schema: 1, + targetSha: options.targetSha, + version: info.version, + integrity: info.dist.integrity, + tarball: info.dist.tarball, + sourceSha: sha, + packageSha256: digest(packageBytes), + lockSha256: digest(lockBytes), + nodeVersion: process.version, + npmVersion, + platform: process.platform, + arch: process.arch, + }); + validateLock(selection, packageBytes, lockBytes); + await writeFile( + path.join(staging, "selection.json"), + `${JSON.stringify(selection, null, 2)}\n`, + ); + options.signal.throwIfAborted(); + await rename(staging, selectionDir); + return selection; + } catch (error) { + unjoined = hasUnjoinedWork(error); + throw error; + } finally { + if (!unjoined) { + await rm(staging, { recursive: true, force: true }); + } + } +} + +export async function prepareIOSReleaseGateway(options: SelectionOptions & { installDir: string }) { + const selection = await selectIOSReleaseGateway(options); + await mkdir(options.installDir); + await Promise.all( + ["package.json", "package-lock.json"].map((file) => + copyFile(path.join(options.selectionDir, file), path.join(options.installDir, file)), + ), + ); + await npm(["ci", "--no-audit", "--no-fund"], options.installDir, options.signal); + const cwd = path.join(options.installDir, "node_modules", "openclaw"); + const [packageBytes, buildBytes, installedLock] = await Promise.all([ + readFile(path.join(cwd, "package.json"), "utf8"), + readFile(path.join(cwd, "dist", "build-info.json"), "utf8"), + readFile(path.join(options.installDir, "package-lock.json"), "utf8"), + ]); + const pkg = z + .object({ + name: z.literal("openclaw"), + version: z.string(), + bin: z.object({ openclaw: z.literal("openclaw.mjs") }), + }) + .parse(JSON.parse(packageBytes)); + const build = z.object({ commit: z.string().regex(SHA) }).parse(JSON.parse(buildBytes)); + if ( + pkg.version !== selection.version || + build.commit !== selection.sourceSha || + digest(installedLock) !== selection.lockSha256 + ) { + throw new Error("Installed Gateway identity differs from the selected stable release."); + } + options.signal.throwIfAborted(); + return { cwd, entrypoint: [path.join(cwd, "openclaw.mjs")], identity: selection }; +} diff --git a/test/scripts/ios-release-e2e.test.ts b/test/scripts/ios-release-e2e.test.ts index 2383a5579134..a9f0823f6b56 100644 --- a/test/scripts/ios-release-e2e.test.ts +++ b/test/scripts/ios-release-e2e.test.ts @@ -20,6 +20,7 @@ import { type TrialDependencies, } from "../../scripts/ios-release-e2e.js"; import { createNativeDependencies } from "../../scripts/lib/ios-release-e2e-native.js"; +import { ensureGatewaySupportsRequiredFeatures } from "../../src/gateway/call-required-features.js"; import { GatewayTransportError } from "../../src/gateway/transport-error.js"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; import { evaluateWorkflowExpression } from "./ci-workflow.test-support.js"; @@ -27,6 +28,7 @@ import { evaluateWorkflowExpression } from "./ci-workflow.test-support.js"; const nativeMocks = vi.hoisted(() => ({ command: vi.fn(), build: vi.fn(), + install: vi.fn(), gateway: vi.fn(), rpc: vi.fn(), })); @@ -37,6 +39,9 @@ vi.mock("../../scripts/lib/managed-child-process.mjs", async (importOriginal) => vi.mock("../../scripts/lib/ios-release-e2e-build.js", () => ({ prepareIOSReleaseNativeBuild: nativeMocks.build, })); +vi.mock("../../scripts/lib/ios-release-gateway.js", () => ({ + prepareIOSReleaseGateway: nativeMocks.install, +})); vi.mock("../helpers/openclaw-test-instance.js", () => ({ createOpenClawTestInstance: nativeMocks.gateway, })); @@ -49,6 +54,7 @@ afterEach(() => { vi.unstubAllEnvs(); nativeMocks.command.mockReset(); nativeMocks.build.mockReset(); + nativeMocks.install.mockReset(); nativeMocks.gateway.mockReset(); nativeMocks.rpc.mockReset(); }); @@ -171,7 +177,7 @@ it("writes a failure proof when the real CLI rejects an impossible target", () = trials: [], errors: ["gate-setup-failed"], }); - expect(proof.gatewayBuildMs).toBeUndefined(); + expect(proof.gatewayInstallMs).toBeUndefined(); expect(proof.nativeBuildMs).toBeUndefined(); }); @@ -425,6 +431,94 @@ describe("release qualification workflow authority", () => { const workflow = parse(readFileSync(".github/workflows/ios-release-e2e.yml", "utf8")); const release = parse(readFileSync(".github/workflows/ios-store-release.yml", "utf8")); const ci = parse(readFileSync(".github/workflows/ci.yml", "utf8")); + const selectionArtifact = { + id: 456, + name: "ios-release-gateway-selection-123", + expired: false, + workflow_run: { id: 123, head_sha: "a".repeat(40) }, + }; + async function findGatewaySelection( + attempt: number, + artifacts: (typeof selectionArtifact)[], + outputs: Map, + apiFailure?: Error, + ) { + const { runInNewContext } = await import("node:vm"); + const step = workflow.jobs.qualify.steps.find( + (candidate: { id?: string }) => candidate.id === "gateway-selection", + ); + const listWorkflowRunArtifacts = vi.fn(); + const paginate = apiFailure + ? vi.fn().mockRejectedValue(apiFailure) + : vi.fn().mockResolvedValue(artifacts); + const execution: unknown = runInNewContext(`(async () => { ${step.with.script} })()`, { + github: { rest: { actions: { listWorkflowRunArtifacts } }, paginate }, + context: { repo: { owner: "openclaw", repo: "openclaw" }, runId: 123 }, + core: { setOutput: (name: string, value: string) => outputs.set(name, value) }, + process: { + env: { + GITHUB_RUN_ATTEMPT: String(attempt), + TARGET_SHA: selectionArtifact.workflow_run.head_sha, + }, + }, + }); + await execution; + expect(paginate).toHaveBeenCalledWith(listWorkflowRunArtifacts, { + owner: "openclaw", + repo: "openclaw", + run_id: 123, + name: selectionArtifact.name, + per_page: 100, + }); + } + it.each([ + ["new workflow run", 1, [], { create: "true", "artifact-id": "" }], + [ + "existing first-attempt selection", + 1, + [selectionArtifact], + { create: "false", "artifact-id": "456" }, + ], + ["workflow rerun", 2, [selectionArtifact], { create: "false", "artifact-id": "456" }], + ])("preserves the Gateway selection for %s", async (_name, attempt, artifacts, expected) => { + const outputs = new Map(); + await findGatewaySelection(attempt, artifacts, outputs); + expect(Object.fromEntries(outputs)).toEqual(expected); + }); + it.each([ + ["missing rerun pin", [], "no saved stable Gateway selection"], + [ + "expired pin", + [{ ...selectionArtifact, expired: true }], + "expired or belongs to another source/run", + ], + [ + "duplicate pins", + [selectionArtifact, selectionArtifact], + "Multiple stable Gateway selections", + ], + [ + "different source", + [{ ...selectionArtifact, workflow_run: { id: 123, head_sha: "b".repeat(40) } }], + "expired or belongs to another source/run", + ], + [ + "different run", + [{ ...selectionArtifact, workflow_run: { ...selectionArtifact.workflow_run, id: 124 } }], + "expired or belongs to another source/run", + ], + ])("rejects %s without authorizing a new selection", async (_name, artifacts, error) => { + const outputs = new Map(); + await expect(findGatewaySelection(2, artifacts, outputs)).rejects.toThrow(error); + expect(outputs.size).toBe(0); + }); + it("fails artifact lookup errors without treating the pin as missing", async () => { + const outputs = new Map(); + await expect( + findGatewaySelection(1, [], outputs, new Error("artifact API unavailable")), + ).rejects.toThrow("artifact API unavailable"); + expect(outputs.size).toBe(0); + }); it.each([ ["manual current revision", {}, true], ["CI current revision", { caller: "ci" }, true], @@ -459,6 +553,9 @@ describe("release qualification workflow authority", () => { }, }); expect(execution.status === 0).toBe(admitted); + expect(readFileSync(path.join(root, "env"), "utf8")).toContain( + `GATEWAY_SELECTION_DIR=${root}/ios-release-gateway-selection\n`, + ); const proof = JSON.parse(readFileSync(path.join(root, "ios-release-e2e-proof.json"), "utf8")); expect(proof).toMatchObject({ status: "failed", @@ -470,7 +567,7 @@ describe("release qualification workflow authority", () => { const qualification = release.jobs[releaseJob.needs]; expect(qualification).toMatchObject({ uses: "./.github/workflows/ios-release-e2e.yml", - permissions: { contents: "read" }, + permissions: { actions: "read", contents: "read" }, with: { target_sha: "${{ github.sha }}", mode: "stock" }, }); expect(qualification.if).toBe(releaseJob.if); @@ -510,7 +607,8 @@ describe("release qualification workflow authority", () => { ).toBe(outcome !== "skipped"); } expect(recovery.with["if-no-files-found"]).toBe("error"); - expect(workflow.permissions).toEqual({ contents: "read" }); + expect(workflow.permissions).toEqual({ actions: "read", contents: "read" }); + expect(ci.jobs["ios-release-e2e"].permissions).toEqual({ actions: "read", contents: "read" }); expect(workflow.jobs.qualify["runs-on"]).toBe("xcode-27-xlarge"); expect(workflow.jobs.qualify.environment).toBeUndefined(); expect(workflow.on.workflow_dispatch.inputs.target_sha).toBeUndefined(); @@ -528,11 +626,14 @@ describe("release qualification workflow authority", () => { expect(verify.run).toContain("test -f scripts/ios-release-e2e.ts"); expect(verify.if).toBeUndefined(); expect(workflow.jobs.qualify.env.OPENCLAW_CI_SIMSLIM_BINARY).toBeUndefined(); - const upload = steps.find((step: { uses?: string }) => - step.uses?.startsWith("actions/upload-artifact@"), + const upload = steps.find( + (step: { name: string }) => step.name === "Upload sanitized proof only", ); expect(upload.if).toBe("always()"); expect(upload.with.path).toBe("${{ runner.temp }}/ios-release-e2e-proof.json"); + expect(upload.with.name).toBe( + "ios-release-e2e-${{ inputs.mode }}-${{ github.run_id }}-${{ github.run_attempt }}", + ); }); it.each([ ["full", "a".repeat(40), true], @@ -587,6 +688,10 @@ describe("native command adapter", () => { "unsupported-runtime-architecture", "non-ios-runtime", "cleanup-failure", + "gateway-install-failure", + "gateway-install-unjoined", + "gateway-preflight-failure", + "gateway-preflight-cleanup-failure", "build-unjoined", "build-exit", "boot-timeout", @@ -598,6 +703,7 @@ describe("native command adapter", () => { "gateway-exit-during-boot", "cancel-during-boot", "gateway-only", + "native-build-only", "setup-code-timeout", "setup-code-rpc-timeout", "test-unjoined", @@ -631,6 +737,42 @@ describe("native command adapter", () => { vi.stubEnv("OPENCLAW_CI_SIMSLIM_BINARY", ""); const instances: { cleanup: ReturnType }[] = []; const lifecycle: string[] = []; + const gatewayIdentity = { + version: "2026.9.29", + integrity: "sha512-YQ==", + sourceSha: "2".repeat(40), + lockSha256: "3".repeat(64), + nodeVersion: process.version, + npmVersion: "11.6.2", + }; + let installedGatewayPath = ""; + nativeMocks.install.mockImplementation(async (options) => { + lifecycle.push("gateway-install"); + if (scenario.startsWith("gateway-install-")) { + throw Object.assign( + new Error("private package installation failure"), + scenario === "gateway-install-unjoined" + ? { code: "ETIMEDOUT", processTreeState: "live" } + : {}, + ); + } + installedGatewayPath = path.join(options.installDir, "node_modules/openclaw"); + return { + cwd: installedGatewayPath, + entrypoint: ["openclaw.mjs"], + identity: gatewayIdentity, + }; + }); + const preflightCleanup = vi.fn(async () => { + lifecycle.push("gateway-preflight-cleanup"); + if (scenario === "gateway-preflight-cleanup-failure") { + throw new Error("private preflight cleanup failure"); + } + }); + const fixtureRpcCalls = () => + nativeMocks.rpc.mock.calls.filter( + ([options]) => options.configPath !== "/private/preflight/config.json", + ); let simulatorReady = false; let sourceChanged = false; let exitMock: (() => void) | undefined; @@ -649,6 +791,20 @@ describe("native command adapter", () => { let nativeCommandActive = false; let historyReadBeforeCommandExit = false; nativeMocks.rpc.mockImplementation(async (options) => { + if (options.configPath === "/private/preflight/config.json") { + expect(options).toMatchObject({ + method: "device.pair.setupStatus", + }); + lifecycle.push("gateway-preflight-rpc"); + expect(nativeMocks.build).not.toHaveBeenCalled(); + ensureGatewaySupportsRequiredFeatures({ + required: options.requiredMethods, + supported: scenario === "gateway-preflight-failure" ? [] : ["chat.send", "chat.history"], + kind: "method", + attemptedMethod: options.method, + }); + return {}; + } if (options.method === "chat.history") { historyReadBeforeCommandExit = nativeCommandActive; if (scenario === "reply-failure-history-error") { @@ -707,7 +863,23 @@ describe("native command adapter", () => { } return { setupCode: `synthetic-code-${instances.length}` }; }); - nativeMocks.gateway.mockImplementation(async () => { + nativeMocks.gateway.mockImplementation(async (options) => { + expect(options).toMatchObject({ + cwd: installedGatewayPath, + entrypoint: ["openclaw.mjs"], + }); + if (options.name === "ios-release-e2e-preflight") { + lifecycle.push("gateway-preflight-create"); + return { + url: "ws://127.0.0.1:19999", + gatewayToken: "synthetic-preflight-token", + configPath: "/private/preflight/config.json", + startGateway: vi.fn(async () => { + lifecycle.push("gateway-preflight-start"); + }), + cleanup: preflightCleanup, + }; + } expect(simulatorReady).toBe(false); lifecycle.push("gateway-create"); const index = instances.length + 1; @@ -879,6 +1051,10 @@ describe("native command adapter", () => { } else if (args.includes("delete")) { lifecycle.push("simulator-delete"); } else if (args.includes("build-for-testing")) { + if (scenario !== "native-build-only") { + expect(preflightCleanup).toHaveBeenCalledOnce(); + expect(lifecycle).toContain("gateway-preflight-rpc"); + } appContainer = path.join( path.dirname(args[args.indexOf("-derivedDataPath") + 1]!), "app-container", @@ -1082,6 +1258,8 @@ describe("native command adapter", () => { targetSha: "1".repeat(40), signal: abort.signal, gatewayOnly: scenario === "gateway-only", + buildOnly: scenario === "native-build-only", + gatewaySelectionDir: path.join(temp, "selection"), proof, onProgress: async () => { progressSnapshots.push(JSON.stringify(proof)); @@ -1127,6 +1305,38 @@ describe("native command adapter", () => { expect(proof.resourcesPreserved).toBe(scenario === "build-unjoined" ? true : undefined); return; } + if (scenario.startsWith("gateway-install-")) { + await expect(admission).rejects.toMatchObject({ + diagnostic: { + operation: "gateway-install", + code: scenario === "gateway-install-unjoined" ? "timeout" : "failed", + }, + }); + expect(nativeMocks.gateway).not.toHaveBeenCalled(); + expect(nativeMocks.build).not.toHaveBeenCalled(); + expect(readdirSync(temp)).toHaveLength(scenario === "gateway-install-unjoined" ? 1 : 0); + expect(proof.resourcesPreserved).toBe( + scenario === "gateway-install-unjoined" ? true : undefined, + ); + return; + } + if (scenario.startsWith("gateway-preflight-")) { + await expect(admission).rejects.toMatchObject({ + diagnostic: + scenario === "gateway-preflight-cleanup-failure" + ? { operation: "cleanup", code: "cleanup-unconfirmed" } + : { operation: "gateway-preflight", code: "failed" }, + }); + expect(nativeMocks.build).not.toHaveBeenCalled(); + expect(preflightCleanup).toHaveBeenCalledOnce(); + expect(instances).toEqual([]); + expect(created).toBe(0); + expect(lifecycle).not.toContain("mock-start"); + expect(readdirSync(temp)).toHaveLength( + scenario === "gateway-preflight-cleanup-failure" ? 1 : 0, + ); + return; + } const native = await admission; expect(proof).toMatchObject({ xcode: scenario === "different-xcode" ? "26.6" : "27.0", @@ -1150,6 +1360,24 @@ describe("native command adapter", () => { : "com.apple.CoreSimulator.SimRuntime.iOS-26-5", }); try { + if (scenario === "native-build-only") { + expect(nativeMocks.build).toHaveBeenCalledOnce(); + expect(nativeMocks.install).not.toHaveBeenCalled(); + expect(nativeMocks.gateway).not.toHaveBeenCalled(); + expect(nativeMocks.rpc).not.toHaveBeenCalled(); + expect(created).toBe(0); + expect(proof.gateway).toBeUndefined(); + expect(proof.gatewayInstallMs).toBeUndefined(); + return; + } + expect(nativeMocks.install).toHaveBeenCalledExactlyOnceWith({ + selectionDir: path.join(temp, "selection"), + installDir: expect.stringMatching(/\/openclaw-ios-release-e2e-[^/]+\/gateway$/u), + targetSha: "1".repeat(40), + signal: abort.signal, + }); + expect(proof.gateway).toEqual(gatewayIdentity); + expect(proof.gatewayInstallMs).toEqual(expect.any(Number)); if (scenario === "gateway-only") { const gatewayProbe = await native.dependencies.create("stock", 1); try { @@ -1158,6 +1386,11 @@ describe("native command adapter", () => { await gatewayProbe.cleanup(); } expect(lifecycle).toEqual([ + "gateway-install", + "gateway-preflight-create", + "gateway-preflight-start", + "gateway-preflight-rpc", + "gateway-preflight-cleanup", "mock-start", "gateway-create", "gateway-start", @@ -1170,7 +1403,7 @@ describe("native command adapter", () => { expect(nativeMocks.build).not.toHaveBeenCalled(); expect(created).toBe(0); expect(joinedMocks).toBe(1); - expect(nativeMocks.rpc.mock.calls.map(([options]) => options.method)).toEqual([ + expect(fixtureRpcCalls().map(([options]) => options.method)).toEqual([ "device.pair.setupStatus", "device.pair.setupCode", ]); @@ -1281,7 +1514,7 @@ describe("native command adapter", () => { expect(lifecycle.at(-1)).toBe("simulator-delete"); } else { expect(created).toBe(0); - expect(nativeMocks.rpc).not.toHaveBeenCalled(); + expect(fixtureRpcCalls()).toHaveLength(0); } return; } @@ -1309,7 +1542,7 @@ describe("native command adapter", () => { ], }, ]); - expect(nativeMocks.rpc).toHaveBeenCalledOnce(); + expect(fixtureRpcCalls()).toHaveLength(1); expect(created).toBe(0); expect(lifecycle).not.toContain("setup-code"); expect(lifecycle).not.toContain("live-test"); @@ -1337,7 +1570,7 @@ describe("native command adapter", () => { }, ]); expect(proof.resourcesPreserved).toBe(true); - expect(nativeMocks.rpc).toHaveBeenCalledOnce(); + expect(fixtureRpcCalls()).toHaveLength(1); expect(created).toBe(0); expect(instances[0]?.cleanup).toHaveBeenCalledOnce(); expect(joinedMocks).toBe(1); @@ -1510,9 +1743,8 @@ describe("native command adapter", () => { expect(created).toBe(1); expect(joinedMocks).toBe(1); for (const [index, instance] of instances.entries()) { - expect(nativeMocks.rpc).toHaveBeenCalledTimes(2); - expect(nativeMocks.rpc).toHaveBeenNthCalledWith( - 1, + expect(fixtureRpcCalls()).toHaveLength(2); + expect(fixtureRpcCalls()[0]?.[0]).toEqual( expect.objectContaining({ method: "device.pair.setupStatus", params: { @@ -1524,7 +1756,7 @@ describe("native command adapter", () => { url: `ws://127.0.0.1:${20001 + index}`, }), ); - expect(nativeMocks.rpc).toHaveBeenNthCalledWith(2, { + expect(fixtureRpcCalls()[1]?.[0]).toEqual({ config: {}, configPath: `/private/fixture-${index + 1}/config.json`, url: `ws://127.0.0.1:${20001 + index}`, @@ -1627,7 +1859,7 @@ describe("native command adapter", () => { expect( commands.filter(({ args }) => args.includes("delete")).map(({ args }) => args.at(-1)), ).toEqual(["11111111-2222-3333-4444-000000000001"]); - expect(nativeMocks.gateway.mock.calls[0]?.[0]).toMatchObject({ + expect(nativeMocks.gateway.mock.calls[1]?.[0]).toMatchObject({ config: { gateway: { controlUi: { enabled: false } }, agents: { defaults: { model: { primary: "openai/ios-e2e" } } }, diff --git a/test/scripts/ios-release-gateway.test.ts b/test/scripts/ios-release-gateway.test.ts new file mode 100644 index 000000000000..ab4bcc47ec29 --- /dev/null +++ b/test/scripts/ios-release-gateway.test.ts @@ -0,0 +1,338 @@ +import { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + prepareIOSReleaseGateway, + selectIOSReleaseGateway, +} from "../../scripts/lib/ios-release-gateway.js"; +import type { RunManagedCommandOptions } from "../../scripts/lib/managed-child-process.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const command = vi.hoisted(() => vi.fn<(options: RunManagedCommandOptions) => Promise>()); +vi.mock("../../scripts/lib/managed-child-process.mjs", async (importOriginal) => ({ + ...(await importOriginal()), + runManagedCommand: command, +})); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const targetSha = "1".repeat(40); +const sourceSha = "2".repeat(40); +const tagSha = "3".repeat(40); +const version = "2026.9.7"; +const npmVersion = "11.20.0"; +const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`; +const tarball = `https://registry.npmjs.org/openclaw/-/openclaw-${version}.tgz`; + +afterEach(() => { + command.mockReset(); + vi.unstubAllGlobals(); +}); + +function writeJson(file: string, value: unknown) { + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function readJson(file: string): Record { + return JSON.parse(readFileSync(file, "utf8")); +} + +function fixture(options: { annotatedTag?: boolean } = {}) { + const root = tempDirs.make("ios-release-gateway-"); + const selectionDir = path.join(root, "selection"); + const installDir = path.join(root, "install"); + const signal = new AbortController().signal; + const state = { + metadata: { name: "openclaw", version, dist: { integrity, tarball } }, + sourceSha, + npmVersion, + installedVersion: version, + installedCommit: sourceSha, + installExitCode: 0, + cancelInstall: undefined as AbortController | undefined, + }; + const lock = { + name: "ios-release-gateway-fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { dependencies: { openclaw: version } }, + "node_modules/openclaw": { + version, + resolved: tarball, + integrity, + dependencies: { "fixture-transitive": "^1.0.0" }, + }, + "node_modules/fixture-transitive": { + version: "1.2.3", + resolved: "https://registry.npmjs.org/fixture-transitive/-/fixture-transitive-1.2.3.tgz", + integrity: `sha512-${Buffer.alloc(64, 2).toString("base64")}`, + }, + }, + }; + const requests: string[] = []; + const fetch = vi.fn(async (url: string) => { + requests.push(url); + if (url === "https://registry.npmjs.org/openclaw/latest") { + return Response.json(state.metadata); + } + if (url.endsWith(`/tags/v${version}`)) { + return Response.json({ + object: { + type: options.annotatedTag ? "tag" : "commit", + sha: options.annotatedTag ? tagSha : state.sourceSha, + }, + }); + } + if (options.annotatedTag && url.endsWith(`/git/tags/${tagSha}`)) { + return Response.json({ object: { type: "commit", sha: state.sourceSha } }); + } + throw new Error(`Unexpected metadata request: ${url}`); + }); + vi.stubGlobal("fetch", fetch); + const installs: Array<{ packageBytes: string; lockBytes: string }> = []; + command.mockImplementation(async (operation: RunManagedCommandOptions) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new ChildProcess(); + child.stdout = stdout; + child.stderr = stderr; + operation.onReady?.(child); + try { + expect(path.basename(operation.bin)).toMatch(/^npm(?:\.cmd)?$/u); + const args = operation.args ?? []; + if (args.includes("--version")) { + stdout.write(`${state.npmVersion}\n`); + return 0; + } + const cwd = operation.cwd; + if (!cwd) { + throw new Error("npm requires an isolated working directory"); + } + if (args.includes("--package-lock-only")) { + expect(args).toEqual( + expect.arrayContaining(["install", "--ignore-scripts", "--no-audit", "--no-fund"]), + ); + expect(readJson(path.join(cwd, "package.json")).dependencies).toEqual({ + openclaw: version, + }); + writeJson(path.join(cwd, "package-lock.json"), lock); + return 0; + } + if (args.includes("ci")) { + installs.push({ + packageBytes: readFileSync(path.join(cwd, "package.json"), "utf8"), + lockBytes: readFileSync(path.join(cwd, "package-lock.json"), "utf8"), + }); + if (state.cancelInstall) { + expect(operation.signal?.aborted).toBe(false); + state.cancelInstall.abort(new Error("fixture install cancellation")); + expect(operation.signal?.aborted).toBe(true); + operation.signal?.throwIfAborted(); + } + if (state.installExitCode) { + return state.installExitCode; + } + const packageRoot = path.join(cwd, "node_modules", "openclaw"); + mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); + writeJson(path.join(packageRoot, "package.json"), { + name: "openclaw", + version: state.installedVersion, + bin: { openclaw: "openclaw.mjs" }, + }); + writeJson(path.join(packageRoot, "dist", "build-info.json"), { + version: state.installedVersion, + commit: state.installedCommit, + }); + writeFileSync(path.join(packageRoot, "openclaw.mjs"), "// synthetic package entrypoint\n"); + return 0; + } + throw new Error(`Unexpected npm command: ${args.join(" ")}`); + } finally { + stdout.end(); + stderr.end(); + } + }); + return { root, selectionDir, installDir, signal, state, lock, fetch, requests, installs }; +} + +describe("iOS stable Gateway package qualification", () => { + it.each([false, true])( + "freezes the published selection and replays its exact dependency graph (annotated tag: %s)", + async (annotatedTag) => { + const f = fixture({ annotatedTag }); + const selected = await selectIOSReleaseGateway({ + selectionDir: f.selectionDir, + targetSha, + signal: f.signal, + }); + expect(selected).toMatchObject({ targetSha, version, sourceSha, integrity, tarball }); + const packageBytes = readFileSync(path.join(f.selectionDir, "package.json"), "utf8"); + const lockBytes = readFileSync(path.join(f.selectionDir, "package-lock.json"), "utf8"); + expect(selected).toMatchObject({ + packageSha256: createHash("sha256").update(packageBytes).digest("hex"), + lockSha256: createHash("sha256").update(lockBytes).digest("hex"), + }); + expect(f.requests).toHaveLength(annotatedTag ? 3 : 2); + f.fetch.mockImplementation(async () => { + throw new Error("The registry and moving latest tag are unavailable during replay"); + }); + const prepared = await prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha, + }); + expect(prepared.identity).toEqual(selected); + expect(prepared.cwd).toBe(path.join(f.installDir, "node_modules", "openclaw")); + expect(prepared.entrypoint).toEqual([path.join(prepared.cwd, "openclaw.mjs")]); + expect(f.installs).toEqual([{ packageBytes, lockBytes }]); + expect(f.fetch).toHaveBeenCalledTimes(annotatedTag ? 3 : 2); + expect( + command.mock.calls.filter(([operation]) => operation.args?.includes("--package-lock-only")), + ).toHaveLength(1); + }, + ); + + it.each(["integrity", "source SHA", "prerelease"])( + "rejects invalid published %s before installing a Gateway", + async (invalid) => { + const f = fixture(); + if (invalid === "integrity") { + f.state.metadata.dist.integrity = "not-an-integrity"; + } else if (invalid === "source SHA") { + f.state.sourceSha = "main"; + } else { + f.state.metadata.version = "2026.9.8-beta.1"; + } + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha, + }), + ).rejects.toThrow(/Invalid string|Invalid.*format|regular stable OpenClaw release/u); + expect(f.installs).toHaveLength(0); + expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(false); + }, + ); + + it("rejects a nonregistry tarball even when npm's lock agrees with the metadata", async () => { + const f = fixture(); + const wrongTarball = "https://example.invalid/openclaw.tgz"; + f.state.metadata.dist.tarball = wrongTarball; + f.lock.packages["node_modules/openclaw"].resolved = wrongTarball; + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha, + }), + ).rejects.toThrow(/tarball|registry|qualification target/u); + expect(f.installs).toHaveLength(0); + }); + + it("rejects a resolved lock whose package integrity differs from the selected artifact", async () => { + const f = fixture(); + f.lock.packages["node_modules/openclaw"].integrity = + `sha512-${Buffer.alloc(64, 3).toString("base64")}`; + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha, + }), + ).rejects.toThrow(/package lock does not match/u); + expect(f.installs).toHaveLength(0); + expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(false); + }); + + it("refuses an incomplete saved selection without replacing it with latest", async () => { + const f = fixture(); + mkdirSync(f.selectionDir); + await expect( + selectIOSReleaseGateway({ selectionDir: f.selectionDir, targetSha, signal: f.signal }), + ).rejects.toThrow(/ENOENT/u); + expect(f.fetch).not.toHaveBeenCalled(); + expect(command).not.toHaveBeenCalled(); + }); + + it.each(["package.json", "package-lock.json", "target SHA", "Node", "npm"])( + "refuses changed saved %s instead of reselecting latest", + async (invalid) => { + const f = fixture(); + await selectIOSReleaseGateway({ selectionDir: f.selectionDir, targetSha, signal: f.signal }); + f.fetch.mockClear(); + const manifestPath = path.join(f.selectionDir, "selection.json"); + if (invalid === "package.json" || invalid === "package-lock.json") { + writeFileSync(path.join(f.selectionDir, invalid), "{}\n"); + } else if (invalid === "npm") { + f.state.npmVersion = "11.21.0"; + } else if (invalid === "Node") { + writeJson(manifestPath, { ...readJson(manifestPath), nodeVersion: "v0.0.0" }); + } + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha: invalid === "target SHA" ? "4".repeat(40) : targetSha, + }), + ).rejects.toThrow( + /manifests changed|qualification target|Node version and platform|npm version/u, + ); + expect(f.fetch).not.toHaveBeenCalled(); + expect(f.installs).toHaveLength(0); + }, + ); + + it.each(["version", "source"])( + "rejects an installed package with the wrong %s", + async (wrong) => { + const f = fixture(); + if (wrong === "version") { + f.state.installedVersion = "2026.9.6"; + } else { + f.state.installedCommit = "4".repeat(40); + } + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + signal: f.signal, + targetSha, + }), + ).rejects.toThrow(/Installed Gateway identity differs/); + expect(f.installs).toHaveLength(1); + }, + ); + + it.each(["failure", "cancellation"])( + "propagates npm ci %s without a source fallback", + async (outcome) => { + const f = fixture(); + const abort = new AbortController(); + if (outcome === "failure") { + f.state.installExitCode = 23; + } else { + f.state.cancelInstall = abort; + } + await expect( + prepareIOSReleaseGateway({ + selectionDir: f.selectionDir, + installDir: f.installDir, + targetSha, + signal: abort.signal, + }), + ).rejects.toThrow(outcome === "failure" ? /23/ : /cancellation/); + expect(f.installs).toHaveLength(1); + expect(existsSync(path.join(f.selectionDir, "selection.json"))).toBe(true); + expect(command.mock.calls.at(-1)?.[0].args).toContain("ci"); + }, + ); +});