mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 07:12:54 +08:00
* feat(scripts): serialize pr prepare gates and add remote testbox test gate Concurrent scripts/pr gate runs across .worktrees queued on the shared heavy-check lock mid-test: the queued run's children hit the 10-minute lock timeout while its unlocked build stage piled CPU load onto the holder's vitest shards, which then stalled past the 120s no-output watchdog and were SIGTERMed with zero real test failures (observed landing PRs #99935/#100026 on a loaded maintainer Mac). - scripts/pr-gates-lock.mjs holds the shared heavy-check lock for the whole local gate block; gate stages inherit the existing *_LOCK_HELD child contract, so concurrent gate runs now queue as whole units before their first command. - OPENCLAW_PR_GATES_REMOTE=testbox runs the full-suite pnpm test gate on a Blacksmith Testbox via scripts/crabbox-wrapper.mjs (same delegation as check:changed). The tbx_ lease id and Actions run URL from the crabbox --timing-json report are recorded in .local/gates.env (REMOTE_GATES_*) and .local/prep.md. Local remains the default; pnpm build/check stay local. Formatting verified with the primary checkout's oxfmt (hook bypassed: linked worktree has no hydrated node_modules). * fix(scripts): refresh the gate stamp when lease-retry gates rerun for a rebased head The lease-retry path reran build/check/test for the rebased prep head but left .local/gates.env describing the pre-push head, so prep.md/prep.env attributed stale evidence (including the new remote testbox lease id) to the pushed commit. Extract write_gates_env_stamp as the single stamp writer, rewrite the stamp from the retry path for all modes, and re-source gates.env in prepare_push after the push settles. Found by autoreview (codex/gpt-5.5); formatting verified with the primary checkout's oxfmt (hook bypassed: linked worktree has no node_modules). * fix(scripts): harden remote PR gate evidence * fix(scripts): serialize complete gate setup * fix(scripts): clear stale docs gate proof
454 lines
16 KiB
Bash
454 lines
16 KiB
Bash
run_hosted_prepare_gates() {
|
|
local pr="$1"
|
|
local current_head="$2"
|
|
local changelog_only="$3"
|
|
local remote_head
|
|
remote_head=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
|
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
|
|
|
|
local repo
|
|
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
local args=(
|
|
scripts/verify-pr-hosted-gates.mjs
|
|
--repo "$repo"
|
|
--sha "$current_head"
|
|
--output ".local/gates-hosted-checks.json"
|
|
)
|
|
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[@]}"
|
|
}
|
|
|
|
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)
|
|
printf 'testbox\n'
|
|
;;
|
|
*)
|
|
echo "Unsupported OPENCLAW_PR_GATES_REMOTE=${OPENCLAW_PR_GATES_REMOTE} (supported: testbox)." >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
PR_GATES_LOCK_PID=""
|
|
PR_GATES_LOCK_STATUS_FILE=""
|
|
|
|
acquire_pr_gates_lock() {
|
|
# Serialize whole gate blocks across .worktrees on the shared heavy-check
|
|
# lock; a queued gate run waits here, before its first command, instead of
|
|
# dying on child lock timeouts or shard no-output watchdog kills mid-test.
|
|
if [ "${OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD:-}" = "1" ]; then
|
|
return 0
|
|
fi
|
|
|
|
PR_GATES_LOCK_STATUS_FILE=$(mktemp)
|
|
# Use the canonical helper: the PR branch under test may predate it.
|
|
local scripts_dir="${script_parent_dir:-}"
|
|
if [ -z "$scripts_dir" ]; then
|
|
scripts_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
fi
|
|
node "$scripts_dir/pr-gates-lock.mjs" --status-file "$PR_GATES_LOCK_STATUS_FILE" &
|
|
PR_GATES_LOCK_PID=$!
|
|
while [ ! -s "$PR_GATES_LOCK_STATUS_FILE" ]; do
|
|
if ! kill -0 "$PR_GATES_LOCK_PID" 2>/dev/null; then
|
|
wait "$PR_GATES_LOCK_PID" 2>/dev/null || true
|
|
PR_GATES_LOCK_PID=""
|
|
echo "Failed to acquire the shared local heavy-check lock for prepare gates."
|
|
exit 1
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
# Same held-lock contract check-changed uses for its children: gate stages
|
|
# must not re-acquire the lock the block holder already owns.
|
|
export OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1
|
|
export OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD=1
|
|
export OPENCLAW_OXLINT_SKIP_LOCK=1
|
|
}
|
|
|
|
prepare_local_gate_workspace() {
|
|
pin_worktree_bundled_plugins_dir
|
|
acquire_pr_gates_lock
|
|
bootstrap_deps_if_needed
|
|
}
|
|
|
|
release_pr_gates_lock() {
|
|
if [ -z "${PR_GATES_LOCK_PID:-}" ]; then
|
|
return 0
|
|
fi
|
|
kill "$PR_GATES_LOCK_PID" 2>/dev/null || true
|
|
wait "$PR_GATES_LOCK_PID" 2>/dev/null || true
|
|
PR_GATES_LOCK_PID=""
|
|
rm -f "$PR_GATES_LOCK_STATUS_FILE"
|
|
PR_GATES_LOCK_STATUS_FILE=""
|
|
unset OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD OPENCLAW_OXLINT_SKIP_LOCK
|
|
}
|
|
|
|
run_remote_testbox_full_test_gate() {
|
|
local label="$1"
|
|
local log_file="$2"
|
|
local lease_label="$3"
|
|
# 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 CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=install 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"
|
|
}
|
|
|
|
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_lease_id="$9"
|
|
local remote_run_url="${10}"
|
|
|
|
# Security: shell-escape values to prevent command injection when sourced.
|
|
printf '%s=%q\n' \
|
|
PR_NUMBER "$pr" \
|
|
DOCS_ONLY "$docs_only" \
|
|
CHANGELOG_REQUIRED "$changelog_required" \
|
|
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" \
|
|
REMOTE_GATES_PROVIDER "$remote_provider" \
|
|
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)" \
|
|
> .local/gates.env
|
|
}
|
|
|
|
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."
|
|
return 1
|
|
fi
|
|
|
|
local gates_remote_mode
|
|
gates_remote_mode=$(resolve_pr_gates_remote_mode)
|
|
|
|
prepare_local_gate_workspace
|
|
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
|
|
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
|
|
|
|
# The retry rebased the prep head, so the pre-push gates.env stamp no longer
|
|
# describes what these gates just verified; rewrite it for the new head so
|
|
# prep.md and prep.env do not attribute stale evidence to the pushed commit.
|
|
local retry_head
|
|
retry_head=$(git rev-parse HEAD)
|
|
local gates_mode="full"
|
|
local full_gates_head="$retry_head"
|
|
local remote_gates_provider=""
|
|
local remote_gates_lease_id=""
|
|
local remote_gates_run_url=""
|
|
|
|
if [ "$docs_only" = "true" ]; then
|
|
release_pr_gates_lock
|
|
gates_mode="docs_only"
|
|
# No test ran: carry the prior full-gates proof and how it was produced.
|
|
full_gates_head="${FULL_GATES_HEAD_SHA:-}"
|
|
remote_gates_provider="${REMOTE_GATES_PROVIDER:-}"
|
|
remote_gates_lease_id="${REMOTE_GATES_LEASE_ID:-}"
|
|
remote_gates_run_url="${REMOTE_GATES_RUN_URL:-}"
|
|
elif [ "$gates_remote_mode" = "testbox" ]; then
|
|
release_pr_gates_lock
|
|
gates_mode="remote_testbox"
|
|
run_remote_testbox_full_test_gate \
|
|
"pnpm test (lease-retry, blacksmith-testbox)" \
|
|
".local/lease-retry-test.log" \
|
|
"pr-${PR_NUMBER:-unknown}-gates-lease-retry"
|
|
local retry_stamp
|
|
retry_stamp=$(require_remote_testbox_gate_stamp ".local/lease-retry-test.log")
|
|
remote_gates_provider="blacksmith-testbox"
|
|
remote_gates_lease_id=$(printf '%s\n' "$retry_stamp" | jq -r '.leaseId')
|
|
remote_gates_run_url=$(printf '%s\n' "$retry_stamp" | jq -r '.actionsRunUrl // ""')
|
|
echo "Remote testbox lease-retry gate stamp: $remote_gates_lease_id${remote_gates_run_url:+ ($remote_gates_run_url)}"
|
|
else
|
|
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
|
|
release_pr_gates_lock
|
|
fi
|
|
|
|
write_gates_env_stamp \
|
|
"${PR_NUMBER:-}" \
|
|
"$docs_only" \
|
|
"${CHANGELOG_REQUIRED:-false}" \
|
|
"$gates_mode" \
|
|
"$retry_head" \
|
|
"$full_gates_head" \
|
|
"" \
|
|
"$remote_gates_provider" \
|
|
"$remote_gates_lease_id" \
|
|
"$remote_gates_run_url"
|
|
}
|
|
|
|
prepare_gates() {
|
|
local pr="$1"
|
|
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."
|
|
exit 2
|
|
fi
|
|
|
|
enter_worktree "$pr" false
|
|
|
|
checkout_prep_branch "$pr"
|
|
require_artifact .local/pr-meta.env
|
|
# shellcheck disable=SC1091
|
|
source .local/pr-meta.env
|
|
|
|
local changed_files
|
|
changed_files=$(git diff --name-only origin/main...HEAD)
|
|
local non_docs
|
|
non_docs=$(printf '%s\n' "$changed_files" | while IFS= read -r path; do
|
|
[ -n "$path" ] || continue
|
|
if ! path_is_docsish "$path"; then
|
|
printf '%s\n' "$path"
|
|
fi
|
|
done)
|
|
|
|
local docs_only=false
|
|
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
|
|
docs_only=true
|
|
fi
|
|
local changelog_only=false
|
|
if [ "$changed_files" = "CHANGELOG.md" ]; then
|
|
changelog_only=true
|
|
fi
|
|
|
|
local changelog_required=false
|
|
if changelog_required_for_changed_files "$changed_files"; then
|
|
changelog_required=true
|
|
fi
|
|
|
|
local has_changelog_update=false
|
|
local unsupported_changelog_fragments=""
|
|
local changed_path
|
|
while IFS= read -r changed_path; do
|
|
[ -n "$changed_path" ] || continue
|
|
case "$changed_path" in
|
|
CHANGELOG.md)
|
|
has_changelog_update=true
|
|
;;
|
|
changelog/fragments/*)
|
|
unsupported_changelog_fragments="${unsupported_changelog_fragments}${changed_path}"$'\n'
|
|
;;
|
|
esac
|
|
done <<<"$changed_files"
|
|
if [ -n "$unsupported_changelog_fragments" ]; then
|
|
echo "Unsupported changelog fragment files detected:"
|
|
printf '%s\n' "$unsupported_changelog_fragments"
|
|
echo "Move changelog fragment content into CHANGELOG.md and remove changelog/fragments files."
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$has_changelog_update" = "true" ]; then
|
|
normalize_pr_changelog_entries "$pr"
|
|
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 current_head
|
|
current_head=$(git rev-parse HEAD)
|
|
local previous_last_verified_head=""
|
|
local previous_full_gates_head=""
|
|
local remote_gates_provider=""
|
|
local remote_gates_lease_id=""
|
|
local remote_gates_run_url=""
|
|
if [ -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 full-suite
|
|
# proof was produced; a fresh full run below overwrites them.
|
|
remote_gates_provider="${REMOTE_GATES_PROVIDER:-}"
|
|
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" ] && git merge-base --is-ancestor "$previous_last_verified_head" HEAD 2>/dev/null; then
|
|
local delta_since_verified
|
|
delta_since_verified=$(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 [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
|
gates_mode="hosted_exact_head"
|
|
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"
|
|
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."
|
|
else
|
|
prepare_local_gate_workspace
|
|
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
|
|
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
|
|
|
|
if [ "$docs_only" = "true" ]; then
|
|
release_pr_gates_lock
|
|
gates_mode="docs_only"
|
|
previous_full_gates_head=""
|
|
remote_gates_provider=""
|
|
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
|
|
# The full suite runs on a Blacksmith Testbox, so free the local lock
|
|
# for other heavy work while we wait on remote proof.
|
|
release_pr_gates_lock
|
|
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_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
|
|
release_pr_gates_lock
|
|
remote_gates_provider=""
|
|
remote_gates_lease_id=""
|
|
remote_gates_run_url=""
|
|
previous_full_gates_head="$current_head"
|
|
fi
|
|
fi
|
|
|
|
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_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"
|
|
}
|