Files
openclaw/test/scripts/pr-prepare-gates.test.ts
Peter Steinberger 76a236da5f fix(ci): harden release validation gates (#108798)
* fix(ci): harden release validation gates

* fix(apns): keep tombstones in strict SQLite schema

* fix(ci): isolate PR publication transport
2026-07-16 03:21:08 -07:00

959 lines
34 KiB
TypeScript

// 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'`,
...(options.sourcePush ? [`source '${repoRoot}/scripts/pr-lib/push.sh'`] : []),
...(options.sourcePrepareCore
? [`source '${repoRoot}/scripts/pr-lib/prepare-core.sh'`]
: []),
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 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<boolean> {
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<void> {
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<string> {
const stderr = child.stderr;
if (!stderr) {
throw new Error("child stderr is not piped");
}
stderr.setEncoding("utf8");
let output = "";
return await new Promise<string>((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 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 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("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 <helper@example.com>"],
]) {
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 <helper@example.com>"',
].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 '${currentHead}\\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("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.",
);
});
});