openclaw/test/scripts/ci-workflow.test-support.ts
Mitchell Etzel dc794c0481
fix(ci): make PR runner capacity independent of author (#160958)
* ci: plan trusted fork PRs with their Blacksmith runner profile

Trusted fork pull requests (CONTRIBUTOR and above) already run on the same
Blacksmith labels as same-repository PRs, but preflight forced them into the
logical `github` planner profile. That profile plans from empty cold-start
timing hints and skips Blacksmith capacity promotion, so these PRs ran compact
Node bins on 2-CPU runners well past the admission budget.

Trusted fork first attempts now keep the configured profile. Untrusted forks
and fork retries (which route hosted) still use `github`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4y3wMpVsBQLpzEEpvAySR

* ci: give every pull request author maintainer CI capacity

Non-maintainer pull requests ran on smaller or hosted runners while CI
timing budgets assume maintainer capacity, so their compact Node shards
regularly exceeded admission budgets. Drop author-association gating
from runner routing, the planner profile, matrix caps, parallelism and
hosted-offload admission so every PR first attempt, including forks
from first-time contributors, gets the same Blacksmith routes as a
maintainer PR.

Fork retries still route hosted because forks cannot read the backend
override, and cache trust stays restricted to openclaw/openclaw.

Approved by maintainer Patrick Erichsen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4y3wMpVsBQLpzEEpvAySR

* ci: keep hosted lint stripes for forks while unifying Node planning

The unified profile put fork PRs on the all-in-one check-lint and
check-test-types jobs. Those jobs are sized for the trusted sticky disks
and caches forks cannot mount, and both were canceled at the 20-minute
limit in run 36579539665 while still linting and compiling.

Split the decision: forks keep the logical github check profile (hosted
lint/type stripes), while node_runner_backend keeps the configured backend
on fork first attempts, so every PR still gets the same Node planning,
32-vCPU promotion and measured timings regardless of author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4y3wMpVsBQLpzEEpvAySR

* ci: give fork first attempts the 130-row Node parallelism

The 130-row Node max-parallel still required a same-repository head and a
non-github runner profile, so fork first attempts planned up to 130 rows
but ran them 96 at a time. Key the cap on the Node planner backend instead,
which is Blacksmith or hybrid for every PR first attempt regardless of
origin or author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4y3wMpVsBQLpzEEpvAySR

* test(ci): source the package helper from the published-driver stub image helper (#163027)

The cell plan test fixture stubbed docker-e2e-image.sh without sourcing docker-e2e-package.sh; #162824 removed the cell script direct source that the stub relied on, so the main hourly failed with docker_e2e_prepare_package_tgz: command not found. The stub now sources its sibling package helper like the real helper.

Refs #162824 #162965

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-10-01 16:04:01 -07:00

377 lines
14 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { runInNewContext } from "node:vm";
import { parse } from "yaml";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { resolveWorkflowBash } from "../helpers/workflow-bash.js";
export const CHECKOUT_V6 = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1";
export const CACHE_V5 = "actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9";
export const CACHE_SAVE_V5 = "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9";
export const SETUP_GO_V6 = "actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e";
export const UPLOAD_ARTIFACT_V7 =
"actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
export const DOWNLOAD_ARTIFACT_V8 =
"actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c";
export const MANTIS_GITHUB_APP_CLIENT_ID = "Iv23liPJCozR0uHm6P7G";
const SETUP_ANDROID_TOOLCHAIN_ACTION = ".github/actions/setup-android-toolchain/action.yml";
export const MATURITY_SCORECARD_WORKFLOW = ".github/workflows/maturity-scorecard.yml";
export const AMBIGUOUS_MAIN_PUSH_DIAGNOSTIC =
"::error title=ambiguous main push::github.event.before is zero; refusing to infer a diff base for a created or recreated main branch.";
export const testNodeExecPath = resolveTestNodeExecPath();
export const TSX_IMPORT = import.meta.resolve("tsx");
export type WorkflowStep = {
"continue-on-error"?: boolean;
env?: Record<string, unknown>;
id?: string;
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, unknown>;
"working-directory"?: string;
};
export const readCiWorkflow = (() => {
// The checked-in workflow is fixed for this suite; clones keep fixture mutations local.
const workflow = parse(readFileSync(".github/workflows/ci.yml", "utf8"));
return () => structuredClone(workflow);
})();
export function evaluateWorkflowExpression(
expression: unknown,
context: {
action?: string;
actor?: string;
githubEvent?: Record<string, unknown>;
maintainerCommands?: string;
authorAssociation?: string;
cancelled?: boolean;
dispatchId?: string;
draft?: boolean;
eventName:
| "pull_request"
| "pull_request_target"
| "issue_comment"
| "issues"
| "push"
| "workflow_dispatch"
| "workflow_run"
| "repository_dispatch"
| "schedule";
failed?: boolean;
env?: Record<string, string>;
frozenTarget?: boolean;
fileHashes?: Record<string, string>;
headRepository?: string;
headSha?: string;
hostedRunnerProfileContract?: boolean;
matrix?: Record<string, unknown>;
preflightOutputs?: Record<string, string>;
additionalNeeds?: Record<
string,
{
outputs: Record<string, string>;
result?: "success" | "failure" | "cancelled" | "skipped";
}
>;
jobResults?: Record<string, string>;
preflightResult?: string;
failFastOutputs?: Record<string, string>;
failFastResult?: string;
pullRequestNumber?: number;
ref?: string;
resolveTargetOutputs?: Record<string, string>;
releaseGate?: boolean;
releaseRunnerGroup?: string;
releasePublishRunId?: string;
runnerGroup?: string;
releasePriorityRun?: string;
releaseScope?: string;
validationTier?: "full" | "main";
repository: string;
runCheck?: boolean;
runWindowsCi?: boolean;
runnerBackend?: "" | "blacksmith" | "github" | "hybrid" | "runson";
requestedRunnerBackend?: "default" | "hybrid" | "runson";
ciShape?: "default" | "main";
ciOnPush?: string;
includeAndroid?: boolean;
runnerEnvironment?: "" | "github-hosted" | "self-hosted";
runnerProfile?: "blacksmith" | "github" | "hybrid";
runAttempt: number;
runId?: number;
runNumber?: number;
sha?: string;
skipDefenderExclusions?: boolean;
steps?: Record<
string,
{ outputs: Record<string, string>; outcome?: "success" | "failure" | "cancelled" | "skipped" }
>;
targetContextRef?: string;
targetRef?: string;
useGithubHostedRunners?: boolean;
workflow?: string;
workflowSha?: string;
workflowToken?: string;
windowsCiReplay?: string;
workspace?: string;
},
) {
if (typeof expression !== "string") {
throw new TypeError("workflow expression must be a string");
}
const match = expression.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/u);
if (!match) {
throw new Error(`invalid workflow expression: ${expression}`);
}
const source = match[1];
if (source === undefined) {
throw new Error(`workflow expression has no body: ${expression}`);
}
// Actions permits dashes in property names; preserve quoted literals while
// translating those accesses for the JavaScript fixture evaluator.
const evaluableSource = source.replace(
/'(?:[^']|'')*'|\.([A-Za-z_][\w-]*)/gu,
(token: string, property: string | undefined) =>
property?.includes("-") ? `[${JSON.stringify(property)}]` : token,
);
return runInNewContext(evaluableSource, {
always: () => true,
success: () => !context.failed && !context.cancelled,
failure: () => context.failed ?? false,
cancelled: () => context.cancelled ?? false,
// GitHub expression builtins the runner-routing clauses use.
contains: (haystack: unknown, needle: unknown) =>
Array.isArray(haystack)
? haystack.includes(needle)
: String(haystack).includes(String(needle)),
endsWith: (value: unknown, suffix: unknown) =>
String(value).toLowerCase().endsWith(String(suffix).toLowerCase()),
fromJSON: (value: string) => JSON.parse(value) as unknown,
fromJson: (value: string) => JSON.parse(value) as unknown,
format: (value: string, ...args: unknown[]) =>
value.replace(/\{\{|\}\}|\{(\d+)\}/gu, (token, index: string | undefined) =>
index === undefined ? token[0]! : String(args[Number(index)]),
),
hashFiles: (file: string) => context.fileHashes?.[file] ?? "",
startsWith: (value: unknown, prefix: unknown) => String(value).startsWith(String(prefix)),
toJson: (value: unknown) => JSON.stringify(value),
toJSON: (value: unknown) => JSON.stringify(value),
github: {
actor: context.actor ?? "",
event_name: context.eventName,
repository: context.repository,
ref: context.ref ?? "refs/heads/main",
run_attempt: context.runAttempt,
run_id: context.runId,
run_number: context.runNumber,
sha: context.sha,
workflow: context.workflow,
workflow_sha: context.workflowSha,
workspace: context.workspace,
token: context.workflowToken,
event:
context.githubEvent ??
(context.headRepository || context.eventName === "pull_request"
? {
action: context.action,
pull_request: {
author_association: context.authorAssociation ?? "CONTRIBUTOR",
draft: context.draft ?? false,
number: context.pullRequestNumber,
head: {
sha: context.headSha,
repo: { full_name: context.headRepository ?? context.repository },
},
},
}
: {}),
},
inputs: {
dispatch_id: context.dispatchId ?? "",
runner_group: context.runnerGroup ?? "",
release_publish_run_id: context.releasePublishRunId ?? "",
runner_backend: context.requestedRunnerBackend ?? "default",
ci_shape: context.ciShape ?? "default",
include_android: context.includeAndroid ?? false,
release_gate: context.releaseGate ?? false,
release_scope: context.releaseScope ?? "full",
validation_tier: context.validationTier ?? "full",
target_context_ref: context.targetContextRef ?? "",
target_ref: context.targetRef ?? "",
use_github_hosted_runners: context.useGithubHostedRunners ?? false,
run_windows_ci: context.runWindowsCi ?? false,
skip_defender_exclusions: context.skipDefenderExclusions ?? false,
windows_ci_replay: context.windowsCiReplay ?? "",
},
env: context.env ?? {},
matrix: context.matrix ?? {},
runner: { environment: context.runnerEnvironment ?? "" },
steps: {
runner_profile: { outputs: { node_runner_backend: "" } },
qualification_dispatch: { outputs: { eligible: "false" } },
...context.steps,
},
needs: {
...context.additionalNeeds,
resolve_target: { outputs: context.resolveTargetOutputs ?? {} },
"checks-baseline-ratchets": {
result: context.jobResults?.["checks-baseline-ratchets"] ?? "success",
},
preflight: {
result: context.preflightResult ?? context.jobResults?.preflight ?? "success",
outputs: {
frozen_target: String(context.frozenTarget ?? false),
hosted_runner_profile_contract: String(context.hostedRunnerProfileContract ?? true),
run_check: String(context.runCheck ?? true),
runner_profile: context.runnerProfile ?? context.runnerBackend ?? "blacksmith",
// Preflight defaults the Node planner backend to the logical profile.
node_runner_backend: context.runnerProfile ?? context.runnerBackend ?? "blacksmith",
...context.preflightOutputs,
},
},
"pr-fail-fast": {
result: context.failFastResult ?? "success",
outputs: { failure_job_id: "", failure_run_attempt: "", ...context.failFastOutputs },
},
},
vars: {
MAINTAINER_COMMAND_REACTIONS: context.maintainerCommands ?? "",
OPENCLAW_CI_RUNNER_BACKEND: context.runnerBackend ?? "",
OPENCLAW_RELEASE_RUNNER_GROUP: context.releaseRunnerGroup ?? "",
OPENCLAW_CI_ON_PUSH: context.ciOnPush ?? "",
OPENCLAW_RELEASE_PRIORITY_RUN: context.releasePriorityRun ?? "",
},
});
}
export function evaluateWorkflowRunner(
selector: unknown,
context: Partial<Parameters<typeof evaluateWorkflowExpression>[1]> = {},
) {
return typeof selector === "string" && selector.startsWith("${{")
? evaluateWorkflowExpression(selector, {
eventName: "workflow_dispatch",
repository: "openclaw/openclaw",
runAttempt: 1,
...context,
})
: selector;
}
export function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
let linuxWorkflowBash: string | undefined;
export function runWorkflowShellScript(
script: string,
options: { cwd?: string; env?: NodeJS.ProcessEnv; linuxWorkflow?: boolean; tempDir?: string },
) {
const { linuxWorkflow, tempDir, ...spawnOptions } = options;
const root = mkdtempSync(path.join(tmpdir(), "openclaw-workflow-shell-"));
const childTempDir = tempDir ?? root;
try {
let moduleIndex = 0;
const rewritten = script
.replace(
/node (?:(--import tsx |"\$\{manifest_node_args\[@\]\}" ))?--input-type=module <<'([A-Z][A-Z0-9_]*)'\n([\s\S]*?)\n\2(?=\n|$)/gu,
(_match, nodeOptions: string | undefined, _marker: string, body: string) => {
// Keep scratch outside the checkout's compiler namespace. File-backed stdin
// avoids Bash heredoc deadlocks and preserves the workflow's cwd-based imports.
const modulePath = path.join(root, `module-${moduleIndex}.mjs`);
moduleIndex += 1;
writeFileSync(modulePath, `${body}\n`, "utf8");
const loader =
nodeOptions === "--import tsx "
? `--import ${quoteShell(TSX_IMPORT)} `
: (nodeOptions ?? "");
return `${quoteShell(testNodeExecPath)} ${loader}--input-type=module < ${quoteShell(modulePath)}`;
},
)
.replace(
'node "${manifest_node_args[@]}" .ci-harness/scripts/ci-build-manifest.mjs',
`${quoteShell(testNodeExecPath)} "\${manifest_node_args[@]}" .ci-harness/scripts/ci-build-manifest.mjs`,
)
.replaceAll(
"manifest_node_args+=(--import tsx)",
`manifest_node_args+=(--import ${quoteShell(TSX_IMPORT)})`,
);
const scriptPath = path.join(root, "run.sh");
writeFileSync(scriptPath, rewritten.endsWith("\n") ? rewritten : `${rewritten}\n`, "utf8");
const bash =
linuxWorkflow && process.platform === "darwin"
? (linuxWorkflowBash ??= resolveWorkflowBash())
: "bash";
return spawnSync(bash, [scriptPath], {
...spawnOptions,
encoding: "utf8",
// Child caches and temporary artifacts share the fixture's cleanup owner.
// Inheriting a huge host tsx cache makes startup depend on unrelated runs.
env: {
...(options.env ?? process.env),
TMPDIR: childTempDir,
TMP: childTempDir,
TEMP: childTempDir,
},
});
} finally {
rmSync(root, { force: true, recursive: true });
}
}
export function readAndroidToolchainAction() {
return parse(readFileSync(SETUP_ANDROID_TOOLCHAIN_ACTION, "utf8"));
}
export function readBuildArtifactsTestboxWorkflow() {
return parse(readFileSync(".github/workflows/ci-build-artifacts-testbox.yml", "utf8"));
}
export function readMaturityScorecardWorkflow() {
return parse(readFileSync(MATURITY_SCORECARD_WORKFLOW, "utf8"));
}
export function readReleaseChecksWorkflow() {
return parse(readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8"));
}
export function readWorkflow(filePath: string) {
return parse(readFileSync(filePath, "utf8"));
}
export function readTrackedText(relativePath: string): string {
if (existsSync(relativePath)) {
return readFileSync(relativePath, "utf8");
}
return execFileSync("git", ["show", `:${relativePath}`], { encoding: "utf8" });
}
export function runGit(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
export function writeExecutable(filePath: string, lines: string[]): void {
writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8");
chmodSync(filePath, 0o755);
}
export function readWorkflowOutputs(outputPath: string): Record<string, string> {
if (!existsSync(outputPath)) {
return {};
}
const output = readFileSync(outputPath, "utf8").trim();
return output
? Object.fromEntries(
output.split("\n").map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}),
)
: {};
}