recipe-upgrade: reconcile mirror to upstream main + close merged/superseded PRs

Per operator: an open mirror PR must mean "genuinely still open against true
current upstream main". On every run the recipe-upgrade flow now:
- force-syncs the recipe-maintainers/<recipe> mirror `main` to be IDENTICAL to
  upstream main (origin/main of the abra checkout = coopcloud);
- closes any open mirror PR whose changes are already in upstream main (merged
  upstream, no-op merge detected via `git merge-tree` vs main's tree) — even
  when the recipe is up to date (new `--reconcile-only` mode, run in step 1);
- when opening a new upgrade PR, closes any other still-open PR for that recipe
  (superseded) and opens the new one IN ITS PLACE; same-version re-runs just
  update the existing same-branch PR.
open-recipe-pr.sh gains the --reconcile-only mode + the close logic (with an
auto-close comment naming the reason). upgrade-all reconciles every candidate's
mirror during the survey so merged PRs are closed fleet-wide. Still never merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:32:34 +01:00
parent a8b4b4c39e
commit 62b7af7a97
3 changed files with 130 additions and 32 deletions

View File

@ -31,7 +31,17 @@ ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; \
abra recipe upgrade <recipe> -m -n'
```
- **Dirty worktree on cc-ci → abort this recipe** (status `SKIPPED — dirty-worktree`); don't prompt.
- **No upgrades available → stop** (status `SKIPPED — up-to-date`).
- **Reconcile the mirror first (always, even if up to date).** Run:
```
set -a; . /srv/cc-ci/.testenv; set +a
ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GITEA_URL='$GITEA_URL' bash -s <recipe> --reconcile-only" \
< /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
```
This force-syncs the `recipe-maintainers/<recipe>` mirror `main` to be **identical to true upstream
main**, and **closes any open mirror PR whose changes are already in upstream main** (merged
upstream — the mirror just never reflected it). Do this before the up-to-date check so a stale
already-merged PR gets cleaned up even when there's nothing new to upgrade.
- **No upgrades available → stop** (status `SKIPPED — up-to-date`) — after the reconcile above.
- Check `git log HEAD..origin/main` and upstream PRs (`git.coopcloud.tech/coop-cloud/<recipe>/pulls`)
— if someone already started the bump, **re-plan from the tip of `origin/main`**, not from scratch.
- For each service with an upgrade, fetch upstream **release notes** (WebFetch) between current and
@ -56,9 +66,12 @@ set -a; . /srv/cc-ci/.testenv; set +a
ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GITEA_URL='$GITEA_URL' bash -s <recipe>" \
< /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
```
This pushes the upgrade branch to `recipe-maintainers/<recipe>` (force-syncing mirror `main` from
upstream so the diff is exactly the upgrade) and opens the PR. Capture the `PR_URL`. Optionally export
`RECIPE_PR_BODY` first (image-tag table + the planned operator-action notes).
This (a) re-syncs mirror `main` to true upstream main, (b) **closes any open PR already merged
upstream**, (c) **closes any other still-open PR for this recipe as superseded**, then pushes the
upgrade branch and opens the new PR **in its place** (so the diff is exactly this upgrade, against the
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
existing same-branch PR rather than duplicating it.
### 4. VERIFY the upgrade on the CI server (the gate)
Run the cc-ci full suite **cold** against the PR head — the dogfood gate from `ci-test-review`:
@ -103,6 +116,9 @@ Always state explicitly that **nothing was merged** — the PR(s) await operator
## Guardrails
- **cc-ci is the gate** — deterministic harness decides pass/fail; AI plans, implements, diagnoses.
- **Create + verify, NEVER merge.** Recipe PR (and any cc-ci test PR) are operator-merged after review.
- **Mirror reflects reality.** Each run force-syncs the `recipe-maintainers/<recipe>` mirror `main` to
true upstream main, closes open PRs already merged upstream, and replaces any superseded open PR with
the new one — so an open mirror PR always means "genuinely still open against current upstream main".
- **Prefer a recipe-only PR.** Only open a cc-ci test PR when the upgrade is correct but a test is
genuinely stale — not to paper over a real upgrade regression.
- **Never weaken a test**; **bounded** changes (the upgrade + minimal test update, not rewrites).

View File

@ -1,22 +1,35 @@
#!/usr/bin/env bash
# recipe-upgrade :: open a recipe PR on git.autonomic.zone (adapted from
# recipe-maintainer's recipe-create-pr for the cc-ci context).
# recipe-upgrade :: reconcile the git.autonomic.zone mirror + open a recipe PR
# (adapted from recipe-maintainer's recipe-create-pr for the cc-ci context).
# -------------------------------------------------------------------------
# RUNS ON cc-ci (it has the recipe checkout at ~/.abra/recipes/<recipe>).
# Invoke from the orchestrator, feeding creds + the script over ssh:
# RUNS ON cc-ci (it has the recipe checkout at ~/.abra/recipes/<recipe> and the
# bot token). Invoke from the orchestrator, feeding creds + the script over ssh:
#
# set -a; . /srv/cc-ci/.testenv; set +a
# ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' \
# GITEA_URL='$GITEA_URL' bash -s <recipe>" \
# GITEA_URL='$GITEA_URL' bash -s <recipe> [--reconcile-only]" \
# < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
#
# Preconditions on cc-ci: ~/.abra/recipes/<recipe> is a git checkout with the
# upgrade commit(s) on HEAD beyond origin/main (the apply step made them).
# Force-syncs the gitea mirror's main from upstream origin/main so the PR diff
# is exactly the upgrade. Prints the PR URL. Never merges.
# Always (both modes):
# - Force-syncs the mirror's `main` to be IDENTICAL to true upstream `main`
# (origin/main of the abra checkout = git.coopcloud.tech). This guarantees
# every open PR is measured against the real current upstream.
# - Closes any open mirror PR whose changes are ALREADY IN upstream main
# (i.e. merging it is a no-op) — it was merged upstream; the mirror just
# never reflected it.
#
# Default mode (open a PR — preconditions: HEAD has the upgrade commit(s)):
# - Additionally closes any OTHER still-open PR for this recipe (superseded),
# then pushes the upgrade branch and opens the new PR IN ITS PLACE.
#
# --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).
#
# NEVER merges anything.
set -o errexit -o nounset -o pipefail
RECIPE="${1:?usage: open-recipe-pr.sh <recipe>}"
RECIPE="${1:?usage: open-recipe-pr.sh <recipe> [--reconcile-only]}"
MODE="${2:-open}"
: "${GITEA_USERNAME:?missing GITEA_USERNAME (pass via ssh env)}"
: "${GITEA_PASSWORD:?missing GITEA_PASSWORD}"
: "${GITEA_URL:?missing GITEA_URL}"
@ -31,25 +44,18 @@ AUTH=(-u "${GITEA_USERNAME}:${GITEA_PASSWORD}")
[ -d "${RECIPE_DIR}/.git" ] || { echo "ERROR: ${RECIPE_DIR} is not a git repo (run 'abra recipe fetch ${RECIPE}')"; exit 1; }
cd "${RECIPE_DIR}"
# --- The true current upstream main ---
echo "→ Fetching upstream main..."
git fetch origin main
git fetch --quiet origin main
NEW_MAIN_SHA=$(git rev-parse refs/remotes/origin/main)
MAIN_TREE=$(git rev-parse "${NEW_MAIN_SHA}^{tree}")
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; }
echo "→ Local commits to PR:"; echo "${DIVERGED}" | sed 's/^/ /'
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
echo "→ PR branch: ${BRANCH}"
# Check / create the mirror repo.
# --- Ensure the mirror repo exists ---
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}")
if [ "${STATUS}" = "404" ]; then
if [ "${MODE}" = "--reconcile-only" ]; then
echo "→ Mirror ${NAMESPACE}/${RECIPE} does not exist; nothing to reconcile."; exit 0
fi
echo "→ Mirror ${NAMESPACE}/${RECIPE} missing; creating..."
BODY=$(python3 -c "import json;print(json.dumps({'name':'${RECIPE}','private':True,'default_branch':'main','auto_init':False}))")
CS=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/orgs/${NAMESPACE}/repos" -d "${BODY}")
@ -66,8 +72,73 @@ fi
REMOTE_URL="https://${GITEA_USERNAME}:${PASS_ENC}@${GITEA_URL}/${NAMESPACE}/${RECIPE}.git"
git remote | grep -qx gitea && git remote set-url gitea "${REMOTE_URL}" || git remote add gitea "${REMOTE_URL}"
echo "→ Force-syncing gitea/main from origin/main (clean diff)..."
git push --force gitea "refs/remotes/origin/main:refs/heads/main"
# --- Mirror main := true upstream main (identical) ---
echo "→ Force-syncing gitea/main to upstream main (${NEW_MAIN_SHA:0:8})..."
git push --force gitea "${NEW_MAIN_SHA}:refs/heads/main"
git fetch --quiet gitea '+refs/heads/*:refs/remotes/gitea/*' || true
# --- Determine the branch we're about to (re)open, if in open mode ---
BRANCH=""
if [ "${MODE}" != "--reconcile-only" ]; then
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; }
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
# --- Reconcile open PRs against the freshly-synced upstream main ---
close_pr() { # <index> <reason>
local idx="$1" reason="$2"
curl -s -o /dev/null "${AUTH[@]}" -H "Content-Type: application/json" -X POST \
"${API}/repos/${NAMESPACE}/${RECIPE}/issues/${idx}/comments" \
-d "$(python3 -c "import json,sys;print(json.dumps({'body':sys.argv[1]}))" "Auto-closed by /recipe-upgrade: ${reason}")" || true
curl -s -o /dev/null "${AUTH[@]}" -H "Content-Type: application/json" -X PATCH \
"${API}/repos/${NAMESPACE}/${RECIPE}/pulls/${idx}" -d '{"state":"closed"}' || true
echo " ✗ closed PR #${idx}${reason}"
}
echo "→ Reconciling open PRs on ${NAMESPACE}/${RECIPE}..."
OPEN_PRS=$(curl -s "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}/pulls?state=open&base=main&limit=50")
# Emit "index<TAB>head_ref" per open PR.
while IFS=$'\t' read -r IDX HEAD_REF; do
[ -n "${IDX:-}" ] || continue
# Same branch we're re-opening? leave it — the force-push below updates it.
if [ -n "${BRANCH}" ] && [ "${HEAD_REF}" = "${BRANCH}" ]; then
echo " • PR #${IDX} is on ${BRANCH} (the branch being updated) — keeping"
continue
fi
# Is this PR already a no-op against upstream main (merged upstream)?
merged=0
if git rev-parse --verify --quiet "refs/remotes/gitea/${HEAD_REF}" >/dev/null; then
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"
elif [ -n "${BRANCH}" ]; then
close_pr "${IDX}" "superseded by ${BRANCH} (fresh upgrade against current upstream main)"
else
echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — left as-is (reconcile-only)"
fi
done < <(echo "${OPEN_PRS}" | python3 -c "
import json,sys
try: data=json.load(sys.stdin)
except Exception: data=[]
for pr in data:
print(f\"{pr['number']}\t{pr['head']['ref']}\")
")
if [ "${MODE}" = "--reconcile-only" ]; then
echo "✓ reconcile-only done for ${NAMESPACE}/${RECIPE} (main synced; merged-upstream PRs closed)."
exit 0
fi
# --- Push the upgrade branch and open the new PR in place of the closed ones ---
echo "→ Pushing branch '${BRANCH}'..."
git push --force gitea "HEAD:refs/heads/${BRANCH}"
@ -87,7 +158,7 @@ PS=$(curl -s -o "${RESP}" -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: appli
if [ "${PS}" = "201" ]; then
echo "PR_URL=$(python3 -c "import json;print(json.load(open('${RESP}'))['html_url'])")"
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)"
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

View File

@ -32,6 +32,17 @@ ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; \
Build `RECIPES_TO_UPGRADE` = recipes with a **clean worktree** AND **≥1 available upgrade**. Others go
to `SKIPPED_UPFRONT` with a reason (`dirty-worktree`, `up-to-date`, `not-fetchable`).
**Reconcile every candidate's mirror during the survey** (even the up-to-date ones), so merged-upstream
PRs are closed and every mirror `main` tracks true upstream — fleet-wide, not just where there's a new
upgrade:
```
set -a; . /srv/cc-ci/.testenv; set +a
ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GITEA_URL='$GITEA_URL' bash -s <recipe> --reconcile-only" \
< /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
```
(The per-recipe `/recipe-upgrade` also reconciles, so this is belt-and-suspenders for skipped recipes —
count closed-merged PRs in the summary.)
## 2. Print the plan
A table — Recipe | Status (will upgrade / skipped:reason) | Available upgrade(s) — plus the mode
(`Sequential` default / `Parallel`). If `--dry-run`, **stop here**.