mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
95ea82b1e5
* fix(plugin-sdk): stabilize session catalog baseline * test(doctor): isolate bind persistence inventory * fix(ci): prepare max-lines base during checkout * test(sessions): align freshness regression fixtures * style(tests): format context usage fixture * fix(ci): preserve token provenance and isolate fork credentials Finish the canonical token-provenance fixtures while preserving the latest maintainer-owned unavailable-usage ordering. Limit the diff-base GitHub token to manual non-release comparisons and guard that fork isolation invariant in the existing workflow regression test. Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com> * test(ui): identify actual managed image eviction * fix(ci): reuse the validated protocol comparison base Consume the immutable diff base already resolved and validated by preflight for every CI event. Remove the dead duplicate manual GitHub lookup and unreachable fallbacks, and guard the single authoritative path without changing fork credential isolation. * test(sessions): preserve stale total expectation * test(ui): arm reconnect deferral before disconnect --------- Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
6487 lines
283 KiB
TypeScript
6487 lines
283 KiB
TypeScript
// Ci Workflow Guards tests cover ci workflow guards script behavior.
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { createNodeTestShards } from "../../scripts/lib/ci-node-test-plan.mjs";
|
|
import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts";
|
|
import { SUPPORTED_LOCALES } from "../../ui/src/i18n/lib/registry.ts";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const CHECKOUT_V6 = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10";
|
|
const CACHE_V5 = "actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae";
|
|
const SETUP_GO_V6 = "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c";
|
|
const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
|
|
const DOWNLOAD_ARTIFACT_V8 = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c";
|
|
const CREATE_GITHUB_APP_TOKEN_V3 =
|
|
"actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1";
|
|
const TRUFFLEHOG_V3_95_9 = "trufflesecurity/trufflehog@27b0417c16317ca9a472a9a8092acce143b49c55";
|
|
const MANTIS_GITHUB_APP_CLIENT_ID = "Iv23liPJCozR0uHm6P7G";
|
|
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 SETUP_ANDROID_TOOLCHAIN_ACTION = ".github/actions/setup-android-toolchain/action.yml";
|
|
const MATURITY_SCORECARD_WORKFLOW = ".github/workflows/maturity-scorecard.yml";
|
|
const MATURITY_SCORECARD_WORKFLOW_REF =
|
|
"openclaw/openclaw/.github/workflows/maturity-scorecard.yml@refs/heads/main";
|
|
const OIDC_BOUND_MAIN_REUSABLE_WORKFLOWS = new Set<string>();
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
const MATURITY_GENERATED_PR_PATHS = [
|
|
"qa/maturity-scores.yaml",
|
|
"docs/maturity/scorecard.md",
|
|
"docs/maturity/taxonomy.md",
|
|
];
|
|
|
|
type WorkflowStep = {
|
|
env?: Record<string, unknown>;
|
|
id?: string;
|
|
if?: string;
|
|
name?: string;
|
|
run?: string;
|
|
uses?: string;
|
|
with?: Record<string, unknown>;
|
|
};
|
|
|
|
function readCiWorkflow() {
|
|
return parse(readFileSync(".github/workflows/ci.yml", "utf8"));
|
|
}
|
|
|
|
function runCiGateFixture(requiredResults: string, selectedResults: string) {
|
|
const gateStep = readCiWorkflow().jobs["ci-gate"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify selected CI lanes",
|
|
);
|
|
return spawnSync("bash", ["-c", gateStep.run], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
REQUIRED_RESULTS: requiredResults,
|
|
SELECTED_RESULTS: selectedResults,
|
|
},
|
|
});
|
|
}
|
|
|
|
function quoteShell(value: string): string {
|
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
}
|
|
|
|
function runWorkflowShellScript(
|
|
script: string,
|
|
options: { cwd?: string; env?: NodeJS.ProcessEnv },
|
|
) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-workflow-shell-"));
|
|
const modulePaths: string[] = [];
|
|
try {
|
|
let moduleIndex = 0;
|
|
const moduleRoot = options.cwd ?? process.cwd();
|
|
const rewritten = script.replace(
|
|
/node --input-type=module <<'([A-Z][A-Z0-9_]*)'\n([\s\S]*?)\n\1(?=\n|$)/gu,
|
|
(_match, _marker: string, body: string) => {
|
|
const modulePath = path.join(
|
|
moduleRoot,
|
|
`.openclaw-${path.basename(root)}-${moduleIndex}.mjs`,
|
|
);
|
|
moduleIndex += 1;
|
|
modulePaths.push(modulePath);
|
|
writeFileSync(modulePath, `${body}\n`, "utf8");
|
|
return `${quoteShell(process.execPath)} ${quoteShell(modulePath)}`;
|
|
},
|
|
);
|
|
const scriptPath = path.join(root, "run.sh");
|
|
writeFileSync(scriptPath, rewritten.endsWith("\n") ? rewritten : `${rewritten}\n`, "utf8");
|
|
return spawnSync("bash", [scriptPath], {
|
|
...options,
|
|
encoding: "utf8",
|
|
});
|
|
} finally {
|
|
for (const modulePath of modulePaths) {
|
|
rmSync(modulePath, { force: true });
|
|
}
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runCiManifestFixture(options: {
|
|
bundledPlanner: boolean;
|
|
changedPlannerImportFails?: boolean;
|
|
changedPaths?: string[] | null;
|
|
eventName?: "pull_request" | "workflow_dispatch";
|
|
historicalCompatibility?: boolean;
|
|
iosCapabilities?: boolean;
|
|
iosBuildCapability?: boolean;
|
|
androidCiCapabilities?: boolean;
|
|
nativeI18nCapabilities?: boolean;
|
|
openClawKitTests?: boolean;
|
|
protocolCoverage?: boolean;
|
|
qaSmokePlan?: boolean;
|
|
formatCheck?: boolean;
|
|
releaseCandidateCompatibility?: boolean;
|
|
nodeFastOnly?: boolean;
|
|
nodeFastPluginContracts?: boolean;
|
|
nodeFastCiRouting?: boolean;
|
|
runNode?: boolean;
|
|
}) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-ci-manifest-"));
|
|
try {
|
|
const scriptsDir = path.join(root, "scripts", "lib");
|
|
mkdirSync(scriptsDir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(scriptsDir, "ci-node-test-plan.mjs"),
|
|
options.bundledPlanner
|
|
? `
|
|
export const createNodeTestShards = () => [{
|
|
checkName: "legacy-node-plan",
|
|
configs: ["test/vitest/legacy.config.ts"],
|
|
requiresDist: false,
|
|
runner: "ubuntu-24.04",
|
|
shardName: "legacy-node-plan",
|
|
}];
|
|
export const createNodeTestShardBundles = () => [{
|
|
checkName: "bundled-node-plan",
|
|
configs: ["test/vitest/bundled.config.ts"],
|
|
requiresDist: false,
|
|
runner: "ubuntu-24.04",
|
|
shardName: "bundled-node-plan",
|
|
}];
|
|
`
|
|
: `
|
|
export const createNodeTestShards = () => [{
|
|
checkName: "legacy-node-plan",
|
|
configs: ["test/vitest/legacy.config.ts"],
|
|
requiresDist: false,
|
|
runner: "ubuntu-24.04",
|
|
shardName: "legacy-node-plan",
|
|
}];
|
|
`,
|
|
"utf8",
|
|
);
|
|
const iosCapabilities = options.iosCapabilities ?? options.bundledPlanner;
|
|
const iosBuildCapability = options.iosBuildCapability ?? iosCapabilities;
|
|
const nativeI18nCapabilities = options.nativeI18nCapabilities ?? options.bundledPlanner;
|
|
const packageScripts = options.bundledPlanner
|
|
? {
|
|
...(nativeI18nCapabilities
|
|
? {
|
|
"android:i18n:check": "true",
|
|
"apple:i18n:check": "true",
|
|
"native:i18n:check": "true",
|
|
}
|
|
: {}),
|
|
...(iosBuildCapability ? { "ios:build": "true" } : {}),
|
|
"check:max-lines-ratchet": "true",
|
|
}
|
|
: {};
|
|
writeFileSync(
|
|
path.join(root, "package.json"),
|
|
`${JSON.stringify({ scripts: packageScripts })}\n`,
|
|
);
|
|
if (options.bundledPlanner) {
|
|
writeFileSync(
|
|
path.join(scriptsDir, "ci-changed-node-test-plan.mjs"),
|
|
options.changedPlannerImportFails
|
|
? `throw new Error("planner import failure");\n`
|
|
: `
|
|
export const createChangedNodeTestShards = (changedPaths) =>
|
|
changedPaths.includes("src/focused.ts")
|
|
? [{
|
|
checkName: "changed-node-plan",
|
|
configs: [],
|
|
requiresDist: false,
|
|
runner: "ubuntu-24.04",
|
|
shardName: "changed-node-plan",
|
|
targets: ["src/focused.test.ts"],
|
|
}]
|
|
: null;
|
|
`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
path.join(scriptsDir, "channel-contract-test-plan.mjs"),
|
|
`export const createChannelContractTestShards = () => [{ checkName: "channel-contracts" }];\n`,
|
|
);
|
|
writeFileSync(
|
|
path.join(scriptsDir, "plugin-contract-test-plan.mjs"),
|
|
`export const createPluginContractTestShards = () => [{ checkName: "plugin-contracts" }];\n`,
|
|
);
|
|
}
|
|
if (options.qaSmokePlan ?? options.bundledPlanner) {
|
|
const smokePlan = path.join(root, "extensions", "qa-lab", "src", "ci-smoke-plan.ts");
|
|
mkdirSync(path.dirname(smokePlan), { recursive: true });
|
|
writeFileSync(smokePlan, "export {};\n");
|
|
}
|
|
if (iosCapabilities) {
|
|
for (const name of [
|
|
"install-swift-tools.sh",
|
|
"install-xcodegen.sh",
|
|
"lint-swift.sh",
|
|
"format-swift.sh",
|
|
]) {
|
|
writeFileSync(path.join(root, "scripts", name), "#!/bin/sh\n");
|
|
}
|
|
}
|
|
if (options.protocolCoverage ?? options.bundledPlanner) {
|
|
writeFileSync(path.join(root, "scripts", "check-protocol-event-coverage.mjs"), "");
|
|
}
|
|
const targetWorkflow = path.join(root, ".github", "workflows", "ci.yml");
|
|
mkdirSync(path.dirname(targetWorkflow), { recursive: true });
|
|
writeFileSync(
|
|
targetWorkflow,
|
|
[
|
|
...((options.formatCheck ?? options.bundledPlanner)
|
|
? ["pnpm format:check", "pnpm format:check"]
|
|
: []),
|
|
...((options.androidCiCapabilities ?? options.bundledPlanner)
|
|
? ["android-ci-contract-v2"]
|
|
: []),
|
|
...((options.openClawKitTests ?? options.bundledPlanner)
|
|
? ["openclawkit-tests-contract-v1"]
|
|
: []),
|
|
].join("\n"),
|
|
);
|
|
const outputPath = path.join(root, "manifest.out");
|
|
writeFileSync(outputPath, "", "utf8");
|
|
const manifestStep = readCiWorkflow().jobs.preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Build CI manifest",
|
|
);
|
|
const run = runWorkflowShellScript(manifestStep.run, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_OUTPUT: outputPath,
|
|
OPENCLAW_CI_CHANGED_PATHS_JSON: JSON.stringify(options.changedPaths ?? null),
|
|
OPENCLAW_CI_CHECKOUT_REVISION: "a".repeat(40),
|
|
OPENCLAW_CI_DOCS_CHANGED: "true",
|
|
OPENCLAW_CI_DOCS_ONLY: "false",
|
|
OPENCLAW_CI_EVENT_NAME: options.eventName ?? "workflow_dispatch",
|
|
OPENCLAW_CI_HISTORICAL_TARGET:
|
|
(options.historicalCompatibility ?? true) &&
|
|
(options.eventName ?? "workflow_dispatch") === "workflow_dispatch"
|
|
? "true"
|
|
: "false",
|
|
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET:
|
|
options.releaseCandidateCompatibility === true ? "true" : "false",
|
|
OPENCLAW_CI_REPOSITORY: "openclaw/openclaw",
|
|
OPENCLAW_CI_RUN_ANDROID: "true",
|
|
OPENCLAW_CI_RUN_CONTROL_UI_I18N: "true",
|
|
OPENCLAW_CI_RUN_IOS_BUILD: "true",
|
|
OPENCLAW_CI_RUN_MACOS: "true",
|
|
OPENCLAW_CI_RUN_NATIVE_I18N: "true",
|
|
OPENCLAW_CI_RUN_NODE: String(options.runNode ?? true),
|
|
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: String(options.nodeFastCiRouting ?? false),
|
|
OPENCLAW_CI_RUN_NODE_FAST_ONLY: String(options.nodeFastOnly ?? false),
|
|
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: String(
|
|
options.nodeFastPluginContracts ?? false,
|
|
),
|
|
OPENCLAW_CI_RUN_SKILLS_PYTHON: "true",
|
|
OPENCLAW_CI_RUN_WINDOWS: "true",
|
|
OPENCLAW_CI_WORKFLOW_REVISION: "b".repeat(40),
|
|
},
|
|
});
|
|
const outputs = Object.fromEntries(
|
|
readFileSync(outputPath, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
}),
|
|
);
|
|
return { output: `${run.stdout}${run.stderr}`, outputs, status: run.status };
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function readAndroidReleaseWorkflow() {
|
|
return parse(readFileSync(".github/workflows/android-release.yml", "utf8"));
|
|
}
|
|
|
|
function readAndroidToolchainAction() {
|
|
return parse(readFileSync(SETUP_ANDROID_TOOLCHAIN_ACTION, "utf8"));
|
|
}
|
|
|
|
function readBuildArtifactsTestboxWorkflow() {
|
|
return parse(readFileSync(".github/workflows/ci-build-artifacts-testbox.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 readMaturityScorecardWorkflow() {
|
|
return parse(readFileSync(MATURITY_SCORECARD_WORKFLOW, "utf8"));
|
|
}
|
|
|
|
function runMaturityInvocationScenario(options: {
|
|
callerEventName: string;
|
|
callerWorkflowRef: string;
|
|
jobWorkflowRef?: string;
|
|
publishPullRequest: boolean;
|
|
}) {
|
|
const workflow = readMaturityScorecardWorkflow();
|
|
const authorizeStep = workflow.jobs.validate_selected_ref.steps.find(
|
|
(step: { name?: string }) => step.name === "Authorize workflow invocation",
|
|
);
|
|
const authorizeRun = spawnSync("bash", ["-c", authorizeStep.run], {
|
|
encoding: "utf8",
|
|
env: {
|
|
CALLER_EVENT_NAME: options.callerEventName,
|
|
CALLER_WORKFLOW_REF: options.callerWorkflowRef,
|
|
JOB_WORKFLOW_FILE_PATH: MATURITY_SCORECARD_WORKFLOW,
|
|
JOB_WORKFLOW_REF: options.jobWorkflowRef ?? MATURITY_SCORECARD_WORKFLOW_REF,
|
|
JOB_WORKFLOW_REPOSITORY: "openclaw/openclaw",
|
|
PATH: process.env.PATH ?? "",
|
|
PUBLISH_PULL_REQUEST: String(options.publishPullRequest),
|
|
},
|
|
});
|
|
return {
|
|
output: `${authorizeRun.stdout}${authorizeRun.stderr}`,
|
|
status: authorizeRun.status,
|
|
};
|
|
}
|
|
|
|
function runMaturityArtifactCopyScenario(
|
|
options: { destinationSymlink?: boolean; extraFile?: boolean; sourceSymlink?: boolean } = {},
|
|
) {
|
|
const workflow = readMaturityScorecardWorkflow();
|
|
const copyStep = workflow.jobs.publish_generated_pr.steps.find(
|
|
(step: { name?: string }) => step.name === "Validate and copy generated PR files",
|
|
);
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-maturity-copy-"));
|
|
const staging = path.join(root, "staging");
|
|
try {
|
|
for (const generatedPath of MATURITY_GENERATED_PR_PATHS) {
|
|
const staged = path.join(staging, generatedPath);
|
|
const selected = path.join(root, "selected", generatedPath);
|
|
mkdirSync(path.dirname(staged), { recursive: true });
|
|
mkdirSync(path.dirname(selected), { recursive: true });
|
|
writeFileSync(staged, `new ${generatedPath}\n`, "utf8");
|
|
writeFileSync(selected, `old ${generatedPath}\n`, "utf8");
|
|
}
|
|
if (options.extraFile) {
|
|
writeFileSync(path.join(staging, "unexpected.txt"), "unexpected\n", "utf8");
|
|
}
|
|
const firstGeneratedPath = expectDefined(
|
|
MATURITY_GENERATED_PR_PATHS[0],
|
|
"first maturity generated PR path",
|
|
);
|
|
if (options.sourceSymlink) {
|
|
const staged = path.join(staging, firstGeneratedPath);
|
|
rmSync(staged);
|
|
symlinkSync("missing-score-source", staged);
|
|
}
|
|
const escaped = path.join(root, "escaped.txt");
|
|
if (options.destinationSymlink) {
|
|
const selected = path.join(root, "selected", firstGeneratedPath);
|
|
writeFileSync(escaped, "outside\n", "utf8");
|
|
rmSync(selected);
|
|
symlinkSync(escaped, selected);
|
|
}
|
|
const run = spawnSync("bash", ["-c", copyStep.run], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: { PATH: process.env.PATH ?? "", STAGING_DIR: staging },
|
|
});
|
|
return {
|
|
copied: MATURITY_GENERATED_PR_PATHS.map((generatedPath) =>
|
|
readFileSync(path.join(root, "selected", generatedPath), "utf8"),
|
|
),
|
|
escaped: existsSync(escaped) ? readFileSync(escaped, "utf8") : "",
|
|
output: `${run.stdout}${run.stderr}`,
|
|
status: run.status,
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function readQaProfileEvidenceWorkflow() {
|
|
return parse(readFileSync(".github/workflows/qa-profile-evidence.yml", "utf8"));
|
|
}
|
|
|
|
type QaProfileTimeoutFixtureMode =
|
|
| "natural-124"
|
|
| "self-kill"
|
|
| "supervisor-term"
|
|
| "term"
|
|
| "kill";
|
|
|
|
function runQaProfileTimeoutFixture(mode: QaProfileTimeoutFixtureMode) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-qa-profile-timeout-"));
|
|
try {
|
|
const binDir = path.join(root, "bin");
|
|
mkdirSync(binDir);
|
|
const fakePnpm = path.join(binDir, "pnpm");
|
|
writeFileSync(
|
|
fakePnpm,
|
|
`#!/usr/bin/env bash
|
|
set -u
|
|
echo "child-stderr-sentinel:\${FAKE_PNPM_MODE}" >&2
|
|
echo "child-locale:\${LC_ALL-unset}" >&2
|
|
case "\${FAKE_PNPM_MODE}" in
|
|
natural-124)
|
|
echo "timeout: sending signal KILL to command 'spoofed-child'" >&2
|
|
exit 124
|
|
;;
|
|
self-kill)
|
|
kill -KILL "$$"
|
|
;;
|
|
supervisor-term)
|
|
: > "$SUPERVISOR_READY_FILE"
|
|
while :; do sleep 0.01; done
|
|
;;
|
|
term)
|
|
trap 'exit 0' TERM
|
|
while :; do sleep 0.01; done
|
|
;;
|
|
kill)
|
|
trap '' TERM
|
|
while :; do sleep 0.01 || true; done
|
|
;;
|
|
esac
|
|
`,
|
|
"utf8",
|
|
);
|
|
chmodSync(fakePnpm, 0o755);
|
|
const fixturePath = `${binDir}:${process.env.PATH ?? ""}`;
|
|
const timeoutVersion = spawnSync("timeout", ["--version"], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, PATH: fixturePath },
|
|
});
|
|
if (timeoutVersion.status !== 0 || !timeoutVersion.stdout.includes("(GNU coreutils)")) {
|
|
throw new Error(
|
|
`QA timeout fixture requires GNU timeout: ${timeoutVersion.stdout}${timeoutVersion.stderr}`,
|
|
);
|
|
}
|
|
|
|
const workflow = readQaProfileEvidenceWorkflow();
|
|
const runProfileStep = expectDefined(
|
|
workflow.jobs.run_qa_profile.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run QA profile",
|
|
),
|
|
"Run QA profile step",
|
|
);
|
|
let script = runProfileStep.run
|
|
.replace("--kill-after=30s 110m", "--kill-after=0.5s 1s")
|
|
.replaceAll("110 minutes", "1 second")
|
|
.replaceAll("30-second", "0.5-second");
|
|
if (mode === "supervisor-term") {
|
|
const timeoutCommandTail = ' 3>&2 2>"$timeout_supervisor_fifo" || qa_exit_code=$?';
|
|
const signaledTimeoutCommandTail = ` 3>&2 2>"$timeout_supervisor_fifo" &
|
|
timeout_supervisor_pid=$!
|
|
(
|
|
for _ in {1..100}; do
|
|
[[ -e "$SUPERVISOR_READY_FILE" ]] && break
|
|
sleep 0.01
|
|
done
|
|
[[ -e "$SUPERVISOR_READY_FILE" ]]
|
|
kill -TERM "$timeout_supervisor_pid"
|
|
) &
|
|
timeout_signaler_pid=$!
|
|
wait "$timeout_supervisor_pid" || qa_exit_code=$?
|
|
wait "$timeout_signaler_pid"`;
|
|
const signaledScript = script.replace(timeoutCommandTail, signaledTimeoutCommandTail);
|
|
if (signaledScript === script) {
|
|
throw new Error("QA timeout fixture could not instrument the timeout supervisor");
|
|
}
|
|
script = signaledScript;
|
|
}
|
|
const timeoutSupervisorCapture = path.join(root, "timeout-supervisor.log");
|
|
const timeoutClassificationStart = `supervisor_tee_pid=""
|
|
|
|
timeout_outcome="none"`;
|
|
// Bash writes killed-job diagnostics outside timeout's redirected stream. Capture the
|
|
// authoritative supervisor log before the workflow's EXIT trap removes it.
|
|
const capturedScript = script.replace(
|
|
timeoutClassificationStart,
|
|
`supervisor_tee_pid=""
|
|
cp "$timeout_supervisor_log" "$TIMEOUT_SUPERVISOR_CAPTURE"
|
|
|
|
timeout_outcome="none"`,
|
|
);
|
|
if (capturedScript === script) {
|
|
throw new Error("QA timeout fixture could not capture the timeout supervisor log");
|
|
}
|
|
script = capturedScript;
|
|
const githubOutput = path.join(root, "github-output");
|
|
const run = runWorkflowShellScript(script, {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
FAKE_PNPM_MODE: mode,
|
|
GITHUB_OUTPUT: githubOutput,
|
|
GITHUB_RUN_ATTEMPT: "1",
|
|
GITHUB_RUN_ID: "42",
|
|
LC_ALL: "POSIX",
|
|
PATH: fixturePath,
|
|
QA_PROFILE: "all",
|
|
REQUESTED_REF: "fixture",
|
|
SUPERVISOR_READY_FILE: path.join(root, "supervisor-ready"),
|
|
TARGET_SHA: "a".repeat(40),
|
|
TIMEOUT_SUPERVISOR_CAPTURE: timeoutSupervisorCapture,
|
|
},
|
|
});
|
|
const outputDir = path.join(root, ".artifacts", "qa-e2e", "profile-all-42-1");
|
|
const status = JSON.parse(
|
|
readFileSync(path.join(outputDir, "qa-profile-run-status.json"), "utf8"),
|
|
) as {
|
|
exitCode: number;
|
|
timedOut: boolean;
|
|
timeoutOutcome: "none" | "term" | "kill";
|
|
};
|
|
return {
|
|
commandStatus: run.status,
|
|
githubOutput: readFileSync(githubOutput, "utf8"),
|
|
status,
|
|
stderr: run.stderr,
|
|
stdout: run.stdout,
|
|
timeoutSupervisorLog: readFileSync(timeoutSupervisorCapture, "utf8"),
|
|
timeoutVersion: timeoutVersion.stdout.trim(),
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runQaProfileFailureGate(options: {
|
|
allowFailures: boolean;
|
|
evidenceValidated?: boolean;
|
|
qaExitCode?: string;
|
|
}) {
|
|
const workflow = readQaProfileEvidenceWorkflow();
|
|
const failStep = workflow.jobs.run_qa_profile.steps.find(
|
|
(step: WorkflowStep) => step.name === "Fail if QA profile failed",
|
|
);
|
|
return spawnSync("bash", ["-c", failStep.run], {
|
|
encoding: "utf8",
|
|
env: {
|
|
ALLOW_FAILURES: String(options.allowFailures),
|
|
EVIDENCE_VALIDATED: String(options.evidenceValidated ?? true),
|
|
PATH: process.env.PATH ?? "",
|
|
QA_EXIT_CODE: options.qaExitCode ?? "",
|
|
QA_PROFILE: "all",
|
|
},
|
|
});
|
|
}
|
|
|
|
function readReleaseChecksWorkflow() {
|
|
return parse(readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8"));
|
|
}
|
|
|
|
function readCriticalQualityWorkflow() {
|
|
return readFileSync(".github/workflows/codeql-critical-quality.yml", "utf8");
|
|
}
|
|
|
|
function readWorkflow(filePath: string) {
|
|
return parse(readFileSync(filePath, "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 readTrackedText(relativePath: string): string {
|
|
if (existsSync(relativePath)) {
|
|
return readFileSync(relativePath, "utf8");
|
|
}
|
|
return execFileSync("git", ["show", `:${relativePath}`], { encoding: "utf8" });
|
|
}
|
|
|
|
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://") ||
|
|
OIDC_BOUND_MAIN_REUSABLE_WORKFLOWS.has(uses)
|
|
) {
|
|
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 runGit(cwd: string, args: string[]): string {
|
|
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function writeExecutable(filePath: string, lines: string[]): void {
|
|
writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8");
|
|
chmodSync(filePath, 0o755);
|
|
}
|
|
|
|
function runDependencyCheckFixture(options: { historicalTarget: boolean; scripts: string[] }): {
|
|
calls: string[];
|
|
output: string;
|
|
status: number | null;
|
|
} {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-ci-deadcode-"));
|
|
try {
|
|
const fakeBin = path.join(root, "bin");
|
|
const callsPath = path.join(root, "pnpm-calls.txt");
|
|
mkdirSync(fakeBin);
|
|
writeFileSync(
|
|
path.join(root, "package.json"),
|
|
`${JSON.stringify({
|
|
scripts: Object.fromEntries(options.scripts.map((name) => [name, "true"])),
|
|
})}\n`,
|
|
);
|
|
writeExecutable(path.join(fakeBin, "pnpm"), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'printf "%s\\n" "$*" >> "$PNPM_CALLS"',
|
|
]);
|
|
const checkShardRun = readCiWorkflow().jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
).run;
|
|
const run = spawnSync("bash", ["-c", checkShardRun], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
FORMAT_CHECK: "false",
|
|
HISTORICAL_TARGET: options.historicalTarget ? "true" : "false",
|
|
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
|
PNPM_CALLS: callsPath,
|
|
PR_BASE_SHA: "",
|
|
TASK: "dependencies",
|
|
},
|
|
});
|
|
return {
|
|
calls: existsSync(callsPath)
|
|
? readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean)
|
|
: [],
|
|
output: `${run.stdout}${run.stderr}`,
|
|
status: run.status,
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runControlUiI18nSourceFixture(options: {
|
|
compatibilityTarget: boolean;
|
|
hasVerifyScript: boolean;
|
|
}): { calls: string[]; output: string; summary: string; status: number | null } {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-ci-control-ui-i18n-"));
|
|
try {
|
|
const fakeBin = path.join(root, "bin");
|
|
const callsPath = path.join(root, "pnpm-calls.txt");
|
|
const summaryPath = path.join(root, "summary.md");
|
|
mkdirSync(fakeBin);
|
|
writeFileSync(
|
|
path.join(root, "package.json"),
|
|
`${JSON.stringify({
|
|
scripts: options.hasVerifyScript ? { "ui:i18n:verify": "true" } : {},
|
|
})}\n`,
|
|
);
|
|
writeExecutable(path.join(fakeBin, "pnpm"), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'printf "%s\\n" "$*" >> "$PNPM_CALLS"',
|
|
]);
|
|
const sourceStep = readCiWorkflow().jobs["control-ui-i18n"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify Control UI i18n source",
|
|
);
|
|
const run = spawnSync("bash", ["-c", sourceStep.run], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
COMPATIBILITY_TARGET: options.compatibilityTarget ? "true" : "false",
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
|
PNPM_CALLS: callsPath,
|
|
},
|
|
});
|
|
return {
|
|
calls: existsSync(callsPath)
|
|
? readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean)
|
|
: [],
|
|
output: `${run.stdout}${run.stderr}`,
|
|
status: run.status,
|
|
summary: existsSync(summaryPath) ? readFileSync(summaryPath, "utf8") : "",
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function runGeneratedPublisherScenario(
|
|
baseChangePath: "a" | "b" | null,
|
|
options: {
|
|
autoMerge?: boolean;
|
|
existingAutoMergeMethod?: "MERGE" | "REBASE" | "SQUASH";
|
|
existingPr?: boolean;
|
|
expectFailure?: boolean;
|
|
failGeneratedPush?: boolean;
|
|
mergeGeneratedPush?: boolean;
|
|
noGeneratedChange?: boolean;
|
|
overlapPolicy?: string;
|
|
stalePrHeadOnce?: boolean;
|
|
stalePrViewHeadOnce?: boolean;
|
|
updateSource?: boolean;
|
|
} = {},
|
|
) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-generated-pr-"));
|
|
try {
|
|
const origin = path.join(root, "origin.git");
|
|
const updater = path.join(root, "updater");
|
|
const worktree = path.join(root, "worktree");
|
|
const generatedDir = path.join(worktree, "generated");
|
|
const sourceDir = path.join(worktree, "source");
|
|
const fakeBin = path.join(root, "bin");
|
|
const runnerTemp = path.join(root, "runner-temp");
|
|
const prState = path.join(root, "pr-open");
|
|
const mergeCalls = path.join(root, "merge-calls");
|
|
const stalePrHeadOnce = path.join(root, "stale-pr-head-once");
|
|
const stalePrViewHeadOnce = path.join(root, "stale-pr-view-head-once");
|
|
const summary = path.join(root, "summary.md");
|
|
|
|
mkdirSync(generatedDir, { recursive: true });
|
|
mkdirSync(sourceDir);
|
|
mkdirSync(fakeBin);
|
|
mkdirSync(runnerTemp);
|
|
writeFileSync(summary, "", "utf8");
|
|
if (options.stalePrHeadOnce) {
|
|
writeFileSync(stalePrHeadOnce, "", "utf8");
|
|
}
|
|
if (options.stalePrViewHeadOnce) {
|
|
writeFileSync(stalePrViewHeadOnce, "", "utf8");
|
|
}
|
|
runGit(root, ["init", "--bare", origin]);
|
|
runGit(root, ["init", "--initial-branch=main", worktree]);
|
|
runGit(worktree, ["config", "user.name", "Test Publisher"]);
|
|
runGit(worktree, ["config", "user.email", "publisher@example.com"]);
|
|
writeFileSync(path.join(generatedDir, "a.txt"), "old-a\n", "utf8");
|
|
writeFileSync(path.join(generatedDir, "b.txt"), "old-b\n", "utf8");
|
|
writeFileSync(path.join(sourceDir, "input.txt"), "old-input\n", "utf8");
|
|
runGit(worktree, ["add", "generated", "source"]);
|
|
runGit(worktree, ["commit", "-m", "base"]);
|
|
runGit(worktree, ["remote", "add", "origin", origin]);
|
|
runGit(worktree, ["push", "-u", "origin", "main"]);
|
|
runGit(root, ["--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main"]);
|
|
if (options.existingPr) {
|
|
runGit(worktree, ["switch", "-c", "automation/locale"]);
|
|
writeFileSync(path.join(generatedDir, "a.txt"), "stale-pr-a\n", "utf8");
|
|
runGit(worktree, ["add", "generated"]);
|
|
runGit(worktree, ["commit", "-m", "stale generated pull request"]);
|
|
runGit(worktree, ["push", "-u", "origin", "automation/locale"]);
|
|
writeFileSync(prState, "", "utf8");
|
|
runGit(worktree, ["switch", "main"]);
|
|
}
|
|
runGit(root, ["clone", "--branch", "main", origin, updater]);
|
|
runGit(updater, ["config", "user.name", "Base Updater"]);
|
|
runGit(updater, ["config", "user.email", "updater@example.com"]);
|
|
if (baseChangePath !== null) {
|
|
writeFileSync(
|
|
path.join(updater, "generated", `${baseChangePath}.txt`),
|
|
`newer-${baseChangePath}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
if (options.updateSource) {
|
|
writeFileSync(path.join(updater, "source", "input.txt"), "newer-input\n", "utf8");
|
|
}
|
|
if (baseChangePath !== null || options.updateSource) {
|
|
runGit(updater, ["add", "generated", "source"]);
|
|
runGit(updater, ["commit", "-m", "update base"]);
|
|
runGit(updater, ["push", "origin", "main"]);
|
|
}
|
|
if (!options.noGeneratedChange) {
|
|
writeFileSync(path.join(generatedDir, "a.txt"), "desired-a\n", "utf8");
|
|
}
|
|
if (options.failGeneratedPush) {
|
|
writeExecutable(path.join(origin, "hooks", "pre-receive"), [
|
|
"#!/bin/sh",
|
|
'rm -f "$0"',
|
|
"exit 1",
|
|
]);
|
|
}
|
|
if (options.mergeGeneratedPush) {
|
|
writeExecutable(path.join(origin, "hooks", "post-receive"), [
|
|
"#!/bin/sh",
|
|
"while read -r old_head new_head ref; do",
|
|
' if [ "$ref" = "refs/heads/automation/locale" ]; then',
|
|
' git update-ref refs/heads/main "$new_head"',
|
|
' git update-ref -d refs/heads/automation/locale "$new_head"',
|
|
" fi",
|
|
"done",
|
|
]);
|
|
}
|
|
|
|
writeExecutable(path.join(fakeBin, "timeout"), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'while [[ "$#" -gt 0 ]]; do',
|
|
' case "$1" in',
|
|
" --signal=*|--kill-after=*) shift ;;",
|
|
" [0-9]*s) shift; break ;;",
|
|
" *) break ;;",
|
|
" esac",
|
|
"done",
|
|
'exec "$@"',
|
|
]);
|
|
writeExecutable(path.join(fakeBin, "gh"), [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
'case "${1-}:${2-}" in',
|
|
" auth:setup-git) exit 0 ;;",
|
|
" api:*)",
|
|
' if [[ -f "$FAKE_PR_STATE" ]]; then',
|
|
' if [[ -f "$FAKE_STALE_HEAD_ONCE" ]]; then',
|
|
' head="0000000000000000000000000000000000000000"',
|
|
' rm -f "$FAKE_STALE_HEAD_ONCE"',
|
|
" else",
|
|
' head="$(git --git-dir="$FAKE_ORIGIN" rev-parse refs/heads/automation/locale)"',
|
|
" fi",
|
|
' printf "https://github.com/openclaw/openclaw/pull/1\\t%s\\n" "$head"',
|
|
" fi",
|
|
" ;;",
|
|
" pr:create)",
|
|
' : > "$FAKE_PR_STATE"',
|
|
' printf "%s\\n" "https://github.com/openclaw/openclaw/pull/1"',
|
|
" ;;",
|
|
" pr:edit) exit 0 ;;",
|
|
" pr:view)",
|
|
' [[ -n "${GH_TOKEN:-}" ]]',
|
|
' [[ -f "$FAKE_PR_STATE" ]]',
|
|
' if [[ -f "$FAKE_STALE_PR_VIEW_HEAD_ONCE" ]]; then',
|
|
' head="0000000000000000000000000000000000000000"',
|
|
' rm -f "$FAKE_STALE_PR_VIEW_HEAD_ONCE"',
|
|
" else",
|
|
' head="$(git --git-dir="$FAKE_ORIGIN" rev-parse refs/heads/automation/locale)"',
|
|
" fi",
|
|
' printf "%s\\t%s\\n" "$head" "$FAKE_AUTO_MERGE_METHOD"',
|
|
" ;;",
|
|
" pr:merge)",
|
|
' [[ "$GH_TOKEN" == "test-token" ]]',
|
|
' printf "%s\\n" "$*" >> "$FAKE_MERGE_CALLS"',
|
|
" ;;",
|
|
' *) printf "unexpected gh call: %s\\n" "$*" >&2; exit 2 ;;',
|
|
"esac",
|
|
]);
|
|
|
|
const action = parse(readFileSync(PUBLISH_GENERATED_PR_ACTION, "utf8"));
|
|
const publishRun = action.runs.steps.find(
|
|
(step: { name?: string }) => step.name === "Publish generated pull request",
|
|
).run;
|
|
const publish = spawnSync("bash", ["-c", publishRun], {
|
|
cwd: worktree,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
BASE_BRANCH: "main",
|
|
COMMIT_MESSAGE: "chore(test): refresh generated output",
|
|
AUTO_MERGE: String(options.autoMerge ?? false),
|
|
FAKE_AUTO_MERGE_METHOD: options.existingAutoMergeMethod ?? "",
|
|
FAKE_ORIGIN: origin,
|
|
FAKE_MERGE_CALLS: mergeCalls,
|
|
FAKE_PR_STATE: prState,
|
|
FAKE_STALE_HEAD_ONCE: stalePrHeadOnce,
|
|
FAKE_STALE_PR_VIEW_HEAD_ONCE: stalePrViewHeadOnce,
|
|
GENERATED_PATHS: "generated",
|
|
INVALIDATION_PATHS: "source",
|
|
OVERLAP_POLICY: options.overlapPolicy ?? "defer",
|
|
CONTENTS_TOKEN: "contents-token",
|
|
GH_TOKEN: "test-token",
|
|
GITHUB_REPOSITORY: "openclaw/openclaw",
|
|
GITHUB_REPOSITORY_OWNER: "openclaw",
|
|
GITHUB_STEP_SUMMARY: summary,
|
|
HEAD_BRANCH: "automation/locale",
|
|
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
|
PR_BODY: "Generated test body",
|
|
PR_TITLE: "chore(test): refresh generated output",
|
|
RUNNER_TEMP: runnerTemp,
|
|
},
|
|
});
|
|
const publishOutput = `${publish.stdout}${publish.stderr}`;
|
|
if (options.expectFailure ? publish.status === 0 : publish.status !== 0) {
|
|
throw new Error(
|
|
`generated publisher exited ${String(publish.status)} (expected ${options.expectFailure ? "failure" : "success"}):\n${publishOutput}`,
|
|
);
|
|
}
|
|
const authHeader = spawnSync(
|
|
"git",
|
|
["config", "--local", "--get-all", "http.https://github.com/.extraheader"],
|
|
{ cwd: worktree, encoding: "utf8" },
|
|
);
|
|
if (authHeader.status !== 1 || authHeader.stdout.trim() !== "") {
|
|
throw new Error("generated publisher left its Git authorization header configured");
|
|
}
|
|
|
|
const branchRef = "refs/heads/automation/locale";
|
|
const branchExists =
|
|
spawnSync("git", ["--git-dir", origin, "show-ref", "--verify", branchRef]).status === 0;
|
|
const branchHead = branchExists
|
|
? runGit(root, ["--git-dir", origin, "rev-parse", branchRef])
|
|
: "";
|
|
return {
|
|
branchExists,
|
|
branchHead,
|
|
generatedA: branchExists
|
|
? runGit(root, ["--git-dir", origin, "show", `${branchRef}:generated/a.txt`])
|
|
: "",
|
|
generatedB: branchExists
|
|
? runGit(root, ["--git-dir", origin, "show", `${branchRef}:generated/b.txt`])
|
|
: "",
|
|
mainGeneratedA: runGit(root, [
|
|
"--git-dir",
|
|
origin,
|
|
"show",
|
|
"refs/heads/main:generated/a.txt",
|
|
]),
|
|
mainHead: runGit(root, ["--git-dir", origin, "rev-parse", "refs/heads/main"]),
|
|
mergeCalls: existsSync(mergeCalls) ? readFileSync(mergeCalls, "utf8") : "",
|
|
publishOutput,
|
|
summary: readFileSync(summary, "utf8"),
|
|
};
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
describe("ci workflow guards", () => {
|
|
it("extracts module heredocs only at exact closing marker lines", () => {
|
|
const run = runWorkflowShellScript(
|
|
`node --input-type=module <<'NODE'
|
|
NODE_prefix: for (const value of ["heredoc-body-preserved"]) {
|
|
console.log(value);
|
|
break NODE_prefix;
|
|
}
|
|
NODE
|
|
`,
|
|
{},
|
|
);
|
|
|
|
expect(run.status, run.stderr).toBe(0);
|
|
expect(run.stdout).toBe("heredoc-body-preserved\n");
|
|
});
|
|
|
|
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: [],
|
|
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,
|
|
);
|
|
});
|
|
|
|
it("runs the PR context and evidence gate only for relevant PR changes", () => {
|
|
const workflow = readRealBehaviorProofWorkflow();
|
|
|
|
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.group).toBe(
|
|
"${{ github.workflow }}-${{ github.event.pull_request.number }}",
|
|
);
|
|
expect(workflow.concurrency["cancel-in-progress"]).toBe(
|
|
"${{ github.event.action == 'synchronize' }}",
|
|
);
|
|
});
|
|
|
|
it("isolates auto-response per item and ignores ClawSweeper PR label feedback", () => {
|
|
const workflow = readWorkflow(".github/workflows/auto-response.yml");
|
|
const guard = workflow.jobs["auto-response"].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.group).toBe(
|
|
"${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}",
|
|
);
|
|
expect(workflow.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(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(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;
|
|
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" });
|
|
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
|
|
"OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}",
|
|
);
|
|
|
|
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(runsOn, `${jobName} must use GitHub-hosted capacity for release gates`).toContain(
|
|
"github.event_name == 'workflow_dispatch'",
|
|
);
|
|
}
|
|
|
|
for (const jobName of ["macos-node", "macos-swift", "ios-build"]) {
|
|
expect(
|
|
workflow.jobs[jobName]["runs-on"],
|
|
`${jobName} retries must escape stalled Blacksmith macOS capacity`,
|
|
).toContain("github.run_attempt > 1");
|
|
}
|
|
});
|
|
|
|
it("keeps Testbox pull request validation off leased runner capacity", () => {
|
|
const workflow = readTestboxWorkflow();
|
|
|
|
expect(workflow.on.pull_request).toEqual({
|
|
types: ["opened", "reopened", "synchronize", "ready_for_review"],
|
|
paths: [".github/workflows/**"],
|
|
});
|
|
expect(workflow.jobs.check.if).toBe(
|
|
"${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}",
|
|
);
|
|
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).toEqual({
|
|
types: ["opened", "reopened", "synchronize", "ready_for_review"],
|
|
paths: [".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("forbids moving reusable workflow references", () => {
|
|
expect([...OIDC_BOUND_MAIN_REUSABLE_WORKFLOWS]).toEqual([]);
|
|
});
|
|
|
|
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 native locale artifact",
|
|
);
|
|
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 control UI locale files",
|
|
);
|
|
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(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).toEqual(
|
|
SUPPORTED_LOCALES.filter((locale) => locale !== "en"),
|
|
);
|
|
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");
|
|
expect(nativeResolveBase.if).not.toContain("chore(i18n): refresh native 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",
|
|
});
|
|
expect(workflow.on.workflow_dispatch?.inputs).toBeUndefined();
|
|
expect(workflow.on.push.paths).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");
|
|
expect(workflow.on.push.paths).toContain("apps/android/app/src/play/**");
|
|
expect(workflow.on.push.paths).toContain("apps/android/app/src/thirdParty/**");
|
|
expect(workflow.on.push.paths).toContain("apps/android/wear/src/main/**");
|
|
expect(workflow.on.push.paths).toContain("scripts/android-app-i18n.ts");
|
|
expect(workflow.on.push.paths).toContain("scripts/apple-app-i18n.ts");
|
|
expect(refreshStep.run).toContain("run_refresh anthropic");
|
|
expect(refreshStep.run).toContain("retrying with OpenAI");
|
|
expect(refreshStep.run).toContain("run_openai_refresh");
|
|
expect(refreshStep.run).toContain("repository OpenAI key");
|
|
expect(refreshStep.env.OPENCLAW_DOCS_I18N_OPENAI_API_KEY).toBe(
|
|
"${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}",
|
|
);
|
|
expect(refreshStep.env.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
|
|
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("scripts/android-app-i18n.ts");
|
|
expect(nativePublishStep.with["invalidation-paths"]).toContain("scripts/apple-app-i18n.ts");
|
|
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.run).toContain("run_refresh anthropic");
|
|
expect(controlUiRefreshStep.run).toContain("retrying with OpenAI");
|
|
expect(controlUiRefreshStep.run).toContain("run_openai_refresh");
|
|
expect(controlUiRefreshStep.run).toContain("repository OpenAI key");
|
|
expect(controlUiRefreshStep.env.OPENCLAW_DOCS_I18N_OPENAI_API_KEY).toBe(
|
|
"${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}",
|
|
);
|
|
expect(controlUiRefreshStep.env.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
|
|
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",
|
|
);
|
|
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-catalog.ts",
|
|
);
|
|
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 }}");
|
|
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('sha="${WORKFLOW_SHA}"');
|
|
|
|
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["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 }}");
|
|
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).toContain(
|
|
'git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic ${git_auth}"',
|
|
);
|
|
expect(actionPublishStep.run).toContain("printf '::add-mask::%s\\n' \"${git_auth}\"");
|
|
expect(actionPublishStep.run).toContain(
|
|
"git config --local --unset-all http.https://github.com/.extraheader",
|
|
);
|
|
expect(actionPublishStep.run).toContain("trap cleanup_git_auth EXIT");
|
|
expect(actionPublishStep.run).not.toContain("gh auth setup-git");
|
|
expect(actionPublishStep.run).toContain("timeout --signal=TERM --kill-after=10s 120s");
|
|
expect(actionPublishStep.run).toContain("--force-with-lease=refs/heads/");
|
|
expect(actionPublishStep.run).toContain(
|
|
"GH013|repository rule violations|required status check",
|
|
);
|
|
expect(actionPublishStep.run).toContain("refusing a doomed retry");
|
|
expect(actionPublishStep.run).toContain("branch_was_deleted");
|
|
expect(actionPublishStep.run).toContain(
|
|
'[[ -n "${remote_head}" && -z "${current_remote_head}" ]]',
|
|
);
|
|
expect(actionPublishStep.run).toContain('push_generated_branch ""');
|
|
expect(actionPublishStep.run).toContain(
|
|
"overlap policy decides whether stale output defers or fails",
|
|
);
|
|
expect(actionPublishStep.run).toContain(
|
|
'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls"',
|
|
);
|
|
expect(actionPublishStep.run).toContain('-f "head=${GITHUB_REPOSITORY_OWNER}:${HEAD_BRANCH}"');
|
|
expect(actionPublishStep.run).toContain(".head.repo.full_name == env.GITHUB_REPOSITORY");
|
|
expect(actionPublishStep.run).toContain(".head.ref == env.HEAD_BRANCH");
|
|
expect(actionPublishStep.run).toContain(".head.sha");
|
|
expect(actionPublishStep.run).not.toContain("gh pr list");
|
|
expect(actionPublishStep.run).toContain("neutralize_stale_pr");
|
|
expect(actionPublishStep.run).toContain(
|
|
'git diff --quiet "${source_commit}" "${base_ref}" -- "${invalidation_paths[@]}"',
|
|
);
|
|
expect(actionPublishStep.run).not.toContain("force_retirement");
|
|
expect(actionPublishStep.run).toContain("unsafe close mutation");
|
|
expect(actionPublishStep.run).not.toContain("gh pr close");
|
|
expect(actionPublishStep.run).toContain('source_commit="$(git rev-parse HEAD)"');
|
|
expect(actionPublishStep.run).toContain(
|
|
'git merge-base --is-ancestor "${source_commit}" "${base_ref}"',
|
|
);
|
|
expect(actionPublishStep.run).toContain("Snapshot the generator's desired blobs");
|
|
expect(actionPublishStep.run).toContain(
|
|
'git diff --name-only -z --no-renames "${source_commit}" "${desired_commit}"',
|
|
);
|
|
expect(actionPublishStep.run).toContain(
|
|
'[[ "${source_entry}" != "${base_entry}" && "${desired_entry}" != "${base_entry}" ]]',
|
|
);
|
|
expect(actionPublishStep.run).toContain('git switch -C "${HEAD_BRANCH}" "${base_ref}"');
|
|
expect(actionPublishStep.run).toContain(
|
|
'git restore --source="${desired_commit}" --staged --worktree -- "${path}"',
|
|
);
|
|
expect(actionPublishStep.run).not.toContain("git rebase");
|
|
expect(actionPublishStep.run).toContain("verify_publication");
|
|
expect(actionPublishStep.run).toContain("desired_matches_tree");
|
|
expect(actionPublishStep.run).toContain(
|
|
'[[ "${current_remote_head}" != "${published_commit}" ]]',
|
|
);
|
|
expect(actionPublishStep.run).toContain('[[ "${final_pr_head}" != "${published_commit}" ]]');
|
|
expect(actionPublishStep.run).toContain("gh pr edit");
|
|
expect(actionPublishStep.run).toContain("gh pr create");
|
|
expect(actionPublishStep.run).toContain('--base "${BASE_BRANCH}"');
|
|
expect(actionPublishStep.run).toContain('--head "${HEAD_BRANCH}"');
|
|
expect(actionPublishStep.run).toContain('--body-file "${body_file}"');
|
|
expect(actionPublishStep.run).toContain("ensure_auto_merge_compatible");
|
|
expect(actionPublishStep.run).toContain("enable_auto_merge");
|
|
expect(actionPublishStep.run).not.toContain("disable_existing_auto_merge");
|
|
expect(actionPublishStep.run).not.toContain("--disable-auto");
|
|
expect(actionPublishStep.run).toContain("--json autoMergeRequest");
|
|
expect(actionPublishStep.run).not.toContain('GH_TOKEN="${CONTENTS_TOKEN}"');
|
|
expect(actionPublishStep.run).toContain(
|
|
'--auto --squash --match-head-commit "${published_commit}"',
|
|
);
|
|
expect(actionPublishStep.run).not.toContain('HEAD:"${BASE_BRANCH}"');
|
|
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 instead of overwriting a newer overlapping generated path",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario("a");
|
|
|
|
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")(
|
|
"retries a stale pull request head read after the branch push",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, { stalePrHeadOnce: true });
|
|
|
|
expect(result.branchExists).toBe(true);
|
|
expect(result.generatedA).toBe("desired-a");
|
|
expect(result.summary).toContain("https://github.com/openclaw/openclaw/pull/1");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"defers stale generator inputs and neutralizes an existing pull request",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario(null, {
|
|
existingPr: true,
|
|
updateSource: true,
|
|
});
|
|
|
|
expect(result.branchHead).toBe(result.mainHead);
|
|
expect(result.generatedA).toBe("old-a");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because generator inputs changed on main.",
|
|
);
|
|
expect(result.summary).toContain("Neutralized stale generated pull request");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"neutralizes an existing pull request when generation has no changes",
|
|
() => {
|
|
const result = runGeneratedPublisherScenario("b", {
|
|
existingPr: true,
|
|
noGeneratedChange: true,
|
|
});
|
|
|
|
expect(result.branchHead).toBe(result.mainHead);
|
|
expect(result.generatedA).toBe("old-a");
|
|
expect(result.generatedB).toBe("newer-b");
|
|
expect(result.summary).toContain(
|
|
"Deferred stale generated output because owned generated paths changed on main.",
|
|
);
|
|
expect(result.summary).toContain("Neutralized stale generated pull request");
|
|
},
|
|
);
|
|
|
|
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.mainHead);
|
|
expect(stalePr.summary).toContain("Neutralized stale generated pull request");
|
|
expect(stalePr.publishOutput).toContain(
|
|
"::error::Refusing stale generated output because generator inputs changed on main.",
|
|
);
|
|
|
|
const noPr = runGeneratedPublisherScenario(null, {
|
|
expectFailure: true,
|
|
noGeneratedChange: true,
|
|
overlapPolicy: "fail",
|
|
updateSource: true,
|
|
});
|
|
expect(noPr.branchExists).toBe(false);
|
|
expect(noPr.publishOutput).toContain(
|
|
"::error::Refusing stale generated output because generator inputs changed on main.",
|
|
);
|
|
|
|
const unchangedOverlap = runGeneratedPublisherScenario("b", {
|
|
expectFailure: true,
|
|
noGeneratedChange: true,
|
|
overlapPolicy: "fail",
|
|
});
|
|
expect(unchangedOverlap.branchExists).toBe(false);
|
|
expect(unchangedOverlap.publishOutput).toContain(
|
|
"::error::Refusing stale generated output because owned generated paths changed on main.",
|
|
);
|
|
|
|
const invalidPolicy = runGeneratedPublisherScenario("b", {
|
|
expectFailure: true,
|
|
overlapPolicy: "continue",
|
|
});
|
|
expect(invalidPolicy.branchExists).toBe(false);
|
|
expect(invalidPolicy.publishOutput).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.25.0",
|
|
OPENGREP_LINUX_X64_SHA256:
|
|
"9ac4aebb47ba3f7b0d8fc641ac8749cb6c2f253f616131a67d9631e00d4bea33",
|
|
});
|
|
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 source = readFileSync(".github/workflows/real-behavior-proof.yml", "utf8");
|
|
const checkout = workflow.jobs["real-behavior-proof"].steps.find(
|
|
(step: WorkflowStep) => step.uses === CHECKOUT_V6,
|
|
);
|
|
|
|
expect(checkout.with.ref).toBe("${{ github.workflow_sha }}");
|
|
expect(checkout.with.ref).not.toBe("${{ github.event.pull_request.base.sha }}");
|
|
expect(source).toContain("Old PR events can carry a stale base 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 --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)");
|
|
});
|
|
|
|
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(28);
|
|
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(2);
|
|
expect(workflow.jobs.android.strategy["max-parallel"]).toBe(2);
|
|
});
|
|
|
|
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-workflow/.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 cacheStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) => step.name === "Cache Android SDK"),
|
|
"Android SDK cache 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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287");
|
|
expect(javaStep.with).toMatchObject({
|
|
cache: "gradle",
|
|
distribution: "temurin",
|
|
"java-version": 17,
|
|
});
|
|
expect(javaStep.with?.["cache-dependency-path"]).toContain(
|
|
"apps/android/gradle/libs.versions.toml",
|
|
);
|
|
expect(cacheStep.with?.key).toContain(`platform-${appCompileSdk}.0-`);
|
|
expect(installStep.run).toContain(`"${packageId}"`);
|
|
expect(installStep.run).toContain(
|
|
'yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null || [[ "${PIPESTATUS[1]}" -eq 0 ]]',
|
|
);
|
|
});
|
|
|
|
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-workflow",
|
|
"persist-credentials": false,
|
|
ref: "${{ github.workflow_sha }}",
|
|
"sparse-checkout": ".github/actions/setup-android-toolchain",
|
|
});
|
|
expect(checkoutIndex).toBeLessThan(actionCheckoutIndex);
|
|
expect(actionCheckoutIndex).toBeLessThan(setupIndex);
|
|
});
|
|
|
|
it("bounds Android SDK command-line tools downloads", () => {
|
|
const action = readAndroidToolchainAction();
|
|
const setupStep = expectDefined(
|
|
action.runs.steps.find((step: WorkflowStep) =>
|
|
step.run?.includes("commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"),
|
|
),
|
|
"Android SDK setup step",
|
|
);
|
|
|
|
expect(setupStep.run).toContain("curl -fsSL --connect-timeout 10 --max-time 300");
|
|
});
|
|
|
|
it("covers Android app variants, lint, and benchmark compilation", () => {
|
|
const workflow = readCiWorkflow();
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const runStep = workflow.jobs.android.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Android ${{ matrix.task }}",
|
|
);
|
|
const nativeResourcesSetup = expectDefined(
|
|
workflow.jobs.android.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment for native resources",
|
|
),
|
|
"Android native resources Node setup",
|
|
);
|
|
|
|
expect(source).toContain('task: useCompatibleAndroidCi ? "test-play-compat" : "test-play"');
|
|
expect(source).toContain(
|
|
'{ check_name: "android-test-third-party", task: "test-third-party" }',
|
|
);
|
|
expect(source).toContain('check_name: "android-build-play"');
|
|
expect(source).toContain('task: useCompatibleAndroidCi ? "build-play-compat" : "build-play"');
|
|
expect(runStep.run).toContain(":app:testPlayDebugUnitTest");
|
|
expect(runStep.run).toContain(":app:testThirdPartyDebugUnitTest");
|
|
expect(runStep.run).toContain(":app:assemblePlayDebug");
|
|
expect(runStep.run).toContain(":app:assembleThirdPartyDebug");
|
|
expect(runStep.run).toContain(":app:lintPlayDebug");
|
|
expect(runStep.run).toContain(":app:lintThirdPartyDebug");
|
|
expect(runStep.run).toContain(":benchmark:assembleDebug");
|
|
expect(nativeResourcesSetup.uses).toBe("./.github/actions/setup-node-env");
|
|
expect(nativeResourcesSetup.if).toBe(
|
|
"needs.preflight.outputs.use_compatible_android_ci != 'true'",
|
|
);
|
|
expect(nativeResourcesSetup.with).toMatchObject({ "install-bun": "false" });
|
|
});
|
|
|
|
it("runs canonical main CI single-flight while coalescing the pending tip", () => {
|
|
const workflow = readCiWorkflow();
|
|
|
|
// GitHub concurrency keeps one running and one pending run by default.
|
|
// Replacing only the pending run preserves a complete integration cycle
|
|
// while coalescing merge bursts to the newest main tip.
|
|
expect(workflow.concurrency["cancel-in-progress"]).toBe(
|
|
"${{ github.event_name == 'pull_request' }}",
|
|
);
|
|
expect(workflow.jobs["runner-admission"]).toBeUndefined();
|
|
const preflight = workflow.jobs.preflight;
|
|
expect(preflight.needs).toBeUndefined();
|
|
expect(preflight.env?.OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS).toBeUndefined();
|
|
const steps = preflight.steps as Array<{ if?: string; name?: string; run?: string }>;
|
|
expect(steps.some((step) => step.name === "Record debounce epoch")).toBe(false);
|
|
expect(steps.some((step) => step.name === "Debounce canonical main fan-out")).toBe(false);
|
|
expect(workflow.jobs["security-fast"].needs).toBeUndefined();
|
|
});
|
|
|
|
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 security checks hosted and the cache writer on Blacksmith", () => {
|
|
const workflow = readCiWorkflow();
|
|
|
|
expect(workflow.jobs.preflight["runs-on"]).toContain("blacksmith-4vcpu-ubuntu-2404");
|
|
expect(workflow.jobs["security-fast"]["runs-on"]).toBe("ubuntu-24.04");
|
|
expect(workflow.jobs["pnpm-store-warmup"]["runs-on"]).toContain("blacksmith-4vcpu-ubuntu-2404");
|
|
});
|
|
|
|
it("scans only the pull request commit range for leaked credentials", () => {
|
|
const securitySteps = readCiWorkflow().jobs["security-fast"].steps as WorkflowStep[];
|
|
const fetchScanHistoryIndex = securitySteps.findIndex(
|
|
(step) => step.name === "Fetch pull request scan history",
|
|
);
|
|
const scanIndex = securitySteps.findIndex(
|
|
(step) => step.name === "Scan pull request for leaked credentials",
|
|
);
|
|
const fetchScanHistoryStep = expectDefined(
|
|
securitySteps[fetchScanHistoryIndex],
|
|
"TruffleHog history fetch step",
|
|
);
|
|
const scanStep = expectDefined(securitySteps[scanIndex], "TruffleHog pull request scan step");
|
|
|
|
expect(scanIndex).toBeGreaterThan(fetchScanHistoryIndex);
|
|
expect(fetchScanHistoryStep.if).toBe("github.event_name == 'pull_request'");
|
|
expect(fetchScanHistoryStep.env).toEqual({
|
|
PR_COMMIT_COUNT: "${{ github.event.pull_request.commits }}",
|
|
PR_MERGE_SHA: "${{ github.sha }}",
|
|
});
|
|
expect(fetchScanHistoryStep.run).toContain("fetch_depth=$((PR_COMMIT_COUNT + 2))");
|
|
expect(fetchScanHistoryStep.run).toContain(
|
|
'fetch --no-tags --no-recurse-submodules --depth="$fetch_depth" origin "$PR_MERGE_SHA"',
|
|
);
|
|
expect(scanStep.if).toBe("github.event_name == 'pull_request'");
|
|
expect(scanStep.uses).toBe(TRUFFLEHOG_V3_95_9);
|
|
expect(scanStep.with).toEqual({
|
|
base: "${{ steps.diff_base.outputs.sha }}",
|
|
head: "${{ github.sha }}",
|
|
version: "3.95.9@sha256:59b244249d1a1aef4baa24fe73d3c931616264482580d806d77f6c74d26b3e42",
|
|
extra_args: "--results=verified,unknown --fail-on-scan-errors",
|
|
});
|
|
});
|
|
|
|
it("keeps sticky dependency snapshots on trusted Blacksmith Node shards", () => {
|
|
const workflow = readCiWorkflow();
|
|
const blacksmithJobs = Object.entries(workflow.jobs).filter(([, job]) => {
|
|
const runsOn = (job as { "runs-on"?: unknown })["runs-on"];
|
|
return typeof runsOn === "string" && runsOn.includes("blacksmith-");
|
|
});
|
|
const stickySteps = Object.entries(workflow.jobs).flatMap(([jobName, job]) => {
|
|
const steps = (job as { steps?: WorkflowStep[] }).steps ?? [];
|
|
return steps.flatMap((step) => {
|
|
const stepWith = step.with;
|
|
if (!stepWith || stepWith["sticky-disk"] === undefined) {
|
|
return [];
|
|
}
|
|
return [{ jobName, stepWith }];
|
|
});
|
|
});
|
|
const preflightWriter = stickySteps.find((entry) => entry.jobName === "preflight");
|
|
const stickyConsumers = stickySteps.filter((entry) => entry.jobName !== "preflight");
|
|
// Every Linux Blacksmith lane that installs Node dependencies consumes
|
|
// the snapshot; missing entries silently pay the full install again.
|
|
expect(stickyConsumers.map((entry) => entry.jobName).toSorted()).toEqual([
|
|
"build-artifacts",
|
|
"check-additional-shard",
|
|
"check-docs",
|
|
"check-shard",
|
|
"checks-fast-channel-contracts-shard",
|
|
"checks-fast-core",
|
|
"checks-fast-plugin-contracts-shard",
|
|
"checks-node-core-test-nondist-shard",
|
|
"checks-ui",
|
|
"checks-ui-e2e",
|
|
"control-ui-i18n",
|
|
"native-i18n",
|
|
"qa-smoke-ci-profile",
|
|
]);
|
|
for (const { jobName, stepWith } of stickyConsumers) {
|
|
const stickyCondition = stepWith["sticky-disk"];
|
|
const cacheCondition = stepWith["use-actions-cache"];
|
|
expect(stickyCondition, jobName).toContain("github.event_name != 'workflow_dispatch'");
|
|
expect(stickyCondition, jobName).toContain(
|
|
"github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'",
|
|
);
|
|
expect(cacheCondition, jobName).toContain("github.event_name != 'workflow_dispatch'");
|
|
expect(cacheCondition, jobName).toContain(
|
|
"github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'",
|
|
);
|
|
expect(cacheCondition, jobName).toContain("&& 'false' || 'true'");
|
|
}
|
|
// Required CI jobs only clone the snapshot. The disposable warmer below
|
|
// owns commits so writer coalescing cannot cancel a required build job.
|
|
for (const { jobName, stepWith } of stickyConsumers) {
|
|
expect(stepWith["save-sticky-disk"], jobName).toBeUndefined();
|
|
}
|
|
expect(preflightWriter?.stepWith).toMatchObject({
|
|
"save-sticky-disk": "true",
|
|
"sticky-disk": "true",
|
|
"use-actions-cache": "false",
|
|
});
|
|
const preflightSteps = workflow.jobs.preflight.steps as WorkflowStep[];
|
|
const refreshStep = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Refresh sticky dependency snapshot",
|
|
)!;
|
|
const maintainStep = preflightSteps.find(
|
|
(step: WorkflowStep) => step.name === "Maintain sticky dependency store budget",
|
|
)!;
|
|
expect(refreshStep.if).toContain("github.event_name == 'push'");
|
|
expect(refreshStep.if).toContain("github.repository == 'openclaw/openclaw'");
|
|
expect(refreshStep.if).toContain("github.ref == 'refs/heads/main'");
|
|
expect(refreshStep.if).toContain("steps.manifest.outputs.run_node == 'true'");
|
|
expect(maintainStep.if).toBe(refreshStep.if);
|
|
expect(preflightSteps.indexOf(refreshStep)).toBeLessThan(preflightSteps.indexOf(maintainStep));
|
|
expect(maintainStep.env?.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608");
|
|
expect(maintainStep.run).toContain('store_dir="${PNPM_CONFIG_STORE_DIR:?}"');
|
|
expect(maintainStep.run).toContain('PNPM_CONFIG_STORE_DIR="$store_dir" pnpm store prune');
|
|
expect(maintainStep.run).toContain('>> "$GITHUB_STEP_SUMMARY"');
|
|
expect(workflow.jobs["pnpm-store-warmup"].if).toContain("github.ref == 'refs/heads/main'");
|
|
expect(workflow.jobs["pnpm-store-warmup"].if).toContain(
|
|
"github.repository == 'openclaw/openclaw'",
|
|
);
|
|
// Current sticky consumers all use the single supported Node line. A
|
|
// planner-provided version would silently create a writerless disk.
|
|
for (const { jobName, stepWith } of stickyConsumers) {
|
|
const nodeVersion = stepWith["node-version"];
|
|
expect(
|
|
nodeVersion === undefined ||
|
|
nodeVersion === "24.x" ||
|
|
nodeVersion === "${{ matrix.node_version || '24.x' }}",
|
|
`${jobName} must resolve to the writer's 24.x snapshot key (got ${String(nodeVersion)})`,
|
|
).toBe(true);
|
|
if (nodeVersion === "${{ matrix.node_version || '24.x' }}") {
|
|
expect(stepWith["sticky-disk"], jobName).toContain(
|
|
"matrix.node_version == null || matrix.node_version == '24.x'",
|
|
);
|
|
}
|
|
}
|
|
const warmWorkflow = parse(readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8"));
|
|
const warmSetupStep = warmWorkflow.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Node environment",
|
|
);
|
|
expect(warmSetupStep.with["save-sticky-disk"]).toBeUndefined();
|
|
expect(warmSetupStep.with["sticky-disk"]).toBe("false");
|
|
expect(warmWorkflow.on).not.toHaveProperty("pull_request");
|
|
expect(warmWorkflow.on).not.toHaveProperty("workflow_dispatch");
|
|
expect(warmWorkflow.on).not.toHaveProperty("workflow_run");
|
|
const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8"));
|
|
const validateLayoutStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate sticky pnpm layout",
|
|
);
|
|
const setupPnpmStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup pnpm",
|
|
);
|
|
const mountStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Mount dependency sticky disk",
|
|
);
|
|
const cleanupStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Register sticky bind cleanup",
|
|
);
|
|
const bindStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Bind sticky node_modules into workspace",
|
|
);
|
|
const installStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install dependencies",
|
|
);
|
|
|
|
expect(blacksmithJobs.length).toBeGreaterThan(0);
|
|
for (const [jobName, job] of blacksmithJobs) {
|
|
expect(
|
|
(job as { "runs-on": string })["runs-on"],
|
|
`${jobName} must route fork pull requests to GitHub-hosted runners`,
|
|
).toContain(
|
|
"github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'",
|
|
);
|
|
}
|
|
expect(action.inputs["sticky-disk"].default).toBe("false");
|
|
// Writers omit node-version, so the default is the writers' key segment.
|
|
expect(action.inputs["node-version"].default).toBe("24.x");
|
|
expect(action.inputs["save-sticky-disk"].default).toBe("false");
|
|
expect(validateLayoutStep.if).toBe("inputs.sticky-disk == 'true'");
|
|
expect(validateLayoutStep.run).toContain("for config_name in modules-dir virtual-store-dir");
|
|
expect(validateLayoutStep.run).toContain('config_value="$(pnpm config get "$config_name")"');
|
|
expect(validateLayoutStep.run).toContain(
|
|
"sticky mode requires pnpm's stock node_modules layout",
|
|
);
|
|
expect(action.runs.steps.indexOf(setupPnpmStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(validateLayoutStep),
|
|
);
|
|
expect(action.runs.steps.indexOf(validateLayoutStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(mountStep),
|
|
);
|
|
expect(mountStep).toMatchObject({
|
|
if: "inputs.sticky-disk == 'true'",
|
|
uses: "useblacksmith/stickydisk@6d373c96a74cbde0c99fedc5ea5d3a7ba66ba494",
|
|
with: {
|
|
path: "/var/tmp/openclaw-node-deps",
|
|
},
|
|
});
|
|
// Bounded disks: Blacksmith caps sticky disks per installation, and the old
|
|
// per-PR/per-manifest-hash keys saturated that cap. Install inputs and exact
|
|
// runtime patches belong in the marker, not the backing-disk key.
|
|
expect(mountStep.with.key).toBe(
|
|
"${{ github.repository }}-node-deps-bind-v6-${{ inputs.node-version }}",
|
|
);
|
|
expect(mountStep.with.commit).toBe(
|
|
"${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
|
|
);
|
|
expect(cleanupStep).toMatchObject({
|
|
if: "inputs.sticky-disk == 'true'",
|
|
uses: "./.github/actions/register-bind-mount-cleanup",
|
|
with: { path: "${{ github.workspace }}/node_modules" },
|
|
});
|
|
expect(action.runs.steps.indexOf(mountStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(cleanupStep),
|
|
);
|
|
expect(action.runs.steps.indexOf(cleanupStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(bindStep),
|
|
);
|
|
expect(bindStep.run).toContain('sudo mount --bind "$sticky_modules" "$workspace_modules"');
|
|
expect(bindStep.run).toContain('echo "PNPM_CONFIG_STORE_DIR=$sticky_store"');
|
|
expect(bindStep.run).toContain('echo "OPENCLAW_BUILD_ALL_NO_PNPM=1"');
|
|
expect(bindStep.run).toContain(
|
|
'deps_fingerprint="os-${RUNNER_OS:?}-arch-${RUNNER_ARCH:?}-node-$(node --version)-${deps_input_fingerprint:?}"',
|
|
);
|
|
expect(bindStep.run).toContain('echo "OPENCLAW_STICKY_DEPS_FINGERPRINT=$deps_fingerprint"');
|
|
expect(bindStep.run).not.toContain("PNPM_CONFIG_MODULES_DIR");
|
|
expect(bindStep.run).not.toContain("PNPM_CONFIG_VIRTUAL_STORE_DIR");
|
|
// Compute from the checkout before the bind mount adds snapshot-internal
|
|
// manifests. Ordinary package scripts must not rotate dependency trees.
|
|
expect(bindStep.env.FROZEN_LOCKFILE).toBe("${{ inputs.frozen-lockfile }}");
|
|
expect(bindStep.env).not.toHaveProperty("DEPS_INPUT_FINGERPRINT");
|
|
expect(bindStep.run).toContain('node "$GITHUB_ACTION_PATH/dependency-fingerprint.mjs"');
|
|
expect(bindStep.run.indexOf("dependency-fingerprint.mjs")).toBeLessThan(
|
|
bindStep.run.indexOf('sudo mount --bind "$sticky_modules" "$workspace_modules"'),
|
|
);
|
|
expect(installStep.env).toMatchObject({
|
|
STICKY_DISK: "${{ inputs.sticky-disk }}",
|
|
STICKY_ROOT: "/var/tmp/openclaw-node-deps",
|
|
STICKY_WRITER:
|
|
"${{ inputs.save-sticky-disk == 'true' && github.event_name != 'pull_request' && 'true' || 'false' }}",
|
|
});
|
|
expect(installStep.run).toContain('sticky_marker="$STICKY_ROOT/.openclaw-deps-fingerprint"');
|
|
expect(installStep.run).toContain(
|
|
'[ "$sticky_fingerprint" = "${OPENCLAW_STICKY_DEPS_FINGERPRINT:?}" ]',
|
|
);
|
|
expect(installStep.run).toContain('sticky_fingerprint_matches="true"');
|
|
expect(installStep.run).toContain(
|
|
"Sticky dependency fingerprint matches, but restored importer contents are incomplete; reinstalling",
|
|
);
|
|
expect(installStep.run).toContain('[ "$STICKY_WRITER" != "true" ]');
|
|
expect(installStep.run).toContain('sudo umount "$GITHUB_WORKSPACE/node_modules"');
|
|
expect(installStep.run).toContain('ephemeral_store="${RUNNER_TEMP:?}/openclaw-pnpm-store"');
|
|
expect(installStep.run).toContain(
|
|
"Sticky dependency snapshot is unusable; using runner-local storage for this read-only run",
|
|
);
|
|
expect(installStep.run).toContain(
|
|
'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" restore "$STICKY_ROOT" "$GITHUB_WORKSPACE"',
|
|
);
|
|
expect(installStep.run).toContain(
|
|
"Sticky dependency snapshot matches the install fingerprint and importer contents; skipping pnpm install",
|
|
);
|
|
expect(installStep.run).toContain("timeout --signal=TERM --kill-after=15s 4m");
|
|
expect(installStep.run).toContain("timeout --signal=TERM --kill-after=15s 15m");
|
|
expect(installStep.run).toContain('pnpm "${install_args[@]}" --config.fetch-retries=0');
|
|
const forceStickyWriterInstall =
|
|
'if [ "$STICKY_DISK" = "true" ] && [ "$STICKY_WRITER" = "true" ] &&\n' +
|
|
' [ "$sticky_snapshot_matches" != "true" ]; then';
|
|
expect(installStep.run).toContain(forceStickyWriterInstall);
|
|
const clearStickyModules =
|
|
'find "$GITHUB_WORKSPACE/node_modules" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +';
|
|
expect(installStep.run).toContain(clearStickyModules);
|
|
expect(installStep.run).toContain("install_args+=(--force)");
|
|
expect(installStep.run).toContain('sticky_writer_rebuild="true"');
|
|
expect(installStep.run).toContain('if [ "$sticky_writer_rebuild" = "true" ]; then');
|
|
expect(installStep.run).toContain("install_attempts=1");
|
|
expect(installStep.run.indexOf(forceStickyWriterInstall)).toBeLessThan(
|
|
installStep.run.indexOf(clearStickyModules),
|
|
);
|
|
expect(installStep.run.indexOf(clearStickyModules)).toBeLessThan(
|
|
installStep.run.indexOf("run_pnpm_install()"),
|
|
);
|
|
expect(installStep.run).toContain("install_attempts=2");
|
|
expect(installStep.run).toContain("install_attempts=3");
|
|
expect(installStep.run).toContain(
|
|
"for (( attempt = 1; attempt <= install_attempts; attempt += 1 )); do",
|
|
);
|
|
expect(installStep.run).toContain('if [ "$install_status" -ne 0 ]; then');
|
|
expect(installStep.run).not.toContain("accepting the populated sticky tree");
|
|
// Read-only consumers never capture; only the designated writer refreshes
|
|
// the archive and publishes the fingerprint after a successful install.
|
|
expect(installStep.run).toContain('[ "$STICKY_WRITER" = "true" ]');
|
|
expect(installStep.run.indexOf('pnpm "${install_args[@]}"')).toBeLessThan(
|
|
installStep.run.indexOf(
|
|
'bash "$GITHUB_ACTION_PATH/sticky-importers.sh" capture "$STICKY_ROOT" "$GITHUB_WORKSPACE" "$OPENCLAW_STICKY_DEPS_FINGERPRINT"',
|
|
),
|
|
);
|
|
// The content-validated snapshot or successful install already owns
|
|
// dependency validation. pnpm's redundant check sees intentionally pruned
|
|
// plugin importers as stale, so it must not mutate during shard fanout.
|
|
const disableImplicitInstall =
|
|
'echo "pnpm_config_verify_deps_before_run=false" >> "$GITHUB_ENV"';
|
|
expect(installStep.run).toContain('if [ "$STICKY_DISK" = "true" ]; then');
|
|
expect(installStep.run).not.toContain("pnpm_config_verify_deps_before_run=install pnpm exec");
|
|
expect(installStep.run).toContain(disableImplicitInstall);
|
|
expect(installStep.run.indexOf('sticky-importers.sh" restore')).toBeLessThan(
|
|
installStep.run.indexOf(disableImplicitInstall),
|
|
);
|
|
const cleanupAction = parse(
|
|
readFileSync(".github/actions/register-bind-mount-cleanup/action.yml", "utf8"),
|
|
);
|
|
expect(cleanupAction.runs).toMatchObject({
|
|
using: "node24",
|
|
main: "main.cjs",
|
|
post: "post.cjs",
|
|
"post-if": "always()",
|
|
});
|
|
const cleanupPost = readFileSync(
|
|
".github/actions/register-bind-mount-cleanup/post.cjs",
|
|
"utf8",
|
|
);
|
|
expect(cleanupPost).toContain("mountpoint.status === 32");
|
|
expect(cleanupPost).toContain('spawnSync("sudo", ["umount", mountPath]');
|
|
expect(readFileSync(".github/actions/setup-pnpm-store-cache/action.yml", "utf8")).toContain(
|
|
"actions/cache/restore@",
|
|
);
|
|
});
|
|
|
|
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 and save build-all cache",
|
|
);
|
|
|
|
expect(action.inputs["build-all-cache-scope"].default).toBe("");
|
|
expect(cacheStep).toMatchObject({
|
|
if: "inputs.build-all-cache-scope != ''",
|
|
uses: "actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae",
|
|
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 }}");
|
|
expect(cacheStep.with.key).toContain("scripts/lib/optional-bundled-clusters.mjs");
|
|
expect(cacheStep.with.key).toContain("'src/**', 'packages/**', 'extensions/**'");
|
|
expect(cacheStep.with["restore-keys"]).not.toContain("hashFiles");
|
|
expect(action.runs.steps.indexOf(installStep)).toBeLessThan(
|
|
action.runs.steps.indexOf(cacheStep),
|
|
);
|
|
|
|
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/mantis-telegram-live.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"),
|
|
);
|
|
expect(releaseChecks.jobs.validate_repo_e2e.env).toMatchObject({
|
|
OPENCLAW_BUILD_PRIVATE_QA: "1",
|
|
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
|
|
});
|
|
});
|
|
|
|
it("persists Node 22 declarations through trusted bounded artifacts", () => {
|
|
const workflow = parse(readFileSync(".github/workflows/node22-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("restores importer-local node_modules from sticky snapshots", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-sticky-importers-"));
|
|
try {
|
|
const workspace = path.join(root, "workspace");
|
|
const stickyRoot = path.join(root, "sticky");
|
|
const importerRoot = path.join(workspace, "packages", "example");
|
|
const rootModules = path.join(workspace, "node_modules");
|
|
const importerModules = path.join(importerRoot, "node_modules");
|
|
const rootDependency = path.join(rootModules, "ipaddr.js");
|
|
const rootOptionalDependency = path.join(rootModules, "optional-ipaddr");
|
|
const importerDependency = path.join(importerModules, "ipaddr.js");
|
|
const helper = path.resolve(".github/actions/setup-node-env/sticky-importers.sh");
|
|
const lockfile = [
|
|
"lockfileVersion: '9.0'",
|
|
"importers:",
|
|
" packages/example:",
|
|
" dependencies:",
|
|
" ipaddr.js:",
|
|
" specifier: 2.4.0",
|
|
" version: 2.4.0",
|
|
" aliased-ipaddr:",
|
|
" specifier: npm:ipaddr.js@2.4.0",
|
|
" version: ipaddr.js@2.4.0",
|
|
" local-helper:",
|
|
" specifier: file:../local-helper",
|
|
" version: file:../local-helper",
|
|
" optionalDependencies:",
|
|
" optional-ipaddr:",
|
|
" specifier: npm:ipaddr.js@2.4.0",
|
|
" version: ipaddr.js@2.4.0",
|
|
" unsupported-optional:",
|
|
" specifier: 3.0.0",
|
|
" version: 3.0.0",
|
|
"",
|
|
].join("\n");
|
|
mkdirSync(workspace, { recursive: true });
|
|
writeFileSync(path.join(workspace, "pnpm-lock.yaml"), lockfile, "utf8");
|
|
mkdirSync(rootDependency, { recursive: true });
|
|
mkdirSync(rootOptionalDependency, { recursive: true });
|
|
mkdirSync(importerDependency, { recursive: true });
|
|
writeFileSync(
|
|
path.join(rootDependency, "package.json"),
|
|
JSON.stringify({ name: "ipaddr.js", version: "1.9.1" }),
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
path.join(rootOptionalDependency, "package.json"),
|
|
JSON.stringify({ name: "ipaddr.js", version: "1.9.1" }),
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
path.join(importerDependency, "package.json"),
|
|
JSON.stringify({ name: "ipaddr.js", version: "2.4.0" }),
|
|
"utf8",
|
|
);
|
|
for (const dependencyName of ["aliased-ipaddr", "optional-ipaddr"]) {
|
|
const dependencyRoot = path.join(importerModules, dependencyName);
|
|
mkdirSync(dependencyRoot, { recursive: true });
|
|
writeFileSync(
|
|
path.join(dependencyRoot, "package.json"),
|
|
JSON.stringify({ name: "ipaddr.js", version: "2.4.0" }),
|
|
"utf8",
|
|
);
|
|
}
|
|
writeFileSync(
|
|
path.join(rootModules, ".modules.yaml"),
|
|
JSON.stringify({
|
|
hoistedLocations: {
|
|
"ipaddr.js@1.9.1": ["node_modules/ipaddr.js", "node_modules/optional-ipaddr"],
|
|
"ipaddr.js@2.4.0": [
|
|
"packages/example/node_modules/ipaddr.js",
|
|
"packages/example/node_modules/aliased-ipaddr",
|
|
"packages/example/node_modules/optional-ipaddr",
|
|
],
|
|
},
|
|
}),
|
|
"utf8",
|
|
);
|
|
writeFileSync(path.join(rootModules, "root-sentinel"), "before", "utf8");
|
|
|
|
execFileSync("bash", [helper, "capture", stickyRoot, workspace, "fingerprint-a"]);
|
|
rmSync(importerModules, { recursive: true });
|
|
writeFileSync(path.join(rootModules, "root-sentinel"), "after", "utf8");
|
|
execFileSync("bash", [helper, "restore", stickyRoot, workspace]);
|
|
|
|
expect(
|
|
JSON.parse(readFileSync(path.join(importerDependency, "package.json"), "utf8")),
|
|
).toMatchObject({ version: "2.4.0" });
|
|
expect(readFileSync(path.join(rootModules, "root-sentinel"), "utf8")).toBe("after");
|
|
expect(readFileSync(path.join(stickyRoot, ".openclaw-deps-fingerprint"), "utf8")).toBe(
|
|
"fingerprint-a\n",
|
|
);
|
|
|
|
// Recreate the reported failure shape: a marker-matching archive can be
|
|
// structurally valid yet omit the importer-local override, causing Node
|
|
// to fall through to the stale root-hoisted version.
|
|
rmSync(importerModules, { recursive: true });
|
|
const archive = path.join(stickyRoot, "importer-node-modules.tar");
|
|
execFileSync("tar", ["--create", "--file", archive, "--files-from", "/dev/null"]);
|
|
const manifest = path.join(stickyRoot, "importer-node-modules.manifest");
|
|
const archiveChecksum = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
|
const manifestChecksum = createHash("sha256").update(readFileSync(manifest)).digest("hex");
|
|
writeFileSync(
|
|
path.join(stickyRoot, ".openclaw-importer-archive.sha256"),
|
|
`${archiveChecksum}\n${manifestChecksum}\n`,
|
|
"utf8",
|
|
);
|
|
const failedRestore = spawnSync("bash", [helper, "restore", stickyRoot, workspace], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(failedRestore.status).toBe(1);
|
|
expect(failedRestore.stderr).toContain(
|
|
"ipaddr.js expected ipaddr.js@2.4.0, resolved ipaddr.js@1.9.1",
|
|
);
|
|
expect(existsSync(importerModules)).toBe(false);
|
|
|
|
const failedCapture = spawnSync(
|
|
"bash",
|
|
[helper, "capture", stickyRoot, workspace, "fingerprint-b"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(failedCapture.status).toBe(1);
|
|
expect(failedCapture.stderr).toContain(
|
|
"ipaddr.js expected ipaddr.js@2.4.0, resolved ipaddr.js@1.9.1",
|
|
);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fingerprints dependency install inputs without ordinary script churn", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-dependency-fingerprint-"));
|
|
try {
|
|
const helper = path.resolve(".github/actions/setup-node-env/dependency-fingerprint.mjs");
|
|
const writeManifest = (manifest: Record<string, unknown>) => {
|
|
writeFileSync(path.join(root, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
};
|
|
const fingerprint = (frozenLockfile = true) =>
|
|
execFileSync(
|
|
process.execPath,
|
|
[helper, "--workspace", root, "--frozen-lockfile", frozenLockfile ? "true" : "false"],
|
|
{ encoding: "utf8" },
|
|
).trim();
|
|
|
|
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
writeManifest({
|
|
name: "fixture",
|
|
scripts: {
|
|
postinstall: "node scripts/postinstall-bundled-plugins.mjs",
|
|
preinstall: "node scripts/preinstall-package-manager-warning.mjs",
|
|
prepare: "node scripts/prepare-git-hooks.mjs",
|
|
test: "vitest run",
|
|
},
|
|
devDependencies: { vitest: "1.0.0" },
|
|
});
|
|
writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
|
|
execFileSync("git", ["add", "package.json", "pnpm-lock.yaml"], { cwd: root });
|
|
|
|
const baseline = fingerprint();
|
|
expect(baseline).toMatch(/^v2-[a-f0-9]{64}$/);
|
|
|
|
// Presence is part of the record type, so a real file cannot collide
|
|
// with the representation of an absent optional install input.
|
|
writeFileSync(path.join(root, ".pnpmfile.cjs"), "<missing>");
|
|
expect(fingerprint()).not.toBe(baseline);
|
|
rmSync(path.join(root, ".pnpmfile.cjs"));
|
|
expect(fingerprint()).toBe(baseline);
|
|
|
|
mkdirSync(path.join(root, "scripts"), { recursive: true });
|
|
writeFileSync(path.join(root, "scripts", "prepare-git-hooks.mjs"), "export {};\n");
|
|
expect(fingerprint()).not.toBe(baseline);
|
|
rmSync(path.join(root, "scripts"), { recursive: true });
|
|
expect(fingerprint()).toBe(baseline);
|
|
|
|
// Formatting, key order, and scripts that pnpm install never executes
|
|
// should keep the existing dependency snapshot warm.
|
|
writeManifest({
|
|
devDependencies: { vitest: "1.0.0" },
|
|
scripts: {
|
|
test: "vitest run --reporter=dot",
|
|
prepare: "node scripts/prepare-git-hooks.mjs",
|
|
postinstall: "node scripts/postinstall-bundled-plugins.mjs",
|
|
preinstall: "node scripts/preinstall-package-manager-warning.mjs",
|
|
},
|
|
name: "fixture",
|
|
});
|
|
expect(fingerprint()).toBe(baseline);
|
|
|
|
writeManifest({
|
|
name: "fixture",
|
|
scripts: {
|
|
postinstall: "node scripts/postinstall-bundled-plugins.mjs",
|
|
preinstall: "node scripts/preinstall-package-manager-warning.mjs",
|
|
prepare: "node scripts/prepare-git-hooks.mjs",
|
|
test: "vitest run",
|
|
},
|
|
devDependencies: { vitest: "2.0.0" },
|
|
});
|
|
expect(fingerprint()).not.toBe(baseline);
|
|
|
|
writeManifest({
|
|
name: "fixture",
|
|
scripts: { postinstall: "node install-v2.mjs", test: "vitest run" },
|
|
devDependencies: { vitest: "1.0.0" },
|
|
});
|
|
expect(() => fingerprint()).toThrow(/unaudited install lifecycle scripts in package\.json/);
|
|
|
|
mkdirSync(path.join(root, "packages", "worker"), { recursive: true });
|
|
writeManifest({
|
|
name: "fixture",
|
|
scripts: {
|
|
postinstall: "node scripts/postinstall-bundled-plugins.mjs",
|
|
preinstall: "node scripts/preinstall-package-manager-warning.mjs",
|
|
prepare: "node scripts/prepare-git-hooks.mjs",
|
|
},
|
|
devDependencies: { vitest: "1.0.0" },
|
|
});
|
|
const workerManifest = path.join(root, "packages", "worker", "package.json");
|
|
writeFileSync(
|
|
workerManifest,
|
|
`${JSON.stringify({ name: "worker", scripts: { prepare: "node build.mjs" } })}\n`,
|
|
);
|
|
execFileSync("git", ["add", "packages/worker/package.json"], { cwd: root });
|
|
expect(() => fingerprint()).toThrow(
|
|
/unaudited install lifecycle scripts in packages\/worker\/package\.json/,
|
|
);
|
|
writeFileSync(
|
|
workerManifest,
|
|
`${JSON.stringify({ name: "worker", scripts: { build: "node build.mjs" } })}\n`,
|
|
);
|
|
|
|
writeManifest({
|
|
name: "fixture",
|
|
scripts: {
|
|
postinstall: "node scripts/postinstall-bundled-plugins.mjs",
|
|
preinstall: "node scripts/preinstall-package-manager-warning.mjs",
|
|
prepare: "node scripts/prepare-git-hooks.mjs",
|
|
test: "vitest run",
|
|
},
|
|
devDependencies: { vitest: "1.0.0" },
|
|
});
|
|
writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.1'\n");
|
|
expect(fingerprint()).not.toBe(baseline);
|
|
expect(fingerprint(false)).not.toBe(baseline);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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 writerStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore and save Vitest transform cache",
|
|
);
|
|
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 compileEpochStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Select Node compile cache epoch",
|
|
);
|
|
const compileWriterStep = action.runs.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore and save Node compile 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 buildStepCache = workflow.jobs["build-artifacts"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore build-all step cache",
|
|
);
|
|
|
|
expect(setupNodeStep.with).toMatchObject({
|
|
"node-compile-cache": "true",
|
|
"node-compile-cache-scope": "test",
|
|
"vitest-fs-cache": "true",
|
|
});
|
|
expect(setupNodeStep.with).not.toHaveProperty("save-node-compile-cache");
|
|
expect(setupNodeStep.with).not.toHaveProperty("save-vitest-fs-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["save-vitest-fs-cache"].default).toBe("false");
|
|
expect(action.inputs["node-compile-cache"].default).toBe("false");
|
|
expect(action.inputs["node-compile-cache-scope"].default).toBe("test");
|
|
expect(action.inputs["save-node-compile-cache"].default).toBe("false");
|
|
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(writerStep.uses).toBe("actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae");
|
|
expect(writerStep.if).toContain("inputs.save-vitest-fs-cache == 'true'");
|
|
expect(writerStep.with.key).toContain("vitest-fs-v3-protected-");
|
|
expect(writerStep.with.key).toContain("github.run_id");
|
|
expect(writerStep.with.key).toContain("github.run_attempt");
|
|
expect(writerStep.with.key).not.toContain("pull_request");
|
|
expect(writerStep.with["restore-keys"]).toContain("**/tsconfig*.json");
|
|
expect(writerStep.with.key).toContain("!**/node_modules/**");
|
|
expect(writerStep.with["restore-keys"]).toContain("!**/node_modules/**");
|
|
expect(readerStep.uses).toBe(CACHE_V5);
|
|
expect(readerStep.if).toContain("inputs.save-vitest-fs-cache != 'true'");
|
|
expect(readerStep.with["restore-keys"]).toBe(writerStep.with["restore-keys"]);
|
|
expect(readerStep.with.key).toContain("!**/node_modules/**");
|
|
expect(configureStep.env.CACHE_GENERATION).toContain("!**/node_modules/**");
|
|
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=$cache_root");
|
|
expect(configureStep.run).toContain(".openclaw-transform-generation");
|
|
expect(configureStep.run).not.toContain("protected Vitest transform seed");
|
|
expect(configureStep.env.CACHE_WRITER).toBe(
|
|
"${{ inputs.save-vitest-fs-cache == 'true' && '1' || '0' }}",
|
|
);
|
|
expect(configureStep.run).toContain("OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER=");
|
|
expect(compileEpochStep.run).toContain('if [ "$CACHE_SCOPE" = "build" ]');
|
|
expect(compileEpochStep.run).toContain("date -u +%Y%m%d");
|
|
expect(compileEpochStep.run).toContain("GITHUB_RUN_ID");
|
|
expect(compileWriterStep.with.key).toContain(
|
|
"node-compile-v3-${{ inputs.node-compile-cache-scope }}-protected-",
|
|
);
|
|
expect(compileWriterStep.with.key).toContain("steps.node-compile-cache-epoch.outputs.value");
|
|
expect(compileWriterStep.with.key).not.toContain("pull_request");
|
|
expect(compileReaderStep.with["restore-keys"]).toBe(compileWriterStep.with["restore-keys"]);
|
|
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE=$cache_root");
|
|
expect(compileConfigureStep.run).toContain("NODE_COMPILE_CACHE_PORTABLE=1");
|
|
expect(compileConfigureStep.env.CACHE_WRITER).toBe(
|
|
"${{ inputs.save-node-compile-cache == 'true' && '1' || '0' }}",
|
|
);
|
|
expect(buildSetupNodeStep.with).toMatchObject({
|
|
"node-compile-cache": "true",
|
|
"node-compile-cache-scope": "build",
|
|
"save-node-compile-cache":
|
|
"${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}",
|
|
});
|
|
expect(buildSetupNodeStep.with["node-compile-cache-scope"]).not.toBe(
|
|
setupNodeStep.with["node-compile-cache-scope"],
|
|
);
|
|
expect(buildStepCache.with.key).toContain("build-all-v4-");
|
|
expect(buildStepCache.with.key).toContain("'src/**'");
|
|
expect(buildStepCache.with.key).toContain("'packages/**'");
|
|
expect(buildStepCache.with.key).toContain("'!packages/**/dist/**'");
|
|
expect(buildStepCache.with.key).toContain("'!packages/**/node_modules/**'");
|
|
expect(buildStepCache.with["restore-keys"]).toContain("build-all-v4-");
|
|
});
|
|
|
|
it("warms protected caches without main-run cancellation", () => {
|
|
const warmerSource = readFileSync(".github/workflows/vitest-cache-warm.yml", "utf8");
|
|
const warmer = parse(warmerSource);
|
|
const workflow = readCiWorkflow();
|
|
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 seedStep = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Select broad cache seed",
|
|
);
|
|
const warmStep = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Warm transform and compile caches",
|
|
);
|
|
const maintainStoreStep = warmer.jobs.warm.steps.find(
|
|
(step: WorkflowStep) => step.name === "Maintain dependency store budget",
|
|
);
|
|
const maintainStickyStoreStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Maintain sticky dependency store budget",
|
|
)!;
|
|
|
|
expect(warmer.concurrency["cancel-in-progress"]).toBe(false);
|
|
expect(warmer.concurrency.group).toBe("vitest-cache-warm");
|
|
expect(warmer.on.workflow_dispatch).toBeUndefined();
|
|
expect(warmer.on.repository_dispatch.types).toEqual(["vitest-cache-warm"]);
|
|
expect(warmer.jobs.warm.if).toContain("github.repository == 'openclaw/openclaw'");
|
|
expect(warmer.on).not.toHaveProperty("workflow_run");
|
|
expect(checkoutStep.with).toBeUndefined();
|
|
expect(warmerSource).toContain('cron: "17 8 * * *"');
|
|
expect(warmerSource).toContain('candidate.shardName.startsWith("core-unit-fast")');
|
|
expect(warmerSource).toContain('"agentic-agents-embedded"');
|
|
expect(warmerSource).toContain('"agentic-gateway-methods"');
|
|
expect(warmerSource).toContain('"auto-reply-reply-commands-3"');
|
|
expect(warmerSource).toContain("const groups = shards.flatMap((shard) =>");
|
|
expect(warmerSource).toContain("configs: [config]");
|
|
expect(warmerSource).toContain("...(shard.env ? { env: shard.env } : {})");
|
|
expect(warmerSource).toContain(
|
|
"...(shard.includePatterns ? { includePatterns: shard.includePatterns } : {})",
|
|
);
|
|
expect(warmerSource).toContain("`OPENCLAW_NODE_TEST_GROUPS_JSON=${JSON.stringify(groups)}`");
|
|
expect(warmerSource).not.toContain("OPENCLAW_NODE_TEST_CONFIGS_JSON");
|
|
expect(warmerSource).toContain('"OPENCLAW_NODE_TEST_PLAN_CONCURRENCY=1"');
|
|
expect(warmerSetup.with).toMatchObject({
|
|
"node-compile-cache-scope": "test",
|
|
"save-actions-cache": "true",
|
|
"save-node-compile-cache": "true",
|
|
"save-vitest-fs-cache": "true",
|
|
"sticky-disk": "false",
|
|
"use-actions-cache": "true",
|
|
});
|
|
// CI is restore-only, so no per-PR runtime cache family or close-time
|
|
// cleanup workflow exists. Actions cache LRU/TTL expires old warmers.
|
|
expect(existsSync(".github/workflows/pr-cache-cleanup.yml")).toBe(false);
|
|
expect(seedStep.if).toBeUndefined();
|
|
expect(warmStep.if).toBeUndefined();
|
|
expect(maintainStoreStep).toBeUndefined();
|
|
expect(maintainStickyStoreStep.env.OPENCLAW_PNPM_STORE_MAX_KIB).toBe("8388608");
|
|
|
|
const seedRoot = mkdtempSync(path.join(tmpdir(), "openclaw-cache-seed-"));
|
|
try {
|
|
const envPath = path.join(seedRoot, "github-env");
|
|
const result = runWorkflowShellScript(seedStep.run, {
|
|
env: {
|
|
...process.env,
|
|
GITHUB_ENV: envPath,
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const seedEnv = Object.fromEntries(
|
|
readFileSync(envPath, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
}),
|
|
);
|
|
const serializedGroups = seedEnv.OPENCLAW_NODE_TEST_GROUPS_JSON;
|
|
if (!serializedGroups) {
|
|
throw new Error("cache warmer did not export OPENCLAW_NODE_TEST_GROUPS_JSON");
|
|
}
|
|
const groups = JSON.parse(serializedGroups) as Array<{
|
|
configs: string[];
|
|
env?: Record<string, string>;
|
|
includePatterns?: string[];
|
|
shard_name: string;
|
|
}>;
|
|
const selectedShardNames = new Set([
|
|
"agentic-agents-embedded",
|
|
"agentic-gateway-methods",
|
|
"auto-reply-reply-commands-3",
|
|
]);
|
|
const selectedShards = createNodeTestShards().filter(
|
|
(shard) =>
|
|
shard.shardName.startsWith("core-unit-fast") || selectedShardNames.has(shard.shardName),
|
|
);
|
|
const expectedGroups = selectedShards.flatMap((shard) =>
|
|
shard.configs.map((config) => ({
|
|
configs: [config],
|
|
...(shard.env ? { env: shard.env } : {}),
|
|
...(shard.includePatterns ? { includePatterns: shard.includePatterns } : {}),
|
|
shard_name: `cache-warm:${shard.shardName}:${config}`,
|
|
})),
|
|
);
|
|
|
|
expect(groups).toEqual(expectedGroups);
|
|
expect(groups).toHaveLength(10);
|
|
expect(groups.every((group) => group.configs.length === 1)).toBe(true);
|
|
expect(new Set(groups.flatMap((group) => group.configs))).toHaveProperty("size", 9);
|
|
expect(new Set(groups.map((group) => group.shard_name))).toHaveProperty(
|
|
"size",
|
|
groups.length,
|
|
);
|
|
|
|
const coreStripeGroups = groups.filter(
|
|
(group) => group.configs[0] === "test/vitest/vitest.unit-fast.config.ts",
|
|
);
|
|
expect(coreStripeGroups).toHaveLength(2);
|
|
expect(coreStripeGroups.every((group) => (group.includePatterns?.length ?? 0) > 0)).toBe(
|
|
true,
|
|
);
|
|
const coreStripePatterns = coreStripeGroups.flatMap((group) => group.includePatterns ?? []);
|
|
expect(new Set(coreStripePatterns).size).toBe(coreStripePatterns.length);
|
|
|
|
const isolatedGroups = groups.filter((group) =>
|
|
group.shard_name.startsWith("cache-warm:core-unit-fast-isolated:"),
|
|
);
|
|
expect(isolatedGroups).toHaveLength(2);
|
|
expect(isolatedGroups.every((group) => group.includePatterns === undefined)).toBe(true);
|
|
expect(isolatedGroups.every((group) => group.env === undefined)).toBe(true);
|
|
|
|
const embeddedGroups = groups.filter((group) =>
|
|
group.shard_name.startsWith("cache-warm:agentic-agents-embedded:"),
|
|
);
|
|
expect(embeddedGroups).toHaveLength(4);
|
|
expect(
|
|
embeddedGroups.every(
|
|
(group) => group.env?.OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS === "660000",
|
|
),
|
|
).toBe(true);
|
|
|
|
const gatewayGroups = groups.filter((group) =>
|
|
group.shard_name.startsWith("cache-warm:agentic-gateway-methods:"),
|
|
);
|
|
expect(gatewayGroups).toHaveLength(1);
|
|
expect(gatewayGroups[0]?.includePatterns).toBeUndefined();
|
|
expect(gatewayGroups[0]?.env).toBeUndefined();
|
|
|
|
const autoReplyGroups = groups.filter((group) =>
|
|
group.shard_name.startsWith("cache-warm:auto-reply-reply-commands-3:"),
|
|
);
|
|
expect(autoReplyGroups).toHaveLength(1);
|
|
expect(autoReplyGroups[0]?.includePatterns).toHaveLength(18);
|
|
expect(autoReplyGroups[0]?.env).toBeUndefined();
|
|
expect(seedEnv.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY).toBe("1");
|
|
expect(seedEnv).not.toHaveProperty("OPENCLAW_NODE_TEST_CONFIGS_JSON");
|
|
} finally {
|
|
rmSync(seedRoot, { force: true, recursive: true });
|
|
}
|
|
|
|
const maintenanceRoot = mkdtempSync(path.join(tmpdir(), "openclaw-pnpm-maintenance-"));
|
|
try {
|
|
const storeDir = path.join(maintenanceRoot, "store");
|
|
const summaryPath = path.join(maintenanceRoot, "summary.md");
|
|
mkdirSync(storeDir);
|
|
const result = spawnSync("bash", ["-c", maintainStickyStoreStep.run], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
OPENCLAW_PNPM_STORE_MAX_KIB: "-1",
|
|
PNPM_CONFIG_STORE_DIR: storeDir,
|
|
},
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("pruning above -1 KiB ceiling");
|
|
expect(readFileSync(summaryPath, "utf8")).toContain("- Pruned: true");
|
|
} finally {
|
|
rmSync(maintenanceRoot, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("uses bundled Node shards and telemetry-backed runner sizes", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactsTestbox = readBuildArtifactsTestboxWorkflow();
|
|
const source = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
|
|
expect(source).toContain("createNodeTestShardBundles");
|
|
expect(workflow.jobs["build-artifacts"]["runs-on"]).toContain("blacksmith-32vcpu-ubuntu-2404");
|
|
expect(workflow.jobs["build-artifacts"]["timeout-minutes"]).toBe(
|
|
"${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 35 || 20 }}",
|
|
);
|
|
expect(buildArtifactsTestbox.jobs["build-artifacts"]["runs-on"]).toBe(
|
|
"blacksmith-16vcpu-ubuntu-2404",
|
|
);
|
|
expect(
|
|
buildArtifactsTestbox.jobs["build-artifacts"].steps.find(
|
|
(step: { name?: string }) => step.name === "Build dist on cache miss",
|
|
).env.NODE_OPTIONS,
|
|
).toBe("--max-old-space-size=16384");
|
|
expect(workflow.jobs["checks-node-core-test-nondist-shard"]["runs-on"]).toContain(
|
|
"blacksmith-4vcpu-ubuntu-2404",
|
|
);
|
|
expect(workflow.jobs["check-shard"].strategy.matrix.include).toContainEqual({
|
|
check_name: "check-dependencies",
|
|
task: "dependencies",
|
|
// Concurrent Knip scans need cores and memory headroom.
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
});
|
|
expect(workflow.jobs["check-additional-shard"]["runs-on"]).toContain("matrix.runner");
|
|
expect(workflow.jobs["check-additional-shard"].strategy.matrix.include).toContainEqual({
|
|
check_name: "check-session-accessor-boundary",
|
|
group: "session-accessor-boundary",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
expect(workflow.jobs["check-additional-shard"].strategy.matrix.include).toContainEqual({
|
|
check_name: "check-sqlite-session-schema-baseline",
|
|
group: "sqlite-session-schema-baseline",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
expect(workflow.jobs["checks-windows"]["runs-on"]).toContain("matrix.runner");
|
|
expect(source).toContain("blacksmith-8vcpu-windows-2025");
|
|
});
|
|
|
|
it("keeps the extension boundary sticky disk on one protected key", () => {
|
|
const workflow = readCiWorkflow();
|
|
const additionalJob = workflow.jobs["check-additional-shard"];
|
|
const checkShardJob = workflow.jobs["check-shard"];
|
|
|
|
// Light-run pole: cold prep + 122 plugin compiles scale with cores at
|
|
// similar billed core-minutes.
|
|
expect(additionalJob.strategy.matrix.include).toContainEqual({
|
|
check_name: "check-additional-extension-package-boundary",
|
|
group: "extension-package-boundary",
|
|
runner: "blacksmith-32vcpu-ubuntu-2404",
|
|
});
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
expect(runStep.env.OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY).toBe(16);
|
|
|
|
// O(1) disks: Blacksmith caps sticky disks per installation, and the old
|
|
// per-PR/per-config keys minted new disks until every mount 429-failed
|
|
// fleet-wide. Snapshot validity lives in the in-job marker, not the key.
|
|
const boundaryMount = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Mount extension boundary sticky disk",
|
|
);
|
|
const lintMount = checkShardJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Mount extension boundary sticky disk",
|
|
);
|
|
expect(boundaryMount.with.key).toBe("${{ github.repository }}-ext-boundary-v2");
|
|
expect(lintMount.with.key).toBe(boundaryMount.with.key);
|
|
// Single semantic writer: protected pushes commit explicitly (not
|
|
// on-change/if-missing, whose allocated-byte heuristic can strand a stale
|
|
// marker); PR clones and the lint consumer stay read-only.
|
|
expect(boundaryMount.with.commit).toBe(
|
|
"${{ github.event_name != 'pull_request' && 'true' || 'false' }}",
|
|
);
|
|
expect(lintMount.with.commit).toBe("false");
|
|
|
|
// The key no longer hashes config/scripts/lockfile, so every gate must
|
|
// compose the identical marker fingerprint or restores silently tear.
|
|
const restoreStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore extension boundary artifacts from sticky disk",
|
|
);
|
|
const lintRestoreStep = checkShardJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Restore extension boundary artifacts from sticky disk",
|
|
);
|
|
const seedStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Seed extension boundary sticky disk",
|
|
);
|
|
const configHash = seedStep.env.BOUNDARY_CONFIG_HASH;
|
|
expect(configHash).toContain("hashFiles(");
|
|
expect(configHash).toContain("pnpm-lock.yaml");
|
|
expect(restoreStep.env.BOUNDARY_CONFIG_HASH).toBe(configHash);
|
|
expect(lintRestoreStep.env.BOUNDARY_CONFIG_HASH).toBe(configHash);
|
|
for (const gate of [restoreStep, lintRestoreStep, seedStep]) {
|
|
expect(gate.run).toContain('echo "$BOUNDARY_CONFIG_HASH"');
|
|
}
|
|
// Seeding is writer-only work: PR mounts never commit, so seeding there
|
|
// would burn wall clock on a discarded clone.
|
|
expect(seedStep.if).toContain("github.event_name != 'pull_request'");
|
|
expect(seedStep.if).toContain("steps.boundary-sticky-restore.outputs.restored == '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",
|
|
);
|
|
const pointEnv = expectDefined(pointStep.env, "Gradle sticky point step env");
|
|
|
|
// 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 }}");
|
|
// Single semantic writer: protected pushes commit explicitly (on-change's
|
|
// allocated-byte heuristic can miss a same-size refresh and strand the
|
|
// fingerprint marker); PR clones stay read-only.
|
|
expect(mountWith.commit).toBe(
|
|
"${{ github.event_name != 'pull_request' && 'true' || 'false' }}",
|
|
);
|
|
// The dependency hash moved from the key into a runtime fingerprint that
|
|
// bounds disk growth: the writer rebuilds cold when inputs change so
|
|
// retired artifacts do not accumulate on the O(1) key forever.
|
|
expect(pointEnv.GRADLE_DEPS_FINGERPRINT).toContain("hashFiles(");
|
|
expect(pointEnv.GRADLE_DEPS_FINGERPRINT).toContain("apps/android/gradle/libs.versions.toml");
|
|
expect(pointEnv.STICKY_WRITER).toContain("github.event_name != 'pull_request'");
|
|
expect(pointStep.run).toContain(".openclaw-gradle-deps-fingerprint");
|
|
expect(pointStep.run).toContain('rm -rf "$sticky_root/gradle-user-home"');
|
|
});
|
|
|
|
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 the session accessor ratchet as a visible additional check", () => {
|
|
const workflow = readCiWorkflow();
|
|
const additionalJob = workflow.jobs["check-additional-shard"];
|
|
const matrixRows = additionalJob.strategy.matrix.include;
|
|
expect(matrixRows).toContainEqual({
|
|
check_name: "check-session-accessor-boundary",
|
|
group: "session-accessor-boundary",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
expect(runStep.run).toContain("session-accessor-boundary)");
|
|
expect(runStep.run).toContain(
|
|
'run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary',
|
|
);
|
|
});
|
|
|
|
it("runs the transcript reader ratchet as a visible additional check", () => {
|
|
const workflow = readCiWorkflow();
|
|
const additionalJob = workflow.jobs["check-additional-shard"];
|
|
const matrixRows = additionalJob.strategy.matrix.include;
|
|
expect(matrixRows).toContainEqual({
|
|
check_name: "check-session-transcript-reader-boundary",
|
|
group: "session-transcript-reader-boundary",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
expect(runStep.run).toContain("session-transcript-reader-boundary)");
|
|
expect(runStep.run).toContain(
|
|
'run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary',
|
|
);
|
|
});
|
|
|
|
it("runs the Plugin SDK API baseline as a visible additional check", () => {
|
|
const workflow = readCiWorkflow();
|
|
const additionalJob = workflow.jobs["check-additional-shard"];
|
|
const matrixRows = additionalJob.strategy.matrix.include;
|
|
expect(matrixRows).toContainEqual({
|
|
check_name: "check-plugin-sdk-api-baseline",
|
|
group: "plugin-sdk-api-baseline",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
expect(runStep.run).toContain("plugin-sdk-api-baseline)");
|
|
expect(runStep.run).toContain('run_check "plugin-sdk:api:check" pnpm run plugin-sdk:api:check');
|
|
});
|
|
|
|
it("runs the SQLite transaction ratchet in the session boundary check", () => {
|
|
const workflow = readCiWorkflow();
|
|
const additionalJob = workflow.jobs["check-additional-shard"];
|
|
const matrixRows = additionalJob.strategy.matrix.include;
|
|
expect(matrixRows).toContainEqual({
|
|
check_name: "check-session-accessor-boundary",
|
|
group: "session-accessor-boundary",
|
|
runner: "blacksmith-4vcpu-ubuntu-2404",
|
|
});
|
|
|
|
const runStep = additionalJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run additional check shard",
|
|
);
|
|
expect(runStep.run).toContain("session-accessor-boundary)");
|
|
expect(runStep.run).toContain(
|
|
'run_check "lint:tmp:sqlite-transaction-boundary" pnpm run lint:tmp:sqlite-transaction-boundary',
|
|
);
|
|
});
|
|
|
|
it("kills timed manual checkout fetches after the grace period", () => {
|
|
const workflowPaths = [
|
|
[".github/workflows/ci.yml", "120s"],
|
|
[".github/workflows/workflow-sanity.yml", "30s"],
|
|
[".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("bounds mantis discord smoke validation git fetches", () => {
|
|
const workflowPath = ".github/workflows/mantis-discord-smoke.yml";
|
|
const source = readFileSync(workflowPath, "utf8");
|
|
const gitFetchLines = source.split("\n").filter((line) => line.includes("git fetch"));
|
|
|
|
expect(gitFetchLines, workflowPath).toHaveLength(2);
|
|
expect(
|
|
gitFetchLines.every((line) =>
|
|
line.trimStart().startsWith("timeout --signal=TERM --kill-after=10s 120s git fetch"),
|
|
),
|
|
workflowPath,
|
|
).toBe(true);
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|
|
|
|
it("bounds shared base commit fetches", () => {
|
|
const action = readFileSync(".github/actions/ensure-base-commit/action.yml", "utf8");
|
|
const exactFetch = action.indexOf('fetch_base_ref --no-tags --depth=1 origin "$BASE_SHA"');
|
|
const branchDeepening = action.indexOf("for deepen_by in 25 100 300");
|
|
|
|
expect(action).toContain("fetch_base_ref()");
|
|
expect(action).toContain("timeout --signal=TERM --kill-after=10s 30s git");
|
|
expect(action).toContain("-c protocol.version=2");
|
|
expect(action).not.toContain("if ! git fetch --no-tags");
|
|
expect(exactFetch).toBeGreaterThan(-1);
|
|
expect(branchDeepening).toBeGreaterThan(exactFetch);
|
|
expect(action).toContain("::error title=ensure-base-commit missing base::");
|
|
});
|
|
|
|
it("bounds specialized early checkout fetches", () => {
|
|
const workflow = readCiWorkflow();
|
|
|
|
for (const jobName of ["preflight", "skills-python"]) {
|
|
const checkoutStep = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
|
|
expect(checkoutStep.run, jobName).toContain(
|
|
'timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE"',
|
|
);
|
|
expect(checkoutStep.run, jobName).toContain("for attempt in 1 2 3");
|
|
expect(checkoutStep.run, jobName).toContain("timed out on attempt $attempt; retrying");
|
|
expect(checkoutStep.run, jobName).not.toContain("if timeout --signal=TERM");
|
|
expect(checkoutStep.run, jobName).toContain("-c protocol.version=2");
|
|
expect(checkoutStep.run, jobName).toContain(
|
|
"fetch --no-tags --prune --no-recurse-submodules --depth=1 origin",
|
|
);
|
|
if (jobName === "preflight") {
|
|
expect(checkoutStep.run, jobName).toContain("--filter=blob:none");
|
|
expect(checkoutStep.run, jobName).toContain("fetch_parent_metadata");
|
|
}
|
|
if (jobName === "preflight") {
|
|
expect(checkoutStep.run, jobName).toContain('if [ "$fetch_status" = "124" ]');
|
|
expect(checkoutStep.run, jobName).toContain("timed out");
|
|
}
|
|
expect(checkoutStep.run, jobName).not.toContain(
|
|
'git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1',
|
|
);
|
|
}
|
|
});
|
|
|
|
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).toEqual({ "fetch-depth": 2, "persist-credentials": false });
|
|
expect(manualCheckoutStep.if).toBe(
|
|
"github.event_name == 'workflow_dispatch' && inputs.target_ref != ''",
|
|
);
|
|
expect(manualCheckoutStep.run).toContain("workflow_dispatch target_ref");
|
|
});
|
|
|
|
it("refetches an exact manual target when the workflow branch moves", () => {
|
|
const workflow = readCiWorkflow();
|
|
const checkoutStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
const run = checkoutStep.run;
|
|
const driftCheck = run.indexOf(
|
|
'if [ "$resolved_sha" != "$requested_sha" ] && [ "$checkout_ref" != "$requested_sha" ]; then',
|
|
);
|
|
const exactFetch = run.indexOf('fetch_checkout_ref "$checkout_ref"', driftCheck);
|
|
const finalCheck = run.indexOf('if [ "$resolved_sha" != "$requested_sha" ]; then', driftCheck);
|
|
|
|
expect(driftCheck).toBeGreaterThan(-1);
|
|
expect(run).toContain("while the manual run waits for a runner");
|
|
expect(run).toContain('checkout_ref="$requested_sha"');
|
|
expect(exactFetch).toBeGreaterThan(driftCheck);
|
|
expect(finalCheck).toBeGreaterThan(exactFetch);
|
|
});
|
|
|
|
it("uses the maintained checkout across workflow sanity jobs", () => {
|
|
const workflow = readWorkflowSanityWorkflow();
|
|
|
|
for (const jobName of ["no-tabs", "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("prepares all Testbox checkouts from one maintained owner", () => {
|
|
const workflowPaths = [
|
|
".github/workflows/ci-check-testbox.yml",
|
|
".github/workflows/ci-check-arm-testbox.yml",
|
|
".github/workflows/ci-build-artifacts-testbox.yml",
|
|
];
|
|
|
|
for (const workflowPath 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, workflowPath).toEqual({
|
|
"fetch-depth": "${{ github.event_name == 'pull_request' && '2' || '0' }}",
|
|
"persist-credentials": false,
|
|
});
|
|
expect(prepareStep?.uses, workflowPath).toBe("./.github/actions/prepare-testbox-shell");
|
|
expect(prepareStep?.with?.["base-ref"], workflowPath).toBe(
|
|
"${{ github.event.pull_request.base.sha || 'refs/remotes/origin/main' }}",
|
|
);
|
|
const ensureBaseStep = job.steps.find(
|
|
(step: WorkflowStep) => step.name === "Ensure Testbox base commit",
|
|
);
|
|
expect(ensureBaseStep?.if, workflowPath).toBe("github.event_name == 'pull_request'");
|
|
expect(ensureBaseStep?.uses, workflowPath).toBe("./.github/actions/ensure-base-commit");
|
|
expect(ensureBaseStep?.with, workflowPath).toEqual({
|
|
"base-sha": "${{ github.event.pull_request.base.sha }}",
|
|
"fetch-ref": "${{ github.event.pull_request.base.ref }}",
|
|
});
|
|
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"));
|
|
const run = action.runs.steps[0].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 tool downloads", () => {
|
|
const workflow = readWorkflowSanityWorkflow();
|
|
const shellcheckStep = expectDefined(
|
|
workflow.jobs.actionlint.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install ShellCheck",
|
|
),
|
|
"ShellCheck install step",
|
|
);
|
|
const actionlintStep = expectDefined(
|
|
workflow.jobs.actionlint.steps.find(
|
|
(step: WorkflowStep) => step.name === "Install actionlint",
|
|
),
|
|
"actionlint 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");
|
|
expect(actionlintStep.run).toContain("--connect-timeout 10");
|
|
expect(actionlintStep.run).toContain("--max-time 120");
|
|
expect(actionlintStep.run).toContain("--retry 5");
|
|
expect(actionlintStep.run).toContain("--retry-delay 2");
|
|
expect(actionlintStep.run).toContain("--retry-all-errors");
|
|
expect(actionlintStep.run.match(/curl "\$\{curl_args\[@\]\}"/gu)).toHaveLength(2);
|
|
});
|
|
|
|
it("runs 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 plugin SDK API contract manifest");
|
|
expect(stepNames).toContain("Check SQLite sessions/transcripts schema baseline drift");
|
|
expect(stepNames).toContain("Check plugin SDK surface budget");
|
|
expect(stepNames.indexOf("Check plugin SDK API contract manifest")).toBeLessThan(
|
|
stepNames.indexOf("Check SQLite sessions/transcripts schema baseline drift"),
|
|
);
|
|
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("bounds platform checkout fetches without GNU timeout", () => {
|
|
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(3);
|
|
expect(source.match(/fetch_checkout_ref_once\(\)/gu) ?? []).toHaveLength(1);
|
|
|
|
for (const jobName of ["checks-windows", "macos-node", "macos-swift", "ios-build"]) {
|
|
const checkoutStep = workflow.jobs[jobName].steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout",
|
|
);
|
|
|
|
expect(checkoutStep.run, jobName).toContain("fetch_checkout_ref()");
|
|
expect(checkoutStep.run, jobName).toContain("fetch_checkout_ref_once()");
|
|
expect(checkoutStep.run, jobName).toContain("for attempt in 1 2 3");
|
|
expect(checkoutStep.run, jobName).toContain("fetch_timeout_seconds=90");
|
|
expect(checkoutStep.run, jobName).toContain("-c protocol.version=2");
|
|
expect(checkoutStep.run, jobName).toContain(
|
|
"fetch --no-tags --prune --no-recurse-submodules --depth=1 origin",
|
|
);
|
|
expect(checkoutStep.run, jobName).toContain(
|
|
'if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then',
|
|
);
|
|
expect(checkoutStep.run, jobName).toContain('kill -TERM "$fetch_pid"');
|
|
expect(checkoutStep.run, jobName).toContain('kill -KILL "$fetch_pid"');
|
|
expect(checkoutStep.run, jobName).toContain(
|
|
'if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then',
|
|
);
|
|
expect(checkoutStep.run, jobName).toContain("timed out on attempt $attempt; retrying");
|
|
expect(checkoutStep.run, jobName).not.toContain(
|
|
'git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1',
|
|
);
|
|
}
|
|
});
|
|
|
|
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("resets SwiftPM state between macOS release build retries", () => {
|
|
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)",
|
|
);
|
|
|
|
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");
|
|
expect(buildStep.run).toContain("for attempt in 1 2 3");
|
|
expect(buildStep.run).toContain('if [[ "$attempt" -eq 3 ]]; then');
|
|
expect(buildStep.run).toContain("swift package --package-path apps/macos reset");
|
|
expect(buildStep.run.indexOf("swift package --package-path apps/macos reset")).toBeGreaterThan(
|
|
buildStep.run.indexOf("swift build failed"),
|
|
);
|
|
});
|
|
|
|
it("serializes macOS Swift tests only on hosted dispatches and retries", () => {
|
|
const workflow = readCiWorkflow();
|
|
const macosSwift = workflow.jobs["macos-swift"];
|
|
const testStep = macosSwift.steps.find((step: WorkflowStep) => step.name === "Swift test");
|
|
|
|
expect(macosSwift.env.SWIFT_TEST_EXECUTION).toBe(
|
|
"${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'serial' || 'parallel' }}",
|
|
);
|
|
expect(testStep.run).toContain(
|
|
"swift_test_args=(--package-path apps/macos --enable-code-coverage)",
|
|
);
|
|
expect(testStep.run).toContain('if [[ "$SWIFT_TEST_EXECUTION" == "parallel" ]]');
|
|
expect(testStep.run).toContain("swift_test_args+=(--parallel)");
|
|
expect(testStep.run).toContain("else\n swift_test_args+=(--no-parallel)");
|
|
expect(testStep.run).toContain('swift test "${swift_test_args[@]}"');
|
|
expect(testStep.run).not.toContain(
|
|
"swift test --package-path apps/macos --parallel --enable-code-coverage",
|
|
);
|
|
expect(testStep.run).toContain("for attempt in 1 2 3");
|
|
});
|
|
|
|
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");
|
|
|
|
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_ready_curl=${ready_curl_status}"');
|
|
expect(workflow).toContain('echo "phone_home_ready_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 @"$ready_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("mkdir -p ~/.ssh"),
|
|
);
|
|
const readyFailureBlock = workflow.slice(
|
|
workflow.indexOf('if (( ready_curl_status != 0 )) || [[ ! "$http_code" =~ ^2 ]]; then'),
|
|
workflow.indexOf('echo "============================================"'),
|
|
);
|
|
|
|
expect(workflow).toContain(')" || hydrating_curl_status=$?');
|
|
expect(workflow).toContain(')" || ready_curl_status=$?');
|
|
expect(hydratingFailureBlock).toContain("exit 1");
|
|
expect(missingSshKeyFailureBlock).toContain("exit 1");
|
|
expect(readyFailureBlock).toContain("exit 1");
|
|
expect(workflow).toContain(
|
|
"Blacksmith phone-home did not return an SSH public key; testbox cannot accept CLI connections.",
|
|
);
|
|
expect(workflow).not.toContain(
|
|
'phone_home_ready_http=${http_code}"\n\n echo "============================================"',
|
|
);
|
|
expect(workflow).not.toContain('\\"testbox_id\\": \\"${TESTBOX_ID}\\"');
|
|
expect(workflow).not.toContain('cat > "$ready_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:script-declarations"');
|
|
expect(preflightGuards).toContain("pnpm check:script-declarations");
|
|
expect(preflightGuards).toContain('[[ "$HISTORICAL_TARGET" != "true" ]]');
|
|
expect(preflightGuards).toContain(
|
|
"Current CI targets must provide the check:script-declarations package script.",
|
|
);
|
|
expect(npmLockGuards).toContain("pnpm deps:npm-lock:check");
|
|
expect(preflightGuards).toContain("pnpm deps:patches:check");
|
|
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");
|
|
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");
|
|
const checkShardStep = parsedWorkflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
);
|
|
expect(checkShardStep.env.PR_BASE_SHA).toBe(
|
|
"${{ github.event_name == 'pull_request' && needs.preflight.outputs.diff_base_revision || '' }}",
|
|
);
|
|
expect(checkShardStep.run).toContain(
|
|
'timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base"',
|
|
);
|
|
});
|
|
|
|
it("uses stable deadcode checks for current and frozen checkouts", () => {
|
|
const modern = runDependencyCheckFixture({
|
|
historicalTarget: false,
|
|
scripts: ["deadcode:dependencies", "deadcode:unused-files", "deadcode:exports"],
|
|
});
|
|
expect(modern.status, modern.output).toBe(0);
|
|
// The scripts launch concurrently; completion order is nondeterministic.
|
|
expect(modern.calls.toSorted()).toEqual([
|
|
"deadcode:dependencies",
|
|
"deadcode:exports",
|
|
"deadcode:unused-files",
|
|
]);
|
|
|
|
const frozenWithExports = runDependencyCheckFixture({
|
|
historicalTarget: true,
|
|
scripts: ["deadcode:dependencies", "deadcode:unused-files", "deadcode:exports"],
|
|
});
|
|
expect(frozenWithExports.status, frozenWithExports.output).toBe(0);
|
|
expect(frozenWithExports.calls.toSorted()).toEqual([
|
|
"deadcode:dependencies",
|
|
"deadcode:exports",
|
|
"deadcode:unused-files",
|
|
]);
|
|
|
|
const frozen = runDependencyCheckFixture({
|
|
historicalTarget: true,
|
|
scripts: [
|
|
"deadcode:ci",
|
|
"deadcode:dependencies",
|
|
"deadcode:report:ci:ts-unused",
|
|
"deadcode:unused-files",
|
|
],
|
|
});
|
|
expect(frozen.status, frozen.output).toBe(0);
|
|
expect(frozen.calls.toSorted()).toEqual(["deadcode:dependencies", "deadcode:unused-files"]);
|
|
|
|
const currentWithoutExports = runDependencyCheckFixture({
|
|
historicalTarget: false,
|
|
scripts: ["deadcode:dependencies", "deadcode:unused-files"],
|
|
});
|
|
expect(currentWithoutExports.status).toBe(1);
|
|
// The missing-script contract violation now fails fast before launching
|
|
// the concurrent scans instead of wasting two Knip runs first.
|
|
expect(currentWithoutExports.calls).toEqual([]);
|
|
expect(currentWithoutExports.output).toContain(
|
|
"Current CI targets must provide the deadcode:exports package script.",
|
|
);
|
|
|
|
const legacy = runDependencyCheckFixture({
|
|
historicalTarget: true,
|
|
scripts: ["deadcode:ci"],
|
|
});
|
|
expect(legacy.status, legacy.output).toBe(0);
|
|
expect(legacy.calls).toEqual(["deadcode:ci"]);
|
|
|
|
const incompleteCurrent = runDependencyCheckFixture({
|
|
historicalTarget: false,
|
|
scripts: ["deadcode:dependencies"],
|
|
});
|
|
expect(incompleteCurrent.status).toBe(1);
|
|
expect(incompleteCurrent.calls).toEqual([]);
|
|
expect(incompleteCurrent.output).toContain(
|
|
"Target does not provide a supported deadcode check.",
|
|
);
|
|
});
|
|
|
|
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;
|
|
|
|
expect(coverageStep.run).toBe("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 checkShardRun = workflow.jobs["check-shard"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Run check shard",
|
|
).run;
|
|
|
|
expect(checkShardRun).toContain('if [ "$(nproc)" -lt 8 ]; then');
|
|
expect(checkShardRun).toContain("lint_args=(--split-core --threads=1)");
|
|
expect(checkShardRun).toContain('pnpm lint "${lint_args[@]}"');
|
|
expect(checkShardRun).toContain('node scripts/run-oxlint-shards.mjs "${lint_args[@]}"');
|
|
});
|
|
|
|
it("runs the suppression-baseline max-lines ratchet against the exact tested tree", () => {
|
|
const workflow = readCiWorkflow();
|
|
const checksFastJob = workflow.jobs["checks-fast-core"];
|
|
const checksFastSteps = checksFastJob.steps;
|
|
const checkout = checksFastSteps.find((step: WorkflowStep) => step.name === "Checkout");
|
|
const checksFastRun = checksFastSteps.find(
|
|
(step: WorkflowStep) => step.name === "Run ${{ matrix.task }} (${{ matrix.runtime }})",
|
|
);
|
|
const releaseGateMerge = checksFastSteps.find(
|
|
(step: WorkflowStep) => step.name === "Prepare release-gate max-lines merge tree",
|
|
);
|
|
expect(
|
|
checksFastSteps.some((step: WorkflowStep) => step.name === "Resolve manual protocol base"),
|
|
).toBe(false);
|
|
|
|
expect(workflow.jobs["checks-fast-core"].permissions).toEqual({
|
|
contents: "read",
|
|
"pull-requests": "read",
|
|
});
|
|
expect(checksFastJob.env.CHECKOUT_BASE_SHA).toBe(
|
|
"${{ matrix.task == 'max-lines-ratchet' && needs.preflight.outputs.diff_base_revision || '' }}",
|
|
);
|
|
expect(checkout.run).toContain(
|
|
'fetch_refs+=("+${CHECKOUT_BASE_SHA}:refs/remotes/origin/ci-max-lines-base")',
|
|
);
|
|
expect(checkout.run).toContain('"${fetch_refs[@]}" || return 1');
|
|
expect(releaseGateMerge.if).toBe(
|
|
"matrix.task == 'max-lines-ratchet' && github.event_name == 'workflow_dispatch' && inputs.release_gate",
|
|
);
|
|
expect(checksFastRun.run).toContain("max-lines-ratchet)");
|
|
expect(checksFastRun.run).toContain('has_package_script "check:max-lines-ratchet"');
|
|
expect(checksFastRun.env.RATCHET_PR_HEAD_SHA).toBe(
|
|
"${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}",
|
|
);
|
|
expect(checksFastRun.env).not.toHaveProperty("RATCHET_EVENT_BASE_SHA");
|
|
expect(checksFastRun.env).not.toHaveProperty("RATCHET_MANUAL_TARGET_SHA");
|
|
expect(checksFastRun.env).not.toHaveProperty("GH_TOKEN");
|
|
expect(checksFastRun.env).not.toHaveProperty("PROTOCOL_MANUAL_BASE_SHA");
|
|
expect(checksFastRun.env.PROTOCOL_SINCE_BASE_SHA).toBe(
|
|
"${{ needs.preflight.outputs.diff_base_revision }}",
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}"',
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
"release-gate pull request must be open and match the target head",
|
|
);
|
|
expect(releaseGateMerge.run).toContain("for attempt in {1..6}");
|
|
expect(releaseGateMerge.run).toContain(
|
|
'"+refs/pull/${PULL_REQUEST_NUMBER}/merge:refs/remotes/origin/ci-max-lines-merge"',
|
|
);
|
|
expect(releaseGateMerge.run).toContain('"$merge_head" == "$TARGET_SHA"');
|
|
expect(releaseGateMerge.run).toContain('git show -s --format=%P "$merge_sha"');
|
|
expect(releaseGateMerge.run).toContain(
|
|
"timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=2 origin \\",
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
"Freeze GitHub's canonical merge snapshot once it contains the exact head",
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
"Base freshness belongs to the landing gate; chasing moving main here can never converge",
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
"release-gate merge tree did not refresh to the target head",
|
|
);
|
|
expect(releaseGateMerge.run).not.toContain(".base.sha");
|
|
expect(releaseGateMerge.run).toContain('git checkout --detach "$merge_sha"');
|
|
expect(releaseGateMerge.run).toContain(
|
|
'echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV"',
|
|
);
|
|
expect(releaseGateMerge.run).toContain(
|
|
'echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"',
|
|
);
|
|
expect(checksFastRun.run).not.toContain("PROTOCOL_MANUAL_BASE_SHA");
|
|
expect(checksFastRun.run).toContain(
|
|
'"+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base"',
|
|
);
|
|
expect(checksFastRun.run).toContain(
|
|
'base_ref="${RATCHET_BASE_REF:-refs/remotes/origin/ci-max-lines-base}"',
|
|
);
|
|
expect(checksFastRun.run).toContain('git cat-file -e "${base_ref}^{commit}"');
|
|
expect(checksFastRun.run).toContain(
|
|
"mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p')",
|
|
);
|
|
expect(checksFastRun.run).toContain('"${#merge_parents[@]}" != "2"');
|
|
expect(checksFastRun.run).toContain('"${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA"');
|
|
expect(checksFastRun.run).toContain('prepared_base="$(git rev-parse "$base_ref")"');
|
|
expect(checksFastRun.run).toContain('"${merge_parents[0]}" != "$prepared_base"');
|
|
expect(checksFastRun.run).not.toContain("ci-max-lines-target^");
|
|
expect(checksFastRun.run).not.toContain("resolve_manual_merge_base");
|
|
expect(checksFastRun.run).not.toContain("+${merge_base}:refs/remotes/origin/ci-max-lines-base");
|
|
expect(checksFastRun.run).toContain('pnpm check:max-lines-ratchet --base "$base_ref"');
|
|
expect(checksFastRun.run).toContain(
|
|
'if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then',
|
|
);
|
|
expect(checksFastRun.run).toContain(
|
|
"node scripts/run-oxlint.mjs src ui/src packages extensions",
|
|
);
|
|
|
|
const fastOnly = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
eventName: "pull_request",
|
|
historicalCompatibility: false,
|
|
nodeFastOnly: true,
|
|
nodeFastPluginContracts: true,
|
|
});
|
|
expect(fastOnly.status, fastOnly.output).toBe(0);
|
|
expect(fastOnly.outputs.run_check).toBe("false");
|
|
expect(fastOnly.outputs.run_checks_fast_core).toBe("true");
|
|
expect(
|
|
JSON.parse(expectDefined(fastOnly.outputs.checks_fast_core_matrix, "fast-only checks matrix"))
|
|
.include,
|
|
).toEqual([
|
|
{
|
|
check_name: "checks-fast-max-lines-ratchet",
|
|
runtime: "node",
|
|
task: "max-lines-ratchet",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses target-owned CI plans and capabilities for older release checkouts", () => {
|
|
const androidRun = readCiWorkflow().jobs.android.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run Android ${{ matrix.task }}",
|
|
).run;
|
|
expect(androidRun).toContain("build-play-compat)");
|
|
expect(androidRun).toContain("test-play-compat)");
|
|
expect(androidRun).toContain(":app:assemblePlayDebug");
|
|
|
|
const legacy = runCiManifestFixture({ bundledPlanner: false });
|
|
expect(legacy.status, legacy.output).toBe(0);
|
|
expect(legacy.outputs.historical_target).toBe("true");
|
|
expect(legacy.outputs.use_compatible_android_ci).toBe("true");
|
|
expect(legacy.outputs.run_ios_build).toBe("false");
|
|
expect(legacy.outputs.run_native_i18n).toBe("false");
|
|
expect(legacy.outputs.run_openclawkit_tests).toBe("false");
|
|
expect(legacy.outputs.run_qa_smoke_ci).toBe("false");
|
|
expect(legacy.outputs.run_channel_contracts_shards).toBe("false");
|
|
expect(legacy.outputs.run_protocol_event_coverage).toBe("false");
|
|
expect(
|
|
JSON.parse(expectDefined(legacy.outputs.android_matrix, "legacy Android matrix output"))
|
|
.include,
|
|
).toEqual([
|
|
{ check_name: "android-test-play", task: "test-play-compat" },
|
|
{ check_name: "android-test-third-party", task: "test-third-party" },
|
|
{ check_name: "android-build-play", task: "build-play-compat" },
|
|
]);
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
legacy.outputs.checks_node_core_nondist_matrix,
|
|
"legacy node core nondist matrix output",
|
|
),
|
|
).include,
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
check_name: "legacy-node-plan",
|
|
shard_name: "legacy-node-plan",
|
|
}),
|
|
);
|
|
|
|
const current = runCiManifestFixture({ bundledPlanner: true });
|
|
expect(current.status, current.output).toBe(0);
|
|
expect(current.outputs.use_compatible_android_ci).toBe("false");
|
|
expect(current.outputs.run_ios_build).toBe("true");
|
|
expect(current.outputs.run_native_i18n).toBe("true");
|
|
expect(current.outputs.run_openclawkit_tests).toBe("true");
|
|
expect(current.outputs.run_qa_smoke_ci).toBe("true");
|
|
expect(current.outputs.run_channel_contracts_shards).toBe("true");
|
|
expect(current.outputs.run_protocol_event_coverage).toBe("true");
|
|
expect(current.outputs.run_format_check).toBe("true");
|
|
expect(
|
|
JSON.parse(expectDefined(current.outputs.android_matrix, "current Android matrix output"))
|
|
.include,
|
|
).toEqual([
|
|
{ check_name: "android-test-play", task: "test-play" },
|
|
{ check_name: "android-test-third-party", task: "test-third-party" },
|
|
{ check_name: "android-test-wear", task: "test-wear" },
|
|
{ check_name: "android-build-play", task: "build-play" },
|
|
{ check_name: "android-build-wear", task: "build-wear" },
|
|
{ check_name: "android-ktlint", task: "ktlint" },
|
|
]);
|
|
|
|
const releaseCandidateCurrent = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
historicalCompatibility: false,
|
|
releaseCandidateCompatibility: true,
|
|
});
|
|
expect(releaseCandidateCurrent.status, releaseCandidateCurrent.output).toBe(0);
|
|
expect(releaseCandidateCurrent.outputs.compatibility_target).toBe("true");
|
|
expect(releaseCandidateCurrent.outputs.use_compatible_android_ci).toBe("false");
|
|
|
|
const currentMissingAndroidCapabilities = runCiManifestFixture({
|
|
androidCiCapabilities: false,
|
|
bundledPlanner: true,
|
|
eventName: "pull_request",
|
|
});
|
|
expect(currentMissingAndroidCapabilities.status, currentMissingAndroidCapabilities.output).toBe(
|
|
0,
|
|
);
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
currentMissingAndroidCapabilities.outputs.android_matrix,
|
|
"current fallback-resistant Android matrix output",
|
|
),
|
|
).include,
|
|
).toEqual([
|
|
{ check_name: "android-test-play", task: "test-play" },
|
|
{ check_name: "android-test-third-party", task: "test-third-party" },
|
|
{ check_name: "android-test-wear", task: "test-wear" },
|
|
{ check_name: "android-build-play", task: "build-play" },
|
|
{ check_name: "android-build-wear", task: "build-wear" },
|
|
{ check_name: "android-ktlint", task: "ktlint" },
|
|
]);
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
current.outputs.checks_node_core_nondist_matrix,
|
|
"current node core nondist matrix output",
|
|
),
|
|
).include,
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
check_name: "bundled-node-plan",
|
|
shard_name: "bundled-node-plan",
|
|
}),
|
|
);
|
|
|
|
const changedPullRequest = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
changedPaths: ["src/focused.ts"],
|
|
eventName: "pull_request",
|
|
});
|
|
expect(changedPullRequest.status, changedPullRequest.output).toBe(0);
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
changedPullRequest.outputs.checks_node_core_nondist_matrix,
|
|
"changed PR node matrix output",
|
|
),
|
|
).include,
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
check_name: "changed-node-plan",
|
|
shard_name: "changed-node-plan",
|
|
targets: ["src/focused.test.ts"],
|
|
}),
|
|
]);
|
|
expect(changedPullRequest.outputs.run_checks_node_core_dist).toBe("true");
|
|
|
|
const plannerImportFailure = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
changedPaths: ["src/focused.ts"],
|
|
changedPlannerImportFails: true,
|
|
eventName: "pull_request",
|
|
});
|
|
expect(plannerImportFailure.status, plannerImportFailure.output).toBe(0);
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
plannerImportFailure.outputs.checks_node_core_nondist_matrix,
|
|
"planner import fallback node matrix output",
|
|
),
|
|
).include,
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
check_name: "bundled-node-plan",
|
|
shard_name: "bundled-node-plan",
|
|
}),
|
|
]);
|
|
|
|
const currentMissingIos = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
eventName: "pull_request",
|
|
iosCapabilities: false,
|
|
});
|
|
expect(currentMissingIos.status, currentMissingIos.output).toBe(0);
|
|
expect(currentMissingIos.outputs.historical_target).toBe("false");
|
|
expect(currentMissingIos.outputs.run_ios_build).toBe("true");
|
|
expect(currentMissingIos.outputs.run_macos_swift).toBe("true");
|
|
|
|
const currentMissingQaPlan = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
eventName: "pull_request",
|
|
qaSmokePlan: false,
|
|
});
|
|
expect(currentMissingQaPlan.status, currentMissingQaPlan.output).toBe(0);
|
|
expect(currentMissingQaPlan.outputs.run_qa_smoke_ci).toBe("true");
|
|
|
|
const frozenMissingCurrentCapabilities = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
historicalCompatibility: false,
|
|
iosCapabilities: false,
|
|
iosBuildCapability: true,
|
|
nativeI18nCapabilities: false,
|
|
protocolCoverage: false,
|
|
qaSmokePlan: false,
|
|
formatCheck: false,
|
|
});
|
|
expect(frozenMissingCurrentCapabilities.status, frozenMissingCurrentCapabilities.output).toBe(
|
|
0,
|
|
);
|
|
expect(frozenMissingCurrentCapabilities.outputs.historical_target).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_ios_build).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_macos_swift).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_native_i18n).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_qa_smoke_ci).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_protocol_event_coverage).toBe("false");
|
|
expect(frozenMissingCurrentCapabilities.outputs.run_format_check).toBe("false");
|
|
|
|
const releaseCandidateMissingSwiftWrappers = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
historicalCompatibility: false,
|
|
iosCapabilities: false,
|
|
iosBuildCapability: true,
|
|
releaseCandidateCompatibility: true,
|
|
});
|
|
expect(releaseCandidateMissingSwiftWrappers.status).toBe(0);
|
|
expect(releaseCandidateMissingSwiftWrappers.outputs.compatibility_target).toBe("true");
|
|
expect(releaseCandidateMissingSwiftWrappers.outputs.run_ios_build).toBe("true");
|
|
expect(releaseCandidateMissingSwiftWrappers.outputs.run_macos_swift).toBe("true");
|
|
|
|
const releaseCandidateMissingIosBuild = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
historicalCompatibility: false,
|
|
iosCapabilities: false,
|
|
iosBuildCapability: false,
|
|
releaseCandidateCompatibility: true,
|
|
});
|
|
expect(releaseCandidateMissingIosBuild.status).toBe(0);
|
|
expect(releaseCandidateMissingIosBuild.outputs.run_ios_build).toBe("false");
|
|
|
|
const legacyReleaseCandidate = runCiManifestFixture({
|
|
bundledPlanner: false,
|
|
historicalCompatibility: false,
|
|
releaseCandidateCompatibility: true,
|
|
});
|
|
expect(legacyReleaseCandidate.status, legacyReleaseCandidate.output).toBe(0);
|
|
expect(legacyReleaseCandidate.outputs.compatibility_target).toBe("true");
|
|
expect(
|
|
JSON.parse(
|
|
expectDefined(
|
|
legacyReleaseCandidate.outputs.checks_node_core_nondist_matrix,
|
|
"release candidate node core nondist matrix output",
|
|
),
|
|
).include,
|
|
).toContainEqual(expect.objectContaining({ check_name: "legacy-node-plan" }));
|
|
|
|
const currentMissingProtocolCoverage = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
historicalCompatibility: false,
|
|
protocolCoverage: false,
|
|
});
|
|
expect(currentMissingProtocolCoverage.status, currentMissingProtocolCoverage.output).toBe(0);
|
|
expect(currentMissingProtocolCoverage.outputs.historical_target).toBe("false");
|
|
expect(currentMissingProtocolCoverage.outputs.run_protocol_event_coverage).toBe("false");
|
|
|
|
const pullRequestMissingProtocolCoverage = runCiManifestFixture({
|
|
bundledPlanner: true,
|
|
eventName: "pull_request",
|
|
protocolCoverage: false,
|
|
});
|
|
expect(
|
|
pullRequestMissingProtocolCoverage.status,
|
|
pullRequestMissingProtocolCoverage.output,
|
|
).toBe(0);
|
|
expect(pullRequestMissingProtocolCoverage.outputs.historical_target).toBe("false");
|
|
expect(pullRequestMissingProtocolCoverage.outputs.run_protocol_event_coverage).toBe("true");
|
|
|
|
const currentMissingPlanner = runCiManifestFixture({
|
|
bundledPlanner: false,
|
|
eventName: "pull_request",
|
|
});
|
|
expect(currentMissingPlanner.status).not.toBe(0);
|
|
expect(currentMissingPlanner.output).toContain(
|
|
"CI target does not export a supported Node test shard planner",
|
|
);
|
|
|
|
const alternateMissingPlanner = runCiManifestFixture({
|
|
bundledPlanner: false,
|
|
historicalCompatibility: false,
|
|
});
|
|
expect(alternateMissingPlanner.status).not.toBe(0);
|
|
expect(alternateMissingPlanner.output).toContain(
|
|
"CI target does not export a supported Node test shard planner",
|
|
);
|
|
|
|
const workflow = readCiWorkflow();
|
|
const historicalTargetStep = workflow.jobs.preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Validate historical release target",
|
|
);
|
|
expect(historicalTargetStep.if).toBe("inputs.historical_target_tag != ''");
|
|
expect(historicalTargetStep.run).toContain('git ls-remote --tags "$remote"');
|
|
expect(historicalTargetStep.run).toContain('[[ "$tag_sha" != "$EXPECTED_SHA" ]]');
|
|
const releaseCandidateStep = workflow.jobs.preflight.steps.find(
|
|
(step: { name?: string }) => step.name === "Validate release candidate target",
|
|
);
|
|
expect(releaseCandidateStep.if).toBe("inputs.release_candidate_ref != ''");
|
|
expect(releaseCandidateStep.run).toContain('git ls-remote --heads "$remote"');
|
|
expect(releaseCandidateStep.run).toContain('[[ "$branch_sha" != "$EXPECTED_SHA" ]]');
|
|
expect(workflow.jobs["qa-smoke-ci-profile"].if).toBe(
|
|
"needs.preflight.outputs.run_qa_smoke_ci == 'true'",
|
|
);
|
|
expect(workflow.jobs["checks-fast-channel-contracts-shard"].if).toBe(
|
|
"needs.preflight.outputs.run_channel_contracts_shards == 'true'",
|
|
);
|
|
const swiftInstall = workflow.jobs["macos-swift"].steps.find(
|
|
(step: { name?: string }) => step.name === "Install XcodeGen / SwiftLint / SwiftFormat",
|
|
);
|
|
const swiftLint = workflow.jobs["macos-swift"].steps.find(
|
|
(step: { name?: string }) => step.name === "Swift lint",
|
|
);
|
|
const openClawKitTests = workflow.jobs["macos-swift"].steps.find(
|
|
(step: { name?: string }) => step.name === "OpenClawKit tests",
|
|
);
|
|
expect(swiftInstall.run).toContain("brew install xcodegen swiftlint");
|
|
expect(swiftInstall.run).not.toContain("brew install xcodegen swiftlint swiftformat");
|
|
expect(swiftInstall.run).toContain(
|
|
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip",
|
|
);
|
|
expect(swiftInstall.run).toContain(
|
|
'swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"',
|
|
);
|
|
expect(swiftInstall.run).toContain(
|
|
'swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"',
|
|
);
|
|
expect(swiftInstall.run).toContain(
|
|
'swiftformat_min_version="$(awk \'$1 == "--min-version" { print $2; exit }\' config/swiftformat)"',
|
|
);
|
|
expect(swiftInstall.run).toContain(
|
|
'echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2',
|
|
);
|
|
expect(swiftInstall.run).toContain('echo "$swift_tools_dir" >> "$GITHUB_PATH"');
|
|
expect(swiftInstall.run).toContain(
|
|
'[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]',
|
|
);
|
|
expect(workflow.jobs["macos-swift"].env.HISTORICAL_TARGET).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target }}",
|
|
);
|
|
expect(swiftInstall.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]');
|
|
expect(swiftLint.run).toContain("swiftlint lint --config config/swiftlint.yml");
|
|
expect(swiftLint.run).toContain('elif [[ "$HISTORICAL_TARGET" == "true" ]]');
|
|
expect(openClawKitTests.if).toBe("needs.preflight.outputs.run_openclawkit_tests == 'true'");
|
|
|
|
const checkShard = workflow.jobs["check-shard"].steps.find(
|
|
(step: { name?: string }) => step.name === "Run check shard",
|
|
);
|
|
expect(checkShard.env.HISTORICAL_TARGET).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target }}",
|
|
);
|
|
expect(checkShard.run).toContain("pnpm tsgo:scripts");
|
|
expect(checkShard.run).toContain('elif [[ "$HISTORICAL_TARGET" != "true" ]]');
|
|
expect(checkShard.run).toContain('has_package_script "deps:npm-lock:check"');
|
|
expect(checkShard.run).toContain(
|
|
"Current CI targets must provide the deps:npm-lock:check package script.",
|
|
);
|
|
expect(checkShard.run).toContain(
|
|
"[skip] historical target predates the transient npm lock contract",
|
|
);
|
|
expect(checkShard.run).toContain('has_package_script "deadcode:dependencies"');
|
|
expect(checkShard.run).toContain('has_package_script "deadcode:unused-files"');
|
|
expect(checkShard.run).toContain('has_package_script "deadcode:exports"');
|
|
// The concurrent launcher invokes scripts through the dc_scripts array.
|
|
expect(checkShard.run).toContain("dc_scripts+=(deadcode:exports)");
|
|
expect(checkShard.run).toContain(
|
|
"Current CI targets must provide the deadcode:exports package script.",
|
|
);
|
|
expect(checkShard.run).toContain(
|
|
'elif [[ "$HISTORICAL_TARGET" == "true" ]] && has_package_script "deadcode:ci"',
|
|
);
|
|
expect(checkShard.run).toContain("Target does not provide a supported deadcode check.");
|
|
|
|
const uiInstall = workflow.jobs["checks-ui"].steps.find(
|
|
(step: { name?: string }) => step.name === "Install Playwright Chromium",
|
|
);
|
|
const uiTest = workflow.jobs["checks-ui"].steps.find(
|
|
(step: { name?: string }) => step.name === "Test Control UI",
|
|
);
|
|
expect(workflow.jobs["checks-ui"].env.COMPATIBILITY_TARGET).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target }}",
|
|
);
|
|
expect(uiInstall.run).toContain('if [[ "$COMPATIBILITY_TARGET" == "true" ]]');
|
|
expect(uiInstall.run).toContain("pnpm --dir ui exec playwright install chromium");
|
|
expect(uiInstall.run).toContain("node scripts/ensure-playwright-chromium.mjs");
|
|
expect(uiInstall.run).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM");
|
|
expect(uiTest.run).toContain('if [[ "$COMPATIBILITY_TARGET" == "true" ]]');
|
|
expect(uiTest.run).toContain("pnpm --dir ui test --testTimeout=30000 --isolate");
|
|
expect(uiTest.run).not.toContain("--retry");
|
|
expect(uiTest.run).toContain("pnpm --dir ui test");
|
|
});
|
|
|
|
it("gates current Control UI changes on the full mocked Chromium E2E suite", () => {
|
|
const workflow = readCiWorkflow();
|
|
const ui = workflow.jobs["checks-ui"];
|
|
const uiE2e = workflow.jobs["checks-ui-e2e"];
|
|
|
|
expect(uiE2e.permissions).toEqual({ contents: "read" });
|
|
expect(uiE2e.needs).toEqual(["preflight"]);
|
|
expect(uiE2e.if).toBe(
|
|
"needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'",
|
|
);
|
|
expect(uiE2e["runs-on"]).toContain("blacksmith-8vcpu-ubuntu-2404");
|
|
expect(uiE2e["runs-on"]).not.toBe(ui["runs-on"]);
|
|
// Each Chromium worker keeps serial file ownership while all four shards
|
|
// together remain required by the aggregate CI gate.
|
|
expect(uiE2e["timeout-minutes"]).toBe(25);
|
|
expect(uiE2e.strategy).toEqual({
|
|
"fail-fast": false,
|
|
"max-parallel": 4,
|
|
matrix: { shard: [1, 2, 3, 4] },
|
|
});
|
|
expect(workflow.jobs["ci-gate"].needs).toContain("checks-ui-e2e");
|
|
|
|
const uiSetup = expectDefined(
|
|
ui.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
|
"Control UI Node setup",
|
|
);
|
|
const uiE2eSetup = expectDefined(
|
|
uiE2e.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
|
"Control UI E2E Node setup",
|
|
);
|
|
expect(uiE2eSetup.uses).toBe("./.github/actions/setup-node-env");
|
|
expect(uiE2eSetup.with).toEqual(uiSetup.with);
|
|
|
|
const chromiumInstall = expectDefined(
|
|
uiE2e.steps.find((step: WorkflowStep) => step.name === "Install Playwright Chromium"),
|
|
"Control UI E2E Chromium installation",
|
|
);
|
|
expect(chromiumInstall.run).toBe("node scripts/ensure-playwright-chromium.mjs");
|
|
|
|
const scenario = expectDefined(
|
|
uiE2e.steps.find((step: WorkflowStep) => step.name === "Test Control UI end-to-end"),
|
|
"Control UI E2E suite",
|
|
);
|
|
expect(scenario.run).toBe(
|
|
"node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner --shard ${{ matrix.shard }}/4",
|
|
);
|
|
const browserCopilot = expectDefined(
|
|
uiE2e.steps.find((step: WorkflowStep) => step.name === "Test browser copilot end-to-end"),
|
|
"browser copilot E2E suite",
|
|
);
|
|
expect(browserCopilot.if).toBe("matrix.shard == 1");
|
|
expect(browserCopilot.run).toBe("pnpm test:e2e:browser-copilot");
|
|
expect(JSON.stringify(uiE2e)).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM");
|
|
});
|
|
|
|
it("does not rebuild Control UI after build:ci-artifacts", () => {
|
|
const workflow = readCiWorkflow();
|
|
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;
|
|
const buildDistStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Build dist",
|
|
);
|
|
|
|
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,
|
|
);
|
|
});
|
|
|
|
it("keeps source-only Control UI locale drift advisory", () => {
|
|
const workflow = readCiWorkflow();
|
|
const workflowSource = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
const buildArtifactSteps = workflow.jobs["build-artifacts"].steps;
|
|
const localeJob = workflow.jobs["control-ui-i18n"];
|
|
const sourceStep = localeJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify Control UI i18n source",
|
|
);
|
|
const localeStep = localeJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Check Control UI locale parity",
|
|
);
|
|
|
|
expect(buildArtifactSteps).not.toContainEqual(
|
|
expect.objectContaining({ run: "pnpm ui:i18n:check" }),
|
|
);
|
|
expect(JSON.parse(readFileSync("package.json", "utf8")).scripts["test:ui"]).not.toContain(
|
|
"ui:i18n:check",
|
|
);
|
|
expect(workflowSource.match(/pnpm ui:i18n:verify/gu)).toHaveLength(1);
|
|
expect(workflowSource.match(/pnpm ui:i18n:check/gu)).toHaveLength(1);
|
|
expect(readFileSync("ui/src/i18n/test/translate.test.ts", "utf8")).not.toContain(
|
|
"keeps shipped locales structurally aligned with English",
|
|
);
|
|
expect(localeJob.needs).toEqual(["preflight"]);
|
|
expect(localeJob.if).toBe("needs.preflight.outputs.run_control_ui_i18n == 'true'");
|
|
expect(localeJob["continue-on-error"]).toBeUndefined();
|
|
expect(localeJob.env.COMPATIBILITY_TARGET).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target }}",
|
|
);
|
|
expect(sourceStep["continue-on-error"]).toBeUndefined();
|
|
const compatibilityWithoutVerify = runControlUiI18nSourceFixture({
|
|
compatibilityTarget: true,
|
|
hasVerifyScript: false,
|
|
});
|
|
expect(compatibilityWithoutVerify.status, compatibilityWithoutVerify.output).toBe(0);
|
|
expect(compatibilityWithoutVerify.calls).toEqual([]);
|
|
expect(compatibilityWithoutVerify.summary).toContain(
|
|
"Skipping ui:i18n:verify: unavailable on the selected compatibility target.",
|
|
);
|
|
|
|
const currentWithoutVerify = runControlUiI18nSourceFixture({
|
|
compatibilityTarget: false,
|
|
hasVerifyScript: false,
|
|
});
|
|
expect(currentWithoutVerify.status).toBe(1);
|
|
expect(currentWithoutVerify.calls).toEqual([]);
|
|
expect(currentWithoutVerify.output).toContain(
|
|
"ui:i18n:verify is required for non-compatibility targets.",
|
|
);
|
|
|
|
const currentWithVerify = runControlUiI18nSourceFixture({
|
|
compatibilityTarget: false,
|
|
hasVerifyScript: true,
|
|
});
|
|
expect(currentWithVerify.status, currentWithVerify.output).toBe(0);
|
|
expect(currentWithVerify.calls).toEqual(["ui:i18n:verify"]);
|
|
expect(localeStep["continue-on-error"]).toBe(
|
|
"${{ needs.preflight.outputs.strict_control_ui_i18n != 'true' }}",
|
|
);
|
|
expect(localeStep.run).toBe("pnpm ui:i18n:check");
|
|
expect(readFileSync(".github/workflows/full-release-validation.yml", "utf8")).toContain(
|
|
'dispatch_and_wait ci.yml "$dispatch_run_name"',
|
|
);
|
|
});
|
|
|
|
it("splits native source verification from generated locale parity", () => {
|
|
const workflow = readCiWorkflow();
|
|
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;
|
|
|
|
expect(packageScripts["native:i18n:baseline"]).toContain("baseline --write");
|
|
expect(packageScripts["native:i18n:verify"]).toContain(" verify");
|
|
expect(workflow.jobs.preflight.outputs.strict_native_i18n).toContain(
|
|
"steps.changed_scope.outputs.strict_native_i18n",
|
|
);
|
|
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");
|
|
});
|
|
|
|
it("keeps the hosted plugin-list memory allowance scoped to GitHub-hosted runners", () => {
|
|
const workflow = readCiWorkflow();
|
|
const startupMemoryStep = workflow.jobs["build-artifacts"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Check CLI startup memory",
|
|
);
|
|
|
|
expect(startupMemoryStep.env.OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB).toBe(
|
|
"${{ runner.environment == 'github-hosted' && '425' || '400' }}",
|
|
);
|
|
});
|
|
|
|
it("runs the Doctor plugin-index persistence proof against the built CLI", () => {
|
|
const workflow = readCiWorkflow();
|
|
const proofStep = workflow.jobs["build-artifacts"].steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify built Doctor plugin index persistence",
|
|
);
|
|
|
|
expect(proofStep.run).toContain(
|
|
"test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts",
|
|
);
|
|
expect(proofStep.run).toContain("--config test/vitest/vitest.e2e.config.ts");
|
|
expect(proofStep.run).toContain("Selected target predates");
|
|
});
|
|
|
|
it("restores the dist build cache before building and saves only cache misses", () => {
|
|
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 saveStep = buildArtifactSteps.find(
|
|
(step: WorkflowStep) => step.name === "Save dist build cache",
|
|
);
|
|
|
|
expect(stepNames.indexOf("Restore dist build cache")).toBeLessThan(
|
|
stepNames.indexOf("Build dist"),
|
|
);
|
|
expect(stepNames.indexOf("Build dist")).toBeLessThan(
|
|
stepNames.indexOf("Pack built runtime artifacts"),
|
|
);
|
|
expect(stepNames.indexOf("Run built artifact checks")).toBeLessThan(
|
|
stepNames.indexOf("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@27d5ce7f107fe9357f9df03efb73ab90386fccae");
|
|
expect(saveStep.if).toBe("steps.dist_build_cache.outputs.cache-hit != 'true'");
|
|
expect(saveStep.with.key).toBe("${{ steps.dist_build_cache.outputs.cache-primary-key }}");
|
|
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(
|
|
buildArtifactSteps.find((step: WorkflowStep) => step.name === "Pack built runtime artifacts")
|
|
.run,
|
|
).toContain("packages/*/dist");
|
|
expect(restoreStep.with.path).toContain("extensions/*/src/host/**/.bundle.hash");
|
|
expect(restoreStep.with.path).toContain("extensions/*/src/host/**/*.bundle.js");
|
|
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 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-");
|
|
});
|
|
|
|
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");
|
|
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 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(tuiPty).toBeGreaterThan(hostedGatewayWait);
|
|
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()");
|
|
expect(run.match(/wait_checks$/gmu)).toHaveLength(3);
|
|
});
|
|
|
|
it("keeps docs i18n CI on the workflow-owned patched Go toolchain", () => {
|
|
const workflow = readCiWorkflow();
|
|
const nodeTestJob = workflow.jobs["checks-node-core-test-nondist-shard"];
|
|
const setupGoStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Setup Go for docs i18n",
|
|
);
|
|
const verifyGoStep = nodeTestJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify docs i18n Go toolchain",
|
|
);
|
|
expect(setupGoStep).toMatchObject({
|
|
if: "matrix.requires_go == true",
|
|
uses: SETUP_GO_V6,
|
|
with: {
|
|
"go-version": "1.25.12",
|
|
"cache-dependency-path": "scripts/docs-i18n/go.sum",
|
|
},
|
|
});
|
|
expect(setupGoStep.with).not.toHaveProperty("go-version-file");
|
|
expect(verifyGoStep).toMatchObject({
|
|
if: "matrix.requires_go == true",
|
|
run: 'test "$(go env GOVERSION)" = "go1.25.12"',
|
|
});
|
|
|
|
const goMod = readTrackedText("scripts/docs-i18n/go.mod");
|
|
expect(goMod).toMatch(/^go 1\.25\.0$/mu);
|
|
expect(goMod).toMatch(/^toolchain go1\.25\.12$/mu);
|
|
});
|
|
|
|
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",
|
|
);
|
|
|
|
expect(JSON.stringify(preflightJob.steps)).toContain("timeout_minutes: shard.timeoutMinutes");
|
|
expect(manifestStep.run).toContain(
|
|
'shard.groups?.some((group) => group.shard_name.startsWith("core-tooling"))',
|
|
);
|
|
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).toBe("1");
|
|
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_VITEST_ARGS_JSON).toBe(
|
|
"${{ needs.preflight.outputs.compatibility_target == 'true' && '[\"--hookTimeout=600000\"]' || '[]' }}",
|
|
);
|
|
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.mjs') == '' }}",
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
ref: "${{ github.workflow_sha }}",
|
|
path: ".ci-workflow",
|
|
"sparse-checkout": expect.stringContaining("scripts/ci-run-node-test-shard.mjs"),
|
|
"sparse-checkout-cone-mode": false,
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
expect(existsSync("scripts/ci-run-node-test-shard.mjs")).toBe(true);
|
|
});
|
|
|
|
it("emits one final CI gate after every selected lane", () => {
|
|
const workflow = readCiWorkflow();
|
|
const gate = workflow.jobs["ci-gate"];
|
|
const requiredJobs = ["preflight", "security-fast"];
|
|
const selectedJobs = [
|
|
"pnpm-store-warmup",
|
|
"build-artifacts",
|
|
"native-i18n",
|
|
"checks-ui",
|
|
"checks-ui-e2e",
|
|
"control-ui-i18n",
|
|
"checks-fast-core",
|
|
"qa-smoke-ci-profile",
|
|
"checks-fast-plugin-contracts-shard",
|
|
"checks-fast-channel-contracts-shard",
|
|
"checks-node-compat",
|
|
"checks-node-core-test-nondist-shard",
|
|
"check-shard",
|
|
"check-additional-shard",
|
|
"check-docs",
|
|
"skills-python",
|
|
"checks-windows",
|
|
"macos-node",
|
|
"macos-swift",
|
|
"ios-build",
|
|
"android",
|
|
];
|
|
|
|
expect(workflow.on.pull_request).not.toHaveProperty("paths-ignore");
|
|
expect(gate.name).toBe("openclaw/ci-gate");
|
|
expect(gate.needs).toEqual([...requiredJobs, ...selectedJobs]);
|
|
expect(gate.needs.toSorted()).toEqual(
|
|
Object.keys(workflow.jobs)
|
|
.filter((job) => job !== "ci-gate" && job !== "ci-timings-summary")
|
|
.toSorted(),
|
|
);
|
|
expect(gate.if).toBe(
|
|
"${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}",
|
|
);
|
|
expect(gate["runs-on"]).toBe("ubuntu-24.04");
|
|
expect(gate.permissions).toEqual({ contents: "read" });
|
|
|
|
const verifyStep = gate.steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify selected CI lanes",
|
|
);
|
|
expect(Object.keys(verifyStep.env).toSorted()).toEqual([
|
|
"REQUIRED_RESULTS",
|
|
"SELECTED_RESULTS",
|
|
]);
|
|
for (const job of requiredJobs) {
|
|
expect(verifyStep.env.REQUIRED_RESULTS).toContain(`${job}=\${{ needs.${job}.result }}`);
|
|
}
|
|
for (const job of selectedJobs) {
|
|
expect(verifyStep.env.SELECTED_RESULTS).toContain(`${job}=\${{ needs.${job}.result }}`);
|
|
}
|
|
expect(verifyStep.run).toContain("Required CI job did not succeed");
|
|
expect(verifyStep.run).toContain("success | skipped");
|
|
expect(verifyStep.run).toContain("Selected CI job did not succeed");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"accepts only successful required jobs and successful or skipped selected jobs",
|
|
() => {
|
|
const passing = runCiGateFixture(
|
|
"preflight=success\nsecurity-fast=success",
|
|
"checks-ui=success\nmacos-swift=skipped",
|
|
);
|
|
expect(passing.status, `${passing.stdout}\n${passing.stderr}`).toBe(0);
|
|
|
|
const skippedRequired = runCiGateFixture(
|
|
"preflight=skipped\nsecurity-fast=success",
|
|
"checks-ui=skipped",
|
|
);
|
|
expect(skippedRequired.status).not.toBe(0);
|
|
expect(skippedRequired.stdout).toContain("preflight finished with skipped");
|
|
|
|
const failedSelected = runCiGateFixture(
|
|
"preflight=success\nsecurity-fast=success",
|
|
"checks-ui=failure\nmacos-swift=cancelled",
|
|
);
|
|
expect(failedSelected.status).not.toBe(0);
|
|
expect(failedSelected.stdout).toContain("checks-ui finished with failure");
|
|
expect(failedSelected.stdout).toContain("macos-swift finished with cancelled");
|
|
|
|
const failedUiE2e = runCiGateFixture(
|
|
"preflight=success\nsecurity-fast=success",
|
|
"checks-ui=success\nchecks-ui-e2e=failure",
|
|
);
|
|
expect(failedUiE2e.status).not.toBe(0);
|
|
expect(failedUiE2e.stdout).toContain("checks-ui-e2e finished with failure");
|
|
},
|
|
);
|
|
|
|
it("bounds QA profile selected-ref fetches", () => {
|
|
const validateSelectedRef = expectDefined(
|
|
readQaProfileEvidenceWorkflow().jobs.validate_selected_ref.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate selected ref",
|
|
),
|
|
"QA profile selected-ref validation step",
|
|
);
|
|
const gitFetchLines = validateSelectedRef.run
|
|
.split("\n")
|
|
.filter((line: string) => line.includes("git fetch"));
|
|
|
|
expect(gitFetchLines).toHaveLength(2);
|
|
expect(
|
|
gitFetchLines.every((line: string) =>
|
|
line.trimStart().startsWith("timeout --signal=TERM --kill-after=10s 120s git fetch"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.skipIf(process.platform !== "linux")(
|
|
"classifies QA timeouts only from isolated supervisor diagnostics",
|
|
() => {
|
|
const scenarios = [
|
|
{
|
|
exitCode: 124,
|
|
mode: "natural-124",
|
|
supervisorSignals: [],
|
|
timedOut: false,
|
|
timeoutOutcome: "none",
|
|
},
|
|
{
|
|
exitCode: 137,
|
|
mode: "self-kill",
|
|
supervisorSignals: [],
|
|
timedOut: false,
|
|
timeoutOutcome: "none",
|
|
},
|
|
{
|
|
exitCode: 143,
|
|
mode: "supervisor-term",
|
|
supervisorSignals: ["TERM"],
|
|
timedOut: false,
|
|
timeoutOutcome: "none",
|
|
},
|
|
{
|
|
exitCode: 124,
|
|
mode: "term",
|
|
supervisorSignals: ["TERM"],
|
|
timedOut: true,
|
|
timeoutOutcome: "term",
|
|
},
|
|
{
|
|
exitCode: 137,
|
|
mode: "kill",
|
|
supervisorSignals: ["TERM", "KILL"],
|
|
timedOut: true,
|
|
timeoutOutcome: "kill",
|
|
},
|
|
] as const;
|
|
|
|
for (const scenario of scenarios) {
|
|
const result = runQaProfileTimeoutFixture(scenario.mode);
|
|
expect(result.commandStatus, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(result.status).toMatchObject({
|
|
exitCode: scenario.exitCode,
|
|
timedOut: scenario.timedOut,
|
|
timeoutOutcome: scenario.timeoutOutcome,
|
|
});
|
|
expect(result.githubOutput).toContain(`qa_exit_code=${scenario.exitCode}`);
|
|
expect(result.stderr).toContain(`child-stderr-sentinel:${scenario.mode}`);
|
|
expect(result.stderr).toContain("child-locale:POSIX");
|
|
expect(result.timeoutVersion).toContain("(GNU coreutils)");
|
|
|
|
const supervisorSignals: readonly ("TERM" | "KILL")[] = scenario.supervisorSignals;
|
|
for (const signal of ["TERM", "KILL"] as const) {
|
|
const diagnostic = `timeout: sending signal ${signal} to command 'env'`;
|
|
if (supervisorSignals.includes(signal)) {
|
|
expect(result.timeoutSupervisorLog).toContain(diagnostic);
|
|
} else {
|
|
expect(result.timeoutSupervisorLog).not.toContain(diagnostic);
|
|
}
|
|
}
|
|
|
|
if (scenario.mode === "natural-124") {
|
|
expect(result.stderr).toContain(
|
|
"timeout: sending signal KILL to command 'spoofed-child'",
|
|
);
|
|
expect(result.timeoutSupervisorLog).not.toContain("spoofed-child");
|
|
}
|
|
if (scenario.timeoutOutcome === "term") {
|
|
expect(result.stdout).toContain(
|
|
"::warning::QA profile 'all' timed out after 1 second and was terminated",
|
|
);
|
|
} else if (scenario.timeoutOutcome === "kill") {
|
|
expect(result.stdout).toContain(
|
|
"::warning::QA profile 'all' timed out after 1 second and required SIGKILL after the 0.5-second grace period",
|
|
);
|
|
} else {
|
|
expect(result.stdout).not.toContain("::warning::QA profile");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps maturity scorecard generated QA evidence handoff strict", () => {
|
|
const maturityWorkflow = readMaturityScorecardWorkflow();
|
|
const qaEvidenceWorkflow = readQaProfileEvidenceWorkflow();
|
|
const generateJob = maturityWorkflow.jobs.generate_qa_evidence;
|
|
const publisherPreflight = maturityWorkflow.jobs.publisher_preflight;
|
|
const publishJob = maturityWorkflow.jobs.publish;
|
|
const publishPrJob = maturityWorkflow.jobs.publish_generated_pr;
|
|
const qaRunJob = qaEvidenceWorkflow.jobs.run_qa_profile;
|
|
|
|
expect(maturityWorkflow.on.workflow_call.inputs).toMatchObject({
|
|
qa_evidence_run_id: {
|
|
description: "Optional workflow run id containing qa-evidence.json",
|
|
required: false,
|
|
default: "",
|
|
type: "string",
|
|
},
|
|
ref: {
|
|
description: "OpenClaw branch, tag, or SHA containing the maturity score source",
|
|
required: true,
|
|
type: "string",
|
|
},
|
|
expected_sha: {
|
|
description: "Optional full SHA that ref must resolve to",
|
|
required: false,
|
|
default: "",
|
|
type: "string",
|
|
},
|
|
allow_failures: {
|
|
description: "Allow rendering from valid incomplete QA evidence",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
},
|
|
});
|
|
expect(maturityWorkflow.on.workflow_dispatch.inputs.allow_failures).toEqual({
|
|
description: "Allow rendering from valid incomplete QA evidence",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
expect(maturityWorkflow.on.workflow_dispatch.inputs.publish_pull_request).toEqual({
|
|
description: "Open or update a pull request for generated maturity files",
|
|
required: false,
|
|
default: true,
|
|
type: "boolean",
|
|
});
|
|
expect(maturityWorkflow.on.workflow_call.inputs).not.toHaveProperty("publish_pull_request");
|
|
expect(maturityWorkflow.on.workflow_call.secrets.OPENAI_API_KEY.required).toBe(true);
|
|
expect(
|
|
maturityWorkflow.on.workflow_call.secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY
|
|
.required,
|
|
).toBe(false);
|
|
expect(Object.keys(maturityWorkflow.on.workflow_call.secrets).toSorted()).toEqual([
|
|
"CLAWSWEEPER_APP_PRIVATE_KEY",
|
|
"MANTIS_GITHUB_APP_PRIVATE_KEY",
|
|
"OPENAI_API_KEY",
|
|
"OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY",
|
|
"OPENCLAW_QA_CONVEX_SECRET_CI",
|
|
"OPENCLAW_QA_CONVEX_SITE_URL",
|
|
]);
|
|
for (const secret of [
|
|
"CLAWSWEEPER_APP_PRIVATE_KEY",
|
|
"MANTIS_GITHUB_APP_PRIVATE_KEY",
|
|
"OPENCLAW_QA_CONVEX_SECRET_CI",
|
|
"OPENCLAW_QA_CONVEX_SITE_URL",
|
|
]) {
|
|
expect(maturityWorkflow.on.workflow_call.secrets[secret].required).toBe(false);
|
|
}
|
|
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs).not.toHaveProperty("fail_on_qa_failure");
|
|
expect(qaEvidenceWorkflow.on.workflow_call.inputs).not.toHaveProperty("fail_on_qa_failure");
|
|
for (const trigger of ["workflow_dispatch", "workflow_call"] as const) {
|
|
expect(qaEvidenceWorkflow.on[trigger].inputs.allow_failures).toEqual({
|
|
description: "Continue after validated QA result failures",
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
}
|
|
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile).not.toHaveProperty("options");
|
|
expect(qaEvidenceWorkflow.on.workflow_dispatch.inputs.qa_profile.default).toBe("all");
|
|
expect(qaEvidenceWorkflow.on.workflow_call.inputs.qa_profile.type).toBe("string");
|
|
expect(qaRunJob["timeout-minutes"]).toBe(150);
|
|
const validateProfileStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate QA profile input",
|
|
);
|
|
expect(validateProfileStep.run).toContain(
|
|
"readQaScorecardTaxonomyReport(readQaScenarioPack().scenarios)",
|
|
);
|
|
expect(validateProfileStep.run).toContain(
|
|
"taxonomy.profiles.find((entry) => entry.id === requested)",
|
|
);
|
|
expect(validateProfileStep.run).toContain("profile=${profile.id}");
|
|
const ensurePlaywrightStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Ensure Playwright Chromium",
|
|
);
|
|
expect(ensurePlaywrightStep.run).toBe("node scripts/ensure-playwright-chromium.mjs");
|
|
const runProfileStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run QA profile",
|
|
);
|
|
expect(runProfileStep.env?.OPENCLAW_QA_ALLOW_UPDATE_RUN_SELF).toBe("1");
|
|
expect(runProfileStep.env?.OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS).toBe("120000");
|
|
expect(runProfileStep.env?.REQUESTED_REF).toBe("${{ inputs.ref }}");
|
|
expect(runProfileStep.env?.TARGET_SHA).toBe(
|
|
"${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
);
|
|
expect(runProfileStep.run).toContain("--concurrency 3");
|
|
expect(runProfileStep.run).toContain("--fast");
|
|
expect(runProfileStep.run).toContain('mkdir -p "$output_dir"');
|
|
expect(runProfileStep.run.indexOf('mkdir -p "$output_dir"')).toBeLessThan(
|
|
runProfileStep.run.indexOf('echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"'),
|
|
);
|
|
expect(runProfileStep.run).toContain(
|
|
"LC_ALL=C timeout --verbose --signal=TERM --kill-after=30s 110m",
|
|
);
|
|
expect(runProfileStep.run).toContain("qa_exit_code=$?");
|
|
expect(runProfileStep.run).toContain('timeout_child_env+=("LC_ALL=$LC_ALL")');
|
|
expect(runProfileStep.run).toContain('timeout_child_env+=("-u" "LC_ALL")');
|
|
expect(runProfileStep.run).toContain(`bash -c 'exec "$@" 2>&3' bash`);
|
|
expect(runProfileStep.run).toContain('3>&2 2>"$timeout_supervisor_fifo"');
|
|
expect(runProfileStep.run).toContain('mkfifo "$timeout_supervisor_fifo"');
|
|
expect(runProfileStep.run).toContain(
|
|
'tee "$timeout_supervisor_log" <"$timeout_supervisor_fifo" >&2 &',
|
|
);
|
|
expect(runProfileStep.run).toContain("supervisor_tee_pid=$!");
|
|
expect(runProfileStep.run).toContain("trap cleanup_timeout_supervisor EXIT");
|
|
expect(runProfileStep.run).toContain(
|
|
'rm -f "$timeout_supervisor_fifo" "$timeout_supervisor_log"',
|
|
);
|
|
expect(runProfileStep.run).not.toContain(">(tee");
|
|
const teeWait = runProfileStep.run.indexOf('wait "$supervisor_tee_pid"');
|
|
const timeoutClassification = runProfileStep.run.indexOf(
|
|
'grep -Eq "^timeout: sending signal KILL',
|
|
);
|
|
expect(teeWait).toBeGreaterThan(-1);
|
|
expect(teeWait).toBeLessThan(timeoutClassification);
|
|
expect(runProfileStep.run).toContain(
|
|
`[[ "$qa_exit_code" -eq 137 ]] && grep -Eq "^timeout: sending signal KILL to command '[A-Za-z0-9_./+-]+'$"`,
|
|
);
|
|
expect(runProfileStep.run).toContain(
|
|
`[[ "$qa_exit_code" -eq 124 ]] && grep -Eq "^timeout: sending signal TERM to command '[A-Za-z0-9_./+-]+'$"`,
|
|
);
|
|
expect(runProfileStep.run).not.toContain('case "$qa_exit_code"');
|
|
expect(runProfileStep.run).toContain('TIMEOUT_OUTCOME="$timeout_outcome"');
|
|
expect(runProfileStep.run).toContain("qa-profile-run-status.json");
|
|
expect(runProfileStep.run).toContain("exitCode: Number(process.env.QA_EXIT_CODE)");
|
|
expect(runProfileStep.run).toContain('timedOut: timeoutOutcome !== "none"');
|
|
expect(runProfileStep.run).toContain("timeoutOutcome,");
|
|
expect(runProfileStep.run).toContain("completedAt: new Date().toISOString()");
|
|
expect(runProfileStep.run).not.toContain("--allow-failures");
|
|
const failProfileStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Fail if QA profile failed",
|
|
);
|
|
expect(failProfileStep.if).toBe("always()");
|
|
expect(failProfileStep.env?.ALLOW_FAILURES).toBe("${{ inputs.allow_failures }}");
|
|
expect(failProfileStep.env?.EVIDENCE_VALIDATED).toBe(
|
|
"${{ steps.evidence.outcome == 'success' }}",
|
|
);
|
|
expect(failProfileStep.run).toContain(
|
|
'[[ "$ALLOW_FAILURES" == "true" && "$EVIDENCE_VALIDATED" == "true" ]]',
|
|
);
|
|
expect(failProfileStep.run).toContain('exit "$QA_EXIT_CODE"');
|
|
expect(generateJob.needs).toEqual(["validate_selected_ref", "publisher_preflight"]);
|
|
expect(generateJob.if.replace(/\s+/gu, " ")).toBe(
|
|
"${{ always() && needs.validate_selected_ref.result == 'success' && (!inputs.publish_pull_request || needs.publisher_preflight.result == 'success') && inputs.qa_evidence_run_id == '' }}",
|
|
);
|
|
expect(generateJob.uses).toBe("./.github/workflows/qa-profile-evidence.yml");
|
|
expect(generateJob.with).toMatchObject({
|
|
// Reusable jobs start later, so forward the immutable revision instead of a moving branch.
|
|
ref: "${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
expected_sha: "${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
qa_profile: "all",
|
|
allow_failures: "${{ inputs.allow_failures }}",
|
|
});
|
|
expect(generateJob.with).not.toHaveProperty("fail_on_qa_failure");
|
|
expect(generateJob.secrets).toMatchObject({
|
|
OPENAI_API_KEY: "${{ secrets.OPENAI_API_KEY }}",
|
|
OPENCLAW_QA_CONVEX_SECRET_CI: "${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}",
|
|
OPENCLAW_QA_CONVEX_SITE_URL: "${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}",
|
|
});
|
|
|
|
const workflowStep = maturityWorkflow.jobs.validate_selected_ref.steps.find(
|
|
(step: WorkflowStep) => step.name === "Resolve job workflow identity",
|
|
);
|
|
const authorizeStep = maturityWorkflow.jobs.validate_selected_ref.steps.find(
|
|
(step: WorkflowStep) => step.name === "Authorize workflow invocation",
|
|
);
|
|
const validateRefStep = maturityWorkflow.jobs.validate_selected_ref.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate selected ref",
|
|
);
|
|
expect(workflowStep.env.JOB_CONTEXT).toBe("${{ toJSON(job) }}");
|
|
expect(workflowStep.run).toContain("job.workflow_sha must be a full lowercase commit SHA");
|
|
expect(authorizeStep.env).toEqual({
|
|
CALLER_EVENT_NAME: "${{ github.event_name }}",
|
|
CALLER_WORKFLOW_REF: "${{ github.workflow_ref }}",
|
|
JOB_WORKFLOW_FILE_PATH: "${{ steps.workflow.outputs.workflow_file_path }}",
|
|
JOB_WORKFLOW_REF: "${{ steps.workflow.outputs.workflow_ref }}",
|
|
JOB_WORKFLOW_REPOSITORY: "${{ steps.workflow.outputs.workflow_repository }}",
|
|
PUBLISH_PULL_REQUEST: "${{ inputs.publish_pull_request || false }}",
|
|
});
|
|
expect(authorizeStep.run).toContain(
|
|
`expected_workflow_ref="${MATURITY_SCORECARD_WORKFLOW_REF}"`,
|
|
);
|
|
expect(authorizeStep.run).toContain(
|
|
'[[ "$PUBLISH_PULL_REQUEST" == "true" && "$canonical_direct" != "true" ]]',
|
|
);
|
|
expect(authorizeStep.run).toContain(
|
|
"Reusable maturity workflows are artifact-only and cannot publish pull requests.",
|
|
);
|
|
expect(validateRefStep.env.EXPECTED_SHA).toBe("${{ inputs.expected_sha }}");
|
|
expect(validateRefStep.env.PUBLISH_PULL_REQUEST).toBe("${{ inputs.publish_pull_request }}");
|
|
expect(validateRefStep.env).not.toHaveProperty("TRUSTED_WORKFLOW_SHA");
|
|
expect(validateRefStep.env.EVIDENCE_RUN_ID).toBe(
|
|
"${{ inputs.qa_evidence_run_id || github.run_id }}",
|
|
);
|
|
for (const fragment of [
|
|
"expected_sha must be a full 40-character SHA",
|
|
'branch_candidate="${INPUT_REF#refs/heads/}"',
|
|
"floating_default_branch=false",
|
|
'[[ -z "${expected_sha// }" && "$branch_candidate" == "$DEFAULT_BRANCH" ]]',
|
|
'selected_revision="$(git rev-parse refs/remotes/origin/main)"',
|
|
'[[ "$floating_default_branch" == "true" && "$publication_base" == "$DEFAULT_BRANCH" ]]',
|
|
'branch_lookup_status="$?"',
|
|
"2) ;;",
|
|
"Unable to determine whether '${INPUT_REF}' is a remote branch",
|
|
'git merge-base --is-ancestor "$selected_revision"',
|
|
"':(exclude)qa/maturity-scores.yaml'",
|
|
"':(exclude)docs/maturity/scorecard.md'",
|
|
"':(exclude)docs/maturity/taxonomy.md'",
|
|
"qa_evidence_run_id must be a numeric GitHub Actions run id",
|
|
'publication_head="automation/maturity-scorecard-',
|
|
]) {
|
|
expect(validateRefStep.run).toContain(fragment);
|
|
}
|
|
expect(maturityWorkflow.jobs.validate_selected_ref.outputs).toMatchObject({
|
|
publication_base: "${{ steps.validate.outputs.publication_base }}",
|
|
publication_head: "${{ steps.validate.outputs.publication_head }}",
|
|
workflow_file_path: "${{ steps.workflow.outputs.workflow_file_path }}",
|
|
workflow_ref: "${{ steps.workflow.outputs.workflow_ref }}",
|
|
workflow_repository: "${{ steps.workflow.outputs.workflow_repository }}",
|
|
workflow_sha: "${{ steps.workflow.outputs.workflow_sha }}",
|
|
});
|
|
|
|
const trustedPublisherCondition = [
|
|
"${{ inputs.publish_pull_request &&",
|
|
"github.event_name == 'workflow_dispatch' &&",
|
|
`github.workflow_ref == '${MATURITY_SCORECARD_WORKFLOW_REF}' &&`,
|
|
`needs.validate_selected_ref.outputs.workflow_file_path == '${MATURITY_SCORECARD_WORKFLOW}' &&`,
|
|
`needs.validate_selected_ref.outputs.workflow_ref == '${MATURITY_SCORECARD_WORKFLOW_REF}' &&`,
|
|
"needs.validate_selected_ref.outputs.workflow_repository == 'openclaw/openclaw' }}",
|
|
].join(" ");
|
|
expect(publisherPreflight.needs).toBe("validate_selected_ref");
|
|
expect(publisherPreflight.if).toBe("${{ inputs.publish_pull_request }}");
|
|
const preflightCheckoutStep = publisherPreflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout trusted workflow source",
|
|
);
|
|
const preflightTokensStep = publisherPreflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Create generated PR tokens",
|
|
);
|
|
expect(preflightCheckoutStep).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
repository: "${{ needs.validate_selected_ref.outputs.workflow_repository }}",
|
|
ref: "${{ needs.validate_selected_ref.outputs.workflow_sha }}",
|
|
"persist-credentials": false,
|
|
submodules: false,
|
|
},
|
|
});
|
|
expect(preflightTokensStep.if.replace(/\s+/gu, " ")).toBe(trustedPublisherCondition);
|
|
expect(preflightTokensStep).toMatchObject({
|
|
uses: "./.github/actions/create-generated-pr-tokens",
|
|
with: {
|
|
"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 }}",
|
|
},
|
|
});
|
|
expect(publishJob.needs).toEqual([
|
|
"validate_selected_ref",
|
|
"publisher_preflight",
|
|
"generate_qa_evidence",
|
|
]);
|
|
expect(publishJob.if.replace(/\s+/gu, " ")).toBe(
|
|
"${{ always() && needs.validate_selected_ref.result == 'success' && (!inputs.publish_pull_request || needs.publisher_preflight.result == 'success') && (inputs.qa_evidence_run_id != '' || needs.generate_qa_evidence.result == 'success') }}",
|
|
);
|
|
expect(JSON.stringify(publishJob)).not.toMatch(
|
|
/CLAWSWEEPER_APP_PRIVATE_KEY|MANTIS_GITHUB_APP/u,
|
|
);
|
|
|
|
const generatedDownloadStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Download generated QA evidence artifact",
|
|
);
|
|
expect(generatedDownloadStep.if).toBe("${{ inputs.qa_evidence_run_id == '' }}");
|
|
expect(generatedDownloadStep.env.GENERATED_ARTIFACT_NAME).toBe(
|
|
"${{ needs.generate_qa_evidence.outputs.artifact_name }}",
|
|
);
|
|
expect(generatedDownloadStep.run).toContain('gh run download "$GITHUB_RUN_ID"');
|
|
expect(generatedDownloadStep.run).toContain('--name "$GENERATED_ARTIFACT_NAME"');
|
|
expect(generatedDownloadStep.run).not.toContain("--pattern");
|
|
|
|
const requireEvidenceStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Require one QA evidence file",
|
|
);
|
|
expect(requireEvidenceStep.run).toContain("Expected exactly one qa-evidence.json file");
|
|
|
|
const validateManifestStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate QA evidence manifest",
|
|
);
|
|
expect(validateManifestStep.run).toContain("qa-profile-evidence-manifest.json");
|
|
expect(validateManifestStep.run).toContain("qa-evidence.json profile must be all");
|
|
expect(validateManifestStep.run).toContain("QA evidence manifest profile must be all");
|
|
expect(validateManifestStep.run).toContain("manifest.targetSha !== targetSha");
|
|
expect(validateManifestStep.run).toContain("qaProfileEvidencePlan.attest");
|
|
expect(validateManifestStep.run).toContain("profilePlanSha256");
|
|
expect(validateManifestStep.run).toContain("rerun the QA Profile Evidence workflow");
|
|
|
|
expect(qaRunJob.outputs.artifact_name).toBe("${{ steps.evidence.outputs.artifact_name }}");
|
|
const qaEvidenceStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate QA profile evidence",
|
|
);
|
|
expect(qaEvidenceStep.env.ARTIFACT_NAME).toBe(
|
|
"qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
);
|
|
expect(qaEvidenceStep.run).toContain("qa-profile-evidence-manifest.json");
|
|
expect(qaEvidenceStep.run).toContain("validateQaEvidenceSummaryJson");
|
|
expect(qaEvidenceStep.run).toContain("qaProfileEvidencePlan.attest");
|
|
expect(qaEvidenceStep.run).toContain("profilePlanSha256");
|
|
expect(qaEvidenceStep.run).toContain("rerun the QA Profile Evidence workflow");
|
|
expect(qaEvidenceStep.env.ALLOW_FAILURES).toBe("${{ inputs.allow_failures }}");
|
|
expect(qaEvidenceStep.run).toContain("qaExitCode: Number(process.env.QA_EXIT_CODE)");
|
|
expect(qaEvidenceStep.run).toContain('qaPassed: process.env.QA_EXIT_CODE === "0"');
|
|
expect(qaEvidenceStep.run).toContain('allowFailures: process.env.ALLOW_FAILURES === "true"');
|
|
expect(qaEvidenceStep.run).toContain("QA failures allowed:");
|
|
|
|
const qaUploadStep = qaRunJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload QA profile evidence",
|
|
);
|
|
expect(qaUploadStep.if).toBe("always()");
|
|
expect(qaUploadStep.with).toMatchObject({
|
|
name: "qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
path: "${{ steps.run_profile.outputs.output_dir }}",
|
|
"if-no-files-found": "error",
|
|
});
|
|
|
|
const renderCheckoutStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout selected ref",
|
|
);
|
|
const generatedPrUploadStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload generated PR files",
|
|
);
|
|
expect(renderCheckoutStep.with["fetch-depth"]).toBe(0);
|
|
expect(generatedPrUploadStep).toMatchObject({
|
|
if: "${{ inputs.publish_pull_request }}",
|
|
uses: UPLOAD_ARTIFACT_V7,
|
|
with: {
|
|
name: "maturity-scorecard-pr-${{ github.run_id }}-${{ github.run_attempt }}",
|
|
"retention-days": 1,
|
|
"if-no-files-found": "error",
|
|
},
|
|
});
|
|
expect(generatedPrUploadStep.with.path.trim().split("\n")).toEqual(MATURITY_GENERATED_PR_PATHS);
|
|
|
|
for (const stepName of ["Render artifact docs", "Render committed docs preview"]) {
|
|
const renderStep = publishJob.steps.find((step: WorkflowStep) => step.name === stepName);
|
|
expect(renderStep.env.ALLOW_FAILURES).toBe("${{ inputs.allow_failures }}");
|
|
expect(renderStep.run).toContain('[[ "$ALLOW_FAILURES" == "true" ]]');
|
|
expect(renderStep.run).toContain("allow_failures_args+=(--allow-failures)");
|
|
expect(renderStep.run).toContain('"${allow_failures_args[@]}"');
|
|
}
|
|
const renderArtifactStep = publishJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Render artifact docs",
|
|
);
|
|
expect(renderArtifactStep.run).toContain("QA failures allowed:");
|
|
|
|
expect(publishPrJob.needs).toEqual(["validate_selected_ref", "publisher_preflight", "publish"]);
|
|
expect(publishPrJob["runs-on"]).toBe("ubuntu-24.04");
|
|
for (const fragment of [
|
|
"needs.publisher_preflight.result == 'success'",
|
|
"needs.publish.result == 'success'",
|
|
`github.workflow_ref == '${MATURITY_SCORECARD_WORKFLOW_REF}'`,
|
|
`needs.validate_selected_ref.outputs.workflow_ref == '${MATURITY_SCORECARD_WORKFLOW_REF}'`,
|
|
]) {
|
|
expect(publishPrJob.if).toContain(fragment);
|
|
}
|
|
const trustedPublishCheckoutStep = publishPrJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout trusted workflow source",
|
|
);
|
|
const selectedCheckoutStep = publishPrJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Checkout selected ref",
|
|
);
|
|
const downloadPrFilesStep = publishPrJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Download generated PR files",
|
|
);
|
|
const openDocsPrStep = publishPrJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Open or update generated docs PR",
|
|
);
|
|
expect(trustedPublishCheckoutStep).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
repository: "${{ needs.validate_selected_ref.outputs.workflow_repository }}",
|
|
ref: "${{ needs.validate_selected_ref.outputs.workflow_sha }}",
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
expect(selectedCheckoutStep).toMatchObject({
|
|
uses: CHECKOUT_V6,
|
|
with: {
|
|
ref: "${{ needs.validate_selected_ref.outputs.selected_revision }}",
|
|
path: "selected",
|
|
"fetch-depth": 0,
|
|
"persist-credentials": false,
|
|
},
|
|
});
|
|
expect(downloadPrFilesStep).toMatchObject({
|
|
uses: DOWNLOAD_ARTIFACT_V8,
|
|
with: {
|
|
name: "maturity-scorecard-pr-${{ github.run_id }}-${{ github.run_attempt }}",
|
|
path: "${{ steps.staging.outputs.path }}",
|
|
},
|
|
});
|
|
expect(openDocsPrStep.if.replace(/\s+/gu, " ")).toBe(trustedPublisherCondition);
|
|
expect(openDocsPrStep.uses).toBe("./.github/actions/publish-generated-pr");
|
|
expect(openDocsPrStep.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": "${{ needs.validate_selected_ref.outputs.publication_base }}",
|
|
"head-branch": "${{ needs.validate_selected_ref.outputs.publication_head }}",
|
|
"working-directory": "selected",
|
|
"commit-message": "docs: update maturity scorecard",
|
|
"pr-title": "docs: update maturity scorecard",
|
|
"overlap-policy": "fail",
|
|
});
|
|
expect(openDocsPrStep.with["generated-paths"].trim().split("\n")).toEqual(
|
|
MATURITY_GENERATED_PR_PATHS,
|
|
);
|
|
expect(openDocsPrStep.with["invalidation-paths"].trim().split("\n")).toEqual([
|
|
".",
|
|
":(exclude)qa/maturity-scores.yaml",
|
|
":(exclude)docs/maturity/scorecard.md",
|
|
":(exclude)docs/maturity/taxonomy.md",
|
|
]);
|
|
for (const heading of [
|
|
"## What Problem This Solves",
|
|
"## Why This Change Was Made",
|
|
"## User Impact",
|
|
"## Evidence",
|
|
]) {
|
|
expect(openDocsPrStep.with["pr-body"]).toContain(heading);
|
|
}
|
|
expect(publishPrJob.steps).not.toContainEqual(
|
|
expect.objectContaining({ name: "Create generated docs PR app token" }),
|
|
);
|
|
const maturityWorkflowSource = readFileSync(".github/workflows/maturity-scorecard.yml", "utf8");
|
|
expect(maturityWorkflowSource).not.toContain("permission-pull-requests: write");
|
|
expect(maturityWorkflowSource).not.toContain("GH_APP_PRIVATE_KEY");
|
|
expect(maturityWorkflowSource).not.toContain("gh auth setup-git");
|
|
expect(maturityWorkflowSource).not.toContain("git push --force-with-lease");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"round-trips complete and incomplete profile evidence and rejects digest drift",
|
|
() => {
|
|
const qaWorkflow = readQaProfileEvidenceWorkflow();
|
|
const maturityWorkflow = readMaturityScorecardWorkflow();
|
|
const producerStep = qaWorkflow.jobs.run_qa_profile.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate QA profile evidence",
|
|
);
|
|
const consumerStep = maturityWorkflow.jobs.publish.steps.find(
|
|
(step: WorkflowStep) => step.name === "Validate QA evidence manifest",
|
|
);
|
|
const producerScript = expectDefined(producerStep?.run, "QA evidence producer script");
|
|
const consumerScript = expectDefined(consumerStep?.run, "QA evidence consumer script");
|
|
const root = tempDirs.make("openclaw-qa-profile-artifact-");
|
|
const evidencePath = path.join(root, "qa-evidence.json");
|
|
const manifestPath = path.join(root, "qa-profile-evidence-manifest.json");
|
|
const targetSha = "a".repeat(40);
|
|
const expectedCell = {
|
|
scenarioId: "scenario-one",
|
|
executionKind: "flow",
|
|
channel: null,
|
|
};
|
|
const scorecard = {
|
|
filters: { surface: null, category: null },
|
|
run: { evidenceEntryCount: 0 },
|
|
categories: { total: 1, fulfilled: 1, partial: 0, missing: 0, fulfillmentPercent: 100 },
|
|
features: { total: 1, fulfilled: 1, partial: 0, missing: 0, fulfillmentPercent: 100 },
|
|
coverageIds: {
|
|
total: 1,
|
|
fulfilled: 1,
|
|
missing: 0,
|
|
fulfillmentPercent: 100,
|
|
},
|
|
categoryReports: [
|
|
{
|
|
id: "surface.category",
|
|
surfaceId: "surface",
|
|
name: "Category",
|
|
status: "fulfilled",
|
|
features: {
|
|
total: 1,
|
|
fulfilled: 1,
|
|
partial: 0,
|
|
missing: 0,
|
|
fulfillmentPercent: 100,
|
|
},
|
|
coverageIds: {
|
|
total: 1,
|
|
fulfilled: 1,
|
|
missing: 0,
|
|
fulfillmentPercent: 100,
|
|
secondaryOnly: 0,
|
|
},
|
|
missingCoverageIds: [],
|
|
},
|
|
],
|
|
};
|
|
|
|
const writeEvidence = (complete: boolean) => {
|
|
const observedCells = complete ? [expectedCell] : [];
|
|
const missingCells = complete ? [] : [expectedCell];
|
|
writeFileSync(
|
|
evidencePath,
|
|
`${JSON.stringify({
|
|
kind: "openclaw.qa.evidence-summary",
|
|
schemaVersion: 2,
|
|
generatedAt: "2026-08-05T00:00:00.000Z",
|
|
evidenceMode: "full",
|
|
entries: [],
|
|
profile: "all",
|
|
profilePlan: {
|
|
profile: "all",
|
|
membership: ["scenario-one"],
|
|
selected: ["scenario-one"],
|
|
excluded: [],
|
|
expectedCells: [expectedCell],
|
|
observedCells,
|
|
missingCells,
|
|
counts: {
|
|
membership: 1,
|
|
selected: 1,
|
|
excluded: 0,
|
|
expectedCells: 1,
|
|
observedCells: observedCells.length,
|
|
missingCells: missingCells.length,
|
|
},
|
|
},
|
|
scorecard,
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
};
|
|
const runProducer = (qaExitCode: string) =>
|
|
runWorkflowShellScript(producerScript, {
|
|
env: {
|
|
...process.env,
|
|
ALLOW_FAILURES: "true",
|
|
ARTIFACT_NAME: `qa-profile-evidence-all-${targetSha}`,
|
|
GITHUB_OUTPUT: path.join(root, "github-output"),
|
|
GITHUB_STEP_SUMMARY: path.join(root, "github-summary"),
|
|
OUTPUT_DIR: root,
|
|
QA_EXIT_CODE: qaExitCode,
|
|
QA_PROFILE: "all",
|
|
REQUESTED_REF: targetSha,
|
|
TARGET_SHA: targetSha,
|
|
TRUSTED_REASON: "fixture",
|
|
},
|
|
});
|
|
const runConsumer = () =>
|
|
runWorkflowShellScript(consumerScript, {
|
|
env: {
|
|
...process.env,
|
|
QA_EVIDENCE_PATH: evidencePath,
|
|
TARGET_SHA: targetSha,
|
|
},
|
|
});
|
|
|
|
try {
|
|
writeEvidence(true);
|
|
const completeProducer = runProducer("0");
|
|
expect(
|
|
completeProducer.status,
|
|
`${completeProducer.stdout}${completeProducer.stderr}`,
|
|
).toBe(0);
|
|
const completeConsumer = runConsumer();
|
|
expect(
|
|
completeConsumer.status,
|
|
`${completeConsumer.stdout}${completeConsumer.stderr}`,
|
|
).toBe(0);
|
|
|
|
writeEvidence(false);
|
|
const incompleteProducer = runProducer("7");
|
|
expect(
|
|
incompleteProducer.status,
|
|
`${incompleteProducer.stdout}${incompleteProducer.stderr}`,
|
|
).toBe(0);
|
|
const incompleteConsumer = runConsumer();
|
|
expect(
|
|
incompleteConsumer.status,
|
|
`${incompleteConsumer.stdout}${incompleteConsumer.stderr}`,
|
|
).toBe(0);
|
|
|
|
writeEvidence(true);
|
|
const mismatchProducer = runProducer("0");
|
|
expect(
|
|
mismatchProducer.status,
|
|
`${mismatchProducer.stdout}${mismatchProducer.stderr}`,
|
|
).toBe(0);
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
|
manifest.profilePlanSha256 = "0".repeat(64);
|
|
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, "utf8");
|
|
const mismatched = runConsumer();
|
|
expect(mismatched.status).toBe(1);
|
|
expect(`${mismatched.stdout}${mismatched.stderr}`).toContain(
|
|
"QA evidence profilePlan digest does not match the manifest",
|
|
);
|
|
} finally {
|
|
rmSync(root, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"suppresses only reported QA result failures when explicitly allowed",
|
|
() => {
|
|
expect(runQaProfileFailureGate({ allowFailures: false, qaExitCode: "7" }).status).toBe(7);
|
|
expect(runQaProfileFailureGate({ allowFailures: true, qaExitCode: "7" }).status).toBe(0);
|
|
expect(
|
|
runQaProfileFailureGate({
|
|
allowFailures: true,
|
|
evidenceValidated: false,
|
|
qaExitCode: "7",
|
|
}).status,
|
|
).toBe(7);
|
|
expect(runQaProfileFailureGate({ allowFailures: true }).status).toBe(1);
|
|
expect(runQaProfileFailureGate({ allowFailures: false, qaExitCode: "0" }).status).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"authorizes maturity PR publication only for a canonical direct dispatch",
|
|
() => {
|
|
const direct = runMaturityInvocationScenario({
|
|
callerEventName: "workflow_dispatch",
|
|
callerWorkflowRef: MATURITY_SCORECARD_WORKFLOW_REF,
|
|
publishPullRequest: true,
|
|
});
|
|
|
|
expect(direct.status).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps a reusable maturity call artifact-only even when its caller was dispatched",
|
|
() => {
|
|
const callerWorkflowRef =
|
|
"openclaw/openclaw/.github/workflows/openclaw-release-checks.yml@refs/heads/main";
|
|
const artifactOnly = runMaturityInvocationScenario({
|
|
callerEventName: "workflow_dispatch",
|
|
callerWorkflowRef,
|
|
publishPullRequest: false,
|
|
});
|
|
|
|
expect(artifactOnly.status).toBe(0);
|
|
for (const identity of [
|
|
{ callerWorkflowRef },
|
|
{ callerWorkflowRef: MATURITY_SCORECARD_WORKFLOW_REF, jobWorkflowRef: callerWorkflowRef },
|
|
]) {
|
|
const rejected = runMaturityInvocationScenario({
|
|
callerEventName: "workflow_dispatch",
|
|
publishPullRequest: true,
|
|
...identity,
|
|
});
|
|
expect(rejected.status).not.toBe(0);
|
|
expect(rejected.output).toContain(
|
|
"Reusable maturity workflows are artifact-only and cannot publish pull requests.",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Replay the Ubuntu workflow shell only where its Bash 4 and GNU install contract exists.
|
|
it.skipIf(process.platform !== "linux")(
|
|
"copies only regular allowlisted maturity publication files",
|
|
() => {
|
|
const valid = runMaturityArtifactCopyScenario();
|
|
expect(valid.status).toBe(0);
|
|
expect(valid.copied).toEqual(
|
|
MATURITY_GENERATED_PR_PATHS.map((generatedPath) => `new ${generatedPath}\n`),
|
|
);
|
|
|
|
const extra = runMaturityArtifactCopyScenario({ extraFile: true });
|
|
expect(extra.status).not.toBe(0);
|
|
expect(extra.output).toContain("Generated PR artifact must contain exactly 3 files.");
|
|
|
|
const sourceSymlink = runMaturityArtifactCopyScenario({ sourceSymlink: true });
|
|
expect(sourceSymlink.status).not.toBe(0);
|
|
expect(sourceSymlink.output).toContain(
|
|
"Generated PR artifact path must be a regular file: qa/maturity-scores.yaml",
|
|
);
|
|
|
|
const destinationSymlink = runMaturityArtifactCopyScenario({ destinationSymlink: true });
|
|
expect(destinationSymlink.status).not.toBe(0);
|
|
expect(destinationSymlink.output).toContain(
|
|
"Selected worktree destination must be a regular file: qa/maturity-scores.yaml",
|
|
);
|
|
expect(destinationSymlink.escaped).toBe("outside\n");
|
|
},
|
|
);
|
|
|
|
it("keeps maturity scorecard release docs opt-in from release checks", () => {
|
|
const releaseWorkflow = readReleaseChecksWorkflow();
|
|
const job = releaseWorkflow.jobs.maturity_scorecard_release_checks;
|
|
const summaryJob = releaseWorkflow.jobs.summary;
|
|
const verifyStep = summaryJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Verify release check results",
|
|
);
|
|
const inputs = releaseWorkflow.on.workflow_dispatch.inputs;
|
|
const resolveJob = releaseWorkflow.jobs.resolve_target;
|
|
const summarizeStep = resolveJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Summarize validated ref",
|
|
);
|
|
|
|
expect(releaseWorkflow.jobs).not.toHaveProperty("qa_profile_release_evidence_release_checks");
|
|
expect(inputs.run_maturity_scorecard).toMatchObject({
|
|
required: false,
|
|
default: false,
|
|
type: "boolean",
|
|
});
|
|
expect(resolveJob.outputs.run_maturity_scorecard).toBe(
|
|
"${{ steps.inputs.outputs.run_maturity_scorecard }}",
|
|
);
|
|
expect(summarizeStep.env.RUN_MATURITY_SCORECARD).toBe(
|
|
"${{ steps.inputs.outputs.run_maturity_scorecard }}",
|
|
);
|
|
expect(summarizeStep.run).toContain("- Maturity scorecard docs:");
|
|
expect(job.name).toBe("Render maturity scorecard release docs");
|
|
expect(job.if).toBe(
|
|
"contains(fromJSON('[\"all\",\"qa\"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'",
|
|
);
|
|
expect(job.permissions).toMatchObject({
|
|
actions: "read",
|
|
contents: "read",
|
|
});
|
|
expect(job.uses).toBe("./.github/workflows/maturity-scorecard.yml");
|
|
expect(job.with).toMatchObject({
|
|
ref: "${{ needs.resolve_target.outputs.ref }}",
|
|
expected_sha: "${{ needs.resolve_target.outputs.revision }}",
|
|
});
|
|
expect(job.with).not.toHaveProperty("qa_profile");
|
|
expect(job.with).not.toHaveProperty("publish_pull_request");
|
|
expect(job.secrets).toMatchObject({
|
|
OPENAI_API_KEY: "${{ secrets.OPENAI_API_KEY }}",
|
|
OPENCLAW_QA_CONVEX_SECRET_CI: "${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}",
|
|
OPENCLAW_QA_CONVEX_SITE_URL: "${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}",
|
|
});
|
|
expect(summaryJob.needs).toContain("maturity_scorecard_release_checks");
|
|
expect(verifyStep.env.MATURITY_SCORECARD_RELEASE_CHECKS_RESULT).toBe(
|
|
"${{ needs.maturity_scorecard_release_checks.result }}",
|
|
);
|
|
expect(verifyStep.run).toContain(
|
|
'"maturity_scorecard_release_checks=${MATURITY_SCORECARD_RELEASE_CHECKS_RESULT}"',
|
|
);
|
|
expect(verifyStep.run).not.toContain("qa_profile_release_evidence_release_checks");
|
|
});
|
|
|
|
it("keeps workflow guards in fast CI-routing checks", () => {
|
|
const workflow = readCiWorkflow();
|
|
const preflightStep = workflow.jobs.preflight.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build CI manifest",
|
|
);
|
|
const taxonomy = parse(readFileSync("taxonomy.yaml", "utf8")) as {
|
|
surfaces: Array<{ id: string; categories: Array<{ id: string }> }>;
|
|
};
|
|
const taxonomyCategoryIds = taxonomy.surfaces.flatMap((surface) =>
|
|
surface.categories.map((category) => `${surface.id}.${category.id}`),
|
|
);
|
|
const fastCoreJob = workflow.jobs["checks-fast-core"];
|
|
const runStep = fastCoreJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run ${{ matrix.task }} (${{ matrix.runtime }})",
|
|
);
|
|
const smokeProfileJob = workflow.jobs["qa-smoke-ci-profile"];
|
|
const smokeBuildStep = smokeProfileJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Build QA smoke runtime",
|
|
);
|
|
const smokeDockerCacheStep = smokeProfileJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Set up Blacksmith Docker layer cache",
|
|
);
|
|
const smokeRunStep = smokeProfileJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Run smoke profile part",
|
|
);
|
|
const smokeUploadStep = smokeProfileJob.steps.find(
|
|
(step: WorkflowStep) => step.name === "Upload QA smoke profile evidence",
|
|
);
|
|
|
|
const ciWorkflowText = readFileSync(".github/workflows/ci.yml", "utf8");
|
|
|
|
expect(preflightStep.run).not.toContain("qa-smoke-profile");
|
|
expect(preflightStep.run).not.toContain("qa_category");
|
|
expect(taxonomyCategoryIds.length).toBeGreaterThan(0);
|
|
for (const categoryId of taxonomyCategoryIds) {
|
|
expect(ciWorkflowText).not.toContain(`"${categoryId}"`);
|
|
}
|
|
expect(runStep.run).toContain("bundled-protocol)");
|
|
expect(runStep.run).not.toContain("qa-smoke-ci)");
|
|
expect(runStep.run).toContain("contracts-plugins-ci-routing)");
|
|
expect(runStep.run).toContain("ci-routing)");
|
|
expect(fastCoreJob["runs-on"]).toContain("matrix.runner");
|
|
expect(smokeProfileJob.name).toBe("QA Smoke CI (${{ matrix.name }})");
|
|
const publicRuntimeBuild = smokeBuildStep.run.indexOf("node scripts/build-all.mjs qaRuntime");
|
|
const uiBuild = smokeBuildStep.run.indexOf("pnpm ui:build");
|
|
const packageBuild = smokeBuildStep.run.indexOf("node scripts/package-openclaw-for-docker.mjs");
|
|
const privateRuntimeBuild = smokeBuildStep.run.lastIndexOf(
|
|
"node scripts/build-all.mjs qaRuntime",
|
|
);
|
|
expect(smokeBuildStep.run).toContain("node scripts/build-all.mjs qaRuntime");
|
|
expect(smokeBuildStep.run).toContain("pnpm ui:build");
|
|
expect(smokeBuildStep.env).not.toHaveProperty("OPENCLAW_BUILD_PRIVATE_QA");
|
|
expect(smokeBuildStep.run).toContain("unset OPENCLAW_BUILD_PRIVATE_QA");
|
|
expect(smokeBuildStep.run).toContain("--skip-build");
|
|
expect(smokeBuildStep.run).toContain(
|
|
"OPENCLAW_BUILD_PRIVATE_QA=1 node scripts/build-all.mjs qaRuntime",
|
|
);
|
|
expect(smokeBuildStep.run.match(/node scripts\/build-all\.mjs qaRuntime/g)).toHaveLength(2);
|
|
expect(smokeBuildStep.run).toContain("--allow-unreleased-changelog");
|
|
expect(smokeBuildStep.run).toContain("grep -Fq");
|
|
expect(smokeBuildStep.run).toContain('"${package_args[@]}"');
|
|
expect(publicRuntimeBuild).toBeLessThan(uiBuild);
|
|
expect(uiBuild).toBeLessThan(packageBuild);
|
|
expect(packageBuild).toBeLessThan(privateRuntimeBuild);
|
|
expect(workflow.jobs["qa-smoke-ci-artifacts"]).toBeUndefined();
|
|
expect(workflow.jobs["qa-smoke-ci"]).toBeUndefined();
|
|
expect(smokeProfileJob.needs).toEqual(["preflight"]);
|
|
expect(smokeProfileJob.strategy["max-parallel"]).toBe(4);
|
|
expect(
|
|
smokeProfileJob.strategy.matrix.include.map((entry: { slug: string }) => entry.slug),
|
|
).toEqual(["profile-1-of-4", "profile-2-of-4", "profile-3-of-4", "profile-4-of-4"]);
|
|
expect(
|
|
smokeProfileJob.strategy.matrix.include.filter(
|
|
(entry: { docker_cache?: boolean }) => entry.docker_cache,
|
|
),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
lane: "profile-2",
|
|
slug: "profile-2-of-4",
|
|
docker_cache: true,
|
|
}),
|
|
]);
|
|
expect(smokeProfileJob["runs-on"]).toContain("blacksmith-16vcpu-ubuntu-2404");
|
|
expect(smokeDockerCacheStep.uses).toBe(
|
|
"useblacksmith/setup-docker-builder@6ff44f8e5255f9d8aa31ef22f7e57a2d926b7da0",
|
|
);
|
|
expect(smokeDockerCacheStep.if).toContain("matrix.docker_cache == true");
|
|
expect(smokeDockerCacheStep.if).toContain("github.event_name != 'workflow_dispatch'");
|
|
expect(smokeDockerCacheStep.if).toContain("github.repository == 'openclaw/openclaw'");
|
|
expect(smokeDockerCacheStep.if).toContain(
|
|
"github.event.pull_request.head.repo.full_name == 'openclaw/openclaw'",
|
|
);
|
|
expect(smokeDockerCacheStep.with["max-cache-size-mb"]).toBe(800000);
|
|
expect(smokeRunStep.run).toContain("createQaSmokeCiPart");
|
|
expect(smokeRunStep.run).toContain("createQaSmokeCiMatrix");
|
|
expect(smokeRunStep.run).toContain("readQaScenarioPack");
|
|
expect(smokeRunStep.run).toContain("isolate each scenario");
|
|
expect(smokeRunStep.run).toContain("scenario_ids: [scenarioId]");
|
|
expect(smokeRunStep.run).not.toContain("scenarioIdsByKind");
|
|
const compatibilityScenarioBlock = smokeRunStep.run.match(
|
|
/const compatibilityScenarioIds = new Set\(\[([\s\S]*?)\]\);/u,
|
|
)?.[1];
|
|
expect(compatibilityScenarioBlock?.match(/^\s+"[^"]+",$/gmu)).toHaveLength(11);
|
|
expect(compatibilityScenarioBlock).not.toContain('"dreaming-shadow-trial-report"');
|
|
expect(compatibilityScenarioBlock).toContain('"control-ui-chat-flow-playwright"');
|
|
expect(compatibilityScenarioBlock).toContain('"gateway-smoke"');
|
|
expect(compatibilityScenarioBlock).toContain('"matrix-restart-resume"');
|
|
expect(smokeRunStep.run).toContain(
|
|
"console.error(`[skip] ${partId} is not declared by this checkout's smoke plan`)",
|
|
);
|
|
expect(smokeRunStep.run).not.toContain(
|
|
"console.log(`[skip] ${partId} is not declared by this checkout's smoke plan`)",
|
|
);
|
|
expect(smokeRunStep.run).toContain("No QA smoke runs assigned");
|
|
expect(smokeRunStep.run).toContain("node openclaw.mjs qa run");
|
|
expect(smokeRunStep.run).not.toContain("pnpm openclaw qa run");
|
|
expect(smokeRunStep.run).toContain(
|
|
"timeout --signal=TERM --kill-after=15s 10m node openclaw.mjs qa run",
|
|
);
|
|
expect(smokeRunStep.run).toContain("--qa-profile smoke-ci");
|
|
expect(smokeRunStep.run).toContain("--concurrency 10");
|
|
expect(smokeRunStep.env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS).toContain(
|
|
"github.event_name != 'workflow_dispatch'",
|
|
);
|
|
expect(smokeRunStep.env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS).toContain(
|
|
"github.repository == 'openclaw/openclaw'",
|
|
);
|
|
expect(smokeRunStep.env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS).toContain("'0'");
|
|
expect(smokeRunStep.env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS).toContain("'1500'");
|
|
expect(smokeRunStep.run).toContain('scenario_args+=(--scenario "$scenario_id")');
|
|
expect(smokeRunStep.run).toContain('done <<< "$PROFILE_RUNS_TSV"');
|
|
expect(smokeRunStep.run).not.toContain('pids+=("$!")');
|
|
expect(smokeRunStep.run).not.toContain('wait "${pids[$index]}"');
|
|
expect(smokeRunStep.run).not.toContain("--category");
|
|
expect(smokeRunStep.run).not.toContain("--allow-failures");
|
|
expect(smokeRunStep.run).toContain("qa_exit_code=0");
|
|
expect(smokeRunStep.run).toContain('exit "$qa_exit_code"');
|
|
expect(smokeRunStep.run).toContain("OPENCLAW_CURRENT_PACKAGE_TGZ");
|
|
expect(smokeRunStep.run).toContain("--max-old-space-size=16384");
|
|
expect(smokeRunStep.run).not.toContain("scripts/build-all.mjs qaRuntime");
|
|
expect(smokeRunStep.run).not.toContain("OPENAI_API_KEY");
|
|
expect(smokeUploadStep.if).toBe("always()");
|
|
expect(smokeUploadStep.with).toMatchObject({
|
|
path: ".artifacts/qa-e2e/smoke-ci-profile-${{ matrix.slug }}/",
|
|
"if-no-files-found": "warn",
|
|
});
|
|
expect(runStep.run.match(/test\/scripts\/ci-workflow-guards\.test\.ts/g)?.length).toBe(2);
|
|
expect(runStep.run.match(/test\/scripts\/ci-changed-node-test-plan\.test\.ts/g)?.length).toBe(
|
|
2,
|
|
);
|
|
});
|
|
|
|
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).toContain(
|
|
'| select(.filename != "extensions/codex/src/app-server/transport-websocket.ts")',
|
|
);
|
|
expect(workflow).not.toContain('grep -Fv "$codex_transport: " "$added_lines"');
|
|
// Raw-socket exclusions are filename-structural. A monitored package line may
|
|
// contain the transport path as data without disappearing from the scan.
|
|
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).toContain(
|
|
'allowedOwnerScope(call, "extensions/codex/src/app-server/transport-websocket.ts", "connectCodexAppServerUnixSocket")',
|
|
);
|
|
expect(rawSocketQuery).not.toContain(
|
|
'call.getFile().getRelativePath() = "extensions/codex/src/app-server/transport-websocket.ts"',
|
|
);
|
|
});
|
|
});
|