mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
Use complete config, file, and worker identities when refitting compact PR workloads. Preserve independent-run admission and partial-inventory retention, and keep main-run tooling logs out of the PR timing cohort. Publish the complete refit report as an artifact with bounded job outputs. Linux Testbox proof: 508 owner cases, workflow validation, extracted-wrapper inventory, types, boundary lint, three Knip scans, lint, and formatting. The 174-case timing file took 45.452s with one worker. The mixed-source regression failed before the fix and passed afterward. P2 review is clean. Broad PR plans are byte-identical against the base before timing refreshes.
13050 lines
553 KiB
TypeScript
13050 lines
553 KiB
TypeScript
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
cpSync,
|
|
existsSync,
|
|
globSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { devNull, tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { runInNewContext } from "node:vm";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { minimatch } from "minimatch";
|
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs";
|
|
import {
|
|
buildChildEnv,
|
|
resolveShardPlans,
|
|
runShardPlans,
|
|
} from "../../scripts/ci-run-node-test-shard.mts";
|
|
import { encodeNodeTestGroups } from "../../scripts/lib/ci-node-test-groups-codec.mts";
|
|
import {
|
|
SOURCE_CHANNEL_TEST_POLICY,
|
|
createUiRealGatewayTestShards,
|
|
createUiTestShardGroups,
|
|
} from "../../scripts/lib/ci-node-test-plan.mts";
|
|
import { createNativeTypeScriptParser } from "../../scripts/lib/native-typescript.mts";
|
|
import { pnpmLockfileDocuments } from "../../scripts/lib/pnpm-lockfile-documents.mjs";
|
|
import { collectRuntimeImportClosure } from "../../scripts/lib/runtime-import-closure.mts";
|
|
import { resolveRunVitestSpawnEnv } from "../../scripts/lib/vitest-process-env.mts";
|
|
import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts";
|
|
import { resolvePnpmRunner } from "../../scripts/pnpm-runner.mts";
|
|
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
|
|
import { createTempDirTracker, useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
import { sharedVitestConfig } from "../vitest/vitest.shared.config.ts";
|
|
import { createPrebuiltUiE2eVitestConfig } from "../vitest/vitest.ui-e2e-prebuilt.config.ts";
|
|
import { createUiE2eVitestConfig } from "../vitest/vitest.ui-e2e.config.ts";
|
|
import { uiE2eRealGatewayTestFiles } from "../vitest/vitest.ui-paths.mjs";
|
|
import { runCiGitStep } from "./ci-git-owner.test-support.js";
|
|
import { runDependencyFreePreflight } from "./ci-preflight-dependencies.test-support.js";
|
|
import { assertControlUiE2eOwnership } from "./ci-ui-e2e-ownership.test-support.js";
|
|
import {
|
|
AMBIGUOUS_MAIN_PUSH_DIAGNOSTIC,
|
|
CACHE_SAVE_V5,
|
|
CACHE_V5,
|
|
CHECKOUT_V6,
|
|
DOWNLOAD_ARTIFACT_V8,
|
|
MANTIS_GITHUB_APP_CLIENT_ID,
|
|
MATURITY_SCORECARD_WORKFLOW,
|
|
SETUP_GO_V6,
|
|
UPLOAD_ARTIFACT_V7,
|
|
evaluateWorkflowExpression,
|
|
evaluateWorkflowRunner,
|
|
quoteShell,
|
|
readAndroidToolchainAction,
|
|
readBuildArtifactsTestboxWorkflow,
|
|
readCiWorkflow,
|
|
readMaturityScorecardWorkflow,
|
|
readReleaseChecksWorkflow,
|
|
readTrackedText,
|
|
readWorkflow,
|
|
readWorkflowOutputs,
|
|
runGit,
|
|
runWorkflowShellScript,
|
|
testNodeExecPath,
|
|
type WorkflowStep,
|
|
writeExecutable,
|
|
} from "./ci-workflow.test-support.js";
|
|
import { runGeneratedPublisherScenario } from "./generated-publisher.test-support.js";
|
|
|
|
const parser = createNativeTypeScriptParser();
|
|
afterAll(() => parser.close());
|
|
|
|
const SETUP_GRADLE_V6 = "gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb";
|
|
const CREATE_GITHUB_APP_TOKEN_V3 =
|
|
"actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1";
|
|
const MANTIS_MANUAL_ONLY_WORKFLOWS = [
|
|
".github/workflows/mantis-web-ui-chat-proof.yml",
|
|
".github/workflows/mantis-discord-status-reactions.yml",
|
|
".github/workflows/mantis-discord-thread-attachment.yml",
|
|
] as const;
|
|
const OPENGREP_PR_DIFF_WORKFLOW = ".github/workflows/opengrep-precise.yml";
|
|
const OPENGREP_FULL_WORKFLOW = ".github/workflows/opengrep-precise-full.yml";
|
|
const CONTROL_UI_LOCALE_REFRESH_WORKFLOW = ".github/workflows/control-ui-locale-refresh.yml";
|
|
const NATIVE_APP_LOCALE_REFRESH_WORKFLOW = ".github/workflows/native-app-locale-refresh.yml";
|
|
const CREATE_GENERATED_PR_TOKENS_ACTION = ".github/actions/create-generated-pr-tokens/action.yml";
|
|
const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml";
|
|
const AMBIGUOUS_MAIN_PUSH_GUARD = `if [ "$GITHUB_EVENT_NAME" = "push" ] && [[ "$base_sha" =~ ^0+$ ]]; then
|
|
echo "${AMBIGUOUS_MAIN_PUSH_DIAGNOSTIC}" >&2
|
|
exit 1
|
|
fi`;
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
const rootPackageManager = (
|
|
JSON.parse(readFileSync("package.json", "utf8")) as {
|
|
packageManager: string;
|
|
}
|
|
).packageManager;
|
|
|
|
function runCiReleaseRefValidation(options: {
|
|
kind?: "context" | "historical" | "candidate";
|
|
ref: string;
|
|
targetSha: string;
|
|
resolvedSha?: string;
|
|
comparisonStatus?: string;
|
|
apiError?: "ref" | "comparison";
|
|
}) {
|
|
const root = tempDirs.make("openclaw-ci-target-context-");
|
|
const outputPath = path.join(root, "github-output");
|
|
const binPath = path.join(root, "bin");
|
|
const resolvedSha = options.resolvedSha ?? "b".repeat(40);
|
|
const kind = options.kind ?? "context";
|
|
const ref = `refs/${kind === "historical" ? "tags" : "heads"}/${options.ref}`;
|
|
mkdirSync(binPath);
|
|
writeFileSync(
|
|
path.join(root, "ci-git-owner.py"),
|
|
readFileSync(".github/actions/git-owner/owner.py"),
|
|
);
|
|
writeFileSync(outputPath, "", "utf8");
|
|
writeFileSync(
|
|
path.join(binPath, "git"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
if [[ "$1" == "-C" ]]; then shift 2; fi
|
|
if [[ "$*" == "remote get-url origin" ]]; then
|
|
printf '%s\\n' 'https://github.com/openclaw/openclaw.git'
|
|
else
|
|
echo 'fatal: could not read Username for https://github.com: terminal prompts disabled' >&2
|
|
exit 128
|
|
fi
|
|
`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
path.join(binPath, "gh"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
[[ "\${GH_TOKEN:-}" == "test-token" ]] || exit 4
|
|
[[ "$1" == "api" ]] || exit 64
|
|
shift
|
|
if [[ "$1" == "--method" && "$2" == "GET" ]]; then shift 2; fi
|
|
[[ "$#" == 3 && "$2" == "--jq" ]] || exit 64
|
|
case "$1" in
|
|
"$MOCK_REF_ENDPOINT") kind=ref; value="$MOCK_REF_SHA"; query=.sha ;;
|
|
"$MOCK_COMPARE_ENDPOINT") kind=comparison; value="$MOCK_COMPARE_STATUS"; query=.status ;;
|
|
*) echo "Unexpected GitHub API endpoint: $1" >&2; exit 64 ;;
|
|
esac
|
|
[[ "$3" == "$query" ]] || exit 64
|
|
# Valid-looking partial output must not authorize a failed request.
|
|
printf '%s\\n' "$value"
|
|
if [[ "$MOCK_API_ERROR" == "$kind" ]]; then
|
|
echo 'gh: Service Unavailable (HTTP 503)' >&2
|
|
exit 1
|
|
fi
|
|
`,
|
|
"utf8",
|
|
);
|
|
chmodSync(path.join(binPath, "git"), 0o755);
|
|
chmodSync(path.join(binPath, "gh"), 0o755);
|
|
const stepName = {
|
|
context: "Validate target context",
|
|
historical: "Validate historical release target",
|
|
candidate: "Validate release candidate target",
|
|
}[kind];
|
|
const step = expectDefined(
|
|
readCiWorkflow().jobs.preflight.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === stepName,
|
|
),
|
|
stepName,
|
|
);
|
|
const run = spawnSync(
|
|
"bash",
|
|
["-c", expectDefined(step.run, "target context validation script")],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GH_TOKEN: step.env?.GH_TOKEN === "${{ github.token }}" ? "test-token" : "",
|
|
GITHUB_REPOSITORY: "openclaw/openclaw",
|
|
GITHUB_OUTPUT: outputPath,
|
|
MOCK_REF_ENDPOINT: `repos/openclaw/openclaw/commits/${encodeURIComponent(ref)}`,
|
|
MOCK_REF_SHA: resolvedSha,
|
|
MOCK_COMPARE_ENDPOINT: `repos/openclaw/openclaw/compare/${options.targetSha}...${resolvedSha}`,
|
|
MOCK_COMPARE_STATUS: options.comparisonStatus ?? "ahead",
|
|
MOCK_API_ERROR: options.apiError ?? "",
|
|
RUNNER_TEMP: root,
|
|
PATH: `${binPath}:${process.env.PATH ?? ""}`,
|
|
TARGET_CONTEXT_REF: options.ref,
|
|
TARGET_REF: options.targetSha,
|
|
EXPECTED_SHA: options.targetSha,
|
|
HISTORICAL_TARGET_TAG: options.ref,
|
|
RELEASE_CANDIDATE_REF: options.ref,
|
|
},
|
|
},
|
|
);
|
|
return {
|
|
output: `${run.stdout}${run.stderr}`,
|
|
outputs: readWorkflowOutputs(outputPath),
|
|
status: run.status,
|
|
};
|
|
}
|
|
|
|
function readAndroidReleaseWorkflow() {
|
|
return parse(readFileSync(".github/workflows/android-release.yml", "utf8"));
|
|
}
|
|
|
|
function readTestboxWorkflow() {
|
|
return parse(readFileSync(".github/workflows/ci-check-testbox.yml", "utf8"));
|
|
}
|
|
|
|
function readWorkflowSanityWorkflow() {
|
|
return parse(readFileSync(".github/workflows/workflow-sanity.yml", "utf8"));
|
|
}
|
|
|
|
function readRealBehaviorProofWorkflow() {
|
|
return parse(readFileSync(".github/workflows/real-behavior-proof.yml", "utf8"));
|
|
}
|
|
|
|
function readCriticalQualityWorkflow() {
|
|
return readFileSync(".github/workflows/codeql-critical-quality.yml", "utf8");
|
|
}
|
|
|
|
const PULL_REQUEST_EDIT_FIELDS = ["title", "body", "base"] as const;
|
|
|
|
function readPullRequestEditFields(condition: unknown) {
|
|
const expression = typeof condition === "string" ? condition : "";
|
|
return PULL_REQUEST_EDIT_FIELDS.filter((field) =>
|
|
expression.includes(`github.event.changes.${field}`),
|
|
);
|
|
}
|
|
|
|
function readAndroidCompileSdk(relativePath: string): number {
|
|
const match = readTrackedText(relativePath).match(/^\s*compileSdk\s*=\s*(\d+)\s*$/mu);
|
|
if (!match) {
|
|
throw new Error(`Missing compileSdk in ${relativePath}`);
|
|
}
|
|
return Number(match[1]);
|
|
}
|
|
|
|
function findYamlFiles(directory: string): string[] {
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const entryPath = `${directory}/${entry.name}`;
|
|
if (entry.isDirectory()) {
|
|
return findYamlFiles(entryPath);
|
|
}
|
|
return entry.isFile() && /\.ya?ml$/u.test(entry.name) ? [entryPath] : [];
|
|
});
|
|
}
|
|
|
|
function findUnpinnedExternalActions(): string[] {
|
|
const violations: string[] = [];
|
|
for (const workflowPath of [
|
|
...findYamlFiles(".github/workflows"),
|
|
...findYamlFiles(".github/actions"),
|
|
]) {
|
|
for (const [index, line] of readFileSync(workflowPath, "utf8").split("\n").entries()) {
|
|
const uses = line.match(/^\s*(?:-\s*)?uses:\s*([^#\s]+)/u)?.[1];
|
|
if (!uses || uses.startsWith("./") || uses.startsWith("docker://")) {
|
|
continue;
|
|
}
|
|
const at = uses.lastIndexOf("@");
|
|
if (at < 1 || !/^[a-f0-9]{40}$/u.test(uses.slice(at + 1))) {
|
|
violations.push(`${workflowPath}:${index + 1}: ${uses}`);
|
|
}
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function runReleaseFallbackHistoryFixture(options: {
|
|
route: "branch" | "tag" | "orphan" | "non-release-tag";
|
|
many?: boolean;
|
|
failure?: "fetch-branches" | "fetch-tags" | "branch-producer" | "tag-producer";
|
|
}) {
|
|
const ownedDirs = createTempDirTracker();
|
|
const root = ownedDirs.make("openclaw-release-fallback-");
|
|
const origin = path.join(root, "origin.git");
|
|
const checkout = path.join(root, "checkout");
|
|
const bin = path.join(root, "bin");
|
|
const home = path.join(root, "home");
|
|
const hooks = path.join(root, "hooks");
|
|
const records = path.join(root, "git-results.jsonl");
|
|
const fixtureEnv: NodeJS.ProcessEnv = {
|
|
PATH: [path.dirname(testNodeExecPath), "/usr/local/bin", "/usr/bin", "/bin"].join(
|
|
path.delimiter,
|
|
),
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: home,
|
|
LC_ALL: "C",
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_CONFIG_GLOBAL: devNull,
|
|
GIT_ALLOW_PROTOCOL: "file",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_CONFIG_COUNT: "6",
|
|
GIT_CONFIG_KEY_0: "credential.helper",
|
|
GIT_CONFIG_VALUE_0: "",
|
|
GIT_CONFIG_KEY_1: "core.hooksPath",
|
|
GIT_CONFIG_VALUE_1: hooks,
|
|
GIT_CONFIG_KEY_2: "gc.auto",
|
|
GIT_CONFIG_VALUE_2: "0",
|
|
GIT_CONFIG_KEY_3: "maintenance.auto",
|
|
GIT_CONFIG_VALUE_3: "false",
|
|
GIT_CONFIG_KEY_4: "commit.gpgsign",
|
|
GIT_CONFIG_VALUE_4: "false",
|
|
GIT_CONFIG_KEY_5: "protocol.file.allow",
|
|
GIT_CONFIG_VALUE_5: "always",
|
|
GIT_AUTHOR_NAME: "Release Fixture",
|
|
GIT_AUTHOR_EMAIL: "release-fixture@example.com",
|
|
GIT_COMMITTER_NAME: "Release Fixture",
|
|
GIT_COMMITTER_EMAIL: "release-fixture@example.com",
|
|
GITHUB_TOKEN: "synthetic-fixture-token",
|
|
};
|
|
try {
|
|
for (const dir of [checkout, bin, home, hooks]) {
|
|
mkdirSync(dir);
|
|
}
|
|
const realGit = execFileSync("bash", ["--noprofile", "--norc", "-c", "command -v git"], {
|
|
env: fixtureEnv,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const git = (cwd: string, args: string[], input?: string) =>
|
|
execFileSync(realGit, args, {
|
|
cwd,
|
|
env: fixtureEnv,
|
|
input,
|
|
encoding: "utf8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
timeout: 20_000,
|
|
}).trim();
|
|
git(root, ["init", "--bare", "-q", origin]);
|
|
const tree = git(origin, ["mktree"], "");
|
|
const selected = git(origin, ["commit-tree", tree, "-m", "selected"]);
|
|
const unrelated = git(origin, ["commit-tree", tree, "-m", "unrelated"]);
|
|
const count = options.many ? 4096 : 1;
|
|
const refs = Array.from({ length: count }, (_, index) => {
|
|
const suffix = options.many
|
|
? `${String(index).padStart(4, "0")}-${"a".repeat(192)}/${"b".repeat(192)}`
|
|
: "small";
|
|
return options.route === "branch"
|
|
? `refs/heads/fixture/${suffix}`
|
|
: `refs/tags/${options.route === "non-release-tag" ? "fixture" : "vfixture"}/${suffix}`;
|
|
});
|
|
git(
|
|
origin,
|
|
["update-ref", "--stdin"],
|
|
[
|
|
`create refs/heads/setup-target ${selected}`,
|
|
`create refs/heads/unrelated ${unrelated}`,
|
|
...(options.route === "orphan" ? [] : refs.map((ref) => `create ${ref} ${selected}`)),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
git(origin, ["pack-refs", "--all"]);
|
|
git(checkout, ["init", "-q"]);
|
|
git(checkout, ["remote", "add", "origin", pathToFileURL(origin).href]);
|
|
git(checkout, ["fetch", "--no-tags", "origin", "refs/heads/setup-target"]);
|
|
git(checkout, ["checkout", "-q", "--detach", "FETCH_HEAD"]);
|
|
git(origin, ["update-ref", "-d", "refs/heads/setup-target"]);
|
|
git(checkout, ["update-ref", "-d", "refs/remotes/origin/setup-target"]);
|
|
expect(
|
|
git(checkout, [
|
|
"for-each-ref",
|
|
"--format=%(objectname)",
|
|
"--contains",
|
|
selected,
|
|
"refs/remotes",
|
|
]),
|
|
).toBe("");
|
|
expect(git(checkout, ["tag", "--points-at", selected])).toBe("");
|
|
if (options.route === "tag") {
|
|
git(checkout, [
|
|
"config",
|
|
"http.https://github.com/.extraheader",
|
|
"AUTHORIZATION: basic Zml4dHVyZQ==",
|
|
]);
|
|
}
|
|
const enumerationBytes = Buffer.byteLength(
|
|
refs
|
|
.map((ref) => ref.replace(/^refs\/heads\//u, "origin/").replace(/^refs\/tags\//u, ""))
|
|
.join("\n") + "\n",
|
|
);
|
|
if (options.many && existsSync("/proc/sys/fs/pipe-max-size")) {
|
|
expect(enumerationBytes).toBeGreaterThan(
|
|
Number(readFileSync("/proc/sys/fs/pipe-max-size", "utf8").trim()),
|
|
);
|
|
}
|
|
|
|
// Enumeration inherits the real pipeline. Only verbose fetch stderr uses a regular file.
|
|
const launcher = path.join(root, "git-launcher.mjs");
|
|
writeFileSync(
|
|
launcher,
|
|
[
|
|
'import { spawnSync } from "node:child_process";',
|
|
'import { createHash } from "node:crypto";',
|
|
'import { appendFileSync, closeSync, openSync, readFileSync, statSync } from "node:fs";',
|
|
'import { constants } from "node:os";',
|
|
`const git = ${JSON.stringify(realGit)};`,
|
|
`const records = ${JSON.stringify(records)};`,
|
|
`const failure = ${JSON.stringify(options.failure ?? null)};`,
|
|
"let args = process.argv.slice(2);",
|
|
'const op = args.includes("fetch") ? (args.includes("--no-tags") ? "fetch-branches" : "fetch-tags")',
|
|
' : args[0] === "tag" ? "tag-producer" : args[0] === "for-each-ref" ? "branch-producer" : args[0];',
|
|
'if (op.startsWith("fetch-") && op === failure) {',
|
|
` args = args.map(arg => arg === "origin" ? ${JSON.stringify(pathToFileURL(path.join(root, "missing.git")).href)} : arg);`,
|
|
"}",
|
|
`const fetchPath = ${JSON.stringify(path.join(root, "fetch-"))} + op + ".stderr";`,
|
|
'const fd = op.startsWith("fetch-") ? openSync(fetchPath, "w", 0o600) : null;',
|
|
'const result = spawnSync(git, args, { stdio: ["inherit", "inherit", fd ?? "inherit"], timeout: 20_000 });',
|
|
"if (fd !== null) closeSync(fd);",
|
|
"const exitCode = result.signal ? 128 + constants.signals[result.signal] : result.status ?? 1;",
|
|
"const entry = { op, status: result.status, signal: result.signal, exitCode, error: result.error?.code };",
|
|
"if (fd !== null) {",
|
|
" const size = statSync(fetchPath).size;",
|
|
' if (size > 8 * 1024 * 1024) throw new Error("fixture fetch capture exceeded 8 MiB");',
|
|
" const bytes = readFileSync(fetchPath);",
|
|
' entry.stderr = { bytes: size, sha256: createHash("sha256").update(bytes).digest("hex") };',
|
|
" process.stderr.write(bytes.subarray(Math.max(0, bytes.length - 1024)));",
|
|
"}",
|
|
'appendFileSync(records, JSON.stringify(entry) + "\\n");',
|
|
"if (op === failure && fd === null && result.status === 0) {",
|
|
' const failed = spawnSync(git, ["rev-parse", "--verify", "refs/heads/fixture-missing"], { stdio: ["ignore", "ignore", "inherit"] });',
|
|
' appendFileSync(records, JSON.stringify({ op: "post-output-failure", status: failed.status, signal: failed.signal }) + "\\n");',
|
|
" process.exit(failed.status ?? 1);",
|
|
"}",
|
|
"process.exit(exitCode);",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeExecutable(path.join(bin, "git"), [
|
|
"#!/bin/bash",
|
|
`exec ${quoteShell(testNodeExecPath)} ${quoteShell(launcher)} "$@"`,
|
|
]);
|
|
const allocatedBytes = () =>
|
|
Number(
|
|
execFileSync("du", ["-sk", root], { env: fixtureEnv, encoding: "utf8" })
|
|
.trim()
|
|
.split(/\s/u)[0],
|
|
) * 1024;
|
|
const beforeBytes = allocatedBytes();
|
|
expect(beforeBytes).toBeLessThan(256 * 1024 * 1024);
|
|
console.info(
|
|
"fallback-fixture-before",
|
|
JSON.stringify({ ...options, refs: count, enumerationBytes, allocatedBytes: beforeBytes }),
|
|
);
|
|
const step = expectDefined(
|
|
readReleaseChecksWorkflow().jobs.resolve_target.steps.find(
|
|
(candidate: WorkflowStep) =>
|
|
candidate.name === "Validate selected ref belongs to this repository",
|
|
) as WorkflowStep | undefined,
|
|
"fallback history validation",
|
|
);
|
|
const result = runWorkflowShellScript(expectDefined(step.run, "fallback validation body"), {
|
|
cwd: checkout,
|
|
env: {
|
|
...fixtureEnv,
|
|
PATH: `${bin}${path.delimiter}${fixtureEnv.PATH}`,
|
|
RELEASE_REF: selected,
|
|
},
|
|
});
|
|
const events = readFileSync(records, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map(
|
|
(line) =>
|
|
JSON.parse(line) as {
|
|
op: string;
|
|
status: number | null;
|
|
signal: string | null;
|
|
error?: string;
|
|
},
|
|
);
|
|
const containingBranches = git(checkout, [
|
|
"for-each-ref",
|
|
"--format=%(objectname)",
|
|
"--contains",
|
|
selected,
|
|
"refs/remotes",
|
|
])
|
|
.split(/\s/u)
|
|
.filter(Boolean).length;
|
|
if (!options.failure?.startsWith("fetch-")) {
|
|
expect(containingBranches).toBe(options.route === "branch" ? count : 0);
|
|
}
|
|
const afterBytes = allocatedBytes();
|
|
expect(afterBytes).toBeLessThan(256 * 1024 * 1024);
|
|
console.info(
|
|
"fallback-fixture-result",
|
|
JSON.stringify({
|
|
...options,
|
|
status: result.status,
|
|
signal: result.signal,
|
|
error: result.error?.message,
|
|
enumerationBytes,
|
|
containingBranches,
|
|
allocatedBytes: afterBytes,
|
|
events,
|
|
rejection: result.stderr.includes("but that commit is not reachable"),
|
|
}),
|
|
);
|
|
expect(result.error).toBeUndefined();
|
|
expect(events.every((event) => event.error === undefined)).toBe(true);
|
|
return { result, events };
|
|
} finally {
|
|
ownedDirs.cleanup();
|
|
expect(existsSync(root)).toBe(false);
|
|
console.info("fallback-fixture-cleanup", JSON.stringify({ ...options, remaining: 0 }));
|
|
}
|
|
}
|
|
describe("release fast lane label", () => {
|
|
it("reads PR labels without subscribing to label-change events", () => {
|
|
const workflow = readCiWorkflow();
|
|
const manifestStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
expect(manifestStep.env.OPENCLAW_CI_RELEASE_FAST_LANE_LABEL).toBe(
|
|
"${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'release-fast-lane') && 'true' || 'false' }}",
|
|
);
|
|
expect(workflow.jobs.preflight.outputs.release_fast_lane).toBe(
|
|
"${{ steps.manifest.outputs.release_fast_lane }}",
|
|
);
|
|
expect(workflow.on.pull_request.types).toEqual([
|
|
"opened",
|
|
"reopened",
|
|
"synchronize",
|
|
"ready_for_review",
|
|
"converted_to_draft",
|
|
]);
|
|
});
|
|
|
|
it("declines fork heads before any reduced check can reach the aggregate gate", () => {
|
|
const workflow = readCiWorkflow();
|
|
const manifestStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
// The manifest fixture "declines fork heads on the canonical base" in
|
|
// ci-workflow-planning.test.ts proves the outputs; this pins the inputs it relies on.
|
|
expect(manifestStep.env.OPENCLAW_CI_REPOSITORY).toBe("${{ github.repository }}");
|
|
expect(manifestStep.env.OPENCLAW_CI_HEAD_REPOSITORY).toBe(
|
|
"${{ github.event.pull_request.head.repo.full_name }}",
|
|
);
|
|
const admission = manifestStep.run.match(/const releaseFastLaneScope = [^;]*;/su)?.[0];
|
|
expect(admission).toContain(
|
|
'eventName === "pull_request" && isCanonicalRepository && process.env.OPENCLAW_CI_HEAD_REPOSITORY === process.env.OPENCLAW_CI_REPOSITORY && runNodeFull',
|
|
);
|
|
// Reduced selection only ever reaches the gate through preflight outputs.
|
|
const gate = workflow.jobs["ci-gate"];
|
|
expect(gate.needs[0]).toBe("preflight");
|
|
const rows: string[] = gate.steps
|
|
.find((candidate: WorkflowStep) => candidate.name === "Verify selected CI lanes")
|
|
.env.JOB_RESULTS.trim()
|
|
.split("\n");
|
|
for (const row of rows) {
|
|
const selected = row.slice(row.lastIndexOf("|") + 1);
|
|
expect(selected === "true" || selected.includes("needs.preflight.outputs."), row).toBe(true);
|
|
expect(selected).not.toContain("release_fast_lane");
|
|
}
|
|
});
|
|
|
|
it("shows admission at the gate while preserving every lane result", () => {
|
|
const gate = readCiWorkflow().jobs["ci-gate"];
|
|
const step = gate.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Verify selected CI lanes",
|
|
);
|
|
expect(step.env.RELEASE_FAST_LANE).toBe("${{ needs.preflight.outputs.release_fast_lane }}");
|
|
expect(
|
|
step.run.startsWith(
|
|
'set -euo pipefail\necho "release fast lane: ${RELEASE_FAST_LANE:-false}"\n',
|
|
),
|
|
).toBe(true);
|
|
const rows: string[] = step.env.JOB_RESULTS.trim().split("\n");
|
|
expect(rows.map((row) => row.split("=")[0])).toEqual(gate.needs);
|
|
for (const row of rows) {
|
|
const name = row.split("=")[0];
|
|
expect(row.slice(0, row.lastIndexOf("|")), row).toContain(`needs.${name}.result`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ci workflow guards", () => {
|
|
it.each([
|
|
["artifact", 1],
|
|
["source", 0],
|
|
] as const)(
|
|
"fetches the required release preparation history for %s mode",
|
|
(packageMode, depth) => {
|
|
const prepare = readReleaseChecksWorkflow().jobs.prepare_release_package;
|
|
const checkout = prepare.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout trusted workflow ref",
|
|
);
|
|
expect(checkout.with.ref).toBe("${{ github.sha }}");
|
|
expect(checkout.with["persist-credentials"]).toBe(false);
|
|
expect(checkout.with.filter).toBe("blob:none");
|
|
const fetchDepth = checkout.with["fetch-depth"];
|
|
expect(
|
|
typeof fetchDepth === "string"
|
|
? evaluateWorkflowExpression(fetchDepth, {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
resolveTargetOutputs: { package_mode: packageMode },
|
|
})
|
|
: fetchDepth,
|
|
).toBe(depth);
|
|
},
|
|
);
|
|
|
|
it("gates frozen runtime-pair compatibility on the trusted suite outcome", () => {
|
|
const workflow = readReleaseChecksWorkflow();
|
|
const laneJob = workflow.jobs.qa_lab_runtime_pair_lane_release_checks;
|
|
const suiteValidation = laneJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate runtime-pair lane",
|
|
);
|
|
const reportValidation = laneJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate runtime-pair lane report",
|
|
);
|
|
|
|
for (const step of [suiteValidation, reportValidation]) {
|
|
expect(step?.env?.CANDIDATE_SUITE_OUTCOME).toBe(
|
|
"${{ steps.candidate_runtime_pair.outcome }}",
|
|
);
|
|
expect(step?.run).toContain('--candidate-suite-outcome "$CANDIDATE_SUITE_OUTCOME"');
|
|
expect(step?.run).toContain('--target-sha "$RELEASE_CHECK_TARGET_SHA"');
|
|
expect(step?.run).toContain('--lane "$RUNTIME_PAIR_LANE"');
|
|
}
|
|
});
|
|
|
|
it("separates release QA lanes without weakening their resource locks", () => {
|
|
const workflowPath = ".github/workflows/qa-live-transports-convex.yml";
|
|
const workflowSource = readFileSync(workflowPath, "utf8");
|
|
const workflow = parse(workflowSource);
|
|
const releaseWorkflow = readReleaseChecksWorkflow();
|
|
|
|
expect(workflow.on.workflow_call.inputs.lock_scope).toEqual({
|
|
description: "Concurrency scope for a trusted single-lane reusable call",
|
|
required: false,
|
|
default: "all",
|
|
type: "string",
|
|
});
|
|
expect(workflow.concurrency).toEqual({
|
|
group:
|
|
"qa-lab-${{ inputs.lock_scope || 'all' }}-${{ github.event_name != 'schedule' && inputs.ref || github.sha }}",
|
|
"cancel-in-progress": false,
|
|
queue: "max",
|
|
});
|
|
expect(workflow.jobs.run_live_matrix.concurrency).toEqual({
|
|
group: "qa-live-matrix-${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
"cancel-in-progress": false,
|
|
queue: "max",
|
|
});
|
|
expect(workflow.jobs.run_live_buzz.concurrency).toEqual({
|
|
group: "qa-live-buzz-shared",
|
|
"cancel-in-progress": false,
|
|
queue: "max",
|
|
});
|
|
expect(releaseWorkflow.jobs.qa_live_release_checks.with.lock_scope).toBe("matrix");
|
|
expect(releaseWorkflow.jobs.qa_live_buzz_release_checks.with.lock_scope).toBe("buzz");
|
|
});
|
|
|
|
it("runs full-access restart proof as a failing nightly gate", () => {
|
|
const workflow = readWorkflow(".github/workflows/qa-live-transports-convex.yml");
|
|
const job = workflow.jobs.run_live_runtime_token_efficiency;
|
|
const step = job.steps.find((candidate: WorkflowStep) =>
|
|
candidate.run?.includes("--scenario gateway-restart-full-access-live"),
|
|
);
|
|
|
|
expect(workflow.on.schedule.length).toBeGreaterThan(0);
|
|
expect(job.if).toBe("github.event_name == 'schedule'");
|
|
expect(step).toBeDefined();
|
|
expect(step?.env?.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
|
|
expect(step?.["continue-on-error"]).toBeUndefined();
|
|
expect(step?.if).toBeUndefined();
|
|
expect(step?.run).toContain("--provider-mode live-frontier");
|
|
expect(step?.run).toContain("--model openai/gpt-5.6-luna");
|
|
expect(step?.run).toContain("--alt-model openai/gpt-5.6-luna");
|
|
expect(step?.run).toContain("--concurrency 1");
|
|
expect(step?.run).not.toContain("--allow-failures");
|
|
expect(step?.run).toContain(
|
|
'--output-dir "${{ steps.run_lane.outputs.output_dir }}/gateway-restart-full-access"',
|
|
);
|
|
});
|
|
|
|
it.each([0, 7])("preserves module heredocs and cleans artifacts after exit %i", (exitCode) => {
|
|
const parentTempDir = tmpdir();
|
|
const run = runWorkflowShellScript(
|
|
`node --input-type=module <<'NODE'
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
NODE_prefix: for (const value of ["heredoc-body-preserved"]) {
|
|
console.log(value);
|
|
break NODE_prefix;
|
|
}
|
|
console.log(mkdtempSync(join(tmpdir(), 'openclaw-workflow-child-')));
|
|
console.log(JSON.stringify(process.execArgv));
|
|
process.exitCode = ${exitCode};
|
|
NODE
|
|
`,
|
|
{},
|
|
);
|
|
|
|
expect(run.status, run.stderr).toBe(exitCode);
|
|
const [body, temporaryDirectory, execArgv] = run.stdout.trim().split("\n");
|
|
const childDirectory = expectDefined(temporaryDirectory, "child temporary directory");
|
|
try {
|
|
expect(body).toBe("heredoc-body-preserved");
|
|
expect(JSON.parse(expectDefined(execArgv, "module arguments"))).toEqual([
|
|
"--input-type=module",
|
|
]);
|
|
expect(tmpdir()).toBe(parentTempDir);
|
|
expect(existsSync(childDirectory)).toBe(false);
|
|
} finally {
|
|
rmSync(childDirectory, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ name: "plain Node", setup: "", nodeOptions: "", extension: "mjs" },
|
|
{ name: "tsx", setup: "", nodeOptions: "--import tsx ", extension: "ts" },
|
|
{
|
|
name: "manifest loader",
|
|
setup: "manifest_node_args=()\nmanifest_node_args+=(--import tsx)\n",
|
|
nodeOptions: '"${manifest_node_args[@]}" ',
|
|
extension: "ts",
|
|
},
|
|
])("keeps $name heredocs outside cwd and resolves imports after cd", (fixture) => {
|
|
const root = tempDirs.make("openclaw-workflow-resolution-");
|
|
const child = path.join(root, "module's directory");
|
|
mkdirSync(child);
|
|
writeFileSync(path.join(child, "package.json"), '{"type":"module"}');
|
|
writeFileSync(
|
|
path.join(child, `value.${fixture.extension}`),
|
|
fixture.extension === "ts"
|
|
? 'export const value: string = "resolved";'
|
|
: 'export const value = "resolved";',
|
|
);
|
|
const run = runWorkflowShellScript(
|
|
`${fixture.setup}node --input-type=module <<'BEFORE_CD'
|
|
import { readdirSync } from 'node:fs';
|
|
console.log(JSON.stringify(readdirSync(process.cwd())));
|
|
BEFORE_CD
|
|
cd ${quoteShell(child)}
|
|
node ${fixture.nodeOptions}--input-type=module <<'AFTER_CD'
|
|
import { readdirSync } from 'node:fs';
|
|
import { value } from './value.${fixture.extension}';
|
|
console.log(value);
|
|
console.log(JSON.stringify(readdirSync(process.cwd()).sort()));
|
|
AFTER_CD
|
|
`,
|
|
{ cwd: root },
|
|
);
|
|
|
|
const [rootFiles, value, childFiles] = run.stdout.trim().split("\n");
|
|
// Observe the namespace while both rewritten bodies exist, not only after cleanup.
|
|
expect(JSON.parse(expectDefined(rootFiles, "cwd namespace"))).toEqual([path.basename(child)]);
|
|
expect(run.status, run.stderr).toBe(0);
|
|
expect(value).toBe("resolved");
|
|
expect(JSON.parse(expectDefined(childFiles, "child namespace"))).toEqual([
|
|
"package.json",
|
|
`value.${fixture.extension}`,
|
|
]);
|
|
});
|
|
|
|
it("routes PR edited metadata only to interested automation", () => {
|
|
const autoResponse = readWorkflow(".github/workflows/auto-response.yml");
|
|
const clawsweeperDispatch = readWorkflow(".github/workflows/clawsweeper-dispatch.yml");
|
|
const labeler = readWorkflow(".github/workflows/labeler.yml");
|
|
const realBehaviorProof = readWorkflow(".github/workflows/real-behavior-proof.yml");
|
|
|
|
for (const workflow of [autoResponse, clawsweeperDispatch, labeler, realBehaviorProof]) {
|
|
expect(workflow.on.pull_request_target.types).toContain("edited");
|
|
}
|
|
|
|
expect({
|
|
autoResponse: readPullRequestEditFields(autoResponse.jobs["auto-response"].if),
|
|
clawsweeperDispatch: readPullRequestEditFields(clawsweeperDispatch.jobs.dispatch.if),
|
|
labeler: readPullRequestEditFields(labeler.jobs.label.if),
|
|
realBehaviorProof: readPullRequestEditFields(
|
|
realBehaviorProof.jobs["real-behavior-proof"].if,
|
|
),
|
|
}).toEqual({
|
|
autoResponse: ["title", "body", "base"],
|
|
clawsweeperDispatch: [],
|
|
labeler: ["title", "base"],
|
|
realBehaviorProof: ["body", "base"],
|
|
});
|
|
|
|
const labelerSteps = labeler.jobs.label.steps;
|
|
const changedFieldsForStep = (matcher: (step: WorkflowStep) => boolean) =>
|
|
readPullRequestEditFields(labelerSteps.find(matcher)?.if);
|
|
expect({
|
|
pathLabels: changedFieldsForStep(
|
|
(step) => step.uses?.startsWith("actions/labeler@") === true,
|
|
),
|
|
size: changedFieldsForStep((step) => step.name === "Apply PR size label"),
|
|
contributor: changedFieldsForStep(
|
|
(step) => step.name === "Apply maintainer or trusted-contributor label",
|
|
),
|
|
betaBlocker: changedFieldsForStep((step) => step.name === "Apply beta-blocker title label"),
|
|
activePrLimit: changedFieldsForStep((step) => step.name === "Apply too-many-prs label"),
|
|
}).toEqual({
|
|
pathLabels: ["base"],
|
|
size: ["base"],
|
|
contributor: [],
|
|
betaBlocker: ["title"],
|
|
activePrLimit: [],
|
|
});
|
|
});
|
|
|
|
it("keeps ClawSweeper dispatch events aligned with receiver workflows", () => {
|
|
const workflowPath = ".github/workflows/clawsweeper-dispatch.yml";
|
|
const source = readFileSync(workflowPath, "utf8");
|
|
const workflow = readWorkflow(workflowPath);
|
|
const steps = workflow.jobs.dispatch.steps as WorkflowStep[];
|
|
const receiverDispatchSteps = steps.filter((step) =>
|
|
step.run?.includes("repos/openclaw/clawsweeper/dispatches"),
|
|
);
|
|
const eventTypes = receiverDispatchSteps.map((step) => {
|
|
const matches = [...(step.run ?? "").matchAll(/\bevent_type\s*:\s*"([^"]+)"/gu)];
|
|
expect(matches, step.name).toHaveLength(1);
|
|
return expectDefined(matches[0]?.[1], step.name ?? "ClawSweeper dispatch event");
|
|
});
|
|
|
|
// This allowlist mirrors the target repository receiver contract; changes require coordinated receiver updates.
|
|
expect(eventTypes.toSorted()).toEqual([
|
|
"clawsweeper_comment",
|
|
"clawsweeper_item",
|
|
"github_activity",
|
|
]);
|
|
expect(source).not.toContain("clawsweeper_commit_review");
|
|
expect(source).not.toContain("CLAWSWEEPER_COMMIT_REVIEW_CREATE_CHECKS");
|
|
expect(workflow.on.push.branches).toEqual(["main"]);
|
|
|
|
const activityRun = expectDefined(
|
|
steps.find((step) => step.name === "Dispatch GitHub activity to ClawSweeper")?.run,
|
|
"ClawSweeper GitHub activity dispatch",
|
|
);
|
|
expect(activityRun).toMatch(
|
|
/push: \(if \$event_name == "push" then \{\s+before: \.before,\s+after: \.after,\s+ref: \.ref,\s+compare: \.compare,\s+head_commit: \.head_commit\.id\s+\} else null end\)/u,
|
|
);
|
|
|
|
const exactReviewStep = expectDefined(
|
|
steps.find((step) => step.name === "Dispatch exact ClawSweeper review"),
|
|
"ClawSweeper exact-review dispatch",
|
|
);
|
|
expect(exactReviewStep.env?.TARGET_BRANCH).toBe(
|
|
"${{ github.event.repository.default_branch }}",
|
|
);
|
|
expect(exactReviewStep.run).toContain('--arg target_branch "$TARGET_BRANCH"');
|
|
expect(exactReviewStep.run).toContain("target_branch:$target_branch");
|
|
expect(exactReviewStep.run).toContain('ingress_route:"target_dispatcher"');
|
|
expect(exactReviewStep.run).toContain("ingress_fingerprint:$ingress_fingerprint");
|
|
});
|
|
|
|
it("admits ClawSweeper content changes, commands, and stale-bug verification before allocation", () => {
|
|
const condition = String(
|
|
readWorkflow(".github/workflows/clawsweeper-dispatch.yml").jobs.dispatch.if,
|
|
)
|
|
.replace(/^\$\{\{|\}\}$/gu, "")
|
|
.replace("github.event.issue.labels.*.name", "issueLabels");
|
|
const cases: {
|
|
eventName: string;
|
|
action: string;
|
|
changes?: Record<string, unknown>;
|
|
allowed: boolean;
|
|
actor?: string;
|
|
actorId?: string;
|
|
label?: string;
|
|
issueLabels?: string[];
|
|
}[] = [
|
|
{ eventName: "pull_request_target", action: "edited", changes: {}, allowed: false },
|
|
{ eventName: "issues", action: "edited", changes: {}, allowed: false },
|
|
{ eventName: "pull_request_target", action: "edited", allowed: true },
|
|
...["title", "body", "base", "maintainer_can_modify", "unknown"].map((field) => ({
|
|
eventName: "pull_request_target",
|
|
action: "edited",
|
|
changes: { [field]: { from: "" } },
|
|
allowed: true,
|
|
})),
|
|
{ eventName: "issues", action: "edited", changes: { body: { from: "" } }, allowed: true },
|
|
{ eventName: "pull_request_target", action: "synchronize", allowed: true },
|
|
{ eventName: "pull_request_target", action: "labeled", allowed: true },
|
|
{ eventName: "pull_request_target", action: "unlabeled", allowed: true },
|
|
{ eventName: "issue_comment", action: "created", allowed: true },
|
|
{ eventName: "issue_comment", action: "edited", allowed: true },
|
|
{ eventName: "pull_request_review", action: "edited", allowed: true },
|
|
{ eventName: "pull_request_review_comment", action: "edited", allowed: true },
|
|
{ eventName: "issues", action: "labeled", actor: "github-actions[bot]", allowed: false },
|
|
{
|
|
eventName: "issues",
|
|
action: "labeled",
|
|
actor: "github-actions[bot]",
|
|
actorId: "257215752",
|
|
label: "stale",
|
|
issueLabels: ["bug", "stale"],
|
|
allowed: true,
|
|
},
|
|
];
|
|
for (const event of cases) {
|
|
expect(
|
|
Boolean(
|
|
runInNewContext(condition, {
|
|
github: {
|
|
actor: event.actor ?? "maintainer",
|
|
actor_id: event.actorId ?? "",
|
|
event_name: event.eventName,
|
|
event: {
|
|
action: event.action,
|
|
changes: event.changes,
|
|
label: { name: event.label ?? "enhancement" },
|
|
},
|
|
},
|
|
issueLabels: event.issueLabels ?? [],
|
|
contains: (values: string[], value: string) => values.includes(value),
|
|
endsWith: (value: string, suffix: string) => value.endsWith(suffix),
|
|
startsWith: (value: unknown, prefix: string) => String(value).startsWith(prefix),
|
|
toJSON: (value: unknown) => JSON.stringify(value),
|
|
vars: { OPENCLAW_RELEASE_PRIORITY_RUN: "" },
|
|
}),
|
|
),
|
|
JSON.stringify(event),
|
|
).toBe(event.allowed);
|
|
}
|
|
});
|
|
|
|
it("keeps existing ClawSweeper per-item coalescing after admission", () => {
|
|
const workflow = readWorkflow(".github/workflows/clawsweeper-dispatch.yml");
|
|
expect(workflow.concurrency).toBeUndefined();
|
|
const concurrency = workflow.jobs.dispatch.concurrency;
|
|
const evaluate = (
|
|
expression: string,
|
|
eventName: "issues" | "pull_request_target" | "issue_comment",
|
|
action: string,
|
|
runId: number,
|
|
) =>
|
|
evaluateWorkflowExpression(expression, {
|
|
repository: "openclaw/openclaw",
|
|
eventName,
|
|
runAttempt: 1,
|
|
runId,
|
|
githubEvent: { action, issue: { number: 123 }, pull_request: { number: 123 } },
|
|
});
|
|
const opened = evaluate(concurrency.group, "pull_request_target", "opened", 1);
|
|
expect(evaluate(concurrency.group, "pull_request_target", "edited", 2)).toBe(opened);
|
|
expect(evaluate(concurrency["cancel-in-progress"], "pull_request_target", "edited", 2)).toBe(
|
|
true,
|
|
);
|
|
expect(evaluate(concurrency.group, "issue_comment", "created", 3)).toBe(opened);
|
|
expect(evaluate(concurrency.group, "issue_comment", "edited", 4)).toBe(opened);
|
|
expect(evaluate(concurrency["cancel-in-progress"], "issue_comment", "created", 3)).toBe(false);
|
|
expect(evaluate(concurrency["cancel-in-progress"], "issue_comment", "edited", 4)).toBe(true);
|
|
});
|
|
|
|
it("runs the PR context and evidence gate only for relevant PR changes", () => {
|
|
const workflow = readRealBehaviorProofWorkflow();
|
|
const job = workflow.jobs["real-behavior-proof"];
|
|
|
|
expect(workflow.name).toBe("PR context and evidence");
|
|
expect(workflow.jobs["real-behavior-proof"].name).toBe("PR context and evidence");
|
|
expect(workflow.on.pull_request_target.types).toEqual([
|
|
"opened",
|
|
"edited",
|
|
"synchronize",
|
|
"reopened",
|
|
"ready_for_review",
|
|
]);
|
|
expect(workflow.concurrency).toBeUndefined();
|
|
expect(job.concurrency.group).toBe(
|
|
"${{ github.workflow }}-${{ github.event.pull_request.number }}",
|
|
);
|
|
expect(job.concurrency["cancel-in-progress"]).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
// name, body, override, permission, issue, admitted, reacts
|
|
["plain", "Thanks", "", "write", false, false, false],
|
|
["empty", "", "", "write", false, false, false],
|
|
["multiline", "context\r\n \t/merge now \r\n", "", "write", false, true, true],
|
|
["slash nonmatch", "/landscape", "", "write", false, true, false],
|
|
["custom words", "ship it now", " ship it ,", "write", false, true, true],
|
|
["custom Unicode", " ✅ later ", "✅", "write", false, true, true],
|
|
["false override", "false", "false", "write", false, true, true],
|
|
["zero override", "0", "0", "write", false, true, true],
|
|
["empty parsed override", "Thanks", " , ", "write", false, true, false],
|
|
["non-maintainer", "/merge", "", "read", false, true, false],
|
|
["issue autoclose", "/autoclose reason", "", "write", true, true, true],
|
|
["issue non-autoclose", "/merge", "", "write", true, true, false],
|
|
] as const)(
|
|
"preserves reaction admission and actions for %s",
|
|
async (_name, body, commands, permission, issue, admitted, reacts) => {
|
|
const job = readWorkflow(".github/workflows/maintainer-command-reactions.yml").jobs.react;
|
|
const event = {
|
|
comment: { id: 456, body, user: { login: "comment-author" } },
|
|
issue: { number: 123, ...(issue ? {} : { pull_request: {} }) },
|
|
};
|
|
const context = {
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
eventName: "issue_comment" as const,
|
|
actor: "event-actor",
|
|
githubEvent: event,
|
|
maintainerCommands: commands,
|
|
};
|
|
const reactions: string[] = [];
|
|
const permissionUsers: string[] = [];
|
|
const step = job.steps.find((candidate: WorkflowStep) => candidate.with?.script);
|
|
await runInNewContext(`(async () => { ${step.with.script} })()`, {
|
|
context: { payload: event, repo: { owner: "openclaw", repo: "openclaw" } },
|
|
process: {
|
|
env: {
|
|
MAINTAINER_COMMAND_REACTIONS: evaluateWorkflowExpression(
|
|
job.env.MAINTAINER_COMMAND_REACTIONS,
|
|
context,
|
|
),
|
|
},
|
|
},
|
|
core: { info() {}, warning() {} },
|
|
github: {
|
|
rest: {
|
|
repos: {
|
|
async getCollaboratorPermissionLevel({ username }: { username: string }) {
|
|
permissionUsers.push(username);
|
|
return { data: { permission } };
|
|
},
|
|
},
|
|
reactions: {
|
|
async createForIssueComment({ content }: { content: string }) {
|
|
reactions.push(content);
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(reactions).toEqual(reacts ? ["eyes"] : []);
|
|
expect(permissionUsers.every((login) => login === "comment-author")).toBe(true);
|
|
expect(Boolean(evaluateWorkflowExpression(job.if, context))).toBe(admitted);
|
|
},
|
|
);
|
|
|
|
it("keeps reaction admission open for every configured default and closed for bot actors", () => {
|
|
const workflow = readWorkflow(".github/workflows/maintainer-command-reactions.yml");
|
|
const job = workflow.jobs.react;
|
|
const context = {
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
eventName: "issue_comment" as const,
|
|
actor: "maintainer",
|
|
};
|
|
const defaults = String(
|
|
evaluateWorkflowExpression(job.env.MAINTAINER_COMMAND_REACTIONS, context),
|
|
);
|
|
for (const command of defaults.split(",")) {
|
|
// A future nonslash default would invalidate the necessary-character admission guard.
|
|
expect(command, "default commands must retain slash admission").toContain("/");
|
|
const githubEvent = { comment: { body: `earlier line\n ${command} args ` } };
|
|
expect(evaluateWorkflowExpression(job.if, { ...context, githubEvent })).toBe(true);
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
...context,
|
|
githubEvent,
|
|
actor: "automation[bot]",
|
|
maintainerCommands: "ship",
|
|
}),
|
|
).toBe(false);
|
|
}
|
|
expect(workflow.on.issue_comment.types).toEqual(["created", "edited"]);
|
|
});
|
|
|
|
it.each([
|
|
["issue_comment", "created", "Bot", "human", false],
|
|
["issue_comment", "created", "User", "clawsweeper[bot]", true],
|
|
["issue_comment", "created", undefined, "human", true],
|
|
["issues", "opened", "Bot", "clawsweeper[bot]", true],
|
|
["pull_request_target", "opened", "Bot", "clawsweeper[bot]", true],
|
|
["pull_request_target", "labeled", "Bot", "clawsweeper[bot]", false],
|
|
["pull_request_target", "unlabeled", "Bot", "openclaw-clawsweeper[bot]", false],
|
|
["pull_request_target", "labeled", "Bot", "openclaw-barnacle[bot]", true],
|
|
["pull_request_target", "labeled", "User", "maintainer", true],
|
|
] as const)(
|
|
"preserves Barnacle comment admission for %s/%s/%s/%s",
|
|
(eventName, action, type, actor, admitted) => {
|
|
const job = readWorkflow(".github/workflows/auto-response.yml").jobs["auto-response"];
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
eventName,
|
|
actor,
|
|
githubEvent: {
|
|
action,
|
|
issue: { author_association: "NONE" },
|
|
pull_request: { author_association: "NONE" },
|
|
comment: { body: "testflight @openclaw/maintainer", user: { type } },
|
|
},
|
|
}),
|
|
).toBe(admitted);
|
|
},
|
|
);
|
|
|
|
it("isolates auto-response per item and ignores ClawSweeper PR label feedback", () => {
|
|
const workflow = readWorkflow(".github/workflows/auto-response.yml");
|
|
const job = workflow.jobs["auto-response"];
|
|
const guard = job.if;
|
|
|
|
expect(workflow.on.issues.types).toEqual(["opened", "edited", "labeled"]);
|
|
expect(workflow.on.issue_comment.types).toEqual(["created"]);
|
|
expect(workflow.on.pull_request_target.types).toEqual([
|
|
"opened",
|
|
"edited",
|
|
"synchronize",
|
|
"reopened",
|
|
"labeled",
|
|
"unlabeled",
|
|
]);
|
|
expect(workflow.concurrency).toBeUndefined();
|
|
expect(job.concurrency.group).toBe(
|
|
"${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}",
|
|
);
|
|
expect(job.concurrency["cancel-in-progress"]).toBe(
|
|
"${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }}",
|
|
);
|
|
expect(guard).toContain("github.event_name != 'pull_request_target'");
|
|
expect(guard).toContain("github.event.action != 'labeled'");
|
|
expect(guard).toContain("github.event.action != 'unlabeled'");
|
|
expect(guard).toContain("github.actor != 'clawsweeper[bot]'");
|
|
expect(guard).toContain("github.actor != 'openclaw-clawsweeper[bot]'");
|
|
expect(guard).not.toContain("openclaw-barnacle[bot]");
|
|
});
|
|
|
|
it("routes stale bug issues through ClawSweeper instead of Barnacle closure", () => {
|
|
const staleWorkflow = readWorkflow(".github/workflows/stale.yml");
|
|
const staleSteps = staleWorkflow.jobs.stale.steps as WorkflowStep[];
|
|
const stepNamed = (name: string) =>
|
|
expectDefined(
|
|
staleSteps.find((step) => step.name === name),
|
|
name,
|
|
);
|
|
|
|
for (const name of [
|
|
"Mark stale unassigned issues and pull requests (primary)",
|
|
"Mark stale assigned issues (primary)",
|
|
"Mark stale unassigned issues and pull requests (fallback)",
|
|
"Mark stale assigned issues (fallback)",
|
|
]) {
|
|
const exemptLabels = String(stepNamed(name).with?.["exempt-issue-labels"])
|
|
.split(",")
|
|
.map((label) => label.trim());
|
|
expect(exemptLabels, name).toContain("bug");
|
|
}
|
|
|
|
const bugJob = staleWorkflow.jobs["stale-bug-verification"];
|
|
expect(bugJob.permissions).toEqual({ issues: "write" });
|
|
expect(evaluateWorkflowRunner(bugJob["runs-on"])).toBe("ubuntu-24.04");
|
|
const bugScript = String(
|
|
(bugJob.steps as WorkflowStep[]).find(
|
|
(step) => step.name === "Mark inactive bugs for ClawSweeper verification",
|
|
)?.with?.script,
|
|
);
|
|
expect(bugScript).toContain("const maxMarks = 25;");
|
|
expect(bugScript).toContain('labels: "bug"');
|
|
expect(bugScript).toContain("github.rest.issues.addLabels");
|
|
expect(bugScript).toContain("github.rest.issues.removeLabel");
|
|
expect(bugScript).toContain("Inactivity alone will not close a bug report.");
|
|
expect(bugScript).toContain("requires separate backfill approval");
|
|
expect(bugScript).toContain("slice(staleEventIndex + 1)");
|
|
expect(bugScript).toContain("updatedAtMs > lastAutomationAtMs");
|
|
expect(bugScript).toContain('item.state !== "open"');
|
|
expect(bugScript).not.toContain("15_000");
|
|
expect(bugScript).not.toContain("github.rest.issues.update");
|
|
|
|
const backfillScript = String(
|
|
(staleWorkflow.jobs["backfill-stale-closures"].steps as WorkflowStep[]).find(
|
|
(step) => step.name === "Backfill stale closures",
|
|
)?.with?.script,
|
|
);
|
|
expect(backfillScript).toMatch(/issueExemptLabels[\s\S]*"bug"/);
|
|
|
|
const dispatchWorkflow = readWorkflow(".github/workflows/clawsweeper-dispatch.yml");
|
|
const dispatchCondition = String(dispatchWorkflow.jobs.dispatch.if);
|
|
expect(dispatchCondition).toContain("github.event.label.name == 'stale'");
|
|
expect(dispatchCondition).toContain("contains(github.event.issue.labels.*.name, 'bug')");
|
|
expect(dispatchCondition).toContain("github.actor_id == '257215752'");
|
|
expect(dispatchCondition).toContain("github.actor_id == '264559031'");
|
|
|
|
const auditJob = staleWorkflow.jobs["audit-bug-closure-reasons"];
|
|
expect(auditJob.permissions).toEqual({ issues: "read" });
|
|
const auditScript = String((auditJob.steps as WorkflowStep[])[0]?.with?.script);
|
|
expect(auditScript).toContain('item.state_reason !== "not_planned"');
|
|
expect(auditScript).toContain("github.rest.issues.listEventsForTimeline");
|
|
expect(auditScript).toContain("github.paginate.iterator(");
|
|
expect(auditScript).toContain("new Set([257215752, 264559031])");
|
|
expect(auditScript).toContain("escapeSummaryCell(violation.title)");
|
|
expect(auditScript).toContain('.replaceAll("<", "<")');
|
|
expect(auditScript).toContain("core.setFailed(");
|
|
expect(auditScript).not.toContain("github.rest.issues.update");
|
|
expect(auditScript).not.toContain("github.rest.issues.createComment");
|
|
});
|
|
|
|
it("makes the hosted release-gate fallback explicit and exact-SHA only", () => {
|
|
const workflow = readCiWorkflow();
|
|
const releaseGate = workflow.on.workflow_dispatch.inputs.release_gate;
|
|
|
|
expect(releaseGate).toEqual({
|
|
description:
|
|
"Run an exact-SHA maintainer release-gate fallback when PR CI is capacity-stalled.",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
expect(workflow.on.workflow_dispatch.inputs.dispatch_id).toEqual({
|
|
description: "Optional parent workflow dispatch identifier",
|
|
required: false,
|
|
default: "",
|
|
type: "string",
|
|
});
|
|
expect(workflow.on.workflow_dispatch.inputs.pull_request_number).toEqual({
|
|
description: "Pull request number required by the exact-SHA release gate.",
|
|
required: false,
|
|
default: "",
|
|
type: "string",
|
|
});
|
|
expect(workflow.on.workflow_dispatch.inputs).not.toHaveProperty("loc_base_ref");
|
|
expect(workflow.on.workflow_dispatch.inputs).not.toHaveProperty("pr_number");
|
|
expect(workflow.on.workflow_dispatch.inputs.release_scope).toMatchObject({
|
|
default: "full",
|
|
type: "choice",
|
|
options: ["full", "npm-beta", "npm-stable"],
|
|
});
|
|
expect(workflow.jobs.preflight.outputs.release_scope).toBe(
|
|
"${{ steps.manifest.outputs.release_scope }}",
|
|
);
|
|
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
|
|
"run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_id != '' && format('CI {0}', inputs.dispatch_id) || (github.event_name == 'workflow_dispatch' && inputs.release_gate && format('CI release gate {0}', inputs.target_ref) || 'CI') }}",
|
|
);
|
|
const preflightSteps = workflow.jobs.preflight.steps;
|
|
expect(
|
|
preflightSteps.find((step: WorkflowStep) => step.name === "Build CI manifest").env,
|
|
).toMatchObject({
|
|
OPENCLAW_CI_RELEASE_SCOPE: "${{ inputs.release_scope || 'full' }}",
|
|
OPENCLAW_CI_PULL_REQUEST_NUMBER: "${{ inputs.pull_request_number }}",
|
|
OPENCLAW_CI_TARGET_REF: "${{ inputs.target_ref }}",
|
|
OPENCLAW_CI_TARGET_CONTEXT_REF: "${{ inputs.target_context_ref }}",
|
|
OPENCLAW_CI_HISTORICAL_TARGET_TAG: "${{ inputs.historical_target_tag }}",
|
|
});
|
|
const validationStep = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Validate release-gate dispatch",
|
|
);
|
|
expect(validationStep.if).toBe(
|
|
"github.event_name == 'workflow_dispatch' && inputs.release_gate",
|
|
);
|
|
expect(validationStep.run).toContain(
|
|
"release_gate requires target_ref to be a full commit SHA",
|
|
);
|
|
expect(validationStep.run).toContain("release_gate requires pull_request_number");
|
|
expect(validationStep.run).toContain("release_gate must run from the branch at target_ref");
|
|
expect(validationStep.run).toContain(
|
|
"release_gate cannot be combined with historical_target_tag",
|
|
);
|
|
const diffBaseStep = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve exact diff base",
|
|
);
|
|
expect(diffBaseStep.env).toMatchObject({
|
|
PULL_REQUEST_NUMBER: "${{ inputs.pull_request_number }}",
|
|
RELEASE_GATE: "${{ inputs.release_gate }}",
|
|
});
|
|
expect(diffBaseStep.run).toContain("refs/pull/${PULL_REQUEST_NUMBER}/merge");
|
|
expect(diffBaseStep.run).toContain('release_gate_head="$(git rev-parse "${merge_ref}^2")"');
|
|
expect(diffBaseStep.run).toContain(
|
|
"release_gate pull request head ${release_gate_head} does not match target ${target_head}",
|
|
);
|
|
expect(diffBaseStep.run).toContain('base_sha="$(git rev-parse "${merge_ref}^1")"');
|
|
expect(diffBaseStep.run).toContain('head_sha="$(git rev-parse "$merge_ref")"');
|
|
expect(diffBaseStep.run).toContain('echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"');
|
|
const changedScopeStep = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Detect changed scopes",
|
|
);
|
|
expect(changedScopeStep.if).toContain(
|
|
"github.event_name == 'workflow_dispatch' && inputs.release_gate",
|
|
);
|
|
expect(changedScopeStep.env?.OPENCLAW_ALLOW_RELEASE_GENERATED_MIX).toContain(
|
|
"github.event_name == 'workflow_dispatch'",
|
|
);
|
|
expect(changedScopeStep.run).toContain('elif [ "${{ github.event_name }}" = "pull_request" ]');
|
|
expect(changedScopeStep.run).toContain('HEAD_SHA="${{ steps.diff_base.outputs.head_sha }}"');
|
|
expect(changedScopeStep.run).toContain(
|
|
'node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"',
|
|
);
|
|
expect(workflow.jobs.preflight.permissions).toEqual({
|
|
contents: "read",
|
|
actions: "read",
|
|
"pull-requests": "read",
|
|
});
|
|
expect(workflow.jobs.preflight.outputs.run_ios_screenshots).toBe(
|
|
"${{ steps.changed_scope.outputs.run_ios_screenshots }}",
|
|
);
|
|
const manifestEnv = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
).env;
|
|
for (const [environmentKey, scopeKey] of [
|
|
["OPENCLAW_CI_RUN_MACOS", "run_macos"],
|
|
["OPENCLAW_CI_RUN_IOS_BUILD", "run_ios_build"],
|
|
] as const) {
|
|
for (const [eventName, isReleaseGate, selected, expected] of [
|
|
["schedule", false, false, true],
|
|
["workflow_dispatch", false, false, true],
|
|
["workflow_dispatch", true, false, false],
|
|
["workflow_dispatch", true, true, true],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(manifestEnv[environmentKey], {
|
|
eventName,
|
|
releaseGate: isReleaseGate,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
steps: { changed_scope: { outputs: { [scopeKey]: String(selected) } } },
|
|
}),
|
|
`${environmentKey}/${eventName}/${isReleaseGate}/${selected}`,
|
|
).toBe(String(expected));
|
|
}
|
|
}
|
|
for (const [
|
|
requestedRunnerBackend,
|
|
isReleaseGate,
|
|
includeAndroid,
|
|
changedAndroid,
|
|
expected,
|
|
] of [
|
|
["runson", true, false, false, false],
|
|
["runson", true, false, true, true],
|
|
["runson", true, true, false, true],
|
|
["default", true, false, false, true],
|
|
["default", false, false, false, false],
|
|
["default", false, true, false, true],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(manifestEnv.OPENCLAW_CI_RUN_ANDROID, {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
requestedRunnerBackend,
|
|
releaseGate: isReleaseGate,
|
|
includeAndroid,
|
|
steps: {
|
|
runner_profile: {
|
|
outputs: {
|
|
runner_profile: "hybrid",
|
|
node_runner_backend: requestedRunnerBackend === "runson" ? "runson" : "hybrid",
|
|
},
|
|
},
|
|
changed_scope: { outputs: { run_android: String(changedAndroid) } },
|
|
},
|
|
}),
|
|
JSON.stringify({ requestedRunnerBackend, isReleaseGate, includeAndroid, changedAndroid }),
|
|
).toBe(String(expected));
|
|
}
|
|
for (const [environmentKey, scopeKey] of [
|
|
["OPENCLAW_CI_RUN_WINDOWS", "run_windows"],
|
|
["OPENCLAW_CI_RUN_SKILLS_PYTHON", "run_skills_python"],
|
|
["OPENCLAW_CI_RUN_CONTROL_UI_I18N", "run_control_ui_i18n"],
|
|
["OPENCLAW_CI_RUN_UI_TESTS", "run_ui_tests"],
|
|
["OPENCLAW_CI_RUN_NATIVE_I18N", "run_native_i18n"],
|
|
] as const) {
|
|
for (const nodeRunnerBackend of ["runson", "hybrid"]) {
|
|
for (const selected of [false, true]) {
|
|
expect(
|
|
evaluateWorkflowExpression(manifestEnv[environmentKey], {
|
|
eventName: "workflow_dispatch",
|
|
releaseGate: true,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
steps: {
|
|
runner_profile: {
|
|
outputs: { runner_profile: "hybrid", node_runner_backend: nodeRunnerBackend },
|
|
},
|
|
changed_scope: { outputs: { [scopeKey]: String(selected) } },
|
|
},
|
|
}),
|
|
`${environmentKey}/${nodeRunnerBackend}/${selected}`,
|
|
).toBe(String(nodeRunnerBackend === "runson" ? selected : true));
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [jobName, job] of Object.entries(workflow.jobs)) {
|
|
const runsOn = (job as { "runs-on"?: unknown })["runs-on"];
|
|
if (typeof runsOn !== "string" || !runsOn.includes("blacksmith-")) {
|
|
continue;
|
|
}
|
|
expect(
|
|
evaluateWorkflowExpression(runsOn, {
|
|
eventName: "workflow_dispatch",
|
|
releaseGate: true,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend: "hybrid",
|
|
}),
|
|
`${jobName} must use GitHub-hosted capacity for release gates`,
|
|
).toMatch(/^(?:ubuntu|windows|macos)-/u);
|
|
}
|
|
|
|
expect(
|
|
workflow.jobs["macos-node"]["runs-on"],
|
|
"macOS Node retries must escape stalled Blacksmith capacity",
|
|
).toContain("github.run_attempt > 1");
|
|
});
|
|
|
|
it("retains main runner placement and native worker policy for admitted main qualification", () => {
|
|
const workflow = readCiWorkflow();
|
|
const push = {
|
|
eventName: "push" as const,
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "hybrid" as const,
|
|
runAttempt: 1,
|
|
matrix: { runner: "blacksmith-8vcpu-ubuntu-2404", check_name: "fixture", task: "test" },
|
|
preflightOutputs: { node_runner_backend: "hybrid", runner_profile: "hybrid" },
|
|
};
|
|
const qualification = {
|
|
...push,
|
|
eventName: "workflow_dispatch" as const,
|
|
ref: "refs/heads/qualification-branch",
|
|
releaseGate: true,
|
|
ciShape: "main" as const,
|
|
requestedRunnerBackend: "hybrid" as const,
|
|
preflightOutputs: {
|
|
...push.preflightOutputs,
|
|
ci_qualification: "true",
|
|
ci_shape: "main",
|
|
qualification_runner_backend: "hybrid",
|
|
},
|
|
};
|
|
// The PR cancellation monitor has no runner contract on main or qualification runs.
|
|
for (const context of [push, qualification]) {
|
|
expect(evaluateWorkflowExpression(workflow.jobs["pr-fail-fast"].if, context)).toBe(false);
|
|
}
|
|
for (const [name, rawJob] of Object.entries(workflow.jobs)) {
|
|
const job = rawJob as {
|
|
"runs-on": string;
|
|
"timeout-minutes"?: string | number;
|
|
needs?: string[] | string;
|
|
};
|
|
if (name === "pr-fail-fast" || !String(job.needs).includes("preflight")) {
|
|
continue;
|
|
}
|
|
for (const key of ["runs-on", "timeout-minutes"] as const) {
|
|
const expression = job[key];
|
|
if (typeof expression === "string" && expression.startsWith("${{")) {
|
|
expect(evaluateWorkflowExpression(expression, qualification), `${name}.${key}`).toEqual(
|
|
evaluateWorkflowExpression(expression, push),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(
|
|
evaluateWorkflowExpression(workflow.jobs.android["timeout-minutes"], {
|
|
...qualification,
|
|
matrix: { task: "build-play" },
|
|
}),
|
|
).toBe(20);
|
|
expect(evaluateWorkflowExpression(workflow.jobs.preflight["runs-on"], qualification)).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
expect(
|
|
evaluateWorkflowExpression(
|
|
workflow.jobs["macos-swift"].env.OPENCLAWKIT_TEST_EXECUTION,
|
|
qualification,
|
|
),
|
|
).toBe("parallel");
|
|
expect(
|
|
evaluateWorkflowExpression(
|
|
`\${{ ${workflow.jobs["checks-node-compat"].if} }}`,
|
|
qualification,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("starts Apple builds and screenshots directly on hosted capacity", () => {
|
|
const workflow = readCiWorkflow();
|
|
for (const jobName of ["macos-swift", "ios-build", "ios-screenshot-shard"]) {
|
|
expect(evaluateWorkflowRunner(workflow.jobs[jobName]["runs-on"]), jobName).toBe("xcode-27");
|
|
}
|
|
expect(workflow.jobs["macos-swift"]["timeout-minutes"]).toBe(30);
|
|
});
|
|
|
|
it("serializes the shared Swift package suite on hosted macOS retries", () => {
|
|
const macosSwift = readCiWorkflow().jobs["macos-swift"];
|
|
|
|
expect(macosSwift.env.OPENCLAWKIT_TEST_EXECUTION).toContain("github.run_attempt > 1");
|
|
const openClawKitTests = macosSwift.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "OpenClawKit tests",
|
|
);
|
|
expect(openClawKitTests?.run).toContain('if [[ "$OPENCLAWKIT_TEST_EXECUTION" == "parallel" ]]');
|
|
expect(openClawKitTests?.run).toContain("--parallel");
|
|
expect(openClawKitTests?.run).toContain("--no-parallel");
|
|
});
|
|
|
|
it("keeps Testbox runner admission and job budget compatible with delegated proof", () => {
|
|
const workflow = readTestboxWorkflow();
|
|
|
|
expect(workflow.on.workflow_dispatch.inputs.timeout_minutes.default).toBe(240);
|
|
expect(workflow.jobs.check["timeout-minutes"]).toBe(
|
|
"${{ fromJSON(inputs.timeout_minutes || '240') }}",
|
|
);
|
|
expect(workflow.jobs.check["runs-on"]).toBe(
|
|
"${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'blacksmith-16vcpu-ubuntu-2404' }}",
|
|
);
|
|
const beginStep = workflow.jobs.check.steps.find(
|
|
(step: { name?: string }) => step.name === "Begin Testbox",
|
|
);
|
|
const runStep = workflow.jobs.check.steps.find(
|
|
(step: { name?: string }) => step.name === "Run Testbox",
|
|
);
|
|
expect(beginStep).toMatchObject({
|
|
if: "github.event_name == 'workflow_dispatch'",
|
|
with: { testbox_id: "${{ inputs.testbox_id }}" },
|
|
});
|
|
expect(runStep).toMatchObject({
|
|
if: "github.event_name == 'workflow_dispatch' && always()",
|
|
});
|
|
});
|
|
|
|
it("keeps every path-filtered hosted gate runnable on landing-relevant events", () => {
|
|
const workflows = [
|
|
[".github/workflows/ci-check-testbox.yml", "check"],
|
|
[".github/workflows/ci-check-arm-testbox.yml", "check-arm"],
|
|
[".github/workflows/ci-build-artifacts-testbox.yml", "build-artifacts"],
|
|
] as const;
|
|
|
|
for (const [workflowPath, jobName] of workflows) {
|
|
const workflow = readWorkflow(workflowPath);
|
|
expect(workflow.on.pull_request.types).toEqual([
|
|
"opened",
|
|
"reopened",
|
|
"synchronize",
|
|
"ready_for_review",
|
|
]);
|
|
expect(workflow.on.pull_request.paths).toContain(workflowPath);
|
|
expect(workflow.on.pull_request.paths).not.toContain(".github/workflows/**");
|
|
expect(workflow.jobs[jobName].if).toBe(
|
|
"${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("pins every external GitHub Action reference to a full commit SHA", () => {
|
|
expect(findUnpinnedExternalActions()).toEqual([]);
|
|
});
|
|
|
|
it("schedules approved Docker refreshes from independently resolved channels", () => {
|
|
const workflow = readWorkflow(".github/workflows/docker-image-refresh.yml");
|
|
const releaseWorkflow = readWorkflow(".github/workflows/docker-release.yml");
|
|
const plan = workflow.jobs.plan;
|
|
const publish = workflow.jobs.publish;
|
|
const planSteps = plan.steps as WorkflowStep[];
|
|
const mainGuard = expectDefined(
|
|
planSteps.find((step) => step.name === "Require a main-branch run"),
|
|
"Docker refresh main-branch guard",
|
|
);
|
|
const resolve = expectDefined(
|
|
planSteps.find((step) => step.name === "Resolve refresh plan"),
|
|
"Docker refresh plan step",
|
|
);
|
|
|
|
expect(workflow.on.schedule).toEqual([{ cron: "17 3 * * 1" }]);
|
|
expect(workflow.on.workflow_dispatch.inputs.channel).toEqual({
|
|
description: "Release channel to rebuild",
|
|
required: false,
|
|
default: "both",
|
|
type: "choice",
|
|
options: ["stable", "extended-stable", "both"],
|
|
});
|
|
expect(workflow.on.workflow_dispatch.inputs.dry_run).toEqual({
|
|
description: "Resolve and summarize without publishing",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
expect(plan.permissions).toEqual({ contents: "read" });
|
|
expect(mainGuard.run).toContain('[[ "${WORKFLOW_REF}" != "refs/heads/main" ]]');
|
|
expect(resolve.run).toContain("docker-release-policy.mjs --current");
|
|
expect(resolve.run).toContain('git rev-parse "refs/tags/${stable_tag}^{commit}"');
|
|
expect(resolve.run).toContain('git rev-parse "refs/tags/${extended_stable_tag}^{commit}"');
|
|
expect(resolve.run).toContain('suffix="-r$(date -u +%Y%m%d)"');
|
|
expect(resolve.run).toContain('echo "matrix=${matrix}"');
|
|
expect(resolve.run).toContain('} >> "${GITHUB_OUTPUT}"');
|
|
expect(plan.environment).toBeUndefined();
|
|
expect(publish.environment).toBeUndefined();
|
|
|
|
expect(publish.needs).toBe("plan");
|
|
expect(publish.if).toBe("needs.plan.outputs.dry_run != 'true'");
|
|
expect(publish.strategy).toEqual({
|
|
"fail-fast": false,
|
|
matrix: { include: "${{ fromJSON(needs.plan.outputs.matrix) }}" },
|
|
});
|
|
expect(publish.uses).toBe("./.github/workflows/docker-release.yml");
|
|
expect(publish.with).toEqual({
|
|
tag: "${{ matrix.tag }}",
|
|
release_sha: "${{ matrix.release_sha }}",
|
|
image_tag_suffix: "${{ needs.plan.outputs.image_tag_suffix }}",
|
|
});
|
|
expect(publish.secrets).toEqual({
|
|
DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}",
|
|
DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}",
|
|
});
|
|
expect(publish.permissions).toEqual({
|
|
actions: "read",
|
|
attestations: "read",
|
|
contents: "read",
|
|
packages: "write",
|
|
});
|
|
expect(releaseWorkflow.jobs.approve.environment).toBe("docker-release");
|
|
expect(releaseWorkflow.jobs.publish.environment).toBeUndefined();
|
|
expect(releaseWorkflow.jobs.publish.needs).toContain("approve");
|
|
});
|
|
|
|
it("keeps locale refresh matrices alive and publishes each aggregate through a PR", () => {
|
|
const controlUiWorkflow = parse(readFileSync(CONTROL_UI_LOCALE_REFRESH_WORKFLOW, "utf8"));
|
|
const workflow = parse(readFileSync(NATIVE_APP_LOCALE_REFRESH_WORKFLOW, "utf8"));
|
|
const controlUiResolveBase = controlUiWorkflow.jobs["resolve-base"];
|
|
const nativeResolveBase = workflow.jobs["resolve-base"];
|
|
const controlUiPreflight = controlUiWorkflow.jobs["publisher-preflight"];
|
|
const nativePreflight = workflow.jobs["publisher-preflight"];
|
|
const refresh = workflow.jobs.refresh;
|
|
const nativeFinalize = workflow.jobs.finalize;
|
|
const controlUiFinalize = controlUiWorkflow.jobs.finalize;
|
|
const refreshStep = refresh.steps.find(
|
|
(step: { name?: string }) => step.name === "Refresh locale translations",
|
|
);
|
|
const nativeArtifactStep = refresh.steps.find(
|
|
(step: { name?: string }) => step.name === "Prepare locale artifact",
|
|
);
|
|
const nativeGeneratedStep = nativeFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Refresh native generated artifacts",
|
|
);
|
|
const nativeValidationStep = nativeFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Validate native locale refresh",
|
|
);
|
|
const nativePublishStep = nativeFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Open or update generated locale PR",
|
|
);
|
|
const controlUiRefreshStep = controlUiWorkflow.jobs.refresh.steps.find(
|
|
(step: { name?: string }) => step.name === "Refresh locale translations",
|
|
);
|
|
const controlUiAggregateStep = controlUiFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Finalize control UI generated artifacts",
|
|
);
|
|
const controlUiValidationStep = controlUiFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Validate control UI locale refresh",
|
|
);
|
|
|
|
expect(refresh.if).toBe(
|
|
"needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success'",
|
|
);
|
|
expect(refresh.strategy.matrix.locale).toEqual(NATIVE_I18N_LOCALES);
|
|
expect(controlUiWorkflow.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(controlUiWorkflow.concurrency.group.replace(/\s+/gu, " ")).toBe(
|
|
"${{ github.event_name == 'workflow_dispatch' && inputs.token_preflight_only && format('control-ui-locale-token-preflight-{0}', github.ref) || 'control-ui-locale-refresh' }}",
|
|
);
|
|
expect(controlUiWorkflow.jobs.plan).toBeUndefined();
|
|
expect(controlUiResolveBase.outputs.locales).toBe("${{ steps.base.outputs.locales }}");
|
|
expect(controlUiWorkflow.jobs.refresh.if).toBe(
|
|
"needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.token_preflight_only)",
|
|
);
|
|
expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toBe(
|
|
"${{ fromJSON(needs.resolve-base.outputs.locales) }}",
|
|
);
|
|
expect(workflow.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(workflow.concurrency.group).toBe("native-app-locale-refresh");
|
|
expect(controlUiResolveBase.if).not.toContain("chore(ui): refresh control ui locales");
|
|
const controlResolveCondition = controlUiResolveBase.if.replace(/\s+/gu, " ");
|
|
expect(controlResolveCondition).toBe(
|
|
"github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
|
|
);
|
|
expect(controlResolveCondition).not.toContain("inputs.token_preflight_only");
|
|
expect(controlResolveCondition).not.toContain("github.ref_type");
|
|
expect(nativeResolveBase.if).toBe(
|
|
"github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
|
|
);
|
|
expect(controlUiWorkflow.on.workflow_dispatch.inputs.token_preflight_only).toEqual({
|
|
description: "Verify generated PR App permissions without running locale generation.",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
for (const owner of [workflow, controlUiWorkflow]) {
|
|
expect(owner.on.workflow_dispatch.inputs.full_refresh).toMatchObject({
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
}
|
|
expect(workflow.on.push.paths).toContain("ui/src/i18n/.i18n/glossary.*.json");
|
|
expect(nativePublishStep.with["invalidation-paths"].trim().split("\n")).toContain(
|
|
"ui/src/i18n/.i18n/glossary.*.json",
|
|
);
|
|
expect(workflow.on.push.paths).toContain("apps/.i18n/native/**");
|
|
expect(workflow.on.push.paths).toContain("apps/.i18n/native-source.json");
|
|
for (const sourceRoot of [
|
|
"apps/android/app/src/main",
|
|
"apps/android/app/src/play",
|
|
"apps/android/app/src/thirdParty",
|
|
"apps/android/wear/src/main",
|
|
"apps/ios",
|
|
"apps/macos/Sources",
|
|
"apps/shared/OpenClawKit/Sources",
|
|
]) {
|
|
expect(workflow.on.push.paths).toContain(`${sourceRoot}/**`);
|
|
expect(nativePublishStep.with["invalidation-paths"].trim().split("\n")).toContain(sourceRoot);
|
|
}
|
|
expect(workflow.on.push.paths).toContain("apps/macos/Package.swift");
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain("apps/macos/Package.swift");
|
|
expect(workflow.on.push.paths).toContain(".github/actions/publish-generated-pr/policy.py");
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain(
|
|
".github/actions/publish-generated-pr/policy.py",
|
|
);
|
|
for (const generatorInput of [
|
|
"scripts/android-app-i18n.ts",
|
|
"scripts/apple-app-i18n.ts",
|
|
"scripts/native-app-i18n.ts",
|
|
"scripts/native-i18n-locales.ts",
|
|
]) {
|
|
expect(workflow.on.push.paths).toContain(generatorInput);
|
|
expect(nativePublishStep.with["invalidation-paths"].trim().split("\n")).toContain(
|
|
generatorInput,
|
|
);
|
|
}
|
|
expect(refreshStep.env.OPENAI_API_KEY).toBe(
|
|
"${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}",
|
|
);
|
|
expect(refreshStep.env.OPENCLAW_CONTROL_UI_I18N_MODEL).toBe(
|
|
"${{ secrets.OPENCLAW_I18N_MODEL }}",
|
|
);
|
|
expect(refreshStep.env.OPENCLAW_I18N_FALLBACK_MODEL).toBe(
|
|
"${{ secrets.OPENCLAW_I18N_FALLBACK_MODEL }}",
|
|
);
|
|
expect(refreshStep.env.FULL_REFRESH).toBe("${{ inputs.full_refresh || false }}");
|
|
expect(refreshStep.run).toContain("args+=(--force)");
|
|
expect(nativeArtifactStep.run).toContain("git add -A apps/.i18n/native");
|
|
expect(nativeArtifactStep.run).not.toContain("native-source.json");
|
|
expect(nativeGeneratedStep.run).toBe(
|
|
"node --import tsx scripts/native-app-i18n.ts sync --write",
|
|
);
|
|
expect(nativeValidationStep.run).toBe("node --import tsx scripts/native-app-i18n.ts check");
|
|
expect(nativeFinalize.steps.map((step: { name?: string }) => step.name)).not.toContain(
|
|
"Refresh Android native resources",
|
|
);
|
|
expect(nativeFinalize.steps.map((step: { name?: string }) => step.name)).not.toContain(
|
|
"Refresh Apple native resources",
|
|
);
|
|
expect(nativePublishStep.with["generated-paths"].trim().split("\n")).toEqual([
|
|
"apps/.i18n/native",
|
|
"apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt",
|
|
"apps/android/app/src/main/res/values*/assistant.xml",
|
|
"apps/android/app/src/main/res/values*/strings.xml",
|
|
"apps/android/app/src/thirdParty/res/values*/accessibility_strings.xml",
|
|
"apps/android/wear/src/main/res/values*/strings.xml",
|
|
"apps/ios/Resources/Localizable.xcstrings",
|
|
"apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings",
|
|
"apps/ios/Sources/*.lproj/InfoPlist.strings",
|
|
"apps/ios/WatchApp/*.lproj/InfoPlist.strings",
|
|
"apps/ios/ShareExtension/*.lproj/InfoPlist.strings",
|
|
"apps/ios/ActivityWidget/*.lproj/InfoPlist.strings",
|
|
]);
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain("apps/.i18n/native-source.json");
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain("apps/android/app/src/play");
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain(
|
|
"apps/android/app/src/thirdParty",
|
|
);
|
|
expect(nativePublishStep.with["auto-merge"]).toBe("true");
|
|
expect(controlUiRefreshStep.env.OPENAI_API_KEY).toBe(
|
|
"${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}",
|
|
);
|
|
expect(controlUiRefreshStep.env.OPENCLAW_CONTROL_UI_I18N_MODEL).toBe(
|
|
"${{ secrets.OPENCLAW_I18N_MODEL }}",
|
|
);
|
|
expect(controlUiRefreshStep.env.OPENCLAW_I18N_FALLBACK_MODEL).toBe(
|
|
"${{ secrets.OPENCLAW_I18N_FALLBACK_MODEL }}",
|
|
);
|
|
expect(controlUiRefreshStep.env.FULL_REFRESH).toBe("${{ inputs.full_refresh || false }}");
|
|
expect(controlUiRefreshStep.run).toContain("args+=(--force)");
|
|
expect(controlUiRefreshStep.env.OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL).toBe("0");
|
|
const controlUiArtifactStep = controlUiWorkflow.jobs.refresh.steps.find(
|
|
(step: { name?: string }) => step.name === "Prepare locale artifact",
|
|
);
|
|
expect(controlUiArtifactStep.run).toContain(
|
|
":(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json",
|
|
);
|
|
expect(controlUiArtifactStep.run).toContain("ui/src/i18n/.i18n/${LOCALE}.tm.jsonl");
|
|
expect(controlUiArtifactStep.run).toContain("ui/src/i18n/.i18n/${LOCALE}.meta.json");
|
|
expect(controlUiArtifactStep.run).not.toContain("git add -A ui/src/i18n");
|
|
expect(controlUiAggregateStep.run).toBe(
|
|
"node --import tsx scripts/control-ui-i18n.ts sync --write",
|
|
);
|
|
const controlUiPublishStep = controlUiFinalize.steps.find(
|
|
(step: { name?: string }) => step.name === "Open or update generated locale PR",
|
|
);
|
|
const controlUiCatalogInputs = [
|
|
"scripts/lib/control-ui-i18n-catalog.ts",
|
|
"ui/src/i18n/lib/config-hint-translation.ts",
|
|
"ui/src/lib/fnv1a.ts",
|
|
"src/config/schema*.ts",
|
|
"src/config/zod-schema*.ts",
|
|
"src/config/media-audio-field-metadata.ts",
|
|
"src/config/talk-defaults.ts",
|
|
"src/config/channel-config-keys.ts",
|
|
];
|
|
const nativeInputOwners = [
|
|
workflow.on.push.paths,
|
|
nativePublishStep.with["invalidation-paths"].trim().split("\n"),
|
|
];
|
|
const controlUiInputOwners = [
|
|
controlUiWorkflow.on.push.paths,
|
|
controlUiPublishStep.with["invalidation-paths"].trim().split("\n"),
|
|
];
|
|
for (const catalogInput of controlUiCatalogInputs) {
|
|
for (const ownerPaths of controlUiInputOwners) {
|
|
expect(ownerPaths).toContain(catalogInput);
|
|
}
|
|
for (const ownerPaths of nativeInputOwners) {
|
|
expect(ownerPaths).not.toContain(catalogInput);
|
|
}
|
|
}
|
|
for (const sharedTranslationInput of [
|
|
"scripts/control-ui-i18n.ts",
|
|
"scripts/lib/control-ui-i18n-catalog-values.ts",
|
|
"scripts/lib/control-ui-i18n-config.json",
|
|
"scripts/lib/control-ui-i18n-config.ts",
|
|
"scripts/lib/control-ui-i18n-sync-plan.ts",
|
|
]) {
|
|
for (const ownerPaths of [...nativeInputOwners, ...controlUiInputOwners]) {
|
|
expect(ownerPaths).toContain(sharedTranslationInput);
|
|
}
|
|
}
|
|
expect(controlUiPublishStep.with["generated-paths"].trim().split("\n")).toEqual([
|
|
"ui/src/i18n/.i18n/*.tm.jsonl",
|
|
"ui/src/i18n/.i18n/*.meta.json",
|
|
"ui/src/i18n/.i18n/catalog-fallbacks.json",
|
|
]);
|
|
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
|
|
"scripts/lib/control-ui-i18n-sync-plan.ts",
|
|
);
|
|
expect(controlUiPublishStep.with["invalidation-paths"]).toContain("ui/src/i18n/locales/*.ts");
|
|
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
|
|
"ui/src/i18n/locales/en-agents.ts",
|
|
);
|
|
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
|
|
"scripts/control-ui-i18n-verify.ts",
|
|
);
|
|
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
|
|
"scripts/lib/control-ui-i18n-raw-copy.ts",
|
|
);
|
|
expect(controlUiFinalize.steps.indexOf(controlUiAggregateStep)).toBeLessThan(
|
|
controlUiFinalize.steps.indexOf(controlUiValidationStep),
|
|
);
|
|
|
|
for (const ownerWorkflow of [controlUiWorkflow, workflow]) {
|
|
expect(ownerWorkflow.on.push.paths).toContain(CREATE_GENERATED_PR_TOKENS_ACTION);
|
|
expect(ownerWorkflow.on.push.paths).toContain(PUBLISH_GENERATED_PR_ACTION);
|
|
const resolveBase = ownerWorkflow.jobs["resolve-base"];
|
|
const resolveStep = resolveBase.steps.find(
|
|
(step: { name?: string }) =>
|
|
step.name ===
|
|
(ownerWorkflow === controlUiWorkflow
|
|
? "Resolve source commit"
|
|
: "Resolve default branch head"),
|
|
);
|
|
expect(resolveBase.outputs.sha).toBe("${{ steps.base.outputs.sha }}");
|
|
expect(resolveStep.env.GH_TOKEN).toBe("${{ github.token }}");
|
|
if (ownerWorkflow === controlUiWorkflow) {
|
|
expect(resolveStep.run.match(/gh api/gu)).toHaveLength(1);
|
|
expect(resolveStep.run).toContain("gh api graphql");
|
|
} else {
|
|
expect(resolveStep.run).toContain(
|
|
'gh api --method GET "repos/${REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha',
|
|
);
|
|
}
|
|
expect(resolveStep.run).toContain('[[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]');
|
|
|
|
const checkoutSteps = (
|
|
Object.values(ownerWorkflow.jobs) as Array<{
|
|
steps?: Array<{ uses?: string; with?: Record<string, unknown> }>;
|
|
}>
|
|
).flatMap((job: { steps?: Array<{ uses?: string; with?: Record<string, unknown> }> }) =>
|
|
(job.steps ?? []).filter((step: WorkflowStep) => step.uses === CHECKOUT_V6),
|
|
);
|
|
expect(checkoutSteps.length).toBeGreaterThan(0);
|
|
for (const checkoutStep of checkoutSteps) {
|
|
expect(checkoutStep.with?.ref).toBe("${{ needs.resolve-base.outputs.sha }}");
|
|
expect(checkoutStep.with?.["persist-credentials"]).toBe(false);
|
|
}
|
|
}
|
|
|
|
const controlUiResolveStep = controlUiResolveBase.steps.find(
|
|
(step: { name?: string }) => step.name === "Resolve source commit",
|
|
);
|
|
expect(controlUiResolveStep.env.TOKEN_PREFLIGHT_ONLY).toContain("inputs.token_preflight_only");
|
|
expect(controlUiResolveStep.env.WORKFLOW_SHA).toBe("${{ github.workflow_sha }}");
|
|
expect(controlUiResolveStep.run).toContain(
|
|
'if [[ "${TOKEN_PREFLIGHT_ONLY}" == "true" ]]; then',
|
|
);
|
|
expect(controlUiResolveStep.run).toContain('source_ref="${WORKFLOW_SHA}"');
|
|
expect(controlUiResolveStep.run).toContain(
|
|
'-F configRef="${source_ref}:scripts/lib/control-ui-i18n-config.json"',
|
|
);
|
|
expect(controlUiResolveStep.run).toContain(
|
|
"jq -ce '.data.repository.config.text | fromjson | [.[].locale]",
|
|
);
|
|
|
|
for (const preflight of [controlUiPreflight, nativePreflight]) {
|
|
expect(preflight.needs).toBe("resolve-base");
|
|
expect(preflight.if).toBe("needs.resolve-base.result == 'success'");
|
|
expect(preflight.strategy).toBeUndefined();
|
|
expect(preflight.steps).toHaveLength(3);
|
|
const checkoutStep = preflight.steps.find(
|
|
(step: { uses?: string }) => step.uses === CHECKOUT_V6,
|
|
);
|
|
const tokensStep = preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Create generated PR tokens",
|
|
);
|
|
expect(checkoutStep.with).toMatchObject({
|
|
ref: "${{ needs.resolve-base.outputs.sha }}",
|
|
"persist-credentials": false,
|
|
});
|
|
expect(tokensStep.uses).toBe("./.github/actions/create-generated-pr-tokens");
|
|
expect(tokensStep.with).toEqual({
|
|
"contents-client-id": "Iv23liOECG0slfuhz093",
|
|
"contents-private-key": "${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}",
|
|
"pull-request-client-id": MANTIS_GITHUB_APP_CLIENT_ID,
|
|
"pull-request-contents-permission": "write",
|
|
"pull-request-private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}",
|
|
});
|
|
}
|
|
for (const preflight of [controlUiPreflight, nativePreflight]) {
|
|
const tokensStep = preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Create generated PR tokens",
|
|
);
|
|
const autoMergeSettingStep = preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Verify repository auto-merge setting",
|
|
);
|
|
expect(tokensStep.id).toBe("tokens");
|
|
expect(autoMergeSettingStep.env.GH_TOKEN).toBe(
|
|
"${{ steps.tokens.outputs.pull-request-token }}",
|
|
);
|
|
expect(autoMergeSettingStep.run).toContain("autoMergeAllowed");
|
|
expect(autoMergeSettingStep.run).toContain("Repository auto-merge must be enabled");
|
|
}
|
|
|
|
const tokenAction = parse(readFileSync(CREATE_GENERATED_PR_TOKENS_ACTION, "utf8"));
|
|
const tokenActionSource = readFileSync(CREATE_GENERATED_PR_TOKENS_ACTION, "utf8");
|
|
const contentsTokenStep = tokenAction.runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Create generated branch app token",
|
|
);
|
|
const pullRequestTokenStep = tokenAction.runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Create generated PR app token",
|
|
);
|
|
const publishAction = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8"));
|
|
const publishActionSource = readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8");
|
|
const createTokensStep = publishAction.runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Create generated PR tokens",
|
|
);
|
|
const actionPublishStep = publishAction.runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Publish generated pull request",
|
|
);
|
|
|
|
expect(tokenAction.runs.steps).toHaveLength(2);
|
|
for (const input of [
|
|
"contents-client-id",
|
|
"contents-private-key",
|
|
"pull-request-client-id",
|
|
"pull-request-private-key",
|
|
]) {
|
|
expect(tokenAction.inputs[input].required).toBe(true);
|
|
expect(publishAction.inputs[input].required).toBe(true);
|
|
}
|
|
expect(`${tokenActionSource}\n${publishActionSource}`).not.toMatch(
|
|
/2729701|2971289|primary-private-key|fallback-private-key/u,
|
|
);
|
|
expect(contentsTokenStep).toEqual({
|
|
name: "Create generated branch app token",
|
|
id: "contents-token",
|
|
uses: CREATE_GITHUB_APP_TOKEN_V3,
|
|
with: {
|
|
"client-id": "${{ inputs.contents-client-id }}",
|
|
"private-key": "${{ inputs.contents-private-key }}",
|
|
owner: "${{ github.repository_owner }}",
|
|
repositories: "${{ github.event.repository.name }}",
|
|
"permission-contents": "write",
|
|
},
|
|
});
|
|
expect(pullRequestTokenStep).toEqual({
|
|
name: "Create generated PR app token",
|
|
id: "pull-request-token",
|
|
uses: CREATE_GITHUB_APP_TOKEN_V3,
|
|
with: {
|
|
"client-id": "${{ inputs.pull-request-client-id }}",
|
|
"private-key": "${{ inputs.pull-request-private-key }}",
|
|
owner: "${{ github.repository_owner }}",
|
|
repositories: "${{ github.event.repository.name }}",
|
|
"permission-contents": "${{ inputs.pull-request-contents-permission }}",
|
|
"permission-pull-requests": "write",
|
|
},
|
|
});
|
|
expect(tokenAction.inputs["pull-request-contents-permission"].required).toBe(false);
|
|
expect(tokenAction.outputs["contents-token"].value).toBe(
|
|
"${{ steps.contents-token.outputs.token }}",
|
|
);
|
|
expect(tokenAction.outputs["pull-request-token"].value).toBe(
|
|
"${{ steps.pull-request-token.outputs.token }}",
|
|
);
|
|
expect(createTokensStep).toMatchObject({
|
|
id: "tokens",
|
|
uses: "./.github/actions/create-generated-pr-tokens",
|
|
with: {
|
|
"contents-client-id": "${{ inputs.contents-client-id }}",
|
|
"contents-private-key": "${{ inputs.contents-private-key }}",
|
|
"pull-request-client-id": "${{ inputs.pull-request-client-id }}",
|
|
"pull-request-contents-permission": "${{ inputs.auto-merge == 'true' && 'write' || '' }}",
|
|
"pull-request-private-key": "${{ inputs.pull-request-private-key }}",
|
|
},
|
|
});
|
|
expect(
|
|
publishAction.runs.steps.filter(
|
|
(step: { uses?: string }) => step.uses === CREATE_GITHUB_APP_TOKEN_V3,
|
|
),
|
|
).toEqual([]);
|
|
expect(actionPublishStep.env.CONTENTS_TOKEN).toBe("${{ steps.tokens.outputs.contents-token }}");
|
|
expect(actionPublishStep.env.GH_TOKEN).toBe("${{ steps.tokens.outputs.pull-request-token }}");
|
|
expect(actionPublishStep.env.INVALIDATION_PATHS).toBe("${{ inputs.invalidation-paths }}");
|
|
expect(publishAction.inputs["invalidation-paths"]).toEqual({
|
|
description: "Newline-delimited generator input paths that make an older run stale.",
|
|
required: false,
|
|
default: "",
|
|
});
|
|
expect(publishAction.inputs["working-directory"]).toEqual({
|
|
description: "Repository root containing the generated files.",
|
|
required: false,
|
|
default: ".",
|
|
});
|
|
expect(actionPublishStep["working-directory"]).toBe("${{ inputs.working-directory }}");
|
|
expect(publishAction.inputs["overlap-policy"]).toEqual({
|
|
description: "Whether stale inputs or owned-path overlap defer to a successor run or fail.",
|
|
required: false,
|
|
default: "defer",
|
|
});
|
|
expect(publishAction.inputs["auto-merge"]).toEqual({
|
|
description: "Enable squash auto-merge; false rejects an inherited auto-merge request.",
|
|
required: false,
|
|
default: "false",
|
|
});
|
|
expect(actionPublishStep.env.OVERLAP_POLICY).toBe("${{ inputs.overlap-policy }}");
|
|
expect(actionPublishStep.env.AUTO_MERGE).toBe("${{ inputs.auto-merge }}");
|
|
const publishPolicy = readFileSync(".github/actions/publish-generated-pr/policy.py", "utf8");
|
|
expect(actionPublishStep.run).toContain('case "${OVERLAP_POLICY}" in');
|
|
expect(actionPublishStep.run).toContain("defer | fail");
|
|
expect(actionPublishStep.run).toContain("GIT_TERMINAL_PROMPT=0");
|
|
expect(
|
|
actionPublishStep.run.match(/timeout --signal=TERM --kill-after=10s 60s/gu),
|
|
).toHaveLength(6);
|
|
expect(actionPublishStep.env.PUBLISH_ACTION_PATH).toBe("${{ github.action_path }}");
|
|
expect(actionPublishStep.run).toContain(
|
|
'exec python3 -I -S "$CI_GIT_OWNER" --policy "$PUBLISH_ACTION_PATH/policy.py"',
|
|
);
|
|
expect(actionPublishStep.run).not.toMatch(
|
|
/(?:^|[\s;])git (?:config|fetch|push|diff|ls-tree|ls-remote|rev-parse|merge-base|add|commit|switch|restore|rm)\b/mu,
|
|
);
|
|
expect(publishPolicy).not.toMatch(
|
|
/except (?:Exception|BaseException|SystemExit|RuntimeError)|backoff\(|subprocess\.(?:run|Popen)\([^\n]*["']git/u,
|
|
);
|
|
expect(publishPolicy.match(/timeout=\d+/gu)).toEqual([
|
|
"timeout=60",
|
|
"timeout=120",
|
|
"timeout=60",
|
|
]);
|
|
for (const contract of [
|
|
'auth_key = "http.https://github.com/.extraheader"',
|
|
'f"AUTHORIZATION: basic {git_auth}"',
|
|
'print(f"::add-mask::{git_auth}"',
|
|
'git("config", "--local", "--unset-all", auth_key)',
|
|
"except GitFailure:",
|
|
"except PublicationFailure as error:",
|
|
"finally:\n cleanup_git_auth()",
|
|
"--force-with-lease=refs/heads/{head_branch}:{expected_head}",
|
|
"GH013|repository rule violations|required status check",
|
|
"bool(remote_head) and not current_remote_head",
|
|
'push_generated_branch("")',
|
|
]) {
|
|
expect(publishPolicy).toContain(contract);
|
|
}
|
|
// The real repository scenarios below own overlap, invalidation, tree/lease,
|
|
// reconciliation and auto-merge behavior; spelling is no longer Bash policy.
|
|
for (const contract of [
|
|
'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls"',
|
|
'-f "head=${GITHUB_REPOSITORY_OWNER}:${HEAD_BRANCH}"',
|
|
".head.repo.full_name == env.GITHUB_REPOSITORY",
|
|
".head.ref == env.HEAD_BRANCH",
|
|
".head.sha",
|
|
"gh pr edit",
|
|
"gh pr create",
|
|
'--base "${BASE_BRANCH}"',
|
|
'--head "${HEAD_BRANCH}"',
|
|
'--body-file "${body_file}"',
|
|
"--json autoMergeRequest",
|
|
'--auto --squash --match-head-commit "${published_commit}"',
|
|
]) {
|
|
expect(actionPublishStep.run).toContain(contract);
|
|
}
|
|
for (const forbidden of [
|
|
"gh auth setup-git",
|
|
"gh pr list",
|
|
"gh pr close",
|
|
'GH_TOKEN="${CONTENTS_TOKEN}"',
|
|
'HEAD:"${BASE_BRANCH}"',
|
|
]) {
|
|
expect(actionPublishStep.run).not.toContain(forbidden);
|
|
}
|
|
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
|
|
"OPENCLAW_ALLOW_RELEASE_GENERATED_MIX",
|
|
);
|
|
|
|
for (const [
|
|
ownerWorkflow,
|
|
refreshJob,
|
|
finalizeJob,
|
|
artifactPattern,
|
|
commitMessage,
|
|
automationBranch,
|
|
] of [
|
|
[
|
|
workflow,
|
|
refresh,
|
|
nativeFinalize,
|
|
"native-locale-*",
|
|
"chore(i18n): refresh native locales",
|
|
"automation/native-app-locale-refresh",
|
|
],
|
|
[
|
|
controlUiWorkflow,
|
|
controlUiWorkflow.jobs.refresh,
|
|
controlUiFinalize,
|
|
"control-ui-locale-*",
|
|
"chore(ui): refresh control ui locales",
|
|
"automation/control-ui-locale-refresh",
|
|
],
|
|
] as const) {
|
|
const uploadStep = refreshJob.steps.find(
|
|
(step: { name?: string }) => step.name === "Upload locale artifact",
|
|
);
|
|
const downloadStep = finalizeJob.steps.find(
|
|
(step: { name?: string }) => step.name === "Download locale artifacts",
|
|
);
|
|
const checkoutStep = finalizeJob.steps.find(
|
|
(step: { uses?: string }) => step.uses === CHECKOUT_V6,
|
|
);
|
|
const publishStep = finalizeJob.steps.find(
|
|
(step: { name?: string }) => step.name === "Open or update generated locale PR",
|
|
);
|
|
|
|
expect(ownerWorkflow.permissions.contents).toBe("read");
|
|
expect(refreshJob.needs).toEqual(["resolve-base", "publisher-preflight"]);
|
|
expect(finalizeJob.needs).toEqual(["resolve-base", "publisher-preflight", "refresh"]);
|
|
const isNative = automationBranch.includes("native");
|
|
expect(finalizeJob.if).toBe(
|
|
isNative
|
|
? "needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success' && needs.refresh.result == 'success'"
|
|
: "needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success' && needs.refresh.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.token_preflight_only)",
|
|
);
|
|
expect(uploadStep.uses).toBe(UPLOAD_ARTIFACT_V7);
|
|
expect(downloadStep.uses).toBe(DOWNLOAD_ARTIFACT_V8);
|
|
expect(downloadStep.with.pattern).toBe(artifactPattern);
|
|
expect(downloadStep.with["merge-multiple"]).toBe(true);
|
|
expect(checkoutStep.with["persist-credentials"]).toBe(false);
|
|
expect(checkoutStep.with["fetch-depth"]).toBe(0);
|
|
expect(publishStep.uses).toBe("./.github/actions/publish-generated-pr");
|
|
expect(publishStep.with).toMatchObject({
|
|
"contents-client-id": "Iv23liOECG0slfuhz093",
|
|
"contents-private-key": "${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}",
|
|
"pull-request-client-id": MANTIS_GITHUB_APP_CLIENT_ID,
|
|
"pull-request-private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}",
|
|
"base-branch": "${{ github.event.repository.default_branch }}",
|
|
"head-branch": automationBranch,
|
|
"commit-message": commitMessage,
|
|
"pr-title": commitMessage,
|
|
});
|
|
expect(publishStep.with["generated-paths"]).toContain(
|
|
automationBranch.includes("native") ? "apps/.i18n/native" : "ui/src/i18n",
|
|
);
|
|
expect(publishStep.with["invalidation-paths"]).toContain(
|
|
automationBranch.includes("native")
|
|
? "apps/android/app/src/main"
|
|
: "ui/src/i18n/locales/en.ts",
|
|
);
|
|
expect(publishStep.with["invalidation-paths"]).toContain(
|
|
".github/actions/create-generated-pr-tokens/action.yml",
|
|
);
|
|
expect(publishStep.with["invalidation-paths"]).toContain(
|
|
".github/actions/publish-generated-pr/action.yml",
|
|
);
|
|
expect(publishStep.with).not.toHaveProperty("overlap-policy");
|
|
expect(publishStep.with["auto-merge"]).toBe("true");
|
|
expect(publishStep.with["pr-body"]).toContain("## What Problem This Solves");
|
|
expect(publishStep.with["pr-body"]).toContain("## Evidence");
|
|
expect(publishStep.with["pr-body"]).toContain("${{ needs.resolve-base.outputs.sha }}");
|
|
expect(publishStep.with["pr-body"]).not.toContain("${{ github.sha }}");
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"enables auto-merge for the exact generated pull request head",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, { autoMerge: true });
|
|
|
|
expect(result.branchExists).toBe(true);
|
|
expect(result.mergeCalls).toContain("pr merge https://github.com/openclaw/openclaw/pull/1");
|
|
expect(result.mergeCalls).toContain("--auto --squash --match-head-commit");
|
|
expect(result.summary).toContain("Enabled squash auto-merge for exact generated head");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"waits for the published pull request head before enabling auto-merge",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
stalePrViewHeadOnce: true,
|
|
});
|
|
|
|
expect(result.mergeCalls).toContain("--auto --squash --match-head-commit");
|
|
expect(result.publishOutput).toContain(
|
|
"Generated pull request head has not converged yet; rechecking",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves inherited auto-merge while replacing a generated pull request head",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
existingPr: true,
|
|
});
|
|
|
|
expect(result.generatedA).toBe("desired-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.summary).toContain(
|
|
"Squash auto-merge already enabled for generated pull request",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"accepts inherited auto-merge completing immediately after publication",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
existingPr: true,
|
|
mergeGeneratedPush: true,
|
|
});
|
|
|
|
expect(result.branchExists).toBe(false);
|
|
expect(result.mainGeneratedA).toBe("desired-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.summary).toContain(
|
|
"Generated output was merged before pull request reconciliation",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"waits for the existing pull request head before replacing it",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
existingPr: true,
|
|
stalePrHeadOnce: true,
|
|
});
|
|
|
|
expect(result.generatedA).toBe("desired-a");
|
|
expect(result.publishOutput).toContain(
|
|
"Generated pull request head has not converged yet; rechecking",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"refuses to replace an auto-merge-enabled head when publication opts out",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: false,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
existingPr: true,
|
|
expectFailure: true,
|
|
});
|
|
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.publishOutput).toContain("auto-merge enabled while publication opted out");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not mutate inherited auto-merge when generated publication fails",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
existingPr: true,
|
|
expectFailure: true,
|
|
failGeneratedPush: true,
|
|
});
|
|
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.summary).not.toContain("auto-merge");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects an incompatible inherited auto-merge method without mutating it",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "MERGE",
|
|
existingPr: true,
|
|
expectFailure: true,
|
|
});
|
|
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.publishOutput).toContain(
|
|
"Generated pull request already uses incompatible MERGE auto-merge",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"defers a newer owned snapshot even when the desired diff is disjoint",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario("b");
|
|
|
|
expect(result.branchExists).toBe(false);
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because owned generated paths changed on main.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"defers stale generator inputs and preserves an existing pull request and disarms auto-merge",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
updateSource: true,
|
|
});
|
|
|
|
expect(result.branchHead).not.toBe(result.mainHead);
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because generator inputs changed on main.",
|
|
);
|
|
expect(result.mergeCalls).toContain("--disable-auto");
|
|
expect(result.summary).toContain("Preserved stale generated pull request");
|
|
},
|
|
);
|
|
|
|
it
|
|
.skipIf(process.platform === "win32")
|
|
.each(["scripts/lib/ci-node-test-groups-codec.mts", "scripts/lib/local-check-runtime.mts"])(
|
|
"defers timing refits when only %s changes on main",
|
|
(sourcePath) => {
|
|
const workflow = readWorkflow(".github/workflows/ci-test-timings-refit.yml");
|
|
const publisher = expectDefined(
|
|
workflow.jobs.refit.steps.find(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/publish-generated-pr",
|
|
),
|
|
"timing refit publisher",
|
|
);
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
invalidationPaths: publisher.with["invalidation-paths"],
|
|
updateSource: sourcePath,
|
|
});
|
|
|
|
expect(result.branchExists).toBe(false);
|
|
expect(result.mainGeneratedA).toBe("old-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because generator inputs changed on main.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("preserves large timing refit reports outside generated PR environments", () => {
|
|
const workflow = readWorkflow(".github/workflows/ci-test-timings-refit.yml");
|
|
const steps: WorkflowStep[] = workflow.jobs.refit.steps;
|
|
const refit = expectDefined(
|
|
steps.find((step) => step.id === "refit"),
|
|
"refit step",
|
|
);
|
|
const root = tempDirs.make("openclaw-refit-report-");
|
|
const bin = path.join(root, "bin");
|
|
const source = path.join(root, "source.md");
|
|
const output = path.join(root, "github-output");
|
|
mkdirSync(bin);
|
|
writeExecutable(path.join(bin, "pnpm"), [
|
|
"#!/bin/sh",
|
|
'test "$*" = "--silent ci:timings:refit" || exit 64',
|
|
'cat "$REFIT_FIXTURE_REPORT"',
|
|
]);
|
|
writeExecutable(path.join(bin, "git"), [
|
|
"#!/bin/sh",
|
|
'test "$*" = "diff --quiet -- config/ci-test-timings.json" || exit 64',
|
|
"exit 1",
|
|
]);
|
|
const validIds = "36204091214, 36208949888";
|
|
const largeTable = "| fixture-test | 100 | 50 | -50% |\n".repeat(12_000);
|
|
for (const runIds of [
|
|
validIds,
|
|
"invalid",
|
|
Array.from({ length: 200 }, (_, index) => String(36_200_000_000 + index)).join(", "),
|
|
]) {
|
|
const report = `Sampled CI and release-check runs with successful timing jobs: ${runIds}\n\n${largeTable}`;
|
|
writeFileSync(source, report);
|
|
writeFileSync(output, "");
|
|
const result = runWorkflowShellScript(expectDefined(refit.run, "refit body"), {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
RUNNER_TEMP: root,
|
|
GITHUB_OUTPUT: output,
|
|
REFIT_FIXTURE_REPORT: source,
|
|
},
|
|
});
|
|
expect(readFileSync(path.join(root, "ci-test-timings-report.md"), "utf8")).toBe(report);
|
|
expect(Buffer.byteLength(readFileSync(output, "utf8"))).toBeLessThan(4096);
|
|
if (runIds === validIds) {
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readWorkflowOutputs(output)).toEqual({ run_ids: validIds, changed: "true" });
|
|
} else {
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("Missing or oversized sampled-run summary");
|
|
expect(readWorkflowOutputs(output)).toEqual({});
|
|
}
|
|
}
|
|
const upload = expectDefined(
|
|
steps.find((step) => step.id === "report"),
|
|
"report upload",
|
|
);
|
|
const publisher = expectDefined(
|
|
steps.find((step) => step.uses === "./.github/actions/publish-generated-pr"),
|
|
"refit publisher",
|
|
);
|
|
const uploadInputs = expectDefined(upload.with, "report upload inputs");
|
|
const publisherInputs = expectDefined(publisher.with, "refit publisher inputs");
|
|
expect(upload.uses).toBe(UPLOAD_ARTIFACT_V7);
|
|
expect(uploadInputs.path).toBe("${{ runner.temp }}/ci-test-timings-report.md");
|
|
expect(uploadInputs["if-no-files-found"]).toBe("error");
|
|
expect(upload.if).toBe("${{ !cancelled() && steps.refit.outcome != 'skipped' }}");
|
|
expect(steps.indexOf(upload)).toBeLessThan(steps.indexOf(publisher));
|
|
expect(publisherInputs["pr-body"]).toContain("${{ steps.refit.outputs.run_ids }}");
|
|
expect(publisherInputs["pr-body"]).toContain("${{ steps.report.outputs.artifact-url }}");
|
|
expect(publisherInputs["pr-body"]).not.toContain("${{ steps.refit.outputs.report }}");
|
|
});
|
|
|
|
it
|
|
.skipIf(process.platform === "win32")
|
|
.each(["src/config/schema.help.runtime.ts", "src/config/zod-schema.agent-runtime.ts"])(
|
|
"keeps native publication independent of Control UI schema changes: %s",
|
|
(sourcePath) => {
|
|
const nativeWorkflow = readWorkflow(NATIVE_APP_LOCALE_REFRESH_WORKFLOW);
|
|
const controlUiWorkflow = readWorkflow(CONTROL_UI_LOCALE_REFRESH_WORKFLOW);
|
|
const results = [nativeWorkflow, controlUiWorkflow].map((workflow) => {
|
|
const publisher = expectDefined(
|
|
workflow.jobs.finalize.steps.find(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/publish-generated-pr",
|
|
),
|
|
"locale publisher",
|
|
);
|
|
return runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
invalidationPaths: publisher.with["invalidation-paths"],
|
|
updateSource: sourcePath,
|
|
});
|
|
});
|
|
const native = expectDefined(results[0], "native publication result");
|
|
const controlUi = expectDefined(results[1], "Control UI publication result");
|
|
expect(native.branchExists).toBe(true);
|
|
expect(native.generatedA).toBe("desired-a");
|
|
expect(native.mergeCalls).toContain("--auto --squash");
|
|
expect(controlUi.branchExists).toBe(false);
|
|
expect(controlUi.mergeCalls).toBe("");
|
|
expect(controlUi.summary).toContain(
|
|
"Deferred stale generated output because generator inputs changed on main.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"defers native publication when shared translation config changes",
|
|
() => {
|
|
const workflow = readWorkflow(NATIVE_APP_LOCALE_REFRESH_WORKFLOW);
|
|
const publisher = expectDefined(
|
|
workflow.jobs.finalize.steps.find(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/publish-generated-pr",
|
|
),
|
|
"native locale publisher",
|
|
);
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
invalidationPaths: publisher.with["invalidation-paths"],
|
|
updateSource: "scripts/lib/control-ui-i18n-config.json",
|
|
});
|
|
|
|
expect(result.branchHead).toBe(result.initialBranch);
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.mergeCalls).toContain("--disable-auto");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because generator inputs changed on main.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"publishes after unrelated source changes when input invalidation is disabled",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
invalidationPaths: "",
|
|
overlapPolicy: "fail",
|
|
updateSource: true,
|
|
});
|
|
|
|
expect(result.branchExists).toBe(true);
|
|
expect(result.generatedA).toBe("desired-a");
|
|
expect(result.publishOutput).not.toContain("Refusing stale generated output");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves an existing pull request when a no-change run becomes stale",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario("b", {
|
|
existingPr: true,
|
|
noGeneratedChange: true,
|
|
});
|
|
|
|
expect(result.branchHead).toBe(result.initialBranch);
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.generatedB).toBe("old-b");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because owned generated paths changed on main.",
|
|
);
|
|
expect(result.summary).toContain("Preserved stale generated pull request");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32").each([false, true])(
|
|
"disarms stale output when inputs advance during PR publication (inherited=%s)",
|
|
(inherited) => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
autoMerge: true,
|
|
existingPr: inherited,
|
|
existingAutoMergeMethod: inherited ? "SQUASH" : undefined,
|
|
updateSourceBeforeAutoMerge: true,
|
|
});
|
|
expect(result.generatedA).toBe("desired-a");
|
|
expect(result.branchHead).not.toBe(result.mainHead);
|
|
expect(result.mergeCalls).not.toContain("--auto --squash");
|
|
expect(result.mergeCalls.includes("--disable-auto")).toBe(inherited);
|
|
expect(result.summary).toContain("Deferred stale generated output");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"leaves a current no-change run's existing pull request and auto-merge unchanged",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
noGeneratedChange: true,
|
|
autoMerge: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
});
|
|
expect(result.branchHead).toBe(result.initialBranch);
|
|
expect(result.generatedA).toBe("stale-pr-a");
|
|
expect(result.mergeCalls).toBe("");
|
|
expect(result.summary).toBe("");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not overwrite a successor that moves while stale auto-merge is disabled",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
updateSource: true,
|
|
existingAutoMergeMethod: "SQUASH",
|
|
autoMerge: true,
|
|
disarmRace: true,
|
|
expectFailure: true,
|
|
});
|
|
expect(result.branchHead).not.toBe(result.initialBranch);
|
|
expect(result.generatedA).toBe("old-a");
|
|
expect(result.mergeCalls).toContain("--disable-auto");
|
|
expect(result.mergeCalls).not.toContain("--auto --squash");
|
|
expect(result.summary).not.toContain("Preserved stale");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"fails stale generated publication when no successor run is guaranteed",
|
|
() => {
|
|
const overlap = runGeneratedPublisherScenario("a", {
|
|
expectFailure: true,
|
|
overlapPolicy: "fail",
|
|
});
|
|
expect(overlap.branchExists).toBe(false);
|
|
expect(overlap.publishOutput).toContain(
|
|
"::error::Refusing stale generated output because owned generated paths changed on main.",
|
|
);
|
|
|
|
const stalePr = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
expectFailure: true,
|
|
noGeneratedChange: true,
|
|
overlapPolicy: "fail",
|
|
updateSource: true,
|
|
});
|
|
expect(stalePr.branchHead).toBe(stalePr.initialBranch);
|
|
expect(stalePr.summary).toContain("Preserved stale generated pull request");
|
|
expect(stalePr.publishOutput).toContain(
|
|
"::error::Refusing stale generated output because generator inputs changed on main.",
|
|
);
|
|
|
|
const publishRun = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8")).runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Publish generated pull request",
|
|
).run;
|
|
const invalidPolicy = spawnSync("bash", ["-c", publishRun], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
AUTO_MERGE: "false",
|
|
CONTENTS_TOKEN: "contents-token",
|
|
GH_TOKEN: "pull-request-token",
|
|
OVERLAP_POLICY: "continue",
|
|
},
|
|
});
|
|
expect(invalidPolicy.status).not.toBe(0);
|
|
expect(`${invalidPolicy.stdout}${invalidPolicy.stderr}`).toContain(
|
|
"Generated PR publication overlap policy must be 'defer' or 'fail'.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("fails OpenGrep SARIF artifact uploads when reports are missing", () => {
|
|
const cases = [
|
|
{
|
|
workflowPath: OPENGREP_PR_DIFF_WORKFLOW,
|
|
artifactName: "opengrep-pr-diff-sarif",
|
|
},
|
|
{
|
|
workflowPath: OPENGREP_FULL_WORKFLOW,
|
|
artifactName: "opengrep-full-sarif",
|
|
},
|
|
];
|
|
|
|
for (const item of cases) {
|
|
const workflow = parse(readFileSync(item.workflowPath, "utf8"));
|
|
const uploadStep = workflow.jobs.scan.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload SARIF as workflow artifact",
|
|
);
|
|
|
|
expect(uploadStep.if, item.workflowPath).toBe("always()");
|
|
expect(uploadStep.uses, item.workflowPath).toBe(UPLOAD_ARTIFACT_V7);
|
|
expect(uploadStep.with, item.workflowPath).toMatchObject({
|
|
name: item.artifactName,
|
|
path: ".opengrep-out/precise.sarif",
|
|
"if-no-files-found": "error",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("verifies the pinned OpenGrep release binary before installing it", () => {
|
|
for (const workflowPath of [OPENGREP_PR_DIFF_WORKFLOW, OPENGREP_FULL_WORKFLOW]) {
|
|
const workflow = parse(readFileSync(workflowPath, "utf8"));
|
|
const installStep = expectDefined(
|
|
workflow.jobs.scan.steps.find((step: WorkflowStep) => step.name === "Install opengrep"),
|
|
`Install opengrep step in ${workflowPath}`,
|
|
);
|
|
const run = expectDefined(installStep.run, `Install opengrep script in ${workflowPath}`);
|
|
|
|
expect(installStep.env, workflowPath).toMatchObject({
|
|
OPENGREP_VERSION: "v1.30.0",
|
|
OPENGREP_LINUX_X64_SHA256:
|
|
"35779bdd72e92129c8df2a77f0c55e8c08356801ea92591ef32108d6b28d564c",
|
|
});
|
|
expect(run, workflowPath).toContain('binary="$(mktemp "${RUNNER_TEMP}/opengrep.XXXXXX")"');
|
|
expect(run, workflowPath).toContain("trap 'rm -f \"$binary\"' EXIT");
|
|
expect(run, workflowPath).toContain(
|
|
"curl -fsSL --retry 4 --retry-all-errors --retry-delay 2",
|
|
);
|
|
expect(run, workflowPath).toContain("--connect-timeout 10 --max-time 300");
|
|
expect(run, workflowPath).toContain('-o "$binary"');
|
|
expect(run, workflowPath).toContain(
|
|
"https://github.com/opengrep/opengrep/releases/download/${OPENGREP_VERSION}/opengrep_manylinux_x86",
|
|
);
|
|
expect(run, workflowPath).toContain(
|
|
'printf \'%s %s\\n\' "$OPENGREP_LINUX_X64_SHA256" "$binary" | sha256sum --check',
|
|
);
|
|
expect(run, workflowPath).toContain('install -m 0755 "$binary" "$install_dir/opengrep"');
|
|
expect(run.indexOf('-o "$binary"'), workflowPath).toBeLessThan(
|
|
run.indexOf("sha256sum --check"),
|
|
);
|
|
expect(run.indexOf("sha256sum --check"), workflowPath).toBeLessThan(
|
|
run.indexOf('install -m 0755 "$binary"'),
|
|
);
|
|
expect(run, workflowPath).not.toMatch(/\|\s*bash/u);
|
|
}
|
|
});
|
|
|
|
it("runs real behavior proof from the trusted workflow revision", () => {
|
|
const workflow = readRealBehaviorProofWorkflow();
|
|
const checkout = workflow.jobs["real-behavior-proof"].steps.find(
|
|
(step: WorkflowStep) => step.uses === CHECKOUT_V6,
|
|
);
|
|
|
|
expect(checkout.with.ref).toBe("${{ github.workflow_sha }}");
|
|
});
|
|
|
|
it("keeps docs-change detection fail-safe and fixture-aware", () => {
|
|
const action = readFileSync(".github/actions/detect-docs-changes/action.yml", "utf8");
|
|
|
|
expect(action).toContain("base-sha:");
|
|
expect(action).toContain("docs_only:");
|
|
expect(action).toContain("docs_changed:");
|
|
expect(action).toContain("BASE_SHA: ${{ inputs.base-sha }}");
|
|
expect(action).toContain('BASE="$BASE_SHA"');
|
|
expect(action).toContain(
|
|
'CHANGED=$(git diff --no-renames --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")',
|
|
);
|
|
expect(action).toContain('if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then');
|
|
expect(action).toContain("docs_only=false");
|
|
expect(action).toContain("docs_changed=false");
|
|
expect(action).toContain("test/fixtures/*)");
|
|
expect(action).toContain("docs/* | *.md | *.mdx | config/markdownlint*.jsonc)");
|
|
|
|
const run = parse(action).runs.steps[0].run as string;
|
|
for (const [source, destination, docsChanged, docsOnly] of [
|
|
["src/old.ts", "docs/new.md", "true", "false"],
|
|
["docs/old.md", "src/new.ts", "true", "false"],
|
|
["docs/old.md", "docs/new.md", "true", "true"],
|
|
["docs/old.md", "docs/.generated/config-baseline.counts.json", "true", "true"],
|
|
["docs/old.md", "docs/plugins/plugin-inventory.md", "true", "true"],
|
|
["src/old.ts", "src/new.ts", "false", "false"],
|
|
["test/fixtures/old.md", "docs/new.md", "true", "false"],
|
|
["docs/removed.md", null, "true", "true"],
|
|
] as const) {
|
|
const root = tempDirs.make("openclaw-docs-diff-");
|
|
const origin = path.join(root, "origin");
|
|
const checkout = path.join(root, "checkout");
|
|
mkdirSync(path.dirname(path.join(origin, source)), { recursive: true });
|
|
const content = Array.from({ length: 100 }, (_, index) => `line ${index}\n`).join("");
|
|
writeFileSync(path.join(origin, source), content);
|
|
runGit(origin, ["init", "-q", "-b", "main"]);
|
|
for (const [name, value] of [
|
|
["user.name", "CI Fixture"],
|
|
["user.email", "ci-fixture@example.invalid"],
|
|
["commit.gpgsign", "false"],
|
|
["uploadpack.allowFilter", "true"],
|
|
] as const) {
|
|
runGit(origin, ["config", name, value]);
|
|
}
|
|
runGit(origin, ["add", "."]);
|
|
runGit(origin, ["commit", "-qm", "base"]);
|
|
const base = runGit(origin, ["rev-parse", "HEAD"]);
|
|
const sourceBlob = runGit(origin, ["rev-parse", `HEAD:${source}`]);
|
|
rmSync(path.join(origin, source));
|
|
if (destination) {
|
|
mkdirSync(path.dirname(path.join(origin, destination)), { recursive: true });
|
|
writeFileSync(path.join(origin, destination), `${content}edited after rename\n`);
|
|
}
|
|
runGit(origin, ["add", "-A"]);
|
|
runGit(origin, ["commit", "-qm", "change"]);
|
|
runGit(root, [
|
|
"clone",
|
|
"-q",
|
|
"--no-local",
|
|
"--filter=blob:none",
|
|
"--depth=2",
|
|
origin,
|
|
checkout,
|
|
]);
|
|
runGit(checkout, ["config", "diff.renames", "true"]);
|
|
const localObjects = () =>
|
|
runGit(checkout, ["cat-file", "--batch-all-objects", "--batch-check=%(objectname)"]);
|
|
expect(localObjects()).toContain(base);
|
|
expect(localObjects()).not.toContain(sourceBlob);
|
|
const output = path.join(root, "output");
|
|
const trace = path.join(root, "trace");
|
|
const result = runWorkflowShellScript(run, {
|
|
cwd: checkout,
|
|
env: { ...process.env, BASE_SHA: base, GITHUB_OUTPUT: output, GIT_TRACE2_EVENT: trace },
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readWorkflowOutputs(output), `${source} -> ${destination}`).toEqual({
|
|
docs_changed: docsChanged,
|
|
docs_only: docsOnly,
|
|
});
|
|
expect(readFileSync(trace, "utf8")).not.toContain('"fetch"');
|
|
expect(localObjects()).not.toContain(sourceBlob);
|
|
}
|
|
});
|
|
|
|
it("runs generated docs checks in the docs-only job", () => {
|
|
const job = readCiWorkflow().jobs["check-docs"];
|
|
const configDocsCheck = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check config docs baseline",
|
|
);
|
|
const pluginInventoryCheck = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check plugin inventory",
|
|
);
|
|
|
|
expect(job.if).toBe("needs.preflight.outputs.run_check_docs == 'true'");
|
|
expect(configDocsCheck?.run).toBe("pnpm config:docs:check");
|
|
expect(pluginInventoryCheck?.run).toBe("pnpm plugins:inventory:check");
|
|
});
|
|
|
|
it("bounds matrix fan-out for runner-registration pressure", () => {
|
|
const workflow = readCiWorkflow();
|
|
|
|
expect(workflow.concurrency.group).toContain("github.event.pull_request.number");
|
|
expect(workflow.concurrency["cancel-in-progress"]).toContain(
|
|
"github.event_name == 'pull_request'",
|
|
);
|
|
expect(workflow.jobs["checks-fast-core"].strategy["max-parallel"]).toBe(12);
|
|
expect(workflow.jobs["checks-node-core-test-nondist-shard"].strategy["max-parallel"]).toBe(96);
|
|
expect(workflow.jobs["checks-fast-plugin-contracts-shard"].strategy["max-parallel"]).toBe(12);
|
|
expect(workflow.jobs["checks-fast-channel-contracts-shard"].strategy["max-parallel"]).toBe(12);
|
|
expect(workflow.jobs["check-shard"].strategy["max-parallel"]).toBe(12);
|
|
expect(workflow.jobs["check-additional-shard"].strategy["max-parallel"]).toBe(12);
|
|
expect(workflow.jobs["checks-windows"].strategy["max-parallel"]).toBe(5);
|
|
expect(workflow.jobs["checks-ui-e2e-real-gateway"].strategy["max-parallel"]).toBe(2);
|
|
for (const [context, expected] of [
|
|
[{ eventName: "push" }, 4],
|
|
[{ eventName: "pull_request", runnerBackend: "blacksmith" }, 4],
|
|
[{ eventName: "pull_request", runnerBackend: "hybrid" }, 4],
|
|
[{ eventName: "pull_request", authorAssociation: "NONE" }, 2],
|
|
[{ eventName: "push", runnerBackend: "github" }, 2],
|
|
[{ eventName: "push", runnerBackend: "blacksmith", runAttempt: 2 }, 2],
|
|
[{ eventName: "workflow_dispatch", runnerBackend: "blacksmith" }, 2],
|
|
[{ eventName: "pull_request", headRepository: "contributor/openclaw" }, 2],
|
|
[{ eventName: "push", repository: "contributor/openclaw" }, 2],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(workflow.jobs.android.strategy["max-parallel"], {
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
...context,
|
|
}),
|
|
JSON.stringify(context),
|
|
).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it("runs the Docker seed tier with the published updater and a checked main smoke package", () => {
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const jobs = readCiWorkflow().jobs;
|
|
const job = jobs["docker-seed-e2e"];
|
|
expect(source).toContain("docker-seed-e2e-contract-v1");
|
|
expect(source).toContain('typeof dockerSeedPlan.resolveDockerSeedLanes === "function"');
|
|
expect(jobs.preflight.outputs).toMatchObject({
|
|
docker_seed_lanes: "${{ steps.manifest.outputs.docker_seed_lanes }}",
|
|
run_docker_seed_e2e: "${{ steps.manifest.outputs.run_docker_seed_e2e }}",
|
|
});
|
|
expect(job.if).toBe("needs.preflight.outputs.run_docker_seed_e2e == 'true'");
|
|
expect(job.needs).toEqual(["preflight"]);
|
|
expect(job["timeout-minutes"]).toBe(60);
|
|
expect(job.permissions).toEqual({ contents: "read" });
|
|
expect(job.strategy).toBeUndefined();
|
|
expect(job.steps[0]).toEqual(jobs["build-artifacts"].steps[0]);
|
|
expect(job.steps[1].uses).toBe("./.ci-harness/.github/actions/setup-node-env");
|
|
expect(job.steps[1].with).toMatchObject({
|
|
"build-all-cache-scope": "full",
|
|
"cache-mode": "${{ needs.preflight.outputs.cache_mode }}",
|
|
});
|
|
const run = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Docker seed tier",
|
|
) as WorkflowStep;
|
|
const parallelism = run.env?.OPENCLAW_DOCKER_ALL_PARALLELISM;
|
|
expect(run).toMatchObject({
|
|
run: "pnpm test:docker:all",
|
|
env: {
|
|
OPENCLAW_DOCKER_ALL_LANES: "${{ needs.preflight.outputs.docker_seed_lanes }}",
|
|
OPENCLAW_DOCKER_ALL_LIVE_MODE: "skip",
|
|
OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1",
|
|
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS:
|
|
"${{ needs.preflight.outputs.frozen_target == 'true' && 'base' || 'legacy-operator-state' }}",
|
|
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth",
|
|
OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: parallelism,
|
|
},
|
|
});
|
|
expect(parallelism).toContain("&& 3 || 1");
|
|
expect(run.env).not.toHaveProperty("OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC");
|
|
const prepare = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Prepare main Docker smoke package",
|
|
) as WorkflowStep;
|
|
for (const eventName of ["push", "pull_request"] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression("${{ " + prepare.if + " }}", {
|
|
eventName,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
}),
|
|
).toBe(eventName === "push");
|
|
}
|
|
expect(prepare.run).toContain("pnpm build:ci-artifacts");
|
|
expect(prepare.run).toContain("node scripts/package-openclaw-for-docker.mjs --skip-build");
|
|
expect(prepare.run).not.toContain("--skip-check");
|
|
expect(prepare.run).toContain("OPENCLAW_CURRENT_PACKAGE_TGZ=");
|
|
expect(job.steps.indexOf(prepare)).toBeLessThan(job.steps.indexOf(run));
|
|
const baseline = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve published Docker seed upgrade baseline",
|
|
) as WorkflowStep;
|
|
expect(baseline.if).toBe(
|
|
"contains(format(' {0} ', needs.preflight.outputs.docker_seed_lanes), ' published-upgrade-survivor ')",
|
|
);
|
|
expect(baseline.env).toEqual({
|
|
TARGET_CONTEXT_REF: "${{ inputs.target_context_ref || github.base_ref || github.ref_name }}",
|
|
});
|
|
expect(job.steps.indexOf(baseline)).toBeLessThan(job.steps.indexOf(run));
|
|
const survivorProof = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload sanitized upgrade survivor proof",
|
|
) as WorkflowStep;
|
|
expect(survivorProof).toMatchObject({
|
|
if: `always() && ${baseline.if}`,
|
|
uses: UPLOAD_ARTIFACT_V7,
|
|
with: { "include-hidden-files": true, "if-no-files-found": "warn" },
|
|
});
|
|
const proofPaths = String(survivorProof.with?.path).trim().split(/\s+/u);
|
|
const uploaded = (file: string) => proofPaths.some((pattern) => minimatch(file, pattern));
|
|
for (const file of [
|
|
".artifacts/docker-tests/20260920T000000Z/summary.json",
|
|
".artifacts/docker-tests/20260920T000000Z/failures.json",
|
|
".artifacts/docker-tests/upgrade-survivor-baseline.123/failure.json",
|
|
".artifacts/docker-tests/upgrade-survivor-baseline.123/summary.json",
|
|
]) {
|
|
expect(uploaded(file), file).toBe(true);
|
|
}
|
|
for (const file of [
|
|
".artifacts/upgrade-survivor/baseline/legacy-operator-baseline-turn.err",
|
|
".artifacts/upgrade-survivor/baseline/diagnostics/raw.json",
|
|
".artifacts/docker-tests/run/published-upgrade-survivor.log",
|
|
".artifacts/docker-tests/20260920T000000Z/baseline-cache/package/summary.json",
|
|
".artifacts/docker-tests/20260920T000000Z/baseline-cache/package/failures.json",
|
|
".artifacts/docker-tests/upgrade-survivor-baseline.123/private/summary.json",
|
|
]) {
|
|
expect(uploaded(file), file).toBe(false);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ candidate: "2026.9.3", expected: "openclaw@2026.9.2" },
|
|
{ candidate: "2026.9.4-beta.1", expected: "openclaw@2026.9.3" },
|
|
{
|
|
candidate: "2026.6.35",
|
|
context: "extended-stable/2026.6.33",
|
|
expected: "openclaw@2026.6.34",
|
|
},
|
|
{ candidate: "2026.6.34", expected: undefined },
|
|
])("hands Docker seed a strictly older published baseline for $candidate", (fixture) => {
|
|
const job = readCiWorkflow().jobs["docker-seed-e2e"];
|
|
const baseline = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve published Docker seed upgrade baseline",
|
|
) as WorkflowStep | undefined;
|
|
const run = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Docker seed tier",
|
|
) as WorkflowStep;
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-ci-upgrade-baseline-"));
|
|
try {
|
|
const bin = path.join(root, "bin");
|
|
const tooling = path.join(root, ".ci-harness", "scripts", "lib");
|
|
mkdirSync(bin);
|
|
mkdirSync(tooling, { recursive: true });
|
|
for (const name of ["release-upgrade-baseline.mjs", "release-version.mjs"]) {
|
|
copyFileSync(path.resolve("scripts", "lib", name), path.join(tooling, name));
|
|
}
|
|
writeFileSync(
|
|
path.join(root, "package.json"),
|
|
JSON.stringify({ version: fixture.candidate }),
|
|
);
|
|
writeFileSync(
|
|
path.join(root, ".ci-harness", "package.json"),
|
|
JSON.stringify({ version: "2026.10.1" }),
|
|
);
|
|
symlinkSync(process.execPath, path.join(bin, "node"));
|
|
writeFileSync(
|
|
path.join(bin, "npm"),
|
|
`#!${process.execPath}
|
|
const args = process.argv.slice(2);
|
|
if (JSON.stringify(args) !== JSON.stringify(["view", "openclaw", "versions", "--json", "--silent", "--prefer-online"])) process.exit(2);
|
|
console.log(JSON.stringify(["2026.6.34", "2026.6.35", "2026.9.1", "2026.9.2", "2026.9.3", "2026.9.4-beta.1"]));
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(
|
|
path.join(bin, "pnpm"),
|
|
`#!${process.execPath}
|
|
if (process.argv.slice(2).join(" ") !== "test:docker:all") process.exit(2);
|
|
require("node:fs").writeFileSync("scheduler-baseline", process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC ?? "missing");
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(path.join(root, "github-env"), "");
|
|
const inheritedBaseline = run.env?.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC;
|
|
const result = runWorkflowShellScript(
|
|
`set -euo pipefail\n${baseline?.run ?? ""}\nset -a\nsource "$GITHUB_ENV"\nset +a\n${run.run}`,
|
|
{
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
GITHUB_ENV: path.join(root, "github-env"),
|
|
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:
|
|
typeof inheritedBaseline === "string" ? inheritedBaseline : "",
|
|
TARGET_CONTEXT_REF: fixture.context ?? "main",
|
|
},
|
|
},
|
|
);
|
|
const receipt = path.join(root, "scheduler-baseline");
|
|
if (fixture.expected) {
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readFileSync(receipt, "utf8")).toBe(fixture.expected);
|
|
} else {
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("no published stable OpenClaw baseline predates candidate");
|
|
expect(existsSync(receipt)).toBe(false);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps Windows projects serial on each runner while shard jobs remain parallel",
|
|
() => {
|
|
const workflow = readCiWorkflow();
|
|
const job = workflow.jobs["checks-windows"];
|
|
const runStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run ${{ matrix.task }} (${{ matrix.runtime }})",
|
|
);
|
|
const cwd = tempDirs.make("windows-project-budget-");
|
|
const bin = path.join(cwd, "bin");
|
|
mkdirSync(bin);
|
|
writeFileSync(
|
|
path.join(cwd, "package.json"),
|
|
JSON.stringify({
|
|
scripts: { "test:windows:ci:1": "fixture", "test:windows:ci:2": "fixture" },
|
|
}),
|
|
);
|
|
const pnpm = path.join(bin, "pnpm");
|
|
writeFileSync(
|
|
pnpm,
|
|
'#!/bin/sh\nprintf "project_parallelism=%s\\n" "${OPENCLAW_TEST_PROJECTS_PARALLEL:-1}"\n',
|
|
);
|
|
chmodSync(pnpm, 0o755);
|
|
const targets = ["test/windows-first.test.ts", "test/windows-second.test.ts"];
|
|
mkdirSync(path.join(cwd, "scripts"));
|
|
writeFileSync(path.join(cwd, "scripts/tsx.mjs"), "export {};\n");
|
|
writeFileSync(
|
|
path.join(cwd, "scripts/test-projects.mts"),
|
|
`
|
|
console.log("project_parallelism=" + process.env.OPENCLAW_TEST_PROJECTS_PARALLEL);
|
|
console.log("targets=" + JSON.stringify(process.argv.slice(2)));
|
|
`,
|
|
);
|
|
for (const task of ["test", "test-1", "test-2"]) {
|
|
for (const runner of ["github-hosted", "self-hosted"]) {
|
|
const result = runWorkflowShellScript(runStep.run, {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
|
TASK: task,
|
|
WINDOWS_TARGETS_JSON: String(
|
|
evaluateWorkflowExpression(runStep.env.WINDOWS_TARGETS_JSON, {
|
|
eventName: "push",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
matrix: { targets },
|
|
}),
|
|
),
|
|
RUNNER_ENVIRONMENT: runner,
|
|
OPENCLAW_TEST_PROJECTS_PARALLEL: undefined,
|
|
},
|
|
});
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("project_parallelism=1");
|
|
if (task === "test") {
|
|
expect(result.stdout).toContain(
|
|
`targets=${JSON.stringify([...targets, "--fileParallelism"])}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(job.strategy["max-parallel"]).toBe(5);
|
|
expect(runStep.env.OPENCLAW_VITEST_MAX_WORKERS).toBe(
|
|
"${{ runner.environment == 'self-hosted' && 4 || 1 }}",
|
|
);
|
|
expect(runStep.run).toContain("pnpm test:windows:ci:1 -- --fileParallelism");
|
|
expect(runStep.run).toContain("pnpm test:windows:ci:2 -- --fileParallelism");
|
|
},
|
|
);
|
|
|
|
it("installs the Android SDK platform used by Gradle", () => {
|
|
const workflow = readCiWorkflow();
|
|
const releaseWorkflow = readAndroidReleaseWorkflow();
|
|
const action = readAndroidToolchainAction();
|
|
const appCompileSdk = readAndroidCompileSdk("apps/android/app/build.gradle.kts");
|
|
const benchmarkCompileSdk = readAndroidCompileSdk("apps/android/benchmark/build.gradle.kts");
|
|
const packageId = `platforms;android-${appCompileSdk}.0`;
|
|
|
|
expect(appCompileSdk).toBe(benchmarkCompileSdk);
|
|
expect(
|
|
workflow.jobs.android.steps.filter(
|
|
(step: WorkflowStep) =>
|
|
step.uses === "./.ci-harness/.github/actions/setup-android-toolchain",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(
|
|
releaseWorkflow.jobs.publish_signed_android_apk.steps.filter(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/setup-android-toolchain",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
const sdkRestoreStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Restore Android SDK cache"),
|
|
"Android SDK cache restore step",
|
|
);
|
|
const sdkSaveStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Save Android SDK cache"),
|
|
"Android SDK cache save step",
|
|
);
|
|
const gradleCacheStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Setup Gradle cache"),
|
|
"Gradle cache setup step",
|
|
);
|
|
const javaStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Setup Java"),
|
|
"Android Java setup step",
|
|
);
|
|
const installStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Install Android SDK packages"),
|
|
"Android SDK package install step",
|
|
);
|
|
|
|
expect(javaStep.uses).toBe("actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6");
|
|
expect(javaStep.with).toMatchObject({
|
|
distribution: "temurin",
|
|
"java-version": 17,
|
|
});
|
|
expect(javaStep.with?.["set-default"]).not.toBe(false);
|
|
const gradleJavaStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Setup Gradle Java"),
|
|
"Gradle Java setup step",
|
|
);
|
|
expect(gradleJavaStep).toMatchObject({
|
|
id: "gradle-java",
|
|
uses: javaStep.uses,
|
|
with: {
|
|
distribution: "temurin",
|
|
"java-version": "21.0.12+101.0.LTS",
|
|
"set-default": false,
|
|
},
|
|
});
|
|
expect(action.outputs["gradle-java-home"].value).toBe("${{ steps.gradle-java.outputs.path }}");
|
|
expect(action.outputs["gradle-java-version"].value).toBe(
|
|
"${{ steps.gradle-java.outputs.version }}",
|
|
);
|
|
expect(action.inputs["cache-mode"].default).toBe("off");
|
|
expect(sdkRestoreStep.if).toBe("inputs.cache-mode != 'off'");
|
|
expect(sdkRestoreStep.uses).toBe(CACHE_V5);
|
|
expect(sdkRestoreStep.with?.key).toContain(`platform-${appCompileSdk}.0-`);
|
|
expect(sdkRestoreStep.with?.key).toContain(
|
|
"${{ inputs.install-screenshot-emulators == 'true' && 'screenshot-emulators' || 'base' }}",
|
|
);
|
|
expect(String(sdkRestoreStep.with?.["restore-keys"])).toContain(
|
|
"inputs.install-screenshot-emulators == 'true'",
|
|
);
|
|
expect(sdkSaveStep.if).toContain("inputs.cache-mode == 'read-write'");
|
|
expect(sdkSaveStep.if).toContain("steps.android-sdk-cache.outputs.cache-hit != 'true'");
|
|
expect(sdkSaveStep.uses).toBe(CACHE_SAVE_V5);
|
|
expect(sdkSaveStep.with?.key).toBe("${{ steps.android-sdk-cache.outputs.cache-primary-key }}");
|
|
expect(gradleCacheStep).toMatchObject({
|
|
if: "inputs.cache-mode != 'off'",
|
|
uses: SETUP_GRADLE_V6,
|
|
with: {
|
|
"add-job-summary": "never",
|
|
"cache-provider": "basic",
|
|
"cache-read-only": "${{ inputs.cache-mode != 'read-write' }}",
|
|
},
|
|
});
|
|
expect(installStep.run).toContain(`"${packageId}"`);
|
|
expect(installStep.run).toContain(
|
|
'yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null || [[ "${PIPESTATUS[1]}" -eq 0 ]]',
|
|
);
|
|
});
|
|
|
|
it("binds frozen target context to the declared live release branch", () => {
|
|
const workflow = readCiWorkflow();
|
|
const input = workflow.on.workflow_dispatch.inputs.target_context_ref;
|
|
const step = expectDefined(
|
|
workflow.jobs.preflight.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Validate target context",
|
|
),
|
|
"target context validation step",
|
|
);
|
|
const targetSha = "a".repeat(40);
|
|
|
|
expect(input).toEqual({
|
|
description:
|
|
"Canonical release branch context authorizing compatibility fallbacks for an exact-SHA target",
|
|
required: false,
|
|
default: "",
|
|
type: "string",
|
|
});
|
|
expect(step.if).toBe("inputs.target_context_ref != ''");
|
|
|
|
for (const contextRef of [
|
|
"release/2026.8.1",
|
|
"release/2026.8.1-1",
|
|
"extended-stable/2026.8.33",
|
|
]) {
|
|
for (const comparisonStatus of ["ahead", "identical"]) {
|
|
const result = runCiReleaseRefValidation({
|
|
ref: contextRef,
|
|
targetSha,
|
|
resolvedSha: comparisonStatus === "identical" ? targetSha : "b".repeat(40),
|
|
comparisonStatus,
|
|
});
|
|
expect(result.status, `${contextRef}: ${result.output}`).toBe(0);
|
|
expect(result.outputs.eligible).toBe("true");
|
|
}
|
|
}
|
|
|
|
for (const contextRef of [
|
|
"v2026.8.1",
|
|
"main",
|
|
"release-ci/2026.8.1-beta.2-frozen",
|
|
"release/2026.8",
|
|
"refs/heads/release/2026.8.1",
|
|
]) {
|
|
const result = runCiReleaseRefValidation({ ref: contextRef, targetSha });
|
|
expect(result.status, contextRef).toBe(1);
|
|
expect(result.output).toContain(
|
|
"target_context_ref must be a canonical OpenClaw release branch.",
|
|
);
|
|
}
|
|
|
|
for (const targetRef of ["main", "a".repeat(39)]) {
|
|
const result = runCiReleaseRefValidation({ ref: "release/2026.8.1", targetSha: targetRef });
|
|
expect(result.status, targetRef).toBe(1);
|
|
expect(result.output).toContain(
|
|
"target_context_ref requires target_ref to be a full commit SHA.",
|
|
);
|
|
}
|
|
|
|
for (const comparisonStatus of ["behind", "diverged"]) {
|
|
const result = runCiReleaseRefValidation({
|
|
ref: "release/2026.8.1",
|
|
targetSha,
|
|
comparisonStatus,
|
|
});
|
|
expect(result.status, comparisonStatus).toBe(1);
|
|
expect(result.output).toContain(
|
|
"target_ref must be the declared release branch head or one of its ancestors.",
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ kind: "historical", ref: "v2026.8.1" },
|
|
{ kind: "historical", ref: "v2026.8.1-beta.2" },
|
|
{ kind: "historical", ref: "v2026.8.1-1" },
|
|
{ kind: "candidate", ref: "release/2026.8.1" },
|
|
{ kind: "candidate", ref: "release/2026.8.1-1" },
|
|
{ kind: "candidate", ref: "extended-stable/2026.8.33" },
|
|
] as const)("binds authenticated $kind ref $ref to its exact commit", (identity) => {
|
|
const targetSha = "a".repeat(40);
|
|
const accepted = runCiReleaseRefValidation({ ...identity, targetSha, resolvedSha: targetSha });
|
|
expect(accepted.status, accepted.output).toBe(0);
|
|
expect(accepted.outputs.eligible).toBe("true");
|
|
|
|
const mismatched = runCiReleaseRefValidation({ ...identity, targetSha });
|
|
expect(mismatched.status).not.toBe(0);
|
|
expect(mismatched.output).toContain(`does not resolve to ${targetSha}`);
|
|
expect(mismatched.outputs).not.toHaveProperty("eligible");
|
|
});
|
|
|
|
it.each([
|
|
{ kind: "context", ref: "release/2026.8.1", apiError: "ref" },
|
|
{ kind: "context", ref: "release/2026.8.1", apiError: "comparison" },
|
|
{ kind: "historical", ref: "v2026.8.1", apiError: "ref" },
|
|
{ kind: "candidate", ref: "release/2026.8.1", apiError: "ref" },
|
|
] as const)("rejects unavailable authenticated $kind $apiError evidence", (identity) => {
|
|
const targetSha = "a".repeat(40);
|
|
const result = runCiReleaseRefValidation({
|
|
...identity,
|
|
targetSha,
|
|
resolvedSha: targetSha,
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.output).toContain("HTTP 503");
|
|
expect(result.outputs).not.toHaveProperty("eligible");
|
|
});
|
|
|
|
it.each([
|
|
{ kind: "historical", ref: "refs/heads/v2026.8.1" },
|
|
{ kind: "historical", ref: "release/2026.8.1" },
|
|
{ kind: "candidate", ref: "refs/tags/release/2026.8.1" },
|
|
{ kind: "candidate", ref: "v2026.8.1" },
|
|
] as const)("rejects wrong-namespace $kind ref $ref before remote admission", (identity) => {
|
|
const result = runCiReleaseRefValidation({ ...identity, targetSha: "a".repeat(40) });
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.output).toContain("must be a canonical OpenClaw release");
|
|
expect(result.outputs).not.toHaveProperty("eligible");
|
|
});
|
|
|
|
// Native Windows Node cannot execute this fixture's POSIX gh child shim.
|
|
it.skipIf(process.platform === "win32")("protects correction credentials", () => {
|
|
const root = tempDirs.make("openclaw-ci-correction-order-");
|
|
const trusted = path.join(root, ".ci-harness/scripts/lib");
|
|
const eventsPath = path.join(root, "events");
|
|
const outputPath = path.join(root, "output");
|
|
const bin = path.join(root, "bin");
|
|
mkdirSync(trusted, { recursive: true });
|
|
mkdirSync(path.join(root, "scripts"));
|
|
mkdirSync(bin);
|
|
writeFileSync(eventsPath, "");
|
|
writeFileSync(outputPath, "");
|
|
writeFileSync(path.join(root, "package.json"), JSON.stringify({ version: "2026.9.1" }));
|
|
for (const name of ["release-context.mjs", "release-version.mjs"]) {
|
|
writeFileSync(path.join(trusted, name), readFileSync(`scripts/lib/${name}`));
|
|
}
|
|
writeFileSync(
|
|
path.join(trusted, "release-context-original.mjs"),
|
|
readFileSync("scripts/lib/release-context.mjs"),
|
|
);
|
|
const poisonedHelper = `
|
|
import { appendFileSync } from 'node:fs';
|
|
if (process.env.GH_TOKEN) appendFileSync(${JSON.stringify(eventsPath)}, 'token-exposed\\n');
|
|
export { resolveReleaseContextIdentity } from './release-context-original.mjs';
|
|
`;
|
|
writeFileSync(
|
|
path.join(root, "scripts/ci-changed-scope.mjs"),
|
|
`import { appendFileSync, writeFileSync } from 'node:fs';
|
|
appendFileSync(${JSON.stringify(eventsPath)}, 'candidate\\n');
|
|
writeFileSync(${JSON.stringify(path.join(trusted, "release-context.mjs"))}, ${JSON.stringify(poisonedHelper)});`,
|
|
);
|
|
writeExecutable(path.join(bin, "gh"), [
|
|
"#!/bin/sh",
|
|
'[ "$GH_TOKEN" = test-token ] || exit 4',
|
|
`[ "$*" = 'api repos/openclaw/openclaw/commits/refs%2Ftags%2Fv2026.9.1 --jq .sha' ] || exit 64`,
|
|
`printf 'lookup\\n' >> ${quoteShell(eventsPath)}`,
|
|
`printf '%s\\n' '${"a".repeat(40)}'`,
|
|
]);
|
|
const context = {
|
|
eventName: "workflow_dispatch" as const,
|
|
releaseGate: true,
|
|
releaseScope: "npm-stable",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
targetContextRef: "release/2026.9.1-1",
|
|
workflowToken: "test-token",
|
|
steps: {
|
|
diff_base: { outputs: { sha: "b".repeat(40), head_sha: "a".repeat(40) } },
|
|
target_context_target: { outputs: { eligible: "true" } },
|
|
},
|
|
};
|
|
const steps = readCiWorkflow().jobs.preflight.steps.filter((step: WorkflowStep) =>
|
|
["Resolve release correction base", "Detect changed scopes"].includes(step.name ?? ""),
|
|
);
|
|
expect(steps).toHaveLength(2);
|
|
for (const step of steps) {
|
|
const evaluate = (expression: string) => evaluateWorkflowExpression(expression, context);
|
|
expect(evaluate(`\${{ ${step.if} }}`), step.name).toBe(true);
|
|
const run = runWorkflowShellScript(
|
|
step.run.replace(/\$\{\{[\s\S]*?\}\}/gu, (expression: string) =>
|
|
String(evaluate(expression)),
|
|
),
|
|
{
|
|
cwd: root,
|
|
env: {
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
GITHUB_REPOSITORY: context.repository,
|
|
GITHUB_OUTPUT: outputPath,
|
|
...Object.fromEntries(
|
|
Object.entries(step.env ?? {}).map(([name, value]) => [
|
|
name,
|
|
String(evaluate(String(value))),
|
|
]),
|
|
),
|
|
},
|
|
},
|
|
);
|
|
expect(run.status, `${step.name}: ${run.stdout}${run.stderr}`).toBe(0);
|
|
}
|
|
expect(readFileSync(eventsPath, "utf8").trim().split("\n")).toEqual(["lookup", "candidate"]);
|
|
expect(readWorkflowOutputs(outputPath).sha).toBe("a".repeat(40));
|
|
});
|
|
|
|
it("selects the declared compiler for native builds and analysis", () => {
|
|
const codeql = parse(
|
|
readFileSync(".github/workflows/codeql-macos-critical-security.yml", "utf8"),
|
|
);
|
|
const codeqlJob = codeql.jobs.macos;
|
|
const codeqlSelect = expectDefined(
|
|
codeqlJob.steps.find((step: WorkflowStep) => step.name === "Select Xcode"),
|
|
"CodeQL macOS Xcode selection",
|
|
);
|
|
|
|
const codeqlInitializeIndex = codeqlJob.steps.findIndex(
|
|
(step: WorkflowStep) => step.name === "Initialize CodeQL",
|
|
);
|
|
const codeqlBuildIndex = codeqlJob.steps.findIndex(
|
|
(step: WorkflowStep) => step.name === "Build macOS for CodeQL",
|
|
);
|
|
const codeqlAnalyzeIndex = codeqlJob.steps.findIndex(
|
|
(step: WorkflowStep) => step.name === "Analyze",
|
|
);
|
|
const codeqlBuild = expectDefined(codeqlJob.steps[codeqlBuildIndex], "CodeQL macOS build");
|
|
const codeqlPrepare = codeql.jobs["prepare-mermaid"];
|
|
const codeqlPrepareSteps = codeqlPrepare.steps as WorkflowStep[];
|
|
const codeqlPrepareCheckout = expectDefined(
|
|
codeqlPrepareSteps.find((step) => step.name === "Checkout"),
|
|
"CodeQL Mermaid checkout",
|
|
);
|
|
const codeqlPrepareSetup = expectDefined(
|
|
codeqlPrepareSteps.find((step) => step.name === "Setup Node environment"),
|
|
"CodeQL Mermaid Node setup",
|
|
);
|
|
const codeqlPrepareInstall = expectDefined(
|
|
codeqlPrepareSteps.find((step) => step.name === "Install Mermaid renderer dependencies"),
|
|
"CodeQL Mermaid dependency install",
|
|
);
|
|
const codeqlPrepareAssets = expectDefined(
|
|
codeqlPrepareSteps.find((step) => step.name === "Prepare Apple Mermaid assets"),
|
|
"CodeQL Mermaid asset preparation",
|
|
);
|
|
const codeqlUpload = expectDefined(
|
|
codeqlPrepareSteps.find((step) => step.name === "Upload Mermaid assets"),
|
|
"CodeQL Mermaid artifact upload",
|
|
);
|
|
const codeqlDownloadIndex = codeqlJob.steps.findIndex(
|
|
(step: WorkflowStep) => step.name === "Download Mermaid assets",
|
|
);
|
|
const codeqlDownload = expectDefined(
|
|
codeqlJob.steps[codeqlDownloadIndex],
|
|
"CodeQL Mermaid artifact download",
|
|
);
|
|
|
|
expect(evaluateWorkflowRunner(codeqlPrepare["runs-on"])).toBe("ubuntu-24.04");
|
|
expect(codeqlPrepare["timeout-minutes"]).toBe(10);
|
|
expect(codeqlPrepare.permissions).toEqual({ contents: "read" });
|
|
expect(codeqlPrepare.outputs["artifact-id"]).toBe("${{ steps.upload.outputs.artifact-id }}");
|
|
expect(codeqlPrepareCheckout.with).toMatchObject({
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
ref: "${{ github.sha }}",
|
|
submodules: false,
|
|
});
|
|
expect(codeqlPrepareSetup.with).toMatchObject({
|
|
"cache-mode": "restore",
|
|
"install-bun": "false",
|
|
"install-deps": "false",
|
|
});
|
|
expect(codeqlPrepareInstall.env).toEqual({ CI: "true" });
|
|
expect(codeqlPrepareInstall.run).toContain(
|
|
"pnpm install --frozen-lockfile --prefer-offline --optional",
|
|
);
|
|
expect(codeqlPrepareInstall.run).toContain("--filter '@openclaw/mermaid-renderer...'");
|
|
expect(codeqlPrepareInstall.run).toContain("--config.ignore-scripts=false");
|
|
expect(codeqlPrepareInstall.run).toContain("--config.engine-strict=false");
|
|
expect(codeqlPrepareInstall.run).toContain("--config.enable-pre-post-scripts=true");
|
|
expect(codeqlPrepareInstall.run).toContain("--config.side-effects-cache=true");
|
|
expect(codeqlPrepareAssets.run).toBe("node scripts/prepare-apple-mermaid.mjs");
|
|
expect(codeqlUpload).toMatchObject({
|
|
id: "upload",
|
|
uses: UPLOAD_ARTIFACT_V7,
|
|
with: {
|
|
name: "codeql-macos-mermaid-${{ github.run_id }}-${{ github.run_attempt }}",
|
|
path: "apps/shared/OpenClawKit/Sources/OpenClawChatUI/Resources/Mermaid",
|
|
"if-no-files-found": "error",
|
|
"retention-days": 1,
|
|
},
|
|
});
|
|
expect(codeqlPrepareSteps.indexOf(codeqlPrepareInstall)).toBeLessThan(
|
|
codeqlPrepareSteps.indexOf(codeqlPrepareAssets),
|
|
);
|
|
expect(codeqlPrepareSteps.indexOf(codeqlPrepareAssets)).toBeLessThan(
|
|
codeqlPrepareSteps.indexOf(codeqlUpload),
|
|
);
|
|
expect(codeqlJob.needs).toBe("prepare-mermaid");
|
|
expect(evaluateWorkflowRunner(codeqlJob["runs-on"])).toBe("macos-26-intel");
|
|
expect(codeqlJob["timeout-minutes"]).toBe(90);
|
|
const codeqlCheckout = expectDefined(
|
|
codeqlJob.steps.find((step: WorkflowStep) => step.name === "Checkout"),
|
|
"CodeQL macOS checkout",
|
|
);
|
|
expect(codeqlCheckout.with).toMatchObject({
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
ref: "${{ github.sha }}",
|
|
submodules: false,
|
|
});
|
|
expect(
|
|
codeqlJob.steps.some(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/setup-node-env",
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
codeqlJob.steps.some((step: WorkflowStep) => step.run?.includes("prepare-apple-mermaid.mjs")),
|
|
).toBe(false);
|
|
expect(codeqlDownload).toMatchObject({
|
|
uses: DOWNLOAD_ARTIFACT_V8,
|
|
with: {
|
|
"artifact-ids": "${{ needs.prepare-mermaid.outputs.artifact-id }}",
|
|
path: "apps/shared/OpenClawKit/Sources/OpenClawChatUI/Resources/Mermaid",
|
|
},
|
|
});
|
|
expect(codeqlDownloadIndex).toBeLessThan(codeqlInitializeIndex);
|
|
expect(codeqlInitializeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(codeqlInitializeIndex).toBeLessThan(codeqlBuildIndex);
|
|
expect(codeqlBuildIndex).toBeLessThan(codeqlAnalyzeIndex);
|
|
expect(codeqlBuild.run).toBe(
|
|
"swift build --package-path apps/macos --product OpenClaw --arch arm64 --disable-index-store -debug-info-format none",
|
|
);
|
|
expect(codeqlSelect.run).toContain("/Applications/Xcode_26.6.app/Contents/Developer");
|
|
expect(codeqlSelect.run).toContain('if [[ "$xcode_version" != 26.6* ]]; then');
|
|
|
|
for (const [workflowPath, jobNames] of [
|
|
[".github/workflows/ci.yml", ["macos-swift", "ios-build", "ios-screenshot-shard"]],
|
|
[".github/workflows/ios-periphery.yml", ["scan"]],
|
|
[".github/workflows/macos-periphery.yml", ["scan"]],
|
|
[".github/workflows/shared-openclawkit-periphery.yml", ["scan-ios", "scan-macos"]],
|
|
] as const) {
|
|
const workflow = parse(readFileSync(workflowPath, "utf8"));
|
|
for (const jobName of jobNames) {
|
|
const job = workflow.jobs[jobName];
|
|
expect(evaluateWorkflowRunner(job["runs-on"]), `${workflowPath}: ${jobName}`).toBe(
|
|
"xcode-27",
|
|
);
|
|
const selection = expectDefined(
|
|
job.steps.find((step: WorkflowStep) =>
|
|
["Select Xcode", "Verify Xcode"].includes(step.name ?? ""),
|
|
),
|
|
`${workflowPath}: ${jobName} toolchain selection`,
|
|
);
|
|
const toolingRoot = workflowPath === ".github/workflows/ci.yml" ? ".ci-harness/" : "";
|
|
expect(selection.run).toContain(`source ${toolingRoot}scripts/lib/swift-toolchain.sh`);
|
|
expect(selection.run).toContain("select_xcode_toolchain");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("loads Android CI setup from the workflow revision for frozen targets", () => {
|
|
const steps = readCiWorkflow().jobs.android.steps as WorkflowStep[];
|
|
const checkoutIndex = steps.findIndex((step) => step.name === "Checkout");
|
|
const actionCheckoutIndex = steps.findIndex(
|
|
(step) => step.name === "Checkout CI Android toolchain action",
|
|
);
|
|
const setupIndex = steps.findIndex((step) => step.name === "Setup Android toolchain");
|
|
const actionCheckout = expectDefined(steps[actionCheckoutIndex], "Android action checkout");
|
|
|
|
expect(actionCheckout.uses).toBe(CHECKOUT_V6);
|
|
expect(actionCheckout.with).toMatchObject({
|
|
path: ".ci-harness",
|
|
"persist-credentials": false,
|
|
ref: "${{ github.workflow_sha }}",
|
|
"sparse-checkout": ".github/actions",
|
|
});
|
|
expect(checkoutIndex).toBeLessThan(actionCheckoutIndex);
|
|
expect(actionCheckoutIndex).toBeLessThan(setupIndex);
|
|
});
|
|
|
|
it("bounds Android SDK command-line tools downloads", () => {
|
|
const action = readAndroidToolchainAction();
|
|
const restoreStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Restore Android SDK cache"),
|
|
"Android SDK cache restore step",
|
|
);
|
|
const setupStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) =>
|
|
step.run?.includes("commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"),
|
|
),
|
|
"Android SDK setup step",
|
|
);
|
|
|
|
expect(restoreStep.with?.key).toBe(
|
|
"${{ runner.os }}-android-sdk-v2-cmdline-15859902-platform-37.0-build-tools-36.0.0-${{ inputs.install-screenshot-emulators == 'true' && 'screenshot-emulators' || 'base' }}",
|
|
);
|
|
expect(String(restoreStep.with?.["restore-keys"]).trim().split("\n")).toEqual([
|
|
"${{ inputs.install-screenshot-emulators == 'true' && format('{0}-android-sdk-v2-cmdline-15859902-platform-37.0-build-tools-36.0.0-base', runner.os) || '' }}",
|
|
"${{ runner.os }}-android-sdk-v1-cmdline-15859902-platform-37.0-build-tools-36.0.0",
|
|
"${{ runner.os }}-android-sdk-v1-cmdline-15859902-",
|
|
]);
|
|
expect(setupStep.run).toContain('CMDLINE_TOOLS_VERSION="15859902"');
|
|
expect(setupStep.run).toContain(
|
|
'CMDLINE_TOOLS_SHA256="4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"',
|
|
);
|
|
expect(setupStep.run).toContain("curl -fsSL --connect-timeout 10 --max-time 300");
|
|
expect(setupStep.run).toContain("sha256sum --check -");
|
|
});
|
|
|
|
it("covers Android app variants, lint, and benchmark compilation", () => {
|
|
const workflow = readCiWorkflow();
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const androidJob = workflow.jobs.android;
|
|
const runStep = expectDefined(
|
|
androidJob.steps.find((step: WorkflowStep) => step.name === "Run Android ${{ matrix.task }}"),
|
|
"Android task runner",
|
|
);
|
|
const nativeResourcesSetup = expectDefined(
|
|
androidJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment for native resources",
|
|
),
|
|
"Android native resources Node setup",
|
|
);
|
|
const buildPlayCase = expectDefined(
|
|
runStep.run?.match(/^\s*build-play\)\n([\s\S]*?)^\s*;;$/mu)?.[1],
|
|
"Android build-play case",
|
|
);
|
|
const buildTasks = [
|
|
":app:assemblePlayDebug",
|
|
":app:assembleThirdPartyDebug",
|
|
":app:lintPlayDebug",
|
|
":app:lintThirdPartyDebug",
|
|
":benchmark:assembleDebug",
|
|
":wear-shared:assembleDebug",
|
|
":wear-shared:lintDebug",
|
|
];
|
|
const buildRoot = tempDirs.make("openclaw-android-build-routing-");
|
|
const commandLog = path.join(buildRoot, "gradle.log");
|
|
writeExecutable(path.join(buildRoot, "gradlew"), [
|
|
"#!/bin/sh",
|
|
'printf "%s\\n" "$*" >> "$GRADLE_LOG"',
|
|
]);
|
|
const buildContexts = (["", "github", "blacksmith", "hybrid"] as const).flatMap(
|
|
(runnerBackend) => [
|
|
{
|
|
runnerBackend,
|
|
eventName: "push" as const,
|
|
mainShape: false,
|
|
runAttempt: 1,
|
|
expectedCommands: runnerBackend === "github" ? 3 : 1,
|
|
},
|
|
{
|
|
runnerBackend,
|
|
eventName: "workflow_dispatch" as const,
|
|
mainShape: false,
|
|
runAttempt: 1,
|
|
expectedCommands: 3,
|
|
},
|
|
{
|
|
runnerBackend,
|
|
eventName: "workflow_dispatch" as const,
|
|
mainShape: true,
|
|
runAttempt: 1,
|
|
expectedCommands: 1,
|
|
},
|
|
{
|
|
runnerBackend,
|
|
eventName: "workflow_dispatch" as const,
|
|
mainShape: true,
|
|
runAttempt: 2,
|
|
expectedCommands: 3,
|
|
},
|
|
],
|
|
);
|
|
for (const {
|
|
runnerBackend,
|
|
eventName,
|
|
mainShape,
|
|
runAttempt,
|
|
expectedCommands,
|
|
} of buildContexts) {
|
|
const context = {
|
|
eventName,
|
|
runAttempt,
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend,
|
|
matrix: { task: "build-play" },
|
|
preflightOutputs: {
|
|
ci_shape: mainShape ? "main" : "default",
|
|
ci_qualification: String(mainShape),
|
|
qualification_runner_backend: mainShape ? "hybrid" : "",
|
|
},
|
|
};
|
|
const expectedHosted = expectedCommands === 3;
|
|
expect(evaluateWorkflowExpression(androidJob["runs-on"], context)).toBe(
|
|
expectedHosted ? "ubuntu-24.04" : "blacksmith-8vcpu-ubuntu-2404",
|
|
);
|
|
expect(evaluateWorkflowExpression(androidJob["timeout-minutes"], context)).toBe(
|
|
expectedHosted ? 35 : 20,
|
|
);
|
|
expect(evaluateWorkflowExpression(runStep.env.CI_RUNNER_BACKEND, context)).toBe(
|
|
expectedHosted ? "github" : "blacksmith",
|
|
);
|
|
writeFileSync(commandLog, "");
|
|
const result = runWorkflowShellScript(buildPlayCase, {
|
|
cwd: buildRoot,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_EVENT_NAME: eventName,
|
|
GRADLE_LOG: commandLog,
|
|
CI_RUNNER_BACKEND: String(
|
|
evaluateWorkflowExpression(runStep.env.CI_RUNNER_BACKEND, context),
|
|
),
|
|
CI_MAIN_QUALIFICATION: String(
|
|
evaluateWorkflowExpression(runStep.env.CI_MAIN_QUALIFICATION, context),
|
|
),
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const commands = readFileSync(commandLog, "utf8").trim().split("\n");
|
|
expect(commands, `${runnerBackend}/${eventName}/${mainShape}/${runAttempt}`).toHaveLength(
|
|
expectedCommands,
|
|
);
|
|
const tasks = commands.flatMap((command) =>
|
|
command.split(/\s+/u).filter((argument) => argument.startsWith(":")),
|
|
);
|
|
expect(tasks.toSorted()).toEqual(buildTasks.toSorted());
|
|
if (expectedCommands === 1) {
|
|
expect(tasks).toEqual(buildTasks);
|
|
}
|
|
}
|
|
|
|
expect(source).toContain('task: useCompatibleAndroidCi ? "test-play-compat" : "test-play"');
|
|
expect(source).toContain('task: "test-third-party"');
|
|
expect(source.match(/check_name: "android-build-play"/gu)).toHaveLength(1);
|
|
expect(source).toContain('task: useCompatibleAndroidCi ? "build-play-compat" : "build-play"');
|
|
expect(androidJob.name).toBe("${{ matrix.check_name || 'android' }}");
|
|
expect(runStep.env.CI_RUNNER_BACKEND).toContain(
|
|
"contains(fromJSON('[\"hybrid\",\"runson\"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1",
|
|
);
|
|
expect(nativeResourcesSetup.uses).toBe("./.ci-harness/.github/actions/setup-node-env");
|
|
expect(nativeResourcesSetup.if).toBe(
|
|
"needs.preflight.outputs.use_compatible_android_ci != 'true'",
|
|
);
|
|
expect(nativeResourcesSetup.with).toMatchObject({
|
|
"install-bun": "false",
|
|
"install-deps": "false",
|
|
});
|
|
const nativeResourcesInstall = expectDefined(
|
|
androidJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install Mermaid renderer dependencies",
|
|
),
|
|
"Android native resources dependency install",
|
|
);
|
|
expect(nativeResourcesInstall.if).toBe(nativeResourcesSetup.if);
|
|
expect(nativeResourcesInstall.env).toEqual({ CI: "true" });
|
|
expect(nativeResourcesInstall.run.trim().split(/\s+/u)).toEqual([
|
|
"pnpm",
|
|
"install",
|
|
"--frozen-lockfile",
|
|
"--prefer-offline",
|
|
"--optional",
|
|
"--filter",
|
|
"'@openclaw/mermaid-renderer...'",
|
|
"--config.ignore-scripts=false",
|
|
"--config.engine-strict=false",
|
|
"--config.enable-pre-post-scripts=true",
|
|
"--config.side-effects-cache=true",
|
|
]);
|
|
expect(androidJob.steps.indexOf(nativeResourcesSetup)).toBeLessThan(
|
|
androidJob.steps.indexOf(nativeResourcesInstall),
|
|
);
|
|
expect(androidJob.steps.indexOf(nativeResourcesInstall)).toBeLessThan(
|
|
androidJob.steps.indexOf(runStep),
|
|
);
|
|
});
|
|
|
|
it.each(["plugin", "channel"] as const)(
|
|
"joins %s contract envelopes and stops admission on any failure",
|
|
(family) => {
|
|
const workflow = readCiWorkflow();
|
|
const job = workflow.jobs[`checks-fast-${family}-contracts-shard`];
|
|
const step = job.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === `Run ${family} contract shard`,
|
|
);
|
|
expect(step.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON).toBe("${{ toJson(matrix) }}");
|
|
expect(step.env.OPENCLAW_TEST_PROJECTS_PARALLEL).toBe(family === "channel" ? "4" : undefined);
|
|
const fixture = tempDirs.make("openclaw-contract-groups-");
|
|
const binDir = path.join(fixture, "bin");
|
|
mkdirSync(binDir);
|
|
const commandLog = path.join(fixture, "commands.jsonl");
|
|
const pnpm = path.join(binDir, "pnpm");
|
|
writeFileSync(
|
|
pnpm,
|
|
String.raw`#!${process.execPath}
|
|
const fs = require("node:fs");
|
|
const files = JSON.parse(fs.readFileSync(process.env.OPENCLAW_VITEST_INCLUDE_FILE, "utf8"));
|
|
const record = { args: process.argv.slice(2), files, parallel: process.env.OPENCLAW_TEST_PROJECTS_PARALLEL ?? null };
|
|
fs.appendFileSync(process.env.CONTRACT_COMMAND_LOG, JSON.stringify({ ...record, phase: "start" }) + "\n");
|
|
setImmediate(() => {
|
|
fs.appendFileSync(process.env.CONTRACT_COMMAND_LOG, JSON.stringify({ ...record, phase: "end" }) + "\n");
|
|
process.exitCode = files[0] === "first.test.ts" ? Number(process.env.CONTRACT_FIRST_EXIT) : 0;
|
|
});
|
|
|
|
`,
|
|
);
|
|
chmodSync(pnpm, 0o755);
|
|
for (const firstExit of [0, 7, 143]) {
|
|
writeFileSync(commandLog, "");
|
|
const run = runWorkflowShellScript(step.run, {
|
|
cwd: fixture,
|
|
env: {
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
RUNNER_TEMP: fixture,
|
|
CONTRACT_COMMAND_LOG: commandLog,
|
|
CONTRACT_FIRST_EXIT: String(firstExit),
|
|
OPENCLAW_TEST_PROJECTS_PARALLEL: step.env.OPENCLAW_TEST_PROJECTS_PARALLEL,
|
|
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: JSON.stringify({
|
|
task: `contracts-${family}s`,
|
|
groups: [
|
|
{ checkName: "first-envelope", includePatterns: ["first.test.ts"] },
|
|
{ checkName: "second-envelope", includePatterns: ["second.test.ts"] },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(run.status, `${run.stdout}${run.stderr}`).toBe(firstExit);
|
|
const files = firstExit === 0 ? ["first.test.ts", "second.test.ts"] : ["first.test.ts"];
|
|
expect(
|
|
readFileSync(commandLog, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line)),
|
|
).toEqual(
|
|
files.flatMap((file) =>
|
|
["start", "end"].map((phase) => ({
|
|
args: [`test:contracts:${family}s`],
|
|
files: [file],
|
|
parallel: family === "channel" ? "4" : null,
|
|
phase,
|
|
})),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps CodeQL critical quality scans off Blacksmith registrations", () => {
|
|
const source = readCriticalQualityWorkflow();
|
|
const workflow = parse(source);
|
|
const blacksmithJobs = Object.entries(workflow.jobs)
|
|
.filter(([, job]) => job && typeof job === "object")
|
|
.filter(([, job]) => (job as Record<string, unknown>)["runs-on"] !== "ubuntu-24.04")
|
|
.map(([name]) => name);
|
|
|
|
expect(blacksmithJobs).toEqual([]);
|
|
expect(source).not.toContain("blacksmith-");
|
|
});
|
|
|
|
it("keeps trusted hybrid controls on Blacksmith when optional hosted admission is closed", () => {
|
|
const workflow = readCiWorkflow();
|
|
const context = {
|
|
eventName: "pull_request",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend: "hybrid",
|
|
} as const;
|
|
for (const jobName of ["preflight", "security-fast", "ci-gate"]) {
|
|
const expression = workflow.jobs[jobName]["runs-on"];
|
|
for (const eventName of ["pull_request", "push"] as const) {
|
|
expect(evaluateWorkflowExpression(expression, { ...context, eventName }), jobName).toBe(
|
|
jobName === "preflight"
|
|
? "blacksmith-16vcpu-ubuntu-2404"
|
|
: "blacksmith-4vcpu-ubuntu-2404",
|
|
);
|
|
}
|
|
for (const override of [
|
|
{ runAttempt: 0 },
|
|
{ runAttempt: 2 },
|
|
{ runAttempt: 2, headRepository: "contributor/openclaw" },
|
|
{ runnerBackend: "github" },
|
|
{ eventName: "workflow_dispatch" },
|
|
{ repository: "contributor/openclaw" },
|
|
{ authorAssociation: "NONE", headRepository: "contributor/openclaw" },
|
|
] as const) {
|
|
expect(evaluateWorkflowExpression(expression, { ...context, ...override }), jobName).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
}
|
|
for (const runnerBackend of ["", "blacksmith"] as const) {
|
|
for (const eventName of ["pull_request", "push"] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, { ...context, eventName, runnerBackend }),
|
|
jobName,
|
|
).toBe(jobName === "preflight" ? "blacksmith-4vcpu-ubuntu-2404" : "ubuntu-24.04");
|
|
}
|
|
}
|
|
}
|
|
for (const [jobName, task, expected] of [
|
|
["preflight", undefined, "blacksmith-16vcpu-ubuntu-2404"],
|
|
["security-fast", undefined, "ubuntu-24.04"],
|
|
["checks-ui", undefined, "ubuntu-24.04"],
|
|
["checks-ui-e2e", "browser-extension", "ubuntu-24.04"],
|
|
["checks-ui-e2e", "control-ui", "blacksmith-16vcpu-ubuntu-2404"],
|
|
["checks-ui-e2e-real-gateway", undefined, "blacksmith-32vcpu-ubuntu-2404"],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(workflow.jobs[jobName]["runs-on"], {
|
|
...context,
|
|
matrix: { task },
|
|
preflightOutputs: { hybrid_hosted_offload: "true" },
|
|
}),
|
|
`${jobName}: ${task ?? "default"}`,
|
|
).toBe(expected);
|
|
}
|
|
for (const authorAssociation of ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"]) {
|
|
for (const headRepository of ["openclaw/openclaw", "contributor/openclaw"]) {
|
|
expect(
|
|
evaluateWorkflowExpression(workflow.jobs.preflight["runs-on"], {
|
|
...context,
|
|
authorAssociation,
|
|
headRepository,
|
|
}),
|
|
`${authorAssociation}: ${headRepository}`,
|
|
).toBe("blacksmith-16vcpu-ubuntu-2404");
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each(
|
|
[
|
|
{
|
|
file: "full-release-validation.yml",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
jobs: [
|
|
"release_checks_independent",
|
|
"release_checks_candidate",
|
|
"performance",
|
|
"release_execution_plan",
|
|
"release_decision",
|
|
"diagnostic_drain",
|
|
],
|
|
},
|
|
{
|
|
file: "openclaw-npm-preflight.yml",
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
jobs: [
|
|
"check_openclaw_npm",
|
|
"prepare_openclaw_npm",
|
|
"check_sdk_npm",
|
|
"check_dependencies_npm",
|
|
"check_contents_npm",
|
|
"verify_openclaw_npm",
|
|
],
|
|
},
|
|
{
|
|
file: "qa-live-transports-convex.yml",
|
|
runner: "blacksmith-8vcpu-ubuntu-2404",
|
|
jobs: ["authorize_actor", "validate_selected_ref"],
|
|
},
|
|
{
|
|
file: "qa-live-transports-convex.yml",
|
|
runner: "blacksmith-16vcpu-ubuntu-2404",
|
|
jobs: [
|
|
"run_mock_parity",
|
|
"run_live_runtime_token_efficiency",
|
|
"run_live_matrix",
|
|
"run_live_buzz",
|
|
"run_live_telegram",
|
|
"run_live_discord",
|
|
"run_live_whatsapp",
|
|
"run_live_slack",
|
|
],
|
|
},
|
|
{
|
|
file: "openclaw-performance.yml",
|
|
runner: "blacksmith-16vcpu-ubuntu-2404",
|
|
jobs: ["kova", "source_performance"],
|
|
},
|
|
{
|
|
file: "npm-telegram-beta-e2e.yml",
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
jobs: ["run_package_telegram_e2e"],
|
|
},
|
|
{
|
|
file: "openclaw-live-and-e2e-checks-reusable.yml",
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
jobs: ["validate_docker_openwebui"],
|
|
},
|
|
{
|
|
file: "openclaw-release-checks.yml",
|
|
runner: "blacksmith-8vcpu-ubuntu-2404",
|
|
jobs: ["qa_lab_runtime_pair_lane_release_checks"],
|
|
},
|
|
].flatMap(({ file, runner, jobs }) => jobs.map((job) => ({ file, runner, job }))),
|
|
)("honors the global hosted runner override for $file/$job", ({ file, runner, job }) => {
|
|
const workflow = parse(readFileSync(`.github/workflows/${file}`, "utf8"));
|
|
const runsOn = workflow.jobs[job]["runs-on"];
|
|
const supportsHostedInput =
|
|
file === "openclaw-npm-preflight.yml" || file === "openclaw-live-and-e2e-checks-reusable.yml";
|
|
|
|
for (const runnerBackend of ["github", "", "blacksmith", "hybrid"] as const) {
|
|
for (const useGithubHostedRunners of [false, true]) {
|
|
const expectedRunner =
|
|
runnerBackend === "github" || (supportsHostedInput && useGithubHostedRunners)
|
|
? "ubuntu-24.04"
|
|
: runner;
|
|
const actualRunner =
|
|
typeof runsOn === "string" && runsOn.startsWith("${{")
|
|
? evaluateWorkflowExpression(runsOn, {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend,
|
|
useGithubHostedRunners,
|
|
})
|
|
: runsOn;
|
|
|
|
expect(
|
|
actualRunner,
|
|
`${runnerBackend || "unset"}, use_github_hosted_runners=${useGithubHostedRunners}`,
|
|
).toBe(expectedRunner);
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each(["", "release/2026.9.1"])(
|
|
"honors trusted dispatch runner selection for check shards with context %j",
|
|
(targetContextRef) => {
|
|
const runsOn = readCiWorkflow().jobs["check-shard"]["runs-on"];
|
|
const lintMatrix = {
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
task: "lint",
|
|
};
|
|
const evaluateDispatch = (
|
|
runnerBackend: "blacksmith" | "github" | "hybrid",
|
|
overrides: {
|
|
dispatchId?: string;
|
|
frozenTarget?: boolean;
|
|
matrix?: Record<string, unknown>;
|
|
releaseGate?: boolean;
|
|
repository?: string;
|
|
targetContextRef?: string;
|
|
} = {},
|
|
) =>
|
|
evaluateWorkflowExpression(runsOn, {
|
|
eventName: "workflow_dispatch",
|
|
matrix: lintMatrix,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend,
|
|
...overrides,
|
|
});
|
|
|
|
expect(evaluateDispatch("blacksmith")).toBe("blacksmith-32vcpu-ubuntu-2404");
|
|
expect(evaluateDispatch("blacksmith", { releaseGate: true })).toBe("ubuntu-24.04");
|
|
expect(evaluateDispatch("github")).toBe("ubuntu-24.04");
|
|
expect(evaluateDispatch("hybrid")).toBe("ubuntu-24.04");
|
|
|
|
const frozenFrv = {
|
|
dispatchId: "full-release-validation-33128772779-ci",
|
|
frozenTarget: true,
|
|
targetContextRef,
|
|
};
|
|
expect(evaluateDispatch("hybrid", frozenFrv)).toBe("blacksmith-32vcpu-ubuntu-2404");
|
|
expect(evaluateDispatch("github", frozenFrv)).toBe("ubuntu-24.04");
|
|
expect(evaluateDispatch("hybrid", { ...frozenFrv, frozenTarget: false })).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
expect(evaluateDispatch("hybrid", { ...frozenFrv, dispatchId: "manual-ci-proof" })).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
expect(evaluateDispatch("hybrid", { ...frozenFrv, releaseGate: true })).toBe("ubuntu-24.04");
|
|
expect(
|
|
evaluateDispatch("hybrid", {
|
|
...frozenFrv,
|
|
matrix: { runner: "blacksmith-16vcpu-ubuntu-2404", task: "test-types" },
|
|
}),
|
|
).toBe("ubuntu-24.04");
|
|
expect(evaluateDispatch("hybrid", { ...frozenFrv, repository: "fork/openclaw" })).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
expect(
|
|
evaluateWorkflowExpression(runsOn, {
|
|
authorAssociation: "NONE",
|
|
eventName: "pull_request",
|
|
headRepository: "openclaw/openclaw",
|
|
matrix: lintMatrix,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend: "blacksmith",
|
|
}),
|
|
).toBe("ubuntu-24.04");
|
|
},
|
|
);
|
|
|
|
it("encodes GitHub, Blacksmith, and hybrid runner-backend shapes", () => {
|
|
const workflow = readCiWorkflow();
|
|
const jobs = workflow.jobs as Record<string, { "runs-on": unknown }>;
|
|
const expectedHostedRunners = {
|
|
"pr-fail-fast": "ubuntu-24.04",
|
|
android: "ubuntu-24.04",
|
|
"build-artifacts": "ubuntu-24.04",
|
|
"check-additional-shard": "ubuntu-24.04",
|
|
"check-lint-hosted-core-shard": "ubuntu-24.04",
|
|
"check-shard": "ubuntu-24.04",
|
|
"checks-baseline-ratchets": "ubuntu-24.04",
|
|
"checks-fast-channel-contracts-shard": "ubuntu-24.04",
|
|
"checks-fast-core": "ubuntu-24.04",
|
|
"checks-fast-plugin-contracts-shard": "ubuntu-24.04",
|
|
"checks-node-compat": "ubuntu-24.04",
|
|
"checks-node-core-test-nondist-shard": "ubuntu-24.04",
|
|
"checks-ui": "ubuntu-24.04",
|
|
"checks-ui-e2e": "ubuntu-24.04",
|
|
"checks-ui-e2e-real-gateway": "ubuntu-24.04",
|
|
"ci-gate": "ubuntu-24.04",
|
|
"control-ui-i18n": "ubuntu-24.04",
|
|
"control-ui-performance": "ubuntu-24.04",
|
|
"docker-seed-e2e": "ubuntu-24.04",
|
|
"macos-node": "macos-15",
|
|
"native-i18n": "ubuntu-24.04",
|
|
preflight: "ubuntu-24.04",
|
|
"security-fast": "ubuntu-24.04",
|
|
"qa-smoke-ci-profile": "ubuntu-24.04",
|
|
"skills-python": "ubuntu-24.04",
|
|
"check-test-types-hosted-core-shard": "ubuntu-24.04",
|
|
"checks-windows": "windows-2025",
|
|
} as const;
|
|
const expectedHybridFirstAttemptRunners = {
|
|
...expectedHostedRunners,
|
|
"pr-fail-fast": "blacksmith-4vcpu-ubuntu-2404",
|
|
preflight: "blacksmith-16vcpu-ubuntu-2404",
|
|
"security-fast": "blacksmith-4vcpu-ubuntu-2404",
|
|
android: "blacksmith-8vcpu-ubuntu-2404",
|
|
"build-artifacts": "blacksmith-16vcpu-ubuntu-2404",
|
|
"checks-node-core-test-nondist-shard": "blacksmith-32vcpu-ubuntu-2404",
|
|
"checks-ui-e2e": "blacksmith-8vcpu-ubuntu-2404",
|
|
"checks-ui-e2e-real-gateway": "blacksmith-32vcpu-ubuntu-2404",
|
|
"docker-seed-e2e": "blacksmith-16vcpu-ubuntu-2404",
|
|
"qa-smoke-ci-profile": "blacksmith-16vcpu-ubuntu-2404",
|
|
"check-test-types-hosted-core-shard": "blacksmith-16vcpu-ubuntu-2404",
|
|
"check-lint-hosted-core-shard": "blacksmith-8vcpu-ubuntu-2404",
|
|
"ci-gate": "blacksmith-4vcpu-ubuntu-2404",
|
|
"checks-ui": "blacksmith-8vcpu-ubuntu-2404",
|
|
"checks-windows": "blacksmith-16vcpu-windows-2025",
|
|
} as const;
|
|
const expectedHybridForkRunners = {
|
|
...expectedHybridFirstAttemptRunners,
|
|
"pr-fail-fast": "ubuntu-24.04",
|
|
"docker-seed-e2e": "ubuntu-24.04",
|
|
} as const;
|
|
const configurableJobs = Object.entries(jobs)
|
|
.filter(
|
|
([, job]) =>
|
|
String(job["runs-on"]).includes("OPENCLAW_CI_RUNNER_BACKEND") ||
|
|
String(job["runs-on"]).includes("needs.preflight.outputs.runner_profile") ||
|
|
String(job["runs-on"]).includes("matrix.runner"),
|
|
)
|
|
.map(([jobName]) => jobName)
|
|
.toSorted();
|
|
const canonicalPullRequest = {
|
|
eventName: "pull_request",
|
|
headRepository: "openclaw/openclaw",
|
|
matrix: { runner: "blacksmith-32vcpu-ubuntu-2404" },
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
} as const;
|
|
expect(configurableJobs).toEqual(Object.keys(expectedHostedRunners).toSorted());
|
|
expect(evaluateWorkflowRunner(jobs["check-lint-hosted-extension-shard"]?.["runs-on"])).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
// check-docs stays hosted in every mode: its ClawHub clone is unauthenticated by design.
|
|
expect(evaluateWorkflowRunner(jobs["check-docs"]?.["runs-on"])).toBe("ubuntu-24.04");
|
|
for (const [jobName, hostedRunner] of Object.entries(expectedHostedRunners)) {
|
|
const expression = jobs[jobName]?.["runs-on"];
|
|
for (const [label, overrides, expectedRunner] of [
|
|
["github backend", { runnerBackend: "github" }, hostedRunner],
|
|
[
|
|
"hybrid first attempt",
|
|
{ runnerBackend: "hybrid" },
|
|
expectedHybridFirstAttemptRunners[jobName as keyof typeof expectedHostedRunners],
|
|
],
|
|
["hybrid retry", { runnerBackend: "hybrid", runAttempt: 2 }, hostedRunner],
|
|
[
|
|
"RunsOn ordinary rows retain hybrid routing",
|
|
{ runnerBackend: "runson" },
|
|
expectedHybridFirstAttemptRunners[jobName as keyof typeof expectedHostedRunners],
|
|
],
|
|
["RunsOn retry", { runnerBackend: "runson", runAttempt: 2 }, hostedRunner],
|
|
[
|
|
"explicit Blacksmith matches default",
|
|
{ runnerBackend: "blacksmith" },
|
|
evaluateWorkflowExpression(expression, canonicalPullRequest),
|
|
],
|
|
// New contributors stay hosted. GitHub can also report maintainers as
|
|
// CONTRIBUTOR when organization membership is concealed.
|
|
[
|
|
"untrusted fork",
|
|
{
|
|
authorAssociation: "NONE",
|
|
headRepository: "contributor/openclaw",
|
|
runnerBackend: "hybrid",
|
|
},
|
|
hostedRunner,
|
|
],
|
|
[
|
|
"returning-contributor fork",
|
|
{
|
|
authorAssociation: "CONTRIBUTOR",
|
|
headRepository: "contributor/openclaw",
|
|
runnerBackend: "hybrid",
|
|
},
|
|
expectedHybridForkRunners[jobName as keyof typeof expectedHostedRunners],
|
|
],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, { ...canonicalPullRequest, ...overrides }),
|
|
`${jobName}: ${label}`,
|
|
).toBe(expectedRunner);
|
|
}
|
|
for (const runnerBackend of ["", "blacksmith", "hybrid"] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, {
|
|
...canonicalPullRequest,
|
|
authorAssociation: "CONTRIBUTOR",
|
|
headRepository: "contributor/openclaw",
|
|
runnerBackend,
|
|
runAttempt: 2,
|
|
}),
|
|
`${jobName}: returning-contributor fork retry (${runnerBackend || "unset"})`,
|
|
).toBe(hostedRunner);
|
|
}
|
|
}
|
|
|
|
for (const jobName of ["check-lint-hosted-core-shard", "ci-gate"] as const) {
|
|
const expression = jobs[jobName]?.["runs-on"];
|
|
const runner = expectedHybridFirstAttemptRunners[jobName];
|
|
for (const [label, overrides, expected] of [
|
|
["main", { eventName: "push" }, runner],
|
|
[
|
|
"heavy packed core stripe",
|
|
{ runnerProfile: "hybrid", matrix: { stripe: 1 } },
|
|
jobName === "ci-gate" ? runner : "blacksmith-16vcpu-ubuntu-2404",
|
|
],
|
|
["lighter packed core stripe", { runnerProfile: "hybrid", matrix: { stripe: 2 } }, runner],
|
|
["unpaired core stripe", { runnerProfile: "github", matrix: { stripe: 1 } }, runner],
|
|
["noncanonical", { repository: "contributor/openclaw" }, "ubuntu-24.04"],
|
|
["manual", { eventName: "workflow_dispatch" }, "ubuntu-24.04"],
|
|
[
|
|
"trusted fork with hosted profile",
|
|
{ headRepository: "contributor/openclaw", runnerProfile: "github" },
|
|
runner,
|
|
],
|
|
["frozen target", { frozenTarget: true }, jobName === "ci-gate" ? runner : "ubuntu-24.04"],
|
|
[
|
|
"admitted qualification overrides configured backend",
|
|
{
|
|
eventName: "workflow_dispatch",
|
|
runnerBackend: "github",
|
|
preflightOutputs: {
|
|
ci_qualification: "true",
|
|
qualification_runner_backend: "hybrid",
|
|
},
|
|
},
|
|
runner,
|
|
],
|
|
[
|
|
"qualification hosted override",
|
|
{
|
|
eventName: "workflow_dispatch",
|
|
preflightOutputs: {
|
|
ci_qualification: "true",
|
|
qualification_runner_backend: "github",
|
|
},
|
|
},
|
|
"ubuntu-24.04",
|
|
],
|
|
[
|
|
"qualification retry",
|
|
{
|
|
eventName: "workflow_dispatch",
|
|
runAttempt: 2,
|
|
preflightOutputs: {
|
|
ci_qualification: "true",
|
|
qualification_runner_backend: "hybrid",
|
|
},
|
|
},
|
|
"ubuntu-24.04",
|
|
],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, {
|
|
...canonicalPullRequest,
|
|
runnerBackend: "hybrid",
|
|
...overrides,
|
|
}),
|
|
`${jobName}: ${label}`,
|
|
).toBe(expected);
|
|
}
|
|
}
|
|
|
|
const widenedHybridMatrixRows = [
|
|
...["lint", "test-types", "dependencies"].map((task) => ({
|
|
jobName: "check-shard",
|
|
matrix: { runner: "blacksmith-32vcpu-ubuntu-2404", task },
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
})),
|
|
...["extension-package-boundary", "runtime-topology-architecture"].map((group) => ({
|
|
jobName: "check-additional-shard",
|
|
matrix: { group, runner: "blacksmith-32vcpu-ubuntu-2404" },
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
})),
|
|
{
|
|
jobName: "check-additional-shard",
|
|
matrix: { group: "plugin-sdk-api-diff", runner: "blacksmith-4vcpu-ubuntu-2404" },
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
},
|
|
...[4, 8].map((size) => ({
|
|
jobName: "checks-node-core-test-nondist-shard",
|
|
matrix: { runner: `blacksmith-${size}vcpu-ubuntu-2404` },
|
|
runner: `blacksmith-${size}vcpu-ubuntu-2404`,
|
|
})),
|
|
// Preserve planner capacity for parallel, tooling and explicit large owners.
|
|
...["small-3", "small-4", "small-7", "small-10", "large-10", "large32-1"].map((bin) => ({
|
|
jobName: "checks-node-core-test-nondist-shard",
|
|
matrix: {
|
|
check_name: `checks-node-compact-${bin}`,
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
},
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
})),
|
|
...[
|
|
["changed-extensions-bundle-16", 8],
|
|
["changed-extensions-bundle-25", 8],
|
|
["compact-large-5", 16],
|
|
["compact-large-9", 16],
|
|
].map(([bin, size]) => ({
|
|
jobName: "checks-node-core-test-nondist-shard",
|
|
matrix: { check_name: `checks-node-${bin}`, runner: "blacksmith-8vcpu-ubuntu-2404" },
|
|
runner: `blacksmith-${size}vcpu-ubuntu-2404`,
|
|
})),
|
|
] as const;
|
|
for (const { jobName, matrix, runner } of widenedHybridMatrixRows) {
|
|
const expression = jobs[jobName]?.["runs-on"];
|
|
for (const [label, overrides, expectedRunner] of [
|
|
["hybrid attempt 1", { runnerBackend: "hybrid" }, runner],
|
|
["hybrid main", { eventName: "push", runnerBackend: "hybrid" }, runner],
|
|
["Blacksmith main", { eventName: "push", runnerBackend: "blacksmith" }, runner],
|
|
["hybrid retry", { runnerBackend: "hybrid", runAttempt: 2 }, "ubuntu-24.04"],
|
|
["github backend", { runnerBackend: "github" }, "ubuntu-24.04"],
|
|
[
|
|
"untrusted fork pull request",
|
|
{
|
|
authorAssociation: "NONE",
|
|
headRepository: "contributor/openclaw",
|
|
runnerBackend: "hybrid",
|
|
},
|
|
"ubuntu-24.04",
|
|
],
|
|
[
|
|
"workflow dispatch",
|
|
{ eventName: "workflow_dispatch", runnerBackend: "hybrid" },
|
|
"ubuntu-24.04",
|
|
],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, { ...canonicalPullRequest, matrix, ...overrides }),
|
|
`${jobName}: ${label}`,
|
|
).toBe(expectedRunner);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives breaker-routed hosted jobs their hosted timeout budgets", () => {
|
|
const workflow = readCiWorkflow();
|
|
const jobs = workflow.jobs as Record<string, { "timeout-minutes": unknown }>;
|
|
const expectedHostedTimeouts = {
|
|
android: 35,
|
|
"build-artifacts": 35,
|
|
"checks-ui": 35,
|
|
"checks-ui-e2e-real-gateway": 40,
|
|
} as const;
|
|
const routeDependentTimeoutJobs = Object.entries(jobs)
|
|
.filter(([, job]) => {
|
|
const timeout = job["timeout-minutes"];
|
|
return typeof timeout === "string" && timeout.includes("github.");
|
|
})
|
|
.map(([jobName]) => jobName)
|
|
.toSorted();
|
|
const canonicalPullRequest = {
|
|
eventName: "pull_request",
|
|
headRepository: "openclaw/openclaw",
|
|
matrix: { task: "build-play" },
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
} as const;
|
|
const evaluateTimeout = (
|
|
jobName: string,
|
|
context: Parameters<typeof evaluateWorkflowExpression>[1],
|
|
) => {
|
|
const value = jobs[jobName]?.["timeout-minutes"];
|
|
return typeof value === "number" ? value : evaluateWorkflowExpression(value, context);
|
|
};
|
|
|
|
for (const [jobName, hostedTimeout] of Object.entries(expectedHostedTimeouts)) {
|
|
for (const [overrides, expectedTimeout] of [
|
|
[{ runnerBackend: "github" }, hostedTimeout],
|
|
[{ runnerBackend: "blacksmith" }, 20],
|
|
[{ runnerBackend: "hybrid" }, 20],
|
|
[{ runnerBackend: "hybrid", runAttempt: 2 }, hostedTimeout],
|
|
] as const) {
|
|
expect(evaluateTimeout(jobName, { ...canonicalPullRequest, ...overrides }), jobName).toBe(
|
|
expectedTimeout,
|
|
);
|
|
}
|
|
expect(jobs[jobName]?.["timeout-minutes"], jobName).toContain(
|
|
"(needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github'",
|
|
);
|
|
}
|
|
expect(routeDependentTimeoutJobs).toEqual(Object.keys(expectedHostedTimeouts).toSorted());
|
|
|
|
const androidRunStep = expectDefined(
|
|
workflow.jobs.android.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Android ${{ matrix.task }}",
|
|
),
|
|
"Android task runner",
|
|
);
|
|
const androidRunEnv = expectDefined(androidRunStep.env, "Android task environment");
|
|
const androidRoutes = [
|
|
["GitHub override", { runnerBackend: "github" }, "ubuntu-24.04"],
|
|
["hybrid retry", { runnerBackend: "hybrid", runAttempt: 2 }, "ubuntu-24.04"],
|
|
["manual dispatch", { eventName: "workflow_dispatch" }, "ubuntu-24.04"],
|
|
["non-canonical repository", { repository: "contributor/openclaw" }, "ubuntu-24.04"],
|
|
["untrusted author", { authorAssociation: "NONE" }, "ubuntu-24.04"],
|
|
[
|
|
"untrusted fork",
|
|
{ authorAssociation: "FIRST_TIME_CONTRIBUTOR", headRepository: "contributor/openclaw" },
|
|
"ubuntu-24.04",
|
|
],
|
|
[
|
|
"trusted fork first attempt",
|
|
{ headRepository: "contributor/openclaw" },
|
|
"blacksmith-8vcpu-ubuntu-2404",
|
|
],
|
|
[
|
|
"trusted fork retry",
|
|
{ headRepository: "contributor/openclaw", runAttempt: 2 },
|
|
"ubuntu-24.04",
|
|
],
|
|
["same-repository Blacksmith retry", { runAttempt: 2 }, "blacksmith-8vcpu-ubuntu-2404"],
|
|
] as const;
|
|
for (const [label, overrides, runner] of androidRoutes) {
|
|
const context = { ...canonicalPullRequest, ...overrides };
|
|
expect(evaluateWorkflowExpression(workflow.jobs.android["runs-on"], context), label).toBe(
|
|
runner,
|
|
);
|
|
if (runner === "ubuntu-24.04") {
|
|
expect(evaluateWorkflowExpression(androidRunEnv.CI_RUNNER_BACKEND, context), label).toBe(
|
|
"github",
|
|
);
|
|
}
|
|
for (const task of [
|
|
"build-play",
|
|
"build-play-compat",
|
|
"build-wear",
|
|
"ktlint",
|
|
"test-play",
|
|
"test-play-compat",
|
|
"test-third-party",
|
|
"test-wear",
|
|
]) {
|
|
for (const lint of [undefined, false, true]) {
|
|
const extendedBudget = task === "build-play" && runner === "ubuntu-24.04";
|
|
expect(
|
|
evaluateTimeout("android", { ...context, matrix: { task, lint } }),
|
|
`${label}: ${task}, lint=${lint}`,
|
|
).toBe(extendedBudget ? 35 : 20);
|
|
}
|
|
}
|
|
}
|
|
|
|
const realGateway = workflow.jobs["checks-ui-e2e-real-gateway"];
|
|
for (const eventName of ["pull_request", "push", "workflow_dispatch"] as const) {
|
|
for (const repository of ["openclaw/openclaw", "contributor/openclaw"]) {
|
|
for (const authorAssociation of ["CONTRIBUTOR", "NONE"]) {
|
|
for (const runnerBackend of ["", "blacksmith", "github", "hybrid"] as const) {
|
|
for (const runAttempt of [1, 2]) {
|
|
const context = {
|
|
...canonicalPullRequest,
|
|
eventName,
|
|
repository,
|
|
authorAssociation,
|
|
runnerBackend,
|
|
runAttempt,
|
|
};
|
|
const runner = evaluateWorkflowExpression(realGateway["runs-on"], context);
|
|
expect(
|
|
evaluateTimeout("checks-ui-e2e-real-gateway", context),
|
|
JSON.stringify(context),
|
|
).toBe(runner === "ubuntu-24.04" ? 40 : 20);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("resolves the pull request base and changed files from the shallow security checkout", () => {
|
|
const securitySteps = readCiWorkflow().jobs["security-fast"].steps as WorkflowStep[];
|
|
const checkoutIndex = securitySteps.findIndex((step) => step.name === "Checkout");
|
|
const checkout = expectDefined(securitySteps[checkoutIndex], "security checkout");
|
|
const root = tempDirs.make("openclaw-security-checkout-");
|
|
const depth = checkout.with?.["fetch-depth"];
|
|
expect(Number.isInteger(Number(depth)) && Number(depth) > 0).toBe(true);
|
|
expect(checkout.with?.["persist-credentials"]).toBe(false);
|
|
|
|
const source = path.join(root, "source");
|
|
const selected = path.join(root, "selected");
|
|
mkdirSync(source);
|
|
mkdirSync(selected);
|
|
const git = (cwd: string, ...args: string[]) =>
|
|
execFileSync(
|
|
"git",
|
|
[
|
|
"-C",
|
|
cwd,
|
|
"-c",
|
|
"user.name=CI Fixture",
|
|
"-c",
|
|
"user.email=ci@example.invalid",
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
...args,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
env: {
|
|
...process.env,
|
|
GIT_ALLOW_PROTOCOL: "file",
|
|
GIT_CONFIG_GLOBAL: devNull,
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
},
|
|
},
|
|
).trim();
|
|
git(source, "init", "--initial-branch=main");
|
|
writeFileSync(path.join(source, "base.txt"), "base\n");
|
|
git(source, "add", ".");
|
|
git(source, "commit", "-m", "base");
|
|
git(source, "checkout", "-b", "pull-request");
|
|
for (let index = 0; index < 3; index++) {
|
|
writeFileSync(path.join(source, "change.txt"), `change ${index}\n`);
|
|
git(source, "add", ".");
|
|
git(source, "commit", "-m", `change ${index}`);
|
|
}
|
|
git(source, "checkout", "main");
|
|
writeFileSync(path.join(source, "base.txt"), "advanced base\n");
|
|
git(source, "commit", "-am", "advance main");
|
|
const base = git(source, "rev-parse", "HEAD");
|
|
git(source, "merge", "--no-ff", "pull-request", "-m", "synthetic merge");
|
|
const merge = git(source, "rev-parse", "HEAD");
|
|
git(selected, "init");
|
|
git(
|
|
selected,
|
|
"fetch",
|
|
"--no-tags",
|
|
`--depth=${String(depth)}`,
|
|
pathToFileURL(source).href,
|
|
merge,
|
|
);
|
|
git(selected, "checkout", "--detach", "FETCH_HEAD");
|
|
|
|
const resolveBase = expectDefined(
|
|
securitySteps.find((step) => step.id === "diff_base"),
|
|
"security diff base",
|
|
);
|
|
const output = path.join(root, "base-output");
|
|
const result = spawnSync("bash", ["-e", "-c", expectDefined(resolveBase.run, "base script")], {
|
|
cwd: selected,
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_EVENT_NAME: "pull_request",
|
|
EVENT_BASE_SHA: "stale-event-base",
|
|
GITHUB_OUTPUT: output,
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readFileSync(output, "utf8").trim()).toBe(`sha=${base}`);
|
|
expect(git(selected, "diff", "--name-only", base, "HEAD")).toBe("change.txt");
|
|
});
|
|
|
|
it("keeps setup cache access explicit and isolates every cache write", () => {
|
|
const setupActionPaths = [
|
|
".github/actions/setup-node-env/action.yml",
|
|
".github/actions/setup-pnpm-store-cache/action.yml",
|
|
];
|
|
const legacyInputs = [
|
|
"save-actions-cache",
|
|
"save-dependency-cache",
|
|
"save-node-compile-cache",
|
|
"save-vitest-fs-cache",
|
|
"use-actions-cache",
|
|
];
|
|
for (const actionPath of setupActionPaths) {
|
|
const action = parse(readFileSync(actionPath, "utf8"));
|
|
const steps = action.runs.steps as WorkflowStep[];
|
|
expect(action.inputs["cache-mode"].default, actionPath).toBe("off");
|
|
for (const legacyInput of legacyInputs) {
|
|
expect(action.inputs, `${actionPath}: ${legacyInput}`).not.toHaveProperty(legacyInput);
|
|
}
|
|
expect(
|
|
steps.filter(
|
|
(step) =>
|
|
step.uses?.startsWith("actions/cache@") || step.uses?.startsWith("actions/cache/save@"),
|
|
),
|
|
actionPath,
|
|
).toEqual([]);
|
|
expect(
|
|
steps.filter((step) => step.uses?.startsWith("actions/cache/restore@")).length,
|
|
actionPath,
|
|
).toBeGreaterThan(0);
|
|
const validation = expectDefined(
|
|
steps.find((step) => step.run?.includes("off|restore|read-write")),
|
|
`${actionPath} cache-mode validation`,
|
|
);
|
|
expect(validation.run).toContain("Invalid cache-mode input");
|
|
}
|
|
|
|
const callers: Array<{ file: string; mode: unknown; step: WorkflowStep }> = [];
|
|
const directCaches: Array<{
|
|
file: string;
|
|
step: WorkflowStep;
|
|
jobId?: string;
|
|
jobCondition?: string;
|
|
}> = [];
|
|
const rubySetups: Array<{ file: string; step: WorkflowStep }> = [];
|
|
for (const file of [
|
|
...findYamlFiles(".github/workflows"),
|
|
...findYamlFiles(".github/actions"),
|
|
]) {
|
|
const parsed = parse(readFileSync(file, "utf8"));
|
|
const stepLists = [
|
|
...Object.entries(parsed?.jobs ?? {}).map(([jobId, job]) => {
|
|
const owner = job as { steps?: WorkflowStep[]; if?: string };
|
|
return { jobId, jobCondition: owner.if, steps: owner.steps ?? [] };
|
|
}),
|
|
{
|
|
jobId: undefined,
|
|
jobCondition: undefined,
|
|
steps: (parsed?.runs?.steps ?? []) as WorkflowStep[],
|
|
},
|
|
];
|
|
for (const { jobId, jobCondition, steps } of stepLists) {
|
|
for (const step of steps) {
|
|
if (step.uses?.startsWith("actions/cache")) {
|
|
directCaches.push({ file, step, jobId, jobCondition });
|
|
}
|
|
if (step.uses?.startsWith("ruby/setup-ruby@")) {
|
|
rubySetups.push({ file, step });
|
|
}
|
|
if (
|
|
step.uses === "./.github/actions/setup-node-env" ||
|
|
step.uses?.endsWith("/.github/actions/setup-node-env") ||
|
|
step.uses === "./.github/actions/setup-pnpm-store-cache" ||
|
|
step.uses?.endsWith("/.github/actions/setup-pnpm-store-cache")
|
|
) {
|
|
callers.push({ file, mode: step.with?.["cache-mode"], step });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(rubySetups.length).toBeGreaterThan(0);
|
|
for (const { file, step } of rubySetups) {
|
|
const bundlerCache = step.with?.["bundler-cache"] ?? false;
|
|
expect([false, true, "false", "true"], `${file}: ${step.name}`).toContain(bundlerCache);
|
|
if (bundlerCache === true || bundlerCache === "true") {
|
|
expect(String(step.if), `${file}: ${step.name}`).toContain("cache_write_allowed == 'true'");
|
|
}
|
|
}
|
|
expect(callers.length).toBeGreaterThan(0);
|
|
for (const caller of callers) {
|
|
const staticMode = ["off", "restore", "read-write"].includes(String(caller.mode));
|
|
const conditionalMode =
|
|
typeof caller.mode === "string" &&
|
|
caller.mode.startsWith("${{") &&
|
|
(caller.mode.includes("needs.preflight.outputs.cache_mode") ||
|
|
caller.mode.includes("steps.candidate_trust.outputs.cache_mode") ||
|
|
(caller.mode.includes("'restore'") &&
|
|
(caller.mode.includes("'off'") || caller.mode.includes("'read-write'"))));
|
|
expect(staticMode || conditionalMode, `${caller.file}: ${caller.step.name}`).toBe(true);
|
|
for (const legacyInput of legacyInputs) {
|
|
expect(caller.step.with, `${caller.file}: ${legacyInput}`).not.toHaveProperty(legacyInput);
|
|
}
|
|
}
|
|
const writeAuthorizedCallers = callers.filter(
|
|
(caller) =>
|
|
caller.mode === "read-write" ||
|
|
(typeof caller.mode === "string" && caller.mode.includes("'read-write'")),
|
|
);
|
|
expect(writeAuthorizedCallers).toHaveLength(4);
|
|
expect(writeAuthorizedCallers).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
file: ".github/workflows/ci-build-artifacts-testbox.yml",
|
|
mode: expect.stringContaining("'read-write'"),
|
|
step: expect.objectContaining({ name: "Setup Node environment" }),
|
|
},
|
|
{
|
|
file: ".github/workflows/openclaw-npm-preflight.yml",
|
|
mode: "read-write",
|
|
step: expect.objectContaining({ name: "Setup Node environment" }),
|
|
},
|
|
{
|
|
file: ".github/workflows/vitest-cache-warm.yml",
|
|
mode: "read-write",
|
|
step: expect.objectContaining({ name: "Setup Node environment" }),
|
|
},
|
|
]),
|
|
);
|
|
|
|
const nodeCachePathPattern =
|
|
/(?:^|\n)\s*(?:\.artifacts\/build-all-cache|dist\/|dist-runtime\/|packages\/\*\/dist\/|extensions\/\*\/dist\/|~\/\.cache\/ms-playwright|~\/\.local\/share\/pnpm|~\/\.cache\/pnpm|node_modules)(?:\n|$)/u;
|
|
for (const { file, step, jobId, jobCondition } of directCaches) {
|
|
if (step.uses?.startsWith("actions/cache/save@")) {
|
|
if (step.with?.path === "full-release-execution-plan") {
|
|
expect(file).toBe(".github/workflows/full-release-validation.yml");
|
|
expect(jobId).toBe("release_execution_plan");
|
|
expect(step.with).toEqual({
|
|
path: "full-release-execution-plan",
|
|
key: "full-release-execution-plan-v2-${{ github.run_id }}",
|
|
});
|
|
expect(step.if).toBe(
|
|
"${{ always() && github.run_attempt == 1 && steps.plan_witness.outcome == 'success' }}",
|
|
);
|
|
continue;
|
|
}
|
|
if (step.with?.path === ".cache/openclaw-cross-os-npm-cache/_cacache") {
|
|
expect([
|
|
".github/workflows/openclaw-cross-os-release-checks-reusable.yml",
|
|
".github/workflows/release-npm-cache-warm.yml",
|
|
]).toContain(file);
|
|
const authority = `${jobCondition ?? ""} ${step.if ?? ""}`;
|
|
expect(authority).toContain("github.repository == 'openclaw/openclaw'");
|
|
expect(authority).toContain("github.event_name == 'workflow_dispatch'");
|
|
continue;
|
|
}
|
|
const condition = String(step.if);
|
|
expect(
|
|
condition.includes(".outputs.cache-mode == 'read-write'") ||
|
|
condition.includes("inputs.cache-mode == 'read-write'") ||
|
|
condition.includes("needs.preflight.outputs.cache_write_allowed == 'true'"),
|
|
`${file}: ${step.name}`,
|
|
).toBe(true);
|
|
}
|
|
if (step.uses?.startsWith("actions/cache@")) {
|
|
expect(nodeCachePathPattern.test(String(step.with?.path)), `${file}: ${step.name}`).toBe(
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("owns one exact immutable semantic dependency cache", () => {
|
|
const actionSource = readFileSync(".github/actions/setup-node-env/action.yml", "utf8");
|
|
const ciSource = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const action = parse(actionSource);
|
|
const workflow = parse(ciSource);
|
|
const actionSteps = action.runs.steps as WorkflowStep[];
|
|
const step = (name: string) =>
|
|
expectDefined(
|
|
actionSteps.find((candidate) => candidate.name === name),
|
|
name,
|
|
);
|
|
const configureStore = step("Configure dependency cache store");
|
|
const resolve = step("Resolve dependency cache key");
|
|
const prepare = step("Prepare dependency cache restore");
|
|
const restore = step("Restore exact dependency cache");
|
|
const prepareFallback = step("Prepare dependency cache miss fallback");
|
|
const setupPnpm = step("Setup pnpm");
|
|
const install = step("Install dependencies");
|
|
const installScript = readFileSync(
|
|
".github/actions/setup-node-env/install-dependencies.sh",
|
|
"utf8",
|
|
);
|
|
const cachePaths =
|
|
"node_modules\nui/node_modules\npackages/*/node_modules\nextensions/*/node_modules\nexamples/*/node_modules\n.cache/openclaw-pnpm-store\n";
|
|
|
|
expect(action.inputs["cache-mode"].default).toBe("off");
|
|
expect(action.inputs["dependency-cache"].default).toBe("false");
|
|
expect(action.inputs).not.toHaveProperty("save-dependency-cache");
|
|
expect(action.inputs).not.toHaveProperty("save-actions-cache");
|
|
expect(action.inputs).not.toHaveProperty("use-actions-cache");
|
|
expect(action.inputs).not.toHaveProperty("sticky-disk");
|
|
expect(action.inputs).not.toHaveProperty("save-sticky-disk");
|
|
expect(actionSource).not.toContain("useblacksmith/stickydisk");
|
|
|
|
for (const mode of ["off", "restore", "read-write"]) {
|
|
for (const exact of ["false", "true"]) {
|
|
expect(
|
|
runInNewContext(
|
|
expectDefined(configureStore.if, "store configuration condition").replace(
|
|
/inputs\.([a-z-]+)/gu,
|
|
'inputs["$1"]',
|
|
),
|
|
{ inputs: { "cache-mode": mode, "dependency-cache": exact }, runner: { os: "Linux" } },
|
|
),
|
|
`store-only and exact consumers share the publisher path: ${mode}/${exact}`,
|
|
).toBe(mode !== "off");
|
|
}
|
|
}
|
|
expect(configureStore.run).toContain(
|
|
'echo "PNPM_CONFIG_STORE_DIR=$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store"',
|
|
);
|
|
expect(resolve.if).toBe("inputs.cache-mode != 'off' && inputs.dependency-cache == 'true'");
|
|
expect(resolve.run).toContain('node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs"');
|
|
expect(resolve.run).toContain("${GITHUB_REPOSITORY:?}-node-deps-v4");
|
|
expect(resolve.run).toContain("${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}");
|
|
expect(resolve.run).toContain("node-$(node --version)-${deps_input_fingerprint:?}");
|
|
expect(resolve.run).not.toMatch(/GITHUB_(?:REF|SHA|RUN_ID)|RUN_(?:ID|ATTEMPT)/u);
|
|
expect(actionSteps.indexOf(resolve)).toBeLessThan(actionSteps.indexOf(restore));
|
|
for (const cleanup of [prepare, prepareFallback]) {
|
|
expect(cleanup.run).toContain('rm -rf "$GITHUB_WORKSPACE/node_modules"');
|
|
expect(cleanup.run).toContain('"$GITHUB_WORKSPACE/.cache/openclaw-pnpm-store"');
|
|
expect(cleanup.run).toContain('"$GITHUB_WORKSPACE/packages"');
|
|
expect(cleanup.run).toContain("-name node_modules");
|
|
}
|
|
expect(actionSteps.indexOf(prepare)).toBeLessThan(actionSteps.indexOf(restore));
|
|
expect(restore).toMatchObject({
|
|
if: "inputs.cache-mode != 'off' && inputs.dependency-cache == 'true'",
|
|
uses: CACHE_V5,
|
|
with: { key: "${{ steps.dependency-cache-key.outputs.key }}", path: cachePaths },
|
|
});
|
|
expect((restore as WorkflowStep & { "continue-on-error"?: boolean })["continue-on-error"]).toBe(
|
|
true,
|
|
);
|
|
expect(restore.with).not.toHaveProperty("restore-keys");
|
|
expect(prepareFallback.if).toContain("steps.dependency-cache.outputs.cache-hit != 'true'");
|
|
expect(prepareFallback.run).toContain(
|
|
"actions/cache treats service, download, and extraction failures as",
|
|
);
|
|
expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(prepareFallback));
|
|
expect(actionSteps.indexOf(prepareFallback)).toBeLessThan(actionSteps.indexOf(setupPnpm));
|
|
expect(setupPnpm.with?.["cache-mode"]).toContain(
|
|
"steps.dependency-cache.outputs.cache-hit != 'true'",
|
|
);
|
|
expect(setupPnpm.with?.["cache-mode"]).toContain("inputs.cache-mode != 'off'");
|
|
expect(setupPnpm.with?.["cache-mode"]).toContain("'restore' || 'off'");
|
|
expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(setupPnpm));
|
|
|
|
expect(install.run).toBe('bash "$GITHUB_ACTION_PATH/install-dependencies.sh"');
|
|
expect(installScript).toContain('"$GITHUB_WORKSPACE/packages"');
|
|
expect(installScript).toContain("-name node_modules");
|
|
expect(
|
|
actionSteps.some(
|
|
(candidate) =>
|
|
candidate.uses?.startsWith("actions/cache@") ||
|
|
candidate.uses?.startsWith("actions/cache/save@"),
|
|
),
|
|
).toBe(false);
|
|
|
|
const dependencySetups = Object.entries(workflow.jobs).flatMap(([jobName, job]) =>
|
|
((job as { steps?: WorkflowStep[] }).steps ?? []).flatMap((candidate) =>
|
|
candidate.uses?.endsWith("/.github/actions/setup-node-env") &&
|
|
candidate.with?.["dependency-cache"] !== undefined
|
|
? [{ jobName, step: candidate }]
|
|
: [],
|
|
),
|
|
);
|
|
const preflightRestore = dependencySetups.find(({ jobName }) => jobName === "preflight");
|
|
expect(preflightRestore?.step).toMatchObject({
|
|
if: expect.stringContaining("steps.manifest.outputs.run_node == 'true'"),
|
|
with: {
|
|
"cache-mode": "${{ steps.candidate_trust.outputs.cache_mode }}",
|
|
"dependency-cache": "true",
|
|
"install-bun": "false",
|
|
},
|
|
});
|
|
expect(preflightRestore?.step.if).toContain("github.ref == 'refs/heads/main'");
|
|
expect(preflightRestore?.step.if).toContain("github.event_name == 'pull_request'");
|
|
expect(preflightRestore?.step.if).toContain("vars.OPENCLAW_CI_RUNNER_BACKEND != 'github'");
|
|
expect(preflightRestore?.step.if).toContain(
|
|
'!contains(fromJSON(\'["hybrid","runson"]\'), vars.OPENCLAW_CI_RUNNER_BACKEND)',
|
|
);
|
|
const consumers = dependencySetups.filter(({ jobName }) => jobName !== "preflight");
|
|
expect(consumers.map(({ jobName }) => jobName).toSorted()).toEqual([
|
|
"build-artifacts",
|
|
"check-additional-shard",
|
|
"check-docs",
|
|
"check-lint-hosted-core-shard",
|
|
"check-shard",
|
|
"check-test-types-hosted-core-shard",
|
|
"checks-baseline-ratchets",
|
|
"checks-fast-channel-contracts-shard",
|
|
"checks-fast-core",
|
|
"checks-fast-plugin-contracts-shard",
|
|
"checks-node-core-test-nondist-shard",
|
|
"checks-ui",
|
|
"checks-ui-e2e",
|
|
"checks-ui-e2e-real-gateway",
|
|
"control-ui-i18n",
|
|
"control-ui-performance",
|
|
"docker-seed-e2e",
|
|
"native-i18n",
|
|
"qa-smoke-ci-profile",
|
|
]);
|
|
for (const { jobName, step: consumer } of consumers) {
|
|
const needs = workflow.jobs[jobName].needs;
|
|
expect(Array.isArray(needs) ? needs : [needs], jobName).toContain("preflight");
|
|
expect(consumer.with, jobName).not.toHaveProperty("save-dependency-cache");
|
|
expect(consumer.with?.["dependency-cache"], jobName).toContain("'true' || 'false'");
|
|
expect(consumer.with?.["cache-mode"], jobName).toBe(
|
|
"${{ needs.preflight.outputs.cache_mode }}",
|
|
);
|
|
const canonical = {
|
|
eventName: "push",
|
|
matrix: {
|
|
group: "extension-package-boundary",
|
|
node_version: "24.x",
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
task: "lint",
|
|
},
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
} as const;
|
|
const scenarios = [
|
|
{ eventName: "push", trusted: true },
|
|
{ eventName: "pull_request", headRepository: "openclaw/openclaw", trusted: true },
|
|
{ eventName: "pull_request", headRepository: "contributor/openclaw", trusted: false },
|
|
{
|
|
eventName: "pull_request",
|
|
headRepository: "contributor/openclaw",
|
|
authorAssociation: "NONE",
|
|
trusted: false,
|
|
},
|
|
{ eventName: "workflow_dispatch", trusted: false },
|
|
{ eventName: "push", repository: "contributor/openclaw", trusted: false },
|
|
] as const;
|
|
for (const runnerBackend of ["", "blacksmith", "github", "hybrid"] as const) {
|
|
for (const runAttempt of [1, 2]) {
|
|
for (const { trusted, ...scenario } of scenarios) {
|
|
const context = { ...canonical, ...scenario, runnerBackend, runAttempt };
|
|
const runsOn = workflow.jobs[jobName]["runs-on"] as string;
|
|
const routedRunner = runsOn.startsWith("${{")
|
|
? evaluateWorkflowExpression(runsOn, context)
|
|
: runsOn;
|
|
const selfHosted = String(routedRunner).startsWith("blacksmith-");
|
|
expect(
|
|
evaluateWorkflowExpression(consumer.with?.["dependency-cache"], {
|
|
...context,
|
|
runnerEnvironment: selfHosted ? "self-hosted" : "github-hosted",
|
|
}),
|
|
`${jobName} ${JSON.stringify(context)} on ${routedRunner}`,
|
|
).toBe(trusted && selfHosted ? "true" : "false");
|
|
}
|
|
}
|
|
}
|
|
// The actual runner must fence restores even when the configured backend
|
|
// still names Blacksmith or hybrid (including hosted retry routing).
|
|
for (const runnerEnvironment of ["", "github-hosted"] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(consumer.with?.["dependency-cache"], {
|
|
...canonical,
|
|
runnerBackend: "hybrid",
|
|
runnerEnvironment,
|
|
}),
|
|
`${jobName} actual runner ${runnerEnvironment}`,
|
|
).toBe("false");
|
|
}
|
|
if (jobName === "checks-node-core-test-nondist-shard") {
|
|
expect(
|
|
evaluateWorkflowExpression(consumer.with?.["dependency-cache"], {
|
|
...canonical,
|
|
matrix: { ...canonical.matrix, node_version: "22.x" },
|
|
runnerBackend: "hybrid",
|
|
runnerEnvironment: "self-hosted",
|
|
}),
|
|
).toBe("false");
|
|
}
|
|
}
|
|
for (const { jobName: setupJobName, step: setup } of Object.entries(workflow.jobs).flatMap(
|
|
([jobName, job]) =>
|
|
((job as { steps?: WorkflowStep[] }).steps ?? [])
|
|
.filter((candidate) => candidate.uses?.endsWith("/.github/actions/setup-node-env"))
|
|
.map((candidate) => ({ jobName, step: candidate })),
|
|
)) {
|
|
expect(setup.with, setupJobName).not.toHaveProperty("sticky-disk");
|
|
expect(setup.with, setupJobName).not.toHaveProperty("save-sticky-disk");
|
|
expect(
|
|
[
|
|
"off",
|
|
"restore",
|
|
"read-write",
|
|
"${{ needs.preflight.outputs.cache_mode }}",
|
|
"${{ steps.candidate_trust.outputs.cache_mode }}",
|
|
],
|
|
setupJobName,
|
|
).toContain(setup.with?.["cache-mode"]);
|
|
}
|
|
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
const dependencySave = warmer.jobs.dependencies.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Save exact dependency cache",
|
|
);
|
|
expect(dependencySave).toMatchObject({
|
|
uses: "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9",
|
|
with: {
|
|
key: "${{ steps.setup-node-env.outputs.dependency-cache-key }}",
|
|
path: cachePaths,
|
|
},
|
|
});
|
|
expect(dependencySave.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{
|
|
name: "uncached frozen",
|
|
cache: false,
|
|
frozen: "true",
|
|
exits: [0],
|
|
modes: ["--prefer-offline"],
|
|
status: 0,
|
|
},
|
|
{
|
|
name: "uncached mutable",
|
|
cache: false,
|
|
frozen: "false",
|
|
exits: [0],
|
|
modes: ["--prefer-offline"],
|
|
status: 0,
|
|
},
|
|
{
|
|
name: "invalid frozen policy",
|
|
cache: false,
|
|
frozen: "invalid",
|
|
exits: [],
|
|
modes: [],
|
|
status: 2,
|
|
},
|
|
{
|
|
name: "uncached failure",
|
|
cache: false,
|
|
frozen: "true",
|
|
exits: [23],
|
|
modes: ["--prefer-offline"],
|
|
status: 23,
|
|
},
|
|
{
|
|
name: "cached success",
|
|
cache: true,
|
|
frozen: "true",
|
|
exits: [0],
|
|
modes: ["--offline"],
|
|
status: 0,
|
|
},
|
|
{
|
|
name: "cached relink",
|
|
cache: true,
|
|
frozen: "true",
|
|
exits: [23, 0],
|
|
modes: ["--offline", "--offline"],
|
|
status: 0,
|
|
},
|
|
{
|
|
name: "cached store rebuild",
|
|
cache: true,
|
|
frozen: "true",
|
|
exits: [23, 23, 0],
|
|
modes: ["--offline", "--offline", "--prefer-offline"],
|
|
status: 0,
|
|
},
|
|
{
|
|
name: "cached terminal failure",
|
|
cache: true,
|
|
frozen: "true",
|
|
exits: [23, 23, 23],
|
|
modes: ["--offline", "--offline", "--prefer-offline"],
|
|
status: 23,
|
|
},
|
|
])("executes the dependency install recipe: $name", ({ cache, frozen, exits, modes, status }) => {
|
|
const root = tempDirs.make("openclaw-install-recipe-");
|
|
const workspace = path.join(root, "workspace");
|
|
const bin = path.join(root, "bin");
|
|
const store = path.join(root, "store");
|
|
const log = path.join(root, "calls.jsonl");
|
|
const githubEnv = path.join(root, "github.env");
|
|
const payload = path.join(root, "payload");
|
|
for (const directory of [
|
|
bin,
|
|
store,
|
|
...["", "ui", "packages", "extensions", "examples"].map((entry) =>
|
|
path.join(workspace, entry),
|
|
),
|
|
]) {
|
|
mkdirSync(directory, { recursive: true });
|
|
}
|
|
mkdirSync(path.join(workspace, "node_modules"));
|
|
writeFileSync(path.join(workspace, "node_modules", "before"), "");
|
|
writeFileSync(path.join(store, "before"), "");
|
|
mkdirSync(path.join(store, "toolchain"));
|
|
writeFileSync(path.join(store, "toolchain", "pnpm.tgz"), "authenticated archive");
|
|
symlinkSync(testNodeExecPath, path.join(bin, "node"));
|
|
const pnpm = path.join(bin, "pnpm");
|
|
writeFileSync(
|
|
pnpm,
|
|
"#!" +
|
|
testNodeExecPath +
|
|
"\n" +
|
|
String.raw`
|
|
const fs = require("node:fs");
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === "-v") { console.log("fixture"); process.exit(0); }
|
|
const log = process.env.RECIPE_LOG;
|
|
const count = fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").length : 0;
|
|
fs.appendFileSync(log, JSON.stringify({ args, cwd: process.cwd(), importMethod: process.env.PNPM_CONFIG_PACKAGE_IMPORT_METHOD }) + "\n");
|
|
process.exit(JSON.parse(process.env.RECIPE_EXITS)[count] ?? 99);
|
|
`,
|
|
);
|
|
chmodSync(pnpm, 0o755);
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const step: WorkflowStep = expectDefined(
|
|
action.runs.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Install dependencies",
|
|
),
|
|
"Install dependencies",
|
|
);
|
|
const run = expectDefined(step.run, "Install dependencies script");
|
|
const config = {
|
|
PNPM_CONFIG_CACHE_DIR: path.join(root, "metadata"),
|
|
PNPM_CONFIG_CHILD_CONCURRENCY: "3",
|
|
PNPM_CONFIG_NETWORK_CONCURRENCY: "4",
|
|
PNPM_CONFIG_PACKAGE_IMPORT_METHOD: "copy",
|
|
PNPM_CONFIG_STORE_DIR: store,
|
|
PNPM_CONFIG_VIRTUAL_STORE_DIR: path.join(root, "virtual"),
|
|
};
|
|
const result = spawnSync(
|
|
"bash",
|
|
["-c", run.trimEnd() + ' && printf reached > "$RECIPE_PAYLOAD"'],
|
|
{
|
|
cwd: workspace,
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
NODE_BIN: bin,
|
|
GITHUB_ACTION_PATH: path.resolve(".github/actions/setup-node-env"),
|
|
GITHUB_WORKSPACE: workspace,
|
|
GITHUB_ENV: githubEnv,
|
|
CI: "true",
|
|
DEPENDENCY_CACHE: String(cache),
|
|
DEPENDENCY_CACHE_HIT: String(cache),
|
|
FROZEN_LOCKFILE: frozen,
|
|
RECIPE_LOG: log,
|
|
RECIPE_PAYLOAD: payload,
|
|
RECIPE_EXITS: JSON.stringify(exits),
|
|
...config,
|
|
},
|
|
},
|
|
);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, result.stdout + result.stderr).toBe(status);
|
|
expect(existsSync(payload)).toBe(status === 0);
|
|
const calls: Array<{ args: string[]; cwd: string; importMethod: string }> = existsSync(log)
|
|
? readFileSync(log, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line))
|
|
: [];
|
|
const expectedArgs = [
|
|
"install",
|
|
"--config.ignore-scripts=false",
|
|
"--config.engine-strict=false",
|
|
"--config.enable-pre-post-scripts=true",
|
|
"--config.side-effects-cache=true",
|
|
...(frozen === "true" ? ["--frozen-lockfile"] : []),
|
|
"--config.cache-dir=" + config.PNPM_CONFIG_CACHE_DIR,
|
|
"--config.child-concurrency=3",
|
|
"--config.network-concurrency=4",
|
|
"--config.package-import-method=" + (cache ? "hardlink" : "copy"),
|
|
"--config.store-dir=" + store,
|
|
"--config.virtual-store-dir=" + config.PNPM_CONFIG_VIRTUAL_STORE_DIR,
|
|
];
|
|
expect(calls).toEqual(
|
|
modes.map((mode) => ({
|
|
args: [...expectedArgs, mode],
|
|
cwd: workspace,
|
|
importMethod: cache ? "hardlink" : "copy",
|
|
})),
|
|
);
|
|
expect(existsSync(path.join(workspace, "node_modules", "before"))).toBe(modes.length < 2);
|
|
expect(existsSync(path.join(store, "before"))).toBe(modes.length < 3);
|
|
expect(readFileSync(path.join(store, "toolchain", "pnpm.tgz"), "utf8")).toBe(
|
|
"authenticated archive",
|
|
);
|
|
expect(existsSync(githubEnv)).toBe(cache && status === 0);
|
|
if (cache && status === 0) {
|
|
expect(readFileSync(githubEnv, "utf8")).toBe(
|
|
"OPENCLAW_BUILD_ALL_NO_PNPM=1\npnpm_config_verify_deps_before_run=false\n",
|
|
);
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves pnpm hard links and validates cached importers and supply-chain policy offline",
|
|
async ({ onTestFinished, signal }) => {
|
|
const fixtureDirs = createTempDirTracker();
|
|
// oxlint-disable-next-line prefer-const -- Failure cleanup can run before the registry is started.
|
|
let stopRegistry: (() => Promise<void>) | undefined;
|
|
let readyTimeout: NodeJS.Timeout | undefined;
|
|
// Timeout does not join the test body. Keep close and deletion in one hook,
|
|
// outside afterEach, so a failed join cannot release the registry's files.
|
|
onTestFinished(async () => {
|
|
clearTimeout(readyTimeout);
|
|
await stopRegistry?.();
|
|
fixtureDirs.cleanup();
|
|
});
|
|
const root = fixtureDirs.make("openclaw-dependency-cache-");
|
|
const source = path.join(root, "source");
|
|
const registry = path.join(root, "registry");
|
|
const workspace = path.join(root, "workspace");
|
|
const consumer = path.join(workspace, "packages", "consumer");
|
|
const store = path.join(workspace, ".cache", "openclaw-pnpm-store");
|
|
let userHome = path.join(root, "producer-home");
|
|
mkdirSync(userHome, { recursive: true });
|
|
mkdirSync(source, { recursive: true });
|
|
mkdirSync(registry, { recursive: true });
|
|
mkdirSync(consumer, { recursive: true });
|
|
writeFileSync(
|
|
path.join(source, "package.json"),
|
|
JSON.stringify({
|
|
files: ["index.js"],
|
|
name: "cache-proof-dep",
|
|
packageManager: rootPackageManager,
|
|
scripts: { "pnpm-path": "node -p process.env.npm_execpath" },
|
|
version: "1.0.0",
|
|
}),
|
|
);
|
|
writeFileSync(path.join(source, "index.js"), 'module.exports = "cache-proof-v1";\n');
|
|
// Both projects own the pinned environment before any command runs; otherwise
|
|
// pnpm resolves its own metadata from the public registry during bootstrap.
|
|
const { environment } = pnpmLockfileDocuments(readFileSync("pnpm-lock.yaml", "utf8"));
|
|
if (environment !== null) {
|
|
for (const directory of [source, workspace]) {
|
|
writeFileSync(path.join(directory, "pnpm-lock.yaml"), `---\n${environment}\n---\n`);
|
|
}
|
|
}
|
|
// Capture the pinned CLI before switching to the fixture-only registry/store.
|
|
const nodeExecPath = resolveTestNodeExecPath();
|
|
const bootstrap = resolvePnpmRunner({ nodeExecPath });
|
|
const npmExecPath = execFileSync(
|
|
bootstrap.command,
|
|
[...bootstrap.args, "--silent", "run", "pnpm-path"],
|
|
{ cwd: source, encoding: "utf8", env: { ...process.env, CI: "true" } },
|
|
).trim();
|
|
const pnpm = resolvePnpmRunner({ nodeExecPath, npmExecPath });
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const configureCache = expectDefined(
|
|
action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Configure dependency cache store",
|
|
)?.run,
|
|
"Configure dependency cache store script",
|
|
);
|
|
const envFile = path.join(root, "dependency-cache.env");
|
|
execFileSync("bash", ["-c", configureCache], {
|
|
env: { ...process.env, GITHUB_WORKSPACE: workspace, GITHUB_ENV: envFile },
|
|
});
|
|
const dependencyEnvironment = Object.fromEntries(
|
|
readFileSync(envFile, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
}),
|
|
);
|
|
const runPnpm = (args: string[], cwd: string) =>
|
|
spawnSync(pnpm.command, [...pnpm.args, ...args], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HOME: userHome,
|
|
XDG_CACHE_HOME: path.join(userHome, ".cache"),
|
|
CI: "true",
|
|
PNPM_CONFIG_PACKAGE_IMPORT_METHOD: "hardlink",
|
|
...dependencyEnvironment,
|
|
},
|
|
});
|
|
const version = runPnpm(["--version"], source);
|
|
expect(version.status, version.stderr).toBe(0);
|
|
expect(`pnpm@${version.stdout.trim()}`).toBe(rootPackageManager.split("+")[0]);
|
|
const packed = runPnpm(["pack", "--pack-destination", registry], source);
|
|
expect(packed.status, `${packed.stdout}${packed.stderr}`).toBe(0);
|
|
const tarball = path.join(registry, "cache-proof-dep-1.0.0.tgz");
|
|
const registryScript = String.raw`
|
|
const { createHash } = require("node:crypto");
|
|
const { readFileSync } = require("node:fs");
|
|
const { createServer } = require("node:http");
|
|
const tarballPath = process.argv[1];
|
|
const tarball = readFileSync(tarballPath);
|
|
const server = createServer((request, response) => {
|
|
if (request.url === "/cache-proof-dep") {
|
|
const port = server.address().port;
|
|
const metadata = {
|
|
name: "cache-proof-dep",
|
|
"dist-tags": { latest: "1.0.0" },
|
|
time: {
|
|
"1.0.0": new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
|
|
modified: new Date().toISOString(),
|
|
},
|
|
versions: {
|
|
"1.0.0": {
|
|
name: "cache-proof-dep",
|
|
version: "1.0.0",
|
|
dist: {
|
|
tarball: "http://127.0.0.1:" + port + "/cache-proof-dep-1.0.0.tgz",
|
|
shasum: createHash("sha1").update(tarball).digest("hex"),
|
|
integrity: "sha512-" + createHash("sha512").update(tarball).digest("base64"),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const abbreviated = request.headers.accept?.includes("application/vnd.npm.install-v1+json");
|
|
if (abbreviated) {
|
|
delete metadata.time;
|
|
}
|
|
response.setHeader("content-type", abbreviated ? "application/vnd.npm.install-v1+json" : "application/json");
|
|
response.end(JSON.stringify(metadata));
|
|
return;
|
|
}
|
|
if (request.url === "/cache-proof-dep-1.0.0.tgz") {
|
|
response.setHeader("content-type", "application/octet-stream");
|
|
response.end(tarball);
|
|
return;
|
|
}
|
|
response.statusCode = 404;
|
|
response.end();
|
|
});
|
|
server.listen(0, "127.0.0.1", () => {
|
|
process.send(server.address().port);
|
|
});
|
|
`;
|
|
const registryServer = spawn(process.execPath, ["-e", registryScript, tarball], {
|
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
});
|
|
let registryDidClose = false;
|
|
// Retain actual close from launch, including failed spawn; readiness must not own this join.
|
|
const registryClosed = new Promise<void>((resolve) => {
|
|
registryServer.once("close", () => {
|
|
registryDidClose = true;
|
|
resolve();
|
|
});
|
|
});
|
|
const failures: unknown[] = [];
|
|
registryServer.on("error", (error) => failures.push(error));
|
|
stopRegistry = async () => {
|
|
if (!registryDidClose) {
|
|
registryServer.kill("SIGTERM");
|
|
}
|
|
await registryClosed;
|
|
};
|
|
try {
|
|
const port = await new Promise<number>((resolve, reject) => {
|
|
readyTimeout = setTimeout(() => reject(new Error("fixture registry not ready")), 2_000);
|
|
registryServer.once("message", (message) => {
|
|
if (typeof message !== "number") {
|
|
reject(new Error("fixture registry sent an invalid port"));
|
|
return;
|
|
}
|
|
resolve(message);
|
|
});
|
|
registryServer.once("error", reject);
|
|
void registryClosed.then(() => reject(new Error("fixture registry closed before ready")));
|
|
});
|
|
clearTimeout(readyTimeout);
|
|
signal.throwIfAborted();
|
|
const registryUrl = `http://127.0.0.1:${port}`;
|
|
writeFileSync(
|
|
path.join(workspace, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: { "cache-proof-dep": "1.0.0" },
|
|
name: "cache-proof-root",
|
|
packageManager: rootPackageManager,
|
|
private: true,
|
|
}),
|
|
);
|
|
const workspaceConfig =
|
|
"packages:\n - packages/*\nminimumReleaseAge: 10080\nminimumReleaseAgeStrict: true\n";
|
|
writeFileSync(path.join(workspace, "pnpm-workspace.yaml"), workspaceConfig);
|
|
const writeConsumerManifest = (dependencyVersion: string) =>
|
|
writeFileSync(
|
|
path.join(consumer, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: { "cache-proof-dep": dependencyVersion },
|
|
name: "cache-proof-consumer",
|
|
private: true,
|
|
}),
|
|
);
|
|
writeConsumerManifest("1.0.0");
|
|
// The fixture registry serves only its test package, not the preserved project pnpm pin.
|
|
const installArgs = [
|
|
"install",
|
|
"--ignore-scripts",
|
|
"--config.engine-strict=false",
|
|
"--pm-on-fail=ignore",
|
|
];
|
|
const onlineArgs = [...installArgs, `--registry=${registryUrl}`];
|
|
const seeded = runPnpm([...onlineArgs, "--lockfile-only"], workspace);
|
|
expect(seeded.status, `${seeded.stdout}${seeded.stderr}`).toBe(0);
|
|
// CI publishes a frozen install, without the lockfile generator's caches.
|
|
rmSync(userHome, { force: true, recursive: true });
|
|
rmSync(store, { force: true, recursive: true });
|
|
mkdirSync(userHome, { recursive: true });
|
|
const installed = runPnpm([...onlineArgs, "--frozen-lockfile"], workspace);
|
|
expect(installed.status, `${installed.stdout}${installed.stderr}`).toBe(0);
|
|
|
|
const findSameFile = (directory: string, referencePath: string): string | undefined => {
|
|
const reference = statSync(referencePath);
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
const nested = findSameFile(entryPath, referencePath);
|
|
if (nested) {
|
|
return nested;
|
|
}
|
|
} else if (entry.isFile()) {
|
|
const candidate = statSync(entryPath);
|
|
if (candidate.dev === reference.dev && candidate.ino === reference.ino) {
|
|
return entryPath;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const rootPackageFile = path.join(workspace, "node_modules", "cache-proof-dep", "index.js");
|
|
expect(findSameFile(store, rootPackageFile)).toBeDefined();
|
|
|
|
const archive = path.join(root, "dependency-cache.tar");
|
|
execFileSync(
|
|
"tar",
|
|
[
|
|
"-cf",
|
|
archive,
|
|
"-C",
|
|
workspace,
|
|
"node_modules",
|
|
"packages/consumer/node_modules",
|
|
".cache/openclaw-pnpm-store",
|
|
],
|
|
{ stdio: "pipe" },
|
|
);
|
|
|
|
rmSync(path.join(workspace, "node_modules"), { force: true, recursive: true });
|
|
rmSync(path.join(consumer, "node_modules"), { force: true, recursive: true });
|
|
rmSync(store, { force: true, recursive: true });
|
|
rmSync(userHome, { force: true, recursive: true });
|
|
userHome = path.join(root, "consumer-home");
|
|
mkdirSync(userHome, { recursive: true });
|
|
execFileSync("tar", ["-xf", archive, "-C", workspace], { stdio: "pipe" });
|
|
|
|
const restoredPackageFile = path.join(
|
|
workspace,
|
|
"node_modules",
|
|
"cache-proof-dep",
|
|
"index.js",
|
|
);
|
|
expect(findSameFile(store, restoredPackageFile)).toBeDefined();
|
|
expect(
|
|
readFileSync(path.join(consumer, "node_modules", "cache-proof-dep", "index.js"), "utf8"),
|
|
).toBe('module.exports = "cache-proof-v1";\n');
|
|
|
|
await stopRegistry();
|
|
const registryAtStop = {
|
|
closed: registryDidClose,
|
|
exitCode: registryServer.exitCode,
|
|
signalCode: registryServer.signalCode,
|
|
};
|
|
signal.throwIfAborted();
|
|
expect(
|
|
registryAtStop.closed,
|
|
"registry closed before source deletion/offline install",
|
|
).toBe(true);
|
|
// This direct child owns the listener; its released port may already have a new owner.
|
|
expect(registryAtStop.exitCode !== null || registryAtStop.signalCode !== null).toBe(true);
|
|
rmSync(registry, { force: true, recursive: true });
|
|
const cachedIdentity = statSync(restoredPackageFile);
|
|
const cachedLockfile = readFileSync(path.join(workspace, "pnpm-lock.yaml"), "utf8");
|
|
const offlineArgs = [...onlineArgs, "--offline", "--frozen-lockfile"];
|
|
const reconciliation = runPnpm(offlineArgs, workspace);
|
|
expect(reconciliation.status, `${reconciliation.stdout}${reconciliation.stderr}`).toBe(0);
|
|
expect(statSync(restoredPackageFile)).toMatchObject({
|
|
dev: cachedIdentity.dev,
|
|
ino: cachedIdentity.ino,
|
|
});
|
|
expect(readFileSync(path.join(workspace, "pnpm-lock.yaml"), "utf8")).toBe(cachedLockfile);
|
|
expect(
|
|
readFileSync(path.join(consumer, "node_modules", "cache-proof-dep", "index.js"), "utf8"),
|
|
).toBe('module.exports = "cache-proof-v1";\n');
|
|
// A stricter policy invalidates pnpm's saved verification and reads the
|
|
// restored registry metadata. A 14-day-old release fails a 21-day gate.
|
|
writeFileSync(
|
|
path.join(workspace, "pnpm-workspace.yaml"),
|
|
workspaceConfig.replace("minimumReleaseAge: 10080", "minimumReleaseAge: 30240"),
|
|
);
|
|
const stricterPolicy = runPnpm(offlineArgs, workspace);
|
|
expect(stricterPolicy.status).toBe(1);
|
|
expect(`${stricterPolicy.stdout}${stricterPolicy.stderr}`).toContain(
|
|
"ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION",
|
|
);
|
|
writeFileSync(path.join(workspace, "pnpm-workspace.yaml"), workspaceConfig);
|
|
writeConsumerManifest("2.0.0");
|
|
const drift = runPnpm(offlineArgs, workspace);
|
|
expect(drift.status).toBe(1);
|
|
expect(`${drift.stdout}${drift.stderr}`).toContain('Cannot install with "frozen-lockfile"');
|
|
expect(`${drift.stdout}${drift.stderr}`).toContain('in importers["packages/consumer"]');
|
|
expect(`${drift.stdout}${drift.stderr}`).toContain(
|
|
"cache-proof-dep (lockfile: 1.0.0, manifest: 2.0.0)",
|
|
);
|
|
} catch (error) {
|
|
if (failures[0] !== error) {
|
|
failures.unshift(error);
|
|
}
|
|
} finally {
|
|
clearTimeout(readyTimeout);
|
|
try {
|
|
await stopRegistry();
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if (failures.length === 1) {
|
|
throw failures[0];
|
|
}
|
|
if (failures.length > 1) {
|
|
throw new AggregateError(failures, "dependency cache fixture failed");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("restores compiled workers only for opted-in Linux consumers with a shared seed", () => {
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const steps = action.runs.steps as WorkflowStep[];
|
|
const restore = expectDefined(
|
|
steps.find((step) => step.id === "vitest-worker-cache"),
|
|
"compiled worker restore",
|
|
);
|
|
const enable = expectDefined(
|
|
steps.find((step) => step.name === "Enable restored Vitest workers"),
|
|
"compiled worker opt-in",
|
|
);
|
|
expect(action.inputs["vitest-worker-cache"].default).toBe("false");
|
|
expect(restore).toMatchObject({
|
|
uses: CACHE_V5,
|
|
"continue-on-error": true,
|
|
with: { path: ".artifacts/vitest-worker-cache" },
|
|
});
|
|
expect(restore.with?.key).toContain("${{ steps.setup-node.outputs.resolved-version }}");
|
|
expect(restore.with?.key).toContain("${{ github.run_id }}-${{ github.run_attempt }}");
|
|
expect(restore.with?.["restore-keys"]).toContain(
|
|
"-protected-${{ runner.os }}-${{ runner.arch }}-",
|
|
);
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
const warmerSteps = warmer.jobs.warm.steps as WorkflowStep[];
|
|
const prepare = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Prepare compiled Vitest workers"),
|
|
"compiled worker preparation",
|
|
);
|
|
const save = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Save compiled Vitest workers"),
|
|
"compiled worker publication",
|
|
);
|
|
expect(prepare).toMatchObject({
|
|
run: "node scripts/prepare-vitest-worker-cache.mts",
|
|
env: { NODE_OPTIONS: "--max-old-space-size=8192", OPENCLAW_VITEST_WORKER_CACHE: "1" },
|
|
});
|
|
expect(save).toMatchObject({
|
|
uses: CACHE_SAVE_V5,
|
|
with: {
|
|
path: restore.with?.path,
|
|
key: "${{ steps.setup-node-env.outputs.vitest-worker-cache-key }}",
|
|
},
|
|
});
|
|
expect(save.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
expect(save.if).not.toMatch(/always\(|failure\(|cancelled\(/u);
|
|
expect(warmerSteps.indexOf(prepare)).toBeLessThan(warmerSteps.indexOf(save));
|
|
for (const step of [prepare, save]) {
|
|
expect(step.if).toContain("matrix.platform == 'linux'");
|
|
}
|
|
expect(warmer.jobs.dependencies.steps.map((step: WorkflowStep) => step.name)).not.toContain(
|
|
"Prepare compiled Vitest workers",
|
|
);
|
|
expect(warmerSteps.indexOf(save)).toBeLessThan(
|
|
warmerSteps.findIndex((step) => step.name === "Prepare native SDK boundary cache"),
|
|
);
|
|
for (const mode of ["off", "restore", "read-write"]) {
|
|
for (const optedIn of ["true", "false"]) {
|
|
for (const os of ["Linux", "macOS", "Windows"]) {
|
|
for (const matched of ["", "protected-seed"]) {
|
|
const enabled = (step: WorkflowStep) =>
|
|
runInNewContext(String(step.if).replace(/\.([a-z][a-z-]*)/gu, '["$1"]'), {
|
|
inputs: { "cache-mode": mode, "vitest-worker-cache": optedIn },
|
|
runner: { os },
|
|
steps: { "vitest-worker-cache": { outputs: { "cache-matched-key": matched } } },
|
|
});
|
|
const eligible = mode !== "off" && optedIn === "true" && os === "Linux";
|
|
expect(enabled(restore)).toBe(eligible);
|
|
expect(enabled(enable)).toBe(eligible && matched !== "");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const shard = readCiWorkflow().jobs["checks-node-core-test-nondist-shard"];
|
|
const setup = expectDefined(
|
|
shard.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
|
"Node shard setup",
|
|
);
|
|
for (const frozenTarget of [false, true]) {
|
|
for (const pretestBuild of [null, "runtime", "private-qa"]) {
|
|
for (const nodeVersion of [null, "24.x", "26.x"]) {
|
|
expect(
|
|
evaluateWorkflowExpression(setup.with["vitest-worker-cache"], {
|
|
eventName: "pull_request",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
frozenTarget,
|
|
matrix: { pretest_build_mode: pretestBuild, node_version: nodeVersion },
|
|
}),
|
|
).toBe(
|
|
String(
|
|
!frozenTarget &&
|
|
pretestBuild === null &&
|
|
(nodeVersion === null || nodeVersion === "24.x"),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("persists content-validated public full-build declarations", () => {
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const installStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install dependencies",
|
|
);
|
|
const cacheStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore build-all cache",
|
|
);
|
|
|
|
expect(action.inputs["build-all-cache-scope"].default).toBe("");
|
|
expect(cacheStep).toMatchObject({
|
|
if: "inputs.cache-mode != 'off' && inputs.build-all-cache-scope != ''",
|
|
uses: CACHE_V5,
|
|
with: { path: ".artifacts/build-all-cache" },
|
|
});
|
|
expect(cacheStep.with.key).toContain("build-all-v1-${{ inputs.build-all-cache-scope }}");
|
|
expect(cacheStep.with.key).toContain("${{ runner.os }}-${{ runner.arch }}");
|
|
const renderCacheKey = (template: string, runId: number, runAttempt: number) =>
|
|
template.replace(/\$\{\{([\s\S]*?)\}\}/gu, (_, expression: string) =>
|
|
String(
|
|
runInNewContext(expression.replace(/inputs\.([a-z-]+)/gu, 'inputs["$1"]'), {
|
|
github: { repository: "openclaw/openclaw", run_id: runId, run_attempt: runAttempt },
|
|
inputs: { "build-all-cache-scope": "full", "node-version": "24.x" },
|
|
runner: { os: "Linux", arch: "X64" },
|
|
hashFiles: () => "unchanged-source",
|
|
}),
|
|
),
|
|
);
|
|
// A new warmer or rerun must publish rebuilt groups even when an outer input
|
|
// fingerprint would be unchanged; per-group signatures own content validity.
|
|
const keys = (
|
|
[
|
|
[10, 1],
|
|
[11, 1],
|
|
[11, 2],
|
|
] as const
|
|
).map(([runId, runAttempt]) => renderCacheKey(cacheStep.with.key, runId, runAttempt));
|
|
expect(new Set(keys).size).toBe(3);
|
|
for (const key of keys) {
|
|
expect(key.startsWith(renderCacheKey(cacheStep.with["restore-keys"], 11, 2).trim())).toBe(
|
|
true,
|
|
);
|
|
}
|
|
expect(cacheStep.with["restore-keys"]).not.toContain("hashFiles");
|
|
expect(action.runs.steps.indexOf(installStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(cacheStep),
|
|
);
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
const buildSave = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Save build-all cache",
|
|
);
|
|
expect(buildSave).toMatchObject({
|
|
uses: "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9",
|
|
with: {
|
|
key: "${{ steps.setup-node-env.outputs.build-all-cache-key }}",
|
|
path: ".artifacts/build-all-cache",
|
|
},
|
|
});
|
|
expect(buildSave.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
|
|
const privateQaWorkflows = [
|
|
".github/workflows/mantis-discord-smoke.yml",
|
|
".github/workflows/mantis-discord-status-reactions.yml",
|
|
".github/workflows/mantis-discord-thread-attachment.yml",
|
|
".github/workflows/mantis-slack-desktop-smoke.yml",
|
|
".github/workflows/qa-live-transports-convex.yml",
|
|
];
|
|
for (const workflowPath of privateQaWorkflows) {
|
|
const source = readFileSync(workflowPath, "utf8");
|
|
expect(source, workflowPath).not.toContain("build-all-cache-scope:");
|
|
}
|
|
|
|
const releaseChecks = parse(
|
|
readFileSync(".github/workflows/openclaw-live-and-e2e-checks-reusable.yml", "utf8"),
|
|
);
|
|
const repoE2eWorkflow = readWorkflow(".github/workflows/openclaw-repo-e2e-reusable.yml");
|
|
const pipelines = [
|
|
releaseChecks.jobs.validate_repo_e2e_gateway,
|
|
releaseChecks.jobs.validate_repo_e2e_runtime,
|
|
];
|
|
expect(releaseChecks.jobs.validate_live_docker_provider_suites.env).toMatchObject({
|
|
OPENCLAW_SELECTED_SHA: "${{ needs.validate_selected_ref.outputs.selected_sha }}",
|
|
OPENCLAW_TOOLING_SHA: "${{ needs.validate_selected_ref.outputs.workflow_sha }}",
|
|
});
|
|
const repoE2eRows = pipelines.flatMap((pipeline) => JSON.parse(pipeline.with.suites)) as Array<{
|
|
name: string;
|
|
command: string;
|
|
target_script?: string;
|
|
}>;
|
|
expect(pipelines.map((pipeline) => pipeline.with.build_profile)).toEqual([
|
|
"full",
|
|
"ciArtifacts",
|
|
]);
|
|
for (const pipeline of pipelines) {
|
|
// Each profile starts independently; a slow/full declaration build cannot hold up UI readers.
|
|
expect(pipeline.needs).toBe("validate_selected_ref");
|
|
expect(pipeline.if).toBe(
|
|
"(!inputs.prepare_only) && inputs.include_repo_e2e && inputs.live_suite_filter == ''",
|
|
);
|
|
expect(pipeline.uses).toBe("./.github/workflows/openclaw-repo-e2e-reusable.yml");
|
|
expect(pipeline.with.ref).toBe("${{ needs.validate_selected_ref.outputs.selected_sha }}");
|
|
expect(pipeline.with.advisory).toBeUndefined();
|
|
expect(pipeline.with.allow_frozen_target_scenario_omissions).toBe(
|
|
"${{ inputs.allow_frozen_target_scenario_omissions }}",
|
|
);
|
|
}
|
|
expect(repoE2eRows.map((row) => row.command)).toEqual([
|
|
...Array.from({ length: 4 }, (_, index) => `pnpm test:e2e:gateway --shard=${index + 1}/4`),
|
|
...Array.from({ length: 4 }, (_, index) => `pnpm test:ui:e2e --shard=${index + 1}/4`),
|
|
"pnpm test:e2e:agent-plugin-gateway",
|
|
]);
|
|
expect(new Set(repoE2eRows.map((row) => row.name)).size).toBe(9);
|
|
expect(repoE2eRows.find((row) => row.name === "Agent plugin Gateway")).toMatchObject({
|
|
target_script: "test:e2e:agent-plugin-gateway",
|
|
});
|
|
expect(repoE2eWorkflow.env).toMatchObject({
|
|
OPENCLAW_BUILD_PRIVATE_QA: "1",
|
|
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
|
|
OPENCLAW_VITEST_MAX_WORKERS: "2",
|
|
OPENCLAW_SELECTED_SHA: "${{ inputs.ref }}",
|
|
OPENCLAW_TOOLING_SHA: "${{ inputs.workflow_sha }}",
|
|
OPENCLAW_DOCKER_E2E_REPO_ROOT: "${{ github.workspace }}",
|
|
});
|
|
const producer = repoE2eWorkflow.jobs.build;
|
|
const repoE2e = repoE2eWorkflow.jobs.test;
|
|
expect(repoE2e.needs).toBe("build");
|
|
expect(repoE2e.name).toBe("Repo E2E (${{ matrix.name }})");
|
|
expect(repoE2e["timeout-minutes"]).toBe(90);
|
|
expect(repoE2e.strategy).toMatchObject({ "fail-fast": false, "max-parallel": 4 });
|
|
expect(repoE2e["continue-on-error"]).toBeUndefined();
|
|
const producerSteps = producer.steps as WorkflowStep[];
|
|
expect(producerSteps.find((step) => step.name === "Build dist for repo E2E")?.run).toContain(
|
|
"full) pnpm build",
|
|
);
|
|
expect(producerSteps.find((step) => step.name === "Build dist for repo E2E")?.run).toContain(
|
|
"ciArtifacts) pnpm build:ci-artifacts",
|
|
);
|
|
expect(producerSteps.find((step) => step.uses === UPLOAD_ARTIFACT_V7)?.with?.name).toContain(
|
|
"${{ github.run_attempt }}",
|
|
);
|
|
const repoE2eSteps = repoE2e.steps as WorkflowStep[];
|
|
expect(repoE2eSteps.find((step) => step.name === "Checkout selected ref")?.with?.ref).toBe(
|
|
"${{ inputs.ref }}",
|
|
);
|
|
expect(repoE2eSteps.find((step) => step.uses === DOWNLOAD_ARTIFACT_V8)?.with).toMatchObject({
|
|
"artifact-ids": "${{ needs.build.outputs.artifact_id }}",
|
|
"run-id": "${{ needs.build.outputs.artifact_run_id }}",
|
|
"github-token": "${{ github.token }}",
|
|
});
|
|
expect(repoE2eSteps.some((step) => step.run?.includes("pnpm build"))).toBe(false);
|
|
const restoreIndex = repoE2eSteps.findIndex((step) => step.name === "Restore repo E2E build");
|
|
const sandboxSetupIndex = repoE2eSteps.findIndex(
|
|
(step) => step.run === "scripts/sandbox-setup.sh",
|
|
);
|
|
const repoE2eIndex = repoE2eSteps.findIndex((step) => step.name === "Run repo E2E suite");
|
|
expect(restoreIndex).toBeGreaterThanOrEqual(0);
|
|
expect(sandboxSetupIndex).toBeGreaterThan(restoreIndex);
|
|
expect(repoE2eIndex).toBeGreaterThan(sandboxSetupIndex);
|
|
expect(repoE2eSteps[repoE2eIndex]).toMatchObject({
|
|
env: {
|
|
OPENCLAW_E2E_WORKERS: "2",
|
|
OPENCLAW_E2E_USE_PREBUILT_DIST: "1",
|
|
TARGET_REQUIRED_SCRIPT: "${{ matrix.target_script || '' }}",
|
|
},
|
|
});
|
|
const repoE2eRun = repoE2eSteps[repoE2eIndex]?.run;
|
|
expect(repoE2eRun).toContain("OPENCLAW_ALLOW_FROZEN_TARGET_SCENARIO_OMISSIONS");
|
|
expect(repoE2eRun).toContain("Selected target does not provide required repo E2E capability");
|
|
expect(repoE2eRun).toContain("selected target does not provide this newer repo E2E capability");
|
|
expect(repoE2eRun).toContain("${{ matrix.command }}");
|
|
const targetedGroupStep = releaseChecks.jobs.plan_docker_lane_groups.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build targeted Docker lane groups",
|
|
);
|
|
expect(targetedGroupStep.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS).toBe(
|
|
"${{ inputs.published_upgrade_survivor_scenarios }}",
|
|
);
|
|
expect(releaseChecks.jobs.validate_docker_lanes["timeout-minutes"]).toBe(
|
|
"${{ matrix.group.timeout_minutes || 60 }}",
|
|
);
|
|
expect(releaseChecks.jobs.validate_docker_lanes.strategy["max-parallel"]).toBe(32);
|
|
expect(releaseChecks.jobs.validate_docker_lanes.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS).toBe(
|
|
"${{ matrix.group.published_upgrade_survivor_scenarios || inputs.published_upgrade_survivor_scenarios }}",
|
|
);
|
|
});
|
|
|
|
it("persists Node 26 minimum declarations through trusted bounded artifacts", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/node-runtime-compat.yml", "utf8"));
|
|
const steps = workflow.jobs.compat.steps as WorkflowStep[];
|
|
const setupStep = steps.find((step) => step.name === "Setup Node environment");
|
|
const resolveStep = steps.find(
|
|
(step) => step.name === "Resolve trusted declaration cache artifact",
|
|
);
|
|
const downloadStep = steps.find(
|
|
(step) => step.name === "Restore trusted declaration cache artifact",
|
|
);
|
|
const uploadStep = steps.find(
|
|
(step) => step.name === "Publish trusted declaration cache artifact",
|
|
);
|
|
|
|
expect(workflow.permissions).toMatchObject({ actions: "read", contents: "read" });
|
|
expect(setupStep?.with).not.toHaveProperty("build-all-cache-scope");
|
|
expect(resolveStep?.run).toContain('.head_branch == "main"');
|
|
expect(resolveStep?.run).toContain('(.path | split("@")[0])');
|
|
expect(resolveStep?.run).toContain('.conclusion == "success"');
|
|
expect(resolveStep?.run).toContain("status=success&per_page=5");
|
|
expect(resolveStep?.run).toContain("artifacts?per_page=10");
|
|
expect(resolveStep?.run).not.toContain("--paginate");
|
|
expect(downloadStep).toMatchObject({
|
|
if: "steps.declaration_cache.outputs.artifact_id != ''",
|
|
uses: DOWNLOAD_ARTIFACT_V8,
|
|
with: {
|
|
path: ".artifacts/build-all-cache",
|
|
repository: "${{ github.repository }}",
|
|
},
|
|
});
|
|
expect(uploadStep).toMatchObject({
|
|
if: "success() && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'",
|
|
uses: UPLOAD_ARTIFACT_V7,
|
|
with: {
|
|
"if-no-files-found": "error",
|
|
"include-hidden-files": true,
|
|
overwrite: true,
|
|
path: ".artifacts/build-all-cache",
|
|
"retention-days": 14,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("hashes transform inputs once per enabled setup and never for skipped caches", () => {
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const transformSteps = (action.runs.steps as WorkflowStep[]).filter((step) =>
|
|
step.name?.includes("Vitest transform cache"),
|
|
);
|
|
const output = path.join(tempDirs.make("openclaw-transform-generation-"), "output");
|
|
for (const os of ["Linux", "macOS", "Windows"]) {
|
|
for (const mode of ["off", "restore", "read-write"]) {
|
|
for (const flags of [
|
|
["false", "false"],
|
|
["true", "false"],
|
|
["false", "true"],
|
|
["true", "true"],
|
|
]) {
|
|
for (const generation of ["a".repeat(64), "b".repeat(64)]) {
|
|
const hashes: string[][] = [];
|
|
const steps: Record<string, { outputs: Record<string, string> }> = {};
|
|
const context = {
|
|
github: { repository: "openclaw/openclaw", run_id: 10, run_attempt: 2 },
|
|
inputs: {
|
|
"cache-mode": mode,
|
|
"vitest-fs-cache": flags[0],
|
|
"restore-test-caches": flags[1],
|
|
"node-version": "24.x",
|
|
},
|
|
runner: { os, arch: "X64" },
|
|
steps,
|
|
hashFiles: (...patterns: string[]) => {
|
|
hashes.push(patterns);
|
|
return generation;
|
|
},
|
|
};
|
|
const evaluate = (expression: string): unknown =>
|
|
runInNewContext(
|
|
expression.replace(/(inputs|steps)\.([a-z-]+)/gu, '$1["$2"]'),
|
|
context,
|
|
);
|
|
const render = (value: unknown) =>
|
|
String(value).replace(/\$\{\{([\s\S]*?)\}\}/gu, (_, expression: string) => {
|
|
const result = evaluate(expression);
|
|
if (result == null) {
|
|
return "";
|
|
}
|
|
if (
|
|
typeof result === "string" ||
|
|
typeof result === "number" ||
|
|
typeof result === "boolean"
|
|
) {
|
|
return String(result);
|
|
}
|
|
throw new TypeError(`non-scalar workflow interpolation: ${expression}`);
|
|
});
|
|
let cacheInputs: Record<string, string> | undefined;
|
|
let configuredGeneration: string | undefined;
|
|
let configuredRestored: string | undefined;
|
|
for (const step of transformSteps) {
|
|
// Runner v2.336.0 evaluates embedded env before if; run/with inputs
|
|
// are evaluated only after admission (CompositeActionHandler/ActionRunner).
|
|
const env = Object.fromEntries(
|
|
Object.entries(step.env ?? {}).map(([key, value]) => [key, render(value)]),
|
|
);
|
|
if (!evaluate(step.if ?? "true")) {
|
|
if (step.id) {
|
|
steps[step.id] = { outputs: {} };
|
|
}
|
|
continue;
|
|
}
|
|
if (step.name === "Resolve Vitest transform cache generation") {
|
|
writeFileSync(output, "");
|
|
execFileSync("bash", ["-e", "-c", render(step.run)], {
|
|
env: { ...process.env, GITHUB_OUTPUT: output },
|
|
});
|
|
steps[expectDefined(step.id, "transform generation step id")] = {
|
|
outputs: Object.fromEntries(
|
|
readFileSync(output, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => line.split("=")),
|
|
),
|
|
};
|
|
} else if (step.uses) {
|
|
cacheInputs = Object.fromEntries(
|
|
Object.entries(step.with ?? {}).map(([key, value]) => [key, render(value)]),
|
|
);
|
|
// Exercise misses and prefix hits; neither reports cache-hit=true.
|
|
steps[expectDefined(step.id, "transform restore step id")] = {
|
|
outputs: {
|
|
"cache-hit": generation === "a".repeat(64) ? "" : "false",
|
|
"cache-matched-key":
|
|
generation === "a".repeat(64)
|
|
? ""
|
|
: `${expectDefined(cacheInputs["restore-keys"], "transform restore prefix").trim()}9-1`,
|
|
},
|
|
};
|
|
} else {
|
|
configuredGeneration = env.CACHE_GENERATION;
|
|
configuredRestored = env.CACHE_RESTORED;
|
|
}
|
|
}
|
|
const enabled = os !== "Windows" && mode !== "off" && flags.includes("true");
|
|
expect(hashes, JSON.stringify({ os, mode, flags, generation })).toHaveLength(
|
|
enabled ? 1 : 0,
|
|
);
|
|
if (enabled) {
|
|
expect(hashes[0]).toEqual([
|
|
"pnpm-lock.yaml",
|
|
"pnpm-workspace.yaml",
|
|
"**/package.json",
|
|
"**/tsconfig*.json",
|
|
"vitest.config.*",
|
|
"test/vitest/**",
|
|
"src/state/*.sql",
|
|
"!**/node_modules/**",
|
|
"!.ci-harness/**",
|
|
]);
|
|
const prefix = `openclaw/openclaw-vitest-fs-v4-protected-${os}-X64-node-24.x-${generation}-`;
|
|
expect(cacheInputs).toEqual({
|
|
path: "/var/tmp/openclaw-vitest-fs-cache",
|
|
key: `${prefix}10-2`,
|
|
"restore-keys": `${prefix}\n`,
|
|
});
|
|
expect(configuredGeneration).toBe(generation);
|
|
expect(configuredRestored).toBe(generation === "a".repeat(64) ? "false" : "true");
|
|
} else {
|
|
expect(cacheInputs).toBeUndefined();
|
|
expect(configuredGeneration).toBeUndefined();
|
|
expect(configuredRestored).toBeUndefined();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("shares transform generations with the warmer after CI exports its harness", () => {
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const generationStep = (action.runs.steps as WorkflowStep[]).find(
|
|
(step) => step.name === "Resolve Vitest transform cache generation",
|
|
);
|
|
const expression = expectDefined(
|
|
generationStep?.run?.match(/\$\{\{(.*?)\}\}/u)?.[1],
|
|
"transform generation expression",
|
|
);
|
|
const sourceFiles = {
|
|
"pnpm-lock.yaml": "lockfileVersion: '9.0'",
|
|
"pnpm-workspace.yaml": "packages: ['packages/*']",
|
|
"package.json": '{"name":"fixture"}',
|
|
"packages/worker/package.json": '{"name":"worker"}',
|
|
"packages/worker/tsconfig.json": "{}",
|
|
"vitest.config.ts": "export default {}",
|
|
"test/vitest/shared.ts": "export const shared = {}",
|
|
"src/state/schema.sql": "CREATE TABLE fixture (id TEXT);",
|
|
".github/actions/setup-security-review/package.json": '{"name":"review"}',
|
|
".ci-harness-source/package.json": '{"name":"real-source"}',
|
|
};
|
|
const files: Record<string, string> = { ...sourceFiles };
|
|
const fingerprint = () =>
|
|
runInNewContext(expression, {
|
|
hashFiles: (...patterns: string[]) => {
|
|
const includes = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
const excludes = patterns
|
|
.filter((pattern) => pattern.startsWith("!"))
|
|
.map((pattern) => pattern.slice(1));
|
|
const hash = createHash("sha256");
|
|
for (const [file, contents] of Object.entries(files).toSorted(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
)) {
|
|
if (
|
|
includes.some((pattern) => minimatch(file, pattern, { dot: true })) &&
|
|
!excludes.some((pattern) => minimatch(file, pattern, { dot: true }))
|
|
) {
|
|
hash.update(createHash("sha256").update(contents).digest());
|
|
}
|
|
}
|
|
return hash.digest("hex");
|
|
},
|
|
});
|
|
const warmer = fingerprint();
|
|
files[".ci-harness/.github/actions/setup-security-review/package.json"] =
|
|
sourceFiles[".github/actions/setup-security-review/package.json"];
|
|
files[".ci-harness/tsconfig.json"] = "{}";
|
|
files["node_modules/dependency/package.json"] = '{"name":"dependency"}';
|
|
expect(fingerprint()).toBe(warmer);
|
|
for (const [file, contents] of Object.entries(sourceFiles)) {
|
|
files[file] = `${contents}\n`;
|
|
expect(fingerprint(), file).not.toBe(warmer);
|
|
files[file] = contents;
|
|
}
|
|
});
|
|
|
|
it("persists isolated transform and compile caches through immutable protected archives", () => {
|
|
const workflow = readCiWorkflow();
|
|
const nodeTestJob = workflow.jobs["checks-node-core-test-nondist-shard"];
|
|
const setupNodeStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const readerStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore Vitest transform cache",
|
|
);
|
|
const configureStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Configure Vitest transform cache",
|
|
);
|
|
const compileReaderStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore Node compile cache",
|
|
);
|
|
const compileConfigureStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Configure Node compile cache",
|
|
);
|
|
const buildSetupNodeStep = workflow.jobs["build-artifacts"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
const hostedTestCacheInput =
|
|
"${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }}";
|
|
const hostedTestCacheJobs = [
|
|
"checks-ui",
|
|
"checks-ui-e2e",
|
|
"checks-fast-plugin-contracts-shard",
|
|
"checks-fast-channel-contracts-shard",
|
|
];
|
|
const hostedFastCoreTestCacheInput =
|
|
"${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (matrix.task == 'bundled-protocol' || matrix.task == 'contracts-plugins-ci-routing' || matrix.task == 'ci-routing' || matrix.task == 'bun-launcher') && 'true' || 'false' }}";
|
|
|
|
expect(setupNodeStep.with).toMatchObject({
|
|
"cache-mode": "${{ needs.preflight.outputs.cache_mode }}",
|
|
"node-compile-cache": "true",
|
|
"vitest-fs-cache": "true",
|
|
});
|
|
expect(setupNodeStep.with).not.toHaveProperty("save-node-compile-cache");
|
|
expect(setupNodeStep.with).not.toHaveProperty("runtime-cache-sticky-disk");
|
|
expect(action.inputs).not.toHaveProperty("runtime-cache-sticky-disk");
|
|
expect(action.inputs["vitest-fs-cache"].default).toBe("false");
|
|
expect(action.inputs["restore-test-caches"].default).toBe("false");
|
|
expect(action.inputs).not.toHaveProperty("save-vitest-fs-cache");
|
|
expect(action.inputs["node-compile-cache"].default).toBe("false");
|
|
expect(action.inputs).not.toHaveProperty("node-compile-cache-scope");
|
|
expect(action.inputs).not.toHaveProperty("save-node-compile-cache");
|
|
expect(
|
|
action.runs.steps.some((step: WorkflowStep) =>
|
|
step.name?.includes("transform cache sticky disk"),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
action.runs.steps.some((step: WorkflowStep) =>
|
|
step.name?.includes("compile cache sticky disk"),
|
|
),
|
|
).toBe(false);
|
|
expect(readerStep.uses).toBe(CACHE_V5);
|
|
expect(readerStep.if).toContain("inputs.cache-mode != 'off'");
|
|
expect(readerStep.if).toContain("inputs.restore-test-caches == 'true'");
|
|
expect(readerStep.if).toContain("runner.os != 'Windows'");
|
|
expect(readerStep.if).not.toMatch(/runner\.(?:environment|labels|name)/u);
|
|
expect(readerStep.with.key).toContain("vitest-fs-v4-protected-");
|
|
expect(readerStep.with.key).toContain("github.run_id");
|
|
expect(readerStep.with.key).toContain("github.run_attempt");
|
|
expect(configureStep.if).toContain("inputs.restore-test-caches == 'true'");
|
|
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_ROOT=$cache_root");
|
|
expect(configureStep.run).toContain(".openclaw-transform-generation");
|
|
expect(configureStep.run).not.toContain("protected Vitest transform seed");
|
|
expect(configureStep.env.CACHE_WRITER).toBe("0");
|
|
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=");
|
|
expect(compileReaderStep.with.key).toContain("node-compile-v3-test-protected-");
|
|
expect(compileReaderStep.with.key).toContain("github.run_id");
|
|
expect(compileReaderStep.with.key).toContain("github.run_attempt");
|
|
expect(compileReaderStep.with.key).not.toContain("pull_request");
|
|
expect(compileReaderStep.if).toContain("inputs.cache-mode != 'off'");
|
|
expect(compileReaderStep.if).toContain("inputs.restore-test-caches == 'true'");
|
|
expect(compileConfigureStep.if).toContain("inputs.restore-test-caches == 'true'");
|
|
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE=$cache_root");
|
|
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE_PORTABLE=1");
|
|
expect(compileConfigureStep.run).toContain("OPENCLAW_NODE_COMPILE_CACHE_WRITER=0");
|
|
expect(buildSetupNodeStep.with).toMatchObject({
|
|
"cache-mode": "${{ needs.preflight.outputs.cache_mode }}",
|
|
"node-compile-cache": "true",
|
|
"build-all-cache-scope": "full",
|
|
});
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
for (const job of [...Object.values(workflow.jobs), ...Object.values(warmer.jobs)]) {
|
|
for (const step of (job as { steps?: WorkflowStep[] }).steps ?? []) {
|
|
if (step.uses?.endsWith("/.github/actions/setup-node-env")) {
|
|
expect(step.with, step.name).not.toHaveProperty("node-compile-cache-scope");
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const jobName of hostedTestCacheJobs) {
|
|
const setup = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
expect(setup.with["restore-test-caches"], jobName).toBe(hostedTestCacheInput);
|
|
expect(
|
|
evaluateWorkflowExpression(setup.with["restore-test-caches"], {
|
|
eventName: "push",
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "github",
|
|
runAttempt: 1,
|
|
}),
|
|
jobName,
|
|
).toBe("true");
|
|
expect(
|
|
evaluateWorkflowExpression(setup.with["restore-test-caches"], {
|
|
eventName: "push",
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "blacksmith",
|
|
runAttempt: 1,
|
|
}),
|
|
jobName,
|
|
).toBe("false");
|
|
expect(setup.with, jobName).not.toHaveProperty("save-node-compile-cache");
|
|
expect(setup.with, jobName).not.toHaveProperty("save-vitest-fs-cache");
|
|
}
|
|
const fastCoreSetup = workflow.jobs["checks-fast-core"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
expect(fastCoreSetup.with["restore-test-caches"]).toBe(hostedFastCoreTestCacheInput);
|
|
for (const task of [
|
|
"bundled-protocol",
|
|
"contracts-plugins-ci-routing",
|
|
"ci-routing",
|
|
"bun-launcher",
|
|
]) {
|
|
expect(
|
|
evaluateWorkflowExpression(fastCoreSetup.with["restore-test-caches"], {
|
|
eventName: "push",
|
|
matrix: { task },
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "github",
|
|
runAttempt: 1,
|
|
}),
|
|
task,
|
|
).toBe("true");
|
|
}
|
|
for (const task of ["startup-corpus", "coercion-helpers"]) {
|
|
expect(
|
|
evaluateWorkflowExpression(fastCoreSetup.with["restore-test-caches"], {
|
|
eventName: "push",
|
|
matrix: { task },
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "github",
|
|
runAttempt: 1,
|
|
}),
|
|
task,
|
|
).toBe("false");
|
|
}
|
|
expect(
|
|
evaluateWorkflowExpression(fastCoreSetup.with["restore-test-caches"], {
|
|
eventName: "push",
|
|
matrix: { task: "bundled-protocol" },
|
|
repository: "openclaw/openclaw",
|
|
runnerBackend: "blacksmith",
|
|
runAttempt: 1,
|
|
}),
|
|
).toBe("false");
|
|
expect(fastCoreSetup.with).not.toHaveProperty("save-node-compile-cache");
|
|
expect(fastCoreSetup.with).not.toHaveProperty("save-vitest-fs-cache");
|
|
|
|
for (const jobName of ["checks-ui-e2e-real-gateway", "native-i18n", "control-ui-i18n"]) {
|
|
const setup = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
expect(setup.with, jobName).not.toHaveProperty("restore-test-caches");
|
|
}
|
|
});
|
|
|
|
it("publishes dependencies independently of long backend-local code warming", () => {
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
expect(warmer).not.toHaveProperty("concurrency");
|
|
expect(warmer.on.push["paths-ignore"]).toEqual(readCiWorkflow().on.push["paths-ignore"]);
|
|
const dependencies = warmer.jobs.dependencies;
|
|
const code = warmer.jobs.warm;
|
|
expect(dependencies.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(code.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(code.concurrency.group).not.toBe(dependencies.concurrency.group);
|
|
for (const job of [dependencies, code]) {
|
|
expect(job).not.toHaveProperty("needs");
|
|
expect(job.concurrency.group).toContain("matrix.platform");
|
|
expect(job.concurrency.group).toContain("github.ref");
|
|
for (const repository of ["openclaw/openclaw", "example/fork"]) {
|
|
for (const ref of ["refs/heads/main", "refs/heads/feature"]) {
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
eventName: "workflow_dispatch",
|
|
repository,
|
|
ref,
|
|
runAttempt: 1,
|
|
}),
|
|
).toBe(repository === "openclaw/openclaw" && ref === "refs/heads/main");
|
|
}
|
|
}
|
|
const hosted = { eventName: "push" as const, repository: "openclaw/openclaw", runAttempt: 1 };
|
|
expect(
|
|
evaluateWorkflowExpression(job.concurrency.group, {
|
|
...hosted,
|
|
runnerBackend: "hybrid",
|
|
matrix: { platform: "linux-hosted" },
|
|
}),
|
|
).toBe(
|
|
evaluateWorkflowExpression(job.concurrency.group, {
|
|
...hosted,
|
|
runnerBackend: "github",
|
|
matrix: { platform: "linux" },
|
|
}),
|
|
);
|
|
}
|
|
const dependencySaveNames = [
|
|
"Save Node toolchain cache",
|
|
"Save exact dependency cache",
|
|
"Save pnpm store cache",
|
|
];
|
|
expect(
|
|
dependencies.steps
|
|
.filter((step: WorkflowStep) => step.uses?.startsWith("actions/cache/save@"))
|
|
.map((step: WorkflowStep) => step.name),
|
|
).toEqual(dependencySaveNames);
|
|
expect(
|
|
code.steps.some((step: WorkflowStep) => dependencySaveNames.includes(step.name ?? "")),
|
|
).toBe(false);
|
|
const setup = dependencies.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
for (const key of [
|
|
"vitest-fs-cache",
|
|
"vitest-worker-cache",
|
|
"node-compile-cache",
|
|
"build-all-cache-scope",
|
|
]) {
|
|
expect(setup.with).not.toHaveProperty(key);
|
|
}
|
|
for (const save of dependencies.steps.filter((step: WorkflowStep) =>
|
|
dependencySaveNames.includes(step.name ?? ""),
|
|
)) {
|
|
expect(save.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
expect(save.if).not.toMatch(/\b(?:always|failure|cancelled)\(/u);
|
|
expect(dependencies.steps.indexOf(save)).toBeGreaterThan(dependencies.steps.indexOf(setup));
|
|
}
|
|
});
|
|
|
|
it("warms protected caches without main-run cancellation", () => {
|
|
const warmerSource = readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8");
|
|
const warmer = parse(warmerSource);
|
|
const warmerSetup = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
const checkoutStep = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
const bunSetup = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup pinned Bun test runtime",
|
|
);
|
|
const warmStep = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Warm transform and compile caches",
|
|
);
|
|
const warmerSteps = warmer.jobs.warm.steps as WorkflowStep[];
|
|
const buildStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Warm build cache"),
|
|
"cache warm build",
|
|
);
|
|
const boundaryRestoreStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Restore native SDK boundary cache"),
|
|
"native SDK boundary cache restore",
|
|
);
|
|
const boundaryPrepareStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Prepare native SDK boundary cache"),
|
|
"native SDK boundary cache preparation",
|
|
);
|
|
const boundarySaveStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Save native SDK boundary cache"),
|
|
"native SDK boundary cache publication",
|
|
);
|
|
const boundaryCleanupStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Clear native SDK boundary output before build"),
|
|
"native SDK boundary output cleanup",
|
|
);
|
|
const warmAssertionStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Assert cache warming succeeded"),
|
|
"final cache warming assertion",
|
|
);
|
|
|
|
expect(warmer.jobs.warm.concurrency["cancel-in-progress"]).toBe(false);
|
|
// hosted-mode cache recovery needs a maintainer-operated fallback when the
|
|
// scheduled seed is missing or stale.
|
|
expect(warmer.on).toHaveProperty("workflow_dispatch");
|
|
expect(warmer.on.push.branches).toEqual(["main"]);
|
|
expect(warmer.on.repository_dispatch.types).toEqual(["vitest-cache-warm"]);
|
|
expect(warmer.jobs.warm.if).toContain("github.repository == 'openclaw/openclaw'");
|
|
expect(warmer.jobs.warm.strategy["fail-fast"]).toBe(false);
|
|
expect(warmer.on).not.toHaveProperty("pull_request");
|
|
expect(warmer.on).not.toHaveProperty("pull_request_target");
|
|
for (const eventName of ["push", "workflow_dispatch"] as const) {
|
|
for (const runnerBackend of ["blacksmith", "hybrid", "github"] as const) {
|
|
const configuredPlatforms = warmer.jobs.warm.strategy.matrix.platform;
|
|
const platforms =
|
|
typeof configuredPlatforms === "string"
|
|
? evaluateWorkflowExpression(configuredPlatforms, {
|
|
eventName,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend,
|
|
})
|
|
: configuredPlatforms;
|
|
expect(platforms).toEqual(
|
|
runnerBackend === "hybrid" ? ["linux", "linux-hosted"] : ["linux"],
|
|
);
|
|
for (const platform of platforms as string[]) {
|
|
const context = {
|
|
eventName,
|
|
matrix: { platform },
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend,
|
|
};
|
|
const full = platform === "linux";
|
|
const expectedRunner =
|
|
platform === "macos"
|
|
? "macos-15"
|
|
: platform === "linux-hosted" || runnerBackend === "github"
|
|
? "ubuntu-24.04"
|
|
: "blacksmith-8vcpu-ubuntu-2404";
|
|
expect(evaluateWorkflowExpression(warmer.jobs.warm["runs-on"], context)).toBe(
|
|
expectedRunner,
|
|
);
|
|
const setupInputs = Object.fromEntries(
|
|
Object.entries(warmerSetup.with).map(([key, value]) => [
|
|
key,
|
|
typeof value === "string" && value.startsWith("${{")
|
|
? evaluateWorkflowExpression(value, context)
|
|
: value,
|
|
]),
|
|
);
|
|
expect(setupInputs).toMatchObject({
|
|
"build-all-cache-scope": full ? "full" : "",
|
|
"cache-mode": "read-write",
|
|
"dependency-cache": String(full),
|
|
"install-bun": "false",
|
|
"node-compile-cache": "true",
|
|
"vitest-fs-cache": "true",
|
|
"vitest-worker-cache": String(full),
|
|
});
|
|
for (const step of [buildStep, boundaryCleanupStep]) {
|
|
expect(evaluateWorkflowExpression(step.if, context), step.name).toBe(full);
|
|
}
|
|
for (const step of [boundaryPrepareStep, bunSetup, warmStep]) {
|
|
expect(step.if, step.name).toBeUndefined();
|
|
}
|
|
expect(evaluateWorkflowExpression(warmAssertionStep.if, context)).toBe(true);
|
|
expect(evaluateWorkflowExpression(warmStep.env.CACHE_WARM_PLATFORM, context)).toBe(
|
|
platform,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(bunSetup.uses).toBe("./.github/actions/setup-test-bun");
|
|
expect(warmerSteps.indexOf(bunSetup)).toBeLessThan(warmerSteps.indexOf(warmStep));
|
|
expect(
|
|
warmer.jobs.dependencies.steps.some(
|
|
(step: WorkflowStep) => step.uses === "./.github/actions/setup-test-bun",
|
|
),
|
|
).toBe(false);
|
|
for (const platform of ["linux", "linux-hosted"]) {
|
|
const invocation = runWorkflowShellScript(
|
|
`node() { printf '%s\\n' "$*"; return 23; }\n${warmStep.run}`,
|
|
{ env: { ...process.env, CACHE_WARM_PLATFORM: platform } },
|
|
);
|
|
expect(invocation.stdout.trim(), invocation.stderr).toBe(
|
|
"--import tsx scripts/ci-warm-vitest-caches.mts",
|
|
);
|
|
expect(invocation.status, invocation.stderr).toBe(23);
|
|
}
|
|
expect(warmer.on).not.toHaveProperty("workflow_run");
|
|
expect(checkoutStep.with).toBeUndefined();
|
|
expect(warmer.on.schedule).toEqual([{ cron: "17 * * * *" }]);
|
|
expect(warmerSource).not.toContain("OPENCLAW_NODE_TEST_CONFIGS_JSON");
|
|
expect(warmStep.id).toBe("warm-caches");
|
|
expect(warmStep["continue-on-error"]).toBe(true);
|
|
expect(warmStep.env).toMatchObject({
|
|
OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER: "1",
|
|
OPENCLAW_NODE_COMPILE_CACHE_WRITER: "1",
|
|
});
|
|
expect(warmerSetup["continue-on-error"]).not.toBe(true);
|
|
for (const legacyInput of [
|
|
"save-actions-cache",
|
|
"save-dependency-cache",
|
|
"save-node-compile-cache",
|
|
"save-vitest-fs-cache",
|
|
"use-actions-cache",
|
|
]) {
|
|
expect(warmerSetup.with).not.toHaveProperty(legacyInput);
|
|
}
|
|
const saveSteps = warmerSteps.filter((step) => step.uses?.startsWith("actions/cache/save@"));
|
|
expect(saveSteps.map((step) => step.name)).toEqual([
|
|
"Save compiled Vitest workers",
|
|
"Save native SDK boundary cache",
|
|
"Save build-all cache",
|
|
"Save dist build cache",
|
|
"Save Vitest transform cache",
|
|
"Save Node compile cache",
|
|
]);
|
|
for (const saveStep of saveSteps) {
|
|
expect(saveStep.if, saveStep.name).toContain(
|
|
"steps.setup-node-env.outputs.cache-mode == 'read-write'",
|
|
);
|
|
expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeGreaterThan(
|
|
warmerSteps.indexOf(warmerSetup),
|
|
);
|
|
if (saveStep.name === "Save compiled Vitest workers") {
|
|
expect(warmerSteps.indexOf(saveStep)).toBeLessThan(
|
|
warmerSteps.indexOf(boundaryPrepareStep),
|
|
);
|
|
expect(saveStep.if).not.toMatch(/always\(|failure\(|cancelled\(/u);
|
|
} else if (
|
|
saveStep.name === "Save build-all cache" ||
|
|
saveStep.name === "Save dist build cache"
|
|
) {
|
|
expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeGreaterThan(
|
|
warmerSteps.findIndex((step) => step.name === "Warm build cache"),
|
|
);
|
|
expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeLessThan(
|
|
warmerSteps.indexOf(warmStep),
|
|
);
|
|
expect(saveStep.if).not.toMatch(/always\(|failure\(/u);
|
|
} else if (saveStep.name === "Save native SDK boundary cache") {
|
|
expect(saveStep.if).toContain(
|
|
"steps.extension-package-boundary-cache.outputs.cache-hit != 'true'",
|
|
);
|
|
expect(saveStep.if).not.toMatch(/always\(|failure\(|cancelled\(/u);
|
|
} else {
|
|
expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeGreaterThan(
|
|
warmerSteps.indexOf(warmStep),
|
|
);
|
|
}
|
|
expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeLessThan(
|
|
warmerSteps.indexOf(warmAssertionStep),
|
|
);
|
|
}
|
|
expect(warmAssertionStep.if).toBe("${{ always() }}");
|
|
expect(warmAssertionStep.run).toContain("steps.warm-caches.outcome");
|
|
expect(warmAssertionStep.run).toContain("exit 1");
|
|
expect(warmerSteps.at(-1)).toBe(warmAssertionStep);
|
|
// No close-time cleanup workflow is needed; Actions cache LRU/TTL expires
|
|
// old hosted-writer and warmer generations.
|
|
expect(existsSync(".github/workflows/pr-cache-cleanup.yml")).toBe(false);
|
|
expect(warmStep.if).toBeUndefined();
|
|
const distSave = expectDefined(
|
|
saveSteps.find((step) => step.name === "Save dist build cache"),
|
|
"Linux dist publication",
|
|
);
|
|
expect(distSave.if).toBe(
|
|
"${{ matrix.platform == 'linux' && steps.setup-node-env.outputs.cache-mode == 'read-write' }}",
|
|
);
|
|
expect(boundaryRestoreStep.uses).toBe(CACHE_V5);
|
|
expect(boundarySaveStep.uses).toBe(CACHE_SAVE_V5);
|
|
const boundaryRestoreInputs = expectDefined(
|
|
boundaryRestoreStep.with,
|
|
"native SDK boundary cache inputs",
|
|
);
|
|
expect(boundaryRestoreInputs.key).toBe(
|
|
"${{ runner.os }}-extension-package-boundary-v4-${{ github.sha }}",
|
|
);
|
|
expect(boundarySaveStep.with).toEqual({
|
|
path: boundaryRestoreInputs.path,
|
|
key: boundaryRestoreInputs.key,
|
|
});
|
|
expect(boundaryRestoreInputs["restore-keys"]).toBe(
|
|
"${{ runner.os }}-extension-package-boundary-v4-\n",
|
|
);
|
|
expect(boundaryPrepareStep.run).toBe(
|
|
"node --import ./scripts/tsx.mjs scripts/prepare-extension-package-boundary-artifacts.mts --mode=package-boundary",
|
|
);
|
|
expect(boundaryPrepareStep["continue-on-error"]).not.toBe(true);
|
|
expect(warmerSteps.indexOf(boundaryRestoreStep)).toBeLessThan(warmerSteps.indexOf(buildStep));
|
|
expect(warmerSteps.indexOf(boundaryPrepareStep)).toBeGreaterThan(
|
|
warmerSteps.indexOf(boundaryRestoreStep),
|
|
);
|
|
expect(warmerSteps.indexOf(boundarySaveStep)).toBeGreaterThan(
|
|
warmerSteps.indexOf(boundaryPrepareStep),
|
|
);
|
|
expect(warmerSteps.indexOf(boundarySaveStep)).toBeLessThan(warmerSteps.indexOf(buildStep));
|
|
expect(warmerSteps.indexOf(boundaryCleanupStep)).toBeGreaterThan(
|
|
warmerSteps.indexOf(boundarySaveStep),
|
|
);
|
|
expect(warmerSteps.indexOf(boundaryCleanupStep)).toBeLessThan(warmerSteps.indexOf(buildStep));
|
|
const cleanupRoot = tempDirs.make("openclaw-native-sdk-cleanup-");
|
|
const sdkOutput = path.join(cleanupRoot, "packages/plugin-sdk/dist/native.d.ts");
|
|
const sdkSource = path.join(cleanupRoot, "packages/plugin-sdk/src/core.ts");
|
|
const siblingOutput = path.join(cleanupRoot, "packages/normalization-core/dist/index.js");
|
|
const boundaryReceipt = path.join(
|
|
cleanupRoot,
|
|
".artifacts/extension-package-boundary/plugin-sdk.json",
|
|
);
|
|
for (const file of [sdkOutput, sdkSource, siblingOutput, boundaryReceipt]) {
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, "sentinel\n");
|
|
}
|
|
const cleanupResult = runWorkflowShellScript(
|
|
expectDefined(boundaryCleanupStep.run, "cleanup"),
|
|
{
|
|
cwd: cleanupRoot,
|
|
env: process.env,
|
|
},
|
|
);
|
|
expect(cleanupResult.status, `${cleanupResult.stdout}${cleanupResult.stderr}`).toBe(0);
|
|
expect(existsSync(sdkOutput)).toBe(false);
|
|
expect(existsSync(sdkSource)).toBe(true);
|
|
expect(existsSync(siblingOutput)).toBe(true);
|
|
expect(existsSync(boundaryReceipt)).toBe(true);
|
|
const storeSave = expectDefined(
|
|
warmer.jobs.dependencies.steps.find(
|
|
(step: WorkflowStep) => step.name === "Save pnpm store cache",
|
|
),
|
|
"platform pnpm store publication",
|
|
);
|
|
expect(storeSave.if).not.toContain("matrix.platform");
|
|
expect(storeSave.if).toContain("steps.setup-node-env.outputs.pnpm-store-cache-hit != 'true'");
|
|
expect(storeSave.if).not.toMatch(/\b(?:always|failure|cancelled)\(/u);
|
|
expect(storeSave.with).toEqual({
|
|
path: "${{ steps.setup-node-env.outputs.pnpm-store-cache-path }}",
|
|
key: "${{ steps.setup-node-env.outputs.pnpm-store-cache-key }}",
|
|
});
|
|
});
|
|
|
|
it("publishes a portable release npm seed without hooks or push-time downloads", () => {
|
|
const warmer = parse(readFileSync(".github/workflows/release-npm-cache-warm.yml", "utf8"));
|
|
expect(warmer.on).not.toHaveProperty("push");
|
|
expect(warmer.on).toHaveProperty("schedule");
|
|
expect(warmer.on).toHaveProperty("workflow_dispatch");
|
|
expect(warmer.concurrency.group).not.toBe(
|
|
parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8")).jobs.warm.concurrency
|
|
.group,
|
|
);
|
|
const seed = warmer.jobs["warm-release-npm"];
|
|
for (const repository of ["openclaw/openclaw", "example/fork"]) {
|
|
for (const eventName of [
|
|
"push",
|
|
"pull_request",
|
|
"repository_dispatch",
|
|
"schedule",
|
|
"workflow_dispatch",
|
|
] as const) {
|
|
expect(evaluateWorkflowExpression(seed.if, { repository, eventName, runAttempt: 1 })).toBe(
|
|
repository === "openclaw/openclaw" &&
|
|
(eventName === "schedule" || eventName === "workflow_dispatch"),
|
|
);
|
|
}
|
|
}
|
|
expect(evaluateWorkflowRunner(seed["runs-on"])).toBe("ubuntu-24.04");
|
|
const steps = seed.steps as WorkflowStep[];
|
|
const install = expectDefined(
|
|
steps.find((entry) => entry.run),
|
|
"npm seed install",
|
|
);
|
|
expect(install.run).toContain("openclaw@latest --ignore-scripts --omit=dev");
|
|
expect(install.env).toEqual({
|
|
NPM_CONFIG_CACHE: "${{ github.workspace }}/.cache/openclaw-cross-os-npm-cache",
|
|
});
|
|
const save = expectDefined(
|
|
steps.find((entry) => entry.uses?.startsWith("actions/cache/save@")),
|
|
"npm seed publication",
|
|
);
|
|
expect(save.with).toMatchObject({
|
|
path: ".cache/openclaw-cross-os-npm-cache/_cacache",
|
|
enableCrossOsArchive: true,
|
|
});
|
|
expect(save.with?.key).toMatch(/^openclaw-cross-os-npm-v1-seed-/u);
|
|
expect(save["continue-on-error"]).toBe(true);
|
|
expect(save.if ?? "").not.toMatch(/always\(|failure\(|cancelled\(/u);
|
|
expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(install));
|
|
expect(steps.some((entry) => entry.uses?.startsWith("actions/cache/restore@"))).toBe(false);
|
|
});
|
|
|
|
it("keeps the Gradle sticky disk on O(1) per-task protected keys", () => {
|
|
const workflow = readCiWorkflow();
|
|
const androidSteps = workflow.jobs.android.steps as WorkflowStep[];
|
|
const mountWith = expectDefined(
|
|
androidSteps.find((step) => step.name === "Mount Gradle sticky disk")?.with,
|
|
"Gradle sticky mount step",
|
|
);
|
|
const pointStep = expectDefined(
|
|
androidSteps.find((step) => step.name === "Point Gradle at the sticky disk"),
|
|
"Gradle sticky point step",
|
|
);
|
|
|
|
// Task scope stays in the key (a light task like ktlint must never seed
|
|
// heavy build lanes), but PR number and dependency hash must not: those
|
|
// minted a backing disk per PR/bump until Blacksmith's installation-wide
|
|
// budget 429-failed every mount fleet-wide.
|
|
expect(mountWith.key).toBe("${{ github.repository }}-gradle-v2-${{ matrix.task }}");
|
|
expect(androidSteps.find((step) => step.name === "Mount Gradle sticky disk")?.if).toContain(
|
|
"vars.OPENCLAW_CI_RUNNER_BACKEND != 'github'",
|
|
);
|
|
expect(pointStep.if).toContain("vars.OPENCLAW_CI_RUNNER_BACKEND != 'github'");
|
|
// Single semantic writer: protected pushes commit explicitly (on-change's
|
|
// allocated-byte heuristic can miss a same-size refresh); PR clones stay read-only.
|
|
expect(mountWith.commit).toBe(
|
|
"${{ github.event_name != 'pull_request' && 'true' || 'false' }}",
|
|
);
|
|
// Gradle owns invalidation and expiry; dependency updates must retain reusable entries.
|
|
const stickyRoot = tempDirs.make("openclaw-gradle-sticky-");
|
|
const gradleHome = path.join(stickyRoot, "gradle-user-home");
|
|
const cachedDependency = path.join(gradleHome, "caches", "dependency.jar");
|
|
const githubEnv = path.join(stickyRoot, "github-env");
|
|
mkdirSync(path.dirname(cachedDependency), { recursive: true });
|
|
writeFileSync(cachedDependency, "cached dependency");
|
|
writeFileSync(path.join(stickyRoot, ".openclaw-gradle-deps-fingerprint"), "old-inputs\n");
|
|
const result = runWorkflowShellScript(
|
|
expectDefined(pointStep.run, "Gradle sticky home").replace(
|
|
"sticky_root=/var/tmp/openclaw-gradle",
|
|
`sticky_root=${quoteShell(stickyRoot)}`,
|
|
),
|
|
{
|
|
env: {
|
|
...process.env,
|
|
GITHUB_ENV: githubEnv,
|
|
GRADLE_DEPS_FINGERPRINT: "new-inputs",
|
|
STICKY_WRITER: "true",
|
|
},
|
|
},
|
|
);
|
|
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
|
|
expect(readFileSync(cachedDependency, "utf8")).toBe("cached dependency");
|
|
expect(readFileSync(githubEnv, "utf8")).toBe(`GRADLE_USER_HOME=${gradleHome}\n`);
|
|
});
|
|
|
|
it("caches Robolectric SDK artifacts for Android test tasks only", () => {
|
|
const workflowSource = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const androidSteps = readCiWorkflow().jobs.android.steps as WorkflowStep[];
|
|
const restoreIndex = androidSteps.findIndex(
|
|
(step) => step.name === "Restore Robolectric Maven cache",
|
|
);
|
|
const configureIndex = androidSteps.findIndex(
|
|
(step) => step.name === "Configure Robolectric Maven cache",
|
|
);
|
|
const runIndex = androidSteps.findIndex(
|
|
(step) => step.name === "Run Android ${{ matrix.task }}",
|
|
);
|
|
const saveIndex = androidSteps.findIndex(
|
|
(step) => step.name === "Save Robolectric Maven cache",
|
|
);
|
|
const restoreStep = expectDefined(androidSteps[restoreIndex], "Robolectric cache restore");
|
|
const configureStep = expectDefined(
|
|
androidSteps[configureIndex],
|
|
"Robolectric cache configuration",
|
|
);
|
|
const runStep = expectDefined(androidSteps[runIndex], "Android task runner");
|
|
const saveStep = expectDefined(androidSteps[saveIndex], "Robolectric cache save");
|
|
|
|
expect([restoreIndex, configureIndex, runIndex, saveIndex]).toEqual(
|
|
[restoreIndex, configureIndex, runIndex, saveIndex].toSorted((a, b) => a - b),
|
|
);
|
|
expect(restoreStep).toMatchObject({
|
|
id: "robolectric-cache",
|
|
if: "startsWith(matrix.task, 'test-') && needs.preflight.outputs.cache_mode != 'off'",
|
|
uses: CACHE_V5,
|
|
with: {
|
|
path: "/var/tmp/openclaw-robolectric-m2",
|
|
},
|
|
});
|
|
const cacheKey = String(restoreStep.with?.key);
|
|
expect(cacheKey).toContain("${{ github.repository }}-robolectric-m2-v1-");
|
|
expect(cacheKey).toContain("${{ runner.os }}-${{ runner.arch }}-${{ matrix.task }}-");
|
|
expect(cacheKey).toContain("apps/android/**/*.gradle*");
|
|
expect(cacheKey).toContain("apps/android/**/gradle-wrapper.properties");
|
|
expect(cacheKey).toContain("apps/android/gradle/libs.versions.toml");
|
|
expect(cacheKey).toContain("apps/android/**/src/test*/**");
|
|
for (const forbiddenDimension of [
|
|
"github.run_id",
|
|
"github.sha",
|
|
"github.ref",
|
|
"github.event.pull_request.number",
|
|
]) {
|
|
expect(cacheKey).not.toContain(forbiddenDimension);
|
|
}
|
|
expect(String(restoreStep.with?.["restore-keys"]).trim()).toBe(
|
|
"${{ github.repository }}-robolectric-m2-v1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.task }}-",
|
|
);
|
|
|
|
expect(configureStep.if).toBe("startsWith(matrix.task, 'test-')");
|
|
expect(configureStep.run).toContain("OPENCLAW_ROBOLECTRIC_M2");
|
|
expect(configureStep.run).toContain("OPENCLAW_ROBOLECTRIC_INIT");
|
|
expect(configureStep.run).toContain(
|
|
'systemProperty "maven.repo.local", System.getenv("OPENCLAW_ROBOLECTRIC_M2")',
|
|
);
|
|
expect(workflowSource).not.toContain("robolectric.dependency.repo.url");
|
|
|
|
expect(saveStep).toMatchObject({
|
|
if: "success() && startsWith(matrix.task, 'test-') && needs.preflight.outputs.cache_write_allowed == 'true' && steps.robolectric-cache.outputs.cache-hit != 'true'",
|
|
uses: CACHE_SAVE_V5,
|
|
with: {
|
|
key: "${{ steps.robolectric-cache.outputs.cache-primary-key }}",
|
|
path: "/var/tmp/openclaw-robolectric-m2",
|
|
},
|
|
});
|
|
|
|
const taskCases = new Map(
|
|
[...String(runStep.run).matchAll(/^\s{2}([a-z-]+)\)\n([\s\S]*?)^\s{4};;$/gmu)].map(
|
|
(match) => [match[1], match[2]],
|
|
),
|
|
);
|
|
for (const task of ["test-play", "test-play-compat", "test-third-party", "test-wear"]) {
|
|
expect(taskCases.get(task), task).toContain('--init-script "$OPENCLAW_ROBOLECTRIC_INIT"');
|
|
}
|
|
for (const task of ["build-play", "build-wear", "build-play-compat", "ktlint"]) {
|
|
expect(taskCases.get(task), task).not.toContain("--init-script");
|
|
}
|
|
expect(runStep.run).not.toMatch(/\bsleep\b/u);
|
|
expect(runStep.run).not.toMatch(/\bretry\b/iu);
|
|
});
|
|
|
|
it("retains Android test XML and JVM diagnostics after failures without collecting caches or canceled jobs", () => {
|
|
const steps = readCiWorkflow().jobs.android.steps as WorkflowStep[];
|
|
const runIndex = steps.findIndex((step) => step.name === "Run Android ${{ matrix.task }}");
|
|
const uploadIndex = steps.findIndex((step) => step.name === "Upload Android test reports");
|
|
const upload = expectDefined(steps[uploadIndex], "Android test reports");
|
|
expect(uploadIndex).toBeGreaterThan(runIndex);
|
|
expect(upload.with?.["retention-days"]).toBe(14);
|
|
// A status function prevents Actions' implicit success() from hiding failed-test evidence.
|
|
expect(upload.if).toMatch(/\b(?:always|cancelled|failure|success)\(\)/u);
|
|
for (const [task, failed, cancelled, expected] of [
|
|
["test-play", false, false, true],
|
|
["test-play", true, false, true],
|
|
["test-play-compat", true, false, true],
|
|
["test-third-party", true, false, true],
|
|
["test-wear", false, false, true],
|
|
["test-wear", true, true, false],
|
|
["test-play", false, true, false],
|
|
["build-play", false, false, false],
|
|
["build-wear", true, false, false],
|
|
["ktlint", false, false, false],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(upload.if, {
|
|
eventName: "push",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
matrix: { task },
|
|
failed,
|
|
cancelled,
|
|
}),
|
|
`${task}: failed=${failed}, cancelled=${cancelled}`,
|
|
).toBe(expected);
|
|
}
|
|
const root = tempDirs.make("openclaw-android-test-reports-");
|
|
const reports = [
|
|
"apps/android/app/build/test-results/testPlayDebugUnitTest/TEST-Play.xml",
|
|
"apps/android/app/build/test-results/testThirdPartyDebugUnitTest/TEST-ThirdParty.xml",
|
|
"apps/android/wear/build/test-results/testDebugUnitTest/TEST-Wear.xml",
|
|
"apps/android/wear-shared/build/test-results/testDebugUnitTest/TEST-Shared.xml",
|
|
"apps/android/hs_err_pid101.log",
|
|
"apps/android/replay_pid101.log",
|
|
"apps/android/app/hs_err_pid202.log",
|
|
"apps/android/app/replay_pid202.log",
|
|
"apps/android/wear/hs_err_pid303.log",
|
|
"apps/android/wear-shared/replay_pid404.log",
|
|
];
|
|
const unrelated = [
|
|
"apps/android/app/build/test-results/testPlayDebugUnitTest/binary/results.bin",
|
|
"apps/android/app/build/reports/lint-results-playDebug.xml",
|
|
"apps/android/app/build/outputs/apk/play/debug/app.apk",
|
|
"apps/android/benchmark/build/test-results/testDebugUnitTest/TEST-Benchmark.xml",
|
|
".gradle/caches/TEST-cached.xml",
|
|
".gradle/caches/hs_err_pid505.log",
|
|
"hs_err_pid606.log",
|
|
"replay_pid606.log",
|
|
"apps/android/app/core.202",
|
|
"apps/android/app/java_pid202.hprof",
|
|
];
|
|
for (const file of [...reports, ...unrelated]) {
|
|
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
|
writeFileSync(path.join(root, file), "synthetic report fixture");
|
|
}
|
|
const patterns = String(upload.with?.path).trim().split("\n");
|
|
expect(globSync(patterns, { cwd: root }).toSorted()).toEqual(reports.toSorted());
|
|
});
|
|
|
|
it("never keys a Blacksmith sticky disk by unbounded run dimensions", () => {
|
|
// Blacksmith caps backing disks per installation; per-PR, per-commit,
|
|
// per-run, or per-hash key segments mint disks until every mount 429s.
|
|
// Snapshot validity belongs in in-job fingerprints/markers, never the key.
|
|
const workflowFiles = readdirSync(".github/workflows")
|
|
.filter((name) => name.endsWith(".yml"))
|
|
.map((name) => `.github/workflows/${name}`);
|
|
const actionFiles = readdirSync(".github/actions").map(
|
|
(name) => `.github/actions/${name}/action.yml`,
|
|
);
|
|
const stickyKeys: Array<{ file: string; key: string }> = [];
|
|
for (const file of [...workflowFiles, ...actionFiles]) {
|
|
if (!existsSync(file)) {
|
|
continue;
|
|
}
|
|
const parsed = parse(readFileSync(file, "utf8"));
|
|
const jobs = parsed?.jobs ? Object.values(parsed.jobs) : [];
|
|
const stepLists = [
|
|
...jobs.map((job) => (job as { steps?: WorkflowStep[] }).steps ?? []),
|
|
(parsed?.runs?.steps ?? []) as WorkflowStep[],
|
|
];
|
|
for (const step of stepLists.flat()) {
|
|
if (typeof step?.uses !== "string" || !step.uses.startsWith("useblacksmith/stickydisk@")) {
|
|
continue;
|
|
}
|
|
const key = step.with?.key;
|
|
stickyKeys.push({ file, key: typeof key === "string" ? key : "" });
|
|
}
|
|
}
|
|
expect(stickyKeys.length).toBeGreaterThan(0);
|
|
for (const { file, key } of stickyKeys) {
|
|
expect(key, file).not.toContain("github.event.pull_request.number");
|
|
expect(key, file).not.toContain("github.sha");
|
|
expect(key, file).not.toContain("github.ref");
|
|
expect(key, file).not.toContain("github.run_");
|
|
expect(key, file).not.toContain("hashFiles(");
|
|
}
|
|
});
|
|
|
|
it("deletes only exact allowlisted retired sticky disks from protected main", () => {
|
|
const cleanupSource = readFileSync(".github/workflows/sticky-disk-cleanup.yml", "utf8");
|
|
const cleanup = parse(cleanupSource);
|
|
const job = cleanup.jobs.delete;
|
|
const checkoutStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout protected manifest",
|
|
);
|
|
const validateStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate exact retired key",
|
|
);
|
|
const deleteStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Delete retired sticky disk",
|
|
);
|
|
const retiredDisks = JSON.parse(
|
|
readFileSync(".github/retired-sticky-disks.json", "utf8"),
|
|
) as Array<{ architecture?: unknown; key?: unknown; region?: unknown }>;
|
|
|
|
expect(Array.isArray(retiredDisks)).toBe(true);
|
|
expect(
|
|
retiredDisks.every(
|
|
(disk) =>
|
|
typeof disk.key === "string" &&
|
|
disk.key.length > 0 &&
|
|
disk.key === disk.key.trim() &&
|
|
(disk.architecture === "amd64" || disk.architecture === "arm64") &&
|
|
typeof disk.region === "string" &&
|
|
disk.region.length > 0 &&
|
|
disk.region === disk.region.trim(),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
new Set(
|
|
retiredDisks.map(
|
|
(disk) => `${disk.key as string}:${disk.architecture as string}:${disk.region as string}`,
|
|
),
|
|
).size,
|
|
).toBe(retiredDisks.length);
|
|
expect(cleanup.on).toHaveProperty("workflow_dispatch");
|
|
expect(cleanup.permissions).toEqual({ contents: "read" });
|
|
expect(cleanup.concurrency).toEqual({
|
|
group: "sticky-disk-cleanup",
|
|
"cancel-in-progress": false,
|
|
});
|
|
expect(job.if).toContain("github.ref == 'refs/heads/main'");
|
|
expect(job.if).toContain("inputs.confirm");
|
|
expect(checkoutStep.with.ref).toBe("refs/heads/main");
|
|
expect(job["runs-on"]).toContain("inputs.architecture == 'arm64'");
|
|
expect(validateStep.env.RETIRED_ARCHITECTURE).toBe("${{ inputs.architecture }}");
|
|
expect(validateStep.env.RETIRED_KEY).toBe("${{ inputs.retired_key }}");
|
|
expect(validateStep.env.RETIRED_REGION).toBe("${{ inputs.region }}");
|
|
expect(validateStep.run).toContain('process.env.BLACKSMITH_ENV?.includes("arm")');
|
|
expect(validateStep.run).toContain("requestedRegion !== process.env.BLACKSMITH_REGION");
|
|
expect(validateStep.run).toContain("requestedKey !== requestedKey.trim()");
|
|
expect(validateStep.run).toContain("disk?.key === requestedKey");
|
|
const rejectedKey = runWorkflowShellScript(validateStep.run, {
|
|
env: {
|
|
...process.env,
|
|
BLACKSMITH_ENV: "production-amd64",
|
|
BLACKSMITH_REGION: "us-test-1",
|
|
RETIRED_ARCHITECTURE: "amd64",
|
|
RETIRED_KEY: "openclaw/openclaw-not-retired",
|
|
RETIRED_REGION: "us-test-1",
|
|
},
|
|
});
|
|
expect(rejectedKey.status).not.toBe(0);
|
|
expect(rejectedKey.stderr).toContain("identity is not allowlisted for retirement");
|
|
const paddedKey = runWorkflowShellScript(validateStep.run, {
|
|
env: {
|
|
...process.env,
|
|
BLACKSMITH_ENV: "production-amd64",
|
|
BLACKSMITH_REGION: "us-test-1",
|
|
RETIRED_ARCHITECTURE: "amd64",
|
|
RETIRED_KEY: " openclaw/openclaw-active-key ",
|
|
RETIRED_REGION: "us-test-1",
|
|
},
|
|
});
|
|
expect(paddedKey.status).not.toBe(0);
|
|
expect(paddedKey.stderr).toContain("key must be non-empty and canonical");
|
|
expect(deleteStep).toMatchObject({
|
|
uses: "useblacksmith/stickydisk-delete@3bd8d43f9da764c6b80c2cd6db129bdb568c79b6",
|
|
with: {
|
|
"delete-docker-cache": "false",
|
|
"delete-key": "${{ inputs.retired_key }}",
|
|
},
|
|
});
|
|
|
|
// A retired-key entry must never match any disk family still mounted by
|
|
// the repository. Expressions stand for one non-empty resolved segment.
|
|
const workflowFiles = readdirSync(".github/workflows")
|
|
.filter((name) => name.endsWith(".yml"))
|
|
.map((name) => `.github/workflows/${name}`);
|
|
const actionFiles = readdirSync(".github/actions").map(
|
|
(name) => `.github/actions/${name}/action.yml`,
|
|
);
|
|
const activeKeyPatterns: RegExp[] = [];
|
|
for (const file of [...workflowFiles, ...actionFiles]) {
|
|
if (!existsSync(file)) {
|
|
continue;
|
|
}
|
|
const parsed = parse(readFileSync(file, "utf8"));
|
|
const jobs = parsed?.jobs ? Object.values(parsed.jobs) : [];
|
|
const stepLists = [
|
|
...jobs.map((candidate) => (candidate as { steps?: WorkflowStep[] }).steps ?? []),
|
|
(parsed?.runs?.steps ?? []) as WorkflowStep[],
|
|
];
|
|
for (const step of stepLists.flat()) {
|
|
if (typeof step?.uses !== "string" || !step.uses.startsWith("useblacksmith/stickydisk@")) {
|
|
continue;
|
|
}
|
|
const key = step.with?.key;
|
|
if (typeof key !== "string") {
|
|
continue;
|
|
}
|
|
const escapedParts = key
|
|
.split(/\$\{\{[^}]+\}\}/u)
|
|
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"));
|
|
activeKeyPatterns.push(new RegExp(`^${escapedParts.join(".+")}$`, "u"));
|
|
}
|
|
}
|
|
for (const retiredDisk of retiredDisks) {
|
|
expect(
|
|
activeKeyPatterns.some((pattern) => pattern.test(retiredDisk.key as string)),
|
|
`${retiredDisk.key as string} is still an active sticky-disk key`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("runs all source checks serially and preserves individual failures", () => {
|
|
const additionalJob = readCiWorkflow().jobs["check-additional-shard"];
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
const sessionCommands = [
|
|
"lint:tmp:session-accessor-boundary",
|
|
"lint:tmp:sqlite-transaction-boundary",
|
|
"lint:tmp:session-transcript-reader-boundary",
|
|
];
|
|
const root = tempDirs.make("openclaw-session-boundary-workflow-");
|
|
const binDir = path.join(root, "bin");
|
|
const callsPath = path.join(root, "pnpm-calls.txt");
|
|
mkdirSync(binDir);
|
|
const pnpmPath = path.join(binDir, "pnpm");
|
|
writeFileSync(
|
|
pnpmPath,
|
|
'#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >> "$PNPM_CALLS"\nif [[ "${2:-}" == "${PNPM_FAIL:-}" ]]; then exit 1; fi\n',
|
|
"utf8",
|
|
);
|
|
chmodSync(pnpmPath, 0o755);
|
|
const exportScript = path.join(root, "scripts/check-export-name-collisions.mts");
|
|
mkdirSync(path.dirname(exportScript));
|
|
for (const [group, commands] of [
|
|
[
|
|
"source-contracts",
|
|
["lint:tmp:export-name-collisions", ...sessionCommands, "sqlite:sessions-schema:check"],
|
|
],
|
|
["session-accessor-boundary", sessionCommands],
|
|
["export-name-collisions", ["lint:tmp:export-name-collisions"]],
|
|
["sqlite-session-schema-baseline", ["sqlite:sessions-schema:check"]],
|
|
] as const) {
|
|
for (const scenario of [
|
|
{ failed: "", missing: "", missingFile: false },
|
|
...commands.map((failed) => ({ failed, missing: "", missingFile: false })),
|
|
...(group === "source-contracts"
|
|
? [
|
|
...commands.map((missing) => ({ failed: "", missing, missingFile: false })),
|
|
{ failed: "", missing: "", missingFile: true },
|
|
]
|
|
: []),
|
|
]) {
|
|
const present = commands.filter(
|
|
(command) =>
|
|
command !== scenario.missing &&
|
|
!(scenario.missingFile && command === "lint:tmp:export-name-collisions"),
|
|
);
|
|
if (scenario.missingFile) {
|
|
rmSync(exportScript, { force: true });
|
|
} else {
|
|
writeFileSync(exportScript, "");
|
|
}
|
|
writeFileSync(
|
|
path.join(root, "package.json"),
|
|
JSON.stringify({
|
|
scripts: Object.fromEntries(
|
|
commands
|
|
.filter((command) => command !== scenario.missing)
|
|
.map((command) => [command, "fixture"]),
|
|
),
|
|
}),
|
|
);
|
|
writeFileSync(callsPath, "");
|
|
const result = runWorkflowShellScript(runStep.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
ADDITIONAL_CHECK_GROUP: group,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
PNPM_CALLS: callsPath,
|
|
PNPM_FAIL: scenario.failed,
|
|
},
|
|
});
|
|
const context = `${group} ${JSON.stringify(scenario)}\n${result.stdout}${result.stderr}`;
|
|
expect(readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean), context).toEqual(
|
|
present.map((command) => `run ${command}`),
|
|
);
|
|
expect(result.status, context).toBe(scenario.failed ? 1 : 0);
|
|
expect(result.stdout.match(/^::error .+$/gmu) ?? [], context).toEqual(
|
|
scenario.failed
|
|
? [`::error title=${scenario.failed} failed::${scenario.failed} failed`]
|
|
: [],
|
|
);
|
|
for (const command of present.filter((entry) => entry !== scenario.failed)) {
|
|
expect(result.stdout, context).toContain(`[ok] ${command}`);
|
|
}
|
|
expect(result.stdout.match(/^\[skip\].+$/gmu) ?? [], context).toHaveLength(
|
|
scenario.missing || scenario.missingFile ? 1 : 0,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("uses the current SDK diff and preserves the historical baseline check", () => {
|
|
const workflow = readCiWorkflow();
|
|
const runStep = workflow.jobs["check-additional-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
const runCase = (
|
|
scripts: Record<string, string>,
|
|
compatibilityTarget: boolean,
|
|
eventName = "workflow_dispatch",
|
|
fail = false,
|
|
) => {
|
|
const root = tempDirs.make("openclaw-plugin-sdk-api-workflow-");
|
|
const binDir = path.join(root, "bin");
|
|
const callsPath = path.join(root, "pnpm-calls.txt");
|
|
const summaryPath = path.join(root, "summary.md");
|
|
mkdirSync(binDir);
|
|
writeFileSync(path.join(root, "package.json"), JSON.stringify({ scripts }), "utf8");
|
|
const pnpmPath = path.join(binDir, "pnpm");
|
|
writeFileSync(
|
|
pnpmPath,
|
|
'#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$*" >> "$PNPM_CALLS"\nexit "$PNPM_RESULT"\n',
|
|
"utf8",
|
|
);
|
|
chmodSync(pnpmPath, 0o755);
|
|
const script = runStep.run
|
|
.replaceAll("${{ needs.preflight.outputs.diff_base_revision }}", "base-sha")
|
|
.replaceAll("${{ needs.preflight.outputs.diff_head_revision }}", "synthetic-head-sha");
|
|
const result = runWorkflowShellScript(script, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
ADDITIONAL_CHECK_GROUP: "plugin-sdk-api-diff",
|
|
COMPATIBILITY_TARGET: compatibilityTarget ? "true" : "false",
|
|
GITHUB_EVENT_NAME: eventName,
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
PNPM_CALLS: callsPath,
|
|
PNPM_RESULT: fail ? "1" : "0",
|
|
RUN_PROMPT_SNAPSHOTS: "false",
|
|
},
|
|
});
|
|
return {
|
|
calls: existsSync(callsPath) ? readFileSync(callsPath, "utf8").trim().split("\n") : [],
|
|
result,
|
|
summaryPath,
|
|
};
|
|
};
|
|
|
|
// Pure reporting: pushes and PRs skip the diff; dispatches (including
|
|
// release validation) still produce it.
|
|
for (const eventName of ["push", "pull_request"]) {
|
|
const skipped = runCase({ "plugin-sdk:api:diff": "mock" }, false, eventName);
|
|
expect(skipped.result.status, skipped.result.stderr).toBe(0);
|
|
expect(skipped.calls).toEqual([]);
|
|
expect(skipped.result.stdout).toContain("manual and release dispatches only");
|
|
}
|
|
|
|
const current = runCase({ "plugin-sdk:api:diff": "mock" }, false);
|
|
expect(current.result.status, current.result.stderr).toBe(0);
|
|
expect(current.calls).toEqual([
|
|
"run plugin-sdk:api:diff -- --base base-sha --head synthetic-head-sha --json .artifacts/plugin-sdk-api-diff.json --summary " +
|
|
current.summaryPath,
|
|
]);
|
|
|
|
const failed = runCase({ "plugin-sdk:api:diff": "mock" }, false, "workflow_dispatch", true);
|
|
expect(failed.result.status, failed.result.stderr).toBe(1);
|
|
expect(failed.calls).toHaveLength(1);
|
|
expect(failed.result.stdout).toContain(
|
|
"::error title=plugin-sdk:api:diff failed::plugin-sdk:api:diff failed",
|
|
);
|
|
|
|
const historical = runCase({ "plugin-sdk:api:check": "mock" }, true);
|
|
expect(historical.result.status, historical.result.stderr).toBe(0);
|
|
expect(historical.calls).toEqual(["run plugin-sdk:api:check"]);
|
|
|
|
const missingCurrent = runCase({ "plugin-sdk:api:check": "mock" }, false);
|
|
expect(missingCurrent.result.status).toBe(1);
|
|
expect(missingCurrent.calls).toEqual([]);
|
|
expect(missingCurrent.result.stdout).toContain(
|
|
"Current CI targets must provide plugin-sdk:api:diff.",
|
|
);
|
|
});
|
|
|
|
it("retains fetch deadlines in other standalone workflows", () => {
|
|
const workflowPaths = [[".github/workflows/crabbox-hydrate.yml", "30s"]] as const;
|
|
|
|
for (const [workflowPath, timeoutSeconds] of workflowPaths) {
|
|
const workflow = readFileSync(workflowPath, "utf8");
|
|
const fetchTimeouts = workflow.match(
|
|
new RegExp(
|
|
`timeout --signal=TERM[^\\n]* ${timeoutSeconds} git(?: -C "(?:\\$workdir|\\$GITHUB_WORKSPACE|clawhub-source)")?`,
|
|
"g",
|
|
),
|
|
);
|
|
|
|
expect(fetchTimeouts?.length, workflowPath).toBeGreaterThan(0);
|
|
expect(
|
|
fetchTimeouts?.every((line) =>
|
|
line.startsWith(`timeout --signal=TERM --kill-after=10s ${timeoutSeconds} git`),
|
|
),
|
|
workflowPath,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("owns Docs Agent Git with bounded deadlines and action authority", () => {
|
|
const source = readFileSync(".github/workflows/docs-agent.yml", "utf8");
|
|
const workflow = parse(source);
|
|
const job = workflow.jobs["update-docs"];
|
|
const steps = job.steps as WorkflowStep[];
|
|
expect(steps.map(({ name }) => name)).toEqual([
|
|
"Checkout",
|
|
"Prepare Git owner",
|
|
"Gate trusted main activity and hourly cadence",
|
|
"Setup Node environment",
|
|
"Ensure docs agent key exists",
|
|
"Run Codex docs agent",
|
|
"Enforce existing-docs-only patch",
|
|
"Restore Node 24 path",
|
|
"Check docs",
|
|
"Commit docs updates",
|
|
]);
|
|
expect(steps[1]).toEqual({
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
});
|
|
expect(steps[0]).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
ref: "main",
|
|
"fetch-depth": 0,
|
|
"persist-credentials": false,
|
|
submodules: false,
|
|
},
|
|
});
|
|
expect(job["timeout-minutes"]).toBe(30);
|
|
expect(workflow.permissions).toEqual({ actions: "read", contents: "read" });
|
|
expect(job.permissions).toEqual({ actions: "read", contents: "write" });
|
|
expect(workflow.concurrency).toBeUndefined();
|
|
expect(job.concurrency).toEqual({ group: "docs-agent-main", "cancel-in-progress": false });
|
|
expect(steps[5]).toEqual({
|
|
name: "Run Codex docs agent",
|
|
if: "steps.gate.outputs.run_agent == 'true'",
|
|
uses: "openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e",
|
|
env: {
|
|
DOCS_AGENT_BASE_SHA: "${{ steps.gate.outputs.review_base_sha }}",
|
|
DOCS_AGENT_HEAD_SHA: "${{ steps.gate.outputs.review_head_sha }}",
|
|
},
|
|
with: {
|
|
"openai-api-key":
|
|
"${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}",
|
|
"prompt-file": ".github/codex/prompts/docs-agent.md",
|
|
model: "${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}",
|
|
effort: "medium",
|
|
sandbox: "workspace-write",
|
|
"safety-strategy": "drop-sudo",
|
|
},
|
|
});
|
|
const gate = expectDefined(steps[2]?.run, "gate policy");
|
|
const commit = expectDefined(steps[9]?.run, "commit policy");
|
|
const enforce = expectDefined(steps[6]?.run, "enforcement producers");
|
|
const calls = [
|
|
...`${gate}\n${commit}`.matchAll(/(?:run_git|git_output)\(([\s\S]*?)\)(?=\.rstrip|\n|$)/gu),
|
|
].map((match) => match[1]!);
|
|
const fetches = calls.filter((call) => call.startsWith('workspace, "fetch"'));
|
|
expect(fetches).toEqual([
|
|
'workspace, "fetch", "--no-tags", "origin", "main", timeout=120, reclaim_locks=True',
|
|
'workspace, "fetch", "--no-tags", "origin", target, timeout=120, reclaim_locks=True',
|
|
]);
|
|
expect(calls.filter((call) => call.includes("timeout="))).toEqual(fetches);
|
|
expect(enforce.match(/--checkout-git 0 (?:ls-files|diff)/gu)).toHaveLength(5);
|
|
});
|
|
|
|
it("owns docs mirror Git lifecycle without changing transport or stale-source policy", () => {
|
|
const source = readFileSync(".github/workflows/docs-sync-publish.yml", "utf8");
|
|
const workflow = parse(source);
|
|
const steps = workflow.jobs["sync-publish-repo"].steps as WorkflowStep[];
|
|
expect(steps.map(({ name }) => name)).toEqual([
|
|
"Skip publish sync without token",
|
|
"Checkout source repo",
|
|
"Checkout ClawHub docs source",
|
|
"Prepare Git owner",
|
|
"Setup Node",
|
|
"Clone publish repo",
|
|
"Sync docs into publish repo",
|
|
"Cache successful docs validation",
|
|
"Commit publish repo sync",
|
|
]);
|
|
expect(steps[3]).toEqual({
|
|
name: "Prepare Git owner",
|
|
if: "env.OPENCLAW_DOCS_SYNC_TOKEN != ''",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
});
|
|
expect(steps[1]).toMatchObject({ with: { "fetch-depth": 0 } });
|
|
expect(steps[2]).toMatchObject({
|
|
with: {
|
|
repository: "openclaw/clawhub",
|
|
ref: "main",
|
|
path: "clawhub-source",
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
for (const step of steps.slice(1)) {
|
|
expect(step.if).toBe(
|
|
step.name === "Cache successful docs validation"
|
|
? "env.OPENCLAW_DOCS_SYNC_TOKEN != '' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'"
|
|
: "env.OPENCLAW_DOCS_SYNC_TOKEN != ''",
|
|
);
|
|
}
|
|
expect(source).not.toContain("setup-python");
|
|
expect(workflow.concurrency).toEqual({
|
|
group:
|
|
"docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}",
|
|
"cancel-in-progress": false,
|
|
});
|
|
const clone = expectDefined(steps[5]?.run, "clone policy");
|
|
const publish = expectDefined(steps[8]?.run, "publication policy");
|
|
expect(steps[8]?.["working-directory"]).toBe("publish");
|
|
const calls = [...`${clone}\n${publish}`.matchAll(/run_git\(([\s\S]*?)\)(?=\n|$)/gu)].map(
|
|
(match) => match[1]!,
|
|
);
|
|
const transports = calls.filter((call) => /^\w+, "(?:clone|fetch)"/u.test(call));
|
|
expect(transports).toHaveLength(3);
|
|
expect(transports.every((call) => call.includes("timeout=120"))).toBe(true);
|
|
expect(transports.slice(1)).toEqual(
|
|
Array(2).fill(
|
|
'publish, "fetch", "origin", "main:refs/remotes/origin/main", timeout=120, reclaim_locks=True',
|
|
),
|
|
);
|
|
expect(calls.filter((call) => call.includes("timeout="))).toEqual(transports);
|
|
});
|
|
|
|
it("pins plugin publication owners before selected checkout and preserves Git deadlines", () => {
|
|
const owner = {
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
};
|
|
const clawhub = parse(readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8"));
|
|
const npm = parse(readFileSync(".github/workflows/plugin-npm-release.yml", "utf8"));
|
|
for (const [workflow, jobName, checkoutName] of [
|
|
[clawhub, "preview_plugins_clawhub", "Checkout"],
|
|
[npm, "preview_plugins_npm", "Checkout"],
|
|
[npm, "verify_plugin_npm_preflight", "Checkout trusted npm preflight tooling"],
|
|
[npm, "publish_plugins_npm", "Checkout trusted publication tooling"],
|
|
] as const) {
|
|
const steps = workflow.jobs[jobName].steps as WorkflowStep[];
|
|
expect(steps[0], jobName).toEqual(owner);
|
|
expect(steps[1]?.name, jobName).toBe(checkoutName);
|
|
const body = steps
|
|
.map(({ run }) => run ?? "")
|
|
.join("\n")
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("#"))
|
|
.join("\n");
|
|
const calls = [...body.matchAll(/(?:run_git|git_output)\(([\s\S]*?)\)(?=\.|\n|$)/gu)].map(
|
|
(match) => match[1]!,
|
|
);
|
|
const transports = calls.filter((call) => /^\s*workspace,\s*"(?:fetch|show)",/u.test(call));
|
|
expect(transports.length, jobName).toBeGreaterThan(0);
|
|
for (const call of transports) {
|
|
expect(call, jobName).toMatch(/\btimeout\s*=\s*120\b/u);
|
|
}
|
|
expect(body, jobName).not.toMatch(
|
|
/timeout[^\n]*git|(?:^|\s)git (?:fetch|rev-parse|merge-base|for-each-ref|checkout|show)\b/mu,
|
|
);
|
|
expect(body, jobName).not.toMatch(/backoff\(|for attempt in range/u);
|
|
}
|
|
for (const stepName of [
|
|
"Read exact npm preflight source package",
|
|
"Read exact npm publication source package",
|
|
]) {
|
|
const step = [
|
|
...npm.jobs.verify_plugin_npm_preflight.steps,
|
|
...npm.jobs.publish_plugins_npm.steps,
|
|
].find(({ name }: WorkflowStep) => name === stepName) as WorkflowStep;
|
|
expect(step.run, stepName).toContain("git_output(");
|
|
expect(step.run, stepName).toContain('errors="surrogateescape"');
|
|
}
|
|
});
|
|
|
|
it("pins the Mantis Git owner and preserves distinct terminal ref-validation contracts", () => {
|
|
const action = parse(
|
|
readFileSync(".github/actions/mantis-validate-trusted-ref/action.yml", "utf8"),
|
|
);
|
|
const workflow = parse(readFileSync(".github/workflows/mantis-discord-smoke.yml", "utf8"));
|
|
const owner = {
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
};
|
|
const actionSteps = action.runs.steps as WorkflowStep[];
|
|
const discordSteps = workflow.jobs.validate_selected_ref.steps as WorkflowStep[];
|
|
expect(actionSteps.map(({ name }) => name)).toEqual([
|
|
"Prepare Git owner",
|
|
"Validate refs are trusted",
|
|
]);
|
|
expect(actionSteps[0]).toEqual(owner);
|
|
expect(discordSteps.map(({ name }) => name)).toEqual([
|
|
"Prepare Git owner",
|
|
"Checkout selected ref",
|
|
"Validate selected ref",
|
|
]);
|
|
expect(discordSteps[0]).toEqual(owner);
|
|
expect(discordSteps[1]).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: { "persist-credentials": false, ref: "${{ inputs.ref }}", "fetch-depth": 0 },
|
|
});
|
|
expect(Object.keys(action.inputs)).toEqual(["candidate-ref", "baseline-ref"]);
|
|
expect(Object.keys(action.outputs)).toEqual(["candidate-revision", "baseline-revision"]);
|
|
for (const [steps, shared] of [
|
|
[actionSteps, true],
|
|
[discordSteps, false],
|
|
] as const) {
|
|
const run = expectDefined(steps.at(-1)?.run, "Mantis validation body");
|
|
const revision = shared ? "revision" : "selected_revision";
|
|
const prefix = `python3 -I -S "$CI_GIT_OWNER" --checkout-git ${shared ? 0 : 120} fetch --no-tags origin `;
|
|
expect(run.startsWith("set -euo pipefail\n")).toBe(true);
|
|
expect(
|
|
run
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => /\bfetch\b/u.test(line)),
|
|
).toEqual([
|
|
`${prefix}+refs/heads/main:refs/remotes/origin/main`,
|
|
...(shared
|
|
? []
|
|
: [`${prefix}"+refs/heads/\${INPUT_REF}:refs/remotes/origin/\${INPUT_REF}"`]),
|
|
]);
|
|
expect(run).not.toMatch(/\bgit fetch\b|^\s*(?:timeout|for|while|until)\b|\$\?/mu);
|
|
expect(
|
|
run.match(
|
|
/(?:reason|trusted_reason)="(?:main-ancestor|release-tag|release-branch-head|open-pr-head)"/gu,
|
|
),
|
|
).toEqual(
|
|
[
|
|
"main-ancestor",
|
|
"release-tag",
|
|
...(shared ? [] : ["release-branch-head"]),
|
|
"open-pr-head",
|
|
].map((reason) => `${shared ? "reason" : "trusted_reason"}="${reason}"`),
|
|
);
|
|
expect(run).toContain(`git tag --points-at "$${revision}" | grep -Eq '^v'`);
|
|
expect(run).toContain("gh api \\\n");
|
|
expect(run).toContain('-H "Accept: application/vnd.github+json"');
|
|
expect(run).toContain(`"repos/\${GITHUB_REPOSITORY}/commits/\${${revision}}/pulls"`);
|
|
expect(run).toContain(
|
|
`select(.state == "open" and .head.repo.full_name == "'"\${GITHUB_REPOSITORY}"'" and .head.sha == "'"\${${revision}}"'")] | length`,
|
|
);
|
|
if (shared) {
|
|
expect(run).toContain('echo "${label}_revision=${revision}" >> "$GITHUB_OUTPUT"');
|
|
expect(run).toContain(
|
|
'validate_ref baseline "$BASELINE_REF"\nfi\nvalidate_ref candidate "$CANDIDATE_REF"',
|
|
);
|
|
} else {
|
|
expect(run).toContain(
|
|
'elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\\.[0-9]+\\.[0-9]+$ ]]; then',
|
|
);
|
|
expect(run).toContain(
|
|
'release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"',
|
|
);
|
|
expect(run).toContain(
|
|
'if [[ "$selected_revision" == "$release_branch_sha" ]]; then\n trusted_reason="release-branch-head"\n fi\nelse\n pr_head_count=',
|
|
);
|
|
expect(run).toContain(
|
|
'echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"\necho "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps shared Mantis reaction ownership stable", () => {
|
|
const resolveWorkflowPath = ".github/workflows/mantis-resolve-request.yml";
|
|
const cleanupWorkflowPath = ".github/workflows/mantis-clear-reaction.yml";
|
|
const resolveSource = readFileSync(resolveWorkflowPath, "utf8");
|
|
const cleanupSource = readFileSync(cleanupWorkflowPath, "utf8");
|
|
const resolveWorkflow = parse(resolveSource);
|
|
const cleanupWorkflow = parse(cleanupSource);
|
|
const expectedWorkflowCallSecrets = {
|
|
MANTIS_GITHUB_APP_ID: { required: true },
|
|
MANTIS_GITHUB_APP_PRIVATE_KEY: { required: true },
|
|
};
|
|
const resolveJob = resolveWorkflow.jobs.resolve;
|
|
const cleanupJob = cleanupWorkflow.jobs.clear;
|
|
const resolveSteps = resolveJob.steps as WorkflowStep[];
|
|
const cleanupSteps = cleanupJob.steps as WorkflowStep[];
|
|
const findStep = (steps: WorkflowStep[], id: string, workflowPath: string) =>
|
|
expectDefined(
|
|
steps.find((step) => step.id === id),
|
|
`${workflowPath} ${id}`,
|
|
);
|
|
const createTokenStep = findStep(resolveSteps, "mantis_reaction_token", resolveWorkflowPath);
|
|
const createStep = findStep(resolveSteps, "add_reaction", resolveWorkflowPath);
|
|
const cleanupTokenStep = findStep(cleanupSteps, "mantis_reaction_token", cleanupWorkflowPath);
|
|
const deleteStep = expectDefined(
|
|
cleanupSteps.find((step) => step.env?.REACTION_ID),
|
|
`${cleanupWorkflowPath} reaction cleanup step`,
|
|
);
|
|
|
|
expect(resolveWorkflow.on.workflow_call.secrets, resolveWorkflowPath).toEqual(
|
|
expectedWorkflowCallSecrets,
|
|
);
|
|
expect(cleanupWorkflow.on.workflow_call.secrets, cleanupWorkflowPath).toEqual(
|
|
expectedWorkflowCallSecrets,
|
|
);
|
|
expect(resolveJob.outputs.reaction_id, resolveWorkflowPath).toBe(
|
|
"${{ steps.add_reaction.outputs.reaction_id }}",
|
|
);
|
|
for (const [label, tokenStep] of [
|
|
["creation", createTokenStep],
|
|
["cleanup", cleanupTokenStep],
|
|
] as const) {
|
|
expect(tokenStep, `${label} token`).toMatchObject({
|
|
uses: CREATE_GITHUB_APP_TOKEN_V3,
|
|
with: {
|
|
"app-id": "${{ secrets.MANTIS_GITHUB_APP_ID }}",
|
|
"private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}",
|
|
},
|
|
});
|
|
expect(
|
|
Object.entries(tokenStep.with ?? {}).filter(([key]) => key.startsWith("permission-")),
|
|
`${label} permissions`,
|
|
).toEqual([["permission-issues", "write"]]);
|
|
}
|
|
expect(createStep, resolveWorkflowPath).toMatchObject({
|
|
if: "${{ steps.resolve.outputs.request_source == 'issue_comment' && steps.mantis_reaction_token.outcome == 'success' }}",
|
|
uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3",
|
|
with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" },
|
|
});
|
|
expect(createStep.with?.script, resolveWorkflowPath).toContain("createForIssueComment");
|
|
expect(createStep.with?.script, resolveWorkflowPath).toContain(
|
|
'core.setOutput("reaction_id", String(reaction.id))',
|
|
);
|
|
expect(resolveSource.match(/createForIssueComment/gu), resolveWorkflowPath).toHaveLength(1);
|
|
expect(cleanupJob.permissions, cleanupWorkflowPath).toEqual({});
|
|
expect(deleteStep, cleanupWorkflowPath).toMatchObject({
|
|
env: {
|
|
COMMENT_ID: "${{ inputs.comment-id }}",
|
|
REACTION_ID: "${{ inputs.reaction-id }}",
|
|
},
|
|
uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3",
|
|
with: { "github-token": "${{ steps.mantis_reaction_token.outputs.token }}" },
|
|
});
|
|
expect(deleteStep.with?.script, cleanupWorkflowPath).toContain("deleteForIssueComment");
|
|
expect(deleteStep.with?.script, cleanupWorkflowPath).toContain(
|
|
"Number(process.env.REACTION_ID)",
|
|
);
|
|
expect(deleteStep.with?.script, cleanupWorkflowPath).toContain("reaction_id: reactionId");
|
|
expect(JSON.stringify(cleanupJob), cleanupWorkflowPath).not.toMatch(
|
|
/listForIssueComment|\.filter\(|github-actions\[bot\]/u,
|
|
);
|
|
});
|
|
|
|
it.each(MANTIS_MANUAL_ONLY_WORKFLOWS)(
|
|
"keeps legacy Mantis scenarios on manual dispatch in %s",
|
|
(workflowPath) => {
|
|
const workflow = parse(readFileSync(workflowPath, "utf8"));
|
|
|
|
expect(workflow.on.workflow_dispatch, workflowPath).toBeDefined();
|
|
expect(workflow.on.issue_comment, workflowPath).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("bounds release ref validation fetches across checkout auth modes", () => {
|
|
const resolveTargetSteps = readReleaseChecksWorkflow().jobs.resolve_target.steps;
|
|
|
|
for (const stepName of [
|
|
"Validate selected ref belongs to this repository",
|
|
"Validate Tideclaw alpha target matches workflow branch",
|
|
]) {
|
|
const step = resolveTargetSteps.find(
|
|
(candidate: WorkflowStep) => candidate.name === stepName,
|
|
);
|
|
|
|
expect(step?.run, stepName).toContain("local -a git_args=(git)");
|
|
expect(step?.run, stepName).toContain(
|
|
'git_args+=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}")',
|
|
);
|
|
expect(step?.run, stepName).toContain(
|
|
'timeout --signal=TERM --kill-after=10s 120s "${git_args[@]}" fetch "$@"',
|
|
);
|
|
expect(step?.run, stepName).not.toContain('git -c "http.https://github.com/.extraheader');
|
|
}
|
|
});
|
|
|
|
describe.skipIf(process.platform !== "linux")("release fallback history with real Git", () => {
|
|
it.each(["branch", "tag"] as const)("accepts a small valid %s history", (route) => {
|
|
const { result, events } = runReleaseFallbackHistoryFixture({ route });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(events.filter((event) => event.op.startsWith("fetch-"))).toMatchObject([
|
|
{ op: "fetch-branches", status: 0, signal: null },
|
|
{ op: "fetch-tags", status: 0, signal: null },
|
|
]);
|
|
expect(events.find((event) => event.op === `${route}-producer`)).toMatchObject({
|
|
status: 0,
|
|
signal: null,
|
|
});
|
|
});
|
|
|
|
it.each(["orphan", "non-release-tag"] as const)("rejects %s history", (route) => {
|
|
const { result, events } = runReleaseFallbackHistoryFixture({ route });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("but that commit is not reachable");
|
|
expect(events.filter((event) => event.op.endsWith("-producer"))).toMatchObject([
|
|
{ op: "tag-producer", status: 0, signal: null },
|
|
{ op: "branch-producer", status: 0, signal: null },
|
|
]);
|
|
});
|
|
|
|
it.each(["fetch-branches", "fetch-tags"] as const)(
|
|
"fails closed when the real %s command fails",
|
|
(failure) => {
|
|
const { result, events } = runReleaseFallbackHistoryFixture({ route: "branch", failure });
|
|
expect(result.status).not.toBe(0);
|
|
expect(events.find((event) => event.op === failure)).toMatchObject({
|
|
status: 128,
|
|
signal: null,
|
|
});
|
|
expect(events.some((event) => event.op.endsWith("-producer"))).toBe(false);
|
|
expect(result.stderr).not.toContain("but that commit is not reachable");
|
|
},
|
|
);
|
|
|
|
it.each(["branch", "tag"] as const)(
|
|
"does not accept matching %s output followed by a real Git failure",
|
|
(route) => {
|
|
const { result, events } = runReleaseFallbackHistoryFixture({
|
|
route,
|
|
failure: `${route}-producer`,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("but that commit is not reachable");
|
|
expect(events.find((event) => event.op === `${route}-producer`)).toMatchObject({
|
|
status: 0,
|
|
signal: null,
|
|
});
|
|
expect(events.find((event) => event.op === "post-output-failure")).toMatchObject({
|
|
status: 128,
|
|
signal: null,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["branch", "tag"] as const)(
|
|
"accepts valid %s enumeration larger than the pipe capacity",
|
|
(route) => {
|
|
const { result, events } = runReleaseFallbackHistoryFixture({ route, many: true });
|
|
expect(events.filter((event) => event.op.startsWith("fetch-"))).toMatchObject([
|
|
{ op: "fetch-branches", status: 0, signal: null },
|
|
{ op: "fetch-tags", status: 0, signal: null },
|
|
]);
|
|
const producer = events.find((event) => event.op === `${route}-producer`);
|
|
if (result.status !== 0) {
|
|
expect(result.stderr).toContain("but that commit is not reachable");
|
|
expect(producer).toMatchObject({ status: null, signal: "SIGPIPE", exitCode: 141 });
|
|
}
|
|
expect(result.status, JSON.stringify({ producer, stderr: result.stderr })).toBe(0);
|
|
expect(producer).toMatchObject({ status: 0, signal: null });
|
|
},
|
|
60_000,
|
|
);
|
|
});
|
|
|
|
it("uses the maintained authenticated checkout for security-fast", () => {
|
|
const workflow = readCiWorkflow();
|
|
const checkoutStep = workflow.jobs["security-fast"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
const manualCheckoutStep = workflow.jobs["security-fast"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout manual target",
|
|
);
|
|
|
|
expect(checkoutStep.uses).toBe(CHECKOUT_V6);
|
|
expect(checkoutStep.if).toBe(
|
|
"github.event_name != 'workflow_dispatch' || inputs.target_ref == ''",
|
|
);
|
|
expect(checkoutStep.with["persist-credentials"]).toBe(false);
|
|
expect(checkoutStep.with["fetch-depth"]).toBe(2);
|
|
expect(manualCheckoutStep.if).toBe(
|
|
"github.event_name == 'workflow_dispatch' && inputs.target_ref != ''",
|
|
);
|
|
expect(manualCheckoutStep.run).toContain("workflow_dispatch target_ref");
|
|
});
|
|
|
|
it("keeps manual candidates separate from trusted cache authority", () => {
|
|
const workflow = readCiWorkflow();
|
|
const preflight = workflow.jobs.preflight;
|
|
const checkoutStep = expectDefined(
|
|
preflight.steps.find((step: WorkflowStep) => step.name === "Checkout"),
|
|
"preflight checkout owner",
|
|
);
|
|
expect(checkoutStep.env?.WORKFLOW_SHA).toBe("${{ github.workflow_sha }}");
|
|
const harnessSteps = preflight.steps.filter(
|
|
(step: WorkflowStep) =>
|
|
step.uses?.startsWith("actions/checkout@") && step.with?.path === ".ci-harness",
|
|
);
|
|
expect(harnessSteps).toHaveLength(1);
|
|
const harnessStep = expectDefined(harnessSteps[0], "different-revision harness checkout");
|
|
expect(harnessStep).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: ".ci-harness",
|
|
"sparse-checkout": expect.stringContaining("/.github/actions/\n"),
|
|
"sparse-checkout-cone-mode": false,
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
const resolvedIndex = preflight.steps.findIndex(
|
|
(step: WorkflowStep) => step.id === "checkout_ref",
|
|
);
|
|
const harnessIndex = preflight.steps.indexOf(harnessStep);
|
|
const consumerIndex = preflight.steps.findIndex((step: WorkflowStep) =>
|
|
step.uses?.startsWith("./.ci-harness/"),
|
|
);
|
|
expect(preflight.steps.indexOf(checkoutStep)).toBeLessThan(resolvedIndex);
|
|
expect(resolvedIndex).toBeLessThan(harnessIndex);
|
|
expect(harnessIndex).toBeLessThan(consumerIndex);
|
|
const workflowSha = "a".repeat(40);
|
|
for (const eventName of ["push", "pull_request", "workflow_dispatch"] as const) {
|
|
for (const headRepository of ["openclaw/openclaw", "contributor/openclaw"]) {
|
|
for (const selectedSha of [workflowSha, "b".repeat(40)]) {
|
|
expect(
|
|
evaluateWorkflowExpression(harnessStep.if, {
|
|
eventName,
|
|
headRepository,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
steps: { checkout_ref: { outputs: { sha: selectedSha } } },
|
|
workflowSha,
|
|
}),
|
|
).toBe(selectedSha !== workflowSha);
|
|
}
|
|
}
|
|
}
|
|
const trustStep = expectDefined(
|
|
preflight.steps.find((step: WorkflowStep) => step.name === "Classify candidate cache trust"),
|
|
"candidate cache trust step",
|
|
);
|
|
const nativeCheckout = expectDefined(
|
|
workflow.jobs["native-i18n"].steps.find((step: WorkflowStep) => step.name === "Checkout"),
|
|
"native i18n checkout",
|
|
);
|
|
|
|
expect(preflight.outputs).toMatchObject({
|
|
candidate_trust: "${{ steps.candidate_trust.outputs.trust }}",
|
|
cache_mode: "${{ steps.candidate_trust.outputs.cache_mode }}",
|
|
cache_write_allowed: "${{ steps.candidate_trust.outputs.cache_write_allowed }}",
|
|
});
|
|
expect(trustStep.env).toMatchObject({
|
|
CHECKOUT_REVISION: "${{ steps.checkout_ref.outputs.sha }}",
|
|
DEFAULT_SHA: "${{ steps.diff_base.outputs.default_sha }}",
|
|
TARGET_REF: "${{ inputs.target_ref }}",
|
|
WORKFLOW_REVISION: "${{ github.workflow_sha }}",
|
|
});
|
|
expect(trustStep.run).toContain("trust=untrusted");
|
|
expect(trustStep.run).toContain("cache_mode=off");
|
|
expect(trustStep.run).toContain("cache_write_allowed=false");
|
|
expect(trustStep.run).toContain('elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]');
|
|
expect(trustStep.run).toContain('"$RELEASE_GATE" == "true"');
|
|
expect(trustStep.run).toContain('"$CHECKOUT_REVISION" == "$DEFAULT_SHA"');
|
|
expect(trustStep.run).toContain('"$CHECKOUT_REVISION" == "$WORKFLOW_REVISION"');
|
|
expect(trustStep.run).toContain("cache_write_allowed=true");
|
|
|
|
const ciLocalActions = Object.values(workflow.jobs).flatMap(
|
|
(job) =>
|
|
(job as { steps?: WorkflowStep[] }).steps?.filter((step) =>
|
|
step.uses?.includes("/.github/actions/"),
|
|
) ?? [],
|
|
);
|
|
expect(ciLocalActions.length).toBeGreaterThan(0);
|
|
for (const step of ciLocalActions) {
|
|
expect(step.uses, step.name).toContain("./.ci-harness/.github/actions/");
|
|
}
|
|
|
|
expect(nativeCheckout.uses).toBeUndefined();
|
|
expect(nativeCheckout.env).toMatchObject({
|
|
CHECKOUT_SHA: "${{ needs.preflight.outputs.checkout_revision }}",
|
|
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
|
});
|
|
|
|
for (const [jobName, job] of Object.entries(workflow.jobs)) {
|
|
for (const step of (job as { steps?: WorkflowStep[] }).steps ?? []) {
|
|
if (step.uses?.startsWith("actions/cache/restore@")) {
|
|
expect(String(step.if), `${jobName}: ${step.name}`).toContain(
|
|
"preflight.outputs.cache_mode != 'off'",
|
|
);
|
|
}
|
|
if (step.uses?.startsWith("actions/cache/save@")) {
|
|
expect(String(step.if), `${jobName}: ${step.name}`).toContain(
|
|
"preflight.outputs.cache_write_allowed == 'true'",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const goSetup = expectDefined(
|
|
workflow.jobs["checks-node-core-test-nondist-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Go for docs i18n",
|
|
),
|
|
"docs i18n Go setup",
|
|
);
|
|
expect(goSetup.with?.cache).toBe(false);
|
|
});
|
|
|
|
it("uses the maintained checkout across workflow sanity jobs", () => {
|
|
const workflow = readWorkflowSanityWorkflow();
|
|
|
|
for (const jobName of ["actionlint", "generated-doc-baselines"]) {
|
|
const checkoutStep = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
|
|
expect(checkoutStep.uses, jobName).toBe(CHECKOUT_V6);
|
|
expect(checkoutStep.with, jobName).toEqual({
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("selects a supported Node before lightweight checks, release approval, and image wrappers", () => {
|
|
const cases: [file: string, jobId: string, consumerName: string, setupCondition?: string][] = [
|
|
["workflow-sanity.yml", "actionlint", "Disallow tracked merge conflict markers"],
|
|
["android-release.yml", "publish_signed_android_apk", "Validate release approval and target"],
|
|
["docker-channel-promote.yml", "resolve", "Resolve release channel policy"],
|
|
["linux-app-release.yml", "validate_release", "Verify trusted release tooling identity"],
|
|
[
|
|
"openclaw-live-and-e2e-checks-reusable.yml",
|
|
"prepare_live_test_image",
|
|
"Pack live-test image artifact",
|
|
],
|
|
[
|
|
"openclaw-live-and-e2e-checks-reusable.yml",
|
|
"validate_live_models_docker",
|
|
"Verify and load live-test image artifact",
|
|
],
|
|
[
|
|
"openclaw-live-and-e2e-checks-reusable.yml",
|
|
"validate_live_models_docker_targeted",
|
|
"Verify and load live-test image artifact",
|
|
],
|
|
["openclaw-release-publish.yml", "publish", "Record postpublish outcome", "${{ always() }}"],
|
|
];
|
|
for (const [file, jobId, consumerName, setupCondition] of cases) {
|
|
const workflow = parse(readFileSync(`.github/workflows/${file}`, "utf8"));
|
|
const job = workflow.jobs[jobId];
|
|
const steps: WorkflowStep[] = job.steps;
|
|
const consumerIndex = steps.findIndex((step) => step.name === consumerName);
|
|
const context = `${file}:${jobId}`;
|
|
expect(consumerIndex, context).toBeGreaterThanOrEqual(0);
|
|
const setup = expectDefined(
|
|
steps.slice(0, consumerIndex).find((step) => step.uses?.startsWith("actions/setup-node@")),
|
|
`${context} must select Node before ${consumerName}`,
|
|
);
|
|
const version = setup.with?.["node-version"];
|
|
const resolved =
|
|
version === "${{ env.NODE_VERSION }}"
|
|
? (job.env?.NODE_VERSION ?? workflow.env?.NODE_VERSION)
|
|
: version;
|
|
expect(isSupportedOpenClawNodeVersion(resolved), context).toBe(true);
|
|
expect(setup.if, context).toBe(setupCondition);
|
|
expect(setup.with?.["package-manager-cache"], context).toBe(false);
|
|
}
|
|
const ci = readCiWorkflow();
|
|
const manifestRuntime = expectDefined(
|
|
ci.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup manifest TypeScript runtime",
|
|
),
|
|
"CI manifest runtime",
|
|
);
|
|
expect(manifestRuntime.with?.["node-version"]).toBe("${{ env.NODE_VERSION }}");
|
|
expect(isSupportedOpenClawNodeVersion(ci.env.NODE_VERSION), "CI manifest runtime pin").toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("pins workflow sanity's typed Git policy after Python setup", () => {
|
|
const steps: WorkflowStep[] = readWorkflowSanityWorkflow().jobs.actionlint.steps;
|
|
const python = expectDefined(
|
|
steps.find((step) => step.name === "Setup Python"),
|
|
"Python",
|
|
);
|
|
const owner = expectDefined(
|
|
steps.find((step) => step.name === "Prepare Git owner"),
|
|
"owner",
|
|
);
|
|
const policy = expectDefined(
|
|
steps.find((step) => step.name === "Prepare trusted workflow audit configs"),
|
|
"policy",
|
|
);
|
|
expect(python.with).toEqual({ "python-version": "3.12" });
|
|
expect(owner.uses).toBe(
|
|
"openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
);
|
|
expect(owner.with).toBeUndefined();
|
|
expect(steps.indexOf(python)).toBeLessThan(steps.indexOf(owner));
|
|
expect(steps.indexOf(owner)).toBeLessThan(steps.indexOf(policy));
|
|
expect(policy.if).toBe(
|
|
"github.event_name == 'pull_request' && steps.scope.outputs.changed == 'true'",
|
|
);
|
|
expect(policy.env).toEqual({
|
|
BASE_REF: "${{ github.event.pull_request.base.ref }}",
|
|
BASE_SHA: "${{ github.event.pull_request.base.sha }}",
|
|
});
|
|
const audit = expectDefined(
|
|
steps.find((step) => step.name === "Audit all workflows with zizmor"),
|
|
"audit",
|
|
);
|
|
expect(audit.run).toContain(
|
|
'pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor',
|
|
);
|
|
});
|
|
|
|
it("prepares Testbox checkouts with one maintained owner and scoped history", () => {
|
|
const workflowPaths = [
|
|
[
|
|
".github/workflows/ci-check-testbox.yml",
|
|
"1",
|
|
"${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'HEAD' }}",
|
|
"1.27.1",
|
|
],
|
|
[
|
|
".github/workflows/ci-check-arm-testbox.yml",
|
|
"0",
|
|
"${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}",
|
|
"1.27.1",
|
|
],
|
|
[
|
|
".github/workflows/ci-build-artifacts-testbox.yml",
|
|
"0",
|
|
"${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}",
|
|
undefined,
|
|
],
|
|
] as const;
|
|
|
|
for (const [workflowPath, dispatchFetchDepth, baseRef, goVersion] of workflowPaths) {
|
|
const workflow = parse(readFileSync(workflowPath, "utf8"));
|
|
const job = Object.values(workflow.jobs)[0] as { steps: WorkflowStep[] };
|
|
const checkoutStep = job.steps.find((step) => step.name === "Checkout");
|
|
const prepareStep = job.steps.find((step) => step.name === "Prepare Testbox shell");
|
|
|
|
expect(checkoutStep?.uses, workflowPath).toBe(CHECKOUT_V6);
|
|
expect(checkoutStep?.with?.["persist-credentials"], workflowPath).toBe(false);
|
|
for (const [eventName, expectedDepth] of [
|
|
["pull_request", "2"],
|
|
["workflow_dispatch", dispatchFetchDepth],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(checkoutStep?.with?.["fetch-depth"], {
|
|
eventName,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
}),
|
|
`${workflowPath} ${eventName}`,
|
|
).toBe(expectedDepth);
|
|
}
|
|
expect(prepareStep?.uses, workflowPath).toBe("./.github/actions/prepare-testbox-shell");
|
|
expect(prepareStep?.with?.["base-ref"], workflowPath).toBe(baseRef);
|
|
expect(prepareStep?.with?.["go-version"], workflowPath).toBe(goVersion);
|
|
const ensureBaseStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Ensure Testbox base commit",
|
|
);
|
|
expect(ensureBaseStep, workflowPath).toBeUndefined();
|
|
expect(JSON.stringify(job.steps), workflowPath).not.toContain(
|
|
"+refs/heads/main:refs/remotes/origin/main",
|
|
);
|
|
}
|
|
|
|
const action = parse(readFileSync(".github/actions/prepare-testbox-shell/action.yml", "utf8"));
|
|
expect(action.inputs["go-version"]).toMatchObject({ required: false });
|
|
const setupGo = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.uses === SETUP_GO_V6),
|
|
"Testbox Go setup",
|
|
);
|
|
expect(setupGo).toMatchObject({
|
|
if: "inputs.go-version != ''",
|
|
with: {
|
|
cache: false,
|
|
"go-version": "${{ inputs.go-version }}",
|
|
},
|
|
});
|
|
const exposeGo = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Expose Go tools"),
|
|
"Testbox Go exposure",
|
|
);
|
|
expect(exposeGo.if).toBe("inputs.go-version != ''");
|
|
expect(exposeGo.run).toContain('test "$(go env GOVERSION)" = "go${TESTBOX_GO_VERSION}"');
|
|
expect(exposeGo.run).toContain('go_root="$(go env GOROOT)"');
|
|
expect(exposeGo.run).toContain("for tool in go gofmt; do");
|
|
expect(exposeGo.run).toContain('"/usr/local/bin/$tool"');
|
|
const prepare = expectDefined(
|
|
action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Pin Testbox base and Node tools",
|
|
),
|
|
"Testbox base preparation",
|
|
);
|
|
const run = prepare.run as string;
|
|
expect(run).toContain('base_ref="${TESTBOX_BASE_REF:-HEAD}"');
|
|
expect(run).toContain('git rev-parse --verify "${base_ref}^{commit}"');
|
|
expect(run).toContain('git update-ref refs/remotes/origin/main "$base_sha"');
|
|
expect(run).not.toContain("git fetch");
|
|
});
|
|
|
|
it("bounds the workflow sanity ShellCheck download", () => {
|
|
const workflow = readWorkflowSanityWorkflow();
|
|
const shellcheckStep = expectDefined(
|
|
workflow.jobs.actionlint.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install ShellCheck",
|
|
),
|
|
"ShellCheck install step",
|
|
);
|
|
expect(shellcheckStep.run).toContain("curl --connect-timeout 10 --max-time 120");
|
|
expect(shellcheckStep.run).toContain("--retry 5 --retry-delay 2 --retry-all-errors");
|
|
});
|
|
|
|
it("pins workflow and pre-commit actionlint to the large-stdin deadlock fix", () => {
|
|
const revision = "011a6d15e749bb3f2d771eed9c7aa0e7e3e10ee7";
|
|
const steps: WorkflowStep[] = readWorkflowSanityWorkflow().jobs.actionlint.steps;
|
|
const setupGo = expectDefined(
|
|
steps.find((step) => step.uses === SETUP_GO_V6),
|
|
"Go setup",
|
|
);
|
|
const install = expectDefined(
|
|
steps.find((step) => step.name === "Install actionlint"),
|
|
"actionlint install",
|
|
);
|
|
|
|
expect(setupGo.with).toEqual({ "go-version": "1.27.1", cache: false });
|
|
expect(steps.indexOf(setupGo)).toBeLessThan(steps.indexOf(install));
|
|
expect(install.run).toContain(`ACTIONLINT_REVISION="${revision}"`);
|
|
expect(install.run).toContain('export GOBIN="$RUNNER_TEMP/actionlint-bin"');
|
|
expect(install.run).toContain(
|
|
'go install "github.com/rhysd/actionlint/cmd/actionlint@${ACTIONLINT_REVISION}"',
|
|
);
|
|
expect(install.run).toContain('"$GOBIN/actionlint" -version');
|
|
expect(install.run).toContain("v1.7.13-0.20260419144658-${ACTIONLINT_REVISION:0:12}");
|
|
expect(install.run).toContain('echo "$GOBIN" >> "$GITHUB_PATH"');
|
|
const preCommit = parse(readFileSync(".pre-commit-config.yaml", "utf8"));
|
|
expect(
|
|
preCommit.repos.find(
|
|
(repo: { repo: string }) => repo.repo === "https://github.com/rhysd/actionlint",
|
|
).rev,
|
|
).toBe(revision);
|
|
});
|
|
|
|
it("runs committed generated baseline drift checks in workflow sanity", () => {
|
|
const workflow = readWorkflowSanityWorkflow();
|
|
const steps = workflow.jobs["generated-doc-baselines"].steps;
|
|
const stepNames = steps.map((step: WorkflowStep) => step.name);
|
|
|
|
expect(stepNames).toContain("Check SQLite sessions/transcripts schema baseline drift");
|
|
expect(stepNames).toContain("Check plugin SDK surface budget");
|
|
expect(
|
|
stepNames.indexOf("Check SQLite sessions/transcripts schema baseline drift"),
|
|
).toBeLessThan(stepNames.indexOf("Check plugin SDK surface budget"));
|
|
expect(
|
|
steps.find(
|
|
(step: WorkflowStep) =>
|
|
step.name === "Check SQLite sessions/transcripts schema baseline drift",
|
|
).run,
|
|
).toBe("pnpm sqlite:sessions-schema:check");
|
|
expect(
|
|
steps.find((step: WorkflowStep) => step.name === "Check plugin SDK surface budget").run,
|
|
).toBe("pnpm plugin-sdk:surface:check");
|
|
});
|
|
|
|
it("shares checkout ownership across Linux and native platforms with their existing budgets", () => {
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const workflow = readCiWorkflow();
|
|
|
|
expect(source.match(/&platform_checkout_step/gu) ?? []).toHaveLength(1);
|
|
expect(source.match(/\*platform_checkout_step/gu) ?? []).toHaveLength(4);
|
|
expect(source.match(/&owned_checkout_run/gu) ?? []).toHaveLength(1);
|
|
const linuxCheckout = workflow.jobs["checks-fast-core"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
for (const runner of ["Linux", "macOS", "Windows"]) {
|
|
const defaults = spawnSync(
|
|
process.platform === "win32" ? "python" : "python3",
|
|
[
|
|
"-I",
|
|
"-S",
|
|
"-c",
|
|
'import json,runpy; owner=runpy.run_path(".github/actions/git-owner/owner.py"); print(json.dumps([owner["fetch_timeout_seconds"], owner["cleanup_seconds"]]))',
|
|
],
|
|
{ encoding: "utf8", env: { ...process.env, RUNNER_OS: runner } },
|
|
);
|
|
expect(defaults.status, defaults.stderr).toBe(0);
|
|
expect(JSON.parse(defaults.stdout)).toEqual([runner === "Linux" ? 120 : 90, 10]);
|
|
}
|
|
|
|
for (const jobName of [
|
|
"checks-windows",
|
|
"macos-node",
|
|
"macos-swift",
|
|
"ios-build",
|
|
"ios-screenshot-shard",
|
|
]) {
|
|
const checkoutStep = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
|
|
expect(checkoutStep.run, jobName).toBe(linuxCheckout.run);
|
|
expect(checkoutStep.env, jobName).toEqual(linuxCheckout.env);
|
|
// Bootstrap cannot load Python startup code from the candidate checkout.
|
|
expect(checkoutStep.run, jobName).toContain('exec "$python_command" -I -S -');
|
|
}
|
|
|
|
const macosNodeSetup = workflow.jobs["macos-node"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
expect(macosNodeSetup.with).toMatchObject({
|
|
"cache-mode": "${{ needs.preflight.outputs.cache_mode }}",
|
|
"install-bun": "false",
|
|
});
|
|
});
|
|
|
|
it("checks native and Node state schema versions in the macOS lane", () => {
|
|
const workflow = readCiWorkflow();
|
|
const schemaVersionStep = workflow.jobs["macos-swift"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Native state schema version contract",
|
|
);
|
|
|
|
expect(schemaVersionStep.run).toContain("node scripts/check-native-state-schema-version.mjs");
|
|
expect(schemaVersionStep.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]');
|
|
});
|
|
|
|
it("prepares offline Apple assets before CI opens a macOS SwiftPM graph", () => {
|
|
for (const [workflowPath, jobName] of [
|
|
[".github/workflows/ci.yml", "macos-swift"],
|
|
[".github/workflows/macos-periphery.yml", "scan"],
|
|
[".github/workflows/shared-openclawkit-periphery.yml", "scan-macos"],
|
|
] as const) {
|
|
const workflow = parse(readFileSync(workflowPath, "utf8"));
|
|
const steps = workflow.jobs[jobName].steps as WorkflowStep[];
|
|
const setupIndex = steps.findIndex((step) => step.uses?.endsWith("/setup-node-env"));
|
|
const installIndex = steps.findIndex(
|
|
(step) => step.name === "Install Mermaid renderer dependencies",
|
|
);
|
|
const prepareIndex = steps.findIndex((step) =>
|
|
step.run?.includes("node scripts/prepare-apple-mermaid.mjs"),
|
|
);
|
|
const graphIndex = steps.findIndex((step) =>
|
|
/swift (?:build|package)|periphery scan/u.test(step.run ?? ""),
|
|
);
|
|
|
|
const setupStep = expectDefined(steps[setupIndex], `${workflowPath}: dependency setup`);
|
|
const installStep = expectDefined(steps[installIndex], `${workflowPath}: dependency install`);
|
|
expect(setupStep.with?.["install-deps"]).toBe("false");
|
|
expect(installStep.env).toEqual({ CI: "true" });
|
|
expect(installStep.run?.trim().split(/\s+/u)).toEqual([
|
|
"pnpm",
|
|
"install",
|
|
"--frozen-lockfile",
|
|
"--prefer-offline",
|
|
"--optional",
|
|
"--filter",
|
|
"'@openclaw/mermaid-renderer...'",
|
|
"--config.ignore-scripts=false",
|
|
"--config.engine-strict=false",
|
|
"--config.enable-pre-post-scripts=true",
|
|
"--config.side-effects-cache=true",
|
|
]);
|
|
expect(installIndex, `${workflowPath}: filtered dependency install`).toBeGreaterThan(
|
|
setupIndex,
|
|
);
|
|
expect(prepareIndex, `${workflowPath}: resource preparation`).toBeGreaterThan(installIndex);
|
|
expect(graphIndex, `${workflowPath}: SwiftPM graph`).toBeGreaterThan(prepareIndex);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ historical: false, helperPresent: true, expectedStatus: 0 },
|
|
{ historical: true, helperPresent: false, expectedStatus: 0 },
|
|
{ historical: false, helperPresent: false, expectedStatus: 1 },
|
|
])(
|
|
"preserves the Apple asset contract for $historical historical / $helperPresent helper",
|
|
(testCase) => {
|
|
const steps = readCiWorkflow().jobs["macos-swift"].steps as WorkflowStep[];
|
|
const step = expectDefined(
|
|
steps.find((candidate) =>
|
|
candidate.run?.includes("node scripts/prepare-apple-mermaid.mjs"),
|
|
),
|
|
"Apple asset preparation step",
|
|
);
|
|
const root = tempDirs.make("openclaw-apple-assets-workflow-");
|
|
const marker = path.join(root, "prepared");
|
|
if (testCase.helperPresent) {
|
|
mkdirSync(path.join(root, "scripts"));
|
|
writeFileSync(
|
|
path.join(root, "scripts/prepare-apple-mermaid.mjs"),
|
|
'import { writeFileSync } from "node:fs"; writeFileSync("prepared", "ready");',
|
|
);
|
|
}
|
|
const result = runWorkflowShellScript(expectDefined(step.run, "asset preparation script"), {
|
|
cwd: root,
|
|
env: { ...process.env, HISTORICAL_TARGET: String(testCase.historical) },
|
|
});
|
|
|
|
expect(result.status, result.stderr).toBe(testCase.expectedStatus);
|
|
expect(existsSync(marker)).toBe(testCase.helperPresent);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ historical: false, hasWatchRtc: false, expected: true },
|
|
{ historical: true, hasWatchRtc: true, expected: true },
|
|
{ historical: true, hasWatchRtc: false, expected: false },
|
|
])("prepares Watch RTC by source capability: %j", ({ historical, hasWatchRtc, expected }) => {
|
|
const workflow = readCiWorkflow();
|
|
for (const jobName of ["ios-build", "ios-screenshot-shard"]) {
|
|
const install = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Install Watch Rust toolchain",
|
|
);
|
|
const engine = workflow.jobs["ios-build"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Test Watch RTC engine",
|
|
);
|
|
for (const phase of ["smoke", "tests", "release"]) {
|
|
const context: Parameters<typeof evaluateWorkflowExpression>[1] = {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
env: { HISTORICAL_TARGET: String(historical) },
|
|
matrix: { phase },
|
|
fileHashes: hasWatchRtc ? { "apps/shared/OpenClawWatchRTC/Cargo.toml": "present" } : {},
|
|
};
|
|
expect(evaluateWorkflowExpression(`\${{ ${install.if} }}`, context)).toBe(expected);
|
|
expect(evaluateWorkflowExpression(`\${{ ${engine.if} }}`, context)).toBe(
|
|
expected && phase === "tests",
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("retries macOS release builds only when Sparkle metadata is incomplete", () => {
|
|
const workflow = readCiWorkflow();
|
|
const macosInstallStep = workflow.jobs["macos-swift"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Install XcodeGen / SwiftLint / SwiftFormat",
|
|
);
|
|
const iosInstallStep = workflow.jobs["ios-build"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Install iOS Swift tooling",
|
|
);
|
|
const macosLintStep = workflow.jobs["macos-swift"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Swift lint",
|
|
);
|
|
const iosLintStep = workflow.jobs["ios-build"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Swift lint",
|
|
);
|
|
const buildStep = workflow.jobs["macos-swift"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Swift build (release)",
|
|
);
|
|
const validateCacheStep = workflow.jobs["macos-swift"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate Swift build cache",
|
|
);
|
|
|
|
for (const installStep of [macosInstallStep, iosInstallStep]) {
|
|
const currentTargetBranch = installStep.run.split('elif [[ "$HISTORICAL_TARGET"')[0];
|
|
expect(currentTargetBranch).toContain(
|
|
"if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then",
|
|
);
|
|
expect(currentTargetBranch).toContain('./scripts/install-xcodegen.sh "$swift_tools_dir"');
|
|
expect(currentTargetBranch).toContain('"$swift_tools_dir/xcodegen" --version');
|
|
expect(currentTargetBranch).not.toContain("brew ");
|
|
expect(installStep.run).toContain("brew install xcodegen swiftlint");
|
|
expect(installStep.run).not.toContain("brew install xcodegen swiftlint swiftformat");
|
|
expect(installStep.run).toContain(
|
|
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip",
|
|
);
|
|
expect(installStep.run).toContain("--connect-timeout 10 --max-time 120");
|
|
expect(installStep.run).toContain("--retry 3 --retry-max-time 120");
|
|
expect(installStep.run).toContain(
|
|
'swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"',
|
|
);
|
|
expect(installStep.run).toContain(
|
|
'swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"',
|
|
);
|
|
expect(installStep.run).toContain(
|
|
'[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]',
|
|
);
|
|
}
|
|
for (const jobName of ["macos-swift", "ios-build"]) {
|
|
expect(workflow.jobs[jobName].env.HISTORICAL_TARGET).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target }}",
|
|
);
|
|
}
|
|
expect(iosInstallStep.run).toContain('swiftformat_link="$(brew --prefix)/bin/swiftformat"');
|
|
expect(iosInstallStep.run).toContain(
|
|
'ln -sfn "$swift_tools_dir/swiftformat" "$swiftformat_link"',
|
|
);
|
|
expect(iosInstallStep.run).toContain(
|
|
'[[ "$("$swiftformat_link" --version)" == "$swiftformat_version" ]]',
|
|
);
|
|
for (const lintStep of [macosLintStep, iosLintStep]) {
|
|
expect(lintStep.run).toContain(
|
|
"if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then",
|
|
);
|
|
}
|
|
expect(macosLintStep.run).toContain("swiftlint lint --config config/swiftlint.yml");
|
|
expect(macosLintStep.run).toContain("swiftformat --lint apps/macos/Sources");
|
|
expect(iosLintStep.run).toContain("skipping iOS lint for this frozen target");
|
|
|
|
const runCacheFixture = (artifactState: "no-build" | "absent" | "incomplete" | "complete") => {
|
|
const root = tempDirs.make(`openclaw-swift-cache-${artifactState}-`);
|
|
const binDir = path.join(root, "bin");
|
|
const buildDir = path.join(root, "apps/macos/.build");
|
|
const frameworkDir = path.join(
|
|
root,
|
|
"apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework",
|
|
);
|
|
const callsPath = path.join(root, "swift-calls");
|
|
const outputPath = path.join(root, "github-output");
|
|
mkdirSync(binDir, { recursive: true });
|
|
if (artifactState === "absent") {
|
|
mkdirSync(buildDir, { recursive: true });
|
|
} else if (artifactState === "incomplete" || artifactState === "complete") {
|
|
mkdirSync(frameworkDir, { recursive: true });
|
|
}
|
|
if (artifactState === "complete") {
|
|
writeFileSync(path.join(frameworkDir, "Info.plist"), "complete\n", "utf8");
|
|
}
|
|
writeFileSync(
|
|
path.join(binDir, "swift"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf '%s\\n' "$*" >> "$SWIFT_CALLS"
|
|
`,
|
|
"utf8",
|
|
);
|
|
chmodSync(path.join(binDir, "swift"), 0o755);
|
|
const result = runWorkflowShellScript(validateCacheStep.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_OUTPUT: outputPath,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
SWIFT_CALLS: callsPath,
|
|
},
|
|
});
|
|
const calls = existsSync(callsPath) ? readFileSync(callsPath, "utf8").trim().split("\n") : [];
|
|
return {
|
|
calls,
|
|
output: readFileSync(outputPath, "utf8").trim(),
|
|
status: result.status,
|
|
};
|
|
};
|
|
|
|
for (const artifactState of ["no-build", "complete"] as const) {
|
|
const result = runCacheFixture(artifactState);
|
|
expect(result.status).toBe(0);
|
|
expect(result.calls).toEqual([]);
|
|
expect(result.output).toBe("cache-valid=true");
|
|
}
|
|
for (const artifactState of ["absent", "incomplete"] as const) {
|
|
const result = runCacheFixture(artifactState);
|
|
expect(result.status).toBe(0);
|
|
expect(result.calls).toEqual(["package --package-path apps/macos reset"]);
|
|
expect(result.output).toBe("cache-valid=false");
|
|
}
|
|
|
|
const runBuildFixture = (
|
|
artifactState: "absent" | "incomplete" | "complete",
|
|
buildOutcome: "recover" | "fail",
|
|
) => {
|
|
const root = tempDirs.make(`openclaw-swift-build-${artifactState}-${buildOutcome}-`);
|
|
const binDir = path.join(root, "bin");
|
|
const frameworkDir = path.join(
|
|
root,
|
|
"apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework",
|
|
);
|
|
const callsPath = path.join(root, "swift-calls");
|
|
mkdirSync(binDir, { recursive: true });
|
|
if (artifactState === "incomplete" || artifactState === "complete") {
|
|
mkdirSync(frameworkDir, { recursive: true });
|
|
}
|
|
if (artifactState === "complete") {
|
|
writeFileSync(path.join(frameworkDir, "Info.plist"), "complete\n", "utf8");
|
|
}
|
|
writeFileSync(
|
|
path.join(binDir, "swift"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf '%s\\n' "$*" >> "$SWIFT_CALLS"
|
|
if [[ "\${1:-}" == "package" ]]; then
|
|
exit 0
|
|
fi
|
|
build_count="$(grep -c '^build ' "$SWIFT_CALLS")"
|
|
if [[ "$BUILD_OUTCOME" == "recover" && "$build_count" -eq 2 ]]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
`,
|
|
"utf8",
|
|
);
|
|
chmodSync(path.join(binDir, "swift"), 0o755);
|
|
const result = runWorkflowShellScript(buildStep.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
BUILD_OUTCOME: buildOutcome,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
SWIFT_CALLS: callsPath,
|
|
},
|
|
});
|
|
return {
|
|
calls: readFileSync(callsPath, "utf8").trim().split("\n"),
|
|
output: `${result.stdout}${result.stderr}`,
|
|
status: result.status,
|
|
};
|
|
};
|
|
|
|
const releaseBuildCommand =
|
|
"build --package-path apps/macos --product OpenClaw --configuration release";
|
|
const packageResetCommand = "package --package-path apps/macos reset";
|
|
|
|
const absentFramework = runBuildFixture("absent", "fail");
|
|
expect(absentFramework.status).toBe(1);
|
|
expect(absentFramework.calls).toEqual([releaseBuildCommand]);
|
|
|
|
const recovered = runBuildFixture("incomplete", "recover");
|
|
expect(recovered.status).toBe(0);
|
|
expect(recovered.calls).toEqual([
|
|
releaseBuildCommand,
|
|
packageResetCommand,
|
|
releaseBuildCommand,
|
|
]);
|
|
expect(recovered.output).toContain("did not produce complete Sparkle metadata");
|
|
|
|
const completeFramework = runBuildFixture("complete", "fail");
|
|
expect(completeFramework.status).toBe(1);
|
|
expect(completeFramework.calls).toEqual([releaseBuildCommand]);
|
|
|
|
const secondFailure = runBuildFixture("incomplete", "fail");
|
|
expect(secondFailure.status).toBe(1);
|
|
expect(secondFailure.calls).toEqual([
|
|
releaseBuildCommand,
|
|
packageResetCommand,
|
|
releaseBuildCommand,
|
|
]);
|
|
});
|
|
|
|
it("uses native macOS Swift tests and preserves the first failure", () => {
|
|
const workflow = readCiWorkflow();
|
|
const macosSwift = workflow.jobs["macos-swift"];
|
|
const testStep = macosSwift.steps.find((step: WorkflowStep) => step.name === "Swift test");
|
|
const buildCache = macosSwift.steps.find(
|
|
(step: WorkflowStep) => step.id === "swift-build-cache",
|
|
);
|
|
const nativeCachePrefix =
|
|
"${{ runner.os }}-swift-build-${{ matrix.phase == 'tests' && 'v7' || 'v6' }}-${{ matrix.phase }}-${{ hashFiles('scripts/swift-build-cache-metadata.py') }}-graph-${{ steps.swift-toolchain.outputs.key }}-" +
|
|
"${{ hashFiles('apps/macos/Package*.swift', 'apps/macos/Package.resolved', 'apps/shared/**/Package*.swift', 'apps/shared/**/Package.resolved', 'apps/swabble/Package*.swift', 'apps/swabble/Package.resolved') }}-";
|
|
|
|
expect(buildCache.with).toMatchObject({
|
|
key: expect.stringContaining(nativeCachePrefix),
|
|
"restore-keys": `${nativeCachePrefix}\n`,
|
|
});
|
|
const restoreMetadata = macosSwift.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore Swift build input timestamps",
|
|
);
|
|
const recordMetadata = macosSwift.steps.find(
|
|
(step: WorkflowStep) => step.name === "Record Swift build input timestamps",
|
|
);
|
|
const saveBuildCache = macosSwift.steps.find(
|
|
(step: WorkflowStep) => step.name === "Save Swift build directory cache",
|
|
);
|
|
expect(restoreMetadata.if).toBe(
|
|
"steps.validate-swift-build-cache.outputs.cache-valid == 'true' && env.HISTORICAL_TARGET != 'true'",
|
|
);
|
|
expect(restoreMetadata.run).toBe("python3 -I -S scripts/swift-build-cache-metadata.py restore");
|
|
expect(recordMetadata.run).toBe("python3 -I -S scripts/swift-build-cache-metadata.py record");
|
|
const saveEligibility = saveBuildCache.if.split(" && (env.HISTORICAL_TARGET")[0];
|
|
expect(recordMetadata.if).toBe(`${saveEligibility} && env.HISTORICAL_TARGET != 'true'`);
|
|
expect(saveBuildCache.if).toBe(
|
|
`${saveEligibility} && (env.HISTORICAL_TARGET == 'true' || steps.record-swift-build-cache-metadata.outcome == 'success')`,
|
|
);
|
|
const stepIndex = (step: WorkflowStep) => macosSwift.steps.indexOf(step);
|
|
expect(stepIndex(restoreMetadata)).toBeLessThan(stepIndex(testStep));
|
|
expect(stepIndex(recordMetadata)).toBeGreaterThan(stepIndex(testStep));
|
|
expect(stepIndex(recordMetadata) + 1).toBe(stepIndex(saveBuildCache));
|
|
expect(macosSwift.env).not.toHaveProperty("SWIFT_TEST_EXECUTION");
|
|
expect(testStep.id).toBe("swift-test");
|
|
const currentTargetBranch = testStep.run.split('elif [[ "$HISTORICAL_TARGET" == "true" ]]')[0];
|
|
expect(currentTargetBranch).toContain('logical_cpu="$(sysctl -n hw.logicalcpu)"');
|
|
expect(currentTargetBranch).toContain('[[ ! "$logical_cpu" =~ ^[1-9][0-9]*$ ]]');
|
|
expect(currentTargetBranch).toContain(
|
|
"swift_test_width=$(( logical_cpu < 12 ? logical_cpu : 12 ))",
|
|
);
|
|
expect(currentTargetBranch).toContain(
|
|
'swift_test_args+=(--experimental-maximum-parallelization-width "$swift_test_width")',
|
|
);
|
|
expect(currentTargetBranch).not.toContain("swift_test_args+=(--parallel)");
|
|
expect(currentTargetBranch).not.toContain("--no-parallel");
|
|
expect(testStep.run).toContain("swift_test_args+=(--no-parallel)");
|
|
|
|
for (const buildExitCode of [0, 23]) {
|
|
const root = tempDirs.make(`openclaw-swift-test-${buildExitCode}-`);
|
|
const binDir = path.join(root, "bin");
|
|
const callsPath = path.join(root, "swift-calls");
|
|
const outputPath = path.join(root, "github-output");
|
|
mkdirSync(binDir, { recursive: true });
|
|
symlinkSync(path.resolve("scripts"), path.join(root, "scripts"), "dir");
|
|
mkdirSync(path.join(root, ".ci-harness/scripts"), { recursive: true });
|
|
symlinkSync(path.resolve("scripts/lib"), path.join(root, ".ci-harness/scripts/lib"), "dir");
|
|
writeFileSync(
|
|
path.join(binDir, "swift"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
SWIFT_CALLS=${JSON.stringify(callsPath)}
|
|
GITHUB_OUTPUT=${JSON.stringify(outputPath)}
|
|
BUILD_EXIT_CODE=${buildExitCode}
|
|
printf '%s\\n' "$*" >> "$SWIFT_CALLS"
|
|
if [[ "\${1:-}" == "build" ]]; then
|
|
[[ ! -s "$GITHUB_OUTPUT" ]] || exit 24
|
|
exit "$BUILD_EXIT_CODE"
|
|
fi
|
|
test_count="$(grep -c '^test ' "$SWIFT_CALLS")"
|
|
[[ "$test_count" -gt 1 ]]
|
|
`,
|
|
"utf8",
|
|
);
|
|
chmodSync(path.join(binDir, "swift"), 0o755);
|
|
writeFileSync(path.join(binDir, "sysctl"), "#!/usr/bin/env bash\nprintf '4\\n'\n", {
|
|
mode: 0o755,
|
|
});
|
|
// This fixture executes the real launcher: never fall through to host Security.
|
|
writeFileSync(
|
|
path.join(binDir, "security"),
|
|
`#!${process.execPath}
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const args = process.argv.slice(2);
|
|
assert.notEqual(process.env.HOME, ${JSON.stringify(root)});
|
|
assert.equal(path.dirname(args.at(-1)), path.join(process.env.HOME, 'Library/Keychains'));
|
|
if (args[0] === 'create-keychain') fs.writeFileSync(args.at(-1), 'inert keychain');
|
|
if (args[0] === 'delete-keychain') fs.unlinkSync(args.at(-1));
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const result = runWorkflowShellScript(testStep.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
CI: "true",
|
|
GITHUB_ACTIONS: "true",
|
|
RUNNER_OS: "macOS",
|
|
RUNNER_TEMP: root,
|
|
HOME: root,
|
|
GITHUB_OUTPUT: outputPath,
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
SWIFT_TEST_EXECUTION: "serial",
|
|
HISTORICAL_TARGET: "false",
|
|
},
|
|
});
|
|
const calls = readFileSync(callsPath, "utf8").trim().split("\n");
|
|
expect(result.status).toBe(buildExitCode || 1);
|
|
expect(calls).toEqual([
|
|
"build --package-path apps/macos --build-system native --enable-code-coverage --disable-index-store -Xswiftc -gline-tables-only --build-tests",
|
|
...(buildExitCode === 0
|
|
? [
|
|
expect.stringMatching(
|
|
/^test --package-path apps\/macos --build-system native --enable-code-coverage --disable-index-store -Xswiftc -gline-tables-only --skip-build --experimental-maximum-parallelization-width 4 --skip AppStateIsolationTests\|ProfileChatPreferencesTests\|QuickChatCatalogPresentationTests --event-stream-output-path \S+\/swift-testing-events\.jsonl --event-stream-version 6\.3$/,
|
|
),
|
|
]
|
|
: []),
|
|
]);
|
|
const output = existsSync(outputPath) ? readFileSync(outputPath, "utf8").trim() : "";
|
|
const outputLines = output.split("\n");
|
|
if (buildExitCode === 0) {
|
|
expect(outputLines).toHaveLength(2);
|
|
expect(outputLines[0]).toBe("debug-tests-built=true");
|
|
expect(
|
|
outputLines[1]?.startsWith(`menu-default-artifact-path=${root}/openclaw-menu-default-`),
|
|
).toBe(true);
|
|
} else {
|
|
expect(output).toBe("");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("bounds the Windows Crabbox hydrate main fetch", () => {
|
|
const workflow = readFileSync(".github/workflows/crabbox-hydrate.yml", "utf8");
|
|
|
|
expect(workflow).toContain("$fetchInfo = New-Object System.Diagnostics.ProcessStartInfo");
|
|
expect(workflow).toContain('$fetchInfo.FileName = "git"');
|
|
expect(workflow).toContain("$fetchInfo.WorkingDirectory = $repo");
|
|
expect(workflow).toContain("$fetchInfo.UseShellExecute = $false");
|
|
expect(workflow).not.toContain("$fetchInfo.RedirectStandardOutput = $true");
|
|
expect(workflow).not.toContain("$fetchInfo.RedirectStandardError = $true");
|
|
expect(workflow).toContain(
|
|
"--no-tags --no-progress --prune --no-recurse-submodules --depth=50",
|
|
);
|
|
expect(workflow).toContain("$fetch = New-Object System.Diagnostics.Process");
|
|
expect(workflow).toContain("$fetch.StartInfo = $fetchInfo");
|
|
expect(workflow).toContain("$fetch.WaitForExit(30000)");
|
|
expect(workflow).toContain("$fetch.Kill()");
|
|
expect(workflow).not.toContain("StandardOutput.ReadToEnd()");
|
|
expect(workflow).not.toContain("StandardError.ReadToEnd()");
|
|
expect(workflow).toContain('throw "git fetch failed with exit code $($fetch.ExitCode)"');
|
|
expect(workflow).toContain('throw "git fetch timed out after 30 seconds"');
|
|
expect(workflow).not.toContain(
|
|
'git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"',
|
|
);
|
|
});
|
|
|
|
it("bounds Mantis Slack runner IP discovery", () => {
|
|
const workflow = parse(
|
|
readFileSync(".github/workflows/mantis-slack-desktop-smoke.yml", "utf8"),
|
|
) as { jobs: { run_slack_desktop: { steps: WorkflowStep[] } } };
|
|
const runStep = workflow.jobs.run_slack_desktop.steps.find(
|
|
(step) => step.name === "Run Slack desktop scenario",
|
|
);
|
|
|
|
expect(runStep?.run).toContain("for attempt in 1 2 3");
|
|
expect(runStep?.run).toContain(
|
|
"curl -fsS --connect-timeout 5 --max-time 15 https://checkip.amazonaws.com",
|
|
);
|
|
expect(runStep?.run).not.toContain("--retry");
|
|
expect(runStep?.run).toContain('runner_ip=""');
|
|
expect(runStep?.run).toContain('[[ ! "$runner_ip" =~ ^(0|[1-9][0-9]{0,2})\\.');
|
|
expect(runStep?.run).toContain("((10#$octet > 255))");
|
|
|
|
const discoveryBlock = runStep?.run?.match(
|
|
/runner_ip=""[\s\S]*?echo "Using AWS SSH CIDR \$\{CRABBOX_AWS_SSH_CIDRS\}"/u,
|
|
)?.[0];
|
|
expect(discoveryBlock).toBeTruthy();
|
|
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-mantis-runner-ip-"));
|
|
try {
|
|
const fakeBin = path.join(root, "bin");
|
|
const callCount = path.join(root, "curl-calls");
|
|
mkdirSync(fakeBin);
|
|
writeFileSync(callCount, "0\n");
|
|
writeFileSync(
|
|
path.join(fakeBin, "curl"),
|
|
`#!/bin/bash
|
|
count="$(<"$CURL_CALL_COUNT")"
|
|
count=$((count + 1))
|
|
printf '%s\n' "$count" >"$CURL_CALL_COUNT"
|
|
if [[ "$count" == "1" ]]; then
|
|
printf '198.51.'
|
|
exit 28
|
|
fi
|
|
printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(path.join(fakeBin, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
|
|
const result = spawnSync(
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`set -euo pipefail\n${discoveryBlock}\nprintf 'result=%s\\n' "$CRABBOX_AWS_SSH_CIDRS"`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
CURL_CALL_COUNT: callCount,
|
|
PATH: `${fakeBin}:${process.env.PATH}`,
|
|
},
|
|
},
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("result=203.0.113.7/32");
|
|
expect(result.stdout).not.toContain("198.51.");
|
|
expect(readFileSync(callCount, "utf8")).toBe("2\n");
|
|
|
|
for (const invalidIp of ["999.0.0.1", "203.0.113.7."]) {
|
|
writeFileSync(callCount, "0\n");
|
|
const invalidResult = spawnSync("bash", ["-c", `set -euo pipefail\n${discoveryBlock}`], {
|
|
encoding: "utf8",
|
|
env: {
|
|
CURL_CALL_COUNT: callCount,
|
|
CURL_SUCCESS_IP: invalidIp,
|
|
PATH: `${fakeBin}:${process.env.PATH}`,
|
|
},
|
|
});
|
|
expect(invalidResult.status).toBe(1);
|
|
expect(invalidResult.stderr).toContain(
|
|
"Could not resolve GitHub runner public IPv4 for AWS SSH ingress.",
|
|
);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails Windows Testbox setup when Blacksmith phone-home is not accepted", () => {
|
|
const workflow = readFileSync(".github/workflows/windows-blacksmith-testbox.yml", "utf8");
|
|
const job = parse(workflow).jobs.windows;
|
|
const prepare = job.steps.find((step: WorkflowStep) => step.name === "Prepare Windows SSH");
|
|
const finalize = job.steps.find((step: WorkflowStep) => step.name === "Run Testbox").run;
|
|
|
|
// Windows administrators use the effective ProgramData file and native ACLs.
|
|
// The native handshake is the behavioral proof; this guards workflow wiring.
|
|
expect(prepare?.env).toEqual({
|
|
TESTBOX_PUBLIC_KEY_PATH: "${{ steps.begin_testbox.outputs.public_key_path }}",
|
|
});
|
|
const nativeSetup = prepare?.run ?? "";
|
|
expect(nativeSetup).toContain("WindowsPrincipal");
|
|
expect(nativeSetup).toContain('. "$env:GITHUB_WORKSPACE/scripts/windows-testbox-openssh.ps1"');
|
|
expect(nativeSetup).toContain("$installation = Get-WindowsTestboxOpenSshInstallation");
|
|
expect(nativeSetup).toContain("$sshd = $installation.Sshd");
|
|
expect(
|
|
nativeSetup.indexOf("$installation = Get-WindowsTestboxOpenSshInstallation"),
|
|
).toBeLessThan(nativeSetup.indexOf("$effectiveConfig = & $sshd"));
|
|
expect(nativeSetup).toContain('-T -C "user=$nativeUser"');
|
|
expect(nativeSetup).toContain(
|
|
"authorizedkeysfile __PROGRAMDATA__/ssh/administrators_authorized_keys",
|
|
);
|
|
expect(nativeSetup).toContain("$keygen = $installation.Keygen");
|
|
expect(nativeSetup).toContain("-E sha256 -lf $env:TESTBOX_PUBLIC_KEY_PATH");
|
|
expect(nativeSetup).toContain("[IO.File]::AppendAllText($authorizedKeys,");
|
|
expect(nativeSetup).toContain("S-1-5-18");
|
|
expect(nativeSetup).toContain("S-1-5-32-544");
|
|
expect(nativeSetup).toContain("SetAccessRuleProtection($true, $false)");
|
|
expect(nativeSetup).toContain('"FullControl", "Allow"');
|
|
expect(nativeSetup).toContain("Set-Acl -LiteralPath $authorizedKeys");
|
|
expect(workflow).not.toContain(">> ~/.ssh/authorized_keys");
|
|
|
|
expect(finalize).toMatch(
|
|
/if \[ "\$JOB_STATUS" != "success" \]; then\s+phone_home_status="hydration_failed"/u,
|
|
);
|
|
expect(finalize).toContain('--arg status "$phone_home_status"');
|
|
expect(finalize.match(/\/api\/testbox\/phone-home/gu)).toHaveLength(1);
|
|
expect(finalize.slice(0, finalize.indexOf('echo "Testbox ready!"'))).toMatch(
|
|
/if \[ "\$phone_home_status" != "ready" \]; then[^]*?exit 1\s+fi/u,
|
|
);
|
|
|
|
expect(workflow.match(/--connect-timeout 10 --max-time 30/gu)).toHaveLength(2);
|
|
expect(workflow).toContain('echo "phone_home_hydrating_curl=${hydrating_curl_status}"');
|
|
expect(workflow).toContain('echo "phone_home_hydrating_http=${hydrating_http_code}"');
|
|
expect(workflow).toContain('echo "phone_home_${phone_home_status}_curl=${final_curl_status}"');
|
|
expect(workflow).toContain('echo "phone_home_${phone_home_status}_http=${http_code}"');
|
|
expect(workflow).toContain('jq -e \'type == "number"\' <<<"$installation_model_id"');
|
|
expect(workflow).toContain('--arg testbox_id "$TESTBOX_ID"');
|
|
expect(workflow).toContain('--arg testbox_id "$testbox_id"');
|
|
expect(workflow).toContain('--argjson installation_model_id "$installation_model_id"');
|
|
expect(workflow).toContain('--data-binary @"$hydrating_body"');
|
|
expect(workflow).toContain('--data-binary @"$final_body"');
|
|
const hydratingFailureBlock = workflow.slice(
|
|
workflow.indexOf(
|
|
'if (( hydrating_curl_status != 0 )) || [[ ! "$hydrating_http_code" =~ ^2 ]]; then',
|
|
),
|
|
workflow.indexOf('response="$(cat "$hydrating_response")"'),
|
|
);
|
|
const missingSshKeyFailureBlock = workflow.slice(
|
|
workflow.indexOf('if [ -z "$ssh_public_key" ]; then'),
|
|
workflow.indexOf('public_key_path="$(cygpath'),
|
|
);
|
|
const finalFailureBlock = workflow.slice(
|
|
workflow.indexOf('if (( final_curl_status != 0 )) || [[ ! "$http_code" =~ ^2 ]]; then'),
|
|
workflow.indexOf('echo "============================================"'),
|
|
);
|
|
|
|
expect(workflow).toContain(')" || hydrating_curl_status=$?');
|
|
expect(workflow).toContain(')" || final_curl_status=$?');
|
|
expect(hydratingFailureBlock).toContain("exit 1");
|
|
expect(missingSshKeyFailureBlock).toContain("exit 1");
|
|
expect(finalFailureBlock).toContain("exit 1");
|
|
expect(workflow).toContain(
|
|
"Blacksmith phone-home did not return an SSH public key; testbox cannot accept native SSH connections.",
|
|
);
|
|
expect(workflow).not.toContain(
|
|
'phone_home_${phone_home_status}_http=${http_code}"\n\n echo "============================================"',
|
|
);
|
|
expect(workflow).not.toContain('\\"testbox_id\\": \\"${TESTBOX_ID}\\"');
|
|
expect(workflow).not.toContain('cat > "$final_body" <<JSON');
|
|
expect(workflow).not.toContain('"testbox_id": "${testbox_id}"');
|
|
});
|
|
|
|
it("runs dependency policy guards in PR CI preflight", () => {
|
|
const parsedWorkflow = readCiWorkflow();
|
|
const workflow = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const preflightGuards = workflow.slice(
|
|
workflow.indexOf("guards)"),
|
|
workflow.indexOf("npm-lock)"),
|
|
);
|
|
const npmLockGuards = workflow.slice(
|
|
workflow.indexOf("npm-lock)"),
|
|
workflow.indexOf("prod-types)"),
|
|
);
|
|
|
|
expect(workflow).toContain("check-guards");
|
|
expect(workflow).toContain("check-npm-lock");
|
|
expect(preflightGuards).toContain('has_package_script "check:doctor-deprecation-registry"');
|
|
expect(preflightGuards).toContain("pnpm check:doctor-deprecation-registry");
|
|
expect(preflightGuards).toContain(
|
|
"[skip] frozen target predates the wall-clock doctor deprecation registry guard",
|
|
);
|
|
expect(preflightGuards).toContain(
|
|
"Current CI targets must provide the check:doctor-deprecation-registry package script.",
|
|
);
|
|
expect(preflightGuards.indexOf('elif [[ "$FROZEN_TARGET" == "true" ]]')).toBeGreaterThan(
|
|
preflightGuards.indexOf("pnpm check:doctor-deprecation-registry"),
|
|
);
|
|
const checkShard = parsedWorkflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
);
|
|
expect(checkShard.env.FROZEN_TARGET).toBe("${{ needs.preflight.outputs.frozen_target }}");
|
|
expect(parsedWorkflow.jobs.preflight.outputs.frozen_target).toBe(
|
|
"${{ steps.manifest.outputs.frozen_target }}",
|
|
);
|
|
expect(preflightGuards).toContain(
|
|
'if [[ "$FROZEN_TARGET" == "true" ]]; then\n' +
|
|
" pnpm dup:check:coverage\n" +
|
|
" else\n" +
|
|
" pnpm dup:check\n" +
|
|
" fi",
|
|
);
|
|
expect(npmLockGuards).toContain("pnpm deps:npm-lock:check");
|
|
expect(preflightGuards).toContain("pnpm deps:patches:check");
|
|
expect(preflightGuards).toContain('has_package_script "check:coercion-helpers"');
|
|
expect(preflightGuards).toContain("pnpm check:coercion-helpers");
|
|
expect(preflightGuards).toContain(
|
|
"[skip] historical target predates the coercion-helper declaration guard",
|
|
);
|
|
expect(preflightGuards).toContain(
|
|
"Current CI targets must provide the check:coercion-helpers package script.",
|
|
);
|
|
expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe(
|
|
"${{ steps.diff_base.outputs.sha }}",
|
|
);
|
|
const diffBaseStep = parsedWorkflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve exact diff base",
|
|
);
|
|
expect(diffBaseStep.run).toContain("--prefer-first-parent");
|
|
expect(diffBaseStep.env.DEFAULT_BRANCH).toBe("${{ github.event.repository.default_branch }}");
|
|
expect(diffBaseStep.env.GH_TOKEN).toBe(
|
|
"${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && github.token || '' }}",
|
|
);
|
|
expect(diffBaseStep.run).toContain(
|
|
'"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${head_sha}"',
|
|
);
|
|
expect(diffBaseStep.run).toContain("Could not resolve an exact diff base");
|
|
expect(diffBaseStep.run).toContain(AMBIGUOUS_MAIN_PUSH_GUARD);
|
|
const securityDiffBase = parsedWorkflow.jobs["security-fast"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve security diff base",
|
|
).run;
|
|
expect(securityDiffBase).toContain("git rev-list --parents -n 1 HEAD");
|
|
expect(securityDiffBase).not.toContain("node scripts/lib/merge-head-diff-base.mjs");
|
|
expect(securityDiffBase).toContain(AMBIGUOUS_MAIN_PUSH_GUARD);
|
|
const checkShardStep = parsedWorkflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
);
|
|
expect(checkShardStep.run).not.toContain("--checkout-git");
|
|
expect(checkShardStep.run).toContain(
|
|
'test "$(git rev-parse refs/remotes/origin/ci-ratchet-base^{commit})" = "$CHECKOUT_BASE_SHA"',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ job: "check-shard", task: "prod-types", events: [] },
|
|
{
|
|
job: "checks-fast-core",
|
|
task: "startup-corpus",
|
|
events: ["pull_request", "push", "workflow_dispatch"],
|
|
},
|
|
{
|
|
job: "checks-fast-core",
|
|
task: "release-lint-core-1",
|
|
events: ["pull_request", "push", "workflow_dispatch"],
|
|
},
|
|
{ job: "checks-fast-core", task: "ci-routing", events: [] },
|
|
])("prepares the frozen diff base for $job/$task at checkout", ({ job, task, events }) => {
|
|
const expression = readCiWorkflow().jobs[job].env?.CHECKOUT_BASE_SHA;
|
|
const base = "c".repeat(40);
|
|
expect(typeof expression).toBe("string");
|
|
for (const eventName of ["pull_request", "push", "workflow_dispatch"] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, {
|
|
eventName,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
matrix: { task },
|
|
preflightOutputs: { diff_base_revision: base },
|
|
}),
|
|
eventName,
|
|
).toBe(events.includes(eventName) ? base : "");
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("loads the Node shard planner from its owner (frozen=%s)", (frozen) => {
|
|
const workflow = readCiWorkflow();
|
|
const step = workflow.jobs.preflight.steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Build CI manifest",
|
|
);
|
|
const selection = expectDefined(
|
|
step.run.match(/const nodeTestPlanPath =[\s\S]*?(?=const importTargetPlan)/u)?.[0],
|
|
"Node planner selection",
|
|
);
|
|
const root = tempDirs.make("ci-planner-owner-");
|
|
writeFileSync(path.join(root, "candidate.txt"), "candidate-source");
|
|
for (const [directory, owner] of [
|
|
["scripts/lib", "candidate"],
|
|
[".ci-harness/scripts/lib", "workflow"],
|
|
] as const) {
|
|
mkdirSync(path.join(root, directory), { recursive: true });
|
|
writeFileSync(
|
|
path.join(root, directory, "ci-node-test-plan.mts"),
|
|
`import { readFileSync } from "node:fs";
|
|
export const SOURCE_CHANNEL_TEST_POLICY = ${JSON.stringify(SOURCE_CHANNEL_TEST_POLICY)};
|
|
export const createNodeTestShardBundles = () =>
|
|
[${JSON.stringify(owner)}, readFileSync("candidate.txt", "utf8")];`,
|
|
);
|
|
}
|
|
const run = spawnSync(testNodeExecPath, ["--input-type=module"], {
|
|
cwd: root,
|
|
input: `import { existsSync } from "node:fs";
|
|
const frozenTarget = ${frozen};
|
|
const compatibilityTarget = true;
|
|
${selection}
|
|
console.log(JSON.stringify(createNodeTestPlan()));`,
|
|
encoding: "utf8",
|
|
});
|
|
expect(run.status, run.stderr).toBe(0);
|
|
expect(JSON.parse(run.stdout)).toEqual([frozen ? "workflow" : "candidate", "candidate-source"]);
|
|
if (frozen) {
|
|
const checkout = workflow.jobs.preflight.steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Checkout trusted CI harness",
|
|
);
|
|
expect(checkout.with.ref).toBe("${{ github.workflow_sha }}");
|
|
expect(checkout.with["sparse-checkout"]).toContain("/scripts/");
|
|
expect(checkout.with["sparse-checkout"]).toContain("/test/vitest/");
|
|
expect(checkout.with["sparse-checkout"]).toContain("/config/ci-test-timings.json");
|
|
}
|
|
});
|
|
|
|
it("imports the real frozen planner from the declared sparse checkout", () => {
|
|
const checkout = readCiWorkflow().jobs.preflight.steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Checkout trusted CI harness",
|
|
);
|
|
const root = tempDirs.make("ci-planner-sparse-");
|
|
for (const entry of String(checkout.with["sparse-checkout"]).trim().split("\n")) {
|
|
const relative = entry.replace(/^\//u, "");
|
|
const destination = path.join(root, ".ci-harness", relative);
|
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
cpSync(relative, destination, { recursive: true });
|
|
}
|
|
const run = spawnSync(testNodeExecPath, ["--input-type=module"], {
|
|
cwd: root,
|
|
input: 'await import("./.ci-harness/scripts/lib/ci-node-test-plan.mts");',
|
|
encoding: "utf8",
|
|
});
|
|
expect(run.status, run.stderr).toBe(0);
|
|
});
|
|
|
|
it("keeps the preflight manifest import closure dependency-free", () => {
|
|
const manifestStep = readCiWorkflow().jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
const manifestRun = expectDefined(manifestStep?.run, "Build CI manifest script");
|
|
const manifestSource = expectDefined(
|
|
manifestRun.match(/--input-type=module <<'([A-Z][A-Z0-9_]*)'\n([\s\S]*?)\n\1(?=\n|$)/u)?.[2],
|
|
"Build CI manifest Node source",
|
|
);
|
|
const { result, manifest } = runDependencyFreePreflight(
|
|
manifestSource,
|
|
tempDirs.make("ci-preflight-dependencies-"),
|
|
testNodeExecPath,
|
|
);
|
|
expect(
|
|
result.status,
|
|
`${result.error?.message ?? ""}\n${result.stdout}\n${result.stderr}`,
|
|
).toBe(0);
|
|
expect(manifest).toContain("run_node=true\n");
|
|
expect(manifest).toContain("run_windows=true\n");
|
|
});
|
|
|
|
it("runs mobile protocol coverage for Node and native-only changes", () => {
|
|
const workflow = readCiWorkflow();
|
|
const coverageStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check mobile protocol event coverage",
|
|
);
|
|
const checkShardRun = workflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
).run;
|
|
|
|
// Current-source preflight runs the .mts natively; dispatches selecting
|
|
// another revision retain that target's tsx shim.
|
|
expect(coverageStep.run).toContain("node scripts/check-protocol-event-coverage.mts");
|
|
expect(coverageStep.run).toContain("node scripts/check-protocol-event-coverage.mjs");
|
|
expect(coverageStep.if).toBe("steps.manifest.outputs.run_protocol_event_coverage == 'true'");
|
|
expect(checkShardRun).not.toContain("check:protocol-coverage");
|
|
});
|
|
|
|
it("keeps type-aware oxlint within hosted fork-runner resources", () => {
|
|
const workflow = readCiWorkflow();
|
|
const manifestStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
const checkShardStep = workflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
);
|
|
const checkShardRun = checkShardStep.run;
|
|
const hostedCoreLint = workflow.jobs["check-lint-hosted-core-shard"];
|
|
const hostedExtensionLint = workflow.jobs["check-lint-hosted-extension-shard"];
|
|
const hostedCoreTypes = workflow.jobs["check-test-types-hosted-core-shard"];
|
|
expect(manifestStep.env.OPENCLAW_CI_RUNNER_PROFILE).toBe(
|
|
"${{ steps.runner_profile.outputs.runner_profile }}",
|
|
);
|
|
expect(manifestStep.run).toContain("runnerBackend: nodeRunnerBackend");
|
|
expect(checkShardStep.env.RUNNER_PROFILE).toBe("${{ needs.preflight.outputs.runner_profile }}");
|
|
expect(checkShardStep.env.HOSTED_RUNNER_STRIPES).toContain(
|
|
"needs.preflight.outputs.hosted_runner_profile_contract == 'true'",
|
|
);
|
|
expect(checkShardRun).toContain('if [ "$HOSTED_RUNNER_STRIPES" = "true" ]; then');
|
|
expect(checkShardStep.env.RELEASE_GATE).toBe(
|
|
"${{ inputs.release_gate && (needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') && 'true' || 'false' }}",
|
|
);
|
|
expect(checkShardRun).toContain("lint_args=(--only=extensions --only=scripts --threads=1)");
|
|
expect(checkShardRun).toContain('if [ "$RELEASE_GATE" = "true" ]; then');
|
|
expect(checkShardRun).toContain("lint_args=(--only=scripts --threads=1)");
|
|
expect(checkShardRun).toContain('elif [ "$(nproc)" -lt 8 ]; then');
|
|
expect(checkShardRun).toContain("lint_args=(--threads=1)");
|
|
expect(checkShardRun).not.toContain("lint_args=(--split-core --threads=1)");
|
|
expect(checkShardRun).toContain('pnpm lint "${lint_args[@]}"');
|
|
expect(checkShardRun).toContain(
|
|
'node --import tsx scripts/run-oxlint-shards.mts "${lint_args[@]}"',
|
|
);
|
|
for (const job of [hostedCoreLint, hostedCoreTypes]) {
|
|
expect(job.if).toContain("needs.preflight.outputs.runner_profile == 'github'");
|
|
expect(job.if).toContain("needs.preflight.outputs.runner_profile == 'hybrid'");
|
|
expect(job.if).toContain("needs.preflight.outputs.hosted_runner_profile_contract == 'true'");
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
eventName: "workflow_dispatch",
|
|
frozenTarget: true,
|
|
hostedRunnerProfileContract: false,
|
|
repository: "openclaw/openclaw",
|
|
runnerProfile: "blacksmith",
|
|
runAttempt: 1,
|
|
preflightOutputs: { run_lint_core: "true" },
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
eventName: "workflow_dispatch",
|
|
frozenTarget: true,
|
|
hostedRunnerProfileContract: true,
|
|
repository: "openclaw/openclaw",
|
|
runnerProfile: "github",
|
|
runAttempt: 1,
|
|
preflightOutputs: { run_lint_core: "true" },
|
|
}),
|
|
).toBe(true);
|
|
for (const [runnerProfile, expected] of [
|
|
["blacksmith", false],
|
|
["github", true],
|
|
["hybrid", true],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(job.if, {
|
|
eventName: "pull_request",
|
|
frozenTarget: false,
|
|
hostedRunnerProfileContract: true,
|
|
repository: "openclaw/openclaw",
|
|
runnerProfile,
|
|
runAttempt: 1,
|
|
preflightOutputs: { run_lint_core: "true" },
|
|
}),
|
|
).toBe(expected);
|
|
}
|
|
}
|
|
expect(hostedCoreLint.strategy["fail-fast"]).toBe(false);
|
|
expect(hostedCoreLint.strategy["max-parallel"]).toBe(5);
|
|
const coreLintStep = hostedCoreLint.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run hosted core lint stripe",
|
|
);
|
|
const extensionLintStep = hostedExtensionLint.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run hosted extension lint stripe",
|
|
);
|
|
expect(coreLintStep.env.CORE_STRIPE).toBe("${{ matrix.stripe }}");
|
|
type GoEnv = Partial<Pick<NodeJS.ProcessEnv, "GOMAXPROCS" | "GOGC" | "GOMEMLIMIT">>;
|
|
const goEnvKeys = ["GOMAXPROCS", "GOGC", "GOMEMLIMIT"] as const;
|
|
const runLintOwner = ({
|
|
capability,
|
|
cpuCount = 32,
|
|
eventName = "workflow_dispatch",
|
|
failStripe,
|
|
frozenTarget = !capability,
|
|
goEnv = {},
|
|
expectedGoEnv = goEnv,
|
|
lane,
|
|
nodeRunnerBackend,
|
|
profile,
|
|
releaseGate = false,
|
|
runAttempt = 1,
|
|
stripe = 1,
|
|
}: {
|
|
capability: boolean;
|
|
cpuCount?: number;
|
|
eventName?: "pull_request" | "push" | "workflow_dispatch";
|
|
failStripe?: number;
|
|
frozenTarget?: boolean;
|
|
goEnv?: GoEnv;
|
|
expectedGoEnv?: GoEnv;
|
|
lane: "check" | "core" | "extensions";
|
|
nodeRunnerBackend?: "runson";
|
|
profile: "blacksmith" | "github" | "hybrid";
|
|
releaseGate?: boolean;
|
|
runAttempt?: number;
|
|
stripe?: number;
|
|
}) => {
|
|
const root = tempDirs.make("openclaw-hosted-lint-owner-");
|
|
const binDir = path.join(root, "bin");
|
|
const callsPath = path.join(root, "calls.txt");
|
|
const goEnvPath = path.join(root, "go-env.txt");
|
|
mkdirSync(path.join(root, "scripts"), { recursive: true });
|
|
mkdirSync(binDir);
|
|
writeFileSync(
|
|
path.join(root, "scripts/run-oxlint-shards.mts"),
|
|
capability ? "// --extension-stripe\n" : "// legacy runner\n",
|
|
);
|
|
for (const command of ["node", "pnpm"]) {
|
|
writeExecutable(path.join(binDir, command), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`printf '${command} %s\\n' "$*" >> "$LINT_CALLS"`,
|
|
'printf \'%s\\t%s\\t%s\\n\' "${GOMAXPROCS-}" "${GOGC-}" "${GOMEMLIMIT-}" >> "$LINT_GO_ENV"',
|
|
...(failStripe === undefined
|
|
? []
|
|
: [
|
|
`if [[ " $* " == *" --core-stripe=${failStripe}/5 "* || " $* " == *" --extension-stripe=${failStripe}/6 "* ]]; then exit 23; fi`,
|
|
]),
|
|
]);
|
|
}
|
|
writeExecutable(path.join(binDir, "nproc"), [
|
|
"#!/usr/bin/env bash",
|
|
`printf '${cpuCount}\\n'`,
|
|
]);
|
|
const expressionContext = {
|
|
eventName,
|
|
frozenTarget,
|
|
matrix: { stripe },
|
|
releaseGate,
|
|
repository: "openclaw/openclaw",
|
|
runnerProfile: profile,
|
|
runAttempt,
|
|
preflightOutputs: { node_runner_backend: nodeRunnerBackend ?? "" },
|
|
};
|
|
const step = { check: checkShardStep, core: coreLintStep, extensions: extensionLintStep }[
|
|
lane
|
|
];
|
|
const command = step.run.replace(/\$\{\{[\s\S]*?\}\}/gu, (expression: string) =>
|
|
String(evaluateWorkflowExpression(expression, expressionContext)),
|
|
);
|
|
const stepEnv = step.env;
|
|
const result = spawnSync("bash", ["-c", command], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GOMAXPROCS: undefined,
|
|
GOGC: undefined,
|
|
GOMEMLIMIT: undefined,
|
|
...goEnv,
|
|
...Object.fromEntries(
|
|
goEnvKeys.flatMap((key) => (stepEnv[key] === undefined ? [] : [[key, stepEnv[key]]])),
|
|
),
|
|
FORMAT_CHECK: "false",
|
|
CORE_STRIPE: String(stripe),
|
|
EXTENSION_STRIPE: String(stripe),
|
|
FROZEN_TARGET: frozenTarget ? "true" : "false",
|
|
HISTORICAL_TARGET: capability ? "false" : "true",
|
|
HOSTED_RUNNER_STRIPES: profile === "blacksmith" ? "false" : "true",
|
|
LINT_CALLS: callsPath,
|
|
LINT_GO_ENV: goEnvPath,
|
|
OPENCLAW_LOCAL_CHECK: "0",
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
RELEASE_GATE: String(
|
|
stepEnv.RELEASE_GATE
|
|
? evaluateWorkflowExpression(stepEnv.RELEASE_GATE, expressionContext)
|
|
: false,
|
|
),
|
|
RUN_CONTROL_UI_I18N: "false",
|
|
RUNNER_PROFILE: profile,
|
|
RUN_UI_TESTS: "false",
|
|
TASK: "lint",
|
|
},
|
|
});
|
|
expect(result.status, `${result.stdout}${result.stderr}`).toBe(
|
|
failStripe === undefined ? 0 : 23,
|
|
);
|
|
const calls = existsSync(callsPath)
|
|
? readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean)
|
|
: [];
|
|
expect(calls.length).toBeGreaterThan(0);
|
|
expect(readFileSync(goEnvPath, "utf8").split("\n").filter(Boolean)).toEqual(
|
|
calls.map(() => goEnvKeys.map((key) => expectedGoEnv[key] ?? "").join("\t")),
|
|
);
|
|
return calls;
|
|
};
|
|
|
|
// Manifest tests own row admission; these cases execute every full-layout stripe.
|
|
for (const eventName of ["pull_request", "push"] as const) {
|
|
expect(
|
|
[1, 2].map((stripe) =>
|
|
runLintOwner({ capability: true, eventName, lane: "core", profile: "hybrid", stripe }),
|
|
),
|
|
).toEqual(
|
|
[
|
|
[1, 2],
|
|
[3, 4, 5],
|
|
].map((stripes) =>
|
|
stripes.map(
|
|
(stripe) =>
|
|
`node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=${stripe}/5 --threads=1`,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
for (const runAttempt of [1, 2]) {
|
|
expect(
|
|
[1, 2].flatMap((stripe) =>
|
|
runLintOwner({
|
|
capability: true,
|
|
eventName: "workflow_dispatch",
|
|
lane: "core",
|
|
nodeRunnerBackend: "runson",
|
|
profile: "hybrid",
|
|
releaseGate: true,
|
|
runAttempt,
|
|
stripe,
|
|
}),
|
|
),
|
|
).toEqual(
|
|
[1, 2, 3, 4, 5].map(
|
|
(stripe) =>
|
|
`node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=${stripe}/5 --threads=1`,
|
|
),
|
|
);
|
|
expect(
|
|
runLintOwner({
|
|
capability: true,
|
|
eventName: "workflow_dispatch",
|
|
lane: "check",
|
|
nodeRunnerBackend: "runson",
|
|
profile: "hybrid",
|
|
releaseGate: true,
|
|
runAttempt,
|
|
}),
|
|
).toEqual(["node --import tsx scripts/run-oxlint-shards.mts --only=scripts --threads=1"]);
|
|
}
|
|
expect(
|
|
runLintOwner({
|
|
capability: true,
|
|
eventName: "pull_request",
|
|
failStripe: 1,
|
|
lane: "core",
|
|
profile: "hybrid",
|
|
}),
|
|
).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=1/5 --threads=1",
|
|
]);
|
|
expect(
|
|
runLintOwner({
|
|
capability: true,
|
|
eventName: "pull_request",
|
|
failStripe: 4,
|
|
lane: "core",
|
|
profile: "hybrid",
|
|
stripe: 2,
|
|
}),
|
|
).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=3/5 --threads=1",
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=4/5 --threads=1",
|
|
]);
|
|
|
|
expect(runLintOwner({ capability: true, lane: "check", profile: "github" })).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=extensions --extension-stripe=6/6 --threads=1",
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=scripts --threads=1",
|
|
]);
|
|
expect(
|
|
runLintOwner({
|
|
capability: true,
|
|
eventName: "pull_request",
|
|
lane: "core",
|
|
profile: "github",
|
|
}),
|
|
).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=1/5 --threads=1",
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=extensions --extension-stripe=1/6 --threads=1",
|
|
]);
|
|
expect(
|
|
runLintOwner({
|
|
capability: false,
|
|
lane: "check",
|
|
profile: "github",
|
|
expectedGoEnv: { GOMAXPROCS: "2", GOGC: "30", GOMEMLIMIT: "3GiB" },
|
|
}),
|
|
).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=extensions --only=scripts --threads=1",
|
|
]);
|
|
expect(runLintOwner({ capability: true, lane: "check", profile: "hybrid" })).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=scripts --threads=1",
|
|
]);
|
|
expect(hostedExtensionLint.strategy["fail-fast"]).toBe(false);
|
|
expect(hostedExtensionLint.strategy["max-parallel"]).toBe(6);
|
|
for (const stripe of [1, 2, 3, 4, 5, 6]) {
|
|
expect(
|
|
runLintOwner({ capability: true, lane: "extensions", profile: "hybrid", stripe }),
|
|
).toEqual([
|
|
`node --import tsx scripts/run-oxlint-shards.mts --only=extensions --extension-stripe=${stripe}/6 --threads=1`,
|
|
]);
|
|
}
|
|
runLintOwner({
|
|
capability: true,
|
|
failStripe: 2,
|
|
lane: "extensions",
|
|
profile: "hybrid",
|
|
stripe: 2,
|
|
});
|
|
expect(runLintOwner({ capability: true, lane: "check", profile: "blacksmith" })).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --threads=8",
|
|
]);
|
|
expect(
|
|
runLintOwner({ capability: true, lane: "check", profile: "github", releaseGate: true }),
|
|
).toEqual(["node --import tsx scripts/run-oxlint-shards.mts --only=scripts --threads=1"]);
|
|
for (const scenario of [
|
|
{
|
|
capability: false,
|
|
lane: "core" as const,
|
|
profile: "github" as const,
|
|
expectedGoEnv: { GOMAXPROCS: "2" },
|
|
},
|
|
{ capability: true, lane: "core" as const, profile: "hybrid" as const },
|
|
{
|
|
capability: true,
|
|
lane: "core" as const,
|
|
profile: "github" as const,
|
|
releaseGate: true,
|
|
},
|
|
]) {
|
|
expect(runLintOwner(scenario)).toEqual([
|
|
"node --import tsx scripts/run-oxlint-shards.mts --only=core --split-core --core-stripe=1/5 --threads=1",
|
|
]);
|
|
}
|
|
|
|
for (const lane of ["check", "core"] as const) {
|
|
runLintOwner({ capability: true, cpuCount: 4, lane, profile: "hybrid" });
|
|
runLintOwner({
|
|
capability: true,
|
|
lane,
|
|
profile: "github",
|
|
goEnv: { GOMAXPROCS: "3", GOGC: "80", GOMEMLIMIT: "5GiB" },
|
|
});
|
|
}
|
|
runLintOwner({ capability: true, cpuCount: 4, lane: "check", profile: "blacksmith" });
|
|
for (const [profile, cpuCount] of [
|
|
["hybrid", 32],
|
|
["blacksmith", 4],
|
|
] as const) {
|
|
runLintOwner({
|
|
capability: true,
|
|
cpuCount,
|
|
frozenTarget: true,
|
|
lane: "check",
|
|
profile,
|
|
expectedGoEnv: { GOMAXPROCS: "2", GOGC: "30", GOMEMLIMIT: "3GiB" },
|
|
});
|
|
}
|
|
runLintOwner({
|
|
capability: true,
|
|
frozenTarget: true,
|
|
lane: "check",
|
|
profile: "blacksmith",
|
|
});
|
|
expect(coreLintStep.env.FROZEN_TARGET).toBe("${{ needs.preflight.outputs.frozen_target }}");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each(
|
|
(["bundled-protocol", "guards", "npm-lock"] as const).flatMap((task) =>
|
|
(["pull_request", "push", "workflow_dispatch"] as const).map((eventName) => ({
|
|
task,
|
|
eventName,
|
|
})),
|
|
),
|
|
)(
|
|
"uses prefetched CI base without later network access ($task, $eventName)",
|
|
async ({ task, eventName }) => {
|
|
const base = "c".repeat(40);
|
|
const baseRef = "refs/remotes/origin/ci-ratchet-base";
|
|
const jobName = task === "bundled-protocol" ? "checks-fast-core" : "check-shard";
|
|
const job = readCiWorkflow().jobs[jobName];
|
|
const needsBase =
|
|
task === "bundled-protocol" ||
|
|
(task === "guards" ? eventName === "pull_request" : eventName !== "workflow_dispatch");
|
|
const checkoutBase = evaluateWorkflowExpression(job.env?.CHECKOUT_BASE_SHA ?? "${{ '' }}", {
|
|
eventName,
|
|
repository: "fixture/checkout",
|
|
runAttempt: 1,
|
|
matrix: { task },
|
|
preflightOutputs: { diff_base_revision: base },
|
|
});
|
|
const report = await runCiGitStep({
|
|
job: jobName,
|
|
step:
|
|
task === "bundled-protocol"
|
|
? "Run ${{ matrix.task }} (${{ matrix.runtime }})"
|
|
: "Run check shard",
|
|
checkoutBeforeStep: true,
|
|
// The authenticated checkout and trusted harness fetch succeed. Network
|
|
// access is unavailable afterward, even though the base is already local.
|
|
fetchResults: [0, 0, 128],
|
|
baseAvailableAfter: 0,
|
|
revisions: { [`${baseRef}^{commit}`]: base },
|
|
env: {
|
|
TASK: task,
|
|
GITHUB_EVENT_NAME: eventName,
|
|
CHECKOUT_KIND: "linux-node",
|
|
CHECKOUT_BASE_SHA: String(checkoutBase),
|
|
CHECKOUT_TOKEN: "fixture-checkout-token",
|
|
NARROW_CHECK_PATHS_JSON: "",
|
|
CI_TYPE_GRAPHS_JSON: "",
|
|
CI_CORE_TYPE_GRAPHS_JSON: "",
|
|
CI_CORE_TYPE_CONCURRENCY: "",
|
|
},
|
|
});
|
|
expect(report.code, report.output).toBe(0);
|
|
expect(report.fetches).toHaveLength(2);
|
|
const sourceFetch = report.fetches.find(({ cwd }) => cwd === report.workspace);
|
|
expect(sourceFetch?.args.includes(`+${base}:refs/remotes/origin/ci-ratchet-base`)).toBe(
|
|
needsBase,
|
|
);
|
|
const consumers = report.commands.filter(({ tool }) => tool === "node" || tool === "pnpm");
|
|
if (task === "bundled-protocol") {
|
|
expect(consumers.map(({ args }) => args)).toEqual([["test:bundled"], ["protocol:check"]]);
|
|
} else if (task === "guards") {
|
|
const tempReport = consumers.find(
|
|
({ args }) => args[0] === "scripts/report-test-temp-creations.mjs",
|
|
);
|
|
expect(tempReport?.args).toEqual(
|
|
needsBase
|
|
? [
|
|
"scripts/report-test-temp-creations.mjs",
|
|
"--base",
|
|
base,
|
|
"--head",
|
|
"HEAD",
|
|
"--no-merge-base",
|
|
]
|
|
: undefined,
|
|
);
|
|
} else {
|
|
expect(consumers.filter(({ tool }) => tool === "pnpm").map(({ args }) => args)).toEqual([
|
|
needsBase
|
|
? ["deps:npm-lock:check:changed", "--base", base, "--head", "HEAD"]
|
|
: ["deps:npm-lock:check"],
|
|
]);
|
|
}
|
|
},
|
|
55_000,
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
label: "current",
|
|
frozenTarget: false,
|
|
compatibilityTarget: false,
|
|
policy: "bun-compatible",
|
|
runtimes: ["bun", "node"],
|
|
shards: [1, 2, 3],
|
|
},
|
|
{
|
|
label: "frozen current",
|
|
frozenTarget: true,
|
|
compatibilityTarget: false,
|
|
policy: "dual",
|
|
runtimes: ["node", "bun"],
|
|
shards: [1, 2, 3],
|
|
},
|
|
{
|
|
label: "frozen legacy",
|
|
frozenTarget: true,
|
|
compatibilityTarget: true,
|
|
policy: "node",
|
|
runtimes: ["node"],
|
|
shards: [1],
|
|
},
|
|
])("executes the $label standalone UI envelope", async (scenario) => {
|
|
const workflow = readCiWorkflow();
|
|
expect(workflow.env?.BUN_JSC_useFTLJIT).toBeUndefined();
|
|
const ftlSteps: string[] = [];
|
|
for (const [name, job] of Object.entries<{
|
|
env?: Record<string, unknown>;
|
|
steps?: WorkflowStep[];
|
|
}>(workflow.jobs)) {
|
|
expect(job.env?.BUN_JSC_useFTLJIT).toBeUndefined();
|
|
for (const step of job.steps ?? []) {
|
|
if (step.env?.BUN_JSC_useFTLJIT !== undefined) {
|
|
ftlSteps.push(`${name}/${step.name}`);
|
|
}
|
|
}
|
|
}
|
|
expect(ftlSteps).toEqual([]);
|
|
const ui = workflow.jobs["checks-ui"];
|
|
const lint = ui.steps.find(
|
|
(step: WorkflowStep) => step.name === "Lint Control UI window.open usage",
|
|
);
|
|
const test = ui.steps.find((step: WorkflowStep) => step.name === "Test Control UI");
|
|
const diagnostics = expectDefined(
|
|
ui.steps.find((step: WorkflowStep) => step.name === "Upload Control UI timeout diagnostics"),
|
|
"Control UI timeout diagnostic upload",
|
|
);
|
|
expect(ui.steps.indexOf(diagnostics)).toBeGreaterThan(ui.steps.indexOf(test));
|
|
expect(diagnostics).toMatchObject({
|
|
if: "failure()",
|
|
uses: UPLOAD_ARTIFACT_V7,
|
|
with: {
|
|
name: "control-ui-test-timeout-${{ matrix.shard }}-${{ github.run_attempt }}",
|
|
path: `${test.env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR}/failure-*/failure.public.json`,
|
|
"if-no-files-found": "ignore",
|
|
"retention-days": 7,
|
|
},
|
|
});
|
|
const uiGroups = createUiTestShardGroups({
|
|
includeReleaseOnlyTests: scenario.frozenTarget || scenario.compatibilityTarget,
|
|
}).ui;
|
|
const context = {
|
|
eventName: scenario.frozenTarget ? "workflow_dispatch" : "pull_request",
|
|
frozenTarget: scenario.frozenTarget,
|
|
preflightOutputs: {
|
|
compatibility_target: String(scenario.compatibilityTarget),
|
|
ui_test_runtime_policy: scenario.policy,
|
|
ui_test_groups_gzip_base64: encodeNodeTestGroups(uiGroups),
|
|
},
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
runnerBackend: "hybrid",
|
|
} as const;
|
|
// A workflow job without a matrix executes once.
|
|
const shards = ui.strategy
|
|
? evaluateWorkflowExpression(ui.strategy.matrix.shard, context)
|
|
: [1];
|
|
expect(shards).toEqual(scenario.shards);
|
|
expect(ui.strategy).toMatchObject({ "fail-fast": false, "max-parallel": 3 });
|
|
expect(ui.needs).toEqual(["preflight"]);
|
|
expect(ui.if).toBe("needs.preflight.outputs.run_ui_tests == 'true'");
|
|
expect(ui.permissions).toEqual({ contents: "read" });
|
|
// Hosted rows (full-release dispatches, github backend, hybrid retries,
|
|
// fork PRs) run the Control UI suites slower than Blacksmith; a frozen
|
|
// full-release dispatch measured 15-20 min per shard against a 20 min cap.
|
|
expect(evaluateWorkflowExpression(ui["timeout-minutes"], context)).toBe(
|
|
scenario.frozenTarget ? 35 : 20,
|
|
);
|
|
for (const override of [
|
|
{ runnerBackend: "github" },
|
|
{ runnerBackend: "hybrid", runAttempt: 2 },
|
|
{ eventName: "pull_request", headRepository: "contributor/openclaw" },
|
|
] as const) {
|
|
expect(evaluateWorkflowExpression(ui["timeout-minutes"], { ...context, ...override })).toBe(
|
|
35,
|
|
);
|
|
}
|
|
expect(
|
|
evaluateWorkflowExpression(ui["timeout-minutes"], {
|
|
...context,
|
|
eventName: "workflow_dispatch",
|
|
preflightOutputs: { ...context.preflightOutputs, ci_shape: "main" },
|
|
}),
|
|
).toBe(20);
|
|
expect(workflow.jobs["ci-gate"].needs).toContain("checks-ui");
|
|
|
|
const root = tempDirs.make("openclaw-ui-workflow-");
|
|
const bin = path.join(root, "bin");
|
|
const callsPath = path.join(root, "calls.txt");
|
|
const argsPath = path.join(root, "vitest-args.json");
|
|
mkdirSync(bin);
|
|
for (const command of ["node", "pnpm"]) {
|
|
writeExecutable(path.join(bin, command), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`printf '%s\\n' '${command} '"$*" >> "$UI_COMMAND_CALLS"`,
|
|
...(command === "node"
|
|
? ['printf "%s\\n" "$OPENCLAW_NODE_TEST_VITEST_ARGS_JSON" > "$UI_VITEST_ARGS"']
|
|
: []),
|
|
]);
|
|
}
|
|
for (const shard of scenario.shards) {
|
|
const rowContext = { ...context, matrix: { shard }, workspace: root };
|
|
const resolveValue = (value: unknown): string =>
|
|
String(value).replace(/\$\{\{[\s\S]*?\}\}/gu, (expression) =>
|
|
String(evaluateWorkflowExpression(expression, rowContext)),
|
|
);
|
|
expect(resolveValue(ui.name)).toBe(
|
|
scenario.compatibilityTarget ? "checks-ui" : `checks-ui (${shard}/3)`,
|
|
);
|
|
expect(evaluateWorkflowExpression(ui["runs-on"], rowContext)).toBe(
|
|
scenario.frozenTarget ? "ubuntu-24.04" : "blacksmith-8vcpu-ubuntu-2404",
|
|
);
|
|
const env = Object.fromEntries(
|
|
Object.entries({ ...ui.env, ...test.env }).map(([key, value]) => [
|
|
key,
|
|
resolveValue(value),
|
|
]),
|
|
);
|
|
expect(env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY).toBe("1");
|
|
expect(env.BUN_JSC_useFTLJIT).toBeUndefined();
|
|
expect(env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR).toBe(
|
|
`${root}/.artifacts/control-ui-e2e-timeouts/ui-shard-${shard}-attempt-1`,
|
|
);
|
|
const flags = [
|
|
"--maxWorkers",
|
|
"3",
|
|
"--reporter=verbose",
|
|
"--reporter=github-actions",
|
|
"--reporter=./scripts/lib/vitest-resource-reporter.mts",
|
|
...(scenario.compatibilityTarget ? [] : [`--shard=${shard}/3`]),
|
|
];
|
|
const steps = [
|
|
...(!lint.if || evaluateWorkflowExpression(lint.if, rowContext) ? [lint] : []),
|
|
test,
|
|
];
|
|
for (const step of steps) {
|
|
const result = runWorkflowShellScript(step.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
...env,
|
|
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
|
UI_COMMAND_CALLS: callsPath,
|
|
UI_VITEST_ARGS: argsPath,
|
|
},
|
|
});
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
}
|
|
if (!scenario.compatibilityTarget) {
|
|
env.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON = readFileSync(argsPath, "utf8");
|
|
expect(JSON.parse(env.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON)).toEqual(flags);
|
|
const forwarded: string[][] = [];
|
|
const runtimes: Array<string | undefined> = [];
|
|
expect(
|
|
await runShardPlans(resolveShardPlans(env), {
|
|
concurrency: Number(env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY),
|
|
env,
|
|
scratchDir: root,
|
|
runChild: async (args, childEnv) => {
|
|
forwarded.push(args);
|
|
runtimes.push(childEnv.OPENCLAW_VITEST_RUNTIME);
|
|
expect(childEnv.BUN_JSC_useFTLJIT).toBeUndefined();
|
|
if (uiGroups[0]?.includePatterns) {
|
|
expect(
|
|
JSON.parse(readFileSync(childEnv.OPENCLAW_VITEST_INCLUDE_FILE!, "utf8")),
|
|
).toEqual(uiGroups[0].includePatterns);
|
|
} else {
|
|
expect(childEnv.OPENCLAW_VITEST_INCLUDE_FILE).toBeUndefined();
|
|
}
|
|
const includeFile = childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE;
|
|
if (
|
|
childEnv.OPENCLAW_VITEST_RUNTIME === "bun" ||
|
|
scenario.policy === "bun-compatible"
|
|
) {
|
|
expect(includeFile).toBeTruthy();
|
|
const included = JSON.parse(readFileSync(includeFile!, "utf8"));
|
|
const nodeFiles = [
|
|
"ui/src/pages/chat/chat-thread.test.ts",
|
|
"ui/src/pages/usage/usage-page-details.test.ts",
|
|
];
|
|
if (childEnv.OPENCLAW_VITEST_RUNTIME === "node") {
|
|
expect(included.toSorted()).toEqual(nodeFiles);
|
|
} else {
|
|
expect(included.length).toBeGreaterThan(1000);
|
|
expect(included.filter((file: string) => nodeFiles.includes(file))).toEqual([]);
|
|
if (uiGroups[0]?.includePatterns) {
|
|
expect(included.toSorted()).toEqual(
|
|
uiGroups[0].includePatterns
|
|
.filter((file) => !nodeFiles.includes(file))
|
|
.toSorted(),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
expect(includeFile).toBeUndefined();
|
|
}
|
|
expect(childEnv.OPENCLAW_TEST_PROJECTS_PARALLEL).toBe("1");
|
|
expect(childEnv.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR).toBe(
|
|
resolveValue(test.env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR),
|
|
);
|
|
return 0;
|
|
},
|
|
}),
|
|
).toBe(0);
|
|
expect(runtimes).toEqual(scenario.runtimes);
|
|
expect(forwarded).toEqual(
|
|
scenario.runtimes.map(() => ["ui/vitest.config.ts", "--", ...flags]),
|
|
);
|
|
}
|
|
}
|
|
const calls = readFileSync(callsPath, "utf8").trim().split("\n");
|
|
expect(calls.filter((call) => call === "pnpm lint:ui:no-raw-window-open")).toHaveLength(1);
|
|
expect(calls.filter((call) => call !== "pnpm lint:ui:no-raw-window-open")).toEqual(
|
|
scenario.compatibilityTarget
|
|
? ["pnpm --dir ui test --testTimeout=30000 --isolate"]
|
|
: scenario.shards.map(() => "node --import tsx scripts/ci-run-node-test-shard.mts"),
|
|
);
|
|
});
|
|
|
|
it("keeps private Control UI servers and resource-sensitive files under one serial owner", () => {
|
|
assertControlUiE2eOwnership((prefix) => tempDirs.make(prefix), parser);
|
|
});
|
|
|
|
it("retains shared worker limits and local throttling in the bundled UI project", () => {
|
|
const original = sharedVitestConfig.test;
|
|
try {
|
|
for (const [maxWorkers, fileParallelism, expectedWorkers] of [
|
|
[1, false, 1],
|
|
[8, true, 2],
|
|
] as const) {
|
|
sharedVitestConfig.test = { ...original, maxWorkers, fileParallelism };
|
|
const config = createUiE2eVitestConfig({}, []);
|
|
const projects = config.test?.projects as Array<{
|
|
test: { maxWorkers?: number; fileParallelism: boolean };
|
|
}>;
|
|
expect(config.test?.maxWorkers).toBe(expectedWorkers);
|
|
expect(projects.map((project) => project.test.maxWorkers)).toEqual([
|
|
undefined,
|
|
undefined,
|
|
1,
|
|
1,
|
|
]);
|
|
expect(projects.map((project) => project.test.fileParallelism)).toEqual([
|
|
fileParallelism,
|
|
fileParallelism,
|
|
false,
|
|
false,
|
|
]);
|
|
}
|
|
} finally {
|
|
sharedVitestConfig.test = original;
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ failed: false, captured: false },
|
|
{ failed: false, captured: true },
|
|
{ failed: true, captured: false },
|
|
{ failed: true, captured: true },
|
|
])("uploads only captured synthetic widget failures: %j", ({ failed, captured }) => {
|
|
const artifactRoot = ".artifacts/control-ui-e2e/control-ui-authenticated-widget-sandbox-*";
|
|
const timeline = `${artifactRoot}/widget-prompt-failure.json`;
|
|
for (const job of [
|
|
readCiWorkflow().jobs["checks-ui-e2e"],
|
|
readWorkflow(".github/workflows/openclaw-repo-e2e-reusable.yml").jobs.test,
|
|
]) {
|
|
const upload = expectDefined(
|
|
job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload synthetic widget prompt failure evidence",
|
|
),
|
|
"synthetic widget upload",
|
|
);
|
|
expect(
|
|
evaluateWorkflowExpression(`\${{ ${upload.if} }}`, {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
failed,
|
|
fileHashes: captured ? { [timeline]: "present" } : {},
|
|
}),
|
|
).toBe(failed && captured);
|
|
expect(upload.uses).toBe(UPLOAD_ARTIFACT_V7);
|
|
expect(upload.with.path.trim().split("\n")).toEqual([
|
|
timeline,
|
|
`${artifactRoot}/*.png`,
|
|
`${artifactRoot}/*.webm`,
|
|
]);
|
|
expect(upload.with["retention-days"]).toBe(7);
|
|
expect(upload.with["if-no-files-found"]).toBe("error");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ frozen: false, prebuilt: true, childExit: 0, releaseTier: false },
|
|
{ frozen: false, prebuilt: true, childExit: 0, releaseTier: true },
|
|
{ frozen: false, prebuilt: true, childExit: 42, releaseTier: false },
|
|
{ frozen: false, prebuilt: true, childExit: 0, releaseTier: false, sharded: false },
|
|
{ frozen: true, prebuilt: true, childExit: 0 },
|
|
{ frozen: true, prebuilt: false, childExit: 0 },
|
|
{ frozen: false, prebuilt: false, childExit: 0 },
|
|
{ frozen: true, prebuilt: true, childExit: 42 },
|
|
])(
|
|
"selects the real-Gateway tier without retrying failures: %j",
|
|
({ frozen, prebuilt, childExit, releaseTier, sharded }) => {
|
|
const step = expectDefined(
|
|
readCiWorkflow().jobs["checks-ui-e2e-real-gateway"].steps.find(
|
|
(candidate: WorkflowStep) =>
|
|
candidate.name === "Test Control UI suites with a real Gateway",
|
|
),
|
|
"real-Gateway command",
|
|
);
|
|
const directory = tempDirs.make("openclaw-real-gateway-command-");
|
|
const bin = path.join(directory, "bin");
|
|
const prebuiltConfig = "test/vitest/vitest.ui-e2e-prebuilt.config.ts";
|
|
const serialConfig = "test/vitest/vitest.ui-e2e.config.ts";
|
|
mkdirSync(bin);
|
|
mkdirSync(path.join(directory, "test/vitest"), { recursive: true });
|
|
writeFileSync(path.join(directory, serialConfig), "export default {};\n");
|
|
if (prebuilt) {
|
|
writeFileSync(path.join(directory, prebuiltConfig), "export default {};\n");
|
|
}
|
|
mkdirSync(path.join(directory, "scripts/lib"), { recursive: true });
|
|
copyFileSync(
|
|
"scripts/lib/ci-node-test-groups-codec.mts",
|
|
path.join(directory, "scripts/lib/ci-node-test-groups-codec.mts"),
|
|
);
|
|
writeExecutable(path.join(bin, "node"), [
|
|
"#!/bin/sh",
|
|
'if [ "$1" = "--import" ]; then shift; shift; exec "$REAL_GATEWAY_NODE" "$@"; fi',
|
|
'printf "%s\\n" "$@" > "$REAL_GATEWAY_COMMAND_ARGS"',
|
|
'printf "%s" "${OPENCLAW_VITEST_INCLUDE_FILE:-}" > "$REAL_GATEWAY_INCLUDE_PATH"',
|
|
'printf "called\\n" >> "$REAL_GATEWAY_COMMAND_CALLS"',
|
|
'exit "$REAL_GATEWAY_COMMAND_EXIT"',
|
|
]);
|
|
const desktop = "ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts";
|
|
const groups =
|
|
releaseTier === undefined
|
|
? undefined
|
|
: createUiTestShardGroups({ includeReleaseOnlyTests: releaseTier }).e2e;
|
|
const rows =
|
|
groups && !frozen && sharded !== false
|
|
? createUiRealGatewayTestShards(groups)
|
|
: [{ shard: 1, shard_count: 1, run_desktop: true, groups }];
|
|
const selectedFiles: string[] = [];
|
|
for (const row of rows) {
|
|
const rowDirectory = path.join(directory, String(row.shard));
|
|
mkdirSync(rowDirectory);
|
|
const argsPath = path.join(rowDirectory, "args");
|
|
const callsPath = path.join(rowDirectory, "calls");
|
|
const includePath = path.join(rowDirectory, "include-path");
|
|
const context = {
|
|
eventName: "push" as const,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
matrix: {
|
|
...row,
|
|
test_groups_gzip_base64: row.groups ? encodeNodeTestGroups(row.groups) : "",
|
|
},
|
|
preflightOutputs: {
|
|
frozen_target: String(frozen),
|
|
ui_e2e_test_groups_gzip_base64: groups ? encodeNodeTestGroups(groups) : "",
|
|
},
|
|
};
|
|
const result = runWorkflowShellScript(expectDefined(step.run, "real-Gateway script"), {
|
|
linuxWorkflow: true,
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
FROZEN_TARGET: String(evaluateWorkflowExpression(step.env.FROZEN_TARGET, context)),
|
|
RUNNER_TEMP: rowDirectory,
|
|
REAL_GATEWAY_NODE: testNodeExecPath,
|
|
OPENCLAW_VITEST_INCLUDE_FILE: "",
|
|
REAL_GATEWAY_INCLUDE_PATH: includePath,
|
|
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: String(
|
|
evaluateWorkflowExpression(step.env.OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64, context),
|
|
),
|
|
REAL_GATEWAY_COMMAND_ARGS: argsPath,
|
|
REAL_GATEWAY_COMMAND_CALLS: callsPath,
|
|
REAL_GATEWAY_COMMAND_EXIT: String(childExit),
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
},
|
|
});
|
|
const missingCurrentConfig = !prebuilt && !frozen;
|
|
expect(result.status, result.stdout + result.stderr).toBe(
|
|
missingCurrentConfig ? 1 : childExit,
|
|
);
|
|
if (missingCurrentConfig) {
|
|
expect(result.stderr).toContain(`Current target is missing ${prebuiltConfig}`);
|
|
expect(existsSync(callsPath)).toBe(false);
|
|
return;
|
|
}
|
|
expect(readFileSync(callsPath, "utf8").trim().split("\n")).toEqual(["called"]);
|
|
const args = readFileSync(argsPath, "utf8").trim().split("\n");
|
|
expect(args.slice(0, 6)).toEqual([
|
|
"scripts/run-vitest.mjs",
|
|
"run",
|
|
"--config",
|
|
prebuilt ? prebuiltConfig : serialConfig,
|
|
"--configLoader",
|
|
"runner",
|
|
]);
|
|
const reporterArgs = frozen
|
|
? []
|
|
: [
|
|
"--reporter",
|
|
"verbose",
|
|
"--reporter",
|
|
"github-actions",
|
|
"--reporter",
|
|
"default",
|
|
"--reporter",
|
|
"./scripts/lib/vitest-resource-reporter.mts",
|
|
];
|
|
expect(args.slice(6, 6 + reporterArgs.length)).toEqual(reporterArgs);
|
|
expect(args.slice(6 + reporterArgs.length).toSorted()).toEqual(
|
|
prebuilt
|
|
? ["--exclude", desktop]
|
|
: uiE2eRealGatewayTestFiles.filter((file) => file !== desktop).toSorted(),
|
|
);
|
|
const selectedConfig = createPrebuiltUiE2eVitestConfig(
|
|
{ OPENCLAW_VITEST_INCLUDE_FILE: readFileSync(includePath, "utf8") },
|
|
[testNodeExecPath, ...args],
|
|
);
|
|
selectedFiles.push(
|
|
...(selectedConfig.test?.include ?? []).filter((file) => file !== desktop),
|
|
);
|
|
expect(
|
|
resolveRunVitestSpawnEnv(
|
|
{ CI: "true", OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "120000" },
|
|
args.slice(1),
|
|
).OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS,
|
|
).toBe("300000");
|
|
}
|
|
expect(new Set(selectedFiles).size).toBe(selectedFiles.length);
|
|
expect(selectedFiles.toSorted()).toEqual(
|
|
uiE2eRealGatewayTestFiles
|
|
.filter(
|
|
(file) =>
|
|
file !== desktop &&
|
|
(!groups ||
|
|
groups.some(
|
|
(group) => !group.includePatterns || group.includePatterns.includes(file),
|
|
)),
|
|
)
|
|
.toSorted(),
|
|
);
|
|
if (releaseTier === false) {
|
|
expect(selectedFiles).toHaveLength(uiE2eRealGatewayTestFiles.length - 11);
|
|
expect(selectedFiles).not.toContain(
|
|
"ui/src/e2e/cron-duration-save.real-gateway.e2e.test.ts",
|
|
);
|
|
expect(selectedFiles).not.toContain(
|
|
"extensions/qa-lab/src/control-ui-automation-management.real-gateway.e2e.test.ts",
|
|
);
|
|
expect(selectedFiles).not.toContain(
|
|
"extensions/qa-lab/src/control-ui-openclaw-delegation.real-gateway.e2e.test.ts",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ frozen: false, available: true, exit: 0 },
|
|
{ frozen: true, available: true, exit: 0 },
|
|
{ frozen: false, available: true, exit: 42 },
|
|
{ frozen: true, available: false, exit: 0 },
|
|
{ frozen: false, available: false, exit: 0 },
|
|
])(
|
|
"runs available desktop proof and preserves frozen omissions: %j",
|
|
({ frozen, available, exit }) => {
|
|
const step = expectDefined(
|
|
readCiWorkflow().jobs["checks-ui-e2e-real-gateway"].steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Prove desktop resize over node and SSH",
|
|
),
|
|
"desktop proof command",
|
|
);
|
|
const directory = tempDirs.make("desktop-proof-command-");
|
|
const bin = path.join(directory, "bin");
|
|
const calls = path.join(directory, "calls");
|
|
mkdirSync(bin);
|
|
mkdirSync(path.join(directory, "scripts"));
|
|
if (available) {
|
|
writeFileSync(path.join(directory, "scripts/test-desktop-resize-real.mts"), "");
|
|
}
|
|
writeFileSync(
|
|
path.join(bin, "node"),
|
|
'#!/bin/sh\nprintf "%s\\n" "$@" > "$DESKTOP_CALLS"\nexit "$DESKTOP_EXIT"\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
const result = runWorkflowShellScript(expectDefined(step.run, "desktop proof script"), {
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
FROZEN_TARGET: String(frozen),
|
|
DESKTOP_CALLS: calls,
|
|
DESKTOP_EXIT: String(exit),
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
},
|
|
});
|
|
expect(result.status, result.stdout + result.stderr).toBe(available ? exit : frozen ? 0 : 1);
|
|
if (available) {
|
|
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([
|
|
"--import",
|
|
"tsx",
|
|
"scripts/test-desktop-resize-real.mts",
|
|
]);
|
|
} else {
|
|
expect(existsSync(calls)).toBe(false);
|
|
expect(frozen ? result.stdout : result.stderr).toContain(
|
|
frozen ? "no desktop resize proof produced" : "Current target is missing",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("builds artifacts once and smoke-tests the built CLI with Node and Bun", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;
|
|
const setupStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
const buildDistStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Build dist",
|
|
);
|
|
const nodeHelpSmoke = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Smoke test CLI launcher help",
|
|
);
|
|
const nodeStatusSmoke = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Smoke test CLI launcher status json",
|
|
);
|
|
const bunSmoke = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Smoke test built CLI with Bun",
|
|
);
|
|
|
|
expect(
|
|
buildArtifactSteps.some(
|
|
(step: WorkflowStep) =>
|
|
typeof step.uses === "string" && step.uses.endsWith("/ensure-base-commit"),
|
|
),
|
|
).toBe(false);
|
|
expect(setupStep.with["install-bun"]).toBe("true");
|
|
expect(buildDistStep.run).toBe("pnpm build:ci-artifacts");
|
|
expect(buildArtifactSteps.map((step: WorkflowStep) => step.name)).not.toContain(
|
|
"Build Control UI",
|
|
);
|
|
expect(buildArtifactSteps.some((step: WorkflowStep) => step.run === "pnpm ui:build")).toBe(
|
|
false,
|
|
);
|
|
expect(nodeHelpSmoke.run).toBe("node openclaw.mjs --help");
|
|
expect(nodeStatusSmoke.run).toBe("node openclaw.mjs status --json --timeout 1");
|
|
expect(bunSmoke.run).toContain("bun openclaw.mjs --help");
|
|
expect(bunSmoke.run).toContain("bun openclaw.mjs status --json --timeout 1");
|
|
});
|
|
|
|
it("splits native source verification from generated locale parity", () => {
|
|
const workflow = readCiWorkflow();
|
|
const manifestStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
const localeJob = workflow.jobs["native-i18n"];
|
|
const sourceStep = localeJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify native app i18n source",
|
|
);
|
|
const parityStep = localeJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check native app generated locale parity",
|
|
);
|
|
const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts;
|
|
const fullReleaseSource = readFileSync(".github/workflows/full-release-validation.yml", "utf8");
|
|
const fullReleaseCiCase = expectDefined(
|
|
fullReleaseSource.match(/case "\$CHILD_WORKFLOW_KIND" in\n\s+ci\)([\s\S]*?)\n\s+;;/u)?.[1],
|
|
"Full Release CI dispatch case",
|
|
);
|
|
|
|
expect(packageScripts["native:i18n:baseline"]).toContain("baseline --write");
|
|
expect(packageScripts["native:i18n:verify"]).toContain(" verify");
|
|
expect(workflow.jobs.preflight.outputs.strict_native_i18n).toBe(
|
|
"${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.strict_native_i18n }}",
|
|
);
|
|
expect(manifestStep.env.OPENCLAW_CI_RUN_NATIVE_I18N).toBe(
|
|
"${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}",
|
|
);
|
|
expect(sourceStep.run).toContain("pnpm native:i18n:verify");
|
|
expect(sourceStep.run).toContain("Historical release targets");
|
|
expect(parityStep.if).toBe("${{ needs.preflight.outputs.strict_native_i18n == 'true' }}");
|
|
expect(parityStep.run).toContain("pnpm native:i18n:check");
|
|
expect(parityStep.run).not.toContain("pnpm android:i18n:check");
|
|
expect(parityStep.run).not.toContain("pnpm apple:i18n:check");
|
|
expect(fullReleaseCiCase).toContain(
|
|
'args=(-f target_ref="$TARGET_SHA" -f release_scope="$ci_release_scope" -f include_android="$include_android" -f dispatch_id="$dispatch_id")',
|
|
);
|
|
expect(fullReleaseCiCase).toContain('dispatch_child ci.yml "$dispatch_run_name"');
|
|
expect(fullReleaseCiCase).not.toContain("release_gate");
|
|
});
|
|
|
|
it("measures startup memory before the built artifact-check wave", () => {
|
|
const workflow = readCiWorkflow();
|
|
const steps = workflow.jobs["build-artifacts"].steps;
|
|
const verifierStep = steps.find(
|
|
(step: WorkflowStep) => step.name === "Run built artifact checks",
|
|
);
|
|
|
|
// The verifiers always run, so the shared step cannot be gated on the
|
|
// selected checks; each check keeps its own RUN_* gate inside the body.
|
|
expect(verifierStep.if).toBeUndefined();
|
|
expect(steps.some((step: WorkflowStep) => step.name === "Verify built runtime artifacts")).toBe(
|
|
false,
|
|
);
|
|
// RSS measures an unloaded command on every runner, including Blacksmith.
|
|
const startupMemory = verifierStep.run.indexOf('run_verifier "startup-memory"');
|
|
const memoryBarrier = verifierStep.run.indexOf("\nwait_checks\n", startupMemory);
|
|
expect(memoryBarrier).toBeGreaterThan(startupMemory);
|
|
expect(memoryBarrier).toBeLessThan(
|
|
verifierStep.run.indexOf('run_verifier "doctor-plugin-index"'),
|
|
);
|
|
expect(verifierStep.env.OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB).toBe(
|
|
"${{ runner.environment == 'github-hosted' && '425' || '400' }}",
|
|
);
|
|
expect(verifierStep.env.PARALLEL_BUILT_VERIFIERS).toBe(
|
|
"${{ runner.environment != 'github-hosted' && 'true' || 'false' }}",
|
|
);
|
|
expect(verifierStep.run).toContain(
|
|
'OPENCLAW_VITEST_FS_MODULE_CACHE_PATH="${RUNNER_TEMP}/vitest-module-cache/${name}"',
|
|
);
|
|
expect(verifierStep.run).toContain(
|
|
"test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts",
|
|
);
|
|
expect(verifierStep.run).toContain(
|
|
"env OPENCLAW_E2E_USE_PREBUILT_DIST=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=660000 node scripts/run-vitest.mjs run",
|
|
);
|
|
expect(verifierStep.run).toContain("--config test/vitest/vitest.e2e.config.ts");
|
|
expect(verifierStep.run).toContain("Selected target predates");
|
|
expect(verifierStep.run).toContain("pnpm test:build:singleton");
|
|
// The startup asset rebuild must complete before any verifier forks so
|
|
// concurrent readers never observe dist mid-write.
|
|
expect(verifierStep.run).toContain("scripts/ensure-cli-startup-build.mts");
|
|
expect(verifierStep.run).toContain("scripts/check-cli-startup-memory.mjs");
|
|
expect(verifierStep.run).toContain(".artifacts/startup-memory/summary.md");
|
|
expect(verifierStep.env.RUN_CHANNELS).toBe("${{ needs.preflight.outputs.run_checks }}");
|
|
expect(verifierStep.env.FROZEN_TARGET).toBe("${{ needs.preflight.outputs.frozen_target }}");
|
|
const pluginSingleton = verifierStep.run.indexOf(
|
|
'run_verifier "plugin-singleton" pnpm test:build:singleton',
|
|
);
|
|
const pluginWriterBarrier = verifierStep.run.indexOf("\nwait_checks\n", pluginSingleton);
|
|
const parallelGatewayWatch = verifierStep.run.indexOf(
|
|
'if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then',
|
|
);
|
|
const gatewayWriterBarrier = verifierStep.run.indexOf(
|
|
"\n wait_checks\n",
|
|
parallelGatewayWatch,
|
|
);
|
|
const firstReader = verifierStep.run.indexOf(
|
|
'run_verifier "doctor-plugin-index" run_doctor_plugin_index',
|
|
);
|
|
const parallelDiscord = verifierStep.run.indexOf(
|
|
'if [ "$RUN_PROCESS_PROOFS" = "true" ] && [ "$RUN_CHANNELS" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" = "true" ]; then',
|
|
);
|
|
const readerWaveBarrier = verifierStep.run.indexOf("\nwait_checks\n", parallelDiscord);
|
|
const hostedDiscord = verifierStep.run.indexOf(
|
|
'if [ "$RUN_PROCESS_PROOFS" = "true" ] && [ "$RUN_CHANNELS" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" != "true" ]; then',
|
|
);
|
|
expect(pluginWriterBarrier).toBeGreaterThan(pluginSingleton);
|
|
expect(parallelGatewayWatch).toBeGreaterThan(pluginWriterBarrier);
|
|
expect(gatewayWriterBarrier).toBeGreaterThan(parallelGatewayWatch);
|
|
expect(firstReader).toBeGreaterThan(gatewayWriterBarrier);
|
|
expect(parallelDiscord).toBeGreaterThan(firstReader);
|
|
expect(readerWaveBarrier).toBeGreaterThan(parallelDiscord);
|
|
expect(hostedDiscord).toBeGreaterThan(readerWaveBarrier);
|
|
expect(verifierStep.run.slice(parallelDiscord, readerWaveBarrier)).toContain(
|
|
'start_check "discord-component-attachments" run_discord_component_attachments',
|
|
);
|
|
expect(verifierStep.run.slice(hostedDiscord)).toContain(
|
|
'start_check "discord-component-attachments" run_discord_component_attachments',
|
|
);
|
|
expect(verifierStep.run).toContain('["discord-component-attachments"]="skipped"');
|
|
expect(verifierStep.run).toContain("OPENCLAW_E2E_USE_PREBUILT_DIST=1 OPENCLAW_E2E_WORKERS=1");
|
|
expect(verifierStep.run).toContain("OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1");
|
|
const upload = steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Upload Discord component attachment proof",
|
|
);
|
|
expect(upload.if).toBe(
|
|
"always() && needs.preflight.outputs.run_proof_tier == 'true' && needs.preflight.outputs.run_checks == 'true'",
|
|
);
|
|
expect(upload.with.path).toContain("${{ runner.temp }}/discord-component-attachments.json");
|
|
expect(upload.with.path).toContain("${{ runner.temp }}/discord-component-attachments.log");
|
|
// Every verifier reports through the shared results map so a failure can
|
|
// never be swallowed by the wave.
|
|
for (const name of [
|
|
"doctor-plugin-index",
|
|
"plugin-singleton",
|
|
"sqlite-session-lifecycle",
|
|
"startup-memory",
|
|
]) {
|
|
expect(verifierStep.run).toContain(`run_verifier "${name}"`);
|
|
expect(verifierStep.run).toContain(`["${name}"]="skipped"`);
|
|
}
|
|
expect(verifierStep.run).toContain(
|
|
"for name in channels core-support-boundary discord-component-attachments doctor-plugin-index gateway-watch plugin-singleton sqlite-session-lifecycle startup-memory tui-pty; do",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ label: "one passing named case", state: "passed", frozen: false, expected: 0 },
|
|
{ label: "a passing frozen case", state: "passed", frozen: true, expected: 0 },
|
|
{ label: "a failed named case", state: "failed", frozen: false, expected: 1 },
|
|
{ label: "a skipped current case", state: "skipped", frozen: false, expected: 1 },
|
|
{ label: "a skipped frozen case", state: "skipped", frozen: true, expected: 1 },
|
|
{ label: "a missing current case", state: "absent", frozen: false, expected: 1 },
|
|
{ label: "an unavailable historical case", state: "absent", frozen: true, expected: 0 },
|
|
{ label: "a failed suite", state: "suite-failed", frozen: false, expected: 1 },
|
|
{ label: "malformed JSON", state: "malformed", frozen: true, expected: 1 },
|
|
])("validates Discord built proof with $label", ({ state, frozen, expected }) => {
|
|
const steps = readCiWorkflow().jobs["build-artifacts"].steps;
|
|
const step = steps.find((entry: WorkflowStep) => entry.name === "Run built artifact checks");
|
|
const validator = expectDefined(
|
|
step.run.match(
|
|
/node --input-type=module <<'DISCORD_PROOF_REPORT'\n([\s\S]*?)\nDISCORD_PROOF_REPORT/u,
|
|
)?.[1],
|
|
"Discord proof report validator",
|
|
);
|
|
const scratch = tempDirs.make("openclaw-discord-proof-report-");
|
|
const fullName =
|
|
"Discord show_widget contextual presenter process proof preserves component attachment filenames through the public Gateway message action";
|
|
const report = {
|
|
success: true,
|
|
numFailedTestSuites: state === "suite-failed" ? 1 : 0,
|
|
numFailedTests: state === "failed" ? 1 : 0,
|
|
numPassedTests: state === "passed" ? 1 : 0,
|
|
testResults: [
|
|
{
|
|
name: path.resolve(
|
|
"test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts",
|
|
),
|
|
status: "passed",
|
|
assertionResults: state === "absent" ? [] : [{ fullName, status: state }],
|
|
},
|
|
],
|
|
};
|
|
writeFileSync(
|
|
path.join(scratch, "discord-component-attachments.json"),
|
|
state === "malformed" ? "{" : JSON.stringify(report),
|
|
);
|
|
const result = spawnSync(process.execPath, ["--input-type=module", "-e", validator], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, RUNNER_TEMP: scratch, FROZEN_TARGET: String(frozen) },
|
|
});
|
|
expect(result.status, result.stderr).toBe(expected);
|
|
if (state === "absent" && frozen) {
|
|
expect(result.stdout).toContain("[skip] Frozen target predates the named Discord");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ frozen: false, present: false, expected: true },
|
|
{ frozen: true, present: true, expected: true },
|
|
{ frozen: true, present: false, expected: false },
|
|
])(
|
|
"gates browser native-host proof (frozen=$frozen, present=$present)",
|
|
({ frozen, present, expected }) => {
|
|
const step = readCiWorkflow().jobs["build-artifacts"].steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Verify built browser native host",
|
|
);
|
|
const file = "extensions/browser/src/browser/extension-install.native-host.e2e.test.ts";
|
|
expect(
|
|
step.if === undefined ||
|
|
evaluateWorkflowExpression(step.if, {
|
|
eventName: "workflow_dispatch",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
frozenTarget: frozen,
|
|
preflightOutputs: { run_proof_tier: "true" },
|
|
fileHashes: present ? { [file]: "fixture-hash" } : {},
|
|
}),
|
|
).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"passed",
|
|
"skipped",
|
|
"pending",
|
|
"todo",
|
|
"absent",
|
|
"wrong-name",
|
|
"wrong-file",
|
|
"failed",
|
|
"suite-failed",
|
|
"duplicate",
|
|
"malformed",
|
|
"missing-report",
|
|
])("validates browser native-host proof report: %s", (state) => {
|
|
const steps = readCiWorkflow().jobs["build-artifacts"].steps;
|
|
const step = steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Verify built browser native host",
|
|
);
|
|
expect(steps.indexOf(step)).toBeGreaterThan(
|
|
steps.findIndex((entry: WorkflowStep) => entry.name === "Build dist"),
|
|
);
|
|
expect(step["continue-on-error"]).not.toBe(true);
|
|
const root = tempDirs.make("openclaw-browser-proof-report-");
|
|
const file = "extensions/browser/src/browser/extension-install.native-host.e2e.test.ts";
|
|
const names = [
|
|
"does not inspect or migrate configuration before rejecting a malformed native request",
|
|
"rejects an unauthorized bootstrap caller before config, keys or database creation",
|
|
"rejects an unauthorized ensure_relay caller before config, keys or database creation",
|
|
'preserves invalid-config diagnostics for ordinary extension command "status"',
|
|
'preserves invalid-config diagnostics for ordinary extension command "setup"',
|
|
'preserves invalid-config diagnostics for ordinary extension command "pair"',
|
|
"launches launcher with the exact custom installation context when Chrome has no selectors",
|
|
"launches cli with the exact custom installation context when Chrome has no selectors",
|
|
];
|
|
const assertions = names.map((name, index) => ({
|
|
fullName:
|
|
state === "wrong-name" && index === 0 ? "another test" : `native host registration ${name}`,
|
|
status:
|
|
index === 0 && ["skipped", "pending", "todo", "failed"].includes(state) ? state : "passed",
|
|
}));
|
|
if (state === "absent") {
|
|
assertions.pop();
|
|
} else if (state === "duplicate") {
|
|
assertions[1] = assertions[0]!;
|
|
}
|
|
const report = {
|
|
success: state !== "failed" && state !== "suite-failed",
|
|
numFailedTestSuites: state === "suite-failed" ? 1 : 0,
|
|
numPendingTestSuites: 0,
|
|
numTotalTests: assertions.length,
|
|
numPassedTests: assertions.filter((entry) => entry.status === "passed").length,
|
|
numFailedTests: state === "failed" ? 1 : 0,
|
|
numPendingTests: ["skipped", "pending"].includes(state) ? 1 : 0,
|
|
numTodoTests: state === "todo" ? 1 : 0,
|
|
testResults: [
|
|
{
|
|
name: path.join(root, state === "wrong-file" ? "other.test.ts" : file),
|
|
status: state === "suite-failed" ? "failed" : "passed",
|
|
assertionResults: assertions,
|
|
},
|
|
],
|
|
};
|
|
mkdirSync(path.join(root, "scripts"));
|
|
// A previous successful report must not satisfy a run that emits no report.
|
|
writeFileSync(path.join(root, "browser-native-host.json"), JSON.stringify(report));
|
|
// Execute the workflow's shell and validator; replace only the expensive
|
|
// Vitest process with a controlled reporter at its external boundary.
|
|
writeFileSync(
|
|
path.join(root, "scripts/run-vitest.mjs"),
|
|
`
|
|
import fs from 'node:fs';
|
|
const args = process.argv.slice(2);
|
|
fs.writeFileSync('invocation.json', JSON.stringify({ args, prebuilt: process.env.OPENCLAW_E2E_USE_PREBUILT_DIST }));
|
|
const outputIndex = args.indexOf('--outputFile.json');
|
|
if (outputIndex >= 0 && ${JSON.stringify(state)} !== 'missing-report') {
|
|
fs.writeFileSync(args[outputIndex + 1], ${JSON.stringify(state === "malformed" ? "{" : JSON.stringify(report))});
|
|
}
|
|
`,
|
|
);
|
|
const result = runWorkflowShellScript(step.run, {
|
|
cwd: root,
|
|
env: { ...process.env, ...step.env, RUNNER_TEMP: root },
|
|
});
|
|
expect(result.status, result.stderr).toBe(state === "passed" ? 0 : 1);
|
|
if (state === "passed") {
|
|
expect(JSON.parse(readFileSync(path.join(root, "invocation.json"), "utf8"))).toEqual({
|
|
prebuilt: "1",
|
|
args: [
|
|
"run",
|
|
"--config",
|
|
"test/vitest/vitest.e2e.config.ts",
|
|
file,
|
|
"--reporter=default",
|
|
"--reporter=json",
|
|
"--outputFile.json",
|
|
path.join(root, "browser-native-host.json"),
|
|
],
|
|
});
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
fullNames: [
|
|
"native host registration launches with the exact custom installation context when Chrome has no selectors",
|
|
],
|
|
expected: 0,
|
|
},
|
|
{
|
|
fullNames: [
|
|
"does not inspect or migrate configuration before rejecting a malformed native request",
|
|
"rejects an unauthorized bootstrap caller before config, keys or database creation",
|
|
"rejects an unauthorized ensure_relay caller before config, keys or database creation",
|
|
'preserves invalid-config diagnostics for ordinary extension command "status"',
|
|
'preserves invalid-config diagnostics for ordinary extension command "setup"',
|
|
'preserves invalid-config diagnostics for ordinary extension command "pair"',
|
|
"launches launcher with the exact custom installation context when Chrome has no selectors",
|
|
"launches cli with the exact custom installation context when Chrome has no selectors",
|
|
].map((name) => `native host registration ${name}`),
|
|
expected: 0,
|
|
},
|
|
{ fullNames: ["historical native-host proof"], expected: 1 },
|
|
])(
|
|
"validates a complete known frozen native-host test inventory: $fullNames",
|
|
({ fullNames, expected }) => {
|
|
const step = readCiWorkflow().jobs["build-artifacts"].steps.find(
|
|
(entry: WorkflowStep) => entry.name === "Verify built browser native host",
|
|
);
|
|
const root = tempDirs.make("openclaw-frozen-browser-proof-report-");
|
|
const file = "extensions/browser/src/browser/extension-install.native-host.e2e.test.ts";
|
|
const report = {
|
|
success: true,
|
|
numFailedTestSuites: 0,
|
|
numPendingTestSuites: 0,
|
|
numTotalTests: fullNames.length,
|
|
numPassedTests: fullNames.length,
|
|
numFailedTests: 0,
|
|
numPendingTests: 0,
|
|
numTodoTests: 0,
|
|
testResults: [
|
|
{
|
|
name: path.join(root, file),
|
|
status: "passed",
|
|
assertionResults: fullNames.map((fullName) => ({ fullName, status: "passed" })),
|
|
},
|
|
],
|
|
};
|
|
mkdirSync(path.join(root, "scripts"));
|
|
writeFileSync(
|
|
path.join(root, "scripts/run-vitest.mjs"),
|
|
`import fs from "node:fs";
|
|
const args = process.argv.slice(2);
|
|
fs.writeFileSync(args[args.indexOf("--outputFile.json") + 1], ${JSON.stringify(JSON.stringify(report))});`,
|
|
);
|
|
const result = runWorkflowShellScript(step.run, {
|
|
cwd: root,
|
|
env: { ...process.env, ...step.env, FROZEN_TARGET: "true", RUNNER_TEMP: root },
|
|
});
|
|
expect(result.status, result.stderr).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("restores dist in PR CI and saves it only from the trusted warmer", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;
|
|
const stepNames = buildArtifactSteps.map((step: WorkflowStep) => step.name);
|
|
const restoreStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Restore dist build cache",
|
|
);
|
|
const buildDistStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Build dist",
|
|
);
|
|
const warmer = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
const warmerSteps = warmer.jobs.warm.steps as WorkflowStep[];
|
|
const saveStep = expectDefined(
|
|
warmerSteps.find((step) => step.name === "Save dist build cache"),
|
|
"trusted dist cache save",
|
|
);
|
|
|
|
expect(stepNames.indexOf("Restore dist build cache")).toBeLessThan(
|
|
stepNames.indexOf("Build dist"),
|
|
);
|
|
expect(stepNames.indexOf("Build dist")).toBeLessThan(
|
|
stepNames.indexOf("Smoke test CLI launcher help"),
|
|
);
|
|
expect(stepNames).not.toContain("Save dist build cache");
|
|
expect(restoreStep.uses).toBe(CACHE_V5);
|
|
expect(buildDistStep.if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'");
|
|
expect(saveStep.uses).toBe("actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9");
|
|
expect(saveStep.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
expect(saveStep.with?.key).toBe("${{ runner.os }}-dist-build-v3-${{ github.sha }}");
|
|
expect(restoreStep.with.path).toContain("dist/");
|
|
expect(restoreStep.with.path).toContain("dist-runtime/");
|
|
expect(restoreStep.with.path).toContain("packages/*/dist/");
|
|
expect(saveStep.with?.path).toContain("packages/*/dist/");
|
|
expect(restoreStep.with.key).toContain("dist-build-v3-");
|
|
expect(restoreStep.with.path).toContain("extensions/*/src/host/**/.bundle.hash");
|
|
expect(restoreStep.with.path).toContain("extensions/*/src/host/**/*.bundle.js");
|
|
expect(warmerSteps.indexOf(saveStep)).toBeGreaterThan(
|
|
warmerSteps.findIndex((step) => step.name === "Warm build cache"),
|
|
);
|
|
expect(buildArtifactSteps.map((step: WorkflowStep) => step.name)).not.toContain(
|
|
"Cache dist build",
|
|
);
|
|
});
|
|
|
|
it("keeps the AI runtime in Testbox build artifact caches", () => {
|
|
const workflow = readBuildArtifactsTestboxWorkflow();
|
|
const steps = workflow.jobs["build-artifacts"].steps;
|
|
const resolveSeedsStep = steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve release dist cache seeds",
|
|
);
|
|
const setupStep = expectDefined(
|
|
steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
|
"Testbox Node setup",
|
|
);
|
|
const restoreStep = steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore dist build cache",
|
|
);
|
|
const verifyStep = steps.find((step: WorkflowStep) => step.name === "Verify build artifacts");
|
|
const saveStep = steps.find((step: WorkflowStep) => step.name === "Save dist build cache");
|
|
|
|
expect(resolveSeedsStep.run).toContain('cache_prefix="${RUNNER_OS}-dist-build-v2-"');
|
|
expect(restoreStep.with.path).toContain("packages/*/dist/");
|
|
expect(restoreStep.with.key).toContain("dist-build-v2-");
|
|
expect(verifyStep.run).toContain("test -f packages/ai/dist/internal/runtime.mjs");
|
|
expect(saveStep.with.path).toContain("packages/*/dist/");
|
|
expect(saveStep.with.key).toContain("dist-build-v2-");
|
|
expect(setupStep.with["cache-mode"]).toContain("'read-write'");
|
|
expect(saveStep.if).toContain("steps.setup-node-env.outputs.cache-mode == 'read-write'");
|
|
});
|
|
|
|
it("keeps the full built TUI PTY suite out of the artifact canary gate", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;
|
|
const builtArtifactChecks = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Run built artifact checks",
|
|
);
|
|
const run = builtArtifactChecks.run;
|
|
|
|
expect(builtArtifactChecks.env.PARALLEL_GATEWAY_WATCH).toBe(
|
|
"${{ runner.environment != 'github-hosted' && 'true' || 'false' }}",
|
|
);
|
|
expect(run).toContain('start_check "channels"');
|
|
expect(run).toContain('start_check "core-support-boundary"');
|
|
expect(run).toContain('start_check "gateway-watch"');
|
|
expect(run).toContain(
|
|
'if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then',
|
|
);
|
|
expect(run).toContain(
|
|
'if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" != "true" ]; then',
|
|
);
|
|
const firstWait = run.indexOf(
|
|
"\nwait_checks\n",
|
|
run.indexOf('start_check "core-support-boundary"'),
|
|
);
|
|
const hostedGatewayWatch = run.indexOf(
|
|
'if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" != "true" ]; then',
|
|
);
|
|
const tuiPty = run.indexOf('if [ "$RUN_TUI_PTY" = "true" ]; then');
|
|
const hostedGatewayWait = run.indexOf("\n wait_checks\n", hostedGatewayWatch);
|
|
const parallelDiscord = run.indexOf(
|
|
'if [ "$RUN_PROCESS_PROOFS" = "true" ] && [ "$RUN_CHANNELS" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" = "true" ]; then',
|
|
);
|
|
const hostedDiscord = run.indexOf(
|
|
'if [ "$RUN_PROCESS_PROOFS" = "true" ] && [ "$RUN_CHANNELS" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" != "true" ]; then',
|
|
);
|
|
const hostedDiscordWait = run.indexOf("\n wait_checks\n", hostedDiscord);
|
|
const tuiPtyWait = run.indexOf("\n wait_checks\n", tuiPty);
|
|
expect(firstWait).toBeGreaterThan(run.indexOf('start_check "core-support-boundary"'));
|
|
expect(hostedGatewayWatch).toBeGreaterThan(firstWait);
|
|
expect(hostedGatewayWait).toBeGreaterThan(hostedGatewayWatch);
|
|
expect(parallelDiscord).toBeLessThan(firstWait);
|
|
expect(hostedDiscord).toBeGreaterThan(hostedGatewayWait);
|
|
expect(hostedDiscordWait).toBeGreaterThan(hostedDiscord);
|
|
expect(tuiPty).toBeGreaterThan(hostedDiscordWait);
|
|
expect(tuiPtyWait).toBeGreaterThan(tuiPty);
|
|
expect(run.slice(tuiPty, tuiPtyWait)).toContain("src/tui/tui-pty-local.e2e.test.ts");
|
|
expect(run.slice(tuiPty, tuiPtyWait)).toContain("--testNamePattern");
|
|
expect(run.slice(tuiPty, tuiPtyWait)).toContain(
|
|
"launches openclaw (chat as local mode|tui against a real Gateway) through a real PTY",
|
|
);
|
|
expect(run).toContain("wait_checks()");
|
|
// Startup memory, artifact writers, and TUI retain explicit barriers;
|
|
// hosted runners also serialize the remaining verifiers inside run_verifier.
|
|
expect(run.match(/wait_checks$/gmu)).toHaveLength(8);
|
|
});
|
|
|
|
it.each([
|
|
{ mode: "private-qa", outcome: "success", expected: "1" },
|
|
{ mode: "runtime", outcome: "success", expected: "" },
|
|
{ mode: "private-qa", outcome: "failure", expected: "" },
|
|
{ mode: "private-qa", outcome: "skipped", expected: "" },
|
|
{ mode: undefined, outcome: "skipped", expected: "" },
|
|
] as const)("hands prepared E2E runtime to children only after $mode $outcome", (scenario) => {
|
|
const steps = readCiWorkflow().jobs["checks-node-core-test-nondist-shard"].steps;
|
|
const build = steps.find((step: WorkflowStep) => step.name === "Build Node test runtime");
|
|
const run = steps.find((step: WorkflowStep) => step.name === "Run Node test shard");
|
|
const prebuilt = evaluateWorkflowExpression(run.env.OPENCLAW_E2E_USE_PREBUILT_DIST, {
|
|
eventName: "pull_request",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
matrix: { pretest_build_mode: scenario.mode },
|
|
steps: { [build.id]: { outputs: {}, outcome: scenario.outcome } },
|
|
});
|
|
const target = "test/example.e2e.test.ts";
|
|
const env = {
|
|
OPENCLAW_NODE_TEST_TARGETS_JSON: JSON.stringify([target]),
|
|
OPENCLAW_E2E_USE_PREBUILT_DIST: prebuilt,
|
|
};
|
|
const plans = resolveShardPlans(env);
|
|
expect(plans).toHaveLength(1);
|
|
const childEnv = buildChildEnv(
|
|
expectDefined(plans[0], "changed target plan"),
|
|
env,
|
|
tempDirs.make("openclaw-ci-prebuilt-env-"),
|
|
0,
|
|
);
|
|
expect(childEnv.OPENCLAW_E2E_USE_PREBUILT_DIST).toBe(scenario.expected);
|
|
expect(steps.indexOf(build)).toBeLessThan(steps.indexOf(run));
|
|
});
|
|
|
|
it("fails and retries quiet Node test shard stalls quickly", () => {
|
|
const workflow = readCiWorkflow();
|
|
const preflightJob = workflow.jobs.preflight;
|
|
const manifestStep = preflightJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
const nodeTestJob = workflow.jobs["checks-node-core-test-nondist-shard"];
|
|
const runStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Node test shard",
|
|
);
|
|
const buildRuntimeStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build Node test runtime",
|
|
);
|
|
const installRipgrepStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install ripgrep for native grep tests",
|
|
);
|
|
|
|
expect(JSON.stringify(preflightJob.steps)).toContain("timeout_minutes: shard.timeoutMinutes");
|
|
expect(manifestStep.run).toContain("pretest_build_mode: shard.pretestBuildMode");
|
|
expect(manifestStep.run).toContain("requires_ripgrep:");
|
|
expect(manifestStep.run).toContain("src/agents/sessions/tools/index.test.ts");
|
|
expect(nodeTestJob["timeout-minutes"]).toBe("${{ matrix.timeout_minutes || 60 }}");
|
|
expect(runStep.env.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target == 'true' && '660000' || '300000' }}",
|
|
);
|
|
expect(runStep.env.OPENCLAW_VITEST_NO_OUTPUT_RETRY).toBeUndefined();
|
|
expect(runStep.env.OPENCLAW_NODE_TEST_ENV_JSON).toBe("${{ toJson(matrix.env) }}");
|
|
expect(runStep.env.OPENCLAW_NODE_TEST_TARGETS_JSON).toBe("${{ toJson(matrix.targets) }}");
|
|
expect(runStep.env.OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64).toBe(
|
|
"${{ matrix.groups_gzip_base64 || '' }}",
|
|
);
|
|
expect(runStep.env.OPENCLAW_NODE_TEST_GROUPS_JSON).toBe(
|
|
"${{ matrix.groups && toJson(matrix.groups) || '' }}",
|
|
);
|
|
expect(runStep.env.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target == 'true' && '[\"--hookTimeout=600000\"]' || '[]' }}",
|
|
);
|
|
expect(buildRuntimeStep).toMatchObject({
|
|
if: "matrix.pretest_build_mode != null",
|
|
env: {
|
|
OPENCLAW_BUILD_PRIVATE_QA: "${{ matrix.pretest_build_mode == 'private-qa' && '1' || '0' }}",
|
|
VITEST: "1",
|
|
},
|
|
run: "pnpm build qaRuntime",
|
|
});
|
|
expect(installRipgrepStep).toMatchObject({
|
|
if: "matrix.requires_ripgrep == true && runner.os == 'Linux'",
|
|
run: expect.stringContaining("apt-get install -y --no-install-recommends ripgrep"),
|
|
});
|
|
expect(nodeTestJob.steps.indexOf(buildRuntimeStep)).toBeLessThan(
|
|
nodeTestJob.steps.indexOf(runStep),
|
|
);
|
|
expect(nodeTestJob.steps.indexOf(installRipgrepStep)).toBeLessThan(
|
|
nodeTestJob.steps.indexOf(runStep),
|
|
);
|
|
const trustedRunnerStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout trusted Node shard runner",
|
|
);
|
|
expect(trustedRunnerStep).toMatchObject({
|
|
if: "${{ hashFiles('scripts/ci-run-node-test-shard.mts') == '' }}",
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: ".ci-workflow",
|
|
"sparse-checkout": expect.stringContaining("scripts/ci-run-node-test-shard.mts"),
|
|
"sparse-checkout-cone-mode": false,
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
// Non-cone sparse-checkout ignores missing paths silently, so a renamed
|
|
// script would surface only as a runtime module-not-found on the frozen
|
|
// lane. Require every listed path to exist at this revision.
|
|
const sparseCheckoutPaths = String(trustedRunnerStep?.with?.["sparse-checkout"] ?? "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
expect(sparseCheckoutPaths).toContain("scripts/ci-run-node-test-shard.mts");
|
|
for (const sparsePath of sparseCheckoutPaths) {
|
|
expect({ sparsePath, exists: existsSync(sparsePath) }).toEqual({ sparsePath, exists: true });
|
|
}
|
|
const runtimeFiles = collectRuntimeImportClosure(process.cwd(), [
|
|
"scripts/ci-run-node-test-shard.mts",
|
|
]).filter((file) => file.startsWith("scripts/"));
|
|
expect(runtimeFiles.toSorted()).toEqual(sparseCheckoutPaths.toSorted());
|
|
expect(runtimeFiles).not.toContain("scripts/lib/vitest-worker-run.mts");
|
|
});
|
|
|
|
it("routes admitted RunsOn rows with unique Spot labels and portable cache readers", () => {
|
|
const job = readCiWorkflow().jobs["checks-node-core-test-nondist-shard"];
|
|
const context = {
|
|
eventName: "pull_request",
|
|
repository: "openclaw/openclaw",
|
|
headRepository: "openclaw/openclaw",
|
|
authorAssociation: "CONTRIBUTOR",
|
|
runAttempt: 1,
|
|
runId: 123,
|
|
runnerBackend: "runson",
|
|
runnerProfile: "hybrid",
|
|
runnerEnvironment: "self-hosted",
|
|
preflightOutputs: { node_runner_backend: "runson" },
|
|
matrix: { runner: "runson-c8i-8xlarge", check_name: "cron-1" },
|
|
} as const;
|
|
const label = evaluateWorkflowExpression(job["runs-on"], context);
|
|
expect(label).toBe(
|
|
"runs-on=123-cron-1/family=c8i.8xlarge/cpu=32/ram=64/spot=true/retry=false/image=ubuntu24-full-x64/volume=80gb",
|
|
);
|
|
expect(
|
|
evaluateWorkflowExpression(job["runs-on"], {
|
|
...context,
|
|
matrix: { ...context.matrix, check_name: "cron-2" },
|
|
}),
|
|
).not.toBe(label);
|
|
for (const overrides of [
|
|
{ runAttempt: 2 },
|
|
{ preflightOutputs: { node_runner_backend: "hybrid" } },
|
|
{
|
|
headRepository: "fork/openclaw",
|
|
preflightOutputs: { node_runner_backend: "github" },
|
|
},
|
|
]) {
|
|
expect(evaluateWorkflowExpression(job["runs-on"], { ...context, ...overrides })).toBe(
|
|
"ubuntu-24.04",
|
|
);
|
|
}
|
|
const dispatch = { ...context, eventName: "workflow_dispatch" as const, releaseGate: true };
|
|
expect(evaluateWorkflowExpression(job["runs-on"], dispatch)).toBe(label);
|
|
expect(
|
|
evaluateWorkflowExpression(job["runs-on"], {
|
|
...dispatch,
|
|
matrix: { runner: "blacksmith-32vcpu-ubuntu-2404", check_name: "ordinary-node" },
|
|
}),
|
|
).toBe("blacksmith-32vcpu-ubuntu-2404");
|
|
for (const [name, matrix, expected, hosted] of [
|
|
["build-artifacts", {}, "blacksmith-16vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
["checks-ui", {}, "blacksmith-8vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
["checks-ui-e2e", { task: "control-ui" }, "blacksmith-16vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
["checks-windows", {}, "blacksmith-16vcpu-windows-2025", "windows-2025"],
|
|
[
|
|
"check-shard",
|
|
{ task: "test-types", runner: "blacksmith-16vcpu-ubuntu-2404" },
|
|
"blacksmith-16vcpu-ubuntu-2404",
|
|
"ubuntu-24.04",
|
|
],
|
|
["check-test-types-hosted-core-shard", {}, "blacksmith-16vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
["check-lint-hosted-core-shard", {}, "blacksmith-8vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
["ci-gate", {}, "blacksmith-4vcpu-ubuntu-2404", "ubuntu-24.04"],
|
|
[
|
|
"check-additional-shard",
|
|
{ group: "extension-package-boundary", runner: "blacksmith-16vcpu-ubuntu-2404" },
|
|
"blacksmith-16vcpu-ubuntu-2404",
|
|
"ubuntu-24.04",
|
|
],
|
|
] as const) {
|
|
const expression = readCiWorkflow().jobs[name]["runs-on"];
|
|
expect(evaluateWorkflowExpression(expression, { ...context, matrix }), `${name} PR`).toBe(
|
|
expected,
|
|
);
|
|
expect(evaluateWorkflowExpression(expression, { ...dispatch, matrix }), `${name} proof`).toBe(
|
|
expected,
|
|
);
|
|
for (const overrides of [
|
|
{ runAttempt: 2 },
|
|
{ preflightOutputs: { node_runner_backend: "hybrid" } },
|
|
]) {
|
|
expect(
|
|
evaluateWorkflowExpression(expression, { ...dispatch, matrix, ...overrides }),
|
|
`${name} manual/retry fallback`,
|
|
).toBe(hosted);
|
|
}
|
|
}
|
|
const setup = expectDefined(
|
|
job.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
|
"Node setup",
|
|
);
|
|
expect(evaluateWorkflowExpression(setup.with["dependency-cache"], context)).toBe("false");
|
|
expect(
|
|
evaluateWorkflowExpression(setup.with["dependency-cache"], {
|
|
...context,
|
|
matrix: { runner: "blacksmith-32vcpu-ubuntu-2404" },
|
|
}),
|
|
).toBe("true");
|
|
for (const [overrides, expected] of [
|
|
[{}, "true"],
|
|
[{ runAttempt: 2 }, "false"],
|
|
[{ preflightOutputs: { node_runner_backend: "hybrid" } }, "false"],
|
|
] as const) {
|
|
expect(
|
|
evaluateWorkflowExpression(setup.with["dependency-cache"], {
|
|
...dispatch,
|
|
matrix: { runner: "blacksmith-32vcpu-ubuntu-2404" },
|
|
...overrides,
|
|
}),
|
|
).toBe(expected);
|
|
}
|
|
expect(setup.with).toMatchObject({ "vitest-fs-cache": "true", "node-compile-cache": "true" });
|
|
const resources = expectDefined(
|
|
job.steps.find((step: WorkflowStep) => step.name === "Configure Node test resources"),
|
|
"Node resources",
|
|
);
|
|
for (const matrix of [
|
|
context.matrix,
|
|
{
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
check_name: "checks-node-runson-cron-blacksmith-control",
|
|
},
|
|
{
|
|
runner: "ubuntu-24.04",
|
|
check_name: "checks-node-runson-cron-github-control",
|
|
},
|
|
]) {
|
|
const comparison = { ...context, matrix, env: { NODE_VERSION: "24.19.0" } };
|
|
expect(evaluateWorkflowExpression(setup.with["node-version"], comparison)).toBe("24.19.0");
|
|
expect(evaluateWorkflowExpression(resources.env.RUNSON_JOB, comparison)).toBe("true");
|
|
}
|
|
const initialize = expectDefined(
|
|
job.steps.find((step: WorkflowStep) => step.name === "Initialize RunsOn"),
|
|
"RunsOn initialization",
|
|
);
|
|
expect(evaluateWorkflowExpression(`\${{ ${initialize.if} }}`, context)).toBe(true);
|
|
expect(
|
|
evaluateWorkflowExpression(`\${{ ${initialize.if} }}`, {
|
|
...context,
|
|
runnerEnvironment: "github-hosted",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("keeps RunsOn Node workers bounded without invoking the Blacksmith scheduler", () => {
|
|
const step = expectDefined(
|
|
readCiWorkflow().jobs["checks-node-core-test-nondist-shard"].steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Configure Node test resources",
|
|
),
|
|
"Node resources",
|
|
);
|
|
const root = tempDirs.make("runson-node-workers-");
|
|
const bin = path.join(root, "bin");
|
|
mkdirSync(bin);
|
|
writeExecutable(path.join(bin, "nproc"), ["#!/bin/sh", 'printf "%s\\n" "$FIXTURE_CORES"']);
|
|
writeExecutable(path.join(bin, "node"), ["#!/bin/sh", "exit 64"]);
|
|
for (const [cores, workers] of [
|
|
[32, 2],
|
|
[1, 1],
|
|
]) {
|
|
const output = path.join(root, "github-env");
|
|
writeFileSync(output, "");
|
|
const result = runWorkflowShellScript(expectDefined(step.run, "resource script"), {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
GITHUB_ENV: output,
|
|
FIXTURE_CORES: String(cores),
|
|
RUNSON_JOB: "true",
|
|
RUNNER_ENVIRONMENT: "self-hosted",
|
|
FROZEN_TARGET: "false",
|
|
SHARD_PLAN_CONCURRENCY: "1",
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readWorkflowOutputs(output)).toEqual({ OPENCLAW_VITEST_MAX_WORKERS: String(workers) });
|
|
}
|
|
});
|
|
|
|
it("clamps Node test workers to the detected core count", () => {
|
|
const workflow = readCiWorkflow();
|
|
const nodeTestJob = workflow.jobs["checks-node-core-test-nondist-shard"];
|
|
const resourceStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Configure Node test resources",
|
|
);
|
|
|
|
expect(resourceStep.run).toContain('if [ "$workers" -gt "$cores" ]; then');
|
|
expect(resourceStep.run).toContain('workers="$cores"');
|
|
expect(resourceStep.run.indexOf('workers="$cores"')).toBeLessThan(
|
|
resourceStep.run.indexOf("OPENCLAW_VITEST_MAX_WORKERS"),
|
|
);
|
|
expect(resourceStep.env.FROZEN_TARGET).toBe("${{ needs.preflight.outputs.frozen_target }}");
|
|
expect(resourceStep.env.RUNNER_ENVIRONMENT).toBe("${{ runner.environment }}");
|
|
expect(resourceStep.run).toContain(
|
|
'[[ "$RUNSON_JOB" != "true" && "$RUNNER_ENVIRONMENT" == "self-hosted" && "$FROZEN_TARGET" != "true" && "$SHARD_PLAN_CONCURRENCY" == "1" ]]',
|
|
);
|
|
expect(resourceStep.run).toContain("isConstrainedCiCheckHost({");
|
|
expect(resourceStep.run).toContain(
|
|
"resolveLocalVitestScheduling(process.env, host).maxWorkers",
|
|
);
|
|
expect(nodeTestJob.steps.indexOf(resourceStep)).toBeGreaterThan(
|
|
nodeTestJob.steps.findIndex((step: WorkflowStep) => step.name === "Build Node test runtime"),
|
|
);
|
|
const runStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Node test shard",
|
|
);
|
|
expect(runStep.env.FROZEN_TARGET).toBe(resourceStep.env.FROZEN_TARGET);
|
|
expect(runStep.env.RUNNER_ENVIRONMENT).toBe(resourceStep.env.RUNNER_ENVIRONMENT);
|
|
});
|
|
|
|
it("uses candidate-owned script interfaces for frozen target CI", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildChecks = workflow.jobs["build-artifacts"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run built artifact checks",
|
|
);
|
|
const qaBuild = workflow.jobs["qa-smoke-ci-profile"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Build QA smoke runtime",
|
|
);
|
|
const additionalChecks = workflow.jobs["check-additional-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
|
|
expect(buildChecks.run).toContain("pnpm test:gateway:watch-regression -- --skip-build");
|
|
expect(buildChecks.run).not.toContain("scripts/check-gateway-watch-regression.mts");
|
|
expect(buildChecks.run).toContain(
|
|
"startup_builder=(node --import tsx scripts/ensure-cli-startup-build.mts)",
|
|
);
|
|
expect(buildChecks.run).toContain(
|
|
"startup_builder=(node scripts/ensure-cli-startup-build.mjs)",
|
|
);
|
|
expect(qaBuild.run.match(/pnpm build qaRuntime/gu)).toHaveLength(1);
|
|
expect(qaBuild.run).not.toContain("package-openclaw-for-docker");
|
|
expect(additionalChecks.run).toContain(
|
|
"boundary_runner=(node --import tsx scripts/run-additional-boundary-checks.mts)",
|
|
);
|
|
expect(additionalChecks.run).toContain(
|
|
"boundary_runner=(node scripts/run-additional-boundary-checks.mjs)",
|
|
);
|
|
expect(additionalChecks.run).not.toContain(
|
|
"if [ ! -f scripts/check-session-accessor-boundary.mts ]",
|
|
);
|
|
expect(additionalChecks.run).not.toContain(
|
|
"if [ ! -f scripts/check-session-transcript-reader-boundary.mts ]",
|
|
);
|
|
const checkLint = workflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
);
|
|
const hostedCoreLint = workflow.jobs["check-lint-hosted-core-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run hosted core lint stripe",
|
|
);
|
|
const lintBoundaryFingerprint = workflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Compute extension boundary input fingerprint",
|
|
);
|
|
const additionalBoundaryFingerprint = workflow.jobs["check-additional-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Compute extension boundary input fingerprint",
|
|
);
|
|
|
|
// The frozen candidate owns the older full lint and boundary builders;
|
|
// current-only stripe and cache mechanics must not replace that coverage.
|
|
expect(checkLint.run).toContain("if [[ ! -f scripts/run-oxlint-shards.mts ]]; then");
|
|
expect(checkLint.run).toContain("pnpm lint");
|
|
expect(hostedCoreLint.run).toContain("target does not support core lint stripes");
|
|
expect(lintBoundaryFingerprint.run).toContain("enabled=false");
|
|
expect(additionalBoundaryFingerprint.run).toContain("enabled=false");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps missing performance coverage fatal outside historical targets",
|
|
() => {
|
|
const job = readCiWorkflow().jobs["control-ui-performance"];
|
|
expect(job.needs).toEqual(["preflight"]);
|
|
expect(job.env.CHECKOUT_BASE_SHA).toBe("${{ needs.preflight.outputs.diff_base_revision }}");
|
|
const step = job.steps.find(
|
|
(candidate: WorkflowStep) => candidate.name === "Check Control UI performance against base",
|
|
);
|
|
const root = tempDirs.make("openclaw-performance-workflow-");
|
|
const summary = path.join(root, "summary.md");
|
|
writeFileSync(path.join(root, "package.json"), "{}");
|
|
for (const compatibility of ["true", "false"]) {
|
|
const result = runWorkflowShellScript(step.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
COMPATIBILITY_TARGET: compatibility,
|
|
GITHUB_STEP_SUMMARY: summary,
|
|
},
|
|
});
|
|
expect(result.status, `${result.stdout}${result.stderr}`).toBe(
|
|
compatibility === "true" ? 0 : 1,
|
|
);
|
|
}
|
|
expect(readFileSync(summary, "utf8")).toContain(
|
|
"unavailable on the selected compatibility target",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("keeps push docs validation ClawHub-backed", () => {
|
|
const workflow = readFileSync(".github/workflows/docs.yml", "utf8");
|
|
|
|
expect(workflow).toContain("repository: openclaw/clawhub");
|
|
expect(workflow).toContain("path: clawhub-source");
|
|
expect(workflow).toContain(
|
|
"OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source",
|
|
);
|
|
});
|
|
|
|
it("skips generated-asset validation only when a frozen candidate lacks the contract", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactsJob = workflow.jobs["build-artifacts"];
|
|
const assetCheckStep = buildArtifactsJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check bundled plugin generated assets",
|
|
);
|
|
|
|
expect(assetCheckStep.run).toContain('packageJson.scripts?.["plugins:assets:check"]');
|
|
expect(assetCheckStep.run).toContain("pnpm plugins:assets:check");
|
|
expect(assetCheckStep.run).toContain("predates plugins:assets:check");
|
|
});
|
|
|
|
it("keeps network CodeQL off unrelated source-only refactors", () => {
|
|
const workflow = readCriticalQualityWorkflow();
|
|
const networkConfig = readFileSync(
|
|
".github/codeql/codeql-network-runtime-boundary-critical-quality.yml",
|
|
"utf8",
|
|
);
|
|
const rawSocketQuery = readFileSync(
|
|
".github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql",
|
|
"utf8",
|
|
);
|
|
const networkSelector = workflow.slice(
|
|
workflow.indexOf(".github/codeql/codeql-network-runtime-boundary-critical-quality.yml"),
|
|
workflow.indexOf("network-runtime-boundary:"),
|
|
);
|
|
const broadCodeqlSelector = workflow.slice(
|
|
workflow.indexOf(".github/codeql/*|.github/workflows/codeql-critical-quality.yml"),
|
|
workflow.indexOf("src/**/*.test.ts|src/**/*.test.tsx"),
|
|
);
|
|
|
|
expect(broadCodeqlSelector).not.toContain("network_runtime=true");
|
|
expect(networkSelector).toContain(
|
|
".github/codeql/codeql-network-runtime-boundary-critical-quality.yml",
|
|
);
|
|
expect(networkSelector).not.toContain("src/*.ts|src/**/*.ts");
|
|
expect(networkSelector).not.toContain("extensions/*.ts|extensions/**/*.ts");
|
|
expect(networkSelector).toContain("src/infra/net/*");
|
|
expect(networkSelector).toContain("src/infra/ssh-tunnel.ts");
|
|
expect(networkSelector).toContain("packages/net-policy/src/*");
|
|
expect(networkConfig).not.toContain("\n - src\n");
|
|
expect(networkConfig).not.toContain("\n - extensions\n");
|
|
expect(networkConfig).toContain("\n - src/infra/net\n");
|
|
expect(networkConfig).toContain("\n - packages/net-policy/src\n");
|
|
expect(workflow).toContain("Fast PR network boundary diff scan");
|
|
expect(workflow).toContain(
|
|
'| select(.filename | test("(^|/)[^/]+\\\\.(?:e2e\\\\.)?test\\\\.tsx?$") | not)',
|
|
);
|
|
expect(workflow).toContain("Network runtime boundary-sensitive added lines");
|
|
expect(workflow).toContain(
|
|
'codex_transport="extensions/codex/src/app-server/transport-websocket.ts"',
|
|
);
|
|
expect(workflow).toContain(
|
|
"network_codeql_contract_pattern='^\\.github/codeql/(codeql-network-runtime-boundary-critical-quality\\.yml|openclaw-boundary/queries/(raw-socket-callsite-classification|managed-proxy-runtime-mutation)\\.ql)$'",
|
|
);
|
|
expect(workflow).toContain(
|
|
'if grep -Eq "$network_codeql_contract_pattern" "$changed_files" ||',
|
|
);
|
|
expect(workflow).not.toContain('grep -Fv "$codex_transport: " "$added_lines"');
|
|
expect(workflow).toContain("packages/net-policy/src/");
|
|
expect(workflow).toContain(
|
|
"grep -En 'HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' \"$added_lines\"",
|
|
);
|
|
expect(workflow).toContain('echo "full_codeql=true" >> "$GITHUB_OUTPUT"');
|
|
expect(workflow).toContain(
|
|
"if: ${{ github.event_name != 'pull_request' || steps.network-diff-scan.outputs.full_codeql == 'true' }}",
|
|
);
|
|
expect(rawSocketQuery).toMatch(
|
|
/allowedOwnerScope\(\s*call\s*,\s*"extensions\/codex\/src\/app-server\/transport-websocket\.ts"\s*,\s*"connectCodexAppServerUnixSocket"\s*\)/,
|
|
);
|
|
expect(rawSocketQuery).not.toContain(
|
|
'call.getFile().getRelativePath() = "extensions/codex/src/app-server/transport-websocket.ts"',
|
|
);
|
|
});
|
|
|
|
it("keeps the Crabbox gate publisher on protected main with minimal permissions", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/pr-crabbox-gate-publisher.yml", "utf8"));
|
|
const publisher = readFileSync("scripts/pr-crabbox-gate-publisher.mjs", "utf8");
|
|
const job = workflow.jobs.publish;
|
|
expect(workflow.permissions).toEqual({});
|
|
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
|
expect(evaluateWorkflowRunner(job["runs-on"])).toBe("ubuntu-24.04");
|
|
expect(job.environment).toBe("qa-live-shared");
|
|
expect(job["timeout-minutes"]).toBe(270);
|
|
expect(job.permissions).toEqual({
|
|
checks: "write",
|
|
contents: "read",
|
|
"pull-requests": "read",
|
|
});
|
|
expect(job.steps[0]).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
"fetch-depth": 0,
|
|
"persist-credentials": false,
|
|
ref: "${{ github.workflow_sha }}",
|
|
},
|
|
});
|
|
expect(job.steps.at(-1)).toMatchObject({
|
|
env: {
|
|
CRABBOX_ACCESS_CLIENT_ID: "${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}",
|
|
CRABBOX_ACCESS_CLIENT_SECRET: "${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}",
|
|
CRABBOX_COORDINATOR:
|
|
"${{ secrets.CRABBOX_COORDINATOR || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}",
|
|
CRABBOX_COORDINATOR_TOKEN:
|
|
"${{ secrets.CRABBOX_COORDINATOR_TOKEN || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}",
|
|
GH_APP_TOKEN:
|
|
"${{ steps.publish-app-token.outputs.token || steps.publish-app-token-fallback.outputs.token }}",
|
|
GH_TOKEN: "${{ github.token }}",
|
|
},
|
|
run: "node scripts/pr-crabbox-gate-publisher.mjs --publish",
|
|
});
|
|
expect(job.steps[2].run).toContain("crabbox_0.46.0_linux_amd64.tar.gz");
|
|
expect(job.steps[2].run).toContain(
|
|
"6a9341e810307356361dbed4c4b84be28a036b5cc291af1566d2ccd376570d90",
|
|
);
|
|
expect(job.steps.slice(3, 5)).toMatchObject([
|
|
{
|
|
id: "app-token",
|
|
uses: CREATE_GITHUB_APP_TOKEN_V3,
|
|
with: { "app-id": "2729701", "permission-members": "read" },
|
|
},
|
|
{
|
|
id: "app-token-fallback",
|
|
uses: CREATE_GITHUB_APP_TOKEN_V3,
|
|
with: { "app-id": "2971289", "permission-members": "read" },
|
|
},
|
|
]);
|
|
expect(publisher).toContain("const CHECK_NAME = CRABBOX_GATE_CHECK_NAME");
|
|
expect(readFileSync("scripts/pr-lib/crabbox-gate-contract.mjs", "utf8")).toContain(
|
|
'CRABBOX_GATE_CHECK_NAME = "openclaw/crabbox-gate"',
|
|
);
|
|
expect(publisher).not.toContain('const CHECK_NAME = "openclaw/ci-gate"');
|
|
expect(Object.keys(workflow.on.workflow_dispatch.inputs).toSorted()).toEqual([
|
|
"base_sha",
|
|
"head_sha",
|
|
"pr_number",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("pins generated publisher and maturity owners before credentials and selected checkout", () => {
|
|
const pinned = {
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
};
|
|
const action = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8"));
|
|
expect(action.runs.steps.map(({ name }: WorkflowStep) => name)).toEqual([
|
|
"Prepare Git owner",
|
|
"Create generated PR tokens",
|
|
"Publish generated pull request",
|
|
]);
|
|
expect(action.runs.steps[0]).toEqual(pinned);
|
|
const steps: WorkflowStep[] = readMaturityScorecardWorkflow().jobs.validate_selected_ref.steps;
|
|
const checkout = steps.findIndex(({ name }) => name === "Checkout selected ref");
|
|
expect(steps[checkout - 1]).toEqual(pinned);
|
|
expect(steps[checkout + 1]?.name).toBe("Validate selected ref");
|
|
const policy = expectDefined(steps[checkout + 1]?.run, "validation body");
|
|
expect(policy).toContain('exec python3 -I -S "$CI_GIT_OWNER" --policy -');
|
|
expect(policy.match(/timeout=\d+/gu)).toEqual(["timeout=60"]);
|
|
expect(policy).not.toMatch(
|
|
/timeout --|(?:^|\s)git (?:fetch|ls-remote|rev-parse|diff|tag|merge-base|check-ref-format)\b|except (?:Exception|BaseException|RuntimeError|SystemExit)|backoff\(/mu,
|
|
);
|
|
for (const file of [
|
|
CONTROL_UI_LOCALE_REFRESH_WORKFLOW,
|
|
NATIVE_APP_LOCALE_REFRESH_WORKFLOW,
|
|
".github/workflows/ci-test-timings-refit.yml",
|
|
MATURITY_SCORECARD_WORKFLOW,
|
|
]) {
|
|
const workflow = parse(readFileSync(file, "utf8"));
|
|
const publishers = Object.values(workflow.jobs).flatMap((job) => {
|
|
const jobSteps = (job as { steps?: WorkflowStep[] }).steps ?? [];
|
|
return jobSteps.flatMap((step, index) =>
|
|
step.uses === "./.github/actions/publish-generated-pr"
|
|
? [{ index, length: jobSteps.length }]
|
|
: [],
|
|
);
|
|
});
|
|
expect(publishers, file).toHaveLength(1);
|
|
expect(publishers[0]?.index, file).toBe(publishers[0]!.length - 1);
|
|
}
|
|
});
|
|
|
|
describe("Linux App validation routing", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/linux-app.yml", "utf8"));
|
|
const linuxSteps: WorkflowStep[] = workflow.jobs.build.steps;
|
|
const macosSteps: WorkflowStep[] = workflow.jobs["test-macos"].steps;
|
|
const manualSteps = [
|
|
"Test native Quick Chat",
|
|
"Stage AppImage GStreamer plugins",
|
|
"Prepare pinned AppImage tools",
|
|
"Build Linux companion bundles",
|
|
"Finalize AppImage",
|
|
"Test native first-run setup and failed Gateway startup",
|
|
"Test packaged AppImage runtime",
|
|
"Upload bundles",
|
|
];
|
|
|
|
it.each(["pull_request", "workflow_dispatch"] as const)(
|
|
"keeps required native tests and selects manual validation steps: %s",
|
|
(eventName) => {
|
|
const selected = (steps: WorkflowStep[]) =>
|
|
steps.filter(
|
|
(step) =>
|
|
!step.if ||
|
|
evaluateWorkflowExpression(`\${{ ${step.if} }}`, {
|
|
eventName,
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
steps: {
|
|
"inline-browser": { outputs: {}, outcome: "success" },
|
|
"gateway-switch": { outputs: {}, outcome: "success" },
|
|
"desktop-sharing": { outputs: {}, outcome: "success" },
|
|
"quick-chat": {
|
|
outputs: {},
|
|
outcome: eventName === "workflow_dispatch" ? "success" : "skipped",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const linux = selected(linuxSteps);
|
|
const macos = selected(macosSteps);
|
|
expect(workflow.jobs.build.if).toBeUndefined();
|
|
expect(workflow.jobs["test-macos"].if).toBeUndefined();
|
|
expect(workflow.jobs.build["continue-on-error"]).toBeUndefined();
|
|
expect(workflow.jobs["test-macos"]["continue-on-error"]).toBeUndefined();
|
|
for (const step of [...linuxSteps, ...macosSteps]) {
|
|
expect(step["continue-on-error"], step.name).toBeUndefined();
|
|
}
|
|
expect(linux.map((step) => step.run)).toContain("cargo +stable fmt --check");
|
|
for (const steps of [linux, macos]) {
|
|
expect(steps.map((step) => step.run)).toContain(
|
|
"cargo +stable test --locked --all-targets",
|
|
);
|
|
}
|
|
expect(
|
|
linux.find((step) => step.name === "Test packaged runtime ABI scanner")?.run,
|
|
).toContain("-s apps/linux/tests -p 'test_packaged_runtime_smoke.py'");
|
|
expect(
|
|
linux.find((step) => step.name === "Test desktop sharing proof report ordering")?.run,
|
|
).toContain("-s apps/linux/tests -p 'test_desktop_sharing_reports.py'");
|
|
expect(linux.map((step) => step.run)).toContain("cargo +stable build --locked");
|
|
expect(linux.find((step) => step.id === "inline-browser")?.run).toContain("--inline-browser");
|
|
expect(linux.find((step) => step.id === "gateway-switch")?.run).toContain("--gateway-switch");
|
|
expect(linux.find((step) => step.id === "desktop-sharing")?.run).toContain(
|
|
"--desktop-sharing",
|
|
);
|
|
for (const name of manualSteps) {
|
|
expect(
|
|
linuxSteps.some((step) => step.name === name),
|
|
name,
|
|
).toBe(true);
|
|
expect(
|
|
linux.some((step) => step.name === name),
|
|
name,
|
|
).toBe(eventName === "workflow_dispatch");
|
|
}
|
|
if (eventName === "pull_request") {
|
|
expect(
|
|
linux
|
|
.filter((step) => step.uses?.startsWith("actions/upload-artifact@"))
|
|
.map((step) => step.with?.name),
|
|
).toEqual(["linux-inline-browser", "linux-gateway-switch", "linux-desktop-sharing"]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["success", "failure", "cancelled", "skipped"] as const)(
|
|
"uploads native proof after an attempted run: %s",
|
|
(outcome) => {
|
|
for (const [name, id] of [
|
|
["Upload native inline browser proof", "inline-browser"],
|
|
["Upload native Gateway switching proof", "gateway-switch"],
|
|
["Upload native desktop sharing proof", "desktop-sharing"],
|
|
["Upload native Quick Chat proof", "quick-chat"],
|
|
] as const) {
|
|
const upload = expectDefined(
|
|
linuxSteps.find((step) => step.name === name),
|
|
`${id} proof upload`,
|
|
);
|
|
expect(
|
|
evaluateWorkflowExpression(`\${{ ${upload.if} }}`, {
|
|
eventName: "pull_request",
|
|
repository: "openclaw/openclaw",
|
|
runAttempt: 1,
|
|
failed: outcome === "failure",
|
|
cancelled: outcome === "cancelled",
|
|
steps: { [id]: { outputs: {}, outcome } },
|
|
}),
|
|
name,
|
|
).toBe(outcome !== "skipped");
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains both first-run cases and failure evidence for manual validation", () => {
|
|
const firstRun = linuxSteps.find(
|
|
(step) => step.name === "Test native first-run setup and failed Gateway startup",
|
|
);
|
|
expect(firstRun?.run?.match(/python3 apps\/linux\/tests\/first_run\.py/gu)).toHaveLength(2);
|
|
expect(firstRun?.run).toContain("--local-start-failure");
|
|
for (const [name, id] of [
|
|
["Upload first-run failure log", "first-run"],
|
|
["Upload packaged runtime failure evidence", "packaged-runtime"],
|
|
]) {
|
|
expect(linuxSteps.find((step) => step.name === name)?.if).toBe(
|
|
`github.event_name == 'workflow_dispatch' && failure() && steps.${id}.outcome == 'failure'`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
it.each(["publish", "promote"])(
|
|
"requests independent Linux publication after stable %s activation",
|
|
(owner) => {
|
|
const workflow = parse(readFileSync(`.github/workflows/openclaw-release-${owner}.yml`, "utf8"));
|
|
const job = workflow.jobs.publish_linux;
|
|
expect(job, "stable publication must request the Linux release owner").toBeDefined();
|
|
expect(job["continue-on-error"]).toBe(true);
|
|
for (const [tag, channel, activation, expected] of [
|
|
["v2026.9.4", "latest", "success", true],
|
|
["v2026.9.4", "beta", "success", true],
|
|
["v2026.9.4-beta.1", "beta", "success", false],
|
|
["v2026.9.4-alpha.1", "alpha", "success", false],
|
|
["v2026.8.33", "extended-stable", "success", false],
|
|
["v2026.9.4", "latest", "failure", false],
|
|
["v2026.9.4", "latest", "skipped", false],
|
|
]) {
|
|
const admitted = runInNewContext(job.if.replace(/^\$\{\{|\}\}$/gu, ""), {
|
|
cancelled: () => false,
|
|
contains: (value: string, part: string) => value.includes(part),
|
|
inputs: { tag, npm_dist_tag: channel },
|
|
needs: {
|
|
publish: {
|
|
result: "success",
|
|
outputs: { release_tag: tag, npm_dist_tag: channel },
|
|
},
|
|
finalize: { result: activation },
|
|
finalize_github_release: { result: activation },
|
|
},
|
|
});
|
|
expect(admitted, `${owner}: ${tag}/${channel}/${activation}`).toBe(expected);
|
|
}
|
|
const dispatch = (job.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Dispatch detached Linux release request",
|
|
);
|
|
expect(dispatch?.run).toContain("dispatch_linux_release_assets");
|
|
const finalize = workflow.jobs[owner === "publish" ? "finalize_github_release" : "finalize"];
|
|
expect(finalize.needs).not.toContain("publish_linux");
|
|
const approvalId = owner === "publish" ? "approve_github_release" : "approve_activation";
|
|
expect(workflow.jobs[approvalId].environment).toBe("npm-release");
|
|
expect(workflow.jobs[approvalId].permissions).toEqual({});
|
|
expect(workflow.jobs[approvalId].concurrency).toBeUndefined();
|
|
expect(finalize.environment).toBeUndefined();
|
|
expect(finalize.needs).toContain(approvalId);
|
|
for (const result of ["success", "failure", "skipped", "cancelled"]) {
|
|
expect(
|
|
runInNewContext(finalize.if.replace(/^\$\{\{|\}\}$/gu, ""), {
|
|
always: () => true,
|
|
contains: (value: string, part: string) => value.includes(part),
|
|
inputs: {
|
|
tag: "v2026.9.4",
|
|
prepared_plugins: "",
|
|
publish_openclaw_npm: true,
|
|
finalize_release_before_docker: false,
|
|
},
|
|
needs: {
|
|
publish: { result: "success" },
|
|
publish_docker: { result: "success" },
|
|
finalize_github_release_before_docker: { result: "skipped" },
|
|
verify: { result: "success" },
|
|
[approvalId]: { result },
|
|
},
|
|
}),
|
|
).toBe(result === "success");
|
|
}
|
|
const activationCommand =
|
|
owner === "publish" ? "linux-app-channel.mjs finalize-core" : "gh release edit";
|
|
const activation = (finalize.steps as WorkflowStep[]).find(({ run }) =>
|
|
run?.includes(activationCommand),
|
|
)?.run;
|
|
expect(activation).toContain("node scripts/linux-updater-manifest.mjs carry");
|
|
expect(activation?.indexOf("linux-updater-manifest.mjs carry")).toBeLessThan(
|
|
activation?.indexOf(activationCommand) ?? -1,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("serializes Linux manifests with stable activation and reuses completed Linux builds", () => {
|
|
const linux = parse(readFileSync(".github/workflows/linux-app-release.yml", "utf8"));
|
|
const finalizers = (
|
|
[
|
|
["openclaw-release-publish.yml", "finalize_github_release"],
|
|
["openclaw-release-promote.yml", "finalize"],
|
|
] as const
|
|
).map(([file, job]) => parse(readFileSync(`.github/workflows/${file}`, "utf8")).jobs[job]);
|
|
for (const job of [...finalizers, linux.jobs.publish, linux.jobs.mirror_legacy]) {
|
|
expect(job.concurrency).toEqual({
|
|
group: "linux-app-release-publish",
|
|
"cancel-in-progress": false,
|
|
queue: "max",
|
|
});
|
|
}
|
|
expect(linux.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(linux.concurrency.queue).toBe("max");
|
|
expect(linux.concurrency.group).not.toBe(linux.jobs.publish.concurrency.group);
|
|
for (const alreadyPublished of ["true", "false"]) {
|
|
expect(
|
|
runInNewContext(linux.jobs.build_linux.if.replace(/^\$\{\{|\}\}$/gu, ""), {
|
|
needs: { validate_release: { outputs: { already_published: alreadyPublished } } },
|
|
}),
|
|
).toBe(alreadyPublished !== "true");
|
|
}
|
|
for (const [alreadyPublished, build, signing, expected] of [
|
|
["true", "skipped", "skipped", true],
|
|
["false", "success", "success", true],
|
|
["false", "success", "failure", false],
|
|
["false", "skipped", "skipped", false],
|
|
]) {
|
|
expect(
|
|
runInNewContext(linux.jobs.publish.if.replace(/^\$\{\{|\}\}$/gu, ""), {
|
|
always: () => true,
|
|
needs: {
|
|
validate_release: {
|
|
outputs: { already_published: alreadyPublished, desktop_test_bundles: "false" },
|
|
},
|
|
build_linux: { result: build },
|
|
sign_linux: { result: signing },
|
|
},
|
|
}),
|
|
).toBe(expected);
|
|
}
|
|
const steps = linux.jobs.publish.steps as WorkflowStep[];
|
|
for (const name of [
|
|
"Download Debian bundle",
|
|
"Download signed AppImage",
|
|
"Assemble release assets and updater manifest",
|
|
]) {
|
|
const condition = expectDefined(steps.find((step) => step.name === name)?.if, name);
|
|
expect(
|
|
runInNewContext(condition.replace(/^\$\{\{|\}\}$/gu, ""), {
|
|
needs: { validate_release: { outputs: { already_published: "true" } } },
|
|
}),
|
|
).toBe(false);
|
|
}
|
|
const publisher = expectDefined(
|
|
steps.find(
|
|
({ name }) =>
|
|
name === "Publish immutable bundles, canonical Linux channel, and legacy mirror",
|
|
),
|
|
"one publisher",
|
|
);
|
|
expect(publisher.if).toBeUndefined();
|
|
expect(publisher.run).toContain("linux-app-channel.mjs publish");
|
|
expect(publisher.run).toContain("input_args=()");
|
|
expect(publisher.run).toContain('"${input_args[@]}"');
|
|
expect(publisher.run).toContain("--request-run-id");
|
|
expect(JSON.stringify(steps)).not.toContain("linux-updater-manifest.mjs publish");
|
|
expect(JSON.stringify(steps)).not.toContain("--clobber");
|
|
});
|
|
|
|
it("detaches Linux mirror-only writers from both completed core finalizers", () => {
|
|
const linux = parse(readFileSync(".github/workflows/linux-app-release.yml", "utf8"));
|
|
for (const event_name of ["push", "pull_request", "workflow_run", "workflow_dispatch"]) {
|
|
expect(
|
|
runInNewContext(linux.jobs.mirror_legacy.if.slice(3, -2), {
|
|
github: { repository: "openclaw/openclaw", event_name },
|
|
}),
|
|
).toBe(event_name === "workflow_dispatch");
|
|
}
|
|
expect(linux.on.push).toBeUndefined();
|
|
expect(linux.on.pull_request).toBeUndefined();
|
|
const mirrorSteps = linux.jobs.mirror_legacy.steps as WorkflowStep[];
|
|
expect(JSON.stringify(mirrorSteps)).not.toContain("TAURI_SIGNING_PRIVATE_KEY");
|
|
expect(JSON.stringify(mirrorSteps)).not.toContain("linux-app-channel.mjs publish");
|
|
expect(JSON.stringify(mirrorSteps)).not.toContain("cargo");
|
|
const admission = expectDefined(
|
|
mirrorSteps.find(({ name }) => name === "Verify detached mirror dispatch identity"),
|
|
"mirror admission",
|
|
);
|
|
expect(admission.run).toContain("refs/tags/release-publish/*");
|
|
expect(admission.run).toContain('"$EXPECTED_TOOLING_SHA" == "$WORKFLOW_SHA"');
|
|
expect(admission.run).toContain("--release-publish-parent-state-policy active-or-success");
|
|
for (const [file, finalizer] of [
|
|
["openclaw-release-publish.yml", "finalize_github_release"],
|
|
["openclaw-release-promote.yml", "finalize"],
|
|
] as const) {
|
|
const workflow = parse(readFileSync(`.github/workflows/${file}`, "utf8"));
|
|
const dispatch = workflow.jobs.dispatch_linux_mirror;
|
|
expect(dispatch.needs).toContain(finalizer);
|
|
expect(dispatch["continue-on-error"]).toBe(true);
|
|
expect(dispatch["timeout-minutes"]).toBe(5);
|
|
expect(dispatch.concurrency).toBeUndefined();
|
|
expect(workflow.jobs[finalizer].needs).not.toContain("dispatch_linux_mirror");
|
|
const dispatchStep = expectDefined(
|
|
(dispatch.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Dispatch detached Linux mirror",
|
|
),
|
|
"bounded mirror dispatch",
|
|
);
|
|
expect(dispatchStep.run).toContain("dispatch_linux_mirror");
|
|
expect(dispatchStep.run).not.toMatch(/\b(?:watch|sleep|until|while)\b/u);
|
|
}
|
|
const prepared = parse(readFileSync(".github/workflows/openclaw-release-promote.yml", "utf8"));
|
|
const preparedDispatch = JSON.stringify(prepared.jobs.dispatch_linux_mirror);
|
|
expect(preparedDispatch).toContain(".releaseRunId");
|
|
expect(preparedDispatch).toContain(".releaseRunAttempt");
|
|
expect(preparedDispatch).toContain(".tooling.fullRef");
|
|
expect(preparedDispatch).not.toContain("$GITHUB_RUN_ID");
|
|
});
|
|
|
|
it("reports stale Linux release requests before selected code runs", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/linux-app-release.yml", "utf8"));
|
|
const job = workflow.jobs.validate_release;
|
|
const requestStep = expectDefined((job.steps as WorkflowStep[])[0], "first request validation");
|
|
const requestSha = "a".repeat(40);
|
|
const requestRun = {
|
|
repository: { full_name: "openclaw/openclaw" },
|
|
event: "workflow_dispatch",
|
|
name: "Linux App Release Request [v2026.8.2] desktop=false",
|
|
path: ".github/workflows/linux-app-release-request.yml",
|
|
head_branch: "main",
|
|
head_sha: requestSha,
|
|
conclusion: "success",
|
|
};
|
|
const github = {
|
|
repository: "openclaw/openclaw",
|
|
event_name: "workflow_run",
|
|
workflow_sha: requestSha,
|
|
event: { workflow_run: requestRun },
|
|
};
|
|
const admitted = (context: typeof github) => runInNewContext(job.if, { github: context });
|
|
expect(admitted({ ...github, repository: "untrusted/openclaw" })).toBe(false);
|
|
for (const changedRun of [
|
|
{ repository: { full_name: "untrusted/openclaw" } },
|
|
{ event: "push" },
|
|
{ path: ".github/workflows/another-workflow.yml" },
|
|
{ head_branch: "topic" },
|
|
{ conclusion: "failure" },
|
|
]) {
|
|
expect(admitted({ ...github, event: { workflow_run: { ...requestRun, ...changedRun } } })).toBe(
|
|
false,
|
|
);
|
|
}
|
|
for (const workflowSha of ["b".repeat(40), requestSha]) {
|
|
expect(admitted({ ...github, workflow_sha: workflowSha })).toBe(true);
|
|
expect(requestStep.name).toBe("Validate trusted release request");
|
|
const output = path.join(tempDirs.make("openclaw-linux-request-"), "output");
|
|
writeFileSync(output, "");
|
|
const result = spawnSync("bash", ["-c", expectDefined(requestStep.run, "request validation")], {
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
GITHUB_OUTPUT: output,
|
|
REQUEST_TITLE: "Linux App Release Request [v2026.8.2] desktop=false",
|
|
REQUEST_HEAD_SHA: requestSha,
|
|
WORKFLOW_SHA: workflowSha,
|
|
},
|
|
});
|
|
const matching = workflowSha === requestSha;
|
|
expect(result.status, `${result.stdout}${result.stderr}`).toBe(matching ? 0 : 1);
|
|
expect(readFileSync(output, "utf8")).toBe(
|
|
matching ? "release_tag=v2026.8.2\ndesktop_test_bundles=false\n" : "",
|
|
);
|
|
if (!matching) {
|
|
expect(`${result.stdout}${result.stderr}`).toContain(
|
|
"::error::Main advanced after this Linux release request. Dispatch a new Linux App Release Request",
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("pins simple release admission owners before selected checkout and preserves Git contracts", () => {
|
|
const pinned = {
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@dd4528b6393e7d00063067a080ca7241b48ce475",
|
|
};
|
|
const workflows = [
|
|
{
|
|
file: ".github/workflows/linux-app-release.yml",
|
|
job: "validate_release",
|
|
checkout: "Checkout selected tag",
|
|
validation: "Ensure tag commit is reachable from its release branch",
|
|
},
|
|
{
|
|
file: ".github/workflows/macos-release.yml",
|
|
job: "validate_macos_release_request",
|
|
checkout: "Checkout selected tag",
|
|
validation: "Validate release tag and package metadata",
|
|
},
|
|
{
|
|
file: ".github/workflows/npm-placeholder-bootstrap.yml",
|
|
job: "plan",
|
|
checkout: "Checkout selected source",
|
|
validation: "Validate trusted workflow and target",
|
|
},
|
|
] as const;
|
|
for (const entry of workflows) {
|
|
const workflow = parse(readFileSync(entry.file, "utf8"));
|
|
const steps = workflow.jobs[entry.job].steps as WorkflowStep[];
|
|
const checkout = steps.findIndex(({ name }) => name === entry.checkout);
|
|
expect(steps[checkout - 1]).toEqual(pinned);
|
|
const validation = steps.find(({ name }) => name === entry.validation);
|
|
const body = expectDefined(validation?.run, `${entry.file} admission body`);
|
|
expect(body).not.toMatch(/timeout --|(?:^|\s)git (?:fetch|rev-parse|merge-base)\b/mu);
|
|
expect(body).not.toMatch(/backoff\(|for attempt in range/u);
|
|
}
|
|
|
|
const request = parse(readFileSync(".github/workflows/linux-app-release-request.yml", "utf8"));
|
|
const linux = parse(readFileSync(workflows[0].file, "utf8"));
|
|
expect(request["run-name"]).toBe(
|
|
"Linux App Release Request [${{ inputs.tag }}] desktop=${{ inputs['desktop-test-bundles'] }}",
|
|
);
|
|
expect(request.permissions).toEqual({});
|
|
expect(Object.keys(request.on.workflow_dispatch.inputs)).toEqual(["tag", "desktop-test-bundles"]);
|
|
expect(request.jobs.validate_request.permissions).toBeUndefined();
|
|
expect(JSON.stringify(request)).not.toContain("${{ secrets.");
|
|
expect(linux.on.workflow_run).toEqual({
|
|
workflows: ["Linux App Release Request"],
|
|
branches: ["main"],
|
|
types: ["completed"],
|
|
});
|
|
expect(Object.keys(linux.on.workflow_dispatch.inputs)).toEqual([
|
|
"release_tag",
|
|
"source_sha",
|
|
"tooling_sha",
|
|
"release_publish_run_id",
|
|
"release_publish_run_attempt",
|
|
]);
|
|
const releaseDocs = expectDefined(
|
|
readFileSync("apps/linux/README.md", "utf8").split("## Releases\n")[1],
|
|
"Linux release documentation",
|
|
);
|
|
expect(releaseDocs).toMatch(/dispatch `Linux App Release Request` from `main`/u);
|
|
expect(releaseDocs).toContain("stable release tag in `tag`");
|
|
expect(releaseDocs).toMatch(/optional\s+`desktop-test-bundles` input/u);
|
|
expect(releaseDocs).toMatch(/successful request automatically triggers `Linux App Release`/u);
|
|
expect(releaseDocs).not.toContain("release-publish/");
|
|
expect(linux.permissions).toEqual({});
|
|
expect(linux.jobs.validate_release.if).toContain(
|
|
"github.event.workflow_run.repository.full_name == 'openclaw/openclaw'",
|
|
);
|
|
expect(linux.jobs.validate_release.if).toContain(
|
|
"github.event.workflow_run.event == 'workflow_dispatch'",
|
|
);
|
|
expect(linux.jobs.validate_release.if).toContain(
|
|
"github.event.workflow_run.head_branch == 'main'",
|
|
);
|
|
expect(linux.jobs.validate_release.if).toContain(
|
|
"github.event.workflow_run.conclusion == 'success'",
|
|
);
|
|
const tauriSigningEnvNames = [
|
|
"TAURI_SIGNING_PRIVATE_KEY",
|
|
"TAURI_SIGNING_PRIVATE_KEY_PATH",
|
|
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
|
"TAURI_PRIVATE_KEY",
|
|
"TAURI_PRIVATE_KEY_PATH",
|
|
"TAURI_PRIVATE_KEY_PASSWORD",
|
|
"TAURI_KEY_PASSWORD",
|
|
];
|
|
const selectedTagJobs = ["validate_release", "build_linux", "build_macos", "build_windows"];
|
|
for (const jobName of selectedTagJobs) {
|
|
const job = linux.jobs[jobName];
|
|
const checkout = expectDefined(
|
|
(job.steps as WorkflowStep[]).find(({ name }) => name === "Checkout selected tag"),
|
|
`${jobName} selected-tag checkout`,
|
|
);
|
|
expect(job.permissions, jobName).toEqual({ contents: "read" });
|
|
expect(checkout.with?.["persist-credentials"], jobName).toBe(false);
|
|
const jobJson = JSON.stringify(job);
|
|
expect(jobJson, jobName).not.toContain("${{ secrets.");
|
|
for (const envName of tauriSigningEnvNames) {
|
|
expect(jobJson, jobName).not.toContain(envName);
|
|
}
|
|
}
|
|
expect(
|
|
Object.entries(linux.jobs)
|
|
.filter(
|
|
([, job]) =>
|
|
(job as { permissions?: { contents?: string } }).permissions?.contents === "write",
|
|
)
|
|
.map(([name]) => name),
|
|
).toEqual(["mirror_legacy"]);
|
|
expect(linux.jobs.publish.permissions).toEqual({ actions: "read", contents: "read" });
|
|
expect(
|
|
Object.entries(linux.jobs)
|
|
.filter(([, job]) => JSON.stringify(job).includes("${{ secrets.TAURI_SIGNING_PRIVATE_KEY"))
|
|
.map(([name]) => name),
|
|
).toEqual(["sign_linux", "sign_desktop"]);
|
|
const linuxSteps = linux.jobs.validate_release.steps as WorkflowStep[];
|
|
expect(
|
|
linuxSteps.find(({ name }) => name === "Checkout trusted release tooling")?.with,
|
|
).toMatchObject({
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: ".release-tooling",
|
|
"persist-credentials": false,
|
|
"sparse-checkout":
|
|
"apps/linux/src-tauri/tauri.conf.json\nscripts/lib/record-shared.mjs\nscripts/release-tooling-identity.mjs\nscripts/linux-updater-manifest.mjs\nscripts/lib/release-version.mjs\n",
|
|
});
|
|
const tooling = linuxSteps.find(({ name }) => name === "Verify trusted release tooling identity");
|
|
expect(tooling?.env).toMatchObject({
|
|
WORKFLOW_FULL_REF: "${{ github.ref }}",
|
|
WORKFLOW_REF: "${{ github.ref_name }}",
|
|
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
|
});
|
|
expect(tooling?.run).toContain(
|
|
"node .release-tooling/scripts/release-tooling-identity.mjs verify",
|
|
);
|
|
expect(tooling?.run).not.toContain("--allow-prevalidated-ref");
|
|
expect(linuxSteps.indexOf(tooling!)).toBeLessThan(
|
|
linuxSteps.findIndex(({ id }) => id === "ancestry"),
|
|
);
|
|
expect(linux.jobs.validate_release.outputs).toEqual({
|
|
desktop_test_bundles: "${{ steps.request.outputs.desktop_test_bundles }}",
|
|
release_tag: "${{ steps.request.outputs.release_tag }}",
|
|
tag_sha: "${{ steps.ancestry.outputs.tag_sha }}",
|
|
updater_pubkey: "${{ steps.updater_trust.outputs.updater_pubkey }}",
|
|
already_published: "${{ steps.completion.outputs.already_published }}",
|
|
});
|
|
const releaseRequest = expectDefined(
|
|
linuxSteps.find(({ id }) => id === "request"),
|
|
"trusted release request validation",
|
|
);
|
|
expect(releaseRequest.env).toEqual({
|
|
REQUEST_TITLE: "${{ github.event.workflow_run.display_title }}",
|
|
REQUEST_HEAD_SHA: "${{ github.event.workflow_run.head_sha }}",
|
|
WORKFLOW_SHA: "${{ github.workflow_sha }}",
|
|
});
|
|
const requestRoot = tempDirs.make("openclaw-linux-release-request-");
|
|
const requestOutput = path.join(requestRoot, "output");
|
|
const acceptedRequest = spawnSync("bash", ["-c", releaseRequest.run ?? ""], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GITHUB_OUTPUT: requestOutput,
|
|
REQUEST_TITLE: "Linux App Release Request [v2026.8.2] desktop=true",
|
|
REQUEST_HEAD_SHA: "a".repeat(40),
|
|
WORKFLOW_SHA: "a".repeat(40),
|
|
},
|
|
});
|
|
expect(acceptedRequest.status, `${acceptedRequest.stdout}${acceptedRequest.stderr}`).toBe(0);
|
|
expect(readFileSync(requestOutput, "utf8")).toBe(
|
|
"release_tag=v2026.8.2\ndesktop_test_bundles=true\n",
|
|
);
|
|
const rejectedRequest = spawnSync("bash", ["-c", releaseRequest.run ?? ""], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GITHUB_OUTPUT: requestOutput,
|
|
REQUEST_TITLE: "Linux App Release Request [v2026.8.2] desktop=true extra",
|
|
REQUEST_HEAD_SHA: "a".repeat(40),
|
|
WORKFLOW_SHA: "a".repeat(40),
|
|
},
|
|
});
|
|
expect(rejectedRequest.status).toBe(1);
|
|
const updaterTrust = expectDefined(
|
|
linuxSteps.find(({ id }) => id === "updater_trust"),
|
|
"updater trust-root validation",
|
|
);
|
|
expect(updaterTrust.run).toContain("selected_config=apps/linux/src-tauri/tauri.conf.json");
|
|
expect(updaterTrust.run).toContain(
|
|
"trusted_config=.release-tooling/apps/linux/src-tauri/tauri.conf.json",
|
|
);
|
|
expect(updaterTrust.run).toContain('-L "$selected_config"');
|
|
expect(updaterTrust.run).toContain('-L "$trusted_config"');
|
|
expect(updaterTrust.run).toContain('"$selected_pubkey" != "$trusted_pubkey"');
|
|
expect(updaterTrust.run).toContain('echo "updater_pubkey=$trusted_pubkey" >> "$GITHUB_OUTPUT"');
|
|
for (const jobName of ["build_linux", "build_macos", "build_windows"]) {
|
|
const steps = linux.jobs[jobName].steps as WorkflowStep[];
|
|
const checkoutIndex = steps.findIndex(({ name }) => name === "Checkout selected tag");
|
|
expect(steps[checkoutIndex]?.with?.ref).toBe("${{ needs.validate_release.outputs.tag_sha }}");
|
|
expect(checkoutIndex, `${jobName} selected checkout order`).toBeGreaterThan(0);
|
|
expect(
|
|
steps.slice(0, checkoutIndex).some(({ uses }) => uses?.startsWith("./")),
|
|
`${jobName} pre-checkout local action`,
|
|
).toBe(false);
|
|
expect(
|
|
steps.slice(checkoutIndex + 1).some(({ uses }) => uses?.startsWith("./")),
|
|
`${jobName} selected-tag local action`,
|
|
).toBe(false);
|
|
expect(JSON.stringify(steps), `${jobName} Actions cache usage`).not.toContain("actions/cache");
|
|
const install = expectDefined(
|
|
steps.find(({ name }) => name === "Install selected-tag dependencies"),
|
|
`${jobName} dependency install`,
|
|
);
|
|
expect(steps.indexOf(install), `${jobName} install order`).toBeGreaterThan(checkoutIndex);
|
|
expect(install.run).toContain("corepack enable");
|
|
expect(install.run).toContain("pnpm install --frozen-lockfile");
|
|
}
|
|
const linuxBuildSteps = linux.jobs.build_linux.steps as WorkflowStep[];
|
|
const selectedTagCheckout = linuxBuildSteps.findIndex(
|
|
({ name }) => name === "Checkout selected tag",
|
|
);
|
|
const trustedToolingCheckout = linuxBuildSteps.findIndex(
|
|
({ name }) => name === "Checkout trusted Linux packaging tooling",
|
|
);
|
|
const selectedTagInstall = linuxBuildSteps.findIndex(
|
|
({ name }) => name === "Install selected-tag dependencies",
|
|
);
|
|
expect(selectedTagCheckout).toBeGreaterThan(0);
|
|
expect(selectedTagInstall).toBeGreaterThan(selectedTagCheckout);
|
|
expect(trustedToolingCheckout).toBe(selectedTagInstall + 1);
|
|
const trustedToolingOptions = linuxBuildSteps[trustedToolingCheckout]?.with;
|
|
expect(trustedToolingOptions).toMatchObject({
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: ".release-tooling",
|
|
"fetch-depth": 1,
|
|
"persist-credentials": false,
|
|
"sparse-checkout-cone-mode": false,
|
|
});
|
|
const trustedToolingFiles = [
|
|
"apps/linux/scripts/stage-appimage-gstreamer.sh",
|
|
"apps/linux/scripts/tauri-appimage-tools.sh",
|
|
"apps/linux/scripts/tauri-appimage-tools-x86_64.tsv",
|
|
"apps/linux/scripts/finalize-appimage.sh",
|
|
"apps/linux/tests/packaged_runtime_smoke.py",
|
|
"apps/linux/tests/first_run.py",
|
|
];
|
|
expect(String(trustedToolingOptions?.["sparse-checkout"]).trim().split("\n")).toEqual(
|
|
trustedToolingFiles,
|
|
);
|
|
const packagedRuntimeSmoke = "apps/linux/tests/packaged_runtime_smoke.py";
|
|
expect(readFileSync(packagedRuntimeSmoke, "utf8")).toContain(
|
|
'Path(__file__).with_name("first_run.py")',
|
|
);
|
|
const buildLinuxBundles = expectDefined(
|
|
linuxBuildSteps.find(({ name }) => name === "Build Linux companion bundles"),
|
|
"Linux bundle build step",
|
|
);
|
|
expect(buildLinuxBundles["working-directory"]).toBe("apps/linux/src-tauri");
|
|
expect(buildLinuxBundles.env?.LDAI_RUNTIME_FILE).toBe(
|
|
"${{ runner.temp }}/openclaw-tauri-cache/tauri/.appimage-runtime-x86_64",
|
|
);
|
|
expect(buildLinuxBundles.run).toContain('\\"createUpdaterArtifacts\\":false');
|
|
expect(buildLinuxBundles.run).toContain('\\"useLocalToolsDir\\":false');
|
|
const stageLinuxBundles = expectDefined(
|
|
linuxBuildSteps.find(({ name }) => name === "Verify and stage unsigned Linux bundles"),
|
|
"Linux unsigned bundle staging",
|
|
);
|
|
expect(stageLinuxBundles.run).toContain(
|
|
'cp "${debs[0]}" "dist/linux-app/release/OpenClaw-${version}-amd64.deb"',
|
|
);
|
|
expect(stageLinuxBundles.run).toContain(
|
|
'cp "${appimages[0]}" "dist/linux-app/unsigned/OpenClaw-${version}-amd64.AppImage"',
|
|
);
|
|
const finalizeAppImage = expectDefined(
|
|
linuxBuildSteps.find(({ name }) => name === "Finalize AppImage"),
|
|
"Linux AppImage finalizer step",
|
|
);
|
|
for (const name of tauriSigningEnvNames) {
|
|
expect(finalizeAppImage.env ?? {}).not.toHaveProperty(name);
|
|
}
|
|
expect(finalizeAppImage.run).not.toContain("signer sign");
|
|
expect(linuxBuildSteps.find(({ name }) => name === "Sign finalized AppImage")).toBeUndefined();
|
|
expect(linux.jobs.build_linux.outputs).toEqual({
|
|
deb_artifact_id: "${{ steps.upload_deb.outputs.artifact-id }}",
|
|
unsigned_appimage_artifact_id: "${{ steps.upload_appimage.outputs.artifact-id }}",
|
|
});
|
|
expect(linuxBuildSteps.find(({ id }) => id === "upload_deb")?.with).toMatchObject({
|
|
name: "linux-app-release-deb",
|
|
path: "dist/linux-app/release/*.deb",
|
|
});
|
|
expect(linuxBuildSteps.find(({ id }) => id === "upload_appimage")?.with).toMatchObject({
|
|
name: "linux-app-release-unsigned-appimage",
|
|
path: "dist/linux-app/unsigned/*.AppImage",
|
|
});
|
|
const signingJob = linux.jobs.sign_linux;
|
|
const signingSteps = signingJob.steps as WorkflowStep[];
|
|
expect(signingJob.needs).toEqual(["validate_release", "build_linux"]);
|
|
expect(signingJob.permissions).toEqual({});
|
|
expect(signingJob.outputs).toEqual({
|
|
signed_appimage_artifact_id: "${{ steps.upload_signed_appimage.outputs.artifact-id }}",
|
|
});
|
|
expect(
|
|
signingSteps.map(({ uses }) => uses).filter((uses): uses is string => uses !== undefined),
|
|
).toEqual([DOWNLOAD_ARTIFACT_V8, UPLOAD_ARTIFACT_V7]);
|
|
expect(signingSteps.some((step) => step["working-directory"] !== undefined)).toBe(false);
|
|
const signingBodies = signingSteps.map(({ run }) => run ?? "").join("\n");
|
|
expect(signingBodies).not.toMatch(/(?:^|\s)(?:git|cargo)\s|\.release-tooling|apps\/linux\//mu);
|
|
expect(signingBodies).not.toMatch(/\b(?:npm|pnpm|npx|corepack)\b/u);
|
|
expect(
|
|
signingSteps.find(({ name }) => name === "Download finalized unsigned AppImage")?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.build_linux.outputs.unsigned_appimage_artifact_id }}",
|
|
path: "dist/signing-input",
|
|
});
|
|
const signAppImage = expectDefined(
|
|
signingSteps.find(({ name }) => name === "Sign finalized AppImage"),
|
|
"Linux AppImage signing step",
|
|
);
|
|
expect(signAppImage.env).toMatchObject({
|
|
RELEASE_TAG: "${{ needs.validate_release.outputs.release_tag }}",
|
|
TAG_SHA: "${{ needs.validate_release.outputs.tag_sha }}",
|
|
TAURI_SIGNING_PRIVATE_KEY: "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}",
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}",
|
|
UPDATER_PUBLIC_KEY: "${{ needs.validate_release.outputs.updater_pubkey }}",
|
|
});
|
|
const signingToolsInstaller = expectDefined(
|
|
signingSteps.find(({ name }) => name === "Install trusted signing tools"),
|
|
"Linux signing tools installer",
|
|
);
|
|
expect(linux.env).toMatchObject({
|
|
MINISIGN_ARCHIVE_SHA256: "9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73",
|
|
MINISIGN_BINARY_SHA256: "2c74dffcc1c9a5ee55957c60971998ace2b89f22585631594ec2152c588af8db",
|
|
MINISIGN_URL:
|
|
"https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz",
|
|
TAURI_CLI_ARCHIVE_SHA256: "6864602a34292aa6f2ad40ae019eebe5c1064d6c623fe20696a8a8974067e60b",
|
|
TAURI_CLI_BINARY_SHA256: "23a27f61c50417fe87c92fa958fb56ecc8de7c791f78df3cac046c8579b45897",
|
|
TAURI_CLI_URL:
|
|
"https://github.com/tauri-apps/tauri/releases/download/tauri-cli-v2.11.4/cargo-tauri-x86_64-unknown-linux-gnu.tgz",
|
|
});
|
|
expect(signingToolsInstaller.run?.match(/--proto '=https' --tlsv1\.2/gu)).toHaveLength(2);
|
|
expect(signingToolsInstaller.run?.match(/--connect-timeout 10 --max-time 120/gu)).toHaveLength(2);
|
|
expect(signingToolsInstaller.run).toContain('--output "$minisign_archive" "$MINISIGN_URL"');
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'printf \'%s %s\\n\' "$MINISIGN_ARCHIVE_SHA256" "$minisign_archive" | sha256sum --check -',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'tar -xzf "$minisign_archive" -C "${RUNNER_TEMP}/bin" --strip-components=2 \\\n minisign-linux/x86_64/minisign',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'printf \'%s %s\\n\' "$MINISIGN_BINARY_SHA256" "${RUNNER_TEMP}/bin/minisign" |',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'printf \'%s %s\\n\' "$TAURI_CLI_ARCHIVE_SHA256" "$tauri_archive" | sha256sum --check -',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain('--output "$tauri_archive" "$TAURI_CLI_URL"');
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'tar -xzf "$tauri_archive" -C "${RUNNER_TEMP}/bin" cargo-tauri',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'printf \'%s %s\\n\' "$TAURI_CLI_BINARY_SHA256" "${RUNNER_TEMP}/bin/cargo-tauri" |',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain(
|
|
'chmod 0555 "${RUNNER_TEMP}/bin/cargo-tauri" "${RUNNER_TEMP}/bin/minisign"',
|
|
);
|
|
expect(signingToolsInstaller.run).toContain('"${RUNNER_TEMP}/bin/cargo-tauri" --version');
|
|
expect(signingToolsInstaller.run).toContain('"${RUNNER_TEMP}/bin/minisign" -v');
|
|
expect(signingToolsInstaller.run).not.toMatch(
|
|
/apt-get|GITHUB_PATH|(?:^|\n)\s*(?:export\s+)?PATH=/u,
|
|
);
|
|
expect(signAppImage.run).toContain(
|
|
'printf \'%s %s\\n\' "$TAURI_CLI_BINARY_SHA256" "${RUNNER_TEMP}/bin/cargo-tauri"',
|
|
);
|
|
expect(signAppImage.run).toContain(
|
|
'printf \'%s %s\\n\' "$MINISIGN_BINARY_SHA256" "${RUNNER_TEMP}/bin/minisign"',
|
|
);
|
|
expect(signAppImage.run).toContain(
|
|
'appimage="dist/signing-input/OpenClaw-${version}-amd64.AppImage"',
|
|
);
|
|
expect(signAppImage.run).toContain('"${RUNNER_TEMP}/bin/cargo-tauri" signer sign "$appimage"');
|
|
expect(signAppImage.run).toContain(
|
|
"unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
|
);
|
|
const signAppImageRun = expectDefined(signAppImage.run, "AppImage signing command");
|
|
expect(signAppImageRun.indexOf("unset TAURI_SIGNING_PRIVATE_KEY")).toBeGreaterThan(
|
|
signAppImageRun.indexOf('"${RUNNER_TEMP}/bin/cargo-tauri" signer sign "$appimage"'),
|
|
);
|
|
expect(signAppImageRun.indexOf("unset TAURI_SIGNING_PRIVATE_KEY")).toBeLessThan(
|
|
signAppImageRun.indexOf('"${RUNNER_TEMP}/bin/minisign" -Vm "$appimage"'),
|
|
);
|
|
expect(signAppImage.run).toContain('before=$(sha256sum "$appimage")');
|
|
expect(signAppImage.run).toContain('after=$(sha256sum "$appimage")');
|
|
expect(signAppImage.run).toContain('base64 --decode < "${appimage}.sig"');
|
|
expect(signAppImage.run).toContain('"${RUNNER_TEMP}/bin/minisign" -Vm "$appimage"');
|
|
expect(signAppImage.run).not.toMatch(/\b(?:curl|wget|npm|pnpm|npx|corepack)\b|https?:\/\//u);
|
|
expect(signAppImage.run).not.toMatch(/(?:^|\n)\s*(?:export\s+)?PATH=/u);
|
|
expect(signAppImage.run).not.toContain("finalize-appimage.sh");
|
|
expect(signingSteps.find(({ id }) => id === "upload_signed_appimage")?.with).toMatchObject({
|
|
name: "linux-app-release-signed-appimage",
|
|
path: "dist/linux-app",
|
|
});
|
|
for (const [jobName, job] of Object.entries(linux.jobs)) {
|
|
if (jobName === "sign_linux" || jobName === "sign_desktop") {
|
|
continue;
|
|
}
|
|
expect(JSON.stringify(job), `${jobName} must not reference signer binaries`).not.toMatch(
|
|
/\$\{RUNNER_TEMP\}\/bin\/(?:cargo-tauri|minisign)/u,
|
|
);
|
|
}
|
|
const macosBuildSteps = linux.jobs.build_macos.steps as WorkflowStep[];
|
|
const buildMacos = expectDefined(
|
|
macosBuildSteps.find(({ name }) => name === "Build macOS test bundles"),
|
|
"macOS bundle build step",
|
|
);
|
|
expect(buildMacos.run).toContain('\\"createUpdaterArtifacts\\":false');
|
|
const stageMacos = expectDefined(
|
|
macosBuildSteps.find(({ name }) => name === "Verify and stage unsigned macOS bundles"),
|
|
"macOS unsigned staging step",
|
|
);
|
|
expect(stageMacos.run).toContain(
|
|
"archives=(apps/linux/src-tauri/target/release/bundle/macos/*.app.tar.gz)",
|
|
);
|
|
expect(stageMacos.run).toContain(
|
|
"signatures=(apps/linux/src-tauri/target/release/bundle/macos/*.sig)",
|
|
);
|
|
expect(stageMacos.run).toContain(
|
|
'tar -czf "$archive" -C "$(dirname "${apps[0]}")" "$(basename "${apps[0]}")"',
|
|
);
|
|
expect(linux.jobs.build_macos.outputs).toEqual({
|
|
dmg_artifact_id: "${{ steps.upload_dmg.outputs.artifact-id }}",
|
|
unsigned_updater_artifact_id: "${{ steps.upload_updater.outputs.artifact-id }}",
|
|
});
|
|
expect(macosBuildSteps.find(({ id }) => id === "upload_dmg")?.with).toMatchObject({
|
|
name: "macos-app-release-dmg",
|
|
path: "dist/macos-app/release/*.dmg",
|
|
});
|
|
expect(macosBuildSteps.find(({ id }) => id === "upload_updater")?.with).toMatchObject({
|
|
name: "macos-app-release-unsigned-updater",
|
|
path: "dist/macos-app/unsigned/*.app.tar.gz",
|
|
});
|
|
|
|
const windowsBuildSteps = linux.jobs.build_windows.steps as WorkflowStep[];
|
|
const buildWindows = expectDefined(
|
|
windowsBuildSteps.find(({ name }) => name === "Build Windows test bundle"),
|
|
"Windows bundle build step",
|
|
);
|
|
expect(buildWindows.run).toContain('\\"createUpdaterArtifacts\\":false');
|
|
const stageWindows = expectDefined(
|
|
windowsBuildSteps.find(({ name }) => name === "Verify and stage unsigned Windows bundle"),
|
|
"Windows unsigned staging step",
|
|
);
|
|
expect(stageWindows.run).toContain(
|
|
'Get-ChildItem "apps/linux/src-tauri/target/release/bundle/nsis/*.sig"',
|
|
);
|
|
expect(linux.jobs.build_windows.outputs).toEqual({
|
|
unsigned_updater_artifact_id: "${{ steps.upload_updater.outputs.artifact-id }}",
|
|
});
|
|
expect(windowsBuildSteps.find(({ id }) => id === "upload_updater")?.with).toMatchObject({
|
|
name: "windows-app-release-unsigned-updater",
|
|
path: "dist/windows-app/unsigned/*.exe",
|
|
});
|
|
|
|
const desktopSigningJob = linux.jobs.sign_desktop;
|
|
const desktopSigningSteps = desktopSigningJob.steps as WorkflowStep[];
|
|
expect(desktopSigningJob.needs).toEqual(["validate_release", "build_macos", "build_windows"]);
|
|
expect(desktopSigningJob.permissions).toEqual({});
|
|
expect(desktopSigningJob.outputs).toEqual({
|
|
signed_desktop_artifact_id: "${{ steps.upload_signed_desktop.outputs.artifact-id }}",
|
|
});
|
|
expect(
|
|
desktopSigningSteps
|
|
.map(({ uses }) => uses)
|
|
.filter((uses): uses is string => uses !== undefined),
|
|
).toEqual([DOWNLOAD_ARTIFACT_V8, DOWNLOAD_ARTIFACT_V8, UPLOAD_ARTIFACT_V7]);
|
|
expect(desktopSigningSteps.some((step) => step["working-directory"] !== undefined)).toBe(false);
|
|
const desktopSigningBodies = desktopSigningSteps.map(({ run }) => run ?? "").join("\n");
|
|
expect(desktopSigningBodies).not.toMatch(
|
|
/(?:^|\s)(?:git|cargo)\s|\.release-tooling|apps\/linux\//mu,
|
|
);
|
|
expect(desktopSigningBodies).not.toMatch(/\b(?:npm|pnpm|npx|corepack)\b/u);
|
|
expect(
|
|
desktopSigningSteps.find(({ name }) => name === "Download finalized macOS updater archive")
|
|
?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.build_macos.outputs.unsigned_updater_artifact_id }}",
|
|
path: "dist/signing-input/macos",
|
|
});
|
|
expect(
|
|
desktopSigningSteps.find(({ name }) => name === "Download finalized Windows updater installer")
|
|
?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.build_windows.outputs.unsigned_updater_artifact_id }}",
|
|
path: "dist/signing-input/windows",
|
|
});
|
|
const signDesktop = expectDefined(
|
|
desktopSigningSteps.find(({ name }) => name === "Sign finalized desktop updater bundles"),
|
|
"desktop updater signing step",
|
|
);
|
|
expect(signDesktop.env).toMatchObject({
|
|
RELEASE_TAG: "${{ needs.validate_release.outputs.release_tag }}",
|
|
TAG_SHA: "${{ needs.validate_release.outputs.tag_sha }}",
|
|
TAURI_SIGNING_PRIVATE_KEY: "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}",
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}",
|
|
UPDATER_PUBLIC_KEY: "${{ needs.validate_release.outputs.updater_pubkey }}",
|
|
});
|
|
expect(desktopSigningSteps.find(({ name }) => name === "Install trusted signing tools")).toEqual(
|
|
signingToolsInstaller,
|
|
);
|
|
expect(signDesktop.run).toContain(
|
|
'printf \'%s %s\\n\' "$TAURI_CLI_BINARY_SHA256" "${RUNNER_TEMP}/bin/cargo-tauri"',
|
|
);
|
|
expect(signDesktop.run).toContain(
|
|
'printf \'%s %s\\n\' "$MINISIGN_BINARY_SHA256" "${RUNNER_TEMP}/bin/minisign"',
|
|
);
|
|
expect(signDesktop.run).toContain('"${RUNNER_TEMP}/bin/cargo-tauri" signer sign "$macos"');
|
|
expect(signDesktop.run).toContain('"${RUNNER_TEMP}/bin/cargo-tauri" signer sign "$windows"');
|
|
expect(signDesktop.run).toContain(
|
|
"unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
|
);
|
|
const signDesktopRun = expectDefined(signDesktop.run, "desktop updater signing command");
|
|
expect(signDesktopRun.indexOf("unset TAURI_SIGNING_PRIVATE_KEY")).toBeGreaterThan(
|
|
signDesktopRun.indexOf('"${RUNNER_TEMP}/bin/cargo-tauri" signer sign "$windows"'),
|
|
);
|
|
expect(signDesktopRun.indexOf("unset TAURI_SIGNING_PRIVATE_KEY")).toBeLessThan(
|
|
signDesktopRun.indexOf('"${RUNNER_TEMP}/bin/minisign" -Vm "$macos"'),
|
|
);
|
|
expect(signDesktop.run).toContain('macos_before=$(sha256sum "$macos")');
|
|
expect(signDesktop.run).toContain('windows_after=$(sha256sum "$windows")');
|
|
expect(signDesktop.run).toContain('"${RUNNER_TEMP}/bin/minisign" -Vm "$macos"');
|
|
expect(signDesktop.run).toContain('"${RUNNER_TEMP}/bin/minisign" -Vm "$windows"');
|
|
expect(signDesktop.run).not.toMatch(/\b(?:curl|wget|npm|pnpm|npx|corepack)\b|https?:\/\//u);
|
|
expect(signDesktop.run).not.toMatch(/(?:^|\n)\s*(?:export\s+)?PATH=/u);
|
|
expect(desktopSigningSteps.find(({ id }) => id === "upload_signed_desktop")?.with).toMatchObject({
|
|
name: "desktop-test-release-signed-updaters",
|
|
path: "dist/desktop-test",
|
|
});
|
|
|
|
expect(linux.jobs.publish.needs).toContain("sign_linux");
|
|
expect(linux.jobs.publish.needs).toContain("sign_desktop");
|
|
expect(linux.jobs.publish.if).toContain("needs.sign_linux.result == 'success'");
|
|
expect(linux.jobs.publish.if).toContain("needs.sign_desktop.result == 'success'");
|
|
expect(linux.jobs.publish.if).not.toContain("inputs.");
|
|
expect(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Download Debian bundle",
|
|
)?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.build_linux.outputs.deb_artifact_id }}",
|
|
path: "dist/input/linux/release",
|
|
});
|
|
expect(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Download signed AppImage",
|
|
)?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.sign_linux.outputs.signed_appimage_artifact_id }}",
|
|
path: "dist/input/linux",
|
|
});
|
|
expect(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Download macOS test DMG",
|
|
)?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.build_macos.outputs.dmg_artifact_id }}",
|
|
path: "dist/input/macos/release",
|
|
});
|
|
expect(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Download signed desktop updater bundles",
|
|
)?.with,
|
|
).toEqual({
|
|
"artifact-ids": "${{ needs.sign_desktop.outputs.signed_desktop_artifact_id }}",
|
|
path: "dist/input",
|
|
});
|
|
const assembleLinuxBundles = expectDefined(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Assemble release assets and updater manifest",
|
|
),
|
|
"Linux release assembly step",
|
|
);
|
|
expect(assembleLinuxBundles.run).not.toContain('"linux-x86_64"');
|
|
const publishLinuxMetadata = expectDefined(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(
|
|
({ name }) =>
|
|
name === "Publish immutable bundles, canonical Linux channel, and legacy mirror",
|
|
),
|
|
"Linux publication owner",
|
|
);
|
|
expect(publishLinuxMetadata.run).toContain(
|
|
'--assets dist/release --signature "dist/input/linux/signatures/OpenClaw-${RELEASE_TAG#v}-amd64.AppImage.sig"',
|
|
);
|
|
const publicationToken = expectDefined(
|
|
(linux.jobs.publish.steps as WorkflowStep[]).find(({ id }) => id === "publication_token"),
|
|
"Linux release-owner token for protected control-tag creation",
|
|
);
|
|
expect(publicationToken.with).toMatchObject({
|
|
"client-id": "Iv23liOECG0slfuhz093",
|
|
"private-key": "${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}",
|
|
owner: "openclaw",
|
|
repositories: "openclaw",
|
|
"permission-actions": "read",
|
|
"permission-contents": "write",
|
|
"permission-workflows": "write",
|
|
});
|
|
expect(publishLinuxMetadata.env?.GH_TOKEN).toBe("${{ steps.publication_token.outputs.token }}");
|
|
const appImageToolsPath = "apps/linux/scripts/tauri-appimage-tools.sh";
|
|
const appImageTools = readFileSync(appImageToolsPath, "utf8");
|
|
expect(appImageTools).toContain("--proto '=https' --tlsv1.2");
|
|
expect(appImageTools).toContain("--connect-timeout 10 --max-time 120");
|
|
expect(appImageTools).toContain("--retry 3 --retry-all-errors");
|
|
|
|
const prLinux = parse(readFileSync(".github/workflows/linux-app.yml", "utf8"));
|
|
expect(evaluateWorkflowRunner(prLinux.jobs.build["runs-on"])).toBe("ubuntu-22.04");
|
|
expect(prLinux.jobs.build.strategy).toBeUndefined();
|
|
expect(prLinux.on.workflow_dispatch?.inputs).toBeUndefined();
|
|
const abiScannerTest = expectDefined(
|
|
(prLinux.jobs.build.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Test packaged runtime ABI scanner",
|
|
),
|
|
"pull request AppImage ABI scanner test",
|
|
);
|
|
expect(abiScannerTest.run).toContain("python3 -m unittest discover");
|
|
expect(abiScannerTest.run).toContain("-s apps/linux/tests -p 'test_packaged_runtime_smoke.py'");
|
|
const workflowContracts = [
|
|
{
|
|
job: prLinux.jobs.build,
|
|
helper: appImageToolsPath,
|
|
label: "pull request",
|
|
},
|
|
{
|
|
job: linux.jobs.build_linux,
|
|
helper: `.release-tooling/${appImageToolsPath}`,
|
|
label: "release",
|
|
},
|
|
];
|
|
for (const contract of workflowContracts) {
|
|
const steps = contract.job.steps as WorkflowStep[];
|
|
const prepareIndex = steps.findIndex(({ name }) => name === "Prepare pinned AppImage tools");
|
|
const buildIndex = steps.findIndex(({ name }) => name === "Build Linux companion bundles");
|
|
const finalizeIndex = steps.findIndex(({ name }) => name === "Finalize AppImage");
|
|
expect(prepareIndex, `${contract.label} prepare`).toBeGreaterThan(0);
|
|
expect(buildIndex, `${contract.label} build`).toBe(prepareIndex + 1);
|
|
expect(finalizeIndex, `${contract.label} finalize`).toBe(buildIndex + 1);
|
|
for (const index of [prepareIndex, buildIndex, finalizeIndex]) {
|
|
expect(steps[index]?.env?.XDG_CACHE_HOME, `${contract.label} cache path`).toBe(
|
|
"${{ runner.temp }}/openclaw-tauri-cache",
|
|
);
|
|
}
|
|
expect(steps[prepareIndex]?.run, contract.label).toContain(`${contract.helper} prepare`);
|
|
expect(steps[prepareIndex]?.run, contract.label).toContain(
|
|
`${contract.helper} verify pre-build`,
|
|
);
|
|
expect(steps[buildIndex]?.run, contract.label).toContain("@tauri-apps/cli@2.11.4");
|
|
expect(steps[buildIndex]?.run, contract.label).toMatch(/\\?"useLocalToolsDir\\?":false/u);
|
|
expect(steps[finalizeIndex]?.run, contract.label).toMatch(
|
|
/finalize-appimage\.sh "?\\?\$?bundle_dir"?|finalize-appimage\.sh apps\/linux\/src-tauri\/target\/release\/bundle\/appimage/u,
|
|
);
|
|
expect(JSON.stringify(contract.job), contract.label).not.toContain("${{ secrets.");
|
|
}
|
|
const prPrepare = expectDefined(
|
|
(prLinux.jobs.build.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Prepare pinned AppImage tools",
|
|
),
|
|
"pull request AppImage tool preparation",
|
|
);
|
|
expect(prPrepare.run).toContain(
|
|
"runtime_file=$(apps/linux/scripts/tauri-appimage-tools.sh runtime-path)",
|
|
);
|
|
expect(prPrepare.run).toContain(
|
|
'printf \'LDAI_RUNTIME_FILE=%s\\n\' "$runtime_file" >> "$GITHUB_ENV"',
|
|
);
|
|
expect(
|
|
(prLinux.jobs.build.steps as WorkflowStep[]).find(
|
|
({ name }) => name === "Build Linux companion bundles",
|
|
)?.env,
|
|
).not.toHaveProperty("LDAI_RUNTIME_FILE");
|
|
expect(evaluateWorkflowRunner(linux.jobs.build_linux["runs-on"])).toBe("ubuntu-22.04");
|
|
expect(linux.jobs.build_linux.strategy).toBeUndefined();
|
|
const finalizerSource = readFileSync("apps/linux/scripts/finalize-appimage.sh", "utf8");
|
|
const postBuildVerifications =
|
|
finalizerSource.match(/"\$tools_helper" verify post-build/gu) ?? [];
|
|
expect(postBuildVerifications).toHaveLength(2);
|
|
expect(finalizerSource.indexOf(postBuildVerifications[0]!)).toBeLessThan(
|
|
finalizerSource.indexOf("mapfile -d '' forbidden_libraries"),
|
|
);
|
|
expect(finalizerSource.lastIndexOf(postBuildVerifications[1]!)).toBeLessThan(
|
|
finalizerSource.indexOf('"$plugin" --appdir "$appdir"'),
|
|
);
|
|
expect(finalizerSource).toContain('LDAI_RUNTIME_FILE="$runtime"');
|
|
const architectureRoot = tempDirs.make("openclaw-appimage-architecture-");
|
|
const architectureBin = path.join(architectureRoot, "bin");
|
|
const architectureCache = path.join(architectureRoot, "cache");
|
|
mkdirSync(architectureBin);
|
|
writeExecutable(path.join(architectureBin, "uname"), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'case "$1" in',
|
|
' -s) printf "%s\\n" "${SYNTHETIC_UNAME_SYSTEM:?}" ;;',
|
|
' -m) printf "%s\\n" "${SYNTHETIC_UNAME_MACHINE:?}" ;;',
|
|
" *) exit 64 ;;",
|
|
"esac",
|
|
]);
|
|
const architectureEnv = (system: string, machine: string, cache = architectureCache) => ({
|
|
...process.env,
|
|
PATH: `${architectureBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
SYNTHETIC_UNAME_MACHINE: machine,
|
|
SYNTHETIC_UNAME_SYSTEM: system,
|
|
XDG_CACHE_HOME: cache,
|
|
});
|
|
for (const [machine, expected] of [
|
|
["x86_64", "x86_64"],
|
|
["amd64", "x86_64"],
|
|
["aarch64", "aarch64"],
|
|
["arm64", "aarch64"],
|
|
] as const) {
|
|
const result = spawnSync(path.resolve(appImageToolsPath), ["architecture"], {
|
|
encoding: "utf8",
|
|
env: architectureEnv("Linux", machine),
|
|
});
|
|
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
|
|
expect(result.stdout).toBe(`${expected}\n`);
|
|
expect(existsSync(architectureCache)).toBe(false);
|
|
}
|
|
const runtimePath = spawnSync(path.resolve(appImageToolsPath), ["runtime-path"], {
|
|
encoding: "utf8",
|
|
env: architectureEnv("Linux", "arm64"),
|
|
});
|
|
expect(runtimePath.status, `${runtimePath.stdout}${runtimePath.stderr}`).toBe(0);
|
|
expect(runtimePath.stdout).toBe(`${architectureCache}/tauri/.appimage-runtime-aarch64\n`);
|
|
expect(existsSync(architectureCache)).toBe(false);
|
|
const relativeRuntimePath = spawnSync(path.resolve(appImageToolsPath), ["runtime-path"], {
|
|
encoding: "utf8",
|
|
env: architectureEnv("Linux", "x86_64", "relative-cache"),
|
|
});
|
|
expect(relativeRuntimePath.status).not.toBe(0);
|
|
for (const [system, machine] of [
|
|
["Darwin", "arm64"],
|
|
["Linux", "riscv64"],
|
|
] as const) {
|
|
const rejectedCache = path.join(architectureRoot, `${system}-${machine}`);
|
|
const result = spawnSync(path.resolve(appImageToolsPath), ["prepare"], {
|
|
encoding: "utf8",
|
|
env: architectureEnv(system, machine, rejectedCache),
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(existsSync(rejectedCache)).toBe(false);
|
|
}
|
|
if (process.platform === "linux") {
|
|
const selectedTagRoot = tempDirs.make("openclaw-linux-release-v2026.8.2-");
|
|
const trustedTools = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/scripts/tauri-appimage-tools.sh",
|
|
);
|
|
const trustedToolsManifest = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/scripts/tauri-appimage-tools-x86_64.tsv",
|
|
);
|
|
const trustedArmToolsManifest = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/scripts/tauri-appimage-tools-aarch64.tsv",
|
|
);
|
|
const trustedFinalizer = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/scripts/finalize-appimage.sh",
|
|
);
|
|
const trustedSmoke = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/tests/packaged_runtime_smoke.py",
|
|
);
|
|
const trustedFirstRun = path.join(
|
|
selectedTagRoot,
|
|
".release-tooling/apps/linux/tests/first_run.py",
|
|
);
|
|
const bundleDir = path.join(
|
|
selectedTagRoot,
|
|
"apps/linux/src-tauri/target/release/bundle/appimage",
|
|
);
|
|
const appDir = path.join(bundleDir, "OpenClaw.AppDir");
|
|
const appImage = path.join(bundleDir, "OpenClaw_2026.8.2_amd64.AppImage");
|
|
const cacheRoot = path.join(selectedTagRoot, ".cache");
|
|
const toolSourceDir = path.join(selectedTagRoot, "tool-sources");
|
|
const fakeBin = path.join(selectedTagRoot, "fake-bin");
|
|
const pluginSentinel = path.join(selectedTagRoot, "plugin-executed");
|
|
const toolNames = [
|
|
"AppRun-x86_64",
|
|
"linuxdeploy-x86_64.AppImage",
|
|
"linuxdeploy-plugin-gtk.sh",
|
|
"linuxdeploy-plugin-gstreamer.sh",
|
|
"linuxdeploy-plugin-appimage.AppImage",
|
|
] as const;
|
|
mkdirSync(path.dirname(trustedFinalizer), { recursive: true });
|
|
mkdirSync(path.dirname(trustedSmoke), { recursive: true });
|
|
mkdirSync(toolSourceDir, { recursive: true });
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
copyFileSync("apps/linux/scripts/tauri-appimage-tools.sh", trustedTools);
|
|
copyFileSync("apps/linux/scripts/tauri-appimage-tools-x86_64.tsv", trustedToolsManifest);
|
|
copyFileSync("apps/linux/scripts/finalize-appimage.sh", trustedFinalizer);
|
|
copyFileSync("apps/linux/tests/packaged_runtime_smoke.py", trustedSmoke);
|
|
copyFileSync("apps/linux/tests/first_run.py", trustedFirstRun);
|
|
chmodSync(trustedTools, 0o755);
|
|
chmodSync(trustedFinalizer, 0o755);
|
|
|
|
const toolSources = new Map<string, Buffer>();
|
|
for (const toolName of toolNames) {
|
|
const contents =
|
|
toolName === "linuxdeploy-plugin-appimage.AppImage"
|
|
? Buffer.from(
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'if [[ ${1:-} == "--appimage-offset" ]]; then',
|
|
" printf '16\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
'[[ "$1" == "--appdir" && -d "$2" ]]',
|
|
'[[ "${ARCH:?}" == "${EXPECTED_ARCH:?}" ]]',
|
|
'[[ -f "${LDAI_RUNTIME_FILE:?}" ]]',
|
|
'printf "executed\\n" > "$PLUGIN_SENTINEL"',
|
|
'cat "$LDAI_RUNTIME_FILE" > "$LDAI_OUTPUT"',
|
|
`printf '\\n#!/bin/sh\\nexit 0\\n' >> "$LDAI_OUTPUT"`,
|
|
'chmod +x "$LDAI_OUTPUT"',
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
: Buffer.from(`#!/bin/sh\n# synthetic ${toolName}\nexit 0\n`);
|
|
toolSources.set(toolName, contents);
|
|
writeFileSync(path.join(toolSourceDir, toolName), contents, { mode: 0o755 });
|
|
}
|
|
const preBuildLinuxdeploy = Buffer.from(
|
|
expectDefined(toolSources.get("linuxdeploy-x86_64.AppImage"), "linuxdeploy source"),
|
|
);
|
|
const postBuildLinuxdeploy = Buffer.from(preBuildLinuxdeploy);
|
|
postBuildLinuxdeploy.fill(0, 8, 11);
|
|
const digest = (contents: Buffer) => createHash("sha256").update(contents).digest("hex");
|
|
const writeSyntheticManifest = (wrongDigest = false) => {
|
|
writeFileSync(
|
|
trustedToolsManifest,
|
|
toolNames
|
|
.map((toolName, index) => {
|
|
const contents = expectDefined(toolSources.get(toolName), `${toolName} source`);
|
|
const preBuildDigest = wrongDigest && index === 0 ? "0".repeat(64) : digest(contents);
|
|
const postBuildDigest =
|
|
toolName === "linuxdeploy-x86_64.AppImage"
|
|
? digest(postBuildLinuxdeploy)
|
|
: digest(contents);
|
|
const mode = toolName === "linuxdeploy-x86_64.AppImage" ? "0755" : "0555";
|
|
return [
|
|
toolName,
|
|
`https://example.invalid/${toolName}`,
|
|
preBuildDigest,
|
|
postBuildDigest,
|
|
mode,
|
|
].join("\t");
|
|
})
|
|
.join("\n") + "\n",
|
|
);
|
|
};
|
|
writeFileSync(
|
|
path.join(fakeBin, "curl"),
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
"output=",
|
|
"url=",
|
|
"while [[ $# -gt 0 ]]; do",
|
|
' case "$1" in',
|
|
" --output) output=$2; shift 2 ;;",
|
|
" https://*) url=$1; shift ;;",
|
|
" *) shift ;;",
|
|
" esac",
|
|
"done",
|
|
'[[ -n "$output" && -n "$url" ]]',
|
|
'if [[ ${CACHE_RACE_TOOL:-} == "${url##*/}" ]]; then',
|
|
' mkdir -p "$XDG_CACHE_HOME/tauri"',
|
|
' printf "raced\\n" > "$XDG_CACHE_HOME/tauri/race-marker"',
|
|
"fi",
|
|
'cp "$TOOL_SOURCE_DIR/${url##*/}" "$output"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
copyFileSync(path.join(architectureBin, "uname"), path.join(fakeBin, "uname"));
|
|
chmodSync(path.join(fakeBin, "uname"), 0o755);
|
|
const toolEnv = {
|
|
...process.env,
|
|
EXPECTED_ARCH: "x86_64",
|
|
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
SYNTHETIC_UNAME_MACHINE: "x86_64",
|
|
SYNTHETIC_UNAME_SYSTEM: "Linux",
|
|
TOOL_SOURCE_DIR: toolSourceDir,
|
|
XDG_CACHE_HOME: cacheRoot,
|
|
};
|
|
writeSyntheticManifest(true);
|
|
const rejectedPrepare = spawnSync(trustedTools, ["prepare"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: toolEnv,
|
|
});
|
|
expect(rejectedPrepare.status).not.toBe(0);
|
|
expect(existsSync(path.join(cacheRoot, "tauri"))).toBe(false);
|
|
|
|
writeSyntheticManifest();
|
|
const rejectedRacedPrepare = spawnSync(trustedTools, ["prepare"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...toolEnv,
|
|
CACHE_RACE_TOOL: "linuxdeploy-plugin-appimage.AppImage",
|
|
},
|
|
});
|
|
expect(
|
|
rejectedRacedPrepare.status,
|
|
`${rejectedRacedPrepare.stdout}${rejectedRacedPrepare.stderr}`,
|
|
).not.toBe(0);
|
|
expect(readFileSync(path.join(cacheRoot, "tauri/race-marker"), "utf8")).toBe("raced\n");
|
|
expect(globSync(path.join(cacheRoot, ".tauri-tools.*"))).toEqual([]);
|
|
rmSync(path.join(cacheRoot, "tauri"), { recursive: true });
|
|
|
|
const prepared = spawnSync(trustedTools, ["prepare"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: toolEnv,
|
|
});
|
|
expect(prepared.status, `${prepared.stdout}${prepared.stderr}`).toBe(0);
|
|
expect(globSync(path.join(cacheRoot, ".tauri-tools.*"))).toEqual([]);
|
|
const verifiedPreBuild = spawnSync(trustedTools, ["verify", "pre-build"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: toolEnv,
|
|
});
|
|
expect(verifiedPreBuild.status, `${verifiedPreBuild.stdout}${verifiedPreBuild.stderr}`).toBe(0);
|
|
const toolsDir = path.join(cacheRoot, "tauri");
|
|
const runtime = path.join(toolsDir, ".appimage-runtime-x86_64");
|
|
const appImagePlugin = expectDefined(
|
|
toolSources.get("linuxdeploy-plugin-appimage.AppImage"),
|
|
"AppImage plugin source",
|
|
);
|
|
expect(readFileSync(runtime)).toEqual(appImagePlugin.subarray(0, 16));
|
|
expect(statSync(runtime).mode & 0o777).toBe(0o444);
|
|
const rejectedStaleCache = spawnSync(trustedTools, ["prepare"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: toolEnv,
|
|
});
|
|
expect(rejectedStaleCache.status).not.toBe(0);
|
|
|
|
const linuxdeploy = path.join(toolsDir, "linuxdeploy-x86_64.AppImage");
|
|
writeFileSync(linuxdeploy, postBuildLinuxdeploy);
|
|
chmodSync(linuxdeploy, 0o755);
|
|
const verifiedPostBuild = spawnSync(trustedTools, ["verify", "post-build"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: toolEnv,
|
|
});
|
|
expect(verifiedPostBuild.status, `${verifiedPostBuild.stdout}${verifiedPostBuild.stderr}`).toBe(
|
|
0,
|
|
);
|
|
|
|
const resetBundle = () => {
|
|
rmSync(bundleDir, { force: true, recursive: true });
|
|
mkdirSync(path.join(appDir, "usr/lib"), { recursive: true });
|
|
writeFileSync(path.join(appDir, "usr/lib/libwayland-client.so.0"), "host-incompatible");
|
|
writeFileSync(appImage, "pre-finalized");
|
|
chmodSync(appImage, 0o755);
|
|
writeFileSync(`${appImage}.sig`, "stale-signature");
|
|
rmSync(pluginSentinel, { force: true });
|
|
};
|
|
const runFinalizer = () =>
|
|
spawnSync(trustedFinalizer, [bundleDir], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...toolEnv,
|
|
PLUGIN_SENTINEL: pluginSentinel,
|
|
},
|
|
});
|
|
const restoreTool = (toolName: (typeof toolNames)[number]) => {
|
|
const contents =
|
|
toolName === "linuxdeploy-x86_64.AppImage"
|
|
? postBuildLinuxdeploy
|
|
: expectDefined(toolSources.get(toolName), `${toolName} source`);
|
|
rmSync(path.join(toolsDir, toolName), { force: true });
|
|
writeFileSync(path.join(toolsDir, toolName), contents, { mode: 0o755 });
|
|
chmodSync(
|
|
path.join(toolsDir, toolName),
|
|
toolName === "linuxdeploy-x86_64.AppImage" ? 0o755 : 0o555,
|
|
);
|
|
};
|
|
|
|
for (const toolName of toolNames) {
|
|
resetBundle();
|
|
const tool = path.join(toolsDir, toolName);
|
|
chmodSync(tool, 0o755);
|
|
writeFileSync(tool, Buffer.concat([readFileSync(tool), Buffer.from("tampered")]));
|
|
const rejected = runFinalizer();
|
|
expect(rejected.status, `${toolName}: ${rejected.stdout}${rejected.stderr}`).not.toBe(0);
|
|
expect(existsSync(pluginSentinel), `${toolName} plugin execution`).toBe(false);
|
|
expect(existsSync(path.join(appDir, "usr/lib/libwayland-client.so.0")), toolName).toBe(true);
|
|
expect(readFileSync(appImage, "utf8"), toolName).toBe("pre-finalized");
|
|
expect(readFileSync(`${appImage}.sig`, "utf8"), toolName).toBe("stale-signature");
|
|
restoreTool(toolName);
|
|
}
|
|
|
|
resetBundle();
|
|
const appRun = path.join(toolsDir, "AppRun-x86_64");
|
|
rmSync(appRun);
|
|
symlinkSync(path.join(toolSourceDir, "AppRun-x86_64"), appRun);
|
|
const rejectedSymlink = runFinalizer();
|
|
expect(rejectedSymlink.status).not.toBe(0);
|
|
expect(existsSync(pluginSentinel)).toBe(false);
|
|
restoreTool("AppRun-x86_64");
|
|
|
|
resetBundle();
|
|
const gtkPlugin = path.join(toolsDir, "linuxdeploy-plugin-gtk.sh");
|
|
chmodSync(gtkPlugin, 0o444);
|
|
const rejectedNonExecutable = runFinalizer();
|
|
expect(rejectedNonExecutable.status).not.toBe(0);
|
|
expect(existsSync(pluginSentinel)).toBe(false);
|
|
restoreTool("linuxdeploy-plugin-gtk.sh");
|
|
|
|
resetBundle();
|
|
chmodSync(runtime, 0o644);
|
|
writeFileSync(runtime, Buffer.concat([readFileSync(runtime), Buffer.from("tampered")]));
|
|
const rejectedRuntime = runFinalizer();
|
|
expect(rejectedRuntime.status).not.toBe(0);
|
|
expect(existsSync(pluginSentinel)).toBe(false);
|
|
expect(existsSync(path.join(appDir, "usr/lib/libwayland-client.so.0"))).toBe(true);
|
|
expect(readFileSync(appImage, "utf8")).toBe("pre-finalized");
|
|
writeFileSync(runtime, appImagePlugin.subarray(0, 16), { mode: 0o644 });
|
|
chmodSync(runtime, 0o444);
|
|
|
|
resetBundle();
|
|
const finalized = runFinalizer();
|
|
expect(finalized.status, `${finalized.stdout}${finalized.stderr}`).toBe(0);
|
|
expect(readFileSync(appImage).subarray(0, 16)).toEqual(appImagePlugin.subarray(0, 16));
|
|
expect(readFileSync(appImage, "utf8")).toContain("#!/bin/sh");
|
|
expect(existsSync(`${appImage}.sig`)).toBe(false);
|
|
expect(existsSync(path.join(appDir, "usr/lib/libwayland-client.so.0"))).toBe(false);
|
|
expect(readFileSync(pluginSentinel, "utf8")).toBe("executed\n");
|
|
|
|
writeFileSync(
|
|
path.join(appDir, "usr/lib/libwayland-client.so.0"),
|
|
"post-finalization-smoke-fixture",
|
|
);
|
|
expect(existsSync(path.join(selectedTagRoot, "apps/linux/scripts/finalize-appimage.sh"))).toBe(
|
|
false,
|
|
);
|
|
const smokeChild = spawnSync(
|
|
"python3",
|
|
[
|
|
"-c",
|
|
[
|
|
"from pathlib import Path",
|
|
"import subprocess",
|
|
"import sys",
|
|
'child = Path(sys.argv[1]).with_name("first_run.py")',
|
|
"assert child.is_file()",
|
|
'subprocess.run([sys.executable, str(child), "--help"], check=True)',
|
|
].join("; "),
|
|
trustedSmoke,
|
|
],
|
|
{ cwd: selectedTagRoot, encoding: "utf8" },
|
|
);
|
|
expect(smokeChild.status, `${smokeChild.stdout}${smokeChild.stderr}`).toBe(0);
|
|
|
|
const armToolNames = [
|
|
"AppRun-aarch64",
|
|
"linuxdeploy-aarch64.AppImage",
|
|
"linuxdeploy-plugin-gtk.sh",
|
|
"linuxdeploy-plugin-gstreamer.sh",
|
|
"linuxdeploy-plugin-appimage.AppImage",
|
|
] as const;
|
|
const armToolSourceDir = path.join(selectedTagRoot, "arm-tool-sources");
|
|
const armCacheRoot = path.join(selectedTagRoot, ".arm-cache");
|
|
const armBundleDir = path.join(selectedTagRoot, "arm-bundle");
|
|
const armAppDir = path.join(armBundleDir, "OpenClaw.AppDir");
|
|
const armAppImage = path.join(armBundleDir, "OpenClaw_2026.8.2_arm64.AppImage");
|
|
const armPluginSentinel = path.join(selectedTagRoot, "arm-plugin-executed");
|
|
mkdirSync(armToolSourceDir);
|
|
const armToolSources = new Map<string, Buffer>();
|
|
for (const toolName of armToolNames) {
|
|
const contents =
|
|
toolName === "linuxdeploy-plugin-appimage.AppImage"
|
|
? expectDefined(toolSources.get(toolName), `${toolName} source`)
|
|
: Buffer.from(`#!/bin/sh\n# synthetic ${toolName}\nexit 0\n`);
|
|
armToolSources.set(toolName, contents);
|
|
writeFileSync(path.join(armToolSourceDir, toolName), contents, { mode: 0o755 });
|
|
}
|
|
const armPostBuildLinuxdeploy = Buffer.from(
|
|
expectDefined(armToolSources.get("linuxdeploy-aarch64.AppImage"), "ARM linuxdeploy source"),
|
|
);
|
|
armPostBuildLinuxdeploy.fill(0, 8, 11);
|
|
writeFileSync(
|
|
trustedArmToolsManifest,
|
|
armToolNames
|
|
.map((toolName) => {
|
|
const contents = expectDefined(armToolSources.get(toolName), `${toolName} source`);
|
|
return [
|
|
toolName,
|
|
`https://example.invalid/${toolName}`,
|
|
digest(contents),
|
|
toolName === "linuxdeploy-aarch64.AppImage"
|
|
? digest(armPostBuildLinuxdeploy)
|
|
: digest(contents),
|
|
toolName === "linuxdeploy-aarch64.AppImage" ? "0755" : "0555",
|
|
].join("\t");
|
|
})
|
|
.join("\n") + "\n",
|
|
);
|
|
const armToolEnv = {
|
|
...toolEnv,
|
|
EXPECTED_ARCH: "aarch64",
|
|
SYNTHETIC_UNAME_MACHINE: "arm64",
|
|
TOOL_SOURCE_DIR: armToolSourceDir,
|
|
XDG_CACHE_HOME: armCacheRoot,
|
|
};
|
|
const armPrepared = spawnSync(trustedTools, ["prepare"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: armToolEnv,
|
|
});
|
|
expect(armPrepared.status, `${armPrepared.stdout}${armPrepared.stderr}`).toBe(0);
|
|
const armRuntimePath = spawnSync(trustedTools, ["runtime-path"], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: armToolEnv,
|
|
});
|
|
expect(armRuntimePath.status, `${armRuntimePath.stdout}${armRuntimePath.stderr}`).toBe(0);
|
|
expect(armRuntimePath.stdout).toBe(`${armCacheRoot}/tauri/.appimage-runtime-aarch64\n`);
|
|
const armToolsDir = path.join(armCacheRoot, "tauri");
|
|
const armLinuxdeploy = path.join(armToolsDir, "linuxdeploy-aarch64.AppImage");
|
|
writeFileSync(armLinuxdeploy, armPostBuildLinuxdeploy);
|
|
chmodSync(armLinuxdeploy, 0o755);
|
|
mkdirSync(path.join(armAppDir, "usr/lib"), { recursive: true });
|
|
writeFileSync(path.join(armAppDir, "usr/lib/libwayland-client.so.0"), "host-incompatible");
|
|
writeFileSync(armAppImage, "pre-finalized", { mode: 0o755 });
|
|
writeFileSync(`${armAppImage}.sig`, "stale-signature");
|
|
const armFinalized = spawnSync(trustedFinalizer, [armBundleDir], {
|
|
cwd: selectedTagRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...armToolEnv,
|
|
PLUGIN_SENTINEL: armPluginSentinel,
|
|
},
|
|
});
|
|
expect(armFinalized.status, `${armFinalized.stdout}${armFinalized.stderr}`).toBe(0);
|
|
expect(readFileSync(armAppImage).subarray(0, 16)).toEqual(appImagePlugin.subarray(0, 16));
|
|
expect(existsSync(`${armAppImage}.sig`)).toBe(false);
|
|
expect(existsSync(path.join(armAppDir, "usr/lib/libwayland-client.so.0"))).toBe(false);
|
|
expect(readFileSync(armPluginSentinel, "utf8")).toBe("executed\n");
|
|
|
|
const writeSigningToolFixtures = (root: string) => {
|
|
const bin = path.join(root, "bin");
|
|
const poisonBin = path.join(root, "poison-bin");
|
|
const tauri = path.join(bin, "cargo-tauri");
|
|
const tauriLog = path.join(root, "cargo-tauri.log");
|
|
const minisignLog = path.join(root, "minisign.log");
|
|
mkdirSync(bin, { recursive: true });
|
|
mkdirSync(poisonBin, { recursive: true });
|
|
writeFileSync(
|
|
tauri,
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'[[ "$#" -eq 3 && "$1" == "signer" && "$2" == "sign" ]]',
|
|
'[[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]',
|
|
'[[ -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" ]]',
|
|
'printf "%s\\n" "$*" >> "$TAURI_SIGN_LOG"',
|
|
'printf "ephemeral-signature:%s" "$(basename "$3")" | base64 > "$3.sig"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(
|
|
path.join(bin, "minisign"),
|
|
[
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'[[ "$#" -eq 6 && "$1" == "-Vm" && "$3" == "-x" && "$5" == "-p" ]]',
|
|
'[[ -z "${TAURI_SIGNING_PRIVATE_KEY+x}" ]]',
|
|
'[[ -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+x}" ]]',
|
|
'[[ -s "$2" && -s "$4" && -s "$6" ]]',
|
|
'[[ "$(cat "$6")" == "ephemeral-public-key" ]]',
|
|
'printf "%s\\n" "$(basename "$2")" >> "$MINISIGN_LOG"',
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
for (const command of ["cargo-tauri", "minisign", "npm", "pnpm", "npx", "corepack"]) {
|
|
writeFileSync(path.join(poisonBin, command), "#!/bin/sh\nexit 97\n", { mode: 0o755 });
|
|
}
|
|
return {
|
|
minisignBinarySha256: createHash("sha256")
|
|
.update(readFileSync(path.join(bin, "minisign")))
|
|
.digest("hex"),
|
|
minisignLog,
|
|
path: `${poisonBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
tauriBinarySha256: createHash("sha256").update(readFileSync(tauri)).digest("hex"),
|
|
tauriLog,
|
|
};
|
|
};
|
|
|
|
const signingRoot = tempDirs.make("openclaw-linux-signing-job-");
|
|
const signingInput = path.join(signingRoot, "dist/signing-input");
|
|
const finalizedArtifact = path.join(signingInput, "OpenClaw-2026.8.2-amd64.AppImage");
|
|
mkdirSync(signingInput, { recursive: true });
|
|
writeFileSync(finalizedArtifact, "trusted-finalized-bytes");
|
|
const linuxSigningTools = writeSigningToolFixtures(signingRoot);
|
|
const signed = spawnSync("bash", ["-c", signAppImage.run ?? ""], {
|
|
cwd: signingRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PATH: linuxSigningTools.path,
|
|
RELEASE_TAG: "v2026.8.2",
|
|
RUNNER_TEMP: signingRoot,
|
|
TAG_SHA: "a".repeat(40),
|
|
MINISIGN_BINARY_SHA256: linuxSigningTools.minisignBinarySha256,
|
|
TAURI_CLI_BINARY_SHA256: linuxSigningTools.tauriBinarySha256,
|
|
TAURI_SIGN_LOG: linuxSigningTools.tauriLog,
|
|
TAURI_SIGNING_PRIVATE_KEY: "ephemeral-test-key",
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "ephemeral-test-password",
|
|
UPDATER_PUBLIC_KEY: Buffer.from("ephemeral-public-key").toString("base64"),
|
|
MINISIGN_LOG: linuxSigningTools.minisignLog,
|
|
},
|
|
});
|
|
expect(signed.status, `${signed.stdout}${signed.stderr}`).toBe(0);
|
|
expect(readFileSync(finalizedArtifact, "utf8")).toBe("trusted-finalized-bytes");
|
|
expect(
|
|
readFileSync(
|
|
path.join(signingRoot, "dist/linux-app/release/OpenClaw-2026.8.2-amd64.AppImage"),
|
|
"utf8",
|
|
),
|
|
).toBe("trusted-finalized-bytes");
|
|
expect(
|
|
readFileSync(
|
|
path.join(signingRoot, "dist/linux-app/signatures/OpenClaw-2026.8.2-amd64.AppImage.sig"),
|
|
"utf8",
|
|
),
|
|
).toBe(
|
|
`${Buffer.from("ephemeral-signature:OpenClaw-2026.8.2-amd64.AppImage").toString("base64")}\n`,
|
|
);
|
|
expect(readFileSync(linuxSigningTools.tauriLog, "utf8")).toBe(
|
|
"signer sign dist/signing-input/OpenClaw-2026.8.2-amd64.AppImage\n",
|
|
);
|
|
expect(readFileSync(linuxSigningTools.minisignLog, "utf8")).toBe(
|
|
"OpenClaw-2026.8.2-amd64.AppImage\n",
|
|
);
|
|
expect(existsSync(path.join(signingRoot, ".release-tooling"))).toBe(false);
|
|
expect(existsSync(path.join(signingRoot, "apps"))).toBe(false);
|
|
|
|
const desktopSigningRoot = tempDirs.make("openclaw-desktop-signing-job-");
|
|
const macosInput = path.join(
|
|
desktopSigningRoot,
|
|
"dist/signing-input/macos/OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz",
|
|
);
|
|
const windowsInput = path.join(
|
|
desktopSigningRoot,
|
|
"dist/signing-input/windows/OpenClaw-2026.8.2-windows-x86_64.exe",
|
|
);
|
|
mkdirSync(path.dirname(macosInput), { recursive: true });
|
|
mkdirSync(path.dirname(windowsInput), { recursive: true });
|
|
writeFileSync(macosInput, "finalized-macos-updater-bytes");
|
|
writeFileSync(windowsInput, "finalized-windows-updater-bytes");
|
|
const desktopSigningTools = writeSigningToolFixtures(desktopSigningRoot);
|
|
const desktopSigned = spawnSync("bash", ["-c", signDesktop.run ?? ""], {
|
|
cwd: desktopSigningRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PATH: desktopSigningTools.path,
|
|
RELEASE_TAG: "v2026.8.2",
|
|
RUNNER_TEMP: desktopSigningRoot,
|
|
TAG_SHA: "a".repeat(40),
|
|
MINISIGN_BINARY_SHA256: desktopSigningTools.minisignBinarySha256,
|
|
TAURI_CLI_BINARY_SHA256: desktopSigningTools.tauriBinarySha256,
|
|
TAURI_SIGN_LOG: desktopSigningTools.tauriLog,
|
|
TAURI_SIGNING_PRIVATE_KEY: "ephemeral-test-key",
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "ephemeral-test-password",
|
|
UPDATER_PUBLIC_KEY: Buffer.from("ephemeral-public-key").toString("base64"),
|
|
MINISIGN_LOG: desktopSigningTools.minisignLog,
|
|
},
|
|
});
|
|
expect(desktopSigned.status, `${desktopSigned.stdout}${desktopSigned.stderr}`).toBe(0);
|
|
expect(readFileSync(macosInput, "utf8")).toBe("finalized-macos-updater-bytes");
|
|
expect(readFileSync(windowsInput, "utf8")).toBe("finalized-windows-updater-bytes");
|
|
expect(
|
|
readFileSync(
|
|
path.join(
|
|
desktopSigningRoot,
|
|
"dist/desktop-test/macos/release/OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz",
|
|
),
|
|
"utf8",
|
|
),
|
|
).toBe("finalized-macos-updater-bytes");
|
|
expect(
|
|
readFileSync(
|
|
path.join(
|
|
desktopSigningRoot,
|
|
"dist/desktop-test/windows/release/OpenClaw-2026.8.2-windows-x86_64.exe",
|
|
),
|
|
"utf8",
|
|
),
|
|
).toBe("finalized-windows-updater-bytes");
|
|
expect(
|
|
Buffer.from(
|
|
readFileSync(
|
|
path.join(
|
|
desktopSigningRoot,
|
|
"dist/desktop-test/macos/signatures/OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz.sig",
|
|
),
|
|
"utf8",
|
|
),
|
|
"base64",
|
|
).toString(),
|
|
).toContain("OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz");
|
|
expect(
|
|
Buffer.from(
|
|
readFileSync(
|
|
path.join(
|
|
desktopSigningRoot,
|
|
"dist/desktop-test/windows/signatures/OpenClaw-2026.8.2-windows-x86_64.exe.sig",
|
|
),
|
|
"utf8",
|
|
),
|
|
"base64",
|
|
).toString(),
|
|
).toContain("OpenClaw-2026.8.2-windows-x86_64.exe");
|
|
expect(readFileSync(desktopSigningTools.tauriLog, "utf8")).toBe(
|
|
"signer sign dist/signing-input/macos/OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz\n" +
|
|
"signer sign dist/signing-input/windows/OpenClaw-2026.8.2-windows-x86_64.exe\n",
|
|
);
|
|
expect(readFileSync(desktopSigningTools.minisignLog, "utf8")).toBe(
|
|
"OpenClaw-2026.8.2-darwin-aarch64.app.tar.gz\nOpenClaw-2026.8.2-windows-x86_64.exe\n",
|
|
);
|
|
expect(existsSync(path.join(desktopSigningRoot, ".release-tooling"))).toBe(false);
|
|
expect(existsSync(path.join(desktopSigningRoot, "apps"))).toBe(false);
|
|
}
|
|
const linuxBuildBodies = linuxBuildSteps.map(({ run }) => run ?? "").join("\n");
|
|
for (const helper of [
|
|
"apps/linux/scripts/stage-appimage-gstreamer.sh",
|
|
"apps/linux/scripts/finalize-appimage.sh",
|
|
"apps/linux/tests/packaged_runtime_smoke.py",
|
|
]) {
|
|
expect(linuxBuildBodies).toContain(`.release-tooling/${helper}`);
|
|
expect(linuxBuildBodies).not.toMatch(new RegExp(`(^|\\s)${helper.replaceAll(".", "\\.")}`));
|
|
}
|
|
const linuxBody = expectDefined(
|
|
(linux.jobs.validate_release.steps as WorkflowStep[]).find(
|
|
({ name }) => name === workflows[0].validation,
|
|
)?.run,
|
|
"Linux release admission body",
|
|
);
|
|
expect(linuxBody).toContain('exec python3 -I -S "$CI_GIT_OWNER" --policy -');
|
|
expect(linuxBody.match(/timeout=120/gu)).toHaveLength(2);
|
|
expect(linuxBody).toContain('"+refs/heads/main:refs/remotes/origin/main"');
|
|
expect(linuxBody).toContain('run_git(workspace, "merge-base", "--is-ancestor", sha, ref)');
|
|
|
|
const macos = parse(readFileSync(workflows[1].file, "utf8"));
|
|
const macosBody = expectDefined(
|
|
(macos.jobs.validate_macos_release_request.steps as WorkflowStep[]).find(
|
|
({ name }) => name === workflows[1].validation,
|
|
)?.run,
|
|
"macOS release admission body",
|
|
);
|
|
expect(macosBody.match(/--git 0/gu)).toHaveLength(1);
|
|
expect(macosBody.match(/--checkout-git 120/gu)).toHaveLength(1);
|
|
expect(macosBody).toContain(
|
|
'"+refs/heads/${PUBLIC_RELEASE_BRANCH}:refs/remotes/origin/${PUBLIC_RELEASE_BRANCH}"',
|
|
);
|
|
expect(macosBody.indexOf("--checkout-git 120")).toBeLessThan(
|
|
macosBody.indexOf("pnpm release:openclaw:npm:check"),
|
|
);
|
|
|
|
const placeholder = parse(readFileSync(workflows[2].file, "utf8"));
|
|
const placeholderBody = expectDefined(
|
|
(placeholder.jobs.plan.steps as WorkflowStep[]).find(
|
|
({ name }) => name === workflows[2].validation,
|
|
)?.run,
|
|
"placeholder admission body",
|
|
);
|
|
expect(placeholderBody).toContain('exec python3 -I -S "$CI_GIT_OWNER" --policy -');
|
|
expect(placeholderBody.match(/timeout=120/gu)).toHaveLength(1);
|
|
expect(placeholderBody.match(/run_git\(workspace, "merge-base"/gu)).toHaveLength(2);
|
|
expect(placeholderBody).toContain('output.write(f"sha={source_ref}\\n")');
|
|
});
|
|
|
|
it("pins every Performance Git owner before checkout and preserves Git deadlines", () => {
|
|
const source = readFileSync(".github/workflows/openclaw-performance.yml", "utf8");
|
|
const workflow = parse(source);
|
|
const targets = [
|
|
["resolve_target", "Checkout target metadata", undefined, 10],
|
|
["kova", "Checkout OpenClaw", "Decide lane", 240],
|
|
["source_performance", "Checkout OpenClaw source target", undefined, 120],
|
|
["publish", "Checkout performance publisher helper", "Decide report publication lane", 30],
|
|
] as const;
|
|
for (const [jobId, checkout, decision, timeout] of targets) {
|
|
const job = workflow.jobs[jobId];
|
|
const steps = job.steps as WorkflowStep[];
|
|
const index = steps.findIndex(({ name }) => name === "Prepare Git owner");
|
|
expect(index).toBe(decision ? 1 : 0);
|
|
expect(steps[index + 1]?.name).toBe(checkout);
|
|
if (decision) {
|
|
expect(steps[index - 1]?.name).toBe(decision);
|
|
}
|
|
expect(steps[index]).toEqual({
|
|
name: "Prepare Git owner",
|
|
uses: "openclaw/openclaw/.github/actions/git-owner@a379bbd73e30b84a89aca4d54744ab9ca19082e7",
|
|
...(decision ? { if: "steps.lane.outputs.run == 'true'" } : {}),
|
|
});
|
|
expect(job["timeout-minutes"]).toBe(timeout);
|
|
const bodies = steps.map(({ run }) => run ?? "").join("\n");
|
|
expect(bodies).not.toMatch(/(?:^|[\s(])git\s/mu);
|
|
expect(bodies).not.toMatch(/(?:^|[\s(])timeout\s+[^\n]*\bgit\b/u);
|
|
const ownerDeadlines = [...bodies.matchAll(/--(?:checkout-)?git (\d+)/gu)].map((match) =>
|
|
Number(match[1]),
|
|
);
|
|
expect(ownerDeadlines.every((deadline) => deadline === 0)).toBe(true);
|
|
if (jobId !== "publish") {
|
|
expect(bodies).not.toMatch(/timeout=\d+/u);
|
|
} else {
|
|
expect(bodies.match(/timeout=120/g)).toHaveLength(2);
|
|
expect(bodies).not.toMatch(/timeout=(?!120)\d+/u);
|
|
expect(bodies.match(/for attempt in range\(1, 6\)/gu)).toHaveLength(1);
|
|
expect(bodies.match(/backoff\(attempt \* 2\)/gu)).toHaveLength(1);
|
|
expect(bodies).toContain('"push", "origin", "HEAD:main", timeout=120, reclaim_locks=True');
|
|
expect(
|
|
bodies.match(/"fetch", "--depth=1", "origin", "main", timeout=120, reclaim_locks=True/gu),
|
|
).toHaveLength(1);
|
|
expect(bodies).toContain('fetch(sys.argv[3], "main", max_attempts=3, retry_failures=True)');
|
|
expect(bodies).toContain("if error.code != 1:");
|
|
expect(bodies).toContain(
|
|
'"ls-tree", "--name-only", "FETCH_HEAD", "--", f"{dest}/report.json"',
|
|
);
|
|
}
|
|
}
|
|
expect(workflow.on.schedule).toEqual([{ cron: "11 5 * * *" }]);
|
|
expect(Object.keys(workflow.on.workflow_dispatch.inputs)).toEqual([
|
|
"mode",
|
|
"target_ref",
|
|
"baseline_ref",
|
|
"profile",
|
|
"repeat",
|
|
"deep_profile",
|
|
"live_openai_candidate",
|
|
"fail_on_regression",
|
|
"publish_reports",
|
|
"kova_ref",
|
|
"kova_config_contract",
|
|
"dispatch_id",
|
|
]);
|
|
expect(workflow.permissions).toEqual({ contents: "read" });
|
|
expect(workflow.jobs.publish.permissions).toEqual({ actions: "read", contents: "read" });
|
|
expect(workflow.concurrency).toEqual({
|
|
group:
|
|
"${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }}",
|
|
"cancel-in-progress": false,
|
|
});
|
|
});
|
|
|
|
describe("frozen CI compatibility contracts", () => {
|
|
it("skips current-only launcher and QA contracts for frozen targets", () => {
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
expect(source).toContain(
|
|
`if: \${{ needs.preflight.outputs.frozen_target != 'true' }}\n run: |\n bun openclaw.mjs --help`,
|
|
);
|
|
expect(source).toContain(
|
|
"[skip] ${partId} is not declared by this checkout's legacy smoke plan",
|
|
);
|
|
expect(source).not.toContain('"control-ui-chat-flow-playwright",');
|
|
expect(source).toContain("if (!source.includes(marker)) process.exit(0);");
|
|
});
|
|
});
|
|
|
|
describe("workflow file size", () => {
|
|
// GitHub refuses workflow files above 500 KiB: it creates a run named after
|
|
// the file that fails with no jobs, so CI stops without reporting a failure.
|
|
const GITHUB_WORKFLOW_MAX_BYTES = 512_000;
|
|
const WORKFLOW_SOFT_LIMIT_BYTES = 480_000;
|
|
|
|
it("keeps every workflow file well below GitHub's size limit", () => {
|
|
const oversized = readdirSync(".github/workflows")
|
|
.filter((name) => /\.ya?ml$/u.test(name))
|
|
.map((name) => `.github/workflows/${name}`)
|
|
.map((file) => ({ file, bytes: statSync(file).size }))
|
|
.filter(({ bytes }) => bytes > WORKFLOW_SOFT_LIMIT_BYTES)
|
|
.map(({ file, bytes }) => `${file}: ${bytes} bytes`);
|
|
|
|
expect(
|
|
oversized,
|
|
`Workflow files must stay at or below ${WORKFLOW_SOFT_LIMIT_BYTES} bytes. GitHub's hard limit is ` +
|
|
`${GITHUB_WORKFLOW_MAX_BYTES} bytes (500 KiB); above it, GitHub creates a run named after the ` +
|
|
`file that fails immediately with no jobs, so all PR and main CI silently stops. Shrink the file ` +
|
|
`(for example, YAML anchors/aliases for byte-identical expressions) before raising this limit.`,
|
|
).toEqual([]);
|
|
});
|
|
});
|