diff --git a/hack/verify-promoted-packages.sh b/hack/verify-promoted-packages.sh index bbd625ff3..26089e418 100755 --- a/hack/verify-promoted-packages.sh +++ b/hack/verify-promoted-packages.sh @@ -206,7 +206,44 @@ normalized_refs() { digest="${raw##*@}" image="${without_digest##*/}" if [ "$without_digest" = "$image" ]; then - repo="${image%:*}" + # No `/` anywhere ahead of the digest, so nothing in this ref names a + # registry host or a repository path — it is a bare scalar sitting in + # front of a digest, and the collector emits those by design. Shape 3 + # (`repository:` on one key, `tag: @sha256:` on another) is + # the dominant shape in the tree, and shape 1's recursive descent sees + # that `tag` scalar on its own, so every shape-3 map arrives here twice: + # once correctly joined, once as the bare tag. hack/lib/image-refs.sh + # documents that degeneracy and leaves host-less refs to its callers — + # promote-retag.sh drops them through its ownership filter, and this + # function had no equivalent. `${image%:*}` is then a no-op (a tag holds + # no colon) and the TAG arrives as the repository. Promotion rewrites + # exactly those tags, which is how a promotion that moved no container + # bytes at all reported six changed "repositories" whose digests were + # identical on both sides. + # + # Compare such a ref on its digest alone. The digest is the whole of the + # container identity a host-less ref carries, and it is the only part + # promotion must not move; the empty repository yields a leading `@`, + # which no real repository name can produce, so these cannot collide with + # a host-bearing entry. Where a repository name does exist behind one of + # them — kube-ovn's `global.images.kubeovn`, whose host lives in + # `global.registry.address` — the same image also arrives host-bearing + # from shape 4, so a repository change stays visible through that entry. + # + # Dropping host-less refs outright would be wrong rather than merely + # blunter: packages/system/kuberture/values.yaml carries an `image:` map + # with a `tag:` and no `repository:`, so shape 1 is the only rule that + # ever sees its digest. Skipping it would silently stop proving that + # digest is unchanged — a hole in the very guard this branch repairs. + # + # Do NOT reach for promote-retag.sh's `${REGISTRY}/` ownership filter to + # do this job. The verify job's REGISTRY names the private build registry + # while both artifacts under comparison live on the public one, so that + # filter drops every ref; and the emptiness guard above tests the RAW + # collection rather than the filtered set, so the proof would then pass + # by comparing two empty sets — failing open on the one invariant it + # exists to establish. + repo="" else repo="${without_digest%/*}/${image%:*}" fi diff --git a/hack/verify-promoted-packages_test.bats b/hack/verify-promoted-packages_test.bats index 1d75f89db..1f6bad367 100644 --- a/hack/verify-promoted-packages_test.bats +++ b/hack/verify-promoted-packages_test.bats @@ -365,3 +365,108 @@ EOF [ "$rc" -ne 0 ] grep -q 'promotion changed the container repository/digest set' "$tmp/err" } + +# Shape 3 — `repository:` on one key, `tag: @sha256:` on another — +# is the shape hack/lib/image-refs.sh itself calls the dominant one, and the one +# no fixture above builds. The collector emits TWO entries for such a map: the +# correctly joined repository@digest, plus shape 1's recursive scrape of the +# bare `tag` scalar, which names no repository at all. Promotion rewrites +# exactly that scalar, so normalization keeping it — treating the tag as the +# repository — makes every clean promotion look as though it moved container +# bytes. The single-string ref in _make_verify_fixture cannot reach that path: +# it carries a registry host, so it normalizes through the other branch and its +# own rc-to-stable rewrite passes either way. That is why the suite looked +# covered while the dominant shape went untested. +_write_split_map() { + cat > "$1" < "$tmp/out" 2> "$tmp/err" || rc=$? + if [ "$rc" -ne 0 ]; then + echo "verification exited $rc" >&2 + cat "$tmp/err" >&2 + return "$rc" + fi + + grep -q "stable tags, identical package tree, and the rc artifact's container digests" "$tmp/out" +} + +# The property a blunter fix would break. packages/system/kuberture/values.yaml +# carries an `image:` map with a `tag:` and no `repository:` — shape 2 wants a +# `digest` key and shape 3 a `repository` key, so shape 1's scrape of the bare +# tag scalar is the ONLY rule that ever sees that image's digest. Discarding +# host-less refs rather than comparing them on their digest would silently stop +# proving it unchanged, which is a hole in the same guard being repaired here. +# Asserted from the rejecting direction, because that is the direction able to +# go red: the digest moves and nothing else does, so the digest-set comparison +# is the only leg that can fire — artifact and release tree stay byte-identical, +# no rc string appears in the candidate, and the installer values match. +_write_repository_less_map() { + cat > "$1" < "$tmp/out" 2> "$tmp/err" || rc=$? + + [ "$rc" -ne 0 ] + grep -q 'promotion changed the container repository/digest set' "$tmp/err" + # The rejection has to come from the digest set and not from a neighbouring leg + # rejecting a tree this test never perturbed, or it would still pass with the + # digest dropped from the comparison entirely. Counted rather than negated with + # `!`, which suppresses errexit and would pass regardless. + for _other in 'contain different files' 'differs from release tree' \ + 'still carries rc image references' 'installer values differ'; do + count="$(grep -c "$_other" "$tmp/err" || true)" + [ "${count:-0}" -eq 0 ] + done +}