diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 2a7025a..76e665f 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -565,16 +565,19 @@ jobs: needs: - base-decide - build-variant - # Skip on cache-hit base builds: when need_build=false, base-latest - # already points at the same digest as base-, so the retag is - # a tautology and any transient failure of it is purely cosmetic. - # Manual workflow_dispatch with promote_latest=true overrides this - # gate as an escape hatch (e.g., if base-latest got hand-deleted). + # Run on every tag release (and on promote_latest=true dispatches). + # The job-level gate deliberately does NOT key off need_build anymore: + # the actual no-op optimization moved INTO the step as a digest compare + # (see below). Keying the gate on need_build was wrong because a prior + # dry-run dispatch (promote_latest=false) can pre-build+push base-, + # making need_build=false on the subsequent tag run even though + # base-latest is still stale — the old gate then skipped promotion and + # left base-latest pointing at the PREVIOUS base. (Observed 2026-06-27, + # v1.2.3: dry-run-first release left base-latest one base behind.) if: | always() && needs.build-variant.result == 'success' && - (inputs.promote_latest == 'true' || - (github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true')) + (inputs.promote_latest == 'true' || github.ref_type == 'tag') runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -596,11 +599,31 @@ jobs: crane auth login docker.io \ -u ${{ vars.DOCKERHUB_USERNAME }} \ -p "${{ secrets.DOCKERHUB_TOKEN }}" - - name: Re-tag base- as base-latest + - name: Re-tag base- as base-latest (only if stale) + env: + BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest run: | - crane copy \ - ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \ - ${{ env.IMAGE }}:base-latest + set -euo pipefail + # Correctness invariant: after a release, base-latest must resolve to + # the SAME digest as the base- the just-built variants were + # FROM. Compare digests rather than trusting need_build — a prior + # dry-run dispatch can pre-build base-, so need_build=false on + # the tag run does NOT imply base-latest is already current. When the + # digests already match (genuine cache-hit release) this is a no-op, + # so we skip the crane copy entirely — preserving the original + # "don't do a tautological retag" intent and avoiding any cosmetic + # transient-failure exposure on releases that change nothing. + want=$(crane digest "${BASE_HASH_REF}") + have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "") + echo "base- digest: ${want}" + echo "base-latest digest: ${have:-}" + if [ "${want}" = "${have}" ]; then + echo "base-latest already current; nothing to promote." + else + echo "Promoting base-latest -> ${BASE_HASH_REF}" + crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}" + fi # ── Phase 6: update Hub description (only on real release runs) ──── update-description: diff --git a/CHANGELOG.md b/CHANGELOG.md index cebbcce..f4714b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,23 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). ## Unreleased +### Fixed (CI) + +- **`promote-base-latest` now re-points `base-latest` reliably after a + dry-run-first release.** The job's gate previously required + `need_build == 'true'`, on the assumption that `need_build == false` + implied `base-latest` was already current. That assumption breaks when a + `workflow_dispatch` dry-run (`promote_latest=false`) pre-builds and pushes + `base-` first: the subsequent tag run then sees `need_build == false` + (probe hit) and **skipped** promotion, leaving `base-latest` pointing at the + *previous* base. (Observed 2026-06-27 releasing v1.2.3 via dry-run-then-tag + — `base-latest` ended up one base behind, lacking the mempalace self-heal.) + Now the gate runs on every tag release (or `promote_latest=true` dispatch), + and the no-op optimization moved **into** the step as a `crane digest` + compare: it re-tags only when `base-latest` actually differs from the + released `base-`, so genuine cache-hit releases stay a no-op while + stale aliases get corrected. No image-content change; base hash unaffected. + --- ## v1.2.3 — 2026-06-27