fix(maint): reuse recent hosted gates after rebase (#100663)

* fix(maint): reuse recent hosted gates after rebase

* fix(maint): preserve whitespace in gate fingerprints
This commit is contained in:
Peter Steinberger 2026-07-06 07:43:03 +01:00 committed by GitHub
parent 6e71b0bf30
commit 5d9cec16f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 700 additions and 111 deletions

View file

@ -2,6 +2,7 @@ run_hosted_prepare_gates() {
local pr="$1"
local current_head="$2"
local changelog_only="$3"
local recent_sha="${4:-}"
local remote_head
remote_head=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
if [ "$remote_head" != "$current_head" ]; then
@ -15,12 +16,20 @@ run_hosted_prepare_gates() {
scripts/verify-pr-hosted-gates.mjs
--repo "$repo"
--sha "$current_head"
--pr "$pr"
--output ".local/gates-hosted-checks.json"
)
if [ -n "$recent_sha" ]; then
args+=(--recent-sha "$recent_sha")
fi
if [ "$changelog_only" = "true" ]; then
args+=(--changelog-only)
fi
run_quiet_logged "exact-head hosted CI/Testbox gates" ".local/gates-hosted-checks.log" node "${args[@]}"
run_quiet_logged "hosted CI/Testbox gates" ".local/gates-hosted-checks.log" node "${args[@]}"
}
compute_pr_patch_id() {
git diff --binary "$1" "$2" | git patch-id --verbatim | awk 'NR == 1 { print $1 }'
}
pin_worktree_bundled_plugins_dir() {
@ -192,7 +201,7 @@ write_gates_env_stamp() {
GATES_MODE "$gates_mode" \
LAST_VERIFIED_HEAD_SHA "$last_verified_head" \
FULL_GATES_HEAD_SHA "$full_gates_head" \
HOSTED_GATES_HEAD_SHA "$hosted_gates_head" \
HOSTED_GATES_TARGET_HEAD_SHA "$hosted_gates_head" \
REMOTE_GATES_PROVIDER "$remote_provider" \
REMOTE_GATES_LEASE_ID "$remote_lease_id" \
REMOTE_GATES_RUN_URL "$remote_run_url" \
@ -204,8 +213,8 @@ run_prepare_push_retry_gates() {
local docs_only="${1:-false}"
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
echo "A lease retry changed the prepared head, so its exact-head hosted evidence no longer applies."
echo "Stop here, wait for CI/Testbox on the pushed head, then re-run prepare-run."
echo "A lease retry changed the prepared head after gate selection."
echo "Stop here, wait for hosted evidence on the pushed branch, then re-run prepare-run."
return 1
fi
@ -271,7 +280,7 @@ prepare_gates() {
local gates_remote_mode
gates_remote_mode=$(resolve_pr_gates_remote_mode)
if [ "$gates_remote_mode" = "testbox" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
echo "OPENCLAW_PR_GATES_REMOTE=testbox conflicts with OPENCLAW_TESTBOX=1; hosted exact-head gates already own remote proof."
echo "OPENCLAW_PR_GATES_REMOTE=testbox conflicts with OPENCLAW_TESTBOX=1; hosted PR gates already own remote proof."
exit 2
fi
@ -371,14 +380,36 @@ prepare_gates() {
fi
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
gates_mode="hosted_exact_head"
gates_mode="hosted_exact_or_recent_rebase"
remote_gates_provider=""
remote_gates_lease_id=""
remote_gates_run_url=""
if [ "$changelog_only" = "true" ]; then
run_quiet_logged "git diff --check" ".local/gates-diff-check.log" git diff --check origin/main...HEAD
fi
run_hosted_prepare_gates "$pr" "$current_head" "$changelog_only"
local recent_hosted_sha=""
if [ -s .local/prep-sync.env ]; then
# shellcheck disable=SC1091
source .local/prep-sync.env
local current_prep_tree
current_prep_tree=$(git rev-parse "${current_head}^{tree}")
if [ "${PREP_SYNC_TREE:-}" != "$current_prep_tree" ]; then
echo "Prepared PR head no longer matches the recorded sync tree."
exit 1
fi
if [ -z "${PREP_SYNC_MAINLINE_BASE_SHA:-}" ] || [ -z "${PREP_SYNC_PATCH_ID:-}" ]; then
echo "Prepared PR sync evidence is incomplete."
exit 1
fi
local current_patch_id
current_patch_id=$(compute_pr_patch_id "$PREP_SYNC_MAINLINE_BASE_SHA" "$current_head")
if [ "$current_patch_id" != "$PREP_SYNC_PATCH_ID" ]; then
echo "Prepared PR patch no longer matches the verified pre-rebase patch."
exit 1
fi
recent_hosted_sha="${PREP_SYNC_EVIDENCE_SHA:-}"
fi
run_hosted_prepare_gates "$pr" "$current_head" "$changelog_only" "$recent_hosted_sha"
hosted_gates_head="$current_head"
elif [ "$reuse_gates" = "true" ]; then
gates_mode="reused_docs_only"

View file

@ -47,6 +47,17 @@ verify_prep_branch_matches_prepared_head() {
exit 1
}
resolve_prep_sync_evidence_sha() {
local local_pre_sync_head="$1"
local remote_pre_sync_head="$2"
local local_tree
local_tree=$(git rev-parse "${local_pre_sync_head}^{tree}") || return 1
local remote_tree
remote_tree=$(git rev-parse "${remote_pre_sync_head}^{tree}") || return 1
[ "$local_tree" = "$remote_tree" ] || return 1
printf '%s\n' "$remote_pre_sync_head"
}
prepare_init() {
local pr="$1"
enter_worktree "$pr" true
@ -236,19 +247,36 @@ prepare_sync_head() {
checkout_prep_branch "$pr"
local pre_sync_head
pre_sync_head=$(git rev-parse HEAD)
# shellcheck disable=SC1091
source .local/pr-meta.env
# shellcheck disable=SC1091
source .local/prep-context.env
local rebased=false
local prep_sync_patch_id=""
git fetch origin main
if ! git merge-base --is-ancestor origin/main HEAD; then
local pre_sync_base
pre_sync_base=$(git merge-base "$pre_sync_head" origin/main)
local pre_sync_patch_id
pre_sync_patch_id=$(compute_pr_patch_id "$pre_sync_base" "$pre_sync_head")
if [ -z "$pre_sync_patch_id" ]; then
echo "Unable to fingerprint the pre-rebase PR patch."
exit 1
fi
git rebase origin/main
rebased=true
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
prep_sync_patch_id=$(compute_pr_patch_id origin/main HEAD)
if [ "$prep_sync_patch_id" != "$pre_sync_patch_id" ]; then
echo "Rebase changed the PR patch; fresh hosted evidence is required."
exit 1
fi
rm -f .local/gates.env .local/prep.env
echo "Rebased head requires fresh exact-head hosted CI/Testbox evidence after push."
echo "A patch-identical recent pre-rebase hosted run may be reusable after push."
else
prepare_gates "$pr"
checkout_prep_branch "$pr"
@ -297,18 +325,32 @@ EOF_PREP
if [ "$rebased" = "true" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
local prep_sync_tree
prep_sync_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
# Preserve the verified local lineage because GraphQL creates a remote
# commit with the same tree but the old branch parent.
local prep_sync_evidence_sha=""
if ! prep_sync_evidence_sha=$(
resolve_prep_sync_evidence_sha "$pre_sync_head" "$pushed_from_sha"
); then
echo "Pre-sync local and hosted trees differ; fresh exact-head evidence is required."
fi
# Preserve local lineage separately from the hosted SHA: GraphQL creates
# a remote commit with the same tree but the old branch parent.
printf '%s=%q\n' \
PREP_SYNC_MAINLINE_BASE_SHA "$mainline_base_sha" \
PREP_SYNC_TREE "$prep_sync_tree" \
PREP_SYNC_PATCH_ID "$prep_sync_patch_id" \
PREP_SYNC_EVIDENCE_SHA "$prep_sync_evidence_sha" \
> .local/prep-sync.env
cat >> .local/prep.md <<EOF_PREP
- Cleared stale prepare artifacts. Wait for hosted CI/Testbox on $prep_head_sha, then run prepare-run again.
if [ -n "$prep_sync_evidence_sha" ]; then
cat >> .local/prep.md <<EOF_PREP
- Cleared stale prepare artifacts. Run prepare-run again; it may reuse green hosted evidence from $prep_sync_evidence_sha within the freshness window.
EOF_PREP
else
cat >> .local/prep.md <<EOF_PREP
- Cleared stale prepare artifacts. Fresh exact-head hosted evidence is required because the prior hosted tree did not match the local pre-rebase tree.
EOF_PREP
fi
echo "prepare-sync-head complete"
echo "prep_head_sha=$prep_head_sha"
echo "Hosted CI/Testbox must pass for this exact head before prepare-run can continue."
echo "Run prepare-run again to verify the available hosted evidence."
return
fi

View file

@ -19,6 +19,9 @@ const ARTIFACT_FALLBACK_REQUIRED_WORKFLOWS = [
];
const WORKFLOW_RUNS_PAGE_SIZE = 100;
const MAX_WORKFLOW_RUN_SEARCH_RESULTS = 1_000;
export const HOSTED_GATE_MAX_AGE_HOURS = 24;
const HOSTED_GATE_MAX_AGE_MS = HOSTED_GATE_MAX_AGE_HOURS * 60 * 60 * 1_000;
const HOSTED_GATE_CLOCK_SKEW_MS = 5 * 60 * 1_000;
function readOptionValue(argv, index, optionName) {
const value = argv[index + 1];
@ -29,7 +32,14 @@ function readOptionValue(argv, index, optionName) {
}
export function parseArgs(argv) {
const args = { repo: "", sha: "", output: "", changelogOnly: false };
const args = {
repo: "",
sha: "",
pr: 0,
recentSha: "",
output: "",
changelogOnly: false,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
@ -49,6 +59,19 @@ export function parseArgs(argv) {
setOnce(arg, "sha", readOptionValue(argv, index, arg));
index += 1;
break;
case "--pr": {
const value = Number(readOptionValue(argv, index, arg));
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error("Expected --pr <positive-integer>.");
}
setOnce(arg, "pr", value);
index += 1;
break;
}
case "--recent-sha":
setOnce(arg, "recentSha", readOptionValue(argv, index, arg));
index += 1;
break;
case "--output":
setOnce(arg, "output", readOptionValue(argv, index, arg));
index += 1;
@ -60,9 +83,9 @@ export function parseArgs(argv) {
throw new Error(`Unknown option: ${arg}`);
}
}
if (!args.repo || !args.sha || !args.output) {
if (!args.repo || !args.sha || !args.pr || !args.output) {
throw new Error(
"Usage: node scripts/verify-pr-hosted-gates.mjs --repo <owner/repo> --sha <sha> --output <path>",
"Usage: node scripts/verify-pr-hosted-gates.mjs --repo <owner/repo> --sha <sha> --pr <number> [--recent-sha <sha>] --output <path>",
);
}
return args;
@ -88,15 +111,15 @@ function isReleaseGateCiRun(run, sha) {
);
}
function matchingAuthoritativeRuns(runs, workflowName, sha) {
function matchingAuthoritativeRuns(runs, workflowName, sha, allowManual = true) {
return runs.filter((run) => {
if (run?.head_sha !== sha) {
return false;
}
if (run?.event === "pull_request") {
return run.name === workflowName;
return run?.name === workflowName;
}
return workflowName === "CI" && isReleaseGateCiRun(run, sha);
return allowManual && workflowName === "CI" && isReleaseGateCiRun(run, sha);
});
}
@ -106,6 +129,20 @@ function latestRun(runs) {
)[0];
}
function runUpdatedAtMs(run) {
const value = Date.parse(String(run?.updated_at ?? ""));
return Number.isFinite(value) ? value : null;
}
function isRecentRun(run, nowMs) {
const updatedAtMs = runUpdatedAtMs(run);
return (
updatedAtMs !== null &&
updatedAtMs >= nowMs - HOSTED_GATE_MAX_AGE_MS &&
updatedAtMs <= nowMs + HOSTED_GATE_CLOCK_SKEW_MS
);
}
function preferredCiRun(runs) {
const scheduledRuns = runs.filter((run) => run.event === "pull_request");
const latestScheduledRun = latestRun(scheduledRuns);
@ -124,12 +161,19 @@ function preferredCiRun(runs) {
return latestRun(runs.filter((run) => run.event === "workflow_dispatch")) ?? latestScheduledRun;
}
function successfulRunOrThrow(runs, workflowName, sha) {
const matchingRuns = matchingAuthoritativeRuns(runs, workflowName, sha);
function successfulRunOrThrow(
runs,
workflowName,
sha,
{ allowManual = true, requireRecent = false, nowMs = Date.now() } = {},
) {
const matchingRuns = matchingAuthoritativeRuns(runs, workflowName, sha, allowManual).filter(
(run) => !requireRecent || (run?.event === "pull_request" && isRecentRun(run, nowMs)),
);
const run = workflowName === "CI" ? preferredCiRun(matchingRuns) : latestRun(matchingRuns);
if (!run || run.status !== "completed" || run.conclusion !== "success") {
throw new Error(
`Missing successful exact-head ${workflowName} workflow for ${sha}. Observed: ${formatObservedRuns(matchingRuns)}`,
`Missing successful ${requireRecent ? "recent " : ""}${workflowName} workflow for ${sha}. Observed: ${formatObservedRuns(matchingRuns)}`,
);
}
return run;
@ -185,23 +229,44 @@ export function workflowRunPageCount(totalCount) {
);
}
export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = false }) {
export function collectHostedGateEvidence({
sha,
recentSha,
workflowRuns,
changelogOnly = false,
nowMs = Date.now(),
}) {
if (!Array.isArray(workflowRuns)) {
throw new Error("workflowRuns must be an array.");
}
const workflows = [];
const fallbackCoveredWorkflows = [];
let ciRun;
if (!changelogOnly) {
ciRun = successfulRunOrThrow(workflowRuns, "CI", sha);
workflows.push(ciRun);
}
for (const workflowName of SCHEDULED_HOSTED_WORKFLOWS) {
const matchingRuns = matchingAuthoritativeRuns(workflowRuns, workflowName, sha);
if (matchingRuns.length > 0) {
const collectForSha = (evidenceSha, requireRecent, requiredScheduledWorkflows = new Set()) => {
const allowManual = !requireRecent;
const workflows = [];
const fallbackCoveredWorkflows = [];
if (!changelogOnly) {
workflows.push(
successfulRunOrThrow(workflowRuns, "CI", evidenceSha, {
allowManual,
requireRecent,
nowMs,
}),
);
}
for (const workflowName of SCHEDULED_HOSTED_WORKFLOWS) {
const matchingRuns = matchingAuthoritativeRuns(
workflowRuns,
workflowName,
evidenceSha,
allowManual,
);
if (matchingRuns.length === 0 && !requiredScheduledWorkflows.has(workflowName)) {
continue;
}
if (
allowManual &&
workflowName === BUILD_ARTIFACTS_WORKFLOW &&
canCoverQueuedBuildArtifacts(workflowRuns, sha)
canCoverQueuedBuildArtifacts(workflowRuns, evidenceSha)
) {
fallbackCoveredWorkflows.push({
name: workflowName,
@ -210,15 +275,63 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
});
continue;
}
workflows.push(successfulRunOrThrow(workflowRuns, workflowName, sha));
workflows.push(
successfulRunOrThrow(workflowRuns, workflowName, evidenceSha, {
allowManual,
requireRecent,
nowMs,
}),
);
}
return { workflows, fallbackCoveredWorkflows };
};
let evidenceSha = sha;
let selected;
try {
selected = collectForSha(sha, false);
} catch (exactError) {
const currentWorkflowNames = ["CI", ...SCHEDULED_HOSTED_WORKFLOWS];
const currentHeadHasTerminalNonSuccess = currentWorkflowNames.some((workflowName) => {
const latestScheduled = latestRun(
matchingAuthoritativeRuns(workflowRuns, workflowName, sha, false).filter(
(run) => run?.status === "completed",
),
);
if (latestScheduled && latestScheduled.conclusion !== "success") {
return true;
}
if (workflowName !== "CI") {
return false;
}
const latestManual = latestRun(
workflowRuns.filter((run) => isReleaseGateCiRun(run, sha) && run?.status === "completed"),
);
return latestManual && latestManual.conclusion !== "success";
});
if (!recentSha || currentHeadHasTerminalNonSuccess) {
throw exactError;
}
// Only prepare-sync-head supplies recentSha, after its controlled rebase preserved the PR patch.
// Arbitrary branch history is never eligible, so new untested pushes cannot borrow old green runs.
const targetScheduledWorkflows = new Set(
SCHEDULED_HOSTED_WORKFLOWS.filter(
(workflowName) =>
matchingAuthoritativeRuns(workflowRuns, workflowName, sha, false).length > 0,
),
);
evidenceSha = recentSha;
selected = collectForSha(recentSha, true, targetScheduledWorkflows);
}
const evidence = {
headSha: sha,
workflows: workflows.map((run) => ({
workflows: selected.workflows.map((run) => ({
id: run.id,
name: run.name,
event: run.event,
headSha: run.head_sha,
headBranch: run.head_branch,
status: run.status,
conclusion: run.conclusion,
createdAt: run.created_at,
@ -226,25 +339,33 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
url: run.html_url,
})),
};
if (fallbackCoveredWorkflows.length > 0) {
evidence.fallbackCoveredWorkflows = fallbackCoveredWorkflows;
if (evidenceSha !== sha) {
evidence.evidenceHeadSha = evidenceSha;
}
if (selected.fallbackCoveredWorkflows.length > 0) {
evidence.fallbackCoveredWorkflows = selected.fallbackCoveredWorkflows;
}
return evidence;
}
function loadWorkflowRuns(repo, sha) {
export function workflowRunQueryPaths(repo, { sha, recentSha }, page = 1) {
const pageSuffix = `per_page=${WORKFLOW_RUNS_PAGE_SIZE}&page=${page}`;
const shas = [...new Set([sha, recentSha].filter(Boolean))];
return shas.map(
(headSha) => `repos/${repo}/actions/runs?head_sha=${encodeURIComponent(headSha)}&${pageSuffix}`,
);
}
function loadWorkflowRunsForQuery(queryForPage) {
const loadPage = (page) =>
parseWorkflowRunPage(
execPlainGh(
[
"api",
`repos/${repo}/actions/runs?head_sha=${sha}&per_page=${WORKFLOW_RUNS_PAGE_SIZE}&page=${page}`,
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
),
execPlainGh(["api", queryForPage(page)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
// Keep every request pinned to the head SHA and bound it to GitHub's documented search window.
// Bound every SHA query to GitHub's documented search window.
const firstPage = loadPage(1);
const workflowRuns = [...firstPage.workflowRuns];
for (let page = 2; page <= workflowRunPageCount(firstPage.totalCount); page += 1) {
@ -253,23 +374,39 @@ function loadWorkflowRuns(repo, sha) {
return workflowRuns;
}
function loadWorkflowRuns(repo, sha, recentSha) {
const queries = workflowRunQueryPaths(repo, { sha, recentSha });
const withPage = (query, page) => query.replace(/page=1$/u, `page=${page}`);
const workflowRuns = queries.flatMap((query) =>
loadWorkflowRunsForQuery((page) => withPage(query, page)),
);
return [...new Map(workflowRuns.map((run) => [run.id, run])).values()];
}
export function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
const evidence = collectHostedGateEvidence({
sha: args.sha,
workflowRuns: loadWorkflowRuns(args.repo, args.sha),
recentSha: args.recentSha,
workflowRuns: loadWorkflowRuns(args.repo, args.sha, args.recentSha),
changelogOnly: args.changelogOnly,
});
const evidenceHeadSha = evidence.evidenceHeadSha ?? args.sha;
const manifest = {
schemaVersion: 1,
schemaVersion: 2,
generatedAt: new Date().toISOString(),
repo: args.repo,
pullRequestNumber: args.pr,
selection: {
mode: evidenceHeadSha === args.sha ? "exact-head" : "recent-rebase-head",
maxAgeHours: HOSTED_GATE_MAX_AGE_HOURS,
},
...evidence,
};
mkdirSync(path.dirname(args.output), { recursive: true });
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(
`Exact-head hosted gates passed for ${args.sha}: ${manifest.workflows
`Hosted gates passed for PR #${args.pr} at ${args.sha} using ${evidenceHeadSha}: ${manifest.workflows
.map((workflow) => `${workflow.name}#${workflow.id}`)
.join(", ")}`,
);

View file

@ -45,7 +45,10 @@ function sanitizedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return { ...env, ...overrides };
}
function runGatesBash(script: string, options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
function runGatesBash(
script: string,
options: { cwd?: string; env?: NodeJS.ProcessEnv; sourcePrepareCore?: boolean } = {},
) {
return spawnSync(
"bash",
[
@ -55,6 +58,9 @@ function runGatesBash(script: string, options: { cwd?: string; env?: NodeJS.Proc
`script_parent_dir='${repoRoot}/scripts'`,
`source '${repoRoot}/scripts/pr-lib/common.sh'`,
`source '${repoRoot}/scripts/pr-lib/gates.sh'`,
...(options.sourcePrepareCore
? [`source '${repoRoot}/scripts/pr-lib/prepare-core.sh'`]
: []),
script,
].join("\n"),
],
@ -328,6 +334,103 @@ describe("lease-retry gate stamp refresh", () => {
});
describe("prepare gate stamp transitions", () => {
it("preserves whitespace in the rebase patch fingerprint", () => {
const { repoDir, headSha: baseSha } = makeRetryRepo();
writeFileSync(join(repoDir, "config.yml"), "root:\n child: value\n");
spawnSync("git", ["add", "config.yml"], { cwd: repoDir });
spawnSync(
"git",
["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "two spaces"],
{ cwd: repoDir },
);
const twoSpaceSha = spawnSync("git", ["rev-parse", "HEAD"], {
cwd: repoDir,
encoding: "utf8",
}).stdout.trim();
writeFileSync(join(repoDir, "config.yml"), "root:\n child: value\n");
spawnSync("git", ["add", "config.yml"], { cwd: repoDir });
spawnSync(
"git",
["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "four spaces"],
{ cwd: repoDir },
);
const fourSpaceSha = spawnSync("git", ["rev-parse", "HEAD"], {
cwd: repoDir,
encoding: "utf8",
}).stdout.trim();
const result = runGatesBash(
[
`compute_pr_patch_id ${baseSha} ${twoSpaceSha}`,
`compute_pr_patch_id ${baseSha} ${fourSpaceSha}`,
].join("\n"),
{ cwd: repoDir },
);
expect(result.status).toBe(0);
const patchIds = result.stdout.trim().split("\n");
expect(patchIds).toHaveLength(2);
expect(patchIds[0]).not.toBe(patchIds[1]);
});
it("uses the hosted pre-sync SHA only when its tree matches the local prep head", () => {
const { repoDir, headSha } = makeRetryRepo();
const tree = spawnSync("git", ["rev-parse", "HEAD^{tree}"], {
cwd: repoDir,
encoding: "utf8",
}).stdout.trim();
const remoteSha = spawnSync(
"git",
[
"-c",
"user.name=t",
"-c",
"user.email=t@example.com",
"commit-tree",
tree,
"-p",
headSha,
"-m",
"hosted head",
],
{ cwd: repoDir, encoding: "utf8" },
).stdout.trim();
const matching = runGatesBash(`resolve_prep_sync_evidence_sha ${headSha} ${remoteSha}`, {
cwd: repoDir,
sourcePrepareCore: true,
});
expect(matching.status).toBe(0);
expect(matching.stdout.trim()).toBe(remoteSha);
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", "different"],
{ cwd: repoDir },
);
const mismatched = runGatesBash(
`resolve_prep_sync_evidence_sha ${headSha} $(git rev-parse HEAD)`,
{ cwd: repoDir, sourcePrepareCore: true },
);
expect(mismatched.status).not.toBe(0);
});
it("forwards only the recorded pre-rebase SHA as recent evidence", () => {
const result = runGatesBash(
[
"gh() { if [ \"$1\" = pr ]; then printf 'deadbeef\\n'; else printf 'openclaw/openclaw\\n'; fi; }",
"run_quiet_logged() { printf 'ARG:%s\\n' \"$@\"; }",
"run_hosted_prepare_gates 100606 deadbeef false cafebabe",
].join("\n"),
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("ARG:hosted CI/Testbox gates");
expect(result.stdout).toContain("ARG:--pr\nARG:100606");
expect(result.stdout).toContain("ARG:--recent-sha\nARG:cafebabe");
});
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 });
@ -374,7 +477,7 @@ describe("prepare gate stamp transitions", () => {
expect(result.stdout).not.toContain("tbx_stale");
});
it("clears remote stamps when hosted exact-head gates replace remote proof", () => {
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");
@ -384,7 +487,33 @@ describe("prepare gate stamp transitions", () => {
["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "change"],
{ cwd: repoDir },
);
const prepTree = spawnSync("git", ["rev-parse", "HEAD^{tree}"], {
cwd: repoDir,
encoding: "utf8",
}).stdout.trim();
const mainlineBase = spawnSync("git", ["rev-parse", "refs/remotes/origin/main"], {
cwd: repoDir,
encoding: "utf8",
}).stdout.trim();
const patchId = spawnSync(
"bash",
[
"-c",
"git diff --binary refs/remotes/origin/main HEAD | git patch-id --verbatim | awk 'NR == 1 { print $1 }'",
],
{ cwd: repoDir, encoding: "utf8" },
).stdout.trim();
writeFileSync(join(repoDir, ".local", "pr-meta.env"), "PR_AUTHOR=steipete\n");
writeFileSync(
join(repoDir, ".local", "prep-sync.env"),
[
`PREP_SYNC_MAINLINE_BASE_SHA=${mainlineBase}`,
`PREP_SYNC_TREE=${prepTree}`,
`PREP_SYNC_PATCH_ID=${patchId}`,
"PREP_SYNC_EVIDENCE_SHA=cafebabe",
"",
].join("\n"),
);
writeFileSync(
join(repoDir, ".local", "gates.env"),
[
@ -403,7 +532,7 @@ describe("prepare gate stamp transitions", () => {
"checkout_prep_branch() { :; }",
"path_is_docsish() { return 1; }",
"changelog_required_for_changed_files() { return 1; }",
"run_hosted_prepare_gates() { :; }",
"run_hosted_prepare_gates() { printf 'RECENT:%s\\n' \"${4:-}\"; }",
"prepare_gates 4242",
"cat .local/gates.env",
].join("\n"),
@ -411,7 +540,8 @@ describe("prepare gate stamp transitions", () => {
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("GATES_MODE=hosted_exact_head");
expect(result.stdout).toContain("GATES_MODE=hosted_exact_or_recent_rebase");
expect(result.stdout).toContain("RECENT:cafebabe");
expect(result.stdout).toContain("REMOTE_GATES_LEASE_ID=''");
expect(result.stdout).not.toContain("tbx_stale");
});

View file

@ -1,14 +1,28 @@
import { describe, expect, it } from "vitest";
import {
collectHostedGateEvidence,
collectHostedGateEvidence as collectHostedGateEvidenceRaw,
HOSTED_GATE_MAX_AGE_HOURS,
parseArgs,
parseWorkflowRunPage,
SCHEDULED_HOSTED_WORKFLOWS,
workflowRunQueryPaths,
workflowRunPageCount,
} from "../../scripts/verify-pr-hosted-gates.mjs";
const sha = "773ffd87a1e1e34451ad6e38fda37380c2569a50";
const pr = 100606;
const nowMs = Date.parse("2026-06-17T10:55:00Z");
const BUILD_ARTIFACTS_WORKFLOW = "Blacksmith Build Artifacts Testbox";
const requiredCliArgs = [
"--repo",
"openclaw/openclaw",
"--sha",
sha,
"--pr",
String(pr),
"--output",
".local/gates-hosted-checks.json",
];
function successfulRun(name: string, id: number, updatedAt: string) {
return {
@ -18,6 +32,9 @@ function successfulRun(name: string, id: number, updatedAt: string) {
status: "completed",
conclusion: "success",
head_sha: sha,
head_branch: "codex/clean-expanded-tool-calls",
head_repository: { full_name: "openclaw/openclaw" },
pull_requests: [{ number: pr }],
path: ".github/workflows/ci.yml",
created_at: "2026-06-17T10:46:24Z",
updated_at: updatedAt,
@ -25,6 +42,10 @@ function successfulRun(name: string, id: number, updatedAt: string) {
};
}
function collectHostedGateEvidence(options: Parameters<typeof collectHostedGateEvidenceRaw>[0]) {
return collectHostedGateEvidenceRaw({ nowMs, ...options });
}
describe("verify-pr-hosted-gates", () => {
it("requires the latest scheduled workflow run to pass", () => {
const evidence = collectHostedGateEvidence({
@ -65,7 +86,7 @@ describe("verify-pr-hosted-gates", () => {
};
expect(() => collectHostedGateEvidence({ sha, workflowRuns })).toThrow(
"Missing successful exact-head Blacksmith ARM Testbox workflow",
"Missing successful Blacksmith ARM Testbox workflow",
);
});
@ -81,6 +102,250 @@ describe("verify-pr-hosted-gates", () => {
});
});
it("accepts recent green evidence from the recorded pre-rebase head while current CI is pending", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
const evidence = collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
status: "in_progress",
conclusion: null,
},
],
});
expect(evidence).toEqual({
headSha: sha,
evidenceHeadSha: previousSha,
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: previousSha })],
});
});
it("requires recent evidence for scheduled gates observed on the target head", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
const targetArmRun = {
...successfulRun("Blacksmith ARM Testbox", 3, "2026-06-17T10:54:00Z"),
status: "queued",
conclusion: null,
};
const workflowRuns = [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
status: "in_progress",
conclusion: null,
},
targetArmRun,
];
expect(() => collectHostedGateEvidence({ sha, recentSha: previousSha, workflowRuns })).toThrow(
`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`,
);
const evidence = collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
...workflowRuns,
{
...successfulRun("Blacksmith ARM Testbox", 4, "2026-06-17T10:51:00Z"),
head_sha: previousSha,
},
],
});
expect(evidence.workflows).toEqual([
expect.objectContaining({ name: "CI", headSha: previousSha }),
expect.objectContaining({ name: "Blacksmith ARM Testbox", headSha: previousSha }),
]);
});
it.each(["failure", "cancelled", "skipped"])(
"does not reuse older green evidence after a current-head %s run",
(conclusion) => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
conclusion,
},
],
}),
).toThrow(`Missing successful CI workflow for ${sha}`);
},
);
it("requires the complete recent gate cohort from the recorded head", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("Blacksmith ARM Testbox", 2, "2026-06-17T10:51:00Z"),
head_sha: previousSha,
conclusion: "failure",
},
{
...successfulRun("CI", 3, "2026-06-17T10:54:00Z"),
status: "in_progress",
conclusion: null,
},
],
}),
).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`);
});
it("does not drop an applicable scheduled gate when its success is stale", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("Blacksmith ARM Testbox", 2, "2026-06-16T10:54:59Z"),
head_sha: previousSha,
},
{
...successfulRun("CI", 3, "2026-06-17T10:54:00Z"),
status: "in_progress",
conclusion: null,
},
],
}),
).toThrow(`Missing successful recent Blacksmith ARM Testbox workflow for ${previousSha}`);
});
it("does not reuse pre-rebase green evidence after a failed current-head manual gate", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
head_sha: previousSha,
},
{
...successfulRun("CI", 2, "2026-06-17T10:53:00Z"),
status: "in_progress",
conclusion: null,
},
{
...successfulRun(`CI release gate ${sha}`, 3, "2026-06-17T10:54:00Z"),
event: "workflow_dispatch",
display_title: `CI release gate ${sha}`,
conclusion: "failure",
},
],
}),
).toThrow(`Missing successful CI workflow for ${sha}`);
});
it("rejects stale or unrecorded fallback heads", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
const unrelatedSha = "ec159b0222cf4fa21b318317a7c5a29d52c846d2";
const currentPending = {
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
status: "in_progress",
conclusion: null,
};
const staleRun = {
...successfulRun("CI", 3, "2026-06-16T10:54:59Z"),
head_sha: previousSha,
};
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [staleRun, currentPending],
}),
).toThrow(`Missing successful recent CI workflow for ${previousSha}`);
const recentUnrelatedRun = {
...successfulRun("CI", 4, "2026-06-17T10:50:00Z"),
head_sha: unrelatedSha,
};
expect(() =>
collectHostedGateEvidence({
sha,
recentSha: previousSha,
workflowRuns: [recentUnrelatedRun, currentPending],
}),
).toThrow(`Missing successful recent CI workflow for ${previousSha}`);
expect(() =>
collectHostedGateEvidence({
sha,
workflowRuns: [{ ...recentUnrelatedRun, head_sha: previousSha }, currentPending],
}),
).toThrow(`Missing successful CI workflow for ${sha}`);
});
it("allows a later scheduled success to clear an earlier current-head failure", () => {
expect(
collectHostedGateEvidence({
sha,
workflowRuns: [
{
...successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
conclusion: "failure",
},
successfulRun("CI", 2, "2026-06-17T10:52:00Z"),
],
}),
).toEqual({
headSha: sha,
workflows: [expect.objectContaining({ name: "CI", id: 2, headSha: sha })],
});
});
it("does not let a late failure from an obsolete head override a green target head", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(
collectHostedGateEvidence({
sha,
workflowRuns: [
successfulRun("CI", 1, "2026-06-17T10:50:00Z"),
{
...successfulRun("CI", 2, "2026-06-17T10:54:00Z"),
head_sha: previousSha,
conclusion: "failure",
},
],
}),
).toEqual({
headSha: sha,
workflows: [expect.objectContaining({ name: "CI", id: 1, headSha: sha })],
});
});
it("uses the latest CI run when an older duplicate was cancelled", () => {
expect(
collectHostedGateEvidence({
@ -158,7 +423,7 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head CI workflow");
).toThrow("Missing successful CI workflow");
});
it("does not mask a failed CI run with a queued rerun and release-gate fallback", () => {
@ -182,7 +447,7 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head CI workflow");
).toThrow("Missing successful CI workflow");
});
it("covers a queued artifact Testbox only with a completed exact CI fallback", () => {
@ -241,7 +506,7 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head Blacksmith Build Artifacts Testbox workflow");
).toThrow("Missing successful Blacksmith Build Artifacts Testbox workflow");
});
it("keeps active or terminal non-successful artifact Testboxes blocking", () => {
@ -267,7 +532,7 @@ describe("verify-pr-hosted-gates", () => {
sha,
workflowRuns: [ciFallback, artifactRun],
}),
).toThrow("Missing successful exact-head Blacksmith Build Artifacts Testbox workflow");
).toThrow("Missing successful Blacksmith Build Artifacts Testbox workflow");
}
expect(() =>
@ -286,7 +551,7 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head Blacksmith Build Artifacts Testbox workflow");
).toThrow("Missing successful Blacksmith Build Artifacts Testbox workflow");
});
it("rejects an unmarked manual CI run", () => {
@ -301,7 +566,7 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head CI workflow");
).toThrow("Missing successful CI workflow");
});
it("rejects a manual release-gate title from another workflow", () => {
@ -317,12 +582,12 @@ describe("verify-pr-hosted-gates", () => {
},
],
}),
).toThrow("Missing successful exact-head CI workflow");
).toThrow("Missing successful CI workflow");
});
it("requires CI for docs unless the head changes only CHANGELOG.md", () => {
expect(() => collectHostedGateEvidence({ sha, workflowRuns: [] })).toThrow(
"Missing successful exact-head CI workflow",
"Missing successful CI workflow",
);
expect(collectHostedGateEvidence({ sha, workflowRuns: [], changelogOnly: true })).toEqual({
headSha: sha,
@ -331,63 +596,33 @@ describe("verify-pr-hosted-gates", () => {
});
it("parses required CLI arguments", () => {
expect(
parseArgs([
"--repo",
"openclaw/openclaw",
"--sha",
sha,
"--output",
".local/gates-hosted-checks.json",
]),
).toEqual({
expect(parseArgs(requiredCliArgs)).toEqual({
repo: "openclaw/openclaw",
sha,
pr,
recentSha: "",
output: ".local/gates-hosted-checks.json",
changelogOnly: false,
});
expect(() => parseArgs(["--repo", "openclaw/openclaw"])).toThrow("Usage:");
expect(() =>
parseArgs(["--repo", "-h", "--sha", sha, "--output", ".local/gates-hosted-checks.json"]),
).toThrow("Expected --repo <value>.");
expect(() =>
parseArgs([
"--repo",
"openclaw/openclaw",
"--sha",
"-h",
"--output",
".local/gates-hosted-checks.json",
]),
).toThrow("Expected --sha <value>.");
expect(() =>
parseArgs(["--repo", "openclaw/openclaw", "--sha", sha, "--output", "-h"]),
).toThrow("Expected --output <value>.");
expect(() => parseArgs(requiredCliArgs.with(1, "-h"))).toThrow("Expected --repo <value>.");
expect(() => parseArgs(requiredCliArgs.with(3, "-h"))).toThrow("Expected --sha <value>.");
expect(() => parseArgs(requiredCliArgs.with(5, "zero"))).toThrow(
"Expected --pr <positive-integer>.",
);
expect(() => parseArgs(requiredCliArgs.with(requiredCliArgs.length - 1, "-h"))).toThrow(
"Expected --output <value>.",
);
});
it("rejects duplicate hosted gate verifier CLI arguments", () => {
const requiredArgs = [
"--repo",
"openclaw/openclaw",
"--sha",
sha,
"--output",
".local/gates-hosted-checks.json",
];
const duplicateCases = [
[
"--repo",
["--repo", "openclaw/openclaw", "--repo", "fork/openclaw", "--sha", sha, "--output", "out.json"],
],
[
"--sha",
["--repo", "openclaw/openclaw", "--sha", sha, "--sha", "other-sha", "--output", "out.json"],
],
[
"--output",
["--repo", "openclaw/openclaw", "--sha", sha, "--output", "one.json", "--output", "two.json"],
],
["--changelog-only", [...requiredArgs, "--changelog-only", "--changelog-only"]],
["--repo", [...requiredCliArgs, "--repo", "fork/openclaw"]],
["--sha", [...requiredCliArgs, "--sha", "other-sha"]],
["--pr", [...requiredCliArgs, "--pr", "7"]],
["--recent-sha", [...requiredCliArgs, "--recent-sha", "one", "--recent-sha", "other"]],
["--output", [...requiredCliArgs, "--output", "two.json"]],
["--changelog-only", [...requiredCliArgs, "--changelog-only", "--changelog-only"]],
] satisfies Array<[string, string[]]>;
for (const [flag, args] of duplicateCases) {
@ -403,6 +638,20 @@ describe("verify-pr-hosted-gates", () => {
).toEqual({ totalCount: 101, workflowRuns: [{ id: 1, name: "CI" }] });
});
it("queries the target and recorded pre-rebase SHAs", () => {
const previousSha = "8d86c44c6144f8f726a460914cddb8c9c201f119";
expect(
workflowRunQueryPaths("openclaw/openclaw", {
sha,
recentSha: previousSha,
}),
).toEqual([
`repos/openclaw/openclaw/actions/runs?head_sha=${sha}&per_page=100&page=1`,
`repos/openclaw/openclaw/actions/runs?head_sha=${previousSha}&per_page=100&page=1`,
]);
expect(HOSTED_GATE_MAX_AGE_HOURS).toBe(24);
});
it("bounds workflow-run pagination to GitHub's search result limit", () => {
expect(workflowRunPageCount(0)).toBe(0);
expect(workflowRunPageCount(101)).toBe(2);