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:
@ -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).
|
||||||
|
|||||||
@ -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}"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user