openclaw/test/scripts/ci-git-owner.test-support.ts

630 lines
25 KiB
TypeScript

import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { expect } from "vitest";
import { parse } from "yaml";
import {
ciCheckoutFixture,
expectCiCheckoutCleanup,
readCiCheckoutStep,
renderGitTestClock,
withCiCheckoutFixture,
} from "./ci-checkout.test-support.js";
import {
prepareGeneratedPublisherFixture,
type GeneratedPublisherOptions,
} from "./generated-publisher.test-support.js";
import {
preparePerformanceFixture,
type PerformanceFixtureOptions,
} from "./openclaw-performance-workflow.test-support.js";
type Step = {
name?: string;
run?: string;
env?: Record<string, string | number>;
"working-directory"?: string;
};
type WorkflowTarget = { file: string; job: string; step: string };
type WorkflowInput = { jobs: Record<string, { steps: Step[] }> };
type ActionInput = { runs: { steps: (Step & { run: string })[] } };
export type FetchResult = number | "hang" | "cleanup-failure";
const workflowInputs = new Map<string, WorkflowInput>();
const actionInputs = new Map<string, ActionInput>();
const ownerSource = readFileSync(".github/actions/git-owner/owner.py", "utf8");
const basePolicySource = readFileSync(".github/actions/ensure-base-commit/policy.py", "utf8");
const publisherPolicySource = readFileSync(
".github/actions/publish-generated-pr/policy.py",
"utf8",
);
function readActionSteps(action: string) {
const input =
actionInputs.get(action) ??
(parse(readFileSync(`.github/actions/${action}/action.yml`, "utf8")) as ActionInput);
actionInputs.set(action, input);
return input.runs.steps;
}
const candidate = "a".repeat(40);
const harness = "b".repeat(40);
const base = "c".repeat(40);
const moved = "d".repeat(40);
const merge = "e".repeat(40);
const defaults: Record<string, string> = {
CHECKOUT_REPO: "fixture/checkout",
CHECKOUT_TOKEN: "",
CHECKOUT_REF: candidate,
CHECKOUT_SHA: candidate,
CHECKOUT_FALLBACK_REF: candidate,
CHECKOUT_EVENT_REF: "refs/heads/main",
WORKFLOW_SHA: harness,
CHECKOUT_GIT_COMMITS_JSON: "null",
GITHUB_EVENT_NAME: "push",
GITHUB_REPOSITORY: "fixture/checkout",
DEFAULT_BRANCH: "main",
EVENT_BASE_SHA: base,
GH_TOKEN: "",
PULL_REQUEST_NUMBER: "17",
TARGET_SHA: candidate,
RELEASE_GATE: "false",
FROZEN_TARGET: "false",
HISTORICAL_TARGET: "false",
FORMAT_CHECK: "false",
SKIP_NPM_LOCK: "false",
CHANGED_CORE_TEST_PATHS_JSON: "",
RUN_CONTROL_UI_I18N: "false",
RUN_UI_TESTS: "false",
HOSTED_RUNNER_STRIPES: "false",
RUNNER_PROFILE: "github",
PROTOCOL_SINCE_BASE_SHA: base,
RATCHET_PR_HEAD_SHA: candidate,
};
function stepEnvironment(step: Step, supplied: Record<string, string>) {
const resolved = { ...defaults, ...supplied };
for (const [key, value] of Object.entries(step.env ?? {})) {
if (String(value).startsWith("${{")) {
if (resolved[key] === undefined) {
throw new Error(`Unresolved fixture workflow environment: ${key}`);
}
} else {
resolved[key] = String(value);
}
}
return resolved;
}
function readWorkflowStep({ file, job, step: name }: WorkflowTarget): Step & { run: string } {
const parsed = workflowInputs.get(file) ?? (parse(readFileSync(file, "utf8")) as WorkflowInput);
workflowInputs.set(file, parsed);
const step = parsed.jobs[job]?.steps.find((entry) => entry.name === name);
if (!step?.run) {
throw new Error(`Missing executable workflow step ${file}/${job}/${name}`);
}
return { ...step, run: step.run };
}
export async function runCiGitStep(options: {
workflow?: "workflow-sanity" | WorkflowTarget;
job?: string;
action?:
| "ensure-base-commit"
| "git-owner"
| "mantis-validate-trusted-ref"
| "publish-generated-pr";
performance?: PerformanceFixtureOptions;
publisher?: GeneratedPublisherOptions & { baseChangePath?: "a" | "b" | null };
gitFault?: { match: string; occurrence?: number; code: FetchResult | "cancel"; output?: string };
gitFaults?: {
match: string;
occurrence?: number;
code: FetchResult | "cancel";
output?: string;
}[];
policy?: string;
inlinePolicy?: boolean;
step?: string;
stepOutputs?: Record<string, Record<string, string>>;
env?: Record<string, string>;
fetchResults: FetchResult[];
cloneResults?: FetchResult[];
worktreeResults?: FetchResult[];
rebaseResults?: FetchResult[];
pushResults?: FetchResult[];
revParseResult?: FetchResult;
diffResult?: number;
commandResults?: Record<string, { code: FetchResult; output?: string }>;
workflowRuns?: {
id: number;
run_attempt: number;
created_at: string;
status: string;
conclusion: string | null;
head_sha: string;
}[];
workflowJobs?: {
runId: number;
runAttempt: number;
jobs: {
name: string;
status: string;
conclusion: string | null;
steps: { name: string; status: string; conclusion: string | null }[];
}[];
}[];
publishPath?: "directory" | "file" | "symlink";
checkoutResults?: number[];
mergeSnapshots?: { sha: string; head: string }[];
prepare?: boolean;
checkoutBeforeStep?: boolean;
cancelDuringCleanup?: boolean;
cleanupCancelMatch?: string;
startupDelay?: { tree: number };
revisions?: Record<string, string>;
mergeBase?: { ancestor: boolean; revision: string };
poisonPython?: boolean;
baseAvailableAfter?: number;
invalidRef?: boolean;
scenario?: string;
lsRemoteResults?: { output: string; code: number | "hang" | "cleanup-failure" }[];
realClock?: boolean;
virtualBackoff?: boolean;
realDrain?: boolean;
readyFetchClockAdvanceSeconds?: number;
objects?: Record<string, { probe?: number; code?: number; text: string }>;
cooperativeTrees?: boolean;
cancelDuringBackoff?: boolean;
setupFailure?: "owner" | "python" | "git";
}) {
const maturity =
typeof options.workflow === "object" &&
options.workflow.file === ".github/workflows/maturity-scorecard.yml";
const docsPublish =
typeof options.workflow === "object" &&
options.workflow.file === ".github/workflows/docs-sync-publish.yml";
const docsAgent =
typeof options.workflow === "object" &&
options.workflow.file === ".github/workflows/docs-agent.yml";
const releaseAdmission =
typeof options.workflow === "object" &&
[
".github/workflows/linux-app-release.yml",
".github/workflows/macos-release.yml",
".github/workflows/npm-placeholder-bootstrap.yml",
].includes(options.workflow.file);
const pluginRelease =
typeof options.workflow === "object" &&
[
".github/workflows/plugin-clawhub-release.yml",
".github/workflows/plugin-npm-release.yml",
].includes(options.workflow.file);
const publisher = options.action === "publish-generated-pr";
const externalOwner =
options.workflow || options.action === "mantis-validate-trusted-ref" || publisher;
const clock = {
...options,
realDrain:
options.cancelDuringCleanup || options.scenario?.startsWith("cancel-") || options.realDrain,
};
const step: (Step & { run: string }) | undefined = options.action
? readActionSteps(options.action).find((entry) =>
options.step ? entry.name === options.step : entry.run,
)
: options.workflow
? readWorkflowStep(
options.workflow === "workflow-sanity"
? {
file: ".github/workflows/workflow-sanity.yml",
job: "actionlint",
step: "Prepare trusted workflow audit configs",
}
: options.workflow,
)
: readCiCheckoutStep(
options.job ?? "security-fast",
options.step ?? (options.job ? "Checkout" : "Prepare Git owner"),
);
if (!step?.run) {
throw new Error("Missing executable action step");
}
let env: Record<string, string>;
let performanceFixture: ReturnType<typeof preparePerformanceFixture> | undefined;
let publisherFixture: ReturnType<typeof prepareGeneratedPublisherFixture> | undefined;
return withCiCheckoutFixture(
`linux:${options.scenario ?? "configured"}`,
(root) => {
const actions = path.join(root, "trusted-actions");
if (options.performance) {
performanceFixture = preparePerformanceFixture(root, options.performance);
}
env = stepEnvironment(step, {
PUBLISH_ACTION_PATH: path.resolve(".github/actions/publish-generated-pr"),
CONTENTS_TOKEN: "fixture-contents",
HEAD_BRANCH: "automation/locale",
BASE_BRANCH: "main",
COMMIT_MESSAGE: "fixture",
PR_TITLE: "fixture",
PR_BODY: "fixture",
GENERATED_PATHS: "generated",
INVALIDATION_PATHS: "source",
OVERLAP_POLICY: "defer",
AUTO_MERGE: "false",
BASE_SHA: base,
BASE_REF: "main",
FETCH_REF: "fixture-base",
BASE_ACTION_PATH: path.join(actions, "ensure-base-commit"),
OWNER_ACTION_PATH: path.join(actions, "git-owner"),
...performanceFixture?.env,
...options.env,
});
const workspace = path.join(root, "workspace");
if (publisher) {
publisherFixture = prepareGeneratedPublisherFixture(
root,
options.publisher?.baseChangePath ?? null,
options.publisher,
"workspace",
);
env = {
...env,
...publisherFixture.env,
...options.env,
PUBLISH_ACTION_PATH: path.resolve(".github/actions/publish-generated-pr"),
GITHUB_STEP_SUMMARY: path.join(root, "github-summary"),
FAKE_REAL_GIT: execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
};
delete env.PATH;
}
if (docsAgent) {
env.GITHUB_TOKEN = "fixture-docs-agent-token";
env.GH_TOKEN = "";
}
if (docsPublish) {
env.GITHUB_SHA = candidate;
// Never let a caller's credential reach fixture command reports.
env.OPENCLAW_DOCS_SYNC_TOKEN = "fixture-docs-token";
env.OPENCLAW_DOCS_MDX_CACHE = path.join(root, "docs-mdx-cache.json");
mkdirSync(path.join(workspace, "clawhub-source/.git"), { recursive: true });
const publish = path.join(workspace, "publish");
if (options.publishPath === "file") {
writeFileSync(publish, "previous publish path\n");
} else if (options.publishPath === "symlink") {
symlinkSync(root, publish, "junction");
} else if (options.publishPath === "directory") {
mkdirSync(publish);
writeFileSync(path.join(publish, ".previous-checkout"), "stale\n");
}
}
if (externalOwner) {
// Workflow bodies follow actions/checkout; selected-source bootstrap must
// still create its own directory, while later selected steps inherit one.
for (const directory of [
workspace,
...(!publisher && step["working-directory"]
? [path.join(workspace, step["working-directory"])]
: []),
]) {
mkdirSync(path.join(directory, ".git"), { recursive: true });
writeFileSync(path.join(directory, ".git/preexisting.lock"), "not invocation-owned\n");
}
if (pluginRelease) {
writeFileSync(path.join(workspace, "package.json"), '{"version":"2026.8.33"}\n');
}
}
if (options.startupDelay?.tree) {
writeFileSync(
path.join(root, "tree-start-delay-1.json"),
String(options.startupDelay.tree),
);
}
for (const action of ["git-owner", "ensure-base-commit"]) {
mkdirSync(path.join(actions, action), { recursive: true });
const name = action === "git-owner" ? "owner.py" : "policy.py";
let source = renderGitTestClock(
action === "git-owner" ? ownerSource : basePolicySource,
clock,
);
if (
action === "git-owner" &&
(publisher || maturity || pluginRelease || releaseAdmission || options.performance)
) {
source = source.replace(
"def main():",
`def fixture_file_boundary(event, args):
names = {os.environ["GITHUB_OUTPUT"]: "output", os.environ["GITHUB_STEP_SUMMARY"]: "summary",
os.environ["GITHUB_ENV"]: "environment",
os.path.join(os.environ["RUNNER_TEMP"], "generated-pr-push.log"): "push-log"}
if event == "open" and args[0] in names:
subprocess.run([${JSON.stringify(process.execPath)}, ${JSON.stringify(ciCheckoutFixture)},
"observe", os.environ["TMPDIR"], "linux:configured", names[args[0]]], check=True)
sys.addaudithook(fixture_file_boundary)
def main():`,
);
}
if (action === "git-owner" && options.performance) {
source = source.replace(
"def backoff(seconds):",
`def backoff(seconds):
subprocess.run([${JSON.stringify(process.execPath)}, ${JSON.stringify(ciCheckoutFixture)},
"observe", os.environ["TMPDIR"], "linux:configured", "backoff"], check=True)`,
);
}
if (action === "git-owner" && options.cancelDuringBackoff) {
if (!options.realClock || options.virtualBackoff) {
throw new Error("Backoff cancellation requires the real owner clock");
}
const boundary = " while time.monotonic() < retry_at:\n check_cancelled()";
if (source.split(boundary).length !== 2) {
throw new Error("Missing unique Git owner backoff cancellation boundary");
}
source = source.replace(
boundary,
`${boundary}
subprocess.run([${JSON.stringify(process.execPath)}, ${JSON.stringify(ciCheckoutFixture)},
"observe", os.environ["TMPDIR"], "linux:configured", "backoff-ready"], check=True)`,
);
}
writeFileSync(path.join(actions, action, name), source);
}
if (publisher) {
mkdirSync(path.join(actions, "publish-generated-pr"), { recursive: true });
writeFileSync(
path.join(actions, "publish-generated-pr/policy.py"),
renderGitTestClock(publisherPolicySource, clock),
);
env.PUBLISH_ACTION_PATH = path.join(actions, "publish-generated-pr");
}
const protectedFile = path.join(
env.CHECKOUT_KIND === "clawhub" ? workspace : root,
"protected",
);
writeFileSync(protectedFile, "not checkout-owned\n");
if (["android", "clawhub"].includes(env.CHECKOUT_KIND ?? "")) {
const checkout =
env.CHECKOUT_KIND === "clawhub" ? path.join(workspace, "clawhub-source") : workspace;
mkdirSync(checkout, { recursive: true });
writeFileSync(path.join(checkout, ".previous-checkout"), "stale\n");
}
if (options.poisonPython) {
env.PYTHONPATH = workspace;
const poison = `from pathlib import Path\nPath(${JSON.stringify(path.join(root, "python-injected"))}).write_text("injected")\nraise RuntimeError("candidate Python startup executed")\n`;
for (const name of ["sitecustomize.py", "subprocess.py"]) {
writeFileSync(path.join(workspace, name), poison);
}
}
const revisions = {
HEAD: candidate,
"refs/heads/main": moved,
"refs/pull/17/merge": merge,
"refs/remotes/origin/release-gate-merge^1": base,
"refs/remotes/origin/release-gate-merge^2": candidate,
...options.revisions,
};
writeFileSync(
path.join(root, "fixture-options.json"),
JSON.stringify({
env,
publisher: publisherFixture
? { git: env.FAKE_REAL_GIT, gh: path.join(publisherFixture.fakeBin, "gh") }
: undefined,
performance: performanceFixture?.proxy,
gitFault: options.gitFault,
gitFaults: options.gitFaults,
revisions,
mergeBase: options.mergeBase,
workingDirectory: publisher ? undefined : step["working-directory"],
fetchResults: options.fetchResults,
cloneResults: options.cloneResults,
worktreeResults: options.worktreeResults,
rebaseResults: options.rebaseResults,
pushResults: options.pushResults,
revParseResult: options.revParseResult,
diffResult: options.diffResult,
commandResults: options.commandResults,
workflowRuns: options.workflowRuns,
workflowJobs: options.workflowJobs,
docsAgent,
docsPublish,
maturity,
pluginRelease,
releaseAdmission,
checkoutResults: options.checkoutResults,
mergeSnapshots: options.mergeSnapshots,
consumers: Boolean(options.prepare || options.checkoutBeforeStep || externalOwner),
cancelDuringCleanup: options.cancelDuringCleanup,
cleanupCancelMatch: options.cleanupCancelMatch,
baseAvailableAfter: options.baseAvailableAfter,
invalidRef: options.invalidRef,
lsRemoteResults: options.lsRemoteResults,
objects: options.objects,
cooperativeTrees: options.cooperativeTrees,
cancelDuringBackoff: options.cancelDuringBackoff,
setupFailure: options.setupFailure,
}),
);
let run = renderGitTestClock(step.run, clock);
for (const [stepId, outputs] of Object.entries(options.stepOutputs ?? {})) {
for (const [name, value] of Object.entries(outputs)) {
run = run.replaceAll(`\${{ steps.${stepId}.outputs.${name} }}`, value);
}
}
if (externalOwner) {
const prepareRun = readActionSteps("git-owner")[0]?.run;
if (!prepareRun) {
throw new Error("Missing Git owner preparation body");
}
writeFileSync(path.join(root, "prepare.sh"), prepareRun);
// Model the runner's environment handoff, not shell evaluation of paths with spaces.
run = `bash --noprofile --norc -eo pipefail "$TMPDIR/prepare.sh"
export CI_GIT_OWNER
CI_GIT_OWNER="$(sed -n 's/^CI_GIT_OWNER=//p' "$GITHUB_ENV")"
: > "$GITHUB_ENV"
: > "$GITHUB_OUTPUT"
${options.setupFailure === "owner" ? 'rm "$CI_GIT_OWNER"' : ""}
${run}`;
}
if (options.policy) {
const policy = path.join(root, "policy.py");
writeFileSync(policy, options.policy);
run =
'unset RUNNER_OS GITHUB_WORKSPACE RUNNER_TEMP\nexec python3 -I -S "$OWNER_ACTION_PATH/owner.py" --policy ' +
(options.inlinePolicy ? '- < "$TMPDIR/policy.py"' : '"$TMPDIR/policy.py"');
}
if (options.prepare) {
const prepare = readCiCheckoutStep("security-fast", "Prepare Git owner");
const prepareEnv = stepEnvironment(prepare, {});
writeFileSync(path.join(root, "prepare.sh"), renderGitTestClock(prepare.run, clock));
// Run the actual prepare body in its own shell: its exec must not replace the caller.
run = `CHECKOUT_KIND=${prepareEnv.CHECKOUT_KIND} bash --noprofile --norc -eo pipefail "$TMPDIR/prepare.sh"\n${run}`;
}
if (options.checkoutBeforeStep) {
const checkout = readCiCheckoutStep(options.job ?? "checks-fast-core");
writeFileSync(path.join(root, "bootstrap.sh"), renderGitTestClock(checkout.run, clock));
run = `bash --noprofile --norc -eo pipefail "$TMPDIR/bootstrap.sh"\n${run}`;
}
if (options.performance) {
const mapfileShim =
options.performance.mode === "prepare"
? `if ! type mapfile >/dev/null 2>&1; then
mapfile() {
local delimiter=$'\\n'
if [[ "\${1:-}" == "-d" ]]; then delimiter="$2"; shift 2; fi
local destination="$1" item quoted index=0
eval "$destination=()"
while IFS= read -r -d "$delimiter" item; do
printf -v quoted '%q' "$item"
eval "$destination[$index]=$quoted"
index=$((index + 1))
done
}
fi
`
: "";
// Observe immediately after each owner invocation; Python policies separately
// expose output/summary writes through the audit hook, and exit is always censused.
run = `${mapfileShim}performance_owner_pending=false
performance_owner_boundary() {
local command="$1"
if [[ "$performance_owner_pending" == "true" ]]; then
${JSON.stringify(process.execPath)} ${JSON.stringify(ciCheckoutFixture)} observe "$TMPDIR" linux:configured shell-command
performance_owner_pending=false
fi
if [[ "$command" == *CI_GIT_OWNER* ]]; then performance_owner_pending=true; fi
}
trap 'performance_owner_boundary "$BASH_COMMAND"' DEBUG
${run}`;
}
writeFileSync(path.join(root, "checkout.sh"), run);
},
(report, result, stderr, root) => {
const workspace = path.join(root, "workspace");
const protectedFile = path.join(
env.CHECKOUT_KIND === "clawhub" ? workspace : root,
"protected",
);
const actions = path.join(root, "trusted-actions");
console.log(
`${typeof options.workflow === "object" ? `${options.workflow.file}/${options.workflow.job}/${options.workflow.step}` : `${options.workflow ?? options.action ?? options.job}/${options.step ?? "Checkout"}`}: ${JSON.stringify(report)}`,
);
expect(result, `${stderr}\n${report.error ?? ""}`).toEqual({ code: 0, signal: null });
expect(report.error, stderr).toBeUndefined();
expectCiCheckoutCleanup(report);
if (docsAgent) {
expect(
readdirSync(path.join(root, "temp")).filter((name) => name.startsWith("docs-agent-")),
).toEqual([]);
}
if (externalOwner) {
for (const directory of new Set([
workspace,
...report.commands
.filter(({ tool, args }) => tool === "git" && args[0] === "fetch")
.map(({ cwd }) => cwd),
...report.commands
.filter(
({ tool, args }) => tool === "git" && (args[0] === "clone" || args[0] === "worktree"),
)
.map(({ cwd, args }) => path.resolve(cwd, args.at(args[0] === "clone" ? -1 : -2)!)),
])) {
expect(readFileSync(path.join(directory, ".git/preexisting.lock"), "utf8")).toBe(
"not invocation-owned\n",
);
}
}
expect(readFileSync(protectedFile, "utf8")).toBe("not checkout-owned\n");
expect(
existsSync(path.join(root, "python-injected")),
"candidate Python startup executed",
).toBe(false);
const readOutput = (name: string) =>
existsSync(path.join(root, name)) ? readFileSync(path.join(root, name), "utf8") : "";
if (options.action === "ensure-base-commit") {
expect(report.output).not.toContain("fixture quiet probe");
}
if (options.action === "git-owner") {
const ownerPath = readOutput("github-env")
.trim()
.replace(/^CI_GIT_OWNER=/u, "");
expect(path.relative(path.join(root, "temp"), ownerPath)).not.toMatch(/^\.\./u);
expect(readFileSync(ownerPath, "utf8")).toBe(
readFileSync(path.join(actions, "git-owner/owner.py"), "utf8"),
);
expect(readOutput("github-output")).toBe(`owner-path=${ownerPath}\n`);
}
const authHeaderPresent = publisher
? execFileSync(env.FAKE_REAL_GIT!, ["-C", workspace, "config", "--local", "--list"], {
encoding: "utf8",
}).includes("http.https://github.com/.extraheader=")
: false;
return {
...report,
backoffClockAdvancedSeconds: [
...report.output.matchAll(/fixture backoff advanced: ([\d.]+)/gu),
].reduce((total, match) => total + Number(match[1]), 0),
...(options.readyFetchClockAdvanceSeconds === undefined
? {}
: {
fetchClockAdvancedSeconds:
options.readyFetchClockAdvanceSeconds *
readdirSync(root).filter((name) => /^fetch-tick-\d+\.json$/u.test(name)).length,
}),
authHeaderPresent,
initialBranch: publisherFixture?.initialBranch,
publication: publisherFixture?.inspect(report.output, false),
performance: performanceFixture?.inspect(),
pluginSourcePackage: pluginRelease ? readOutput("temp/fixture-source-package.json") : "",
pushLog: readOutput("runner-temp/generated-pr-push.log"),
workspace,
githubOutput: readOutput("github-output"),
githubEnv: readOutput("github-env"),
githubSummary: readOutput("github-summary"),
githubPath: readOutput("github-path"),
trustedConfig: readOutput("temp/pre-commit-base.yaml"),
trustedZizmor: readOutput("temp/zizmor-base.yml"),
runnerTemp: performanceFixture?.env.RUNNER_TEMP ?? path.join(root, "temp"),
fetches: report.commands.filter(({ tool, args }) => tool === "git" && args[0] === "fetch"),
clones: report.commands.filter(({ tool, args }) => tool === "git" && args[0] === "clone"),
worktrees: report.commands.filter(
({ tool, args }) => tool === "git" && args[0] === "worktree",
),
rebases: report.commands.filter(({ tool, args }) => tool === "git" && args[0] === "rebase"),
pushes: report.commands.filter(({ tool, args }) => tool === "git" && args[0] === "push"),
checkouts: report.commands.filter(
({ tool, args }) => tool === "git" && args[0] === "checkout",
),
};
},
);
}