mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* 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>
377 lines
14 KiB
TypeScript
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)];
|
|
}),
|
|
)
|
|
: {};
|
|
}
|