diff --git a/scripts/pr-lib/gates.sh b/scripts/pr-lib/gates.sh index 42687078d65..1a763d21d6e 100644 --- a/scripts/pr-lib/gates.sh +++ b/scripts/pr-lib/gates.sh @@ -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" diff --git a/scripts/pr-lib/prepare-core.sh b/scripts/pr-lib/prepare-core.sh index 632073c1248..cad2acf1491 100644 --- a/scripts/pr-lib/prepare-core.sh +++ b/scripts/pr-lib/prepare-core.sh @@ -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 <> .local/prep.md <> .local/prep.md < { 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 ."); + } + 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 --sha --output ", + "Usage: node scripts/verify-pr-hosted-gates.mjs --repo --sha --pr [--recent-sha ] --output ", ); } 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(", ")}`, ); diff --git a/test/scripts/pr-prepare-gates.test.ts b/test/scripts/pr-prepare-gates.test.ts index fff07b09abb..7d486b359ca 100644 --- a/test/scripts/pr-prepare-gates.test.ts +++ b/test/scripts/pr-prepare-gates.test.ts @@ -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"); }); diff --git a/test/scripts/verify-pr-hosted-gates.test.ts b/test/scripts/verify-pr-hosted-gates.test.ts index 8c32a9c619c..b1af33dff9f 100644 --- a/test/scripts/verify-pr-hosted-gates.test.ts +++ b/test/scripts/verify-pr-hosted-gates.test.ts @@ -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[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 ."); - expect(() => - parseArgs([ - "--repo", - "openclaw/openclaw", - "--sha", - "-h", - "--output", - ".local/gates-hosted-checks.json", - ]), - ).toThrow("Expected --sha ."); - expect(() => - parseArgs(["--repo", "openclaw/openclaw", "--sha", sha, "--output", "-h"]), - ).toThrow("Expected --output ."); + expect(() => parseArgs(requiredCliArgs.with(1, "-h"))).toThrow("Expected --repo ."); + expect(() => parseArgs(requiredCliArgs.with(3, "-h"))).toThrow("Expected --sha ."); + expect(() => parseArgs(requiredCliArgs.with(5, "zero"))).toThrow( + "Expected --pr .", + ); + expect(() => parseArgs(requiredCliArgs.with(requiredCliArgs.length - 1, "-h"))).toThrow( + "Expected --output .", + ); }); 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);