The extend path grafts HEAD^{tree} WHOLESALE onto the existing upgrade-* branch
(commit-tree -p <branch tip>). Reconcile force-syncs the MIRROR's main to upstream
but never brought the branch — or the local checkout — forward, so each week the
PR base drifted further back and upstream changes made since the branch was cut
were silently absent from the pushed tree. CI then verified a tree that would
never deploy.
Found on gitea PR #5 (2026-08-10): base 0ab323d predated upstream's
'BREAKING CHANGE: remove forgejo' (37ebd22), so the 1.27.1 bump fixing
CVE-2026-60004 + CVE-2026-59774 was !testme-GREEN against a forgejo-bearing tree.
Two changes:
1. Before pushing, if the local work does not contain the freshly-synced upstream
main, merge upstream in — and FAIL LOUDLY (exit 1, naming the checkout) if that
cannot auto-merge, rather than pushing a tree that omits upstream changes.
2. The extend commit now also parents on upstream main when the branch predated it,
so the recorded history matches the pushed tree. Without it the merge-base stays
stale and a later merge can REVERT upstream's changes. Still no force-push.
Verified against the real gitea drift: detection fires, merge is clean, resulting
tree keeps forgejo removed AND the 1.27.1 pin, history contains upstream.
241 lines
14 KiB
Bash
Executable File
241 lines
14 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)
|
|
|
|
# --- Keep the LOCAL work current with the freshly-synced upstream main (anti-drift) ---
|
|
# The push path below grafts `HEAD^{tree}` WHOLESALE onto the PR branch. If this checkout is not
|
|
# based on the upstream main we just synced, every upstream change made since the branch was cut
|
|
# is silently ABSENT from the pushed tree — the PR (and the CI that verifies it) then describes a
|
|
# tree that will never deploy. Observed on gitea PR #5 (2026-08-10): its base predated upstream's
|
|
# "BREAKING CHANGE: remove forgejo", so `!testme` verified a forgejo-bearing tree while main had
|
|
# dropped it. Merge upstream in FIRST, and fail loudly rather than paper over a conflict.
|
|
if ! git merge-base --is-ancestor "${NEW_MAIN_SHA}" HEAD; then
|
|
echo "→ Local work predates upstream main (${NEW_MAIN_SHA:0:8}) — merging upstream in first..."
|
|
if ! 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 merge --no-edit "${NEW_MAIN_SHA}" >/dev/null 2>&1; then
|
|
git merge --abort 2>/dev/null || true
|
|
echo "ERROR: cannot auto-merge upstream main (${NEW_MAIN_SHA:0:8}) into the local ${RECIPE} work."
|
|
echo " Upstream changed files this upgrade also touches. Resolve by hand in"
|
|
echo " ${RECIPE_DIR}, then re-run. Refusing to push a tree that omits upstream changes."
|
|
exit 1
|
|
fi
|
|
echo " ✓ upstream merged into the local work"
|
|
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 "$(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
|
|
# 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}"
|
|
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
|