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 pr="$1"
|
||||||
local current_head="$2"
|
local current_head="$2"
|
||||||
local changelog_only="$3"
|
local changelog_only="$3"
|
||||||
|
local recent_sha="${4:-}"
|
||||||
local remote_head
|
local remote_head
|
||||||
remote_head=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
remote_head=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||||
if [ "$remote_head" != "$current_head" ]; then
|
if [ "$remote_head" != "$current_head" ]; then
|
||||||
|
|
@ -15,12 +16,20 @@ run_hosted_prepare_gates() {
|
||||||
scripts/verify-pr-hosted-gates.mjs
|
scripts/verify-pr-hosted-gates.mjs
|
||||||
--repo "$repo"
|
--repo "$repo"
|
||||||
--sha "$current_head"
|
--sha "$current_head"
|
||||||
|
--pr "$pr"
|
||||||
--output ".local/gates-hosted-checks.json"
|
--output ".local/gates-hosted-checks.json"
|
||||||
)
|
)
|
||||||
|
if [ -n "$recent_sha" ]; then
|
||||||
|
args+=(--recent-sha "$recent_sha")
|
||||||
|
fi
|
||||||
if [ "$changelog_only" = "true" ]; then
|
if [ "$changelog_only" = "true" ]; then
|
||||||
args+=(--changelog-only)
|
args+=(--changelog-only)
|
||||||
fi
|
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() {
|
pin_worktree_bundled_plugins_dir() {
|
||||||
|
|
@ -192,7 +201,7 @@ write_gates_env_stamp() {
|
||||||
GATES_MODE "$gates_mode" \
|
GATES_MODE "$gates_mode" \
|
||||||
LAST_VERIFIED_HEAD_SHA "$last_verified_head" \
|
LAST_VERIFIED_HEAD_SHA "$last_verified_head" \
|
||||||
FULL_GATES_HEAD_SHA "$full_gates_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_PROVIDER "$remote_provider" \
|
||||||
REMOTE_GATES_LEASE_ID "$remote_lease_id" \
|
REMOTE_GATES_LEASE_ID "$remote_lease_id" \
|
||||||
REMOTE_GATES_RUN_URL "$remote_run_url" \
|
REMOTE_GATES_RUN_URL "$remote_run_url" \
|
||||||
|
|
@ -204,8 +213,8 @@ run_prepare_push_retry_gates() {
|
||||||
local docs_only="${1:-false}"
|
local docs_only="${1:-false}"
|
||||||
|
|
||||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||||
echo "A lease retry changed the prepared head, so its exact-head hosted evidence no longer applies."
|
echo "A lease retry changed the prepared head after gate selection."
|
||||||
echo "Stop here, wait for CI/Testbox on the pushed head, then re-run prepare-run."
|
echo "Stop here, wait for hosted evidence on the pushed branch, then re-run prepare-run."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -271,7 +280,7 @@ prepare_gates() {
|
||||||
local gates_remote_mode
|
local gates_remote_mode
|
||||||
gates_remote_mode=$(resolve_pr_gates_remote_mode)
|
gates_remote_mode=$(resolve_pr_gates_remote_mode)
|
||||||
if [ "$gates_remote_mode" = "testbox" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
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
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -371,14 +380,36 @@ prepare_gates() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||||
gates_mode="hosted_exact_head"
|
gates_mode="hosted_exact_or_recent_rebase"
|
||||||
remote_gates_provider=""
|
remote_gates_provider=""
|
||||||
remote_gates_lease_id=""
|
remote_gates_lease_id=""
|
||||||
remote_gates_run_url=""
|
remote_gates_run_url=""
|
||||||
if [ "$changelog_only" = "true" ]; then
|
if [ "$changelog_only" = "true" ]; then
|
||||||
run_quiet_logged "git diff --check" ".local/gates-diff-check.log" git diff --check origin/main...HEAD
|
run_quiet_logged "git diff --check" ".local/gates-diff-check.log" git diff --check origin/main...HEAD
|
||||||
fi
|
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"
|
hosted_gates_head="$current_head"
|
||||||
elif [ "$reuse_gates" = "true" ]; then
|
elif [ "$reuse_gates" = "true" ]; then
|
||||||
gates_mode="reused_docs_only"
|
gates_mode="reused_docs_only"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,17 @@ verify_prep_branch_matches_prepared_head() {
|
||||||
exit 1
|
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() {
|
prepare_init() {
|
||||||
local pr="$1"
|
local pr="$1"
|
||||||
enter_worktree "$pr" true
|
enter_worktree "$pr" true
|
||||||
|
|
@ -236,19 +247,36 @@ prepare_sync_head() {
|
||||||
|
|
||||||
checkout_prep_branch "$pr"
|
checkout_prep_branch "$pr"
|
||||||
|
|
||||||
|
local pre_sync_head
|
||||||
|
pre_sync_head=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source .local/pr-meta.env
|
source .local/pr-meta.env
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source .local/prep-context.env
|
source .local/prep-context.env
|
||||||
|
|
||||||
local rebased=false
|
local rebased=false
|
||||||
|
local prep_sync_patch_id=""
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
if ! git merge-base --is-ancestor origin/main HEAD; then
|
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
|
git rebase origin/main
|
||||||
rebased=true
|
rebased=true
|
||||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
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
|
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
|
else
|
||||||
prepare_gates "$pr"
|
prepare_gates "$pr"
|
||||||
checkout_prep_branch "$pr"
|
checkout_prep_branch "$pr"
|
||||||
|
|
@ -297,18 +325,32 @@ EOF_PREP
|
||||||
if [ "$rebased" = "true" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
if [ "$rebased" = "true" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||||
local prep_sync_tree
|
local prep_sync_tree
|
||||||
prep_sync_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
|
prep_sync_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
|
||||||
# Preserve the verified local lineage because GraphQL creates a remote
|
local prep_sync_evidence_sha=""
|
||||||
# commit with the same tree but the old branch parent.
|
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' \
|
printf '%s=%q\n' \
|
||||||
PREP_SYNC_MAINLINE_BASE_SHA "$mainline_base_sha" \
|
PREP_SYNC_MAINLINE_BASE_SHA "$mainline_base_sha" \
|
||||||
PREP_SYNC_TREE "$prep_sync_tree" \
|
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
|
> .local/prep-sync.env
|
||||||
cat >> .local/prep.md <<EOF_PREP
|
if [ -n "$prep_sync_evidence_sha" ]; then
|
||||||
- Cleared stale prepare artifacts. Wait for hosted CI/Testbox on $prep_head_sha, then run prepare-run again.
|
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
|
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 "prepare-sync-head complete"
|
||||||
echo "prep_head_sha=$prep_head_sha"
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ const ARTIFACT_FALLBACK_REQUIRED_WORKFLOWS = [
|
||||||
];
|
];
|
||||||
const WORKFLOW_RUNS_PAGE_SIZE = 100;
|
const WORKFLOW_RUNS_PAGE_SIZE = 100;
|
||||||
const MAX_WORKFLOW_RUN_SEARCH_RESULTS = 1_000;
|
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) {
|
function readOptionValue(argv, index, optionName) {
|
||||||
const value = argv[index + 1];
|
const value = argv[index + 1];
|
||||||
|
|
@ -29,7 +32,14 @@ function readOptionValue(argv, index, optionName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(argv) {
|
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 seen = new Set();
|
||||||
const setOnce = (flag, key, value) => {
|
const setOnce = (flag, key, value) => {
|
||||||
if (seen.has(flag)) {
|
if (seen.has(flag)) {
|
||||||
|
|
@ -49,6 +59,19 @@ export function parseArgs(argv) {
|
||||||
setOnce(arg, "sha", readOptionValue(argv, index, arg));
|
setOnce(arg, "sha", readOptionValue(argv, index, arg));
|
||||||
index += 1;
|
index += 1;
|
||||||
break;
|
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":
|
case "--output":
|
||||||
setOnce(arg, "output", readOptionValue(argv, index, arg));
|
setOnce(arg, "output", readOptionValue(argv, index, arg));
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
@ -60,9 +83,9 @@ export function parseArgs(argv) {
|
||||||
throw new Error(`Unknown option: ${arg}`);
|
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(
|
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;
|
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) => {
|
return runs.filter((run) => {
|
||||||
if (run?.head_sha !== sha) {
|
if (run?.head_sha !== sha) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (run?.event === "pull_request") {
|
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];
|
)[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) {
|
function preferredCiRun(runs) {
|
||||||
const scheduledRuns = runs.filter((run) => run.event === "pull_request");
|
const scheduledRuns = runs.filter((run) => run.event === "pull_request");
|
||||||
const latestScheduledRun = latestRun(scheduledRuns);
|
const latestScheduledRun = latestRun(scheduledRuns);
|
||||||
|
|
@ -124,12 +161,19 @@ function preferredCiRun(runs) {
|
||||||
return latestRun(runs.filter((run) => run.event === "workflow_dispatch")) ?? latestScheduledRun;
|
return latestRun(runs.filter((run) => run.event === "workflow_dispatch")) ?? latestScheduledRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
function successfulRunOrThrow(runs, workflowName, sha) {
|
function successfulRunOrThrow(
|
||||||
const matchingRuns = matchingAuthoritativeRuns(runs, workflowName, sha);
|
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);
|
const run = workflowName === "CI" ? preferredCiRun(matchingRuns) : latestRun(matchingRuns);
|
||||||
if (!run || run.status !== "completed" || run.conclusion !== "success") {
|
if (!run || run.status !== "completed" || run.conclusion !== "success") {
|
||||||
throw new Error(
|
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;
|
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)) {
|
if (!Array.isArray(workflowRuns)) {
|
||||||
throw new Error("workflowRuns must be an array.");
|
throw new Error("workflowRuns must be an array.");
|
||||||
}
|
}
|
||||||
const workflows = [];
|
|
||||||
const fallbackCoveredWorkflows = [];
|
const collectForSha = (evidenceSha, requireRecent, requiredScheduledWorkflows = new Set()) => {
|
||||||
let ciRun;
|
const allowManual = !requireRecent;
|
||||||
if (!changelogOnly) {
|
const workflows = [];
|
||||||
ciRun = successfulRunOrThrow(workflowRuns, "CI", sha);
|
const fallbackCoveredWorkflows = [];
|
||||||
workflows.push(ciRun);
|
if (!changelogOnly) {
|
||||||
}
|
workflows.push(
|
||||||
for (const workflowName of SCHEDULED_HOSTED_WORKFLOWS) {
|
successfulRunOrThrow(workflowRuns, "CI", evidenceSha, {
|
||||||
const matchingRuns = matchingAuthoritativeRuns(workflowRuns, workflowName, sha);
|
allowManual,
|
||||||
if (matchingRuns.length > 0) {
|
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 (
|
if (
|
||||||
|
allowManual &&
|
||||||
workflowName === BUILD_ARTIFACTS_WORKFLOW &&
|
workflowName === BUILD_ARTIFACTS_WORKFLOW &&
|
||||||
canCoverQueuedBuildArtifacts(workflowRuns, sha)
|
canCoverQueuedBuildArtifacts(workflowRuns, evidenceSha)
|
||||||
) {
|
) {
|
||||||
fallbackCoveredWorkflows.push({
|
fallbackCoveredWorkflows.push({
|
||||||
name: workflowName,
|
name: workflowName,
|
||||||
|
|
@ -210,15 +275,63 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
|
||||||
});
|
});
|
||||||
continue;
|
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 = {
|
const evidence = {
|
||||||
headSha: sha,
|
headSha: sha,
|
||||||
workflows: workflows.map((run) => ({
|
workflows: selected.workflows.map((run) => ({
|
||||||
id: run.id,
|
id: run.id,
|
||||||
name: run.name,
|
name: run.name,
|
||||||
event: run.event,
|
event: run.event,
|
||||||
|
headSha: run.head_sha,
|
||||||
|
headBranch: run.head_branch,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
conclusion: run.conclusion,
|
conclusion: run.conclusion,
|
||||||
createdAt: run.created_at,
|
createdAt: run.created_at,
|
||||||
|
|
@ -226,25 +339,33 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
|
||||||
url: run.html_url,
|
url: run.html_url,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (fallbackCoveredWorkflows.length > 0) {
|
if (evidenceSha !== sha) {
|
||||||
evidence.fallbackCoveredWorkflows = fallbackCoveredWorkflows;
|
evidence.evidenceHeadSha = evidenceSha;
|
||||||
|
}
|
||||||
|
if (selected.fallbackCoveredWorkflows.length > 0) {
|
||||||
|
evidence.fallbackCoveredWorkflows = selected.fallbackCoveredWorkflows;
|
||||||
}
|
}
|
||||||
return evidence;
|
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) =>
|
const loadPage = (page) =>
|
||||||
parseWorkflowRunPage(
|
parseWorkflowRunPage(
|
||||||
execPlainGh(
|
execPlainGh(["api", queryForPage(page)], {
|
||||||
[
|
encoding: "utf8",
|
||||||
"api",
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
`repos/${repo}/actions/runs?head_sha=${sha}&per_page=${WORKFLOW_RUNS_PAGE_SIZE}&page=${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 firstPage = loadPage(1);
|
||||||
const workflowRuns = [...firstPage.workflowRuns];
|
const workflowRuns = [...firstPage.workflowRuns];
|
||||||
for (let page = 2; page <= workflowRunPageCount(firstPage.totalCount); page += 1) {
|
for (let page = 2; page <= workflowRunPageCount(firstPage.totalCount); page += 1) {
|
||||||
|
|
@ -253,23 +374,39 @@ function loadWorkflowRuns(repo, sha) {
|
||||||
return workflowRuns;
|
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)) {
|
export function main(argv = process.argv.slice(2)) {
|
||||||
const args = parseArgs(argv);
|
const args = parseArgs(argv);
|
||||||
const evidence = collectHostedGateEvidence({
|
const evidence = collectHostedGateEvidence({
|
||||||
sha: args.sha,
|
sha: args.sha,
|
||||||
workflowRuns: loadWorkflowRuns(args.repo, args.sha),
|
recentSha: args.recentSha,
|
||||||
|
workflowRuns: loadWorkflowRuns(args.repo, args.sha, args.recentSha),
|
||||||
changelogOnly: args.changelogOnly,
|
changelogOnly: args.changelogOnly,
|
||||||
});
|
});
|
||||||
|
const evidenceHeadSha = evidence.evidenceHeadSha ?? args.sha;
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
repo: args.repo,
|
repo: args.repo,
|
||||||
|
pullRequestNumber: args.pr,
|
||||||
|
selection: {
|
||||||
|
mode: evidenceHeadSha === args.sha ? "exact-head" : "recent-rebase-head",
|
||||||
|
maxAgeHours: HOSTED_GATE_MAX_AGE_HOURS,
|
||||||
|
},
|
||||||
...evidence,
|
...evidence,
|
||||||
};
|
};
|
||||||
mkdirSync(path.dirname(args.output), { recursive: true });
|
mkdirSync(path.dirname(args.output), { recursive: true });
|
||||||
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
|
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
console.log(
|
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}`)
|
.map((workflow) => `${workflow.name}#${workflow.id}`)
|
||||||
.join(", ")}`,
|
.join(", ")}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ function sanitizedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||||
return { ...env, ...overrides };
|
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(
|
return spawnSync(
|
||||||
"bash",
|
"bash",
|
||||||
[
|
[
|
||||||
|
|
@ -55,6 +58,9 @@ function runGatesBash(script: string, options: { cwd?: string; env?: NodeJS.Proc
|
||||||
`script_parent_dir='${repoRoot}/scripts'`,
|
`script_parent_dir='${repoRoot}/scripts'`,
|
||||||
`source '${repoRoot}/scripts/pr-lib/common.sh'`,
|
`source '${repoRoot}/scripts/pr-lib/common.sh'`,
|
||||||
`source '${repoRoot}/scripts/pr-lib/gates.sh'`,
|
`source '${repoRoot}/scripts/pr-lib/gates.sh'`,
|
||||||
|
...(options.sourcePrepareCore
|
||||||
|
? [`source '${repoRoot}/scripts/pr-lib/prepare-core.sh'`]
|
||||||
|
: []),
|
||||||
script,
|
script,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
],
|
],
|
||||||
|
|
@ -328,6 +334,103 @@ describe("lease-retry gate stamp refresh", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("prepare gate stamp transitions", () => {
|
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", () => {
|
it("clears remote stamps when fresh docs-only gates do not reuse prior proof", () => {
|
||||||
const { repoDir } = makeRetryRepo();
|
const { repoDir } = makeRetryRepo();
|
||||||
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
|
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");
|
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();
|
const { repoDir } = makeRetryRepo();
|
||||||
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
|
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
|
||||||
writeFileSync(join(repoDir, "changed.ts"), "export {};\n");
|
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"],
|
["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-qm", "change"],
|
||||||
{ cwd: repoDir },
|
{ 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", "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(
|
writeFileSync(
|
||||||
join(repoDir, ".local", "gates.env"),
|
join(repoDir, ".local", "gates.env"),
|
||||||
[
|
[
|
||||||
|
|
@ -403,7 +532,7 @@ describe("prepare gate stamp transitions", () => {
|
||||||
"checkout_prep_branch() { :; }",
|
"checkout_prep_branch() { :; }",
|
||||||
"path_is_docsish() { return 1; }",
|
"path_is_docsish() { return 1; }",
|
||||||
"changelog_required_for_changed_files() { 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",
|
"prepare_gates 4242",
|
||||||
"cat .local/gates.env",
|
"cat .local/gates.env",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|
@ -411,7 +540,8 @@ describe("prepare gate stamp transitions", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.status).toBe(0);
|
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).toContain("REMOTE_GATES_LEASE_ID=''");
|
||||||
expect(result.stdout).not.toContain("tbx_stale");
|
expect(result.stdout).not.toContain("tbx_stale");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,28 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
collectHostedGateEvidence,
|
collectHostedGateEvidence as collectHostedGateEvidenceRaw,
|
||||||
|
HOSTED_GATE_MAX_AGE_HOURS,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
parseWorkflowRunPage,
|
parseWorkflowRunPage,
|
||||||
SCHEDULED_HOSTED_WORKFLOWS,
|
SCHEDULED_HOSTED_WORKFLOWS,
|
||||||
|
workflowRunQueryPaths,
|
||||||
workflowRunPageCount,
|
workflowRunPageCount,
|
||||||
} from "../../scripts/verify-pr-hosted-gates.mjs";
|
} from "../../scripts/verify-pr-hosted-gates.mjs";
|
||||||
|
|
||||||
const sha = "773ffd87a1e1e34451ad6e38fda37380c2569a50";
|
const sha = "773ffd87a1e1e34451ad6e38fda37380c2569a50";
|
||||||
|
const pr = 100606;
|
||||||
|
const nowMs = Date.parse("2026-06-17T10:55:00Z");
|
||||||
const BUILD_ARTIFACTS_WORKFLOW = "Blacksmith Build Artifacts Testbox";
|
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) {
|
function successfulRun(name: string, id: number, updatedAt: string) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -18,6 +32,9 @@ function successfulRun(name: string, id: number, updatedAt: string) {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
conclusion: "success",
|
conclusion: "success",
|
||||||
head_sha: sha,
|
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",
|
path: ".github/workflows/ci.yml",
|
||||||
created_at: "2026-06-17T10:46:24Z",
|
created_at: "2026-06-17T10:46:24Z",
|
||||||
updated_at: updatedAt,
|
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", () => {
|
describe("verify-pr-hosted-gates", () => {
|
||||||
it("requires the latest scheduled workflow run to pass", () => {
|
it("requires the latest scheduled workflow run to pass", () => {
|
||||||
const evidence = collectHostedGateEvidence({
|
const evidence = collectHostedGateEvidence({
|
||||||
|
|
@ -65,7 +86,7 @@ describe("verify-pr-hosted-gates", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => collectHostedGateEvidence({ sha, workflowRuns })).toThrow(
|
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", () => {
|
it("uses the latest CI run when an older duplicate was cancelled", () => {
|
||||||
expect(
|
expect(
|
||||||
collectHostedGateEvidence({
|
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", () => {
|
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", () => {
|
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", () => {
|
it("keeps active or terminal non-successful artifact Testboxes blocking", () => {
|
||||||
|
|
@ -267,7 +532,7 @@ describe("verify-pr-hosted-gates", () => {
|
||||||
sha,
|
sha,
|
||||||
workflowRuns: [ciFallback, artifactRun],
|
workflowRuns: [ciFallback, artifactRun],
|
||||||
}),
|
}),
|
||||||
).toThrow("Missing successful exact-head Blacksmith Build Artifacts Testbox workflow");
|
).toThrow("Missing successful Blacksmith Build Artifacts Testbox workflow");
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() =>
|
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", () => {
|
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", () => {
|
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", () => {
|
it("requires CI for docs unless the head changes only CHANGELOG.md", () => {
|
||||||
expect(() => collectHostedGateEvidence({ sha, workflowRuns: [] })).toThrow(
|
expect(() => collectHostedGateEvidence({ sha, workflowRuns: [] })).toThrow(
|
||||||
"Missing successful exact-head CI workflow",
|
"Missing successful CI workflow",
|
||||||
);
|
);
|
||||||
expect(collectHostedGateEvidence({ sha, workflowRuns: [], changelogOnly: true })).toEqual({
|
expect(collectHostedGateEvidence({ sha, workflowRuns: [], changelogOnly: true })).toEqual({
|
||||||
headSha: sha,
|
headSha: sha,
|
||||||
|
|
@ -331,63 +596,33 @@ describe("verify-pr-hosted-gates", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses required CLI arguments", () => {
|
it("parses required CLI arguments", () => {
|
||||||
expect(
|
expect(parseArgs(requiredCliArgs)).toEqual({
|
||||||
parseArgs([
|
|
||||||
"--repo",
|
|
||||||
"openclaw/openclaw",
|
|
||||||
"--sha",
|
|
||||||
sha,
|
|
||||||
"--output",
|
|
||||||
".local/gates-hosted-checks.json",
|
|
||||||
]),
|
|
||||||
).toEqual({
|
|
||||||
repo: "openclaw/openclaw",
|
repo: "openclaw/openclaw",
|
||||||
sha,
|
sha,
|
||||||
|
pr,
|
||||||
|
recentSha: "",
|
||||||
output: ".local/gates-hosted-checks.json",
|
output: ".local/gates-hosted-checks.json",
|
||||||
changelogOnly: false,
|
changelogOnly: false,
|
||||||
});
|
});
|
||||||
expect(() => parseArgs(["--repo", "openclaw/openclaw"])).toThrow("Usage:");
|
expect(() => parseArgs(["--repo", "openclaw/openclaw"])).toThrow("Usage:");
|
||||||
expect(() =>
|
expect(() => parseArgs(requiredCliArgs.with(1, "-h"))).toThrow("Expected --repo <value>.");
|
||||||
parseArgs(["--repo", "-h", "--sha", sha, "--output", ".local/gates-hosted-checks.json"]),
|
expect(() => parseArgs(requiredCliArgs.with(3, "-h"))).toThrow("Expected --sha <value>.");
|
||||||
).toThrow("Expected --repo <value>.");
|
expect(() => parseArgs(requiredCliArgs.with(5, "zero"))).toThrow(
|
||||||
expect(() =>
|
"Expected --pr <positive-integer>.",
|
||||||
parseArgs([
|
);
|
||||||
"--repo",
|
expect(() => parseArgs(requiredCliArgs.with(requiredCliArgs.length - 1, "-h"))).toThrow(
|
||||||
"openclaw/openclaw",
|
"Expected --output <value>.",
|
||||||
"--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>.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate hosted gate verifier CLI arguments", () => {
|
it("rejects duplicate hosted gate verifier CLI arguments", () => {
|
||||||
const requiredArgs = [
|
|
||||||
"--repo",
|
|
||||||
"openclaw/openclaw",
|
|
||||||
"--sha",
|
|
||||||
sha,
|
|
||||||
"--output",
|
|
||||||
".local/gates-hosted-checks.json",
|
|
||||||
];
|
|
||||||
const duplicateCases = [
|
const duplicateCases = [
|
||||||
[
|
["--repo", [...requiredCliArgs, "--repo", "fork/openclaw"]],
|
||||||
"--repo",
|
["--sha", [...requiredCliArgs, "--sha", "other-sha"]],
|
||||||
["--repo", "openclaw/openclaw", "--repo", "fork/openclaw", "--sha", sha, "--output", "out.json"],
|
["--pr", [...requiredCliArgs, "--pr", "7"]],
|
||||||
],
|
["--recent-sha", [...requiredCliArgs, "--recent-sha", "one", "--recent-sha", "other"]],
|
||||||
[
|
["--output", [...requiredCliArgs, "--output", "two.json"]],
|
||||||
"--sha",
|
["--changelog-only", [...requiredCliArgs, "--changelog-only", "--changelog-only"]],
|
||||||
["--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"]],
|
|
||||||
] satisfies Array<[string, string[]]>;
|
] satisfies Array<[string, string[]]>;
|
||||||
|
|
||||||
for (const [flag, args] of duplicateCases) {
|
for (const [flag, args] of duplicateCases) {
|
||||||
|
|
@ -403,6 +638,20 @@ describe("verify-pr-hosted-gates", () => {
|
||||||
).toEqual({ totalCount: 101, workflowRuns: [{ id: 1, name: "CI" }] });
|
).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", () => {
|
it("bounds workflow-run pagination to GitHub's search result limit", () => {
|
||||||
expect(workflowRunPageCount(0)).toBe(0);
|
expect(workflowRunPageCount(0)).toBe(0);
|
||||||
expect(workflowRunPageCount(101)).toBe(2);
|
expect(workflowRunPageCount(101)).toBe(2);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue