diff --git a/.claude/skills/recipe-upgrade/SKILL.md b/.claude/skills/recipe-upgrade/SKILL.md index 785b678..d7f4e04 100644 --- a/.claude/skills/recipe-upgrade/SKILL.md +++ b/.claude/skills/recipe-upgrade/SKILL.md @@ -264,11 +264,13 @@ ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GIT ``` 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 handles the upgrade PR: **if an open upgrade PR already -exists for this recipe (branch `upgrade-*`), the new work is pushed onto THAT PR's branch and the PR is -updated + re-tested** — one evolving upgrade PR per recipe, not a second parallel one. Only if none +exists for this recipe (branch `upgrade-*`), its branch is replaced with one commit directly on the +current upstream tip using `--force-with-lease`, then the PR is updated + re-tested** — one evolving +upgrade PR per recipe with one commit, not a second parallel PR. Only if none exists is a fresh `upgrade-` 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`. Export `RECIPE_PR_BODY` first (image-tag table + the +real current upstream main. This squash happens **before `!testme`**, so CI tests the one-commit PR +head. Capture the `PR_URL`. Export `RECIPE_PR_BODY` first (image-tag table + the planned operator-action notes) — and **the PR body MUST link the upstream release notes**: for each upgraded image/service, pull its release-notes URL from the registry `cc-ci-plan/upstream/.md` (between the current → new version) and include one explicit line per service in the body, e.g. @@ -297,7 +299,9 @@ POST=0 /srv/cc-ci/.claude/skills/recipe-upgrade/testme-on-pr.sh ` mirror `main` to true upstream main and closes open PRs already merged upstream. It does NOT close superseded/unrelated - 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. + open PRs. **An existing open upgrade PR is rewritten with one commit on the current upstream tip + using `--force-with-lease`, then re-tested** — 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 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). diff --git a/.claude/skills/recipe-upgrade/open-recipe-pr.sh b/.claude/skills/recipe-upgrade/open-recipe-pr.sh index e214573..ba5cee6 100755 --- a/.claude/skills/recipe-upgrade/open-recipe-pr.sh +++ b/.claude/skills/recipe-upgrade/open-recipe-pr.sh @@ -20,11 +20,9 @@ # never reflected it. # # Default mode (open a PR — preconditions: HEAD has the upgrade commit(s)): -# - If an open UPGRADE PR already exists for this recipe (branch 'upgrade-*'), the new work is added -# as a commit ON TOP of THAT PR's branch (a fast-forward — NO force-push) and the PR is updated + -# re-tested — ONE evolving upgrade PR per recipe, NOT a second parallel PR. Otherwise a fresh -# 'upgrade-' branch + PR is opened. (The only force-push here is the mirror-main sync -# above, which intentionally mirrors upstream main; PR branches are never force-pushed.) +# - Publish the final recipe tree as ONE commit directly on current upstream main before !testme. +# If an open upgrade PR exists, replace its branch with this single commit using +# --force-with-lease, then re-test the same PR. Otherwise open a fresh upgrade PR. # - 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. # @@ -165,9 +163,8 @@ if [ "${MODE}" = "--reconcile-only" ]; then exit 0 fi -# --- 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- PR. Unrelated open PRs are never touched. --- +# --- DEFAULT: publish one commit with the final tree on current upstream main. This collapses +# old local commits and merge commits while preserving the exact content to be tested. --- 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." @@ -178,39 +175,38 @@ else BRANCH="upgrade-$(git rev-parse --short HEAD)" fi -# Land the work on the PR branch WITHOUT force-pushing. If the branch already exists (extend case, or -# a stale same-named branch), add a commit ON TOP of its tip — a fast-forward, so the PR's history is -# preserved and it re-tests. Otherwise push a fresh branch. +# Do not create an empty PR when the proposed tree equals current upstream main. +UPSTREAM_TREE=$(git rev-parse "${NEW_MAIN_SHA}^{tree}") +WORK_TREE=$(git rev-parse 'HEAD^{tree}') +[ "${WORK_TREE}" != "${UPSTREAM_TREE}" ] || { + echo "ERROR: proposed recipe tree is identical to upstream main; nothing to PR." + exit 1 +} + +# The PR branch is a single commit on the current upstream tip. Reusing an existing PR requires a +# guarded rewrite; the lease rejects concurrent updates instead of overwriting another writer. +SQUASHED=$(GIT_AUTHOR_NAME="${GITEA_USERNAME}" GIT_AUTHOR_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \ + GIT_COMMITTER_NAME="${GITEA_USERNAME}" GIT_COMMITTER_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \ + git commit-tree "${WORK_TREE}" -p "${NEW_MAIN_SHA}" -m "${LATEST_MSG}") if git rev-parse --verify --quiet "refs/remotes/gitea/${BRANCH}" >/dev/null; then EXIST_TIP=$(git rev-parse "refs/remotes/gitea/${BRANCH}") - if [ "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${EXIST_TIP}^{tree}")" ]; then - echo "→ '${BRANCH}' already has this exact tree — nothing new to push (will still re-test)." + if [ "$(git rev-parse "${EXIST_TIP}^{tree}")" = "${WORK_TREE}" ] && + [ "$(git rev-parse "${EXIST_TIP}^" 2>/dev/null || true)" = "${NEW_MAIN_SHA}" ]; then + echo "→ '${BRANCH}' already has this one-commit diff — nothing new to push (will still re-test)." else - # Parent the new commit on the branch tip AND (when the branch predates it) on upstream main, so - # the recorded HISTORY matches the tree we are pushing. Without the second parent the merge-base - # stays stale: git would later treat upstream's post-branch changes as "removed by this PR" and a - # merge could revert them (the gitea #5 / forgejo-removal drift, 2026-08-10). No force-push: this - # is still a fast-forward from the branch tip. - EXTRA_PARENT=() - if ! git merge-base --is-ancestor "${NEW_MAIN_SHA}" "${EXIST_TIP}"; then - EXTRA_PARENT=(-p "${NEW_MAIN_SHA}") - echo " (also parenting on upstream main ${NEW_MAIN_SHA:0:8} — branch predated it)" - fi - ONTOP=$(GIT_AUTHOR_NAME="${GITEA_USERNAME}" GIT_AUTHOR_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \ - GIT_COMMITTER_NAME="${GITEA_USERNAME}" GIT_COMMITTER_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \ - git commit-tree "$(git rev-parse 'HEAD^{tree}')" -p "${EXIST_TIP}" "${EXTRA_PARENT[@]}" -m "${LATEST_MSG}") - echo "→ Adding the new work on top of '${BRANCH}' (fast-forward, no force-push)..." - git push gitea "${ONTOP}:refs/heads/${BRANCH}" + echo "→ Replacing '${BRANCH}' with one commit on upstream ${NEW_MAIN_SHA:0:8} (force-with-lease)..." + git push --force-with-lease="refs/heads/${BRANCH}:${EXIST_TIP}" gitea \ + "${SQUASHED}:refs/heads/${BRANCH}" fi else - echo "→ Pushing new branch '${BRANCH}'..." - git push gitea "HEAD:refs/heads/${BRANCH}" + echo "→ Pushing one-commit branch '${BRANCH}' on upstream ${NEW_MAIN_SHA:0:8}..." + git push gitea "${SQUASHED}:refs/heads/${BRANCH}" fi -PR_BODY="${RECIPE_PR_BODY:-$(printf 'Recipe upgrade.\n\nCommits on top of upstream main:\n\n%s\n' "$(git log origin/main..HEAD --pretty='- %h %s')")}" +PR_BODY="${RECIPE_PR_BODY:-$(printf 'Recipe upgrade as one commit on upstream %s.\n\n%s\n' "${NEW_MAIN_SHA:0:8}" "$(git diff --stat "${NEW_MAIN_SHA}" "${SQUASHED}")")}" 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. +CI verification is run against this one-commit PR head. NOT merged — for operator review. cc @trav @notplants" diff --git a/.claude/skills/upgrade-all/SKILL.md b/.claude/skills/upgrade-all/SKILL.md index 278a635..2e3a80c 100644 --- a/.claude/skills/upgrade-all/SKILL.md +++ b/.claude/skills/upgrade-all/SKILL.md @@ -197,7 +197,8 @@ description `"Upgrade on cc-ci"`) with a prompt like: > Run the `/recipe-upgrade ` skill end-to-end — **DEFAULT mode, do NOT pass `--with-tests`** > (`/srv/cc-ci/.claude/skills/recipe-upgrade/SKILL.md`): plan, implement, **directly deploy + inspect -> the WIP on cc-ci for live feedback (step 2b, `--chaos`, tear it down after)**, open a recipe PR, and +> the WIP on cc-ci for live feedback (step 2b, `--chaos`, tear it down after)**, open a recipe PR as +> **one commit based on current upstream main**, and > verify it by posting `!testme` on the PR (results visible in the PR; iterate ≤3×). Use the **existing > tests** — if a test fails because it is genuinely stale, **leave an explanatory comment on the PR** > for the operator and do NOT modify any test. Drive cc-ci over `ssh cc-ci`. Do NOT prompt. Do NOT push