Files
cozystack/hack/promote-retag.sh
T
Andrei Kvapil 1fc6dd4234 fix(ingress-nginx): pull the protobuf-exporter from the project namespace
The sidecar was still referenced from an individual maintainer's namespace
while the controller beside it, built from the same repository and pinned to
the same version, already came from the project's. The repository moved to the
cozystack organisation some time ago; the image reference did not follow.

Same image: the digest is unchanged, and v1.11.2 is kept so the sidecar stays
aligned with the controller, whose tag tracks the upstream ingress-nginx
version.

Assisted-By: Claude
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-08-12 18:16:18 +02:00

209 lines
10 KiB
Bash
Executable File

#!/bin/sh
# Promote a release-candidate to stable by RETAGGING its already-built images —
# no rebuild. Every cluster artifact cozystack ships (component images, the
# cozystack-packages OCI artifact) is content-addressed; this script reads the
# digests the rc baked into the package values.yaml files and copies each, by
# digest, to the stable tag. Because the copy source is the immutable digest,
# the stable image is bit-for-bit the rc image that passed e2e — promotion
# cannot diverge from what was tested.
#
# Usage: hack/promote-retag.sh <stable-version> [--dry-run]
# <stable-version> e.g. v1.4.0 (the tag the rc is promoted to)
# --dry-run print the skopeo copies without executing them
#
# Environment:
# MOVE_LATEST=1 also (re)point :latest at each promoted image. OFF by
# default: :latest must move only when the version being
# promoted is the newest published stable, otherwise a patch
# on an older line (e.g. v1.4.6 while 1.5.x is current) would
# drag :latest backwards. The caller (finalize) computes this
# with the same max-semver test it uses for the release's
# make_latest flag and sets MOVE_LATEST accordingly.
#
# Reads image refs from the CURRENT tree (see hack/lib/image-refs.sh for which
# files that covers), which is expected to be the promoted stable
# digest-vendored tree (the release-X.Y.Z branch, whose digests are the rc's —
# only the cosmetic tag string differs).
# Requires: yq (mikefarah), skopeo, sha256sum, and a registry login already done.
#
# The repo/tag split (ref_repo below) strips the :tag from the last path
# component only, so registry hosts that carry a :port are preserved.
set -eu
STABLE="${1:?usage: promote-retag.sh <stable-version> [--dry-run]}"
DRY_RUN=0
[ "${2:-}" = "--dry-run" ] && DRY_RUN=1
# :latest is repointed only when the caller asserts this is the newest stable.
MOVE_LATEST="${MOVE_LATEST:-0}"
# Only cozystack-owned images (those the build pushed to $REGISTRY) are
# retagged. Everything else vendored by digest — third-party images and bare
# upstream tags — lives in registries this job cannot push to. Override
# REGISTRY to match a fork's build registry; the default mirrors
# hack/common-envs.mk.
REGISTRY="${REGISTRY:-ghcr.io/cozystack/cozystack}"
command -v yq >/dev/null || { echo "yq (mikefarah) is required" >&2; exit 1; }
# The queries below use mikefarah syntax; reject python-yq and other variants
# (mirrors the build-deps check in the Makefile).
yq --version 2>&1 | grep -q mikefarah || { echo "yq (mikefarah) is required" >&2; exit 1; }
# skopeo is only needed to actually copy; a --dry-run just prints the plan.
[ "$DRY_RUN" -eq 1 ] || command -v skopeo >/dev/null || { echo "skopeo is required" >&2; exit 1; }
[ "$DRY_RUN" -eq 1 ] || command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; }
# Ref collection (which files are scanned, and the YAML shapes within them) is
# shared with hack/nightly-mirror.sh and hack/promote-rewrite-tags.sh — see
# hack/lib/image-refs.sh. It was duplicated between the first two for as long
# as both existed, and they drifted: this script and the mirror scanned only
# the depth-2 values.yaml, so every ref stored in an images/*.tag file or
# stamped into a template was silently skipped. That is the same failure mode
# the shape-3 rule was added for — the promotion reports success while never
# creating those images' :<version> tags. Because the retag happens within one
# registry the digests still resolve and digest-pinned installs are unaffected,
# but the release does not carry the tags it claims to. Keep the enumeration in
# one place so a fix reaches every consumer at once.
# shellcheck source=hack/lib/image-refs.sh
. "$(dirname "$0")/lib/image-refs.sh"
collect_refs() { collect_image_refs packages; }
# Split a "<repo>[:<tag>]@sha256:<digest>" ref into repo and digest.
# ref_repo strips the :tag from the LAST path component only, so a registry
# host that carries a :port (e.g. localhost:5000/cozystack/operator, with no
# tag) keeps its port instead of being truncated to the host.
ref_repo() {
r="${1%@*}" # strip @digest
img="${r##*/}" # last path component (may carry :tag)
if [ "$r" = "$img" ]; then
printf '%s' "${img%:*}"
else
printf '%s/%s' "${r%/*}" "${img%:*}"
fi
}
ref_digest() { printf '%s' "${1##*@}"; } # sha256:...
# manifest_digest <ref> — the digest of <ref>'s raw manifest, or empty output if
# the tag is PROVEN not to exist. Anything else is indeterminate and returns
# non-zero, which under `set -e` aborts the promotion before it writes.
#
# The three states matter because the caller turns "empty" into "not published,
# safe to copy over". Inferring that from a failure — or from a successful
# response with no bytes — is how a rate-limit or a proxy hiccup gets read as
# permission to overwrite released bytes. Absence has to be proven by the
# registry saying so.
manifest_digest() {
_ref="$1"
_manifest="$(mktemp)"
_mderr="$(mktemp)"
# Hash the raw bytes so this works for images and OCI artifacts alike. Files
# preserve inspect's status without relying on non-POSIX pipefail.
if skopeo inspect --raw "docker://$_ref" >"$_manifest" 2>"$_mderr"; then
if [ -s "$_manifest" ]; then
printf 'sha256:%s' "$(sha256sum "$_manifest" | cut -d' ' -f1)"
rm -f "$_manifest" "$_mderr"
return 0
fi
# Exit 0 with no bytes — a registry answering 200 with an empty body. Not a
# digest (hashing nothing yields sha256:e3b0c442…, which looks real and
# belongs to no manifest) and not proof of absence either, so refuse to
# decide rather than let the caller copy over whatever is really there.
echo "::error::skopeo inspect of ${_ref} succeeded but returned no manifest bytes; refusing to decide whether that tag exists" >&2
rm -f "$_manifest" "$_mderr"
return 1
fi
# Non-zero. Only a registry that says the manifest is unknown proves absence;
# 429/5xx/auth/network failures all also exit non-zero with no bytes, and
# reading those as "not published" is exactly the fail-open this guards.
if grep -qiE 'manifest unknown|manifest not known|manifest for [^ ]* not found|not found|404' "$_mderr"; then
rm -f "$_manifest" "$_mderr"
return 0
fi
echo "::error::skopeo inspect of ${_ref} failed and did not report the manifest as unknown, so it cannot be treated as unpublished: $(tr '\n' ' ' <"$_mderr")" >&2
rm -f "$_manifest" "$_mderr"
return 1
}
copy() {
_src="$1"; _dst="$2"
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY-RUN skopeo copy --multi-arch all docker://$_src docker://$_dst"
return 0
fi
# --multi-arch all copies the whole manifest list (every platform); it is
# mutually exclusive with the deprecated --all alias, do not combine them.
skopeo copy --multi-arch all "docker://$_src" "docker://$_dst"
}
# Normalize every collected ref to the canonical "<repo>@<digest>" form (drops
# the cosmetic :tag from shape 1) so the dedup is on the real identity — the
# same image vendored in two shapes is retagged once, not twice.
refs=""
for raw in $(collect_refs); do
[ -n "$raw" ] || continue
_repo="$(ref_repo "$raw")"
# Retag only cozystack-owned images: those the build pushes to $REGISTRY, so
# a skopeo copy can succeed. Everything else is vendored by digest from a
# registry this job cannot push to, and a copy there would fail and (under
# set -e) abort the whole promotion. What this drops today:
# - third-party hosts: docker.io/clastix/kubectl,
# ghcr.io/lexfrei/{kuberture,ouroboros} (deliberately not mirrored under
# ghcr.io/cozystack — see those packages' values.yaml)
# - ghcr.io/cozystack/ingress-nginx-with-protobuf-exporter/*, which is a
# cozystack-org repo but sits outside $REGISTRY's path
# - non-ref scalars, e.g. a "--migrate-image=..." arg string
# Note kilo, kube-ovn and keycloak-operator are NOT in that list: all three
# are built and pushed to $REGISTRY, and are selected by shapes 3 and 4. An
# earlier version of this comment named them as unpushed third parties, which
# is what let their missing release tags go unnoticed.
case "$_repo" in
"${REGISTRY}/"*) ;;
*) continue ;;
esac
refs="${refs}${_repo}@$(ref_digest "$raw")
"
done
refs="$(printf '%s' "$refs" | sort -u)"
[ -n "$refs" ] || { echo "No cozystack-owned digest-pinned image refs found under ${REGISTRY}/ — is this the rc's baked tree?" >&2; exit 1; }
echo "$refs" | while IFS= read -r ref; do
[ -n "$ref" ] || continue
repo="${ref%@*}"
digest="${ref##*@}"
echo "▸ ${repo} ${digest}"
# The stable tag is write-once at the image level: resolve the destination's
# raw manifest digest before copying. No-op if it already points at this rc
# digest (idempotent re-run), fail if it points elsewhere (a partial run or
# manual push already put different bytes there) rather than mutate released
# bytes. :latest is intentionally mutable and (re)pointed below only when
# MOVE_LATEST=1.
if [ "$DRY_RUN" -eq 0 ]; then
cur="$(manifest_digest "${repo}:${STABLE}")"
if [ -n "$cur" ] && [ "$cur" != "$digest" ]; then
echo "::error::${repo}:${STABLE} already exists at '${cur}'; refusing to move it to '${digest}' (stable image tags are write-once)" >&2
exit 1
fi
if [ "$cur" = "$digest" ]; then
echo " = ${repo}:${STABLE} already at ${digest}; skipping stable copy"
else
copy "$ref" "${repo}:${STABLE}"
fi
else
copy "$ref" "${repo}:${STABLE}"
fi
[ "$MOVE_LATEST" = "1" ] && copy "$ref" "${repo}:latest"
# Verify the stable tag now resolves to the exact rc digest (skip in dry-run).
if [ "$DRY_RUN" -eq 0 ]; then
got="$(manifest_digest "${repo}:${STABLE}")"
if [ "$got" != "$digest" ]; then
echo "::error::${repo}:${STABLE} resolved to '${got}', expected '${digest}'" >&2
exit 1
fi
fi
done
if [ "$MOVE_LATEST" = "1" ]; then
echo "Retagged image refs to ${STABLE} (+latest)."
else
echo "Retagged image refs to ${STABLE} (:latest left unmoved; MOVE_LATEST=0)."
fi