mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
691 lines
28 KiB
Bash
691 lines
28 KiB
Bash
# shellcheck source=scripts/pr-lib/github.sh
|
|
source "$(cd "${BASH_SOURCE[0]%/*}" && pwd -P)/github.sh" || return 1
|
|
|
|
run_hosted_prepare_gates() {
|
|
local pr="$1"
|
|
local current_head="$2"
|
|
local changelog_only="$3"
|
|
local recent_sha=""
|
|
local remote_record="${4:-}" remote_head remote_head_ref remote_is_cross_repository
|
|
if [ -z "$remote_record" ]; then
|
|
remote_record=$(read_pr_observation "$pr") || return 1
|
|
fi
|
|
remote_head=$(pr_view_string_field "$remote_record" "headRefOid" "$pr" "Re-run prepare-init.") || return 1
|
|
remote_head_ref=$(printf '%s\n' "$remote_record" | jq -r .headRefName)
|
|
remote_is_cross_repository=$(printf '%s\n' "$remote_record" | jq -r .isCrossRepository)
|
|
if [ "$remote_head" != "$current_head" ]; then
|
|
echo "PR head changed before hosted gate verification (expected $current_head, got $remote_head). Re-run prepare-init."
|
|
return 1
|
|
fi
|
|
# A docs-only final commit may reuse its immutable parent; this covers
|
|
# release-owned cleanup without inferring PR identity from mutable branches.
|
|
if [ -z "$recent_sha" ]; then
|
|
local parent_sha
|
|
local parent_delta
|
|
if parent_sha=$(pr_git rev-parse "${current_head}^" 2>/dev/null) &&
|
|
parent_delta=$(pr_git diff --name-only "$parent_sha" "$current_head" 2>/dev/null) &&
|
|
file_list_is_docsish_only "$parent_delta"; then
|
|
recent_sha="$parent_sha"
|
|
fi
|
|
fi
|
|
|
|
local repo
|
|
repo=$(printf '%s\n' "$remote_record" | jq -er '.baseRepository.nameWithOwner | select(type == "string" and length > 0)') || return 1
|
|
local scripts_dir="${script_parent_dir:-}"
|
|
if [ -z "$scripts_dir" ]; then
|
|
scripts_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
fi
|
|
# A directory argv[1] keeps the imported module's standalone entrypoint inactive.
|
|
local args=(
|
|
"$scripts_dir"
|
|
--repo "$repo"
|
|
--sha "$current_head"
|
|
--pr "$pr"
|
|
--main-sha "$PR_MAIN_SHA"
|
|
--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
|
|
if printf '%s\n' "$remote_record" | run_quiet_logged "hosted CI/Testbox gates" ".local/gates-hosted-checks.log" \
|
|
node --input-type=module -e '
|
|
import { readFileSync } from "node:fs";
|
|
import { pathToFileURL } from "node:url";
|
|
const { main } = await import(pathToFileURL(process.argv[1] + "/verify-pr-hosted-gates.mts").href);
|
|
main(process.argv.slice(2), JSON.parse(readFileSync(0, "utf8")));
|
|
' "${args[@]}"; then
|
|
local reused_head
|
|
reused_head=$(jq -er '.reusedFromSha // "" | strings' .local/gates-hosted-checks.json) || return 1
|
|
if [ -n "$reused_head" ]; then
|
|
# Compare only main context incorporated into the candidate, not later main drift.
|
|
local reused_base current_base
|
|
reused_base=$(pr_git merge-base "$PR_MAIN_SHA" "$reused_head") || return 1
|
|
current_base=$(pr_git merge-base "$PR_MAIN_SHA" "$current_head") || return 1
|
|
if mainline_drift_requires_sync "$reused_base" "$reused_head" "$current_base"; then
|
|
echo "Hosted CI reuse declined: candidate incorporated relevant main changes; require successful CI for $current_head."
|
|
return 1
|
|
else
|
|
local drift_status=$?
|
|
if [ "$drift_status" -ne 1 ]; then
|
|
echo "Hosted CI reuse failed: unable to evaluate mainline input changes." >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if rg -F -q "Missing successful recent CI workflow for $current_head. Observed: none" \
|
|
.local/gates-hosted-checks.log
|
|
then
|
|
if [ "$remote_is_cross_repository" = "true" ]; then
|
|
cat <<EOF_RECOVERY
|
|
Missing hosted CI recovery:
|
|
scripts/pr ci-dispatch $pr
|
|
unavailable: PR #$pr comes from a fork, and release-gate dispatch requires the exact target SHA on a base-repository branch.
|
|
EOF_RECOVERY
|
|
return 1
|
|
fi
|
|
cat <<EOF_RECOVERY
|
|
Missing hosted CI recovery:
|
|
scripts/pr ci-dispatch $pr
|
|
Underlying command:
|
|
EOF_RECOVERY
|
|
printf ' gh workflow run ci.yml --ref %q -f %q -f release_gate=true -f %q\n' \
|
|
"$remote_head_ref" \
|
|
"target_ref=$remote_head" \
|
|
"pull_request_number=$pr"
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
ci_dispatch() {
|
|
local pr="$1"
|
|
shift
|
|
local record base_sha head_ref head_sha is_cross_repository
|
|
record=$(pr_gh pr view "$pr" --json baseRefOid,headRefName,headRefOid,isCrossRepository) || return 1
|
|
base_sha=$(printf '%s\n' "$record" | jq -r .baseRefOid)
|
|
head_ref=$(printf '%s\n' "$record" | jq -r .headRefName)
|
|
head_sha=$(printf '%s\n' "$record" | jq -r .headRefOid)
|
|
is_cross_repository=$(printf '%s\n' "$record" | jq -r .isCrossRepository)
|
|
if [ -z "$head_ref" ] || [ "$head_ref" = "null" ] || [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
|
|
echo "PR #$pr is missing remote headRefName/headRefOid metadata." >&2
|
|
return 1
|
|
fi
|
|
if [ "$is_cross_repository" = "true" ]; then
|
|
echo "PR #$pr comes from a fork; release-gate workflow dispatch requires a base-repository branch at $head_sha." >&2
|
|
return 1
|
|
fi
|
|
if [ "$is_cross_repository" != "false" ]; then
|
|
echo "PR #$pr is missing repository identity for workflow dispatch." >&2
|
|
return 1
|
|
fi
|
|
|
|
mark_pr_operation_side_effects_if_available
|
|
node "$script_parent_dir/pr-lib/ci-dispatch.mjs" \
|
|
"$pr" "$head_ref" "$head_sha" "$base_sha" false "$@"
|
|
}
|
|
|
|
mark_pr_operation_side_effects_if_available() {
|
|
# scripts/pr sources operation-lock.sh first. Policy tests may source this
|
|
# library alone, where advancing a lock phase is neither possible nor needed.
|
|
if declare -F mark_pr_operation_side_effects_started >/dev/null; then
|
|
mark_pr_operation_side_effects_started
|
|
fi
|
|
}
|
|
|
|
pin_worktree_bundled_plugins_dir() {
|
|
# Nested .worktrees/<pr> checkouts resolve vitest tooling from the primary
|
|
# checkout's node_modules; pin bundled plugin discovery to this worktree so
|
|
# PR branches without the openclaw-root node_modules-boundary fix still test
|
|
# their own extensions instead of the primary checkout's stale trees.
|
|
export OPENCLAW_BUNDLED_PLUGINS_DIR="${OPENCLAW_BUNDLED_PLUGINS_DIR:-$PWD/extensions}"
|
|
}
|
|
|
|
resolve_pr_gates_remote_mode() {
|
|
case "${OPENCLAW_PR_GATES_REMOTE:-}" in
|
|
"")
|
|
printf 'local\n'
|
|
;;
|
|
testbox|crabbox-aws|github)
|
|
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
|
echo "OPENCLAW_PR_GATES_REMOTE=$OPENCLAW_PR_GATES_REMOTE conflicts with OPENCLAW_TESTBOX=1; select one gate mode." >&2
|
|
echo "Unset OPENCLAW_TESTBOX to use $OPENCLAW_PR_GATES_REMOTE gates, or unset OPENCLAW_PR_GATES_REMOTE to use completed hosted proof." >&2
|
|
return 2
|
|
fi
|
|
printf '%s\n' "$OPENCLAW_PR_GATES_REMOTE"
|
|
;;
|
|
*)
|
|
echo "Unsupported OPENCLAW_PR_GATES_REMOTE=${OPENCLAW_PR_GATES_REMOTE} (supported: testbox, crabbox-aws, github)." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
prepare_local_gate_workspace() {
|
|
pin_worktree_bundled_plugins_dir
|
|
bootstrap_deps_if_needed
|
|
}
|
|
|
|
run_remote_testbox_full_test_gate() {
|
|
local label="$1"
|
|
local log_file="$2"
|
|
local lease_label="$3"
|
|
local remote_env=(CI=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false)
|
|
local name value
|
|
# Delegated Testbox commands do not inherit the caller's scheduling controls.
|
|
for name in OPENCLAW_TEST_PROJECTS_PARALLEL OPENCLAW_VITEST_MAX_WORKERS; do
|
|
[ -n "${!name:-}" ] || continue
|
|
value=$(node --input-type=module -e '
|
|
import { pathToFileURL } from "node:url";
|
|
const { parsePositiveInt } = await import(pathToFileURL(process.argv[1] + "/lib/numeric-options.mjs").href);
|
|
const value = process.argv[2].trim();
|
|
if (value) {
|
|
try { console.log(parsePositiveInt(value, process.argv[3])); }
|
|
catch (error) { console.error(error.message); process.exitCode = 2; }
|
|
}
|
|
' "$script_parent_dir" "${!name}" "$name") || return 2
|
|
[ -z "$value" ] || remote_env+=("$name=$value")
|
|
done
|
|
# Same Blacksmith Testbox delegation shape check:changed uses; the worktree's
|
|
# own wrapper syncs this prep tree (the canonical copy would sync the primary
|
|
# checkout instead).
|
|
run_quiet_logged "$label" "$log_file" \
|
|
node scripts/crabbox-wrapper.mjs run \
|
|
--provider blacksmith-testbox \
|
|
--blacksmith-org openclaw \
|
|
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
|
|
--blacksmith-job check \
|
|
--blacksmith-ref main \
|
|
--idle-timeout 90m \
|
|
--ttl 240m \
|
|
--timing-json \
|
|
--label "$lease_label" \
|
|
-- env "${remote_env[@]}" corepack pnpm test
|
|
}
|
|
|
|
read_remote_testbox_gate_stamp() {
|
|
# crabbox --timing-json emits one single-line JSON report on stderr; pick the
|
|
# last successful blacksmith-testbox report in the gate log as the stamp.
|
|
local log_file="$1"
|
|
jq -c -R '
|
|
fromjson?
|
|
| select(type == "object")
|
|
| select(.provider == "blacksmith-testbox" and .exitCode == 0 and ((.leaseId // "") | startswith("tbx_")))
|
|
' "$log_file" | tail -n 1
|
|
}
|
|
|
|
read_remote_testbox_gate_run_url() {
|
|
# The delegated timing report currently omits actionsRunUrl, while the same
|
|
# run prints the canonical Actions URL as a separate line.
|
|
local log_file="$1"
|
|
local pr_url="${PR_URL:-}"
|
|
local expected_repo="${pr_url#https://github.com/}"
|
|
expected_repo="${expected_repo%%/pull/*}"
|
|
if [ -z "$expected_repo" ] || [ "$expected_repo" = "$pr_url" ]; then
|
|
expected_repo="openclaw/openclaw"
|
|
fi
|
|
local url_prefix="https://github.com/$expected_repo/actions/runs/"
|
|
local marker="GitHub Actions run: $url_prefix"
|
|
awk -v marker="$marker" -v url_prefix="$url_prefix" '
|
|
index($0, marker) {
|
|
suffix = substr($0, index($0, marker) + length(marker))
|
|
if (match(suffix, /^[0-9]+/)) {
|
|
print url_prefix substr(suffix, RSTART, RLENGTH)
|
|
exit
|
|
}
|
|
}
|
|
' "$log_file"
|
|
}
|
|
|
|
require_remote_testbox_gate_stamp() {
|
|
# Runs inside $(...): report to stderr and fail the substitution so set -e
|
|
# aborts the caller with the message visible.
|
|
local log_file="$1"
|
|
local stamp
|
|
stamp=$(read_remote_testbox_gate_stamp "$log_file")
|
|
if [ -z "$stamp" ]; then
|
|
echo "Remote testbox gate passed but no successful blacksmith-testbox timing stamp was found in $log_file." >&2
|
|
return 1
|
|
fi
|
|
local actions_run_url
|
|
actions_run_url=$(read_remote_testbox_gate_run_url "$log_file")
|
|
if [ -n "$actions_run_url" ] && [ "$(printf '%s\n' "$stamp" | jq -r '.actionsRunUrl // empty')" = "" ]; then
|
|
stamp=$(printf '%s\n' "$stamp" | jq -c --arg actionsRunUrl "$actions_run_url" '. + {actionsRunUrl: $actionsRunUrl}')
|
|
fi
|
|
printf '%s\n' "$stamp"
|
|
}
|
|
|
|
require_active_org_admin_for_crabbox_gate() {
|
|
local actor membership
|
|
actor=$(pr_gh_writer_login) || return
|
|
membership=$(pr_gh_plain api "orgs/openclaw/memberships/$actor" -H 'Cache-Control: max-age=0') || return
|
|
if [ "$(printf '%s\n' "$membership" | jq -r .state)" != "active" ] ||
|
|
[ "$(printf '%s\n' "$membership" | jq -r .role)" != "admin" ]; then
|
|
echo "OPENCLAW_PR_GATES_REMOTE=crabbox-aws requires an active openclaw organization admin." >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$actor"
|
|
}
|
|
|
|
read_crabbox_gate_pr_binding() {
|
|
local pr="$1"
|
|
local expected_head="$2"
|
|
local expected_base="${3:-}"
|
|
local record
|
|
record=$(pr_gh pr view "$pr" --json baseRefName,baseRefOid,headRefOid,isCrossRepository,state) || return 1
|
|
if [ "$(printf '%s\n' "$record" | jq -r .state)" != "OPEN" ] ||
|
|
[ "$(printf '%s\n' "$record" | jq -r .isCrossRepository)" != "false" ] ||
|
|
[ "$(printf '%s\n' "$record" | jq -r .baseRefName)" != "main" ] ||
|
|
[ "$(printf '%s\n' "$record" | jq -r .headRefOid)" != "$expected_head" ]; then
|
|
echo "Crabbox AWS gate requires the requested open same-repository PR at exact head $expected_head." >&2
|
|
return 1
|
|
fi
|
|
local base_sha
|
|
base_sha=$(printf '%s\n' "$record" | jq -r .baseRefOid)
|
|
if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]] ||
|
|
{ [ -n "$expected_base" ] && [ "$base_sha" != "$expected_base" ]; }; then
|
|
echo "Crabbox AWS gate PR base changed or is malformed." >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$base_sha"
|
|
}
|
|
|
|
finalize_remote_crabbox_aws_gate() {
|
|
local pr="$1"
|
|
local head_sha="$2" resume_run="${3:-}"
|
|
local base_sha log_file stamp run_id lease_id run_url
|
|
local dispatch_args=(--backend crabbox --pending-gates)
|
|
base_sha=$(read_crabbox_gate_pr_binding "$pr" "$head_sha") || return 1
|
|
if [ -n "$resume_run" ]; then
|
|
# Preparation already resolved the retained immutable base; never reread a
|
|
# moving base or take one from the observed check as recovery authority.
|
|
base_sha="${4:-}"
|
|
[[ "$base_sha" =~ ^[0-9a-f]{40}$ ]] || return 1
|
|
dispatch_args+=(--resume-crabbox-run "$resume_run")
|
|
fi
|
|
require_active_org_admin_for_crabbox_gate >/dev/null || return 1
|
|
if [ "${LAST_VERIFIED_HEAD_SHA:-}" != "$head_sha" ]; then
|
|
[ -z "${PENDING_CRABBOX_STATE:-}" ] || return 1
|
|
# The publication owner has verified this hosted alias against the local
|
|
# reviewed tree. Pending provenance must follow its actual public OID.
|
|
write_gates_env_stamp "$pr" "${DOCS_ONLY:-false}" "${CHANGELOG_REQUIRED:-false}" \
|
|
remote_crabbox_aws_pending "$head_sha" "" "" aws "" "" "" || return 1
|
|
fi
|
|
log_file=".local/gates-crabbox-aws.log"
|
|
run_quiet_logged "protected-main Crabbox AWS exact-head gate" "$log_file" \
|
|
node "$script_parent_dir/pr-lib/ci-dispatch.mjs" \
|
|
"$pr" "$PR_HEAD" "$head_sha" "$base_sha" false "${dispatch_args[@]}" || return 1
|
|
stamp=$(jq -c -R \
|
|
--arg baseSha "$base_sha" \
|
|
--arg headSha "$head_sha" '
|
|
fromjson?
|
|
| select(
|
|
.backend == "crabbox"
|
|
and .provider == "aws"
|
|
and .target == "linux"
|
|
and .baseSha == $baseSha
|
|
and .headSha == $headSha
|
|
and ((.runId // "") | startswith("run_"))
|
|
and ((.leaseId // "") | startswith("cbx_"))
|
|
and ((.actionsRunUrl // "") | startswith("https://github.com/openclaw/openclaw/actions/runs/"))
|
|
)
|
|
' "$log_file" | tail -n 1)
|
|
if [ -z "$stamp" ]; then
|
|
echo "Protected-main Crabbox publisher passed without trusted exact-head metadata." >&2
|
|
return 1
|
|
fi
|
|
# Main may advance while a protected run is executing. Its immutable base is
|
|
# verified by the check; admission still requires this open PR's exact head.
|
|
read_crabbox_gate_pr_binding "$pr" "$head_sha" >/dev/null || return 1
|
|
if declare -F verify_correction_publication_authority >/dev/null; then
|
|
verify_correction_publication_authority || return 1
|
|
fi
|
|
run_id=$(printf '%s\n' "$stamp" | jq -r .runId)
|
|
lease_id=$(printf '%s\n' "$stamp" | jq -r .leaseId)
|
|
run_url=$(printf '%s\n' "$stamp" | jq -r .actionsRunUrl)
|
|
write_gates_env_stamp \
|
|
"$pr" \
|
|
"${DOCS_ONLY:-false}" \
|
|
"${CHANGELOG_REQUIRED:-false}" \
|
|
"remote_crabbox_aws" \
|
|
"$head_sha" \
|
|
"$head_sha" \
|
|
"" \
|
|
"aws" \
|
|
"$run_id" \
|
|
"$lease_id" \
|
|
"$run_url" \
|
|
"$base_sha" \
|
|
"$(printf '%s\n' "$stamp" | jq -r .workflowSha)" \
|
|
"$(printf '%s\n' "$stamp" | jq -r .actionsRunAttempt)"
|
|
}
|
|
|
|
write_gates_env_stamp() {
|
|
local pr="$1"
|
|
local docs_only="$2"
|
|
local changelog_required="$3"
|
|
local gates_mode="$4"
|
|
local last_verified_head="$5"
|
|
local full_gates_head="$6"
|
|
local hosted_gates_head="$7"
|
|
local remote_provider="$8"
|
|
local remote_run_id="$9"
|
|
local remote_lease_id="${10}"
|
|
local remote_run_url="${11}"
|
|
local remote_base_sha="${12:-}" remote_workflow_sha="${13:-}" remote_attempt="${14:-}"
|
|
|
|
local temporary
|
|
temporary=$(mktemp .local/gates.env.XXXXXX) || return 1
|
|
# Shell-escape values; chain every write so a skipped optional block cannot
|
|
# mask an earlier failure and publish a partial receipt.
|
|
{
|
|
printf '%s=%q\n' \
|
|
PR_NUMBER "$pr" \
|
|
DOCS_ONLY "$docs_only" \
|
|
CHANGELOG_REQUIRED "$changelog_required" \
|
|
GATES_MODE "$gates_mode" \
|
|
HOSTED_GATES_TARGET_HEAD_SHA "$hosted_gates_head" &&
|
|
if [ "$gates_mode" != github_pending ]; then
|
|
printf '%s=%q\n' \
|
|
LAST_VERIFIED_HEAD_SHA "$last_verified_head" \
|
|
FULL_GATES_HEAD_SHA "$full_gates_head" \
|
|
REMOTE_GATES_PROVIDER "$remote_provider" \
|
|
REMOTE_GATES_RUN_ID "$remote_run_id" \
|
|
REMOTE_GATES_LEASE_ID "$remote_lease_id" \
|
|
REMOTE_GATES_RUN_URL "$remote_run_url" \
|
|
GATES_PASSED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
fi &&
|
|
if [ "$gates_mode" = remote_crabbox_aws ] && [ -n "$remote_base_sha" ]; then
|
|
printf '%s=%q\n' \
|
|
REMOTE_GATES_BASE_SHA "$remote_base_sha" \
|
|
REMOTE_GATES_WORKFLOW_SHA "$remote_workflow_sha" \
|
|
REMOTE_GATES_ACTIONS_RUN_ATTEMPT "$remote_attempt"
|
|
fi
|
|
} > "$temporary" || { rm -f "$temporary"; return 1; }
|
|
mv -f "$temporary" .local/gates.env || { rm -f "$temporary"; return 1; }
|
|
}
|
|
|
|
# Correction publication requires the native gate owner's exact candidate
|
|
# stamp. An explicit pending Crabbox stamp is admission to its protected-main
|
|
# publisher, not proof; retain that separately authorized route.
|
|
require_correction_publication_gates() (
|
|
local pr="$1" head="$2" allow_pending="${3:-false}"
|
|
local PR_NUMBER="" LAST_VERIFIED_HEAD_SHA="" FULL_GATES_HEAD_SHA=""
|
|
local GATES_MODE="" HOSTED_GATES_TARGET_HEAD_SHA="" DOCS_ONLY=""
|
|
local REMOTE_GATES_PROVIDER="" REMOTE_GATES_RUN_ID="" REMOTE_GATES_LEASE_ID="" REMOTE_GATES_RUN_URL=""
|
|
require_artifact .local/gates.env || return 1
|
|
source .local/gates.env || return 1
|
|
local qualified_head="$LAST_VERIFIED_HEAD_SHA"
|
|
[ "$PR_NUMBER" = "$pr" ] || return 1
|
|
if [ "$qualified_head" != "$head" ]; then
|
|
# GraphQL can assign a hosted OID for the identical reviewed local tree.
|
|
# The verified publication can precede the completed preparation stamp.
|
|
local PR_HEAD="" PR_HEAD_SHA_BEFORE=""
|
|
local PREP_PUBLICATION_LEASE_SHA="" PREP_PUBLICATION_HEAD_SHA=""
|
|
PR_NUMBER=""
|
|
require_artifact .local/prep-context.env || return 1
|
|
source .local/prep-context.env || return 1
|
|
resolve_prep_publication_target "$pr" "$head" || return 1
|
|
[ "$PREP_PUBLICATION_HEAD_SHA" = "$qualified_head" ] || {
|
|
echo "Correction publication requires gates for the exact reviewed candidate." >&2
|
|
return 1
|
|
}
|
|
fi
|
|
case "$GATES_MODE" in
|
|
full) [ "$FULL_GATES_HEAD_SHA" = "$qualified_head" ] || return 1 ;;
|
|
docs_only|reused_docs_only) [ "$DOCS_ONLY" = true ] || return 1 ;;
|
|
hosted_exact_or_recent_parent) [ "$HOSTED_GATES_TARGET_HEAD_SHA" = "$qualified_head" ] || return 1 ;;
|
|
remote_testbox)
|
|
[ "$FULL_GATES_HEAD_SHA" = "$qualified_head" ] &&
|
|
[ "$REMOTE_GATES_PROVIDER" = blacksmith-testbox ] &&
|
|
[[ "$REMOTE_GATES_LEASE_ID" == tbx_* ]] || return 1
|
|
;;
|
|
remote_crabbox_aws)
|
|
[ "$FULL_GATES_HEAD_SHA" = "$qualified_head" ] &&
|
|
[ "$REMOTE_GATES_PROVIDER" = aws ] &&
|
|
[[ "$REMOTE_GATES_RUN_ID" == run_* ]] && [[ "$REMOTE_GATES_LEASE_ID" == cbx_* ]] &&
|
|
[[ "$REMOTE_GATES_RUN_URL" == https://github.com/openclaw/openclaw/actions/runs/* ]] || return 1
|
|
;;
|
|
remote_crabbox_aws_pending)
|
|
[ "$allow_pending" = true ] && [ "$REMOTE_GATES_PROVIDER" = aws ] || return 1
|
|
require_active_org_admin_for_crabbox_gate >/dev/null || return 1
|
|
# The candidate is not hosted yet. Bind eligibility to the publication
|
|
# lease, then let the existing publisher verify the newly hosted head.
|
|
[ -n "${PREP_PUBLICATION_LEASE_SHA:-}" ] || return 1
|
|
read_crabbox_gate_pr_binding "$pr" "$PREP_PUBLICATION_LEASE_SHA" >/dev/null || return 1
|
|
;;
|
|
*) echo "Unrecognized correction gate mode: $GATES_MODE" >&2; return 1 ;;
|
|
esac
|
|
)
|
|
|
|
derive_prepare_gate_change_plan() {
|
|
PREPARE_GATE_BASE_SHA=$(pr_git merge-base "$PR_MAIN_SHA" "${1:-HEAD}") || return 1
|
|
PREPARE_GATE_CHANGED_FILES=$(pr_git diff --name-only "$PREPARE_GATE_BASE_SHA" "${1:-HEAD}") || return 1
|
|
PREPARE_GATE_DOCS_ONLY=false
|
|
if file_list_is_docsish_only "$PREPARE_GATE_CHANGED_FILES"; then
|
|
PREPARE_GATE_DOCS_ONLY=true
|
|
fi
|
|
PREPARE_GATE_CHANGELOG_ONLY=false
|
|
local changelog_mode
|
|
changelog_mode=$(release_changelog_file_list_mode "$PREPARE_GATE_CHANGED_FILES") || return 1
|
|
PREPARE_GATE_CHANGELOG_UPDATE=false
|
|
if [ "$changelog_mode" != "none" ]; then
|
|
PREPARE_GATE_CHANGELOG_UPDATE=true
|
|
fi
|
|
if [ "$changelog_mode" = "only" ]; then
|
|
PREPARE_GATE_CHANGELOG_ONLY=true
|
|
fi
|
|
PREPARE_GATE_CHANGELOG_REQUIRED=false
|
|
if changelog_required_for_changed_files "$PREPARE_GATE_CHANGED_FILES"; then
|
|
PREPARE_GATE_CHANGELOG_REQUIRED=true
|
|
fi
|
|
}
|
|
|
|
prepare_gates() {
|
|
local pr="$1"
|
|
local remote_record="${2:-}"
|
|
local gates_remote_mode
|
|
gates_remote_mode=$(resolve_pr_gates_remote_mode) || return $?
|
|
|
|
PR_MAIN_SHA=""
|
|
enter_worktree "$pr" false || return 1
|
|
|
|
mark_pr_operation_side_effects_if_available
|
|
refresh_prep_branch_for_reviewed_head "$pr"
|
|
checkout_prep_branch "$pr"
|
|
require_artifact .local/pr-meta.env
|
|
# shellcheck disable=SC1091
|
|
source .local/pr-meta.env
|
|
|
|
require_prepared_review "$pr" || return 1
|
|
local current_head
|
|
current_head=$(pr_git rev-parse HEAD) || return 1
|
|
derive_prepare_gate_change_plan "$current_head" || return 1
|
|
local check_base="$PREPARE_GATE_BASE_SHA"
|
|
local changed_files="$PREPARE_GATE_CHANGED_FILES"
|
|
local docs_only="$PREPARE_GATE_DOCS_ONLY"
|
|
local changelog_only="$PREPARE_GATE_CHANGELOG_ONLY"
|
|
local changelog_required="$PREPARE_GATE_CHANGELOG_REQUIRED"
|
|
|
|
local has_changelog_update="$PREPARE_GATE_CHANGELOG_UPDATE"
|
|
local unsupported_changelog_fragments=""
|
|
local changed_path
|
|
while [ -n "$changed_files" ]; do
|
|
changed_path="${changed_files%%$'\n'*}"
|
|
if [ "$changed_path" = "$changed_files" ]; then
|
|
changed_files=""
|
|
else
|
|
changed_files="${changed_files#*$'\n'}"
|
|
fi
|
|
[ -n "$changed_path" ] || continue
|
|
case "$changed_path" in
|
|
changelog/fragments/*)
|
|
unsupported_changelog_fragments="${unsupported_changelog_fragments}${changed_path}"$'\n'
|
|
;;
|
|
esac
|
|
done
|
|
if [ -n "$unsupported_changelog_fragments" ]; then
|
|
echo "Unsupported changelog fragment files detected:"
|
|
printf '%s\n' "$unsupported_changelog_fragments"
|
|
echo "Move release-note context into the PR body or commit message and remove changelog/fragments files."
|
|
exit 1
|
|
fi
|
|
|
|
local changelog_mode
|
|
if [ "$has_changelog_update" = "true" ]; then
|
|
[ -n "$remote_record" ] || remote_record=$(read_pr_observation "$pr") || return 1
|
|
if ! changelog_mode=$(root_changelog_update_allowed_for_pr "$remote_record"); then
|
|
echo "CHANGELOG.md is release-owned, along with CHANGELOG/<version>.md and matching records; normal PRs should put release-note context in the PR body or commit message."
|
|
echo "Use release/<version>-main-closeout with the documented title and only that origin-tagged release's artifacts and necessary index update, or set OPENCLAW_ALLOW_ROOT_CHANGELOG_PR=1 for explicit release automation."
|
|
exit 1
|
|
fi
|
|
# Release artifacts retain their approved text, including historical PR references.
|
|
validate_changelog_attribution_policy
|
|
fi
|
|
|
|
if [ "$changelog_required" = "true" ]; then
|
|
local contrib="${PR_AUTHOR:-}"
|
|
validate_changelog_merge_hygiene
|
|
validate_changelog_entry_for_pr "$pr" "$contrib"
|
|
else
|
|
echo "Changelog not required for this changed-file set."
|
|
fi
|
|
|
|
local previous_last_verified_head=""
|
|
local previous_full_gates_head=""
|
|
local remote_gates_provider=""
|
|
local remote_gates_run_id=""
|
|
local remote_gates_lease_id=""
|
|
local remote_gates_run_url=""
|
|
if [ "$gates_remote_mode" != github ] && [ -s .local/gates.env ]; then
|
|
# shellcheck disable=SC1091
|
|
source .local/gates.env
|
|
previous_last_verified_head="${LAST_VERIFIED_HEAD_SHA:-}"
|
|
previous_full_gates_head="${FULL_GATES_HEAD_SHA:-}"
|
|
# Carried alongside FULL_GATES_HEAD_SHA: they describe how that exact-head
|
|
# proof was produced; a fresh gate run below overwrites them.
|
|
remote_gates_provider="${REMOTE_GATES_PROVIDER:-}"
|
|
remote_gates_run_id="${REMOTE_GATES_RUN_ID:-}"
|
|
remote_gates_lease_id="${REMOTE_GATES_LEASE_ID:-}"
|
|
remote_gates_run_url="${REMOTE_GATES_RUN_URL:-}"
|
|
fi
|
|
|
|
local gates_mode="full"
|
|
local hosted_gates_head=""
|
|
local reuse_gates=false
|
|
if [ "${OPENCLAW_TESTBOX:-}" != "1" ] && [ "$docs_only" = "true" ] && [ -n "$previous_last_verified_head" ] && pr_git merge-base --is-ancestor "$previous_last_verified_head" HEAD 2>/dev/null; then
|
|
local delta_since_verified
|
|
delta_since_verified=$(pr_git diff --name-only "$previous_last_verified_head"..HEAD)
|
|
if [ -z "$delta_since_verified" ] || file_list_is_docsish_only "$delta_since_verified"; then
|
|
reuse_gates=true
|
|
fi
|
|
fi
|
|
|
|
if [ "$gates_remote_mode" = github ]; then
|
|
gates_mode=github_pending
|
|
hosted_gates_head="$current_head"
|
|
echo "Required GitHub gates deferred for $current_head; no successful gate proof recorded."
|
|
elif [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
|
gates_mode="hosted_exact_or_recent_parent"
|
|
remote_gates_provider=""
|
|
remote_gates_run_id=""
|
|
remote_gates_lease_id=""
|
|
remote_gates_run_url=""
|
|
if [ "$changelog_only" = "true" ]; then
|
|
run_quiet_logged "git diff --check" ".local/gates-diff-check.log" pr_git diff --check "$PR_MAIN_SHA...HEAD"
|
|
fi
|
|
run_hosted_prepare_gates "$pr" "$current_head" "$changelog_only" "$remote_record" || return 1
|
|
hosted_gates_head="$current_head"
|
|
elif [ "$reuse_gates" = "true" ]; then
|
|
gates_mode="reused_docs_only"
|
|
echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates."
|
|
elif [ "$gates_remote_mode" = "crabbox-aws" ]; then
|
|
require_active_org_admin_for_crabbox_gate >/dev/null
|
|
gates_mode="remote_crabbox_aws_pending"
|
|
previous_full_gates_head=""
|
|
remote_gates_provider="aws"
|
|
remote_gates_run_id=""
|
|
remote_gates_lease_id=""
|
|
remote_gates_run_url=""
|
|
echo "Crabbox AWS proof is deferred until prepare-push verifies the exact remote head."
|
|
else
|
|
prepare_local_gate_workspace
|
|
local build_command=(pnpm build)
|
|
if [ "$gates_remote_mode" = local ] && [ "$docs_only" != true ]; then
|
|
# Prepare the full suite's artifacts without changing check/test runtime inputs.
|
|
build_command=(env OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build)
|
|
fi
|
|
run_quiet_logged "pnpm build" ".local/gates-build.log" "${build_command[@]}" || return $?
|
|
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check --base "$check_base"
|
|
|
|
if [ "$docs_only" = "true" ]; then
|
|
gates_mode="docs_only"
|
|
previous_full_gates_head=""
|
|
remote_gates_provider=""
|
|
remote_gates_run_id=""
|
|
remote_gates_lease_id=""
|
|
remote_gates_run_url=""
|
|
echo "Docs-only change detected with high confidence; skipping pnpm test."
|
|
elif [ "$gates_remote_mode" = "testbox" ]; then
|
|
gates_mode="remote_testbox"
|
|
echo "Running pnpm test on Blacksmith Testbox (OPENCLAW_PR_GATES_REMOTE=testbox)."
|
|
run_remote_testbox_full_test_gate \
|
|
"pnpm test (blacksmith-testbox)" \
|
|
".local/gates-test.log" \
|
|
"pr-$pr-gates"
|
|
local remote_stamp
|
|
remote_stamp=$(require_remote_testbox_gate_stamp ".local/gates-test.log")
|
|
remote_gates_provider="blacksmith-testbox"
|
|
remote_gates_run_id=""
|
|
remote_gates_lease_id=$(printf '%s\n' "$remote_stamp" | jq -r '.leaseId')
|
|
remote_gates_run_url=$(printf '%s\n' "$remote_stamp" | jq -r '.actionsRunUrl // ""')
|
|
echo "Remote testbox gate stamp: $remote_gates_lease_id${remote_gates_run_url:+ ($remote_gates_run_url)}"
|
|
previous_full_gates_head="$current_head"
|
|
else
|
|
gates_mode="full"
|
|
if [ -n "${OPENCLAW_VITEST_MAX_WORKERS:-}" ]; then
|
|
echo "Running pnpm test with OPENCLAW_VITEST_MAX_WORKERS=$OPENCLAW_VITEST_MAX_WORKERS."
|
|
run_quiet_logged \
|
|
"pnpm test" \
|
|
".local/gates-test.log" \
|
|
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" pnpm test
|
|
else
|
|
echo "Running pnpm test with host-aware scheduling defaults."
|
|
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
|
|
fi
|
|
remote_gates_provider=""
|
|
remote_gates_run_id=""
|
|
remote_gates_lease_id=""
|
|
remote_gates_run_url=""
|
|
previous_full_gates_head="$current_head"
|
|
fi
|
|
fi
|
|
|
|
require_prepared_review "$pr" || return 1
|
|
[ "$(pr_git rev-parse HEAD)" = "$current_head" ] || {
|
|
echo "Candidate changed while gates ran; no gate stamp written." >&2
|
|
return 1
|
|
}
|
|
write_gates_env_stamp \
|
|
"$pr" \
|
|
"$docs_only" \
|
|
"$changelog_required" \
|
|
"$gates_mode" \
|
|
"$current_head" \
|
|
"${previous_full_gates_head:-}" \
|
|
"$hosted_gates_head" \
|
|
"$remote_gates_provider" \
|
|
"$remote_gates_run_id" \
|
|
"$remote_gates_lease_id" \
|
|
"$remote_gates_run_url"
|
|
|
|
echo "docs_only=$docs_only"
|
|
echo "changelog_only=$changelog_only"
|
|
echo "changelog_required=$changelog_required"
|
|
echo "gates_mode=$gates_mode"
|
|
echo "wrote=.local/gates.env"
|
|
}
|