fix(ios): qualify releases against the stable Gateway (#161682)

This commit is contained in:
Josh Avant 2026-09-30 01:50:38 -05:00 • committed by GitHub
parent 63144fe0c4
commit bb812693f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1121 additions and 40 deletions

View file

@ -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' }}

View file

@ -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

View file

@ -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:

View file

@ -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.

View file

@ -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;

View file

@ -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 <full-sha> --selection-dir <directory>");
}
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;
});
}

View file

@ -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<string, unknown>;
buildDir?: string;
gatewaySelectionDir?: string;
buildOnly?: boolean;
gatewayOnly?: boolean;
onProgress?: () => Promise<void>;
}): 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<ReturnType<typeof prepareIOSReleaseGateway>> | 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<string, unknown> = { trial: index };
const fixtures = (options.proof.fixtures ??= []) as Record<string, unknown>[];
@ -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,
});

View file

@ -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<typeof selectionSchema>;
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<unknown> {
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<string> {
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<string> {
// 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<Selection> {
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<Selection> {
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 };
}

View file

@ -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<string, string>,
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<string, string>();
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<string, string>();
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<string, string>();
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<typeof vi.fn> }[] = [];
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" } } },

View file

@ -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<number>>());
vi.mock("../../scripts/lib/managed-child-process.mjs", async (importOriginal) => ({
...(await importOriginal<typeof import("../../scripts/lib/managed-child-process.mjs")>()),
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<string, unknown> {
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");
},
);
});