fix(recipe-upgrade): default to extending an existing open upgrade PR, not a parallel one

When an open upgrade PR already exists for a recipe (branch upgrade-*), push
the new work onto ITS branch and update+re-test that PR — one evolving
upgrade PR per recipe instead of spawning a second parallel PR. Only open a
fresh upgrade-<version> PR when none exists. Unrelated open PRs (e.g. backup
fixes) are still never touched; merged-upstream PRs still close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
autonomic-bot
2026-06-02 01:54:58 +00:00
parent 35f83a4b74
commit 5f814307ad
2 changed files with 63 additions and 46 deletions

View File

@ -76,9 +76,11 @@ ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GIT
< /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
``` ```
This (a) re-syncs mirror `main` to true upstream main, (b) **closes any open PR already merged This (a) re-syncs mirror `main` to true upstream main, (b) **closes any open PR already merged
upstream** (merging it would be a no-op), then pushes the upgrade branch and opens the new PR upstream** (merging it would be a no-op), then handles the upgrade PR: **if an open upgrade PR already
**alongside** any other still-open PRs — it does **NOT** close superseded/unrelated open PRs; the exists for this recipe (branch `upgrade-*`), the new work is pushed onto THAT PR's branch and the PR is
operator decides which to merge/close. The new PR's diff is still exactly this upgrade against the updated + re-tested** — one evolving upgrade PR per recipe, not a second parallel one. Only if none
exists is a fresh `upgrade-<version>` PR opened. Unrelated open PRs (e.g. a backup fix) are **NOT**
closed or touched — the operator decides on those. The PR's diff is exactly this upgrade against the
real current upstream main. Capture the `PR_URL`. Optionally export `RECIPE_PR_BODY` first (image-tag real current upstream main. Capture the `PR_URL`. Optionally export `RECIPE_PR_BODY` first (image-tag
table + the planned operator-action notes). Re-running with the same target version updates the table + the planned operator-action notes). Re-running with the same target version updates the
existing same-branch PR rather than duplicating it. existing same-branch PR rather than duplicating it.
@ -152,7 +154,8 @@ Always state explicitly that **nothing was merged** — the PR(s) await operator
`/upgrade-all` cron always runs the **default** — it never auto-updates tests. `/upgrade-all` cron always runs the **default** — it never auto-updates tests.
- **Mirror reflects reality.** Each run force-syncs the `recipe-maintainers/<recipe>` mirror `main` to - **Mirror reflects reality.** Each run force-syncs the `recipe-maintainers/<recipe>` mirror `main` to
true upstream main and closes open PRs already merged upstream. It does NOT close superseded/unrelated true upstream main and closes open PRs already merged upstream. It does NOT close superseded/unrelated
open PRs — a new upgrade PR is opened alongside them and the operator decides which to merge/close. open PRs. **An existing open upgrade PR is extended (new work pushed onto its branch + re-tested)
rather than spawning a second upgrade PR** — one evolving upgrade PR per recipe.
- **Prefer a recipe-only PR.** Only open a cc-ci test PR (under `--with-tests`) when the upgrade is - **Prefer a recipe-only PR.** Only open a cc-ci test PR (under `--with-tests`) when the upgrade is
correct but a test is genuinely stale — never to paper over a real upgrade regression. correct but a test is genuinely stale — never to paper over a real upgrade regression.
- **Never weaken a test**; **bounded** changes (the upgrade + minimal test update, not rewrites). - **Never weaken a test**; **bounded** changes (the upgrade + minimal test update, not rewrites).

View File

