Files
cc-ci-orchestrator/.claude/skills/recipe-upgrade/open-recipe-pr.sh
T
autonomic-botandClaude Opus 4.8 65a96453fc fix(recipe-upgrade): reconcile mirror from TRUE coopcloud upstream, not from the mirror itself
The reconcile that's supposed to make the mirror main == upstream main was fetching origin/main —
but origin is the cc-ci MIRROR, so it synced the mirror to itself (a no-op) and never pulled real
upstream. Fix: fetch coopcloud explicitly (git.coopcloud.tech/coop-cloud/<recipe>, default branch
main OR master) via an 'upstream' remote and force-sync the mirror main + tags from it. Every recipe
has a coopcloud correspondent; none are forked. Also reorder the skill so the reconcile runs BEFORE
the upgrade check, so the check sees the real current recipe. Verified by divergence test (diverged a
mirror, reconcile snapped it back to coopcloud HEAD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:44:45 +00:00

210 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# 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> 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> [--reconcile-only]" \
# < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh
#
# Always (both modes):
# - Force-syncs the mirror's `main` to be IDENTICAL to true upstream `main`, fetched EXPLICITLY from
# coopcloud (`git.coopcloud.tech/coop-cloud/<recipe>`, default branch main OR master) via an
# `upstream` remote — NOT from the checkout's `origin`, which is the mirror itself. This guarantees
# the mirror really mirrors upstream and 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)):
# - 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-<version>' 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.)
# - 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
# 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> [--reconcile-only]}"
MODE="${2:-open}"
: "${GITEA_USERNAME:?missing GITEA_USERNAME (pass via ssh env)}"
: "${GITEA_PASSWORD:?missing GITEA_PASSWORD}"
: "${GITEA_URL:?missing GITEA_URL}"
NAMESPACE="${GITEA_NAMESPACE:-recipe-maintainers}"
RECIPE_DIR="${HOME}/.abra/recipes/${RECIPE}"
export PATH="/run/current-system/sw/bin:${PATH}"
PASS_ENC=$(printf '%s' "${GITEA_PASSWORD}" | jq -Rr '@uri')
API="https://${GITEA_URL}/api/v1"
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 = COOPCLOUD ---
# Every recipe has a coop-cloud correspondent and NONE are intentionally forked, so upstream is always
# git.coopcloud.tech/coop-cloud/<recipe>. NOTE: the abra checkout's `origin` is the cc-ci MIRROR
# (recipe-maintainers/<recipe>), so we must fetch coopcloud EXPLICITLY via an `upstream` remote — using
# `origin` here would sync the mirror to itself (a no-op) and never pick up real upstream changes.
# coopcloud is public, so no creds are needed for this fetch.
UPSTREAM_URL="${UPSTREAM_URL:-https://git.coopcloud.tech/coop-cloud/${RECIPE}.git}"
echo "→ Fetching true upstream ${UPSTREAM_URL} ..."
git remote | grep -qx upstream && git remote set-url upstream "${UPSTREAM_URL}" || git remote add upstream "${UPSTREAM_URL}"
git fetch --quiet --prune --tags upstream 2>/dev/null || {
echo "ERROR: cannot fetch coopcloud upstream for ${RECIPE} (${UPSTREAM_URL}). Every recipe must have a coop-cloud correspondent — fix the URL/mirror."; exit 1; }
# coopcloud's default branch (main OR master) is what the mirror's `main` must mirror exactly.
UP_REF=$(git ls-remote --symref upstream HEAD 2>/dev/null | sed -n 's#^ref:[[:space:]]*refs/heads/\([^[:space:]]*\).*#upstream/\1#p' | head -1)
[ -n "${UP_REF}" ] || for b in main master; do git rev-parse --verify -q "upstream/$b" >/dev/null 2>&1 && { UP_REF="upstream/$b"; break; }; done
[ -n "${UP_REF}" ] || { echo "ERROR: no default branch found on coopcloud ${RECIPE}"; exit 1; }
NEW_MAIN_SHA=$(git rev-parse "${UP_REF}")
MAIN_TREE=$(git rev-parse "${NEW_MAIN_SHA}^{tree}")
echo " upstream ${UP_REF} = ${NEW_MAIN_SHA:0:8}"
# --- 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=$(jq -n --arg name "${RECIPE}" '{name:$name,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}")
if [ "${CS}" != "201" ]; then
CS=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH[@]}" -H "Content-Type: application/json" -X POST "${API}/user/repos" -d "${BODY}")
[ "${CS}" = "201" ] || { echo "ERROR: repo create failed (HTTP ${CS})"; exit 1; }
NAMESPACE="${GITEA_USERNAME}"
fi
echo " ✓ created ${NAMESPACE}/${RECIPE}"
elif [ "${STATUS}" != "200" ]; then
echo "ERROR: unexpected HTTP ${STATUS} checking mirror repo"; exit 1
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}"
# --- Mirror main := true upstream main (identical); mirror also gets upstream's published tags ---
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 --quiet gitea "refs/tags/*:refs/tags/*" 2>/dev/null || true # coopcloud published versions
git fetch --quiet gitea '+refs/heads/*:refs/remotes/gitea/*' || true
# --- Preconditions for open mode ---
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)
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 "$(jq -n --arg body "Auto-closed by /recipe-upgrade: ${reason}" '{body:$body}')" || 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}"
}
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}..."
OPEN_PRS=$(curl -s "${AUTH[@]}" "${API}/repos/${NAMESPACE}/${RECIPE}/pulls?state=open&base=main&limit=50")
# 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
[ -n "${IDX:-}" ] || continue
if merged_upstream "${HEAD_REF}"; then
close_pr "${IDX}" "its changes are already in upstream main (merged upstream); mirror main re-synced"
continue
fi
echo " • PR #${IDX} (${HEAD_REF}) still open vs upstream main — leaving open"
if [ "${MODE}" != "--reconcile-only" ] && [ -z "${EXTEND_PR}" ] && printf '%s' "${HEAD_REF}" | grep -q '^upgrade-'; then
EXTEND_PR="${IDX}"; EXTEND_BRANCH="${HEAD_REF}"
fi
done < <(echo "${OPEN_PRS}" | jq -r '.[] | [(.number | tostring), .head.ref] | @tsv' 2>/dev/null || true)
if [ "${MODE}" = "--reconcile-only" ]; then
echo "✓ reconcile-only done for ${NAMESPACE}/${RECIPE} (main synced; merged-upstream PRs closed)."
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-<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
# 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.
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)."
else
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}" -m "${LATEST_MSG}")
echo "→ Adding the new work on top of '${BRANCH}' (fast-forward, no force-push)..."
git push gitea "${ONTOP}:refs/heads/${BRANCH}"
fi
else
echo "→ Pushing new branch '${BRANCH}'..."
git push gitea "HEAD: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="${PR_BODY}
Tested green on the cc-ci recipe CI server (full suite, cold, against this PR head). NOT merged — for operator review.
cc @trav @notplants"
if [ -n "${EXTEND_PR}" ]; then
# The existing PR's branch now points at the new work, so it re-tests on the next !testme.
# Refresh its title + body to match the new work.
curl -s -o /dev/null "${AUTH[@]}" -H "Content-Type: application/json" -X PATCH \
"${API}/repos/${NAMESPACE}/${RECIPE}/pulls/${EXTEND_PR}" \
-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
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