mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-23 07:28:22 +00:00
* fix(scripts): retire materialized PR anchors on every exit path Materialized wrappers were retired only after fully clean supervised exits, leaking code and dependency links on failures, signals, bootstrap exits, unsupervised commands, and failed execs. Keep creator ownership across the Bash and supervisor exec chain. Share FD/path/PID identity checks, retire drained operations independently of PR lock decisions, and restore cleanup after Bash 3.2 failed execs. Retain code for undrained operations and sweep at most four old owned anchors at startup using creator PIDs in new names. PID reuse conservatively delays sweeping. Legacy names have no liveness record and may be reclaimed after 24 hours even during a long operation. Drained cleanup accepts the same fd-closing escapee residual as clean exits; missing runtime or ownership evidence remains retain-only. Cover lifecycle exits, crash-retained recovery fixtures, dependency pinning, and bounded stale sweeping without changing lock recovery semantics. * docs(scripts): name the stale-anchor sweep residual A dead creator PID is the sweep's only evidence that an operation ended. Descendants that outlive a crashed or undrained supervisor by more than 24 hours can lose their anchor; their PR lock stays retained. Also replace the busy loop in the forwarded-SIGTERM supervisor test with sleep.
1372 lines
71 KiB
Bash
Executable file
1372 lines
71 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
|
|
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
|
|
exec /bin/bash "$0" "$@"
|
|
fi
|
|
|
|
set -euo pipefail
|
|
|
|
# Install creator cleanup before even the Git preflight or argument validation.
|
|
# Physical paths keep helper argv aligned with Node's canonical module URLs.
|
|
script_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
|
|
script_parent_dir="$(dirname "$script_self")"
|
|
if [ -n "${OPENCLAW_PR_ANCHOR_REPO_ROOT:-}" ] &&
|
|
[ "${OPENCLAW_PR_ANCHOR_CREATOR_PID:-}" = "$$" ] && [ "${OPENCLAW_PR_ANCHOR_FD:-}" = 9 ]; then
|
|
trap 'node "$script_parent_dir/pr-lib/materialized-anchor.mjs" retire "$script_self" || true' EXIT
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 131' QUIT
|
|
trap 'exit 143' TERM
|
|
fi
|
|
|
|
# This bootstrap stays inline: no local helper is trusted before code selection.
|
|
pr_git() { "${OPENCLAW_PR_GIT:-${GIT_EXEC:-git}}" "$@"; }
|
|
pr_run_bounded() (
|
|
set +e
|
|
# Separate jobs let the deadline reap a hung launcher and its children.
|
|
set -m
|
|
"$@" &
|
|
local tool_pid=$! timer_pid result
|
|
(/bin/sleep 10; kill -KILL -- "-$tool_pid" 2>/dev/null) &
|
|
timer_pid=$!
|
|
wait "$tool_pid" 2>/dev/null
|
|
result=$?
|
|
kill -KILL -- "-$timer_pid" 2>/dev/null
|
|
wait "$timer_pid" 2>/dev/null
|
|
exit "$result"
|
|
)
|
|
|
|
GIT_EXEC=$(command -v "${OPENCLAW_PR_GIT:-${GIT_EXEC:-git}}" 2>/dev/null || true)
|
|
if [ -n "$GIT_EXEC" ]; then
|
|
case "$GIT_EXEC" in /*) ;; *) GIT_EXEC="$PWD/$GIT_EXEC" ;; esac
|
|
fi
|
|
if [ -z "$GIT_EXEC" ] || ! pr_run_bounded "$GIT_EXEC" --version >/dev/null 2>&1; then
|
|
echo "scripts/pr git preflight failed or exceeded 10 seconds: ${GIT_EXEC:-${OPENCLAW_PR_GIT:-git (not found on PATH)}}" >&2
|
|
echo "xcode-select -p: $(pr_run_bounded xcode-select -p 2>/dev/null || printf 'unavailable')" >&2
|
|
echo "export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer or set OPENCLAW_PR_GIT" >&2
|
|
exit 1
|
|
fi
|
|
export GIT_EXEC
|
|
export OPENCLAW_PR_GIT="$GIT_EXEC"
|
|
|
|
# Older trusted wrappers use bare Git before reaching a newer supervisor.
|
|
# Keep this Git-only adapter across exec; OS temp reaping owns its lifetime.
|
|
# Matching inodes let reentry reuse it.
|
|
pr_path_git=$(type -P git 2>/dev/null || true)
|
|
case "$pr_path_git" in /*|"") ;; *) pr_path_git="$PWD/$pr_path_git" ;; esac
|
|
if [ -z "$pr_path_git" ] || [ ! "$pr_path_git" -ef "$GIT_EXEC" ]; then
|
|
pr_git_path_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-git.XXXXXX")
|
|
if ! ln -s "$GIT_EXEC" "$pr_git_path_dir/git"; then
|
|
rm -rf "$pr_git_path_dir"
|
|
exit 1
|
|
fi
|
|
pr_git_path_dir=$(cd "$pr_git_path_dir" && pwd -P)
|
|
export PATH="$pr_git_path_dir:$PATH"
|
|
fi
|
|
unset pr_path_git pr_git_path_dir
|
|
|
|
# This wrapper parses GitHub CLI JSON. Caller shells may force ANSI color globally.
|
|
export NO_COLOR=1
|
|
export CLICOLOR=0
|
|
export CLICOLOR_FORCE=0
|
|
export FORCE_COLOR=0
|
|
unset COLORTERM
|
|
|
|
# This is the single source of truth for the canonical-wrapper trust boundary.
|
|
# Advisory commands may run a mismatched local wrapper only with the explicit
|
|
# developer opt-in; landing commands must always use canonical/origin-main code.
|
|
# Classification is independent of serialization: ci-dispatch remains locked
|
|
# because GitHub exposes neither dispatch deduplication nor a correlation ID.
|
|
# PR_SUBCOMMAND_CLASSIFICATIONS_BEGIN
|
|
pr_subcommand_classification() {
|
|
case "$1" in
|
|
ls | ci-dispatch)
|
|
printf 'advisory\n'
|
|
;;
|
|
gc | lock-recover | tooling-recover | review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | review-tests | prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run | merge-recover | merge-complete)
|
|
printf 'landing\n'
|
|
;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
# PR_SUBCOMMAND_CLASSIFICATIONS_END
|
|
|
|
is_locked_pr_command() {
|
|
# Advisory trust classification permits local dogfood, but dispatches still
|
|
# serialize with landing operations because the remote mutation is not atomic.
|
|
if [ "$1" = "ci-dispatch" ]; then
|
|
return 0
|
|
fi
|
|
local classification
|
|
classification=$(pr_subcommand_classification "$1") || return 1
|
|
[ "$classification" = "landing" ] || return 1
|
|
# gc manages per-PR locks itself; lock-recover performs an exact-OID CAS.
|
|
[ "$1" != "gc" ] && [ "$1" != "lock-recover" ]
|
|
}
|
|
|
|
dev_wrapper_opt_in=0
|
|
if [ "${OPENCLAW_PR_DEV_WRAPPER:-}" = "1" ]; then
|
|
dev_wrapper_opt_in=1
|
|
fi
|
|
if [ "${1-}" = "--dev-wrapper" ]; then
|
|
dev_wrapper_opt_in=1
|
|
export OPENCLAW_PR_DEV_WRAPPER=1
|
|
shift
|
|
fi
|
|
requested_subcommand="${1-}"
|
|
|
|
# Only this explicit recovery may select current main over a retained workflow.
|
|
# Parse before source selection, then parse normally again before taking a lock.
|
|
PR_WORKFLOW_RECOVERY_OUTCOME=""
|
|
PR_WORKFLOW_RECOVERY_HEAD=""
|
|
pr_read_current_workflow_request() {
|
|
local argument found=0
|
|
for argument in "$@"; do
|
|
[ "$argument" != --current-workflow ] || found=$((found + 1))
|
|
done
|
|
[ "$found" -ne 0 ] || return 0
|
|
[ "$found" -eq 1 ] && [ "${1-}" = merge-recover ] &&
|
|
[[ "${2-}" =~ ^[1-9][0-9]*$ ]] && [[ "${3-}" =~ ^[0-9a-f]{40}$ ]] &&
|
|
[ "${4-}" = --confirmed-operator-recovery ] || return 2
|
|
PR_WORKFLOW_RECOVERY_OUTCOME="$3"
|
|
shift 4
|
|
local admin=false confirmed=false body=false current=false
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--current-workflow) [ "$current" = false ] || return 2; current=true; shift ;;
|
|
--replacement-head)
|
|
[ -z "$PR_WORKFLOW_RECOVERY_HEAD" ] && [[ "${2-}" =~ ^[0-9a-f]{40}$ ]] || return 2
|
|
PR_WORKFLOW_RECOVERY_HEAD="$2"; shift 2 ;;
|
|
--body-file) [ "$body" = false ] && [ -n "${2-}" ] || return 2; body=true; shift 2 ;;
|
|
--admin-evidence) [ "$admin" = false ] && [ -n "${2-}" ] || return 2; admin=true; shift 2 ;;
|
|
--confirmed-operator-admin) [ "$confirmed" = false ] || return 2; confirmed=true; shift ;;
|
|
*) return 2 ;;
|
|
esac
|
|
done
|
|
[ "$current" = true ] && [ "$admin" = "$confirmed" ] &&
|
|
{ [ -n "$PR_WORKFLOW_RECOVERY_HEAD" ] || [ "$admin" = true ]; }
|
|
}
|
|
pr_read_current_workflow_request "$@" || {
|
|
echo "--current-workflow requires confirmed merge-recover with an exact outcome and either a replacement head or confirmed same-head admin recovery." >&2
|
|
exit 2
|
|
}
|
|
|
|
# This bootstrap cannot load helpers from an unverified extracted tree.
|
|
pr_batch_wrapper_git() {
|
|
node --input-type=module - "$GIT_EXEC" "$@" <<'EOF_WRAPPER_GIT'
|
|
import { spawnSync } from "node:child_process";
|
|
import { fstatSync, lstatSync, readSync, realpathSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
const [binary, operation, repository, revision, ...paths] = process.argv.slice(2);
|
|
function git(directory, args, input) {
|
|
const result = spawnSync(binary, ["-C", directory, ...args], {
|
|
input, maxBuffer: Infinity, stdio: ["pipe", "pipe", "ignore"],
|
|
env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
|
|
});
|
|
if (result.error || result.signal || result.status !== 0) throw new Error("Git read failed");
|
|
return result.stdout;
|
|
}
|
|
const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
try {
|
|
if (operation.startsWith("workflow-")) {
|
|
if (!/^[1-9][0-9]*$/.test(revision)) throw new Error("Invalid workflow PR");
|
|
const ref = `refs/openclaw/pr-workflow-bindings/${revision}`;
|
|
const run = args => git(repository, args).toString("utf8").trim();
|
|
const owner = realpathSync(run(["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
const remote = run(["remote", "get-url", "origin"]);
|
|
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(remote) ? new URL(remote) : null;
|
|
const scp = !url && /^(?:([^/@:]+)@)?(\[[^\]]+\]|[^/:]+):(.+)$/.exec(remote);
|
|
// SCP paths can be relative to an SSH user's home. Preserve that transport,
|
|
// user, path case and URL ports instead of conflating different endpoints.
|
|
const identity = url ? url.protocol === "file:"
|
|
? realpathSync(fileURLToPath(url)) : url.href
|
|
: scp ? JSON.stringify(["scp", scp[1] ?? "", scp[2].toLowerCase(), scp[3]])
|
|
: realpathSync(resolve(repository, remote));
|
|
if (!identity || url && (url.username && url.protocol !== "ssh:" || url.password)) throw new Error("Invalid repository identity");
|
|
const read = () => {
|
|
const symbolic = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", ref], { stdio: "ignore" });
|
|
if (symbolic.error || symbolic.signal || symbolic.status !== 1) throw new Error("Invalid symbolic workflow binding");
|
|
const listing = run(["for-each-ref", "--format=%(objectname) %(symref)", ref]);
|
|
if (!listing) return null;
|
|
if (!oid.test(listing)) throw new Error("Invalid workflow binding ref");
|
|
const headers = run(["cat-file", "commit", listing]).split("\n\n", 1)[0].split("\n");
|
|
const parents = headers.filter(line => line.startsWith("parent ")).map(line => line.slice(7));
|
|
const entries = run(["ls-tree", listing]).split("\n");
|
|
if (parents.length !== 1 || entries.length !== 1 || !/^100644 blob [a-f0-9]+\tbinding.json$/.test(entries[0])) {
|
|
throw new Error("Invalid workflow binding tree");
|
|
}
|
|
if (Number(run(["cat-file", "-s", `${listing}:binding.json`])) > 4096) throw new Error("Oversized workflow binding");
|
|
const record = JSON.parse(run(["show", `${listing}:binding.json`]));
|
|
if (JSON.stringify(Object.keys(record).sort()) !== JSON.stringify(["owner", "pr", "repository", "source", "version"]) ||
|
|
record.version !== 1 || record.pr !== revision || record.owner !== owner || record.repository !== identity ||
|
|
!oid.test(record.source) || parents[0] !== record.source ||
|
|
run(["cat-file", "-t", record.source]) !== "commit") throw new Error("Workflow binding identity mismatch");
|
|
return { oid: listing, source: record.source };
|
|
};
|
|
const requireSource = (source, anchor = "refs/remotes/origin/main") => {
|
|
if (!oid.test(source)) throw new Error("Invalid workflow source");
|
|
run(["merge-base", "--is-ancestor", source, anchor]);
|
|
};
|
|
const current = read();
|
|
const requireRecovery = (source, outcome, replacementHead, anchor = "refs/remotes/origin/main") => {
|
|
const symbolicAnchor = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", anchor], { stdio: "ignore" });
|
|
if (symbolicAnchor.error || symbolicAnchor.signal || symbolicAnchor.status !== 1) {
|
|
throw new Error("Refusing symbolic or unreadable current workflow anchor");
|
|
}
|
|
if (!current || !oid.test(outcome) || (replacementHead !== "" && !oid.test(replacementHead)) ||
|
|
source !== run(["rev-parse", "--verify", `${anchor}^{commit}`]) ||
|
|
!["https://github.com/openclaw/openclaw", "https://github.com/openclaw/openclaw.git",
|
|
"git@github.com:openclaw/openclaw.git", "ssh://git@github.com/openclaw/openclaw.git"].includes(run(["config", "--get", "remote.origin.url"]))) {
|
|
throw new Error("Current workflow recovery requires official current main and a retained workflow");
|
|
}
|
|
requireSource(current.source, anchor);
|
|
const outcomeRef = `refs/openclaw/pr-merge-outcomes/${revision}`;
|
|
const symbolic = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", outcomeRef], { stdio: "ignore" });
|
|
if (symbolic.error || symbolic.signal || symbolic.status !== 1 ||
|
|
run(["rev-parse", "--verify", outcomeRef]) !== outcome || run(["cat-file", "-t", outcome]) !== "commit") {
|
|
throw new Error("Current workflow recovery outcome changed");
|
|
}
|
|
const record = JSON.parse(run(["show", `${outcome}:outcome.json`]));
|
|
const cancelledAuto = replacementHead !== "" && record.route === "auto" &&
|
|
record.cancellation?.state === "confirmed";
|
|
// This admits current trusted code, not another dispatch. The existing
|
|
// provider-rejection owner must still qualify exact HTTP 405 capture bytes
|
|
// and revalidate the unchanged head and live admin authority before writing.
|
|
const rejectedAdmin = replacementHead === "" && record.route === "admin" &&
|
|
record.accepted === false &&
|
|
record.priorCiAdmin?.dispatchTransport === "rest";
|
|
if (record.version !== 1 || record.pr !== Number(revision) ||
|
|
record.base !== "main" || record.phase !== "intent" ||
|
|
record.method !== "squash" || record.landed !== null || !(cancelledAuto || rejectedAdmin)) {
|
|
throw new Error("Current workflow recovery requires a confirmed cancelled nonqueue auto outcome or an unaccepted same-head REST admin intent");
|
|
}
|
|
};
|
|
if (operation === "workflow-recovery-select" || operation === "workflow-recovery-provenance") {
|
|
const [outcome, replacementHead, anchor = "refs/remotes/origin/main"] = paths;
|
|
const source = operation === "workflow-recovery-select" ? run(["rev-parse", "--verify", `${anchor}^{commit}`]) : paths[3];
|
|
requireRecovery(source, outcome, replacementHead, anchor);
|
|
process.stdout.write(operation === "workflow-recovery-select" ? `${current.oid}\t${source}\n` :
|
|
JSON.stringify({ binding: current.oid, from: current.source, to: source }));
|
|
} else if (operation === "workflow-read" || operation === "workflow-inspect") {
|
|
if (current && operation === "workflow-read") requireSource(current.source);
|
|
if (current) process.stdout.write(`${current.oid}\t${current.source}\n`);
|
|
} else if (operation === "workflow-context-write") {
|
|
const [source, binding, creator, bootstrapFlag, outcome = "", replacementHead = ""] = paths;
|
|
const bootstrap = bootstrapFlag === "1";
|
|
if (outcome) requireRecovery(source, outcome, replacementHead);
|
|
if (!oid.test(source) || (current?.oid ?? "") !== binding || !outcome && !bootstrap && current && current.source !== source ||
|
|
!/^[1-9][0-9]*$/.test(creator)) throw new Error("Workflow selection changed before handoff");
|
|
if (bootstrap) {
|
|
if (source !== run(["rev-parse", "--verify", "HEAD^{commit}"])) throw new Error("Canonical bootstrap changed");
|
|
} else requireSource(source);
|
|
process.stdout.write(JSON.stringify({ version: 1, owner, repository: identity, pr: revision, source, binding, creator, bootstrap,
|
|
...(outcome ? { recovery: { outcome, replacementHead } } : {}) }));
|
|
} else if (operation === "workflow-context-read") {
|
|
const held = fstatSync(8);
|
|
if (!held.isFile() || held.uid !== process.getuid() || (held.mode & 0o777) !== 0o400 || held.nlink !== 0 ||
|
|
held.size < 1 || held.size > 4096 || `${held.dev}:${held.ino}` !== process.env.OPENCLAW_PR_WORKFLOW_CONTEXT) {
|
|
throw new Error("Invalid workflow selection descriptor");
|
|
}
|
|
const bytes = Buffer.alloc(held.size);
|
|
if (readSync(8, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("Incomplete workflow selection");
|
|
const selection = JSON.parse(bytes.toString("utf8"));
|
|
const [creator, supervisor, outcome = "", replacementHead = ""] = paths;
|
|
if (outcome) {
|
|
if (JSON.stringify(selection.recovery) !== JSON.stringify({ outcome, replacementHead }) || selection.bootstrap) {
|
|
throw new Error("Workflow recovery selection changed across handoff");
|
|
}
|
|
requireRecovery(selection.source, outcome, replacementHead);
|
|
}
|
|
const supervised = selection.creator === supervisor && process.env.OPENCLAW_PR_DEDICATED_PROCESS_GROUP === "1" &&
|
|
process.env.OPENCLAW_PR_LOCK_NOTIFY_FD === "3" && process.env.OPENCLAW_PR_LOCK_SUPERVISOR_PID === supervisor;
|
|
const keys = ["binding", "bootstrap", "creator", "owner", "pr", "repository", "source", "version", ...(outcome ? ["recovery"] : [])].sort();
|
|
if (JSON.stringify(Object.keys(selection).sort()) !== JSON.stringify(keys) ||
|
|
selection.version !== 1 || selection.owner !== owner || selection.repository !== identity || selection.pr !== revision ||
|
|
typeof selection.bootstrap !== "boolean" ||
|
|
!oid.test(selection.source) || (selection.creator !== creator && !supervised) ||
|
|
selection.binding !== (current?.oid ?? "") || !outcome && !selection.bootstrap && current && current.source !== selection.source) {
|
|
throw new Error("Workflow selection changed across handoff");
|
|
}
|
|
if (!selection.bootstrap) requireSource(selection.source);
|
|
process.stdout.write(`${selection.binding || "-"}\t${selection.source}\t${selection.bootstrap ? "1" : "0"}\n`);
|
|
} else if (operation === "workflow-cold-select") {
|
|
const [bootstrap, expected, main] = paths;
|
|
if (!oid.test(bootstrap) || !oid.test(main) || (current?.oid ?? "") !== expected) {
|
|
throw new Error("Workflow binding changed during cold acquisition");
|
|
}
|
|
let selected;
|
|
if (current) {
|
|
requireSource(current.source, main);
|
|
selected = current.source;
|
|
} else {
|
|
const ancestry = spawnSync(binary, ["-C", repository, "merge-base", "--is-ancestor", bootstrap, main],
|
|
{ stdio: "ignore", env: { ...process.env, GIT_NO_LAZY_FETCH: "1" } });
|
|
if (ancestry.error || ancestry.signal || ![0, 1].includes(ancestry.status)) throw new Error("Cannot qualify canonical bootstrap");
|
|
selected = ancestry.status === 0 ? bootstrap : main;
|
|
}
|
|
const closure = source => {
|
|
const inventory = run(["show", `${source}:scripts/pr-lib/wrapper-components.txt`]).split("\n");
|
|
return git(repository, ["ls-tree", "-r", "-z", "--full-tree", source, "--", "scripts/pr", "scripts/pr-lib", ...inventory]);
|
|
};
|
|
// Loaded helpers belong to the captured bootstrap, even if files move
|
|
// during acquisition. Compare immutable closures before admitting it.
|
|
if (!closure(bootstrap).equals(closure(selected))) {
|
|
throw new Error(`Cold workflow source differs from the running canonical wrapper ${bootstrap}; selected source ${selected}`);
|
|
}
|
|
process.stdout.write(selected);
|
|
} else if (operation === "workflow-admit") {
|
|
const [source, expected, anchor = "refs/remotes/origin/main", outcome = "", replacementHead = ""] = paths;
|
|
if (outcome) requireRecovery(source, outcome, replacementHead, anchor);
|
|
if (!oid.test(source) || (current?.oid ?? "") !== expected || !outcome && current && current.source !== source) {
|
|
throw new Error("Workflow binding changed before admission");
|
|
}
|
|
requireSource(source, anchor);
|
|
if (current) process.stdout.write(current.oid);
|
|
else {
|
|
const record = JSON.stringify({ version: 1, repository: identity, owner, pr: revision, source });
|
|
const blob = git(repository, ["hash-object", "-w", "--stdin"], record).toString().trim();
|
|
const tree = git(repository, ["mktree"], `100644 blob ${blob}\tbinding.json\n`).toString().trim();
|
|
const commit = git(repository, ["-c", "commit.gpgsign=false", "commit-tree", tree, "-p", source],
|
|
"Native PR workflow binding\n").toString().trim();
|
|
run(["update-ref", "--no-deref", ref, commit, "0".repeat(source.length)]);
|
|
process.stdout.write(commit);
|
|
}
|
|
} else if (operation === "workflow-retire") {
|
|
if (!current || current.oid !== paths[0]) throw new Error("Workflow binding changed before completion");
|
|
requireSource(current.source, paths[1]);
|
|
run(["update-ref", "--no-deref", "-d", ref, current.oid]);
|
|
} else throw new Error("Unknown workflow operation");
|
|
} else if (operation === "differences") {
|
|
const queries = paths.flatMap(path => [`HEAD:${path}`, `${revision}:${path}`]);
|
|
const rows = git(repository, ["cat-file", "--batch-check=%(objectname)"], queries.join("\n") + "\n")
|
|
.toString("utf8").split("\n");
|
|
if (rows.pop() !== "" || rows.length !== queries.length) throw new Error("Incomplete revisions");
|
|
process.stdout.write(paths.filter((_, index) => {
|
|
const [head, anchor] = rows.slice(index * 2, index * 2 + 2);
|
|
return !oid.test(head) || !oid.test(anchor) || head !== anchor;
|
|
}).join("\n"));
|
|
} else if (operation === "verify") {
|
|
const [directory, ...components] = paths;
|
|
const listing = git(repository, ["ls-tree", "-r", "-z", "--full-tree", revision, "--", ...components]);
|
|
const text = listing.toString("utf8");
|
|
const rows = text.split("\0");
|
|
if (!Buffer.from(text).equals(listing) || rows.pop() !== "" || rows.length === 0) {
|
|
throw new Error("Incomplete anchor listing");
|
|
}
|
|
const names = new Set();
|
|
const directories = new Set();
|
|
const expected = [];
|
|
if (!lstatSync(directory).isDirectory()) throw new Error("Invalid extraction root");
|
|
for (const row of rows) {
|
|
const entry = /^(100644|100755) blob ([a-f0-9]+)\t([^\0]+)$/.exec(row);
|
|
if (!entry || !oid.test(entry[2])) throw new Error("Invalid anchor entry");
|
|
const [, , hash, path] = entry;
|
|
if (names.has(path) || path.split("/").some(part => !part || part === "." || part === "..")) {
|
|
throw new Error("Invalid anchor path");
|
|
}
|
|
let parent = "";
|
|
for (const part of path.split("/").slice(0, -1)) {
|
|
parent = parent ? `${parent}/${part}` : part;
|
|
if (!directories.has(parent)) {
|
|
if (!lstatSync(join(directory, parent)).isDirectory()) throw new Error("Non-directory anchor parent");
|
|
directories.add(parent);
|
|
}
|
|
}
|
|
if (!lstatSync(join(directory, path)).isFile()) throw new Error("Non-regular anchor file");
|
|
names.add(path);
|
|
expected.push(hash);
|
|
}
|
|
if (components.some(path => !names.has(path) && !directories.has(path))) throw new Error("Incomplete anchor components");
|
|
// Git's stdin path quoting uses C/octal escapes, not JSON's Unicode escapes.
|
|
const input = [...names].map(path => '"' + join(directory, path).replace(/[\x00-\x1f\x7f\\"]/g, char =>
|
|
char === "\\" || char === '"' ? "\\" + char : "\\" + char.charCodeAt(0).toString(8).padStart(3, "0"),
|
|
) + '"').join("\n") + "\n";
|
|
const actual = git(repository, ["hash-object", "--no-filters", "--stdin-paths"], input);
|
|
if (!actual.equals(Buffer.from(expected.join("\n") + "\n"))) throw new Error("Anchor bytes differ");
|
|
} else {
|
|
throw new Error("Unknown wrapper Git operation");
|
|
}
|
|
} catch (error) {
|
|
if (operation.startsWith("workflow-")) console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
EOF_WRAPPER_GIT
|
|
}
|
|
|
|
PR_WORKFLOW_PR=""
|
|
PR_WORKFLOW_BINDING_OID=""
|
|
PR_WORKFLOW_SOURCE_REVISION=""
|
|
PR_WORKFLOW_BOOTSTRAP=0
|
|
PR_WORKFLOW_RECOVERY_PROVENANCE=""
|
|
PR_WORKFLOW_TRUST_ANCHOR="refs/remotes/origin/main"
|
|
pr_read_workflow_binding() {
|
|
local classification binding tracking_status=0
|
|
classification=$(pr_subcommand_classification "$requested_subcommand" 2>/dev/null || true)
|
|
[ "$classification" = landing ] || return 0
|
|
case "$requested_subcommand" in gc | lock-recover) return 0 ;; esac
|
|
[[ "${2-}" =~ ^[1-9][0-9]*$ ]] || return 0
|
|
PR_WORKFLOW_PR="$2"
|
|
if [ -n "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ]; then
|
|
binding=$(pr_batch_wrapper_git workflow-context-read "$canonical_repo_root" "$PR_WORKFLOW_PR" "$$" "$PPID" \
|
|
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
|
|
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION PR_WORKFLOW_BOOTSTRAP <<< "$binding"
|
|
else
|
|
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
|
|
binding=$(pr_batch_wrapper_git workflow-recovery-select "$canonical_repo_root" "$PR_WORKFLOW_PR" \
|
|
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
|
|
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
|
|
return 0
|
|
fi
|
|
if pr_git -C "$canonical_repo_root" symbolic-ref -q refs/remotes/origin/main >/dev/null 2>&1; then
|
|
echo "Refusing a symbolic main tracking ref for workflow selection." >&2
|
|
return 1
|
|
fi
|
|
pr_git -C "$canonical_repo_root" show-ref --verify --quiet refs/remotes/origin/main || tracking_status=$?
|
|
if [ "$tracking_status" -eq 1 ] && [ "$script_self" = "$canonical_repo_root/scripts/pr" ]; then
|
|
# Only canonical code may bootstrap. A retained record is data until the
|
|
# locked acquisition authenticates its source against canonical main.
|
|
binding=$(pr_batch_wrapper_git workflow-inspect "$canonical_repo_root" "$PR_WORKFLOW_PR") || return 1
|
|
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
|
|
PR_WORKFLOW_SOURCE_REVISION=$(pr_git -C "$canonical_repo_root" rev-parse --verify 'HEAD^{commit}') || return 1
|
|
PR_WORKFLOW_BOOTSTRAP=1
|
|
else
|
|
[ "$tracking_status" -le 1 ] || return 1
|
|
binding=$(pr_batch_wrapper_git workflow-read "$canonical_repo_root" "$PR_WORKFLOW_PR") || return 1
|
|
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
|
|
fi
|
|
fi
|
|
[ "$PR_WORKFLOW_BINDING_OID" != - ] || PR_WORKFLOW_BINDING_OID=""
|
|
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ]; then
|
|
[ "$script_self" = "$canonical_repo_root/scripts/pr" ] || return 1
|
|
fi
|
|
if [ -n "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ] || [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ]; then
|
|
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
|
|
fi
|
|
}
|
|
|
|
verify_pr_workflow_source() {
|
|
local inventory component
|
|
local components=(scripts/pr scripts/pr-lib)
|
|
inventory=$(pr_git -C "$canonical_repo_root" show "$1:scripts/pr-lib/wrapper-components.txt") || return 1
|
|
while IFS= read -r component; do components+=("$component"); done <<< "$inventory"
|
|
pr_batch_wrapper_git verify "$canonical_repo_root" "$1" "$2" "${components[@]}"
|
|
}
|
|
|
|
hold_pr_workflow_selection() {
|
|
[ -n "$PR_WORKFLOW_PR" ] && [ -z "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ] || return 0
|
|
verify_pr_workflow_source "$1" "$2" || return 1
|
|
local context
|
|
context=$(mktemp "${TMPDIR:-/tmp}/openclaw-pr-selection.XXXXXX") || return 1
|
|
if ! pr_batch_wrapper_git workflow-context-write "$canonical_repo_root" "$PR_WORKFLOW_PR" \
|
|
"$1" "$PR_WORKFLOW_BINDING_OID" "$$" "$PR_WORKFLOW_BOOTSTRAP" \
|
|
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD" > "$context" || ! chmod 400 "$context"; then
|
|
rm -f "$context"
|
|
return 1
|
|
fi
|
|
# FD 8 carries selection; FD 9 remains the archive cleanup owner.
|
|
# Positional reads keep exec/child reentry independent of shared FD offsets.
|
|
exec 8< "$context"
|
|
rm "$context"
|
|
OPENCLAW_PR_WORKFLOW_CONTEXT=$(node -e 'const s=require("node:fs").fstatSync(8); process.stdout.write(`${s.dev}:${s.ino}`)') || return 1
|
|
export OPENCLAW_PR_WORKFLOW_CONTEXT
|
|
}
|
|
|
|
admit_pr_workflow_binding() {
|
|
[ -n "$PR_WORKFLOW_PR" ] || return 0
|
|
[ "$PR_WORKFLOW_PR" = "$1" ] && [ -n "$PR_WORKFLOW_SOURCE_REVISION" ] || return 1
|
|
local qualification_ref="" main=""
|
|
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ] || [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
|
|
qualification_ref="refs/openclaw/pr-workflow-qualification/$1/$PR_OPERATION_LOCK_OWNER_OID"
|
|
local ref_status=0 symbolic_status=0 selected
|
|
pr_git -C "$canonical_repo_root" symbolic-ref -q "$qualification_ref" >/dev/null 2>&1 || symbolic_status=$?
|
|
[ "$symbolic_status" -eq 1 ] || { echo "Refusing symbolic or unreadable qualification ref: $qualification_ref." >&2; return 1; }
|
|
pr_git -C "$canonical_repo_root" show-ref --verify --quiet "$qualification_ref" || ref_status=$?
|
|
[ "$ref_status" -eq 1 ] || { echo "Workflow qualification ref already exists; preserve $qualification_ref." >&2; return 1; }
|
|
mark_pr_operation_side_effects_started || return 1
|
|
if ! fetch_canonical_main "$qualification_ref"; then
|
|
echo "Cold workflow acquisition failed; inspect $qualification_ref before any cleanup." >&2
|
|
return 1
|
|
fi
|
|
main=$(pr_git -C "$canonical_repo_root" rev-parse --verify "$qualification_ref^{commit}") || return 1
|
|
PR_WORKFLOW_TRUST_ANCHOR="$main"
|
|
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
|
|
# Current main is selected only for this operation. Keep the historical
|
|
# binding intact so normal reconciliation still uses its admitted source.
|
|
verify_pr_workflow_source "$main" "$script_parent_dir/.." || {
|
|
echo "Current main workflow changed; rerun recovery from current main. Preserving $qualification_ref." >&2
|
|
return 1
|
|
}
|
|
selected="$main"
|
|
elif ! selected=$(pr_batch_wrapper_git workflow-cold-select "$canonical_repo_root" "$1" \
|
|
"$PR_WORKFLOW_SOURCE_REVISION" "$PR_WORKFLOW_BINDING_OID" "$main"); then
|
|
echo "Cold workflow qualification failed; use the admitted source from a qualified checkout. Preserving $qualification_ref." >&2
|
|
return 1
|
|
fi
|
|
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
|
|
PR_WORKFLOW_SOURCE_REVISION="$selected"
|
|
fi
|
|
# Verify again under the operation lock: initial selection may have waited
|
|
# behind another command, and only admitted bytes may mint a lasting pin.
|
|
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
|
|
pr_operation_lock_owner_is_current "$canonical_repo_root" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID" || return 1
|
|
mark_pr_operation_side_effects_started || return 1
|
|
PR_WORKFLOW_BINDING_OID=$(pr_batch_wrapper_git workflow-admit "$canonical_repo_root" "$1" \
|
|
"$PR_WORKFLOW_SOURCE_REVISION" "$PR_WORKFLOW_BINDING_OID" "$PR_WORKFLOW_TRUST_ANCHOR" \
|
|
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
|
|
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
|
|
PR_WORKFLOW_RECOVERY_PROVENANCE=$(pr_batch_wrapper_git workflow-recovery-provenance "$canonical_repo_root" "$1" \
|
|
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD" "$PR_WORKFLOW_TRUST_ANCHOR" "$PR_WORKFLOW_SOURCE_REVISION") || return 1
|
|
fi
|
|
if [ -n "$qualification_ref" ]; then
|
|
# Preserve acquisition evidence until admission succeeds; retire only our
|
|
# captured value, never a changed qualification ref.
|
|
pr_operation_lock_owner_is_current "$canonical_repo_root" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID" || return 1
|
|
pr_git -C "$canonical_repo_root" update-ref --no-deref -d "$qualification_ref" "$main" || return 1
|
|
fi
|
|
exec 8<&-
|
|
unset OPENCLAW_PR_WORKFLOW_CONTEXT
|
|
}
|
|
|
|
# Keep dependency sourcing independent of wrapper selection. In particular, an
|
|
# older anchor still supplies its own package/version policy to this bootstrap.
|
|
pr_materialize_dependencies() {
|
|
node --input-type=module - "$1" "$canonical_repo_root" "$2" <<'EOF_TOOLING'
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
const [owner, canonicalPath, destination] = process.argv.slice(2);
|
|
const canonical = realpathSync(canonicalPath);
|
|
const gitBinary = process.env.OPENCLAW_PR_GIT;
|
|
const git = (root, args) => execFileSync(gitBinary, ["-C", root, ...args], {
|
|
encoding: "utf8", timeout: 300_000, stdio: ["ignore", "pipe", "pipe"],
|
|
}).trim();
|
|
// Refresh steps stream instead: a far-behind root's fetch or fast-forward output
|
|
// can overflow the capture buffer after Git has already changed the checkout.
|
|
const gitStreamed = (root, args) => execFileSync(gitBinary, ["-C", root, ...args], {
|
|
timeout: 300_000, stdio: ["ignore", 2, 2],
|
|
});
|
|
function config(root, key, type = "--path") {
|
|
const result = spawnSync(gitBinary, ["-C", root, "config", type, "--get", key], {
|
|
encoding: "utf8", timeout: 300_000,
|
|
});
|
|
if (result.status === 1) return "";
|
|
if (result.status !== 0) throw new Error(`Cannot read Git setting ${key} in ${root}.`);
|
|
return result.stdout.trim();
|
|
}
|
|
function repository(url, root) {
|
|
if (/^(?:\.?\.?\/|\/)/.test(url)) return realpathSync(resolve(root, url));
|
|
const parsed = new URL(url.replace(/^git@([^:]+):/, "ssh://git@$1/"));
|
|
if (parsed.protocol === "file:") return realpathSync(decodeURIComponent(parsed.pathname));
|
|
return `${parsed.hostname.toLowerCase()}/${parsed.pathname.replace(/^\/|\/$/g, "").replace(/\.git$/, "").toLowerCase()}`;
|
|
}
|
|
let root = canonical;
|
|
try {
|
|
const requested = process.env.OPENCLAW_PR_TOOLING_ROOT || config(canonical, "openclaw.pr.toolingRoot");
|
|
if (requested) {
|
|
root = realpathSync(resolve(canonical, requested));
|
|
if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true" ||
|
|
realpathSync(git(root, ["rev-parse", "--show-toplevel"])) !== root ||
|
|
config(root, "core.sparseCheckout", "--bool") === "true" ||
|
|
repository(git(root, ["remote", "get-url", "origin"]), root) !==
|
|
repository(git(canonical, ["remote", "get-url", "origin"]), canonical)) {
|
|
throw new Error(`Tooling root must be a full Git work tree of this repository: ${root}`);
|
|
}
|
|
console.error(`scripts/pr tooling root: ${root}`);
|
|
}
|
|
const materialize = () => {
|
|
const result = spawnSync(process.execPath, [owner, join(root, "node_modules"), destination], {
|
|
encoding: "utf8", stdio: ["ignore", "inherit", "pipe"],
|
|
});
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
if (result.error) throw result.error;
|
|
// Older anchored materializers used exit 1 for their version refusal.
|
|
// Destination write errors never carry this complete validation diagnostic.
|
|
const drift = result.status === 78 || (result.status === 1 &&
|
|
/^Installed scripts\/pr dependency '[^\r\n]+' has version [^\r\n]+; the trust anchor requires [^\r\n]+\.$/m.test(result.stderr));
|
|
if (result.status !== 0 && !drift) throw new Error(`Dependency materialization failed in ${destination}; tooling root was not refreshed.`);
|
|
return result.status === 0;
|
|
};
|
|
if (!materialize()) {
|
|
if (!requested) process.exit(1);
|
|
const requireCleanMain = () => {
|
|
const branch = git(root, ["branch", "--show-current"]) || "(detached)";
|
|
const dirty = git(root, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"]);
|
|
if (root === canonical || branch !== "main" || dirty) {
|
|
throw new Error(`Cannot refresh scripts/pr tooling root ${root} (branch=${branch}, ${dirty ? "dirty" : "clean"}${root === canonical ? ", canonical checkout" : ""}). Restore its frozen dependencies manually or set OPENCLAW_PR_TOOLING_ROOT to a separate clean main checkout.`);
|
|
}
|
|
};
|
|
requireCleanMain();
|
|
console.error(`Refreshing scripts/pr tooling root ${root} (main): fetch origin main, merge --ff-only origin/main, pnpm install --frozen-lockfile.`);
|
|
gitStreamed(root, ["fetch", "origin", "main"]);
|
|
// Fetch may run hooks; reread the checkout before changing files.
|
|
requireCleanMain();
|
|
gitStreamed(root, ["merge", "--ff-only", "--no-stat", "refs/remotes/origin/main"]);
|
|
// Package-manager Git children must use the same preflighted binary too.
|
|
const bin = join(dirname(destination), ".pr-tooling-bin");
|
|
mkdirSync(bin);
|
|
try {
|
|
symlinkSync(gitBinary, join(bin, "git"));
|
|
// A post-merge hook can dirty or switch the checkout despite merge succeeding.
|
|
requireCleanMain();
|
|
const installed = spawnSync("pnpm", ["-C", root, "install", "--frozen-lockfile"], {
|
|
stdio: "inherit", env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH ?? ""}` },
|
|
});
|
|
if (installed.status !== 0) throw new Error(`Frozen dependency installation failed in ${root}.`);
|
|
} finally {
|
|
rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
if (!materialize()) throw new Error(`Refreshed scripts/pr tooling root ${root} still does not match the trust anchor; no further refresh was attempted.`);
|
|
console.error(`Refreshed and verified scripts/pr tooling root: ${root}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`scripts/pr tooling root ${root}: ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
EOF_TOOLING
|
|
}
|
|
|
|
# Select trusted wrapper code independently from the canonical repository root;
|
|
# a linked wrapper may be removed by merge-run or gc before supervision ends.
|
|
canonical_repo_root="$script_parent_dir/.."
|
|
# Bootstrap paths stay fixed so an edited inventory cannot exclude itself from
|
|
# the working-tree and anchor checks. The inventory owns the remaining closure.
|
|
pr_wrapper_components=(scripts/pr scripts/pr-lib)
|
|
# Anchor-exec handoff: this process was exec'd from wrapper bytes materialized
|
|
# out of refs/remotes/origin/main by the selection logic below in a previous
|
|
# wrapper. The env var conveys repository addressing only — code trust came
|
|
# from the parent's per-blob verification of the materialized copy, the same
|
|
# verify-then-exec contract as the canonical-checkout substitution. Skip
|
|
# re-selection: the running bytes ARE the anchor, and the temp copy has no git
|
|
# context of its own to select against.
|
|
if [ -n "${OPENCLAW_PR_ANCHOR_REPO_ROOT:-}" ]; then
|
|
if ! pr_git -C "$OPENCLAW_PR_ANCHOR_REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
echo "OPENCLAW_PR_ANCHOR_REPO_ROOT is not a git work tree: $OPENCLAW_PR_ANCHOR_REPO_ROOT" >&2
|
|
exit 1
|
|
fi
|
|
canonical_repo_root="$OPENCLAW_PR_ANCHOR_REPO_ROOT"
|
|
pr_read_workflow_binding "$@"
|
|
# Older parents pin only their own dependency set. The anchored child owns
|
|
# completing its installation before any loader, supervisor, or lock starts.
|
|
pr_materialize_dependencies "$script_parent_dir/pr-lib/materialize-dependencies.mjs" \
|
|
"$script_parent_dir/../node_modules"
|
|
elif common_git_dir=$(pr_git -C "$script_parent_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
|
|
canonical_repo_root="$(dirname "$common_git_dir")"
|
|
canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")"
|
|
pr_read_workflow_binding "$@"
|
|
if [ "$PR_WORKFLOW_BOOTSTRAP" != 1 ] && [ -x "$canonical_self" ] && { [ "$script_self" != "$canonical_self" ] || [ -n "$PR_WORKFLOW_PR" ]; }; then
|
|
linked_inventory=$(pr_git -C "$script_parent_dir" show HEAD:scripts/pr-lib/wrapper-components.txt)
|
|
while IFS= read -r wrapper_component; do
|
|
pr_wrapper_components+=("$wrapper_component")
|
|
done <<< "$linked_inventory"
|
|
if ! pr_git -C "$script_parent_dir" diff --quiet HEAD -- "${pr_wrapper_components[@]/#/:(top)}"; then
|
|
echo "scripts/pr wrapper files have uncommitted changes in this worktree." >&2
|
|
echo "Refusing to run unreviewed wrapper code from: $script_parent_dir" >&2
|
|
exit 1
|
|
fi
|
|
linked_wrapper_revision=$(
|
|
pr_git -C "$script_parent_dir" rev-parse "${pr_wrapper_components[@]/#/HEAD:}" 2>/dev/null || true
|
|
)
|
|
canonical_wrapper_revision=$(
|
|
pr_git -C "$canonical_repo_root" rev-parse "${pr_wrapper_components[@]/#/HEAD:}" 2>/dev/null || true
|
|
)
|
|
if [ "$script_self" != "$canonical_self" ] && [ -z "$PR_WORKFLOW_PR" ] && [ -n "$linked_wrapper_revision" ] &&
|
|
[ "$linked_wrapper_revision" = "$canonical_wrapper_revision" ] &&
|
|
pr_git -C "$canonical_repo_root" diff --quiet HEAD -- "${pr_wrapper_components[@]/#/:(top)}"; then
|
|
exec "$canonical_self" "$@"
|
|
fi
|
|
# The canonical checkout can be parked on another branch or carry local
|
|
# edits (release trains move it); maintainer-controlled origin/main is the
|
|
# trust anchor then. Matching code can run in place when its command
|
|
# does not need the materialized dependency context.
|
|
# refs/remotes/... explicitly: bare "origin/main" is a DWIM name that a
|
|
# local branch or tag named origin/main could shadow, spoofing the anchor.
|
|
anchor_commit="${PR_WORKFLOW_SOURCE_REVISION:-$(pr_git -C "$script_parent_dir" rev-parse --verify refs/remotes/origin/main^{commit} 2>/dev/null || true)}"
|
|
anchor_description="origin/main"
|
|
anchor_reference="refs/remotes/origin/main"
|
|
if [ -n "$PR_WORKFLOW_BINDING_OID" ]; then
|
|
anchor_description="retained PR #$PR_WORKFLOW_PR workflow $anchor_commit"
|
|
anchor_reference="$anchor_description"
|
|
fi
|
|
anchor_wrapper_revision=""
|
|
if [ -n "$anchor_commit" ] &&
|
|
anchor_inventory=$(pr_git -C "$script_parent_dir" show "$anchor_commit:scripts/pr-lib/wrapper-components.txt" 2>/dev/null); then
|
|
# A newer anchor can add helpers outside the caller's inventory. Its own
|
|
# closure governs canonical substitution as well as extraction below.
|
|
anchor_components=(scripts/pr scripts/pr-lib)
|
|
while IFS= read -r anchor_component; do
|
|
anchor_components+=("$anchor_component")
|
|
done <<< "$anchor_inventory"
|
|
anchor_wrapper_revision=$(
|
|
pr_git -C "$script_parent_dir" rev-parse "${anchor_components[@]/#/$anchor_commit:}" 2>/dev/null || true
|
|
)
|
|
canonical_wrapper_revision=$(
|
|
pr_git -C "$canonical_repo_root" rev-parse "${anchor_components[@]/#/HEAD:}" 2>/dev/null || true
|
|
)
|
|
fi
|
|
linked_matches_anchor=0
|
|
if [ -n "$linked_wrapper_revision" ] &&
|
|
[ "$linked_wrapper_revision" = "$anchor_wrapper_revision" ]; then
|
|
linked_matches_anchor=1
|
|
fi
|
|
# Node resolves helper imports from their source directory, not the Git owner.
|
|
# Reuse the verified materializer for code-only linked PR operations.
|
|
if [ "$linked_matches_anchor" != "1" ] ||
|
|
{ is_locked_pr_command "$requested_subcommand" && [ ! -d "$script_parent_dir/../node_modules" ]; }; then
|
|
wrapper_route_reason="differs from $anchor_description"
|
|
if [ "$linked_matches_anchor" = "1" ]; then
|
|
wrapper_route_reason="matches $anchor_description but has no node_modules directory"
|
|
fi
|
|
requested_classification=$(pr_subcommand_classification "$requested_subcommand" 2>/dev/null || true)
|
|
if [ "$linked_matches_anchor" != "1" ] && [ "$dev_wrapper_opt_in" = "1" ] && [ "$requested_classification" = "advisory" ]; then
|
|
if [ "${OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN:-}" != "1" ]; then
|
|
local_head_revision=$(pr_git -C "$script_parent_dir" rev-parse HEAD 2>/dev/null || printf 'unknown')
|
|
echo "WARNING: running local scripts/pr revision $local_head_revision via dev-wrapper opt-in." >&2
|
|
echo "subcommand '$requested_subcommand' is classified advisory." >&2
|
|
echo "The local wrapper differs from the canonical checkout and origin/main; landing subcommands remain refused." >&2
|
|
export OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN=1
|
|
fi
|
|
else
|
|
if [ "$linked_matches_anchor" != "1" ] && [ "$dev_wrapper_opt_in" = "1" ] && [ -n "$requested_classification" ]; then
|
|
echo "subcommand '$requested_subcommand' is classified $requested_classification; dev-wrapper opt-in is unavailable." >&2
|
|
fi
|
|
# Worktrees routinely sit on a base that predates (or carries) wrapper
|
|
# changes relative to main. When the canonical checkout is byte-identical
|
|
# to the fetched origin/main anchor, exec-ing it runs exactly the trusted
|
|
# anchor code; announce the substitution so it is never silent.
|
|
if [ "$script_self" != "$canonical_self" ] && [ -n "$anchor_wrapper_revision" ] &&
|
|
[ "$canonical_wrapper_revision" = "$anchor_wrapper_revision" ] &&
|
|
pr_git -C "$canonical_repo_root" diff --quiet HEAD -- "${anchor_components[@]/#/:(top)}"; then
|
|
hold_pr_workflow_selection "$anchor_commit" "$canonical_repo_root" || exit 1
|
|
echo "scripts/pr wrapper in this worktree $wrapper_route_reason; running the canonical checkout's wrapper (matches the $anchor_description trust anchor): $canonical_repo_root" >&2
|
|
exec "$canonical_self" "$@"
|
|
fi
|
|
# The canonical checkout cannot supply trusted code and dependency context.
|
|
# Materialize the anchor wrapper bytes directly
|
|
# from refs/remotes/origin/main and exec that copy: trust flows from
|
|
# the fetched ref itself, so a queued PR stops paying a rebase + CI lap
|
|
# for unrelated wrapper drift on main. Requires the anchor entrypoint
|
|
# to understand the handoff; older anchors fall through to the refusal.
|
|
# Archive emits paths relative to its cwd, so run it (and the
|
|
# verification listing) from the worktree toplevel to keep component
|
|
# paths intact in the materialized tree.
|
|
anchor_git_root=$(pr_git -C "$script_parent_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
|
if [ -n "$anchor_wrapper_revision" ] && [ -n "$anchor_git_root" ] &&
|
|
anchor_exec_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-anchor.$$.XXXXXX" 2>/dev/null); then
|
|
# Hold the directory we created across exec, not an environment pathname.
|
|
# FD 9 is reserved for the supervisor; bootstrap children are awaited.
|
|
trap 'rm -rf "$anchor_exec_dir"' EXIT
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 131' QUIT
|
|
trap 'exit 143' TERM
|
|
exec 9< "$anchor_exec_dir"
|
|
anchor_extraction_valid=0
|
|
# BSD tar can stop at the end marker before Git finishes writing padding,
|
|
# causing SIGPIPE under pipefail. Require producer completion before reading.
|
|
if pr_git -C "$anchor_git_root" archive "$anchor_commit" -- \
|
|
"${anchor_components[@]}" > "$anchor_exec_dir/anchor.tar" 2>/dev/null &&
|
|
tar -xf "$anchor_exec_dir/anchor.tar" -C "$anchor_exec_dir" 2>/dev/null; then
|
|
rm "$anchor_exec_dir/anchor.tar"
|
|
# Verify every extracted file byte-matches its anchor blob so an
|
|
# archive/extract fault can never smuggle different code past the
|
|
# trust boundary. The temp dir is 0700 and exec'd immediately —
|
|
# the same TOCTOU class as the canonical-substitution exec above.
|
|
if pr_batch_wrapper_git verify "$anchor_git_root" "$anchor_commit" \
|
|
"$anchor_exec_dir" "${anchor_components[@]}"; then
|
|
anchor_extraction_valid=1
|
|
fi
|
|
fi
|
|
if [ "$anchor_extraction_valid" = "1" ] &&
|
|
grep -q "OPENCLAW_PR_ANCHOR_REPO_ROOT" "$anchor_exec_dir/scripts/pr" 2>/dev/null; then
|
|
# Only third-party tooling comes from the selected installed tree.
|
|
# Pin package directories: a later install may replace top-level aliases.
|
|
# A whole node_modules link could load unanchored workspace source.
|
|
anchor_dependency_owner="$anchor_exec_dir/scripts/pr-lib/materialize-dependencies.mjs"
|
|
# Older anchors still rely on their parent to prepare dependencies.
|
|
# Keep that bootstrap in this caller, using the extracted manifest.
|
|
if [ ! -f "$anchor_dependency_owner" ]; then
|
|
anchor_dependency_owner="$script_parent_dir/pr-lib/materialize-dependencies.mjs"
|
|
fi
|
|
if ! pr_materialize_dependencies "$anchor_dependency_owner" "$anchor_exec_dir/node_modules"
|
|
then
|
|
exit 1
|
|
fi
|
|
# Bash re-reads this copy: successful exec hands cleanup to the
|
|
# same creator PID, in anchored bash or its drained supervisor.
|
|
# Older anchors without that handoff are reclaimed by the stale sweep.
|
|
hold_pr_workflow_selection "$anchor_commit" "$anchor_exec_dir" || exit 1
|
|
echo "scripts/pr wrapper in this worktree $wrapper_route_reason; running wrapper code materialized from the $anchor_reference trust anchor at revision $(pr_git -C "$script_parent_dir" rev-parse --short "$anchor_commit")." >&2
|
|
# Bash 3.2 clears traps even when exec fails; errexit must be off
|
|
# until we reinstall cleanup on the failure-only continuation.
|
|
shopt -s execfail
|
|
set +e
|
|
OPENCLAW_PR_ANCHOR_REPO_ROOT="$canonical_repo_root" \
|
|
OPENCLAW_PR_ANCHOR_CREATOR_PID="$$" OPENCLAW_PR_ANCHOR_FD=9 \
|
|
exec "$anchor_exec_dir/scripts/pr" "$@"
|
|
trap 'rm -rf "$anchor_exec_dir"' EXIT
|
|
echo "Unable to execute the materialized scripts/pr wrapper." >&2
|
|
exit 1
|
|
fi
|
|
rm -rf "$anchor_exec_dir"
|
|
trap - EXIT HUP INT QUIT TERM
|
|
exec 9<&-
|
|
fi
|
|
# HEAD blobs are authoritative here: the uncommitted-wrapper guard above
|
|
# already exited for any staged or unstaged edit to these paths, so
|
|
# the working tree matches HEAD and this list matches what was rejected.
|
|
# An absent anchor differs at every path without launching another Git read.
|
|
differing_wrapper_components=("${pr_wrapper_components[@]}")
|
|
if [ -n "$anchor_commit" ] &&
|
|
differing_components=$(pr_batch_wrapper_git differences "$script_parent_dir" \
|
|
"$anchor_commit" "${pr_wrapper_components[@]}"); then
|
|
differing_wrapper_components=()
|
|
while IFS= read -r wrapper_component; do
|
|
[ -z "$wrapper_component" ] || differing_wrapper_components+=("$wrapper_component")
|
|
done <<< "$differing_components"
|
|
fi
|
|
if [ "$linked_matches_anchor" = "1" ]; then
|
|
echo "scripts/pr wrapper matches origin/main, but its linked checkout has no node_modules directory." >&2
|
|
else
|
|
echo "scripts/pr implementation differs between this worktree and the canonical checkout, and does not match origin/main." >&2
|
|
fi
|
|
echo "differing wrapper components vs origin/main: ${differing_wrapper_components[*]}" >&2
|
|
echo "Refusing to silently substitute canonical wrapper code from: $canonical_repo_root" >&2
|
|
echo "Run scripts/pr from a checkout with installed dependencies whose wrapper matches the canonical checkout or a fetched origin/main." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Keep the verified owner available after cleanup removes a linked wrapper.
|
|
canonical_repo_root="$(cd "$canonical_repo_root" && pwd -P)"
|
|
readonly canonical_repo_root
|
|
|
|
if [ -n "$PR_WORKFLOW_PR" ]; then
|
|
# A pin is admission state, not caller-selected code. The operation lock
|
|
# fences the verified closure and initial record before any PR side effect.
|
|
PR_WORKFLOW_SOURCE_REVISION="${PR_WORKFLOW_SOURCE_REVISION:-${anchor_commit:-$(pr_git -C "$canonical_repo_root" rev-parse --verify refs/remotes/origin/main^{commit})}}"
|
|
hold_pr_workflow_selection "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || exit 1
|
|
fi
|
|
|
|
is_main_only_pr_command() {
|
|
case "$1" in
|
|
prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run | merge-recover) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
is_supervised_pr_process() {
|
|
[ "${OPENCLAW_PR_DEDICATED_PROCESS_GROUP:-}" = "1" ] &&
|
|
[ "${OPENCLAW_PR_LOCK_NOTIFY_FD:-}" = "3" ] &&
|
|
[ "${OPENCLAW_PR_LOCK_SUPERVISOR_PID:-}" = "$PPID" ]
|
|
}
|
|
|
|
# The supervisor changes cwd to the canonical repo. Resolve explicit body and
|
|
# evidence paths relative to the operator's caller before crossing that
|
|
# boundary; do not read them.
|
|
if [ "${1-}" = tooling-recover ]; then
|
|
[ "$#" -ge 3 ] || { echo "tooling-recover requires a PR and plan" >&2; exit 2; }
|
|
recovery_plan=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$3") || exit 1
|
|
set -- "$1" "$2" "$recovery_plan" "${@:4}"
|
|
fi
|
|
|
|
if [ "${1-}" = merge-run ] || [ "${1-}" = merge-recover ] || [ "${1-}" = prepare-baseline-refresh ] || [ "${1-}" = prepare-baseline-successor ] || [ "${1-}" = prepare-baseline-signing-request ] || [ "${1-}" = prepare-baseline-signing-continue ]; then
|
|
merge_cli_args=() merge_cli_body_next=false
|
|
for merge_cli_arg in "$@"; do
|
|
if [ "$merge_cli_body_next" = true ] && [ -n "$merge_cli_arg" ]; then
|
|
merge_cli_arg=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$merge_cli_arg") || exit 1
|
|
fi
|
|
merge_cli_args+=("$merge_cli_arg")
|
|
merge_cli_body_next=false
|
|
if [ "$merge_cli_arg" = --body-file ] || [ "$merge_cli_arg" = --legacy-refusal ] || [ "$merge_cli_arg" = --pre-dispatch-refusal ] || [ "$merge_cli_arg" = --admin-evidence ] || [ "$merge_cli_arg" = --resolutions ] || [ "$merge_cli_arg" = --signed-commit ]; then merge_cli_body_next=true; fi
|
|
done
|
|
set -- "${merge_cli_args[@]}"
|
|
fi
|
|
|
|
if [ "${1-}" = "gc" ] || is_locked_pr_command "${1-}"; then
|
|
if is_supervised_pr_process; then
|
|
# operation-lock.sh consumes the one-shot marker when it installs the
|
|
# leader-only completion trap, before PR command tools can inherit it.
|
|
:
|
|
else
|
|
unset OPENCLAW_PR_DEDICATED_PROCESS_GROUP
|
|
unset OPENCLAW_PR_LOCK_NOTIFY_FD
|
|
unset OPENCLAW_PR_LOCK_SUPERVISOR_PID
|
|
command -v node >/dev/null 2>&1 || { echo "Missing required command: node" >&2; exit 1; }
|
|
shopt -s execfail
|
|
set +e
|
|
exec node "$script_parent_dir/pr-lib/process-group-runner.mjs" "$canonical_repo_root" "$script_self" "$@"
|
|
if [ "${OPENCLAW_PR_ANCHOR_CREATOR_PID:-}" = "$$" ] && [ "${OPENCLAW_PR_ANCHOR_FD:-}" = 9 ]; then
|
|
trap 'node "$script_parent_dir/pr-lib/materialized-anchor.mjs" retire "$script_self" || true' EXIT
|
|
fi
|
|
echo "Unable to execute the scripts/pr supervisor." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/lib/plain-gh.sh"
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
Usage:
|
|
scripts/pr [--dev-wrapper] <subcommand> ...
|
|
scripts/pr ls
|
|
scripts/pr gc [--pr <number>] [--dry-run]
|
|
scripts/pr lock-recover <PR> <OWNER_OID> --confirmed-no-running-tools
|
|
scripts/pr tooling-recover <PR> <plan> --plan-sha256 <SHA256> --confirmed-quiescent
|
|
scripts/pr review-init <PR>
|
|
scripts/pr review-checkout-main <PR>
|
|
scripts/pr review-checkout-pr <PR>
|
|
scripts/pr review-claim <PR>
|
|
scripts/pr review-guard <PR>
|
|
scripts/pr review-artifacts-init <PR>
|
|
scripts/pr review-validate-artifacts <PR>
|
|
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
|
|
scripts/pr prepare-init <PR>
|
|
scripts/pr prepare-correction-init <PR>
|
|
scripts/pr prepare-correction-continue <PR> --expected-head <SHA> --expected-tree <OID> --incoming-head <SHA> --incoming-review <OID> --publication-receipt <push|sync> --publication-oid <OID>
|
|
scripts/pr prepare-correction-review-init <PR>
|
|
scripts/pr prepare-baseline-refresh <PR> --expected-head <SHA> --baseline <SHA> [--resolutions <file>]
|
|
scripts/pr prepare-baseline-successor <PR> --expected-head <SHA> --expected-tree <OID> --expected-review <OID> --predecessor-head <SHA> --predecessor-binding <OID> --baseline <SHA> [--resolutions <file>]
|
|
scripts/pr prepare-baseline-signing-request <PR> --expected-head <SHA> --baseline <SHA> --signer <fingerprint> [--resolutions <file>] [successor identity options]
|
|
scripts/pr prepare-baseline-signing-continue <PR> --request <OID> --signed-commit <file>
|
|
scripts/pr prepare-validate-commit <PR>
|
|
scripts/pr prepare-gates <PR>
|
|
scripts/pr prepare-push <PR> [--resume-crabbox-run <Actions run ID>]
|
|
Resume observes the selected protected publisher only; never pushes or dispatches.
|
|
scripts/pr prepare-sync-head <PR>
|
|
scripts/pr prepare-run <PR>
|
|
scripts/pr ci-dispatch <PR> [--backend crabbox]
|
|
scripts/pr merge-verify <PR>
|
|
scripts/pr merge-run <PR> [--auto-merge] [--body-file <path>] [--admin-evidence <path> --confirmed-operator-admin]
|
|
OPENCLAW_PR_MERGE_METHOD=merge|rebase preserves the PR commit series.
|
|
--auto-merge selects pinned immediate squash for CLEAN, auto for BEHIND/BLOCKED (MERGEABLE only).
|
|
OPENCLAW_PR_GATES_REMOTE=github prepare-run defers required CI to this auto-merge handoff.
|
|
OPENCLAW_PR_AUTO_MERGE=1 is equivalent.
|
|
--body-file snapshots a regular UTF-8 file relative to the caller, replacing squash prose.
|
|
Empty files are valid. Explicit/source co-authors and preview credit backed by tree-changing PR commits or PR authorship are retained; known machine credit is excluded. Queue merges reject it.
|
|
--admin-evidence admits reviewed prior-green conflict repairs or attributed pre-existing CI failures, with active organization-admin authority.
|
|
Exact evidence is required; branch-caused failures, security checks, and enforced reviews remain blocking.
|
|
This requires --confirmed-operator-admin and immediate squash without auto-merge.
|
|
Repeated merge-run reconciles retained outcomes; it never retries an uncertain dispatch.
|
|
scripts/pr merge-recover <PR> <OUTCOME_OID> --confirmed-operator-recovery [--replacement-head <SHA> [--auto-merge]] [--current-workflow] [--merged-head <SHA>] [--cancel-auto [--observed-head <SHA>]] [--body-file <path>] [--legacy-refusal <evidence-directory>] [--pre-dispatch-refusal <evidence-directory>] [--admin-evidence <path> --confirmed-operator-admin]
|
|
Explicitly authorize one new attempt after inspecting the retained outcome and remote history.
|
|
Defaults to the retained head; replacement requires an explicitly selected full lowercase 40-character SHA,
|
|
freshly authored exact-head review/preparation and completed CI proof. Standing landing authority covers
|
|
a replacement repairing the same scope; new scope or another method needs authorization. Never rebases automatically.
|
|
Ordinary recovery keeps prior evidence and requires the same method with immediate non-admin admission.
|
|
Paired admin flags permit an explicitly selected head after confirmed auto cancellation, including the retained SHA.
|
|
They also permit same-head recovery without --replacement-head for the complete known prior-CI REST base-modified HTTP 405 rejection.
|
|
This reruns current admin admission and retains the original captures; other admin failures remain fenced.
|
|
--current-workflow explicitly selects current official main for either cancelled-auto replacement or
|
|
same-head qualified HTTP 405 admin recovery; the retained binding remains unchanged.
|
|
--replacement-head <SHA> --auto-merge retires an unaccepted prior-CI REST admin intent fenced by a different
|
|
exact current head, then requests ordinary current-head auto-merge. It requires exact-head github_pending
|
|
preparation and all normal required-check gates; it never reuses prior admin evidence.
|
|
--cancel-auto instead retires the exact accepted or uncertain non-queue auto request before head repair.
|
|
Cancels a matching active request once, or records an already absent request without sending cancellation.
|
|
--observed-head <SHA> selects a changed OPEN head whose accepted auto request is already absent.
|
|
It records retirement only after stable observations; armed requests and concurrent drift stay refused.
|
|
Repeated cancellation reconciles only. Use its current outcome OID for later reviewed/prepared recovery.
|
|
--merged-head <SHA> records the receipt of an accepted auto request that GitHub merged on a pushed descendant
|
|
head after inspecting the PR timeline. It requires the exact authoritative merged head and never dispatches.
|
|
scripts/pr merge-complete <PR> <OUTCOME_OID> --confirmed-operator-completion
|
|
Finish a verified merge receipt after cleanup; never dispatches a merge or deletes resources.
|
|
Posts a first completion comment only from merged; uncertain comment attempts are lookup-only.
|
|
|
|
--dev-wrapper permits a mismatched local wrapper only for subcommands
|
|
classified advisory. OPENCLAW_PR_DEV_WRAPPER=1 is equivalent.
|
|
|
|
Required commands: git, gh, jq, rg (ripgrep), pnpm, node.
|
|
When macOS blocks ps, PR operation locks require Python 3 with ctypes on PATH.
|
|
USAGE
|
|
}
|
|
|
|
require_cmds() {
|
|
local missing=()
|
|
local cmd
|
|
for cmd in gh jq rg pnpm node; do
|
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
missing+=("$cmd")
|
|
fi
|
|
done
|
|
if ! resolve_plain_gh_bin >/dev/null; then
|
|
missing+=("gh")
|
|
fi
|
|
|
|
if [ "${#missing[@]}" -gt 0 ]; then
|
|
echo "Missing required command(s): ${missing[*]}" >&2
|
|
if [[ " ${missing[*]} " = *" rg "* ]]; then
|
|
echo "Install ripgrep and retry: https://github.com/BurntSushi/ripgrep#installation" >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
require_main_target_pr() {
|
|
local pr="$1"
|
|
local base base_json
|
|
pr_observe "$pr" || exit 1
|
|
base_json="$PR_OBSERVATION"
|
|
base=$(pr_view_string_field "$base_json" "baseRefName" "$pr" "Retry the scripts/pr command.") || exit 1
|
|
if [ "$base" != "main" ]; then
|
|
echo "scripts/pr prepare and merge commands only support PRs targeting main; PR #$pr targets $base." >&2
|
|
echo "Use the reviewed release-branch landing flow for non-main PRs." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/worktree.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/operation-lock.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/common.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/changelog.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/gates.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/push.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/review.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/prepare-core.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/merge.sh"
|
|
|
|
main() {
|
|
if [ "$#" -lt 1 ]; then
|
|
usage
|
|
exit 2
|
|
fi
|
|
|
|
local cmd="${1-}"
|
|
shift || true
|
|
|
|
if [ "$cmd" = "lock-recover" ]; then
|
|
local pr="${1-}"
|
|
local owner_oid="${2-}"
|
|
local confirmation="${3-}"
|
|
[ -n "$pr" ] && [ -n "$owner_oid" ] && [ "$#" -eq 3 ] || { usage; exit 2; }
|
|
recover_pr_operation_lock "$pr" "$owner_oid" "$confirmation"
|
|
return
|
|
fi
|
|
|
|
case "$cmd" in
|
|
ls) ;;
|
|
gc)
|
|
local gc_dry_run=false gc_pr=""
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--dry-run)
|
|
[ "$gc_dry_run" = false ] || { usage; exit 2; }
|
|
gc_dry_run=true
|
|
shift
|
|
;;
|
|
--pr)
|
|
[ -z "$gc_pr" ] && [ "$#" -ge 2 ] && is_canonical_pr_number "$2" || { usage; exit 2; }
|
|
gc_pr="$2"
|
|
shift 2
|
|
;;
|
|
*) usage; exit 2 ;;
|
|
esac
|
|
done
|
|
;;
|
|
tooling-recover)
|
|
[ "$#" -eq 5 ] && is_canonical_pr_number "$1" && [ "$3" = --plan-sha256 ] &&
|
|
[[ "$4" =~ ^[0-9a-f]{64}$ ]] && [ "$5" = --confirmed-quiescent ] || { usage; exit 2; }
|
|
;;
|
|
review-tests)
|
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
|
;;
|
|
merge-complete)
|
|
[ "$#" -eq 3 ] && is_canonical_pr_number "$1" &&
|
|
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ "$3" = --confirmed-operator-completion ] || { usage; exit 2; }
|
|
;;
|
|
merge-run | merge-recover)
|
|
local merge_pr="${1-}" auto_merge=false recovery_oid="" merged_head="" observed_head="" replacement_head="" body_path="" legacy_directory="" cancel_auto=false refusal_directory="" admin_evidence="" confirmed_admin=false
|
|
[ -n "$merge_pr" ] || { usage; exit 2; }
|
|
shift
|
|
if [ "$cmd" = merge-recover ]; then
|
|
[ "$#" -ge 2 ] && is_canonical_pr_number "$merge_pr" &&
|
|
[[ "$1" =~ ^[0-9a-f]{40}$ ]] && [ "$2" = --confirmed-operator-recovery ] || { usage; exit 2; }
|
|
recovery_oid="$1"
|
|
shift 2
|
|
fi
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--current-workflow)
|
|
[ "$cmd" = merge-recover ] && [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ] || { usage; exit 2; }
|
|
shift
|
|
;;
|
|
--cancel-auto)
|
|
[ "$cmd" = merge-recover ] && [ "$cancel_auto" = false ] || { usage; exit 2; }
|
|
cancel_auto=true
|
|
shift
|
|
;;
|
|
--observed-head)
|
|
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
|
|
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$observed_head" ] || { usage; exit 2; }
|
|
observed_head="$2"
|
|
shift 2
|
|
;;
|
|
--admin-evidence)
|
|
[ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$admin_evidence" ] || { usage; exit 2; }
|
|
admin_evidence="$2"
|
|
shift 2
|
|
;;
|
|
--confirmed-operator-admin)
|
|
[ "$confirmed_admin" = false ] || { usage; exit 2; }
|
|
confirmed_admin=true
|
|
shift
|
|
;;
|
|
--auto-merge)
|
|
[ "$auto_merge" = false ] || { usage; exit 2; }
|
|
auto_merge=true
|
|
shift
|
|
;;
|
|
--body-file)
|
|
[ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$body_path" ] || { usage; exit 2; }
|
|
body_path="$2"
|
|
shift 2
|
|
;;
|
|
--pre-dispatch-refusal)
|
|
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$refusal_directory" ] || { usage; exit 2; }
|
|
refusal_directory="$2"
|
|
shift 2
|
|
;;
|
|
--legacy-refusal)
|
|
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$legacy_directory" ] || { usage; exit 2; }
|
|
legacy_directory="$2"
|
|
shift 2
|
|
;;
|
|
--merged-head)
|
|
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
|
|
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$merged_head" ] || { usage; exit 2; }
|
|
merged_head="$2"
|
|
shift 2
|
|
;;
|
|
--replacement-head)
|
|
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
|
|
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$replacement_head" ] || { usage; exit 2; }
|
|
replacement_head="$2"
|
|
shift 2
|
|
;;
|
|
*) usage; exit 2 ;;
|
|
esac
|
|
done
|
|
if [ "$cmd" = merge-run ] && [ "${OPENCLAW_PR_AUTO_MERGE:-}" = 1 ]; then
|
|
auto_merge=true
|
|
fi
|
|
if [ -n "$admin_evidence" ] || [ "$confirmed_admin" = true ]; then
|
|
[ -n "$admin_evidence" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge" = false ] &&
|
|
[ -z "$legacy_directory$refusal_directory" ] && [ "$cancel_auto" = false ] &&
|
|
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || { usage; exit 2; }
|
|
fi
|
|
if [ "$cmd" = merge-recover ] && [ "$auto_merge" = true ]; then
|
|
[ -n "$replacement_head" ] && [ -z "$body_path$legacy_directory$refusal_directory$admin_evidence" ] &&
|
|
[ "$confirmed_admin" = false ] && [ "$cancel_auto" = false ] || { usage; exit 2; }
|
|
fi
|
|
if [ -n "$merged_head" ]; then
|
|
[ -z "$replacement_head$body_path$legacy_directory$refusal_directory$admin_evidence" ] &&
|
|
[ "$cancel_auto" = false ] && [ "$auto_merge" = false ] && [ "$confirmed_admin" = false ] || { usage; exit 2; }
|
|
fi
|
|
[ -z "$legacy_directory" ] || [ -n "$replacement_head" ] || { usage; exit 2; }
|
|
[ -z "$refusal_directory" ] || [ -z "$legacy_directory" ] || { usage; exit 2; }
|
|
[ "$cancel_auto" = false ] || [ -z "$replacement_head$body_path$legacy_directory$refusal_directory" ] || { usage; exit 2; }
|
|
[ -z "$observed_head" ] || [ "$cancel_auto" = true ] || { usage; exit 2; }
|
|
set -- "$merge_pr"
|
|
;;
|
|
prepare-push)
|
|
if [ "$#" -ne 1 ]; then
|
|
[ "$#" -eq 3 ] && [ "$2" = --resume-crabbox-run ] &&
|
|
[[ "$3" =~ ^[1-9][0-9]*$ ]] &&
|
|
{ [ "${#3}" -lt 16 ] || { [ "${#3}" -eq 16 ] && [[ "$3" < 9007199254740992 ]]; }; } || { usage; exit 2; }
|
|
fi
|
|
;;
|
|
ci-dispatch)
|
|
[ "$#" -eq 1 ] || { [ "$#" -eq 3 ] && [ "$2" = --backend ] && [ "$3" = crabbox ]; } || { usage; exit 2; }
|
|
;;
|
|
review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-sync-head | prepare-run | merge-verify)
|
|
[ "$#" -ge 1 ] || { usage; exit 2; }
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
# Reject contradictory instructions before PR reads, locks, or preparation can
|
|
# retire valid evidence. prepare-push can refresh a head and rerun gates too.
|
|
case "$cmd" in
|
|
prepare-run | prepare-gates | prepare-push)
|
|
resolve_pr_gates_remote_mode >/dev/null || return $?
|
|
;;
|
|
esac
|
|
|
|
require_cmds
|
|
|
|
case "$cmd" in
|
|
review-validate-artifacts) review_artifact_preflight "${1-}" || return 1 ;;
|
|
prepare-init | prepare-run) review_artifact_preflight "${1-}" true || return 1 ;;
|
|
prepare-correction-init) review_artifact_preflight "${1-}" correction || return 1 ;;
|
|
esac
|
|
|
|
# merge-run reconciles retained outcomes before reading disposable artifacts or base guards.
|
|
if [ "$cmd" != merge-run ] && is_main_only_pr_command "$cmd"; then
|
|
require_main_target_pr "${1-}"
|
|
fi
|
|
|
|
if is_locked_pr_command "$cmd"; then
|
|
local locked_pr="${1-}"
|
|
acquire_pr_operation_lock "$locked_pr"
|
|
begin_pr_operation_validation_phase
|
|
# Temp-backed shell redirections must fail while the supervisor can auto-release.
|
|
validate_pr_temp_storage
|
|
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ] && [ "$cmd" = prepare-push ] && [ "${2:-}" = --resume-crabbox-run ]; then
|
|
echo "Publisher resume requires an authenticated workflow source; restore its main tracking anchor before resuming. No acquisition attempted." >&2
|
|
return 1
|
|
fi
|
|
[ -z "$PR_WORKFLOW_PR" ] || ensure_gh_api_auth
|
|
admit_pr_workflow_binding "$locked_pr"
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 131' QUIT
|
|
trap 'exit 143' TERM
|
|
fi
|
|
|
|
case "$cmd" in
|
|
tooling-recover)
|
|
mark_pr_operation_side_effects_started
|
|
node "$script_parent_dir/pr-lib/materialize-dependencies.mjs" --recover "$1" "$2" "$4" \
|
|
"$(repo_root)" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID"
|
|
;;
|
|
ls)
|
|
list_pr_worktrees
|
|
;;
|
|
gc)
|
|
gc_pr_worktrees "$gc_dry_run" "$gc_pr"
|
|
;;
|
|
review-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_init "$pr"
|
|
;;
|
|
review-checkout-main)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_checkout_main "$pr"
|
|
;;
|
|
review-checkout-pr)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_checkout_pr "$pr"
|
|
;;
|
|
review-claim)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_claim "$pr"
|
|
;;
|
|
review-guard)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_guard "$pr"
|
|
;;
|
|
review-artifacts-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_artifacts_init "$pr"
|
|
;;
|
|
review-validate-artifacts)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_validate_artifacts "$pr"
|
|
;;
|
|
review-tests)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
shift || true
|
|
review_tests "$pr" "$@"
|
|
;;
|
|
prepare-correction-init)
|
|
[ "$#" -eq 1 ] || { usage; exit 2; }
|
|
prepare_init "$1" "${PR_OBSERVATION:-}" correction
|
|
;;
|
|
prepare-baseline-refresh)
|
|
prepare_baseline_refresh "$@"
|
|
;;
|
|
prepare-baseline-successor)
|
|
prepare_baseline_successor "$@"
|
|
;;
|
|
prepare-baseline-signing-request)
|
|
prepare_baseline_signing_request "$@"
|
|
;;
|
|
prepare-baseline-signing-continue)
|
|
prepare_baseline_signing_continue "$@"
|
|
;;
|
|
prepare-correction-continue)
|
|
prepare_correction_continue "$@"
|
|
;;
|
|
prepare-correction-review-init)
|
|
[ "$#" -eq 1 ] || { usage; exit 2; }
|
|
prepare_correction_review_init "$1"
|
|
;;
|
|
prepare-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_init "$pr" "${PR_OBSERVATION:-}"
|
|
;;
|
|
prepare-validate-commit)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_validate_commit "$pr"
|
|
;;
|
|
prepare-gates)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_gates "$pr"
|
|
;;
|
|
prepare-push)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_push "$pr" "" "${3:-}"
|
|
;;
|
|
prepare-sync-head)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_sync_head "$pr"
|
|
;;
|
|
prepare-run)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_run "$pr" "${PR_OBSERVATION:-}"
|
|
;;
|
|
ci-dispatch)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
shift
|
|
ci_dispatch "$pr" "$@"
|
|
;;
|
|
merge-verify)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
merge_verify "$pr" '{"replacementHead":"","autoMergeRequested":false,"qualifiedRefusal":false,"observation":null}'
|
|
;;
|
|
merge-run | merge-recover)
|
|
merge_run "$merge_pr" "$auto_merge" "$recovery_oid" "$replacement_head" "$body_path" "$legacy_directory" "$cancel_auto" "$refusal_directory" "$admin_evidence" "$confirmed_admin" "$merged_head" "$observed_head"
|
|
;;
|
|
merge-complete)
|
|
merge_complete "$1" "$2"
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|