// Covers the scripts/pr prepare-gates remote testbox mode and the // cross-worktree gate lock that serializes whole gate blocks. import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const repoRoot = process.cwd(); const gateLockHelperPath = join(repoRoot, "scripts", "pr-gates-lock.mjs"); const tempDirs: string[] = []; const children: ChildProcess[] = []; function makeTempDir(prefix: string): string { // macOS os.tmpdir() is a /var -> /private/var symlink; resolve so lock and // owner paths compare canonically. const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); tempDirs.push(dir); return dir; } function makeLockRepoDir(): string { const dir = makeTempDir("openclaw-pr-gates-lock-"); mkdirSync(join(dir, ".git"), { recursive: true }); return dir; } function heavyCheckLockDir(repoDir: string): string { return join(repoDir, ".git", "openclaw-local-checks", "heavy-check.lock"); } function sanitizedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { // check:changed and gate runs export these to children; drop ambient copies // so lock and mode behavior under test only sees explicit overrides. const env: NodeJS.ProcessEnv = { ...process.env }; delete env.OPENCLAW_PR_GATES_REMOTE; delete env.OPENCLAW_TESTBOX; delete env.OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD; delete env.OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD; delete env.OPENCLAW_OXLINT_SKIP_LOCK; delete env.OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS; delete env.OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS; return { ...env, ...overrides }; } function runGatesBash( script: string, options: { cwd?: string; env?: NodeJS.ProcessEnv; sourcePrepareCore?: boolean; sourcePush?: boolean; } = {}, ) { return spawnSync( "bash", [ "-c", [ "set -euo pipefail", `script_parent_dir='${repoRoot}/scripts'`, `source '${repoRoot}/scripts/pr-lib/common.sh'`, `source '${repoRoot}/scripts/pr-lib/gates.sh'`, "mark_pr_operation_side_effects_started() { :; }", ...(options.sourcePush ? [`source '${repoRoot}/scripts/pr-lib/push.sh'`] : []), ...(options.sourcePrepareCore ? [`source '${repoRoot}/scripts/pr-lib/prepare-core.sh'`] : ["refresh_prep_branch_for_reviewed_head() { :; }"]), script, ].join("\n"), ], { cwd: options.cwd ?? repoRoot, encoding: "utf8", env: sanitizedEnv(options.env), }, ); } function spawnGateLockHolder(repoDir: string, statusFile: string, env: NodeJS.ProcessEnv = {}) { const child = spawn(process.execPath, [gateLockHelperPath, "--status-file", statusFile], { cwd: repoDir, stdio: ["ignore", "ignore", "pipe"], env: sanitizedEnv(env), }); children.push(child); return child; } function makeRetryRepo(): { repoDir: string; stubBin: string; headSha: string } { const dir = makeTempDir("openclaw-pr-gates-retry-"); const repoDir = join(dir, "repo"); mkdirSync(repoDir); for (const args of [ ["init", "-q"], ["config", "user.name", "t"], ["config", "user.email", "t@example.com"], ["commit", "-q", "--allow-empty", "-m", "retry head"], ]) { const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(result.status).toBe(0); } mkdirSync(join(repoDir, ".local")); const stubBin = join(dir, "bin"); mkdirSync(stubBin); writeFileSync(join(stubBin, "pnpm"), "#!/bin/sh\nexit 0\n"); chmodSync(join(stubBin, "pnpm"), 0o755); // Route the crabbox wrapper to a canned timing report; everything else // (the gate lock helper) still needs the real node. writeFileSync( join(stubBin, "node"), [ "#!/bin/sh", 'case "$2" in', `run) printf '{"provider":"blacksmith-testbox","leaseId":"tbx_retry","exitCode":0,"runStatus":"succeeded","actionsRunUrl":"https://example.test/runs/7"}\\n' >&2; exit 0;;`, `*) exec '${process.execPath}' "$@";;`, "esac", ].join("\n"), ); chmodSync(join(stubBin, "node"), 0o755); const headSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8", }).stdout.trim(); return { repoDir, stubBin, headSha }; } function makeSyncRepo(options: { needsRebase: boolean }): string { const repoDir = join(makeTempDir("openclaw-pr-sync-"), "repo"); mkdirSync(repoDir); const git = (...args: string[]) => { const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(result.status, result.stderr).toBe(0); return result.stdout.trim(); }; git("init", "-q", "-b", "main"); git("config", "user.name", "t"); git("config", "user.email", "t@example.com"); const base = ["shared: old", "one", "two", "three", "four", "five", "pr: old", ""].join("\n"); const prChange = base.replace("shared: old", "shared: new").replace("pr: old", "pr: new"); const mainChange = base.replace("shared: old", "shared: new"); writeFileSync(join(repoDir, "config.yml"), base); git("add", "config.yml"); git("commit", "-qm", "base"); git("checkout", "-qb", "prep"); writeFileSync(join(repoDir, "config.yml"), prChange); git("add", "config.yml"); git("commit", "-qm", "pr change"); git("checkout", "-q", "main"); if (options.needsRebase) { writeFileSync(join(repoDir, "config.yml"), mainChange); git("add", "config.yml"); git("commit", "-qm", "upstream shared hunk"); } git("remote", "add", "origin", "."); git("fetch", "-q", "origin", "main"); git("checkout", "-q", "prep"); mkdirSync(join(repoDir, ".local")); writeFileSync(join(repoDir, ".local", "hosted-sha"), `${git("rev-parse", "HEAD")}\n`); writeFileSync( join(repoDir, ".local", "pr-meta.env"), "PR_NUMBER=4242\nPR_AUTHOR=steipete\nPR_URL=https://example.test/pr/4242\n", ); writeFileSync(join(repoDir, ".local", "prep-context.env"), "PR_HEAD=topic\nPREP_BRANCH=prep\n"); writeFileSync(join(repoDir, ".local", "prep.md"), "# Prepare\n"); return repoDir; } function makePreparePushHeadDriftRepo(): { repoDir: string; recordedHead: string; reviewedHead: string; } { const repoDir = join(makeTempDir("openclaw-pr-prepare-drift-"), "repo"); mkdirSync(repoDir); const git = (...args: string[]) => { const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(result.status, result.stderr).toBe(0); return result.stdout.trim(); }; git("init", "-q", "-b", "main"); git("config", "user.name", "t"); git("config", "user.email", "t@example.com"); writeFileSync(join(repoDir, "base.txt"), "base\n"); git("add", "base.txt"); git("commit", "-qm", "base"); const recordedHead = git("rev-parse", "HEAD"); git("remote", "add", "origin", "."); git("fetch", "-q", "origin", "main"); git("checkout", "-qb", "pr-4242"); writeFileSync(join(repoDir, "reviewed.txt"), "new reviewed head\n"); git("add", "reviewed.txt"); git("commit", "-qm", "reviewed head update"); const reviewedHead = git("rev-parse", "HEAD"); git("checkout", "-qb", "prep", recordedHead); writeFileSync(join(repoDir, "stale-fixup.txt"), "belongs to stale prep head\n"); git("add", "stale-fixup.txt"); git("commit", "-qm", "stale prep fixup"); mkdirSync(join(repoDir, ".local")); writeFileSync( join(repoDir, ".local", "pr-meta.env"), [ "PR_NUMBER=4242", "PR_AUTHOR=steipete", "PR_URL=https://example.test/pr/4242", "PR_HEAD=topic", `PR_HEAD_SHA=${reviewedHead}`, "", ].join("\n"), ); writeFileSync( join(repoDir, ".local", "prep-context.env"), [ "PR_NUMBER=4242", "PR_HEAD=topic", `PR_HEAD_SHA_BEFORE=${recordedHead}`, "PREP_BRANCH=prep", "PREP_STARTED_AT=2026-07-19T00:00:00Z", "", ].join("\n"), ); writeFileSync(join(repoDir, ".local", "gates.env"), "GATES_MODE=stale\n"); writeFileSync(join(repoDir, ".local", "prep.env"), "PREP_HEAD_SHA=stale\n"); writeFileSync(join(repoDir, ".local", "prep.md"), "# Prepare\n"); return { repoDir, recordedHead, reviewedHead }; } function prepareSyncHeadStubs(): string[] { return [ "enter_worktree() { :; }", "hosted_sha=$(cat .local/hosted-sha)", 'gh() { printf "%s\\n" "$hosted_sha"; }', "verify_pr_head_branch_matches_expected() { :; }", 'verify_prep_head_extends_hosted_head() { git merge-base --is-ancestor "$1" HEAD; }', "push_prep_head_to_pr_branch() {", ' local result_env="$7"', " touch .local/published", ' printf \'PUSH_PREP_HEAD_SHA=%q\\nPUSH_LOCAL_PREP_HEAD_SHA=%q\\nPUSHED_FROM_SHA=%q\\nPR_HEAD_SHA_AFTER_PUSH=%q\\n\' "$3" "$3" "$hosted_sha" "$3" > "$result_env"', "}", ]; } async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) { return true; } await new Promise((resolve) => setTimeout(resolve, 10)); } return predicate(); } async function waitForExit(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) { return; } await new Promise((resolve) => { child.once("exit", resolve); }); } async function waitForStderr( child: ChildProcess, expected: string, timeoutMs: number, ): Promise { const stderr = child.stderr; if (!stderr) { throw new Error("child stderr is not piped"); } stderr.setEncoding("utf8"); let output = ""; return await new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timeout); stderr.off("data", onData); child.off("exit", onExit); }; const onData = (chunk: string) => { output += chunk; if (output.includes(expected)) { cleanup(); resolve(output); } }; const onExit = () => { cleanup(); reject(new Error(`child exited before writing ${JSON.stringify(expected)}: ${output}`)); }; const timeout = setTimeout(() => { cleanup(); reject(new Error(`timed out waiting for ${JSON.stringify(expected)}: ${output}`)); }, timeoutMs); stderr.on("data", onData); child.once("exit", onExit); }); } afterEach(async () => { for (const child of children.splice(0)) { child.kill("SIGKILL"); await waitForExit(child); } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("resolve_pr_gates_remote_mode", () => { it.each([ { value: undefined, expected: "local" }, { value: "", expected: "local" }, { value: "testbox", expected: "testbox" }, ])("resolves OPENCLAW_PR_GATES_REMOTE=$value to $expected", ({ value, expected }) => { const env: NodeJS.ProcessEnv = {}; if (value !== undefined) { env.OPENCLAW_PR_GATES_REMOTE = value; } const result = runGatesBash("resolve_pr_gates_remote_mode", { env }); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe(expected); }); it("rejects unsupported values", () => { const result = runGatesBash("resolve_pr_gates_remote_mode", { env: { OPENCLAW_PR_GATES_REMOTE: "azure" }, }); expect(result.status).not.toBe(0); expect(result.stderr).toContain("Unsupported OPENCLAW_PR_GATES_REMOTE=azure"); }); it("rejects the hosted-gates conflict before touching the worktree", () => { const result = runGatesBash("prepare_gates 424242", { env: { OPENCLAW_PR_GATES_REMOTE: "testbox", OPENCLAW_TESTBOX: "1" }, }); expect(result.status).toBe(2); expect(result.stdout).toContain("conflicts with OPENCLAW_TESTBOX=1"); }); }); describe("prepare gate changed-file plan", () => { it.each([ { paths: [] as string[], docsOnly: false, changelogOnly: false }, { paths: ["docs/guide.md"], docsOnly: true, changelogOnly: false }, { paths: ["CHANGELOG.md"], docsOnly: true, changelogOnly: true }, { paths: ["src/index.ts"], docsOnly: false, changelogOnly: false }, { paths: ["docs/guide.md", "src/index.ts"], docsOnly: false, changelogOnly: false, }, ])("derives the coupled plan for $paths", ({ paths, docsOnly, changelogOnly }) => { const gitStub = paths.length === 0 ? "git() { :; }" : `git() { printf '%s\\n' ${paths.map((path) => `'${path}'`).join(" ")}; }`; const result = runGatesBash( [ gitStub, "derive_prepare_gate_change_plan", 'printf "%s\\t%s\\t%s\\t%s\\n" "$PREPARE_GATE_CHANGED_FILES" "$PREPARE_GATE_DOCS_ONLY" "$PREPARE_GATE_CHANGELOG_ONLY" "$PREPARE_GATE_CHANGELOG_REQUIRED"', ].join("\n"), ); expect(result.status).toBe(0); const fields = result.stdout.trimEnd().split("\t"); expect(fields).toEqual([paths.join("\n"), String(docsOnly), String(changelogOnly), "false"]); }); it("carries the changelog policy decision into the plan", () => { const result = runGatesBash( [ "git() { printf 'src/index.ts\\n'; }", "changelog_required_for_changed_files() { return 0; }", "derive_prepare_gate_change_plan", 'printf "%s\\n" "$PREPARE_GATE_CHANGELOG_REQUIRED"', ].join("\n"), ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe("true"); }); }); describe("remote testbox gate delegation", () => { it("runs the full pnpm test through the worktree crabbox wrapper", () => { const dir = makeTempDir("openclaw-pr-gates-remote-"); const stubBin = join(dir, "bin"); mkdirSync(stubBin); writeFileSync( join(stubBin, "node"), [ "#!/bin/sh", "printf 'ARG:%s\\n' \"$@\"", `printf '{"provider":"blacksmith-testbox","leaseId":"tbx_stub","exitCode":0,"runStatus":"passed"}\\n' >&2`, ].join("\n"), ); chmodSync(join(stubBin, "node"), 0o755); const workDir = join(dir, "work"); mkdirSync(workDir); const result = runGatesBash( "run_remote_testbox_full_test_gate 'pnpm test (blacksmith-testbox)' .local/gates-test.log pr-424242-gates\n" + "grep '^ARG:' .local/gates-test.log | paste -sd ' ' -", { cwd: workDir, env: { PATH: `${stubBin}:${process.env.PATH ?? ""}` }, }, ); expect(result.status).toBe(0); const argLine = result.stdout .split("\n") .find((line) => line.includes("crabbox-wrapper.mjs")) ?.replaceAll("ARG:", ""); expect(argLine).toBe( "scripts/crabbox-wrapper.mjs run " + "--provider blacksmith-testbox " + "--blacksmith-org openclaw " + "--blacksmith-workflow .github/workflows/ci-check-testbox.yml " + "--blacksmith-job check " + "--blacksmith-ref main " + "--idle-timeout 90m --ttl 240m --timing-json " + "--label pr-424242-gates " + "-- env CI=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 " + "PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=install corepack pnpm test", ); }); it("extracts the last successful blacksmith-testbox timing stamp", () => { const dir = makeTempDir("openclaw-pr-gates-stamp-"); const log = join(dir, "gates-test.log"); writeFileSync( log, [ "provider=blacksmith-testbox id=tbx_first sync=delegated auth=blacksmith", "GitHub Actions run: https://github.com/openclaw/openclaw/actions/runs/1234", '{"not":"a stamp"}', "not json at all", '{"provider":"blacksmith-testbox","leaseId":"tbx_first","exitCode":1,"runStatus":"failed"}', '{"provider":"blacksmith-testbox","leaseId":"tbx_final","exitCode":0,"runStatus":"passed"}', "GitHub Actions run: https://github.com/openclaw/openclaw/actions/runs/9999", "GitHub Actions run: https://github.com/example/other/actions/runs/8888", "", ].join("\n"), ); const result = runGatesBash( `require_remote_testbox_gate_stamp '${log}' | jq -r '[.leaseId, .actionsRunUrl] | @tsv'`, ); expect(result.status).toBe(0); expect(result.stdout.trim()).toBe( "tbx_final\thttps://github.com/openclaw/openclaw/actions/runs/1234", ); }); it("fails when the gate log has no successful stamp", () => { const dir = makeTempDir("openclaw-pr-gates-stamp-"); const log = join(dir, "gates-test.log"); writeFileSync( log, '{"provider":"blacksmith-testbox","leaseId":"tbx_only","exitCode":1,"runStatus":"failed"}\n', ); const result = runGatesBash(`require_remote_testbox_gate_stamp '${log}'`); expect(result.status).not.toBe(0); expect(result.stderr).toContain("no successful blacksmith-testbox timing stamp"); }); }); describe("lease-retry gate stamp refresh", () => { it("rewrites gates.env with the fresh remote stamp for the rebased head", () => { const { repoDir, stubBin, headSha } = makeRetryRepo(); const result = runGatesBash( [ "PR_NUMBER=4242", "CHANGELOG_REQUIRED=false", "REMOTE_GATES_PROVIDER=blacksmith-testbox", "REMOTE_GATES_LEASE_ID=tbx_stale", "REMOTE_GATES_RUN_URL=https://example.test/runs/1", "FULL_GATES_HEAD_SHA=deadbeef", "run_prepare_push_retry_gates false", "cat .local/gates.env", ].join("\n"), { cwd: repoDir, env: { PATH: `${stubBin}:${process.env.PATH ?? ""}`, OPENCLAW_PR_GATES_REMOTE: "testbox", }, }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("GATES_MODE=remote_testbox"); expect(result.stdout).toContain("REMOTE_GATES_LEASE_ID=tbx_retry"); expect(result.stdout).toContain(`LAST_VERIFIED_HEAD_SHA=${headSha}`); expect(result.stdout).toContain(`FULL_GATES_HEAD_SHA=${headSha}`); expect(result.stdout).not.toContain("tbx_stale"); }); it("clears a stale remote stamp when the retry test ran locally", () => { const { repoDir, stubBin, headSha } = makeRetryRepo(); const result = runGatesBash( [ "PR_NUMBER=4242", "CHANGELOG_REQUIRED=false", "REMOTE_GATES_PROVIDER=blacksmith-testbox", "REMOTE_GATES_LEASE_ID=tbx_stale", "run_prepare_push_retry_gates false", "cat .local/gates.env", ].join("\n"), { cwd: repoDir, env: { PATH: `${stubBin}:${process.env.PATH ?? ""}` }, }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("GATES_MODE=full"); expect(result.stdout).toContain("REMOTE_GATES_LEASE_ID=''"); expect(result.stdout).toContain(`FULL_GATES_HEAD_SHA=${headSha}`); expect(result.stdout).not.toContain("tbx_stale"); }); }); describe("prepare review readiness", () => { it("rejects invalid review artifacts before any preparation side effects", () => { const repoDir = makeTempDir("openclaw-pr-prepare-invalid-review-"); mkdirSync(join(repoDir, ".local")); const result = runGatesBash( [ "review_validate_artifacts() { echo 'invalid review artifacts'; return 1; }", "require_ready_review_recommendation() { touch .local/readiness-called; }", "mark_pr_operation_side_effects_started() { touch .local/side-effects; }", "enter_worktree() { touch .local/worktree-entered; }", "prepare_init 4242", ].join("\n"), { cwd: repoDir, sourcePrepareCore: true }, ); expect(result.status).toBe(1); expect(result.stdout).toContain("invalid review artifacts"); expect(existsSync(join(repoDir, ".local", "readiness-called"))).toBe(false); expect(existsSync(join(repoDir, ".local", "side-effects"))).toBe(false); expect(existsSync(join(repoDir, ".local", "worktree-entered"))).toBe(false); }); it("rejects a non-ready review before taking the operation lock past validation", () => { const repoDir = makeTempDir("openclaw-pr-prepare-not-ready-"); mkdirSync(join(repoDir, ".local")); const result = runGatesBash( [ "review_validate_artifacts() { touch .local/review-validated; }", "require_ready_review_recommendation() { echo 'review is not ready'; return 1; }", "mark_pr_operation_side_effects_started() { touch .local/side-effects; }", "enter_worktree() { touch .local/worktree-entered; }", "prepare_init 4242", ].join("\n"), { cwd: repoDir, sourcePrepareCore: true }, ); expect(result.status).toBe(1); expect(result.stdout).toContain("review is not ready"); expect(existsSync(join(repoDir, ".local", "review-validated"))).toBe(true); expect(existsSync(join(repoDir, ".local", "side-effects"))).toBe(false); expect(existsSync(join(repoDir, ".local", "worktree-entered"))).toBe(false); }); }); describe("prepare sync-head transitions", () => { it("publishes only appended fixups when main advances", () => { const repoDir = makeSyncRepo({ needsRebase: true }); writeFileSync(join(repoDir, "fixup.ts"), "export const fixed = true;\n"); for (const args of [ ["add", "fixup.ts"], ["commit", "-qm", "reviewed fixup"], ]) { const commit = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(commit.status, commit.stderr).toBe(0); } const localHead = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8", }).stdout.trim(); const result = runGatesBash( [ ...prepareSyncHeadStubs(), "prepare_sync_head 4242", `test "$(git rev-parse HEAD)" = "${localHead}"`, 'test "$(git diff --name-only "$hosted_sha" HEAD)" = "fixup.ts"', 'git merge-base --is-ancestor "$hosted_sha" HEAD', "! git merge-base --is-ancestor origin/main HEAD", "test -e .local/published", "grep -F 'Preserved hosted PR ancestry' .local/prep.md", ].join("\n"), { cwd: repoDir, env: { OPENCLAW_TESTBOX: "1" }, sourcePrepareCore: true }, ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("prepare-sync-head complete"); expect(result.stdout).not.toContain("Rebase"); }); }); describe("prepare push head drift", () => { it("rebuilds a stale prep branch and reruns gates before push", () => { const { repoDir, recordedHead, reviewedHead } = makePreparePushHeadDriftRepo(); const result = runGatesBash( [ "enter_worktree() { :; }", `reviewed_head='${reviewedHead}'`, 'gh() { printf "%s\\n" "$reviewed_head"; }', "verify_pr_head_branch_matches_expected() { :; }", "prepare_gates() {", " touch .local/gates-reran", " printf 'DOCS_ONLY=false\\nGATES_MODE=fresh\\n' > .local/gates.env", "}", "push_prep_head_to_pr_branch() {", ' local result_env="$7"', ' printf \'PUSH_PREP_HEAD_SHA=%q\\nPUSH_LOCAL_PREP_HEAD_SHA=%q\\nPUSHED_FROM_SHA=%q\\nPR_HEAD_SHA_AFTER_PUSH=%q\\n\' "$3" "$3" "$reviewed_head" "$3" > "$result_env"', "}", "prepare_push 4242", 'test "$(git rev-parse HEAD)" = "$reviewed_head"', "test -e .local/gates-reran", "test ! -e stale-fixup.txt", 'test "$(. .local/prep-context.env; printf "%s" "$PR_HEAD_SHA_BEFORE")" = "$reviewed_head"', `grep -F 'drifted from ${recordedHead} to ${reviewedHead}' .local/prep.md`, "grep -F 'Gate mode: fresh' .local/prep.md", ].join("\n"), { cwd: repoDir, sourcePrepareCore: true }, ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain( `Prep source head changed from ${recordedHead} to reviewed head ${reviewedHead}.`, ); expect(result.stdout).toContain( "Prep branch was refreshed for reviewed head drift; rerunning prepare gates before push.", ); expect(result.stdout).toContain("prepare-push complete"); }); }); describe("GraphQL fork publication", () => { it("accepts appended fixups and preserves the commit body", () => { const { repoDir, headSha } = makeRetryRepo(); writeFileSync(join(repoDir, "fixup.ts"), "export const fixed = true;\n"); for (const args of [ ["add", "fixup.ts"], ["commit", "-qm", "reviewed fixup\n\nCo-authored-by: Helper "], ]) { const commit = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(commit.status, commit.stderr).toBe(0); } const result = runGatesBash( [ 'gh() { cat > .local/graphql-payload.json; printf \'%s\\n\' \'{"data":{"createCommitOnBranch":{"commit":{"oid":"signed-head","url":"https://example.test/commit"}}}}\'; }', `graphql_push_to_fork example/repo topic ${headSha}`, 'test "$(jq -r .variables.input.message.headline .local/graphql-payload.json)" = "reviewed fixup"', 'test "$(jq -r .variables.input.message.body .local/graphql-payload.json)" = "Co-authored-by: Helper "', ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("signed-head"); }); it("rejects merge commits before encoding files or calling GitHub", () => { const { repoDir, headSha } = makeRetryRepo(); const baseBranch = spawnSync("git", ["branch", "--show-current"], { cwd: repoDir, encoding: "utf8", }).stdout.trim(); for (const args of [ ["checkout", "-qb", "other"], ["commit", "-qm", "other", "--allow-empty"], ["checkout", "-q", baseBranch], ["merge", "-q", "--no-ff", "other", "-m", "merge other"], ]) { const command = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); expect(command.status, command.stderr).toBe(0); } const result = runGatesBash( [ "gh() { touch .local/gh-called; return 99; }", `graphql_push_to_fork example/repo topic ${headSha}`, ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status).not.toBe(0); expect(result.stderr).toContain("cannot preserve merge ancestry"); expect(existsSync(join(repoDir, ".local", "gh-called"))).toBe(false); }); it("rejects rewritten history before encoding files or calling GitHub", () => { const { repoDir, headSha } = makeRetryRepo(); const tree = spawnSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: repoDir, encoding: "utf8", }).stdout.trim(); const unrelatedHead = spawnSync( "git", [ "-c", "user.name=t", "-c", "user.email=t@example.com", "commit-tree", tree, "-m", "rewritten", ], { cwd: repoDir, encoding: "utf8" }, ).stdout.trim(); const checkout = spawnSync("git", ["checkout", "-q", "--detach", unrelatedHead], { cwd: repoDir, encoding: "utf8", }); expect(checkout.status, checkout.stderr).toBe(0); const result = runGatesBash( [ "gh() { touch .local/gh-called; return 99; }", `graphql_push_to_fork example/repo topic ${headSha}`, ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status).not.toBe(0); expect(result.stderr).toContain("refused rewritten history"); expect(existsSync(join(repoDir, ".local", "gh-called"))).toBe(false); }); }); describe("fork publication transport", () => { it("keeps the PR push URL process-local", () => { const { repoDir } = makeRetryRepo(); const result = runGatesBash( [ "resolve_head_push_url() { printf '%s\\n' https://github.com/contributor/repo.git; }", "git() { touch .local/git-called; return 99; }", "setup_prhead_remote", 'test "$PRHEAD_REMOTE_URL" = https://github.com/contributor/repo.git', "test ! -e .local/git-called", ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status, result.stderr).toBe(0); }); it("preserves an HTTPS fallback for the later push", () => { const { repoDir } = makeRetryRepo(); const result = runGatesBash( [ "PRHEAD_REMOTE_URL=ssh://git@example.test/contributor/repo.git", "resolve_head_push_url_https() { printf '%s\\n' https://github.com/contributor/repo.git; }", 'git() { if [ "$1" = ls-remote ] && [ "$2" = https://github.com/contributor/repo.git ]; then printf \'hosted\\trefs/heads/topic\\n\'; fi; }', "resolve_prhead_remote_sha topic", 'test "$PRHEAD_REMOTE_URL" = https://github.com/contributor/repo.git', 'test "$PRHEAD_REMOTE_SHA" = hosted', ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status, result.stderr).toBe(0); }); it("uses git transport automatically for a verified signed prep commit", () => { const { repoDir } = makeRetryRepo(); const result = runGatesBash( [ "PR_HEAD_OWNER=contributor", "PR_HEAD_REPO_NAME=repo", "PRHEAD_REMOTE_URL=https://github.com/contributor/repo.git", "git() { printf '%s\\n' \"$*\" >> .local/git-calls; case \"$1\" in rev-list) printf '%s\\n' prepared;; esac; return 0; }", "graphql_push_to_fork() { touch .local/graphql-called; return 99; }", "push_prep_head_once topic hosted prepared", "grep -F 'verify-commit prepared' .local/git-calls", "grep -F 'push --force-with-lease=refs/heads/topic:hosted https://github.com/contributor/repo.git prepared:refs/heads/topic' .local/git-calls", "test ! -e .local/graphql-called", ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("prepared"); }); it("keeps unsigned single-parent fixups on GitHub-signed GraphQL publication", () => { const { repoDir } = makeRetryRepo(); const result = runGatesBash( [ "PR_HEAD_OWNER=contributor", "PR_HEAD_REPO_NAME=repo", "PRHEAD_REMOTE_URL=https://github.com/contributor/repo.git", "git() { case \"$1\" in rev-list) printf '%s\\n' prepared;; verify-commit) return 1;; esac; return 0; }", "graphql_push_to_fork() { touch .local/graphql-called; printf '%s\\n' signed-head; }", "push_prep_head_once topic hosted prepared", "test -e .local/graphql-called", ].join("\n"), { cwd: repoDir, sourcePush: true }, ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("signed-head"); }); }); describe("prepare gate stamp transitions", () => { it.each([ ["CHANGELOG.md", true], ["changed.ts", false], ])("derives recent parent evidence for a %s commit: %s", (path, expected) => { const { repoDir, headSha: parentSha } = makeRetryRepo(); writeFileSync(join(repoDir, path), "change\n"); spawnSync("git", ["add", path], { cwd: repoDir }); spawnSync( "git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "change"], { cwd: repoDir }, ); const currentHead = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, encoding: "utf8", }).stdout.trim(); const result = runGatesBash( [ `gh() { if [ "$1" = pr ]; then printf '{"headRefName":"topic","headRefOid":"${currentHead}","isCrossRepository":false}\\n'; else printf 'openclaw/openclaw\\n'; fi; }`, "run_quiet_logged() { printf 'ARG:%s\\n' \"$@\"; }", `run_hosted_prepare_gates 100606 ${currentHead} false`, ].join("\n"), { cwd: repoDir }, ); expect(result.status).toBe(0); if (expected) { expect(result.stdout).toContain(`ARG:--recent-sha\nARG:${parentSha}`); } else { expect(result.stdout).not.toContain("ARG:--recent-sha"); } }); it("prints the exact recovery command when hosted CI is missing", () => { const { repoDir, headSha } = makeRetryRepo(); const result = runGatesBash( [ `gh() { if [ "$1" = pr ]; then printf '{"headRefName":"topic","headRefOid":"${headSha}","isCrossRepository":false}\\n'; else printf 'openclaw/openclaw\\n'; fi; }`, 'rg() { command grep -F -q "$3" "$4"; }', `run_quiet_logged() { printf 'Missing successful recent CI workflow for ${headSha}. Observed: none\\n' > "$2"; return 1; }`, `run_hosted_prepare_gates 100606 ${headSha} false`, ].join("\n"), { cwd: repoDir }, ); expect(result.status).toBe(1); expect(result.stdout).toContain("scripts/pr ci-dispatch 100606"); expect(result.stdout).toContain( `gh workflow run ci.yml --ref topic -f target_ref=${headSha} -f release_gate=true -f pull_request_number=100606`, ); }); it("does not advertise an unusable dispatch command for fork PRs", () => { const { repoDir, headSha } = makeRetryRepo(); const result = runGatesBash( [ `gh() { if [ "$1" = pr ]; then printf '{"headRefName":"topic","headRefOid":"${headSha}","isCrossRepository":true}\\n'; else printf 'openclaw/openclaw\\n'; fi; }`, 'rg() { command grep -F -q "$3" "$4"; }', `run_quiet_logged() { printf 'Missing successful recent CI workflow for ${headSha}. Observed: none\\n' > "$2"; return 1; }`, `run_hosted_prepare_gates 100606 ${headSha} false`, ].join("\n"), { cwd: repoDir }, ); expect(result.status).toBe(1); expect(result.stdout).toContain("scripts/pr ci-dispatch 100606"); expect(result.stdout).toContain("unavailable: PR #100606 comes from a fork"); expect(result.stdout).not.toContain("gh workflow run"); }); it("clears remote stamps when fresh docs-only gates do not reuse prior proof", () => { const { repoDir } = makeRetryRepo(); spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir }); mkdirSync(join(repoDir, "docs"), { recursive: true }); writeFileSync(join(repoDir, "docs", "proof.md"), "fresh docs\n"); spawnSync("git", ["add", "docs/proof.md"], { cwd: repoDir }); spawnSync( "git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "docs"], { cwd: repoDir }, ); writeFileSync(join(repoDir, ".local", "pr-meta.env"), "PR_AUTHOR=steipete\n"); writeFileSync( join(repoDir, ".local", "gates.env"), [ "LAST_VERIFIED_HEAD_SHA=deadbeef", "FULL_GATES_HEAD_SHA=deadbeef", "REMOTE_GATES_PROVIDER=blacksmith-testbox", "REMOTE_GATES_LEASE_ID=tbx_stale", "REMOTE_GATES_RUN_URL=https://example.test/runs/1", "", ].join("\n"), ); const result = runGatesBash( [ "enter_worktree() { :; }", "checkout_prep_branch() { :; }", "path_is_docsish() { return 0; }", "changelog_required_for_changed_files() { return 1; }", "prepare_local_gate_workspace() { :; }", "run_quiet_logged() { :; }", "release_pr_gates_lock() { :; }", "prepare_gates 4242", "cat .local/gates.env", ].join("\n"), { cwd: repoDir }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("GATES_MODE=docs_only"); expect(result.stdout).toContain("FULL_GATES_HEAD_SHA=''"); expect(result.stdout).toContain("REMOTE_GATES_LEASE_ID=''"); expect(result.stdout).not.toContain("tbx_stale"); }); it("clears remote stamps when hosted gates replace remote proof", () => { const { repoDir } = makeRetryRepo(); spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir }); writeFileSync(join(repoDir, "changed.ts"), "export {};\n"); spawnSync("git", ["add", "changed.ts"], { cwd: repoDir }); spawnSync( "git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "change"], { cwd: repoDir }, ); writeFileSync(join(repoDir, ".local", "pr-meta.env"), "PR_AUTHOR=steipete\n"); writeFileSync( join(repoDir, ".local", "gates.env"), [ "LAST_VERIFIED_HEAD_SHA=deadbeef", "FULL_GATES_HEAD_SHA=deadbeef", "REMOTE_GATES_PROVIDER=blacksmith-testbox", "REMOTE_GATES_LEASE_ID=tbx_stale", "REMOTE_GATES_RUN_URL=https://example.test/runs/1", "", ].join("\n"), ); const result = runGatesBash( [ "enter_worktree() { :; }", "checkout_prep_branch() { :; }", "path_is_docsish() { return 1; }", "changelog_required_for_changed_files() { return 1; }", "run_hosted_prepare_gates() { printf 'HOSTED\\n'; }", "prepare_gates 4242", "cat .local/gates.env", ].join("\n"), { cwd: repoDir, env: { OPENCLAW_TESTBOX: "1" } }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("GATES_MODE=hosted_exact_or_recent_parent"); expect(result.stdout).toContain("HOSTED"); expect(result.stdout).toContain("REMOTE_GATES_LEASE_ID=''"); expect(result.stdout).not.toContain("tbx_stale"); }); }); describe("pr-gates-lock helper", () => { it("acquires the shared heavy-check lock and releases it on SIGTERM", async () => { const repoDir = makeLockRepoDir(); const statusFile = join(repoDir, "status"); const holder = spawnGateLockHolder(repoDir, statusFile); expect(await waitFor(() => existsSync(statusFile), 5_000)).toBe(true); expect(existsSync(heavyCheckLockDir(repoDir))).toBe(true); holder.kill("SIGTERM"); await waitForExit(holder); expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 5_000)).toBe(true); }); it("queues behind an existing holder and acquires after it exits", async () => { const repoDir = makeLockRepoDir(); const firstStatus = join(repoDir, "status-first"); const secondStatus = join(repoDir, "status-second"); const first = spawnGateLockHolder(repoDir, firstStatus); expect(await waitFor(() => existsSync(firstStatus), 5_000)).toBe(true); const second = spawnGateLockHolder(repoDir, secondStatus, { OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50", }); await waitForStderr(second, "queued behind the local heavy-check lock", 5_000); expect(existsSync(secondStatus)).toBe(false); first.kill("SIGTERM"); await waitForExit(first); expect(await waitFor(() => existsSync(secondStatus), 5_000)).toBe(true); second.kill("SIGTERM"); await waitForExit(second); expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 5_000)).toBe(true); }); it("fails instead of holding when the wait times out", async () => { const repoDir = makeLockRepoDir(); const lockDir = heavyCheckLockDir(repoDir); mkdirSync(lockDir, { recursive: true }); // Owner pid must be alive or the helper reclaims the stale lock. writeFileSync( join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, tool: "test-holder", cwd: repoDir })}\n`, ); const statusFile = join(repoDir, "status"); const holder = spawnGateLockHolder(repoDir, statusFile, { OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: "200", OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50", }); await waitForExit(holder); expect(holder.exitCode).not.toBe(0); expect(existsSync(statusFile)).toBe(false); }); it("releases the lock when the parent process dies", async () => { const repoDir = makeLockRepoDir(); const statusFile = join(repoDir, "status"); const parent = spawn( "bash", [ "-c", `node '${gateLockHelperPath}' --status-file '${statusFile}' 2>/dev/null & ` + `while [ ! -s '${statusFile}' ]; do sleep 0.05; done`, ], { cwd: repoDir, stdio: "ignore", env: sanitizedEnv() }, ); children.push(parent); await waitForExit(parent); expect(existsSync(statusFile)).toBe(true); expect(await waitFor(() => !existsSync(heavyCheckLockDir(repoDir)), 8_000)).toBe(true); }); }); describe("gates.sh gate lock plumbing", () => { it("acquires the block lock before dependency bootstrap", () => { const result = runGatesBash( [ "events=$(mktemp)", 'pin_worktree_bundled_plugins_dir() { echo pin >> "$events"; }', 'acquire_pr_gates_lock() { echo lock >> "$events"; }', 'bootstrap_deps_if_needed() { echo bootstrap >> "$events"; }', "prepare_local_gate_workspace", 'cat "$events"', ].join("\n"), ); expect(result.status).toBe(0); expect(result.stdout.trim().split("\n")).toEqual(["pin", "lock", "bootstrap"]); }); it("exports the held-lock contract while holding and clears it on release", () => { const repoDir = makeLockRepoDir(); const result = runGatesBash( [ "acquire_pr_gates_lock", 'echo "held=${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-unset},${OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD:-unset},${OPENCLAW_OXLINT_SKIP_LOCK:-unset}"', "jq -r .tool .git/openclaw-local-checks/heavy-check.lock/owner.json", "release_pr_gates_lock", 'echo "released=${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-unset}"', '[ -d .git/openclaw-local-checks/heavy-check.lock ] && echo "lock=held" || echo "lock=free"', ].join("\n"), { cwd: repoDir }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("held=1,1,1"); expect(result.stdout).toContain("pr-gates"); expect(result.stdout).toContain("released=unset"); expect(result.stdout).toContain("lock=free"); }); it("skips acquisition when a parent already holds the lock", () => { const repoDir = makeLockRepoDir(); const result = runGatesBash( [ "acquire_pr_gates_lock", '[ -d .git/openclaw-local-checks/heavy-check.lock ] && echo "lock=held" || echo "lock=free"', 'echo "helper_pid=${PR_GATES_LOCK_PID:-none}"', ].join("\n"), { cwd: repoDir, env: { OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1" } }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("lock=free"); expect(result.stdout).toContain("helper_pid=none"); }); it("fails the gate run when the lock wait times out", () => { const repoDir = makeLockRepoDir(); const lockDir = heavyCheckLockDir(repoDir); mkdirSync(lockDir, { recursive: true }); writeFileSync( join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, tool: "test-holder", cwd: repoDir })}\n`, ); const result = runGatesBash("acquire_pr_gates_lock", { cwd: repoDir, env: { OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS: "200", OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS: "50", }, }); expect(result.status).toBe(1); expect(result.stdout).toContain( "Failed to acquire the shared local heavy-check lock for prepare gates.", ); }); });