fix(release): compare host-less image refs on digest alone (#3905)

Fixes the false `promotion changed the container repository/digest set`
that blocks #3904.

`normalized_refs()` in `hack/verify-promoted-packages.sh` gives a
host-less ref a repository equal to its own tag.
`hack/lib/image-refs.sh` emits two entries per shape-3 map, the
correctly joined ref plus shape 1's recursive scrape of the bare `tag:
<tag>@sha256:...` scalar, and on that bare scalar `repo="${image%:*}"`
is a no-op because there is no `/` and no `:` in it. Promotion rewrites
exactly those tags, so `v1.6.2-rc.1` to `v1.6.2` shows up as six changed
repositories whose digests are identical on both sides.
`promote-retag.sh` never hits this because it drops host-less refs
through its ownership filter, the verifier had no equivalent.

Host-less refs now compare on digest alone, which is the whole of the
container identity such a ref carries. Checked against the real
published artifacts: before the fix 61 entries each side differing by
those six pairs, after it 60 each side with the sets equal and 48
container digests identical.

Two things this deliberately does not do, both written as comments at
the fix. Not dropping host-less refs outright, because
`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 and a drop would silently stop proving it unchanged. And not
reusing `promote-retag.sh`'s `${REGISTRY}/` ownership filter, because
the verify job's `REGISTRY` names the private build registry while both
artifacts under comparison live on the public one, so that filter drops
all 48 refs, and the emptiness guard above tests the raw collection
rather than the filtered set, so the check would pass by comparing two
empty sets.

Second commit adds the fixture that was missing. The existing suite
writes a host-bearing string and exercises a passing rc to stable
rewrite, so it read as coverage while never building a shape-3 split
map, which the library itself calls the dominant shape. Reverting the
fix reddens the new case on the digest-set assertion, and replacing the
fix with an outright skip of host-less refs makes the verifier exit 0 on
a genuinely changed digest.

Once this is on `release-1.6` the check on #3904 re-runs against this
copy, so no `rc.2` re-cut is needed. `main` carries the same script and
wants the same fix.
This commit is contained in:
myasnikovdaniil authored and GitHub committed 2026-08-19 11:44:10 +05:00
commit fc592eff99
2 files changed
+143 -1

No files matched your search

+38 -1
View File
@@ -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: <tag>@sha256:<digest>` 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
+105
View File
@@ -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: <tag>@sha256:<digest>` 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" <<EOF
linstorCSI:
image:
repository: ghcr.io/cozystack/cozystack/linstor-csi
tag: $2@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
EOF
}
# Same repository and the same digest on every side. ONLY the tag string moves,
# which is exactly and only what promotion is meant to change.
_add_split_map_package() {
t="$1"
mkdir -p "$t/release/system/linstor" "$t/artifact/system/linstor" \
"$t/rc-artifact/system/linstor"
_write_split_map "$t/release/system/linstor/values.yaml" v9.9.9
_write_split_map "$t/artifact/system/linstor/values.yaml" v9.9.9
_write_split_map "$t/rc-artifact/system/linstor/values.yaml" v9.9.9-rc.3
}
@test "accepts a split-map tag rewritten from rc to stable" {
tmp="$(_test_workspace)/fixture"
_make_verify_fixture "$tmp"
_add_split_map_package "$tmp"
rc=0
MOCK_ARTIFACT="$tmp/artifact" MOCK_RC_ARTIFACT="$tmp/rc-artifact" MOCK_FLUX_LOG="$tmp/flux.log" \
VERIFY_PACKAGES_WORKDIR="$tmp/work" \
PATH="$tmp/bin:$PATH" \
hack/verify-promoted-packages.sh 9.9.9 "$tmp/release" \
> "$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" <<EOF
kuberture:
image:
tag: 0.1.1@sha256:$2
EOF
}
_add_repository_less_package() {
t="$1"
mkdir -p "$t/release/system/kuberture" "$t/artifact/system/kuberture" \
"$t/rc-artifact/system/kuberture"
_repoless_stable=1111111111111111111111111111111111111111111111111111111111111111
_repoless_rc=2222222222222222222222222222222222222222222222222222222222222222
_write_repository_less_map "$t/release/system/kuberture/values.yaml" "$_repoless_stable"
_write_repository_less_map "$t/artifact/system/kuberture/values.yaml" "$_repoless_stable"
_write_repository_less_map "$t/rc-artifact/system/kuberture/values.yaml" "$_repoless_rc"
}
@test "rejects a changed digest carried only by a repository-less image map" {
tmp="$(_test_workspace)/fixture"
_make_verify_fixture "$tmp"
_add_repository_less_package "$tmp"
rc=0
MOCK_ARTIFACT="$tmp/artifact" MOCK_RC_ARTIFACT="$tmp/rc-artifact" MOCK_FLUX_LOG="$tmp/flux.log" \
VERIFY_PACKAGES_WORKDIR="$tmp/work" \
PATH="$tmp/bin:$PATH" \
hack/verify-promoted-packages.sh 9.9.9 "$tmp/release" \
> "$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
}