mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
6e71b0bf30
commit
5d9cec16f3
5 changed files with 700 additions and 111 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(", ")}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue