diff --git a/.claude/skills/recipe-upgrade/SKILL.md b/.claude/skills/recipe-upgrade/SKILL.md index 3c0ce96..a005da8 100644 --- a/.claude/skills/recipe-upgrade/SKILL.md +++ b/.claude/skills/recipe-upgrade/SKILL.md @@ -31,7 +31,17 @@ ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; \ abra recipe upgrade -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 --reconcile-only" \ + < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh + ``` + This force-syncs the `recipe-maintainers/` 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//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 " \ < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh ``` -This pushes the upgrade branch to `recipe-maintainers/` (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/` 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). diff --git a/.claude/skills/recipe-upgrade/open-recipe-pr.sh b/.claude/skills/recipe-upgrade/open-recipe-pr.sh index f0dfded..0aaa5b2 100755 --- a/.claude/skills/recipe-upgrade/open-recipe-pr.sh +++ b/.claude/skills/recipe-upgrade/open-recipe-pr.sh @@ -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/). -# Invoke from the orchestrator, feeding creds + the script over ssh: +# RUNS ON cc-ci (it has the recipe checkout at ~/.abra/recipes/ 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 " \ +# GITEA_URL='$GITEA_URL' bash -s [--reconcile-only]" \ # < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh # -# Preconditions on cc-ci: ~/.abra/recipes/ 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="${1:?usage: open-recipe-pr.sh [--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() { # + 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 "indexhead_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 diff --git a/.claude/skills/upgrade-all/SKILL.md b/.claude/skills/upgrade-all/SKILL.md index efb5538..d1b89cc 100644 --- a/.claude/skills/upgrade-all/SKILL.md +++ b/.claude/skills/upgrade-all/SKILL.md @@ -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 --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**.