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