@ -19,10 +19,11 @@
# never reflected it. # never reflected it.
# #
# Default mode (open a PR — preconditions: HEAD has the upgrade commit(s)): # Default mode (open a PR — preconditions: HEAD has the upgrade commit(s)):
# - Pushes the upgrade branch and opens the new PR ALONGSIDE any other still-open # - If an open UPGRADE PR already exists for this recipe (branch 'upgrade-*'), the new work is
# PRs for this recipe. It does NOT close superseded/unrelated open PRs — stacking # pushed onto THAT PR's branch and the PR is updated + re-tested — ONE evolving upgrade PR per
# a fresh PR on top of the open ones is intentional; the operator decides which to # recipe, NOT a second parallel PR. Otherwise a fresh 'upgrade-<version>' branch + PR is opened.
# merge/close. (Only PRs already merged into upstream main are closed — see above.) # - Unrelated open PRs (e.g. a backup fix) are NEVER closed or touched — left for the operator.
# - Only PRs already merged into upstream main are closed (no-op merges) — see above.
# #
# --reconcile-only: just sync main + close merged-upstream PRs; no push, no new # --reconcile-only: just sync main + close merged-upstream PRs; no push, no new
# PR (used when a recipe is up to date but its mirror may be stale). # PR (used when a recipe is up to date but its mirror may be stale).
@ -79,18 +80,11 @@ echo "→ Force-syncing gitea/main to upstream main (${NEW_MAIN_SHA:0:8})..."
git push --force gitea "${NEW_MAIN_SHA}:refs/heads/main" git push --force gitea "${NEW_MAIN_SHA}:refs/heads/main"
git fetch --quiet gitea '+refs/heads/*:refs/remotes/gitea/*' || true git fetch --quiet gitea '+refs/heads/*:refs/remotes/gitea/*' || true
# --- Determine the branch we're about to (re)open, if in open mode --- # --- Preconditions for open mode ---
BRANCH=""
if [ "${MODE}" != "--reconcile-only" ]; then if [ "${MODE}" != "--reconcile-only" ]; then
DIVERGED=$(git log --oneline origin/main..HEAD 2>/dev/null || true) DIVERGED=$(git log --oneline origin/main..HEAD 2>/dev/null || true)
[ -n "${DIVERGED}" ] || { echo "ERROR: HEAD has no commits beyond origin/main. Nothing to PR."; exit 1; } [ -n "${DIVERGED}" ] || { echo "ERROR: HEAD has no commits beyond origin/main. Nothing to PR."; exit 1; }
LATEST_MSG=$(git log -1 --pretty=%s HEAD) LATEST_MSG=$(git log -1 --pretty=%s HEAD)
if echo "${LATEST_MSG}" | grep -qiE "upgrade to [0-9]"; then
VERSION=$(echo "${LATEST_MSG}" | grep -oiE "upgrade to [0-9][^[:space:]]+" | awk '{print $NF}')
BRANCH="upgrade-${VERSION}"
else
BRANCH="upgrade-$(git rev-parse --short HEAD)"
fi
fi fi
# --- Reconcile open PRs against the freshly-synced upstream main --- # --- Reconcile open PRs against the freshly-synced upstream main ---
@ -104,28 +98,27 @@ close_pr() { # <index> <reason>
echo " ✗ closed PR #${idx}${reason}" echo " ✗ closed PR #${idx}${reason}"
} }
merged_upstream() { # <head_ref> -> 0 if the branch's tree already equals upstream main (no-op merge)
local ref="$1" mt
git rev-parse --verify --quiet "refs/remotes/gitea/${ref}" >/dev/null || return 1
mt=$(git merge-tree --write-tree "${NEW_MAIN_SHA}" "refs/remotes/gitea/${ref}" 2>/dev/null) || return 1
[ -n "${mt}" ] && [ "${mt}" = "${MAIN_TREE}" ]
}
echo "→ Reconciling open PRs on ${NAMESPACE}/${RECIPE}..." echo "→ Reconciling open PRs on ${NAMESPACE}/${RECIPE}..."
OPEN_PRS=$(curl -s "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}/pulls?state=open&base=main&limit=50") OPEN_PRS=$(curl -s "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}/pulls?state=open&base=main&limit=50")
# Emit "index<TAB>head_ref" per open PR. # Close merged-upstream PRs (no-op merges); leave everything else open. Also pick an existing open
# UPGRADE PR (branch 'upgrade-*' — this tool's convention) to EXTEND, if one exists.
EXTEND_PR=""; EXTEND_BRANCH=""
while IFS=$'\t' read -r IDX HEAD_REF; do while IFS=$'\t' read -r IDX HEAD_REF; do
[ -n "${IDX:-}" ] || continue [ -n "${IDX:-}" ] || continue
# Same branch we're re-opening? leave it — the force-push below updates it. if merged_upstream "${HEAD_REF}"; then
if [ -n "${BRANCH}" ] && [ "${HEAD_REF}" = "${BRANCH}" ]; then close_pr "${IDX}" "its changes are already in upstream main (merged upstream); mirror main re-synced"
echo " • PR #${IDX} is on ${BRANCH} (the branch being updated) — keeping"
continue continue
fi fi
# Is this PR already a no-op against upstream main (merged upstream)? echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — leaving open"
merged=0 if [ "${MODE}" != "--reconcile-only" ] && [ -z "${EXTEND_PR}" ] && printf '%s' "${HEAD_REF}" | grep -q '^upgrade-'; then
if git rev-parse --verify --quiet "refs/remotes/gitea/${HEAD_REF}" >/dev/null; then EXTEND_PR="${IDX}"; EXTEND_BRANCH="${HEAD_REF}"
mt=$(git merge-tree --write-tree "${NEW_MAIN_SHA}" "refs/remotes/gitea/${HEAD_REF}" 2>/dev/null) || mt=""
[ -n "${mt}" ] && [ "${mt}" = "${MAIN_TREE}" ] && merged=1
fi
if [ "${merged}" = "1" ]; then
close_pr "${IDX}" "its changes are already in upstream main (merged upstream); mirror main re-synced"
else
# Do NOT auto-close superseded/unrelated open PRs. Open the new PR alongside them and
# let the operator decide which to merge/close. Only merged-upstream PRs are closed above.
echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — leaving open (operator decides)"
fi fi
done < <(echo "${OPEN_PRS}" | jq -r '.[] | [(.number | tostring), .head.ref] | @tsv' 2>/dev/null || true) done < <(echo "${OPEN_PRS}" | jq -r '.[] | [(.number | tostring), .head.ref] | @tsv' 2>/dev/null || true)
@ -134,7 +127,19 @@ if [ "${MODE}" = "--reconcile-only" ]; then
exit 0 exit 0
fi fi
# --- Push the upgrade branch and open the new PR in place of the closed ones --- # --- DEFAULT: if an open upgrade PR exists, push the new work onto ITS branch and re-test it, so
# there's ONE evolving upgrade PR per recipe rather than parallel ones. Only if none exists do we
# open a fresh upgrade-<version> PR. Unrelated open PRs are never touched. ---
if [ -n "${EXTEND_PR}" ]; then
BRANCH="${EXTEND_BRANCH}"
echo "→ Extending existing open upgrade PR #${EXTEND_PR} (branch ${BRANCH}) with the new work — it will be re-tested; NOT opening a separate PR."
elif echo "${LATEST_MSG}" | grep -qiE "upgrade to [0-9]"; then
VERSION=$(echo "${LATEST_MSG}" | grep -oiE "upgrade to [0-9][^[:space:]]+" | awk '{print $NF}')
BRANCH="upgrade-${VERSION}"
else
BRANCH="upgrade-$(git rev-parse --short HEAD)"
fi
echo "→ Pushing branch '${BRANCH}'..." echo "→ Pushing branch '${BRANCH}'..."
git push --force gitea "HEAD:refs/heads/${BRANCH}" git push --force gitea "HEAD:refs/heads/${BRANCH}"
@ -144,19 +149,28 @@ PR_BODY="${PR_BODY}
Tested green on the cc-ci recipe CI server (full suite, cold, against this PR head). NOT merged — for operator review. Tested green on the cc-ci recipe CI server (full suite, cold, against this PR head). NOT merged — for operator review.
cc @trav @notplants" cc @trav @notplants"
PAYLOAD=$(jq -n \
--arg title "${LATEST_MSG}" \
--arg body "${PR_BODY}" \
--arg head "${BRANCH}" \
'{title:$title,body:$body,head:$head,base:"main",reviewers:["trav","notplants"]}')
RESP=$(mktemp) if [ -n "${EXTEND_PR}" ]; then
PS=$(curl -s -o "${RESP}" -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/repos/${NAMESPACE}/${RECIPE}/pulls" -d "${PAYLOAD}") # The existing PR's branch now points at the new work, so it re-tests on the next !testme.
if [ "${PS}" = "201" ]; then # Refresh its title + body to match the new work.
echo "PR_URL=$(jq -r '.html_url' "${RESP}")" curl -s -o /dev/null "${AUTH[@]}" -H "Content-Type: application/json" -X PATCH \
elif [ "${PS}" = "409" ] || grep -q "pull request already exists" "${RESP}" 2>/dev/null; then "${API}/repos/${NAMESPACE}/${RECIPE}/pulls/${EXTEND_PR}" \
echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls (branch ${BRANCH} already has a PR — updated by force-push)" -d "$(jq -n --arg t "${LATEST_MSG}" --arg b "${PR_BODY}" '{title:$t,body:$b}')" || true
echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls/${EXTEND_PR} (extended existing PR — re-test it)"
else else
echo "ERROR: PR creation failed (HTTP ${PS}):"; cat "${RESP}"; rm -f "${RESP}"; exit 1 PAYLOAD=$(jq -n \
--arg title "${LATEST_MSG}" \
--arg body "${PR_BODY}" \
--arg head "${BRANCH}" \
'{title:$title,body:$body,head:$head,base:"main",reviewers:["trav","notplants"]}')
RESP=$(mktemp)
PS=$(curl -s -o "${RESP}" -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/repos/${NAMESPACE}/${RECIPE}/pulls" -d "${PAYLOAD}")
if [ "${PS}" = "201" ]; then
echo "PR_URL=$(jq -r '.html_url' "${RESP}")"
elif [ "${PS}" = "409" ] || grep -q "pull request already exists" "${RESP}" 2>/dev/null; then
echo "PR_URL=https://${GITEA_URL}/${NAMESPACE}/${RECIPE}/pulls (branch ${BRANCH} already has a PR — updated by force-push)"
else
echo "ERROR: PR creation failed (HTTP ${PS}):"; cat "${RESP}"; rm -f "${RESP}"; exit 1
fi
rm -f "${RESP}"
fi fi
rm -f "${RESP}"