feat(promote-release): retag docker moving tags + promote paired release on human promotion#194
feat(promote-release): retag docker moving tags + promote paired release on human promotion#194sydorovdmytro wants to merge 4 commits into
Conversation
…ase on human release-promotion DEVOPS-1083. Wires on: release: types: [released] (verified live: fires only on a human editing prerelease->false, never on a GITHUB_TOKEN/bot release publish) to a digest-preserving docker buildx imagetools retag of :latest/:X/:X.Y (and suffix variants), then optionally un-prereleases + latests a paired release in another repo. Image list and oss-repo are caller-supplied inputs so this isn't vcluster-pro-specific.
…r GHCR login Matches the existing vcluster-pro release.yaml pattern (DOCKER_USERNAME secret paired with the GH_ACCESS_TOKEN PAT), instead of assuming the release-promoting human's own GitHub login has GHCR write access.
The hand-written table drifted from auto-doc's exact formatting (alphabetical ordering, quoted default values), which check-docs caught.
…x bats negation - Add .github/workflows/test-promote-release.yaml - every other tested action has one; this one was missing, so the bats suite never actually ran in CI (only actionlint/zizmor/check-docs did). - Replace real-looking test fixtures (v0.37.1, ghcr.io/loft-sh/vcluster-pro, loft-sh/vcluster) with obviously-fake ones (v9.9.9, example-org/...) so nobody mistakes test data for a real artifact. - Fix two bare '! grep' assertions (shellcheck SC2314): a bash negation outside 'run' doesn't reliably fail a bats test. Use 'run !' instead.
| suffix=$(jq -r '.suffix // ""' <<<"${entry}") | ||
|
|
||
| src="${image}:${VERSION}${suffix}" | ||
| for moving in latest "${MAJOR}" "${MAJOR}.${MINOR}"; do |
There was a problem hiding this comment.
blocking — :latest, :{major}, and :{major}.{minor} are advanced unconditionally, with no check that VERSION is the newest published stable release. Because the action fires on any human pre-release→full edit, promoting a backport/patch — flipping an older v0.36.2 to full after v0.37.0 is already :latest — repoints :latest and :{major} to the older image, silently serving every :latest/:{major} puller a downgrade (:{major}.{minor} is fine, since it's scoped to the older line). The same hazard applies to gh release edit --latest at line 90 for the OSS repo.
Guard the advance on version ordering — only move :latest/:{major} when VERSION is newer than what they currently point at (e.g. compare against docker buildx imagetools inspect of the current :{major} tag, or gh release view --json isLatest for the OSS repo) — or add a documented skip-moving-tags input that callers set for backport promotions. This is the one concern I'd resolve before this ships beyond the vcluster-pro forward-release path.
| # Validate every entry before making any changes, so a config typo can't | ||
| # leave the images partially retagged. | ||
| IMAGE_COUNT=$(jq -r 'length' <<<"${INPUT_IMAGES}") | ||
| for ((i = 0; i < IMAGE_COUNT; i++)); do | ||
| image=$(jq -r ".[$i].image // empty" <<<"${INPUT_IMAGES}") |
There was a problem hiding this comment.
consider — The comment says validating up front means "a config typo can't leave the images partially retagged," but the loop only checks that the JSON image field is present — not that the source manifest <image>:<version><suffix> actually exists. If a suffix variant (e.g. -fips) wasn't built for this version, validation passes, then docker buildx imagetools create fails mid-loop after earlier entries are already retagged — the exact partial state the comment promises to prevent. Either extend the pre-flight loop with a docker buildx imagetools inspect "${image}:${VERSION}${suffix}" existence check per entry (skipped under dry-run), or soften the comment to match what's actually guaranteed.
| if [[ -n "${OSS_REPO}" ]]; then | ||
| if gh release view "${VERSION}" --repo "${OSS_REPO}" >/dev/null 2>&1; then | ||
| echo "Promoting ${OSS_REPO}@${VERSION}: unset prerelease, set latest" | ||
| run gh release edit "${VERSION}" --repo "${OSS_REPO}" --prerelease=false --latest |
There was a problem hiding this comment.
consider — gh release edit is a hard-failing call that runs after all docker retags have completed. If it fails (transient API error, token missing contents:write on oss-repo, network blip), set -e exits the whole action non-zero even though the GHCR moving tags are already correctly set — so the run looks like a total failure and an operator can't tell the docker state without inspecting GHCR. Mirror the soft-skip already used on line 92: emit a ::warning:: with the manual recovery command and keep the advisory paired-release step non-blocking.
| run gh release edit "${VERSION}" --repo "${OSS_REPO}" --prerelease=false --latest | |
| if ! run gh release edit "${VERSION}" --repo "${OSS_REPO}" --prerelease=false --latest; then | |
| echo "::warning::gh release edit failed for ${OSS_REPO}@${VERSION}; docker retags are already complete. Promote the OSS release manually: gh release edit ${VERSION} --repo ${OSS_REPO} --prerelease=false --latest" | |
| fi |
| @test "docker push failure -> non-zero exit" { | ||
| export DOCKER_MOCK_FAIL=1 | ||
| run "$SCRIPT" | ||
| [ "$status" -ne 0 ] |
There was a problem hiding this comment.
consider — This test asserts only a non-zero exit. Because DOCKER_MOCK_FAIL=1 fails the very first call, it can't distinguish fail-fast from fail-after-N-retags, and no test exercises a missing source manifest (<image>:<version><suffix> absent) — the scenario that produces the partial-retag state the action.sh:59 comment claims to prevent. Assert the call count so a first-call abort is pinned:
| @test "docker push failure -> non-zero exit" { | |
| export DOCKER_MOCK_FAIL=1 | |
| run "$SCRIPT" | |
| [ "$status" -ne 0 ] | |
| @test "docker push failure -> non-zero exit" { | |
| export DOCKER_MOCK_FAIL=1 | |
| run "$SCRIPT" | |
| [ "$status" -ne 0 ] | |
| # aborts on the first docker call (set -e), leaving no further retags: | |
| [ "$(wc -l < "$DOCKER_MOCK_CALLS")" -eq 1 ] | |
| } |
This becomes blocking if a future change makes the retag loop continue past a failed docker call (e.g. adding || true or a per-image retry), since no test would then catch a silent partial promotion.
| contents: read | ||
| steps: | ||
| - name: Promote release | ||
| uses: loft-sh/github-actions/.github/actions/promote-release@<sha> # promote-release/v1 |
There was a problem hiding this comment.
nit — This usage snippet pins with @<sha> # promote-release/v1, but every sibling action README (vcluster-release, semver-validation, subtree-mirror, release-branch-freeze) and this PR's own top-level README entry pin callers with the floating @promote-release/v1 tag. CLAUDE.md's release process confirms callers pin the coordination tag, not a SHA. Match that here so the two READMEs agree:
| uses: loft-sh/github-actions/.github/actions/promote-release@<sha> # promote-release/v1 | |
| uses: loft-sh/github-actions/.github/actions/promote-release@promote-release/v1 |
|
Correction to my review summary above. Two of the three items I listed under Blocking concerns — the The remaining blocking item stands: |
Summary
promote-release: on a human editing a release from pre-release to full (release: types: [released]— verified live that this only fires on a real human edit, never on a GITHUB_TOKEN/bot-authored release publish), retags the moving docker tags (:latest,:{major},:{major}.{minor}, plus any suffix variant like-fips) onto the already-published, already-signed version tag viadocker buildx imagetools create(digest-preserving copy, no rebuild, no re-signing needed).oss-repoinput: unsets prerelease, sets latest) — used so a single click on the Pro release also promotes the customer-facing OSS release.vcluster-proPR) — draft until that lands and both are rehearsed together in a throwaway repo.Test plan
make test-promote-release— 13 bats cases (happy path incl. suffix variants, non-stable no-op, missing paired release, empty oss-repo, dry-run, validation failures)zizmorclean,shellcheckcleanInternal Reference
References DEVOPS-1083