From 1535e5361f45f829cba6ebc28ce1b67161b27390 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Wed, 26 Aug 2026 13:22:56 -0700 Subject: [PATCH 1/2] ci(promote): make promotion idempotent and immutable-safe The Promote learner branches workflow has never succeeded: it aborted the whole job whenever an immutable acc-/start-of-module-N tag already existed. Those monthly tags are pre-seeded for the current month, so every run died on start-of-module-02 regardless of content, and the mutable start-of-module-N aliases never advanced. Rework the refspec assembly to be idempotent and immutability-safe: - Immutable version tag: create only if absent; if it already exists, leave it untouched (never overwrite) and skip it instead of failing. - Mutable alias: move it only when the freshly built tree differs from what the branch already points at, so unchanged public start-of-module-* branches are not gratuitously rewritten to new commit shas every promotion. - Treat "nothing changed" as success (skip the push) rather than an error. Verified locally against the current repo state: only start-of-module-06 and -07 (the QR-selector fix) are selected to move; 02-05 stay put and no tags are re-cut. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3750cefd-d322-4f02-9184-c8ac3eba2fff --- .github/workflows/promote-branches.yml | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/promote-branches.yml b/.github/workflows/promote-branches.yml index 3c1b327..8b0f944 100644 --- a/.github/workflows/promote-branches.yml +++ b/.github/workflows/promote-branches.yml @@ -82,6 +82,10 @@ jobs: FROM: ${{ github.event.inputs.from_module }} run: | set -euo pipefail + # Make current remote alias tips available so we only move a learner branch when + # its content actually changed -- avoids gratuitously rewriting unchanged public + # start-of-module-* branches to new commit shas on every promotion. + git fetch --no-tags origin "refs/heads/start-of-module-*:refs/remotes/origin/start-of-module-*" || true REFSPECS=() for start in $(node -e "console.log(JSON.parse(process.env.BRANCHES).join(' '))"); do # start = start-of-module-NN ; module = NN-1 @@ -92,23 +96,43 @@ jobs: fi staged="regen/${DID}/${start}" sha="$(git rev-parse "$staged")" - # Mutable alias (force — it moves) + immutable version tag (must NOT already exist). + staged_tree="$(git rev-parse "${staged}^{tree}")" + + # Mutable alias: move it only when the built content differs from what the branch + # already points at (or the branch does not exist yet). Force, because promotion + # rebuilds commits (new commit shas even for identical trees). + cur_tree="$(git rev-parse --verify --quiet "refs/remotes/origin/${start}^{tree}" 2>/dev/null || true)" + if [ "$cur_tree" != "$staged_tree" ]; then + REFSPECS+=("+${sha}:refs/heads/${start}") + echo "alias ${start}: content changed -> ${staged_tree:0:12} (move)" + else + echo "alias ${start}: unchanged (${staged_tree:0:12}); leaving in place." + fi + + # Immutable version tag: cut exactly once. Never overwrite an existing tag (that + # is what "immutable" means); skip it so re-running promotion is idempotent + # instead of aborting the whole job. tag="acc-${VERSION}/${start}" if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then - echo "ERROR: immutable tag ${tag} already exists; refusing to overwrite." >&2 - exit 1 + echo "tag ${tag}: already exists; leaving unchanged." + else + REFSPECS+=("${sha}:refs/tags/${tag}") + echo "tag ${tag}: creating at ${sha:0:12}." fi - REFSPECS+=("+${sha}:refs/heads/${start}") - REFSPECS+=("${sha}:refs/tags/${tag}") done if [ "${#REFSPECS[@]}" -eq 0 ]; then - echo "No refs to promote." >&2; exit 1 + echo "Nothing to promote: all aliases current and all version tags present." + echo "changed=false" >> "$GITHUB_OUTPUT" + : > /tmp/refspecs.txt + exit 0 fi + echo "changed=true" >> "$GITHUB_OUTPUT" printf '%s\n' "${REFSPECS[@]}" # persist for next step printf '%s\n' "${REFSPECS[@]}" > /tmp/refspecs.txt - name: Atomic push (all aliases + tags, or nothing) + if: steps.refs.outputs.changed == 'true' run: | set -euo pipefail mapfile -t REFSPECS < /tmp/refspecs.txt From 25452132212705187f1dbdcd984ea41d1de0ec77 Mon Sep 17 00:00:00 2001 From: Christopher Harrison Date: Wed, 26 Aug 2026 13:36:35 -0700 Subject: [PATCH 2/2] ci(promote): fail fast on fetch errors instead of swallowing them Address review: `git fetch ... || true` would hide a real failure (network/auth/remote down), leaving cur_tree empty for every branch and force-pushing ALL start-of-module-* branches -- exactly the gratuitous rewrite the tree comparison is meant to avoid. A wildcard refspec that matches nothing (e.g. the first-ever promotion, before any start branch exists) already exits 0, so no `|| true` is needed for the benign case. Drop it so genuine fetch failures abort the job under set -e. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3750cefd-d322-4f02-9184-c8ac3eba2fff --- .github/workflows/promote-branches.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote-branches.yml b/.github/workflows/promote-branches.yml index 8b0f944..6bfd23e 100644 --- a/.github/workflows/promote-branches.yml +++ b/.github/workflows/promote-branches.yml @@ -85,7 +85,11 @@ jobs: # Make current remote alias tips available so we only move a learner branch when # its content actually changed -- avoids gratuitously rewriting unchanged public # start-of-module-* branches to new commit shas on every promotion. - git fetch --no-tags origin "refs/heads/start-of-module-*:refs/remotes/origin/start-of-module-*" || true + # No "|| true": a wildcard that matches nothing (e.g. the first-ever promotion, + # before any start branch exists) already exits 0, so the only thing || true would + # hide is a real failure (network/auth/remote down). Swallowing that would leave + # cur_tree empty for every branch and force-push ALL of them, so let it abort. + git fetch --no-tags origin "refs/heads/start-of-module-*:refs/remotes/origin/start-of-module-*" REFSPECS=() for start in $(node -e "console.log(JSON.parse(process.env.BRANCHES).join(' '))"); do # start = start-of-module-NN ; module = NN-1