openclaw/scripts/pr-lib/push.sh

414 lines
15 KiB
Bash

# shellcheck source=scripts/pr-lib/github.sh
source "$(cd "${BASH_SOURCE[0]%/*}" && pwd -P)/github.sh" || return 1
resolve_head_push_url() {
# shellcheck disable=SC1091
source .local/pr-meta.env
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
printf 'https://github.com/%s/%s.git\n' "$PR_HEAD_OWNER" "$PR_HEAD_REPO_NAME"
return 0
fi
if [ -n "${PR_HEAD_REPO_URL:-}" ] && [ "$PR_HEAD_REPO_URL" != "null" ]; then
case "$PR_HEAD_REPO_URL" in
*.git) printf '%s\n' "$PR_HEAD_REPO_URL" ;;
*) printf '%s.git\n' "$PR_HEAD_REPO_URL" ;;
esac
return 0
fi
return 1
}
# Push to a fork PR branch via GitHub GraphQL createCommitOnBranch.
# This uses the same permission model as the GitHub web editor, bypassing
# the git-protocol 403 that occurs even when maintainer_can_modify is true.
# Usage: graphql_push_to_fork <owner/repo> <branch> <expected_head_oid> <pr> <observation> <prepared_head>
# Pushes the diff between expected_head_oid and local HEAD as file additions/deletions.
# File bytes are read from git objects (not the working tree) to avoid
# symlink/special-file dereference risks from untrusted fork content.
verify_prep_head_extends_hosted_head() {
local expected_oid="$1"
local prepared_head="${2:-HEAD}"
if ! GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${expected_oid}^{commit}" 2>/dev/null; then
echo "Prep sync cannot resolve hosted head $expected_oid locally; re-run prepare-init." >&2
return 1
fi
if ! pr_git merge-base --is-ancestor "$expected_oid" "$prepared_head"; then
echo "Prep sync refused rewritten history: hosted head $expected_oid is not an ancestor of prepared head $prepared_head." >&2
echo "Recreate the prep branch from the hosted PR head and replay only reviewed fixup commits." >&2
return 1
fi
}
classify_replaced_hosted_ancestry() {
local hosted_head="$1"
local prepared_head="$2"
if ! GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${hosted_head}^{commit}" 2>/dev/null ||
! GIT_NO_LAZY_FETCH=1 pr_git cat-file -e "${prepared_head}^{commit}" 2>/dev/null; then
echo "Cannot inspect hosted and prepared commits; re-run prepare-init." >&2
return 1
fi
if pr_git merge-base --is-ancestor "$hosted_head" "$prepared_head"; then
printf 'false\n'
else
printf 'true\n'
fi
}
graphql_push_to_fork() {
local repo_nwo="$1"
local branch="$2"
local expected_oid="$3"
local pr="$4" observation="$5" prepared_head="$6"
local max_blob_bytes=$((5 * 1024 * 1024))
verify_prep_head_extends_hosted_head "$expected_oid" || return 1
local merge_commit
merge_commit=$(pr_git rev-list --min-parents=2 --max-count=1 "$expected_oid"..HEAD)
if [ -n "$merge_commit" ]; then
echo "GraphQL push cannot preserve merge ancestry; publish the verified signed merge with git transport." >&2
return 1
fi
local additions="[]"
local deletions="[]"
local added_files
added_files=$(pr_git diff --no-renames --name-only --diff-filter=AM "$expected_oid" HEAD)
if [ -n "$added_files" ]; then
additions="["
local first=true
while IFS= read -r fpath; do
[ -n "$fpath" ] || continue
local tree_entry
tree_entry=$(pr_git ls-tree HEAD -- "$fpath")
if [ -z "$tree_entry" ]; then
echo "GraphQL push could not resolve path in HEAD tree: $fpath" >&2
return 1
fi
local file_mode
file_mode=$(printf '%s\n' "$tree_entry" | awk '{print $1}')
local file_type
file_type=$(printf '%s\n' "$tree_entry" | awk '{print $2}')
local file_oid
file_oid=$(printf '%s\n' "$tree_entry" | awk '{print $3}')
if [ "$file_type" != "blob" ] || [ "$file_mode" = "160000" ]; then
echo "GraphQL push only supports blob files; refusing $fpath (mode=$file_mode type=$file_type)" >&2
return 1
fi
local blob_size
blob_size=$(pr_git cat-file -s "$file_oid")
if [ "$blob_size" -gt "$max_blob_bytes" ]; then
echo "GraphQL push refused large file $fpath (${blob_size} bytes > ${max_blob_bytes})" >&2
return 1
fi
local b64
b64=$(pr_git cat-file -p "$file_oid" | base64 | tr -d '\n')
if [ "$first" = true ]; then first=false; else additions+=","; fi
additions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .),\"contents\":$(printf '%s' "$b64" | jq -Rs .)}"
done <<< "$added_files"
additions+="]"
fi
local deleted_files
deleted_files=$(pr_git diff --no-renames --name-only --diff-filter=D "$expected_oid" HEAD)
if [ -n "$deleted_files" ]; then
deletions="["
local first=true
while IFS= read -r fpath; do
[ -n "$fpath" ] || continue
if [ "$first" = true ]; then first=false; else deletions+=","; fi
deletions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .)}"
done <<< "$deleted_files"
deletions+="]"
fi
local commit_headline
commit_headline=$(pr_git log -1 --format=%s HEAD)
local commit_body
commit_body=$(pr_git log -1 --format=%b HEAD)
local query
query=$(cat <<'GRAPHQL'
mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit { oid url }
}
}
GRAPHQL
)
local additions_file deletions_file
additions_file=$(mktemp)
deletions_file=$(mktemp)
printf '%s\n' "$additions" >"$additions_file"
printf '%s\n' "$deletions" >"$deletions_file"
local variables
variables=$(jq -n \
--arg nwo "$repo_nwo" \
--arg branch "$branch" \
--arg oid "$expected_oid" \
--arg headline "$commit_headline" \
--arg body "$commit_body" \
--slurpfile additions "$additions_file" \
--slurpfile deletions "$deletions_file" \
'{input: {
branch: { repositoryNameWithOwner: $nwo, branchName: $branch },
message: { headline: $headline, body: $body },
fileChanges: { additions: $additions[0], deletions: $deletions[0] },
expectedHeadOid: $oid
}}')
rm -f "$additions_file" "$deletions_file"
local variables_file
variables_file=$(mktemp)
printf '%s\n' "$variables" >"$variables_file"
local payload
payload=$(jq -n --arg query "$query" --slurpfile variables "$variables_file" \
'{query: $query, variables: $variables[0]}')
rm -f "$variables_file"
local payload_file
payload_file=$(mktemp) || return 1
printf '%s\n' "$payload" > "$payload_file"
if ! revalidate_pr_publication "$pr" "$observation" "$branch" "$expected_oid" "$prepared_head"; then
rm -f "$payload_file"
return 1
fi
local result
if [ -n "${PREP_PUBLICATION_REVIEW_SNAPSHOT:-}" ]; then
verify_correction_publication_authority || { rm -f "$payload_file"; return 1; }
fi
result=$(pr_gh_plain api graphql --input "$payload_file" 2>&1) || {
rm -f "$payload_file"
echo "GraphQL push failed: $result" >&2
return 1
}
rm -f "$payload_file"
local new_oid
new_oid=$(printf '%s' "$result" | jq -r '.data.createCommitOnBranch.commit.oid // empty')
if [ -z "$new_oid" ]; then
echo "GraphQL push returned no commit OID: $result" >&2
return 1
fi
echo "GraphQL push succeeded: $new_oid" >&2
printf '%s\n' "$new_oid"
}
verify_pr_publication_identity() {
local pr="$1" before="$2" after="$3"
local identity='{number,url,baseRefName,baseRepository,headRefName,headRepository,headRepositoryOwner,isCrossRepository}'
if [ "$(printf '%s\n' "$after" | jq -r .state)" != "OPEN" ] ||
[ "$(printf '%s\n' "$before" | jq -cS "$identity")" != "$(printf '%s\n' "$after" | jq -cS "$identity")" ]; then
echo "PR identity changed before publication verification for #$pr. Re-run review-init and prepare-init." >&2
return 1
fi
}
revalidate_pr_publication() {
local pr="$1" observation="$2" head_ref="$3" lease_sha="$4" prepared_sha="$5"
pr_observe "$pr" || return 1
verify_pr_publication_identity "$pr" "$observation" "$PR_OBSERVATION" || return 1
local observed_sha
observed_sha=$(pr_view_string_field "$PR_OBSERVATION" headRefOid "$pr" "Re-run prepare-init.") || return 1
if [ "$(printf '%s\n' "$PR_OBSERVATION" | jq -r .headRefName)" != "$head_ref" ] ||
{ [ "$observed_sha" != "$lease_sha" ] && [ "$observed_sha" != "$prepared_sha" ]; }; then
echo "PR head changed before publication (expected $lease_sha or prepared $prepared_sha, observed $observed_sha). Re-run review-init and prepare-init." >&2
return 1
fi
}
PRHEAD_REMOTE_URL=""
PRHEAD_REMOTE_SHA=""
setup_prhead_remote() {
local push_url
push_url=$(resolve_head_push_url) || {
echo "Unable to resolve PR head repo push URL."
exit 1
}
# Git remotes live in shared repository config across every linked worktree.
# Keep this URL process-local so concurrent PR lanes cannot redirect a push.
PRHEAD_REMOTE_URL="$push_url"
}
resolve_prhead_remote_sha() {
local pr_head="$1"
local remote_sha
remote_sha=$(pr_git ls-remote "$PRHEAD_REMOTE_URL" "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
if [ -z "$remote_sha" ]; then
echo "Remote branch refs/heads/$pr_head not found on prhead" >&2
exit 1
fi
PRHEAD_REMOTE_SHA="$remote_sha"
}
verify_prep_first_parent_range_signed() {
local base_sha="$1"
local prep_head_sha="$2"
pr_git merge-base --is-ancestor "$base_sha" "$prep_head_sha" || return 1
local commits
commits=$(pr_git rev-list --first-parent "$base_sha..$prep_head_sha") || return 1
local commit
while IFS= read -r commit; do
[ -n "$commit" ] || continue
pr_git verify-commit "$commit" >/dev/null 2>&1 || return 1
done <<< "$commits"
}
push_prep_head_once() {
local pr_head="$1"
local lease_sha="$2"
local prep_head_sha="$3"
local pr="$4" observation="$5"
local push_mode="${OPENCLAW_PR_PUSH_MODE:-auto}"
if [ "$push_mode" = "auto" ] && verify_prep_first_parent_range_signed "$lease_sha" "$prep_head_sha"; then
push_mode="git"
elif [ "$push_mode" = "auto" ]; then
push_mode="graphql"
fi
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ] && [ "$push_mode" != "git" ]; then
echo "Pushing PR branch through GitHub createCommitOnBranch so the prepared commit is verified." >&2
graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha" "$pr" "$observation" "$prep_head_sha"
return $?
fi
if ! verify_prep_first_parent_range_signed "$lease_sha" "$prep_head_sha" && [ "${OPENCLAW_ALLOW_UNSIGNED_GIT_PUSH:-}" != "1" ]; then
echo "Refusing git-protocol PR branch push because it can publish unsigned commits." >&2
echo "Sign the prep commit, use GitHub createCommitOnBranch, or set OPENCLAW_ALLOW_UNSIGNED_GIT_PUSH=1 for an explicit manual override." >&2
return 2
fi
revalidate_pr_publication "$pr" "$observation" "$pr_head" "$lease_sha" "$prep_head_sha" || return 1
local push_output push_status
if [ -n "${PREP_PUBLICATION_REVIEW_SNAPSHOT:-}" ]; then
verify_correction_publication_authority || return 1
fi
if push_output=$(pr_git push "--force-with-lease=refs/heads/$pr_head:$lease_sha" "$PRHEAD_REMOTE_URL" "$prep_head_sha:refs/heads/$pr_head" 2>&1); then
printf '%s\n' "$push_output" >&2
else
push_status=$?
printf '%s\n' "$push_output" >&2
# Only an actual Git permission failure may change transports. GraphQL
# failures and uncertain Git outcomes never authorize another publication.
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)' &&
[ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
echo "Permission denied on git push; trying GraphQL with the same lease." >&2
graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha" "$pr" "$observation" "$prep_head_sha"
return $?
fi
return "$push_status"
fi
printf '%s\n' "$prep_head_sha"
}
push_prep_head_to_pr_branch() {
local pr="$1"
local pr_head="$2"
local prep_head_sha="$3"
local lease_sha="$4"
local result_env_path="${5:-.local/push-result.env}"
local observation="${6:-}"
if [ -z "$observation" ]; then
require_artifact .local/pr-meta.json || return 1
observation=$(cat .local/pr-meta.json) || return 1
fi
use_pr_observation "$pr" "$observation" || return 1
local local_prep_head_sha
local_prep_head_sha=$(pr_git rev-parse HEAD) || return 1
verify_prep_head_extends_hosted_head "$lease_sha" "$prep_head_sha" || return 1
setup_prhead_remote
resolve_prhead_remote_sha "$pr_head"
local remote_sha="$PRHEAD_REMOTE_SHA"
local pushed_from_sha="$lease_sha"
if [ "$remote_sha" = "$prep_head_sha" ]; then
revalidate_pr_publication "$pr" "$observation" "$pr_head" "$lease_sha" "$prep_head_sha" || return 1
echo "Remote branch already at local prep HEAD; skipping push."
else
if [ "$remote_sha" != "$lease_sha" ]; then
echo "Remote PR head changed (expected $lease_sha, observed $remote_sha). Re-run review-init and prepare-init."
return 1
fi
local push_output
if push_output=$(push_prep_head_once "$pr_head" "$lease_sha" "$prep_head_sha" "$pr" "$observation" 2>&1); then
prep_head_sha=$(printf '%s\n' "$push_output" | tail -n 1)
else
local push_status=$?
echo "Push failed: $push_output"
echo "Publication stopped; retain prepare artifacts and inspect the remote outcome before continuing."
return "$push_status"
fi
fi
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
local observed_sha
observed_sha=$(printf '%s\n' "$PR_OBSERVATION" | jq -r .headRefOid)
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
exit 1
fi
local pr_head_sha_after
pr_head_sha_after=$(pr_view_string_field "$PR_OBSERVATION" headRefOid "$pr") || return 1
verify_pr_publication_identity "$pr" "$observation" "$PR_OBSERVATION" || return 1
fetch_pr_head "$pr" "$prep_head_sha" "refs/heads/pr-$pr-verify" "$PR_OBSERVATION" || return 1
local local_prep_tree
local remote_prep_tree
local_prep_tree=$(pr_git rev-parse "${local_prep_head_sha}^{tree}")
remote_prep_tree=$(pr_git rev-parse "pr-$pr-verify^{tree}")
pr_git branch -D "pr-$pr-verify" 2>/dev/null || true
if [ "$local_prep_tree" != "$remote_prep_tree" ]; then
echo "Pushed PR head tree differs from the prepared local tree."
exit 1
fi
local replaced_hosted_ancestry
replaced_hosted_ancestry=$(classify_replaced_hosted_ancestry "$pushed_from_sha" "$prep_head_sha") || return 1
if [ -n "${PREP_PUBLICATION_REVIEW_SNAPSHOT:-}" ]; then
verify_correction_review_snapshot "$PREP_PUBLICATION_PR" "$PREP_PUBLICATION_REVIEW_SNAPSHOT" || return 1
fi
# A verified retry retains its original publication lease. Another operation
# may own the selected receipt, so replace this file if it records an older pair.
if [ "$remote_sha" = "$prep_head_sha" ] && { [ -e "$result_env_path" ] || [ -L "$result_env_path" ]; }; then
local PUSH_PREP_HEAD_SHA="" PUSH_LOCAL_PREP_HEAD_SHA="" PUSHED_FROM_SHA=""
local PUSH_REPLACED_HOSTED_ANCESTRY="" PR_HEAD_SHA_AFTER_PUSH=""
read_prep_publication_result "$result_env_path" || return 1
if [ "$PUSH_PREP_HEAD_SHA" = "$prep_head_sha" ] && [ "$PUSH_LOCAL_PREP_HEAD_SHA" = "$local_prep_head_sha" ]; then
return 0
fi
fi
# merge-verify owns relevance-aware mainline drift checks. Requiring every
# prepared head to contain main here forces needless rebases, while GraphQL
# createCommitOnBranch cannot move a rebased branch's commit ancestry.
# Security: shell-escape values to prevent command injection when sourced.
printf '%s=%q\n' \
PUSH_PREP_HEAD_SHA "$prep_head_sha" \
PUSH_LOCAL_PREP_HEAD_SHA "$local_prep_head_sha" \
PUSHED_FROM_SHA "$pushed_from_sha" \
PUSH_REPLACED_HOSTED_ANCESTRY "$replaced_hosted_ancestry" \
PR_HEAD_SHA_AFTER_PUSH "$pr_head_sha_after" \
> "$result_env_path"
}