#!/usr/bin/env bats # Tests for hack/promote-rewrite-tags.sh — the rc->stable tag string rewrite. # # Guards the regression that shipped a half-promoted release: the rewrite lived # inline in .github/workflows/promote-rc.yaml and globbed the depth-2 # values.yaml plus packages/apps/kubernetes/images/*.tag alone, on the premise # that the kubernetes app was the only one whose .tag files carry the cozystack # version. Nine other .tag files and one stamped template do, so v1.6.0 was # staged with 33 refs still reading v1.6.0-rc.4. Being workflow-inline, there # was nothing to unit test and the miss was only observable by cutting a # release. # # The central test therefore round-trips against the REAL tree and — crucially — # discovers ref-bearing files BY CONTENT rather than by asking the enumeration # under test where to look. A fixture built from image_ref_files() could only # ever confirm that the enumeration agrees with itself; grepping for the # registry prefix finds files it has never heard of, which is the whole failure # mode. Stamp wider than the rewrite, then assert the rewrite cleaned all of it. # # Harness compatibility note: CI runs this file under Bats through # `make bats-unit-tests`. It also remains compatible with the narrower legacy # `hack/cozytest.sh` translator, whose awk parser recognizes only @test blocks # and a bare `}` on its own line. The tests therefore avoid `run`, `$status`, # `$output`, `skip`, and setup()/teardown(), and capture expected non-zero exits # with `|| rc=$?`. Paths stay repo-root-relative for both runners. # # Run with: bats hack/promote-rewrite-tags_test.bats load test_helper @test "rewrite leaves no rc reference anywhere in the tree" { tmp=$(mktemp -d) RC=9.9.9-rc.9 # Build the fixture from files discovered by CONTENT, independent of # hack/lib/image-refs.sh. Excluding charts/ mirrors the script's own scope: # vendored upstream chart values are not stamped by the build. # --exclude='*.md' keeps documentation examples (which carry placeholder or # historical versions) out of the fixture, matching the postcondition. # Match 'cozystack/cozystack' WITHOUT a host prefix or trailing slash. The # host is not always contiguous with the repository: kubeovn puts it in # `global.registry.address: ghcr.io/cozystack/cozystack` with `repository: # kubeovn`. A 'ghcr\.io/cozystack/cozystack/' pattern silently skips such # files, shrinking the fixture and under-testing the split shapes. for f in $(grep -rIl --exclude-dir=charts --exclude='*.md' 'cozystack/cozystack' packages/); do mkdir -p "$tmp/$(dirname "$f")" cp "$f" "$tmp/$f" done # Stamp a synthetic rc version onto every VERSION-LINE ref, modelling what an # rc build actually produces. The tag must be exactly vX.Y.Z immediately # followed by @, which is the shape the build stamps; that anchor is what # keeps the fixture faithful rather than merely aggressive: # - kamaji's v0.19.0-cozystack.0@ does not match (a '-' follows the patch), # and must not — it is component-versioned and no promotion rewrites it # - a ':' placeholder in a Dockerfile comment does not match # (no digest follows), and must not — nothing ever stamps a version there # Stamping either would make the test demand a rewrite that would itself be a # bug, and both were flagged by the postcondition when this sed was looser. # Stamp both the combined form and the split form (a bare `tag:` key whose # value is a version, as cilium and kubeovn write it), so the fixture # exercises every shape the enumeration claims to cover rather than only the # single-string one. # `sed -i -E`: on BSD `-i` consumes the next argument as the backup suffix, so # `-E` is eaten and never enables extended syntax — the `\1` backreferences # then refer to groups plain BRE never captured. Route each edit through a temp # file instead; `sed -E` (extended regex) is understood by both GNU and BSD. for f in $(grep -rIl 'cozystack/cozystack' "$tmp/packages"); do sed -E "s|(cozystack/cozystack/[A-Za-z0-9._-]+):v[0-9]+\.[0-9]+\.[0-9]+@|\1:v${RC}@|g" "$f" > "$f.tmp" && mv "$f.tmp" "$f" sed -E "s|^([[:space:]]*tag:[[:space:]]*)v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]*)$|\1v${RC}\2|" "$f" > "$f.tmp" && mv "$f.tmp" "$f" done # Sanity: the fixture must actually contain the rc string, otherwise the # assertion below passes vacuously and the test guards nothing. before=$(grep -rIl -- "$RC" "$tmp/packages" | wc -l | tr -d ' ') [ "$before" -gt 0 ] hack/promote-rewrite-tags.sh "$RC" 9.9.9 "$tmp/packages" >"$tmp/log" 2>&1 || { echo "--- promote-rewrite-tags.sh output ---" >&2; cat "$tmp/log" >&2; rm -rf "$tmp"; return 1 } after=$(grep -rIl -- "$RC" "$tmp/packages" | wc -l | tr -d ' ') if [ "$after" -ne 0 ]; then echo "files still carrying $RC after the rewrite:" >&2 grep -rIl -- "$RC" "$tmp/packages" >&2 rm -rf "$tmp" return 1 fi echo "stamped $before files, all rewritten" rm -rf "$tmp" } @test "rewrite covers .tag files outside packages/apps/kubernetes" { # The specific blind spot that shipped. Pinned as its own test so a future # narrowing of the glob fails with a message naming the cause, rather than # only tripping the broad round-trip above. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/monitoring/images" echo 'ghcr.io/cozystack/cozystack/grafana:v9.9.9-rc.9@sha256:'"$(printf 'a%.0s' $(seq 64))" \ > "$tmp/packages/system/monitoring/images/grafana.tag" hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >/dev/null 2>&1 || { rm -rf "$tmp"; return 1 } got=$(cat "$tmp/packages/system/monitoring/images/grafana.tag") case "$got" in *:v9.9.9@sha256:*) ;; *) echo "expected v9.9.9, got: $got" >&2; rm -rf "$tmp"; return 1 ;; esac rm -rf "$tmp" } @test "rewrite covers a ref stamped into a declared template" { # Storage shape 3 (IMAGE_REF_EXTRA_FILES). multus sed's its ref into a # vendored upstream manifest, so it is reachable by neither glob. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/multus/templates" printf ' image: ghcr.io/cozystack/cozystack/multus-cni:v9.9.9-rc.9@sha256:%s\n' \ "$(printf 'b%.0s' $(seq 64))" > "$tmp/packages/system/multus/templates/multus-daemonset-thick.yml" hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >/dev/null 2>&1 || { rm -rf "$tmp"; return 1 } grep -q ':v9.9.9@sha256:' "$tmp/packages/system/multus/templates/multus-daemonset-thick.yml" || { echo "multus template not rewritten" >&2; rm -rf "$tmp"; return 1 } rm -rf "$tmp" } @test "component-versioned and third-party tags are left alone" { # kamaji is versioned by its upstream component (v0.19.0-cozystack.N) and # busybox is third-party; neither rides the cozystack version line, so a # promotion must not touch either. Rewriting them would be a bug introduced # by an over-eager fix to the one this file guards. # # The rc version is chosen to SHARE A PREFIX with kamaji's tag (0.19.0), so # the test is not vacuous: an implementation matching loosely on the X.Y.Z # part, or anchoring on anything short of the full "X.Y.Z-rc.N" string, would # corrupt v0.19.0-cozystack.0 here and fail. An earlier revision used an rc # version sharing no substring with either input, which made the assertion # true by construction and tested nothing. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/capi/images" "$tmp/packages/apps/kubernetes/images" kamaji='ghcr.io/cozystack/cozystack/cluster-api-control-plane-provider-kamaji:v0.19.0-cozystack.0@sha256:c' busybox='docker.io/library/busybox:1.37.0@sha256:d' echo "$kamaji" > "$tmp/packages/system/capi/images/kamaji.tag" echo "$busybox" > "$tmp/packages/apps/kubernetes/images/busybox.tag" hack/promote-rewrite-tags.sh 0.19.0-rc.1 0.19.0 "$tmp/packages" >/dev/null 2>&1 || { rm -rf "$tmp"; return 1 } [ "$(cat "$tmp/packages/system/capi/images/kamaji.tag")" = "$kamaji" ] [ "$(cat "$tmp/packages/apps/kubernetes/images/busybox.tag")" = "$busybox" ] rm -rf "$tmp" # Known and accepted limitation, stated here so it is a decision rather than # an oversight: the rewrite is a substring replace scoped to enumerated # files, so a third-party or component-versioned tag carrying the EXACT # cozystack rc string would also be rewritten. No such collision exists (a # component tag is X.Y.Z-cozystack.N and third-party tags are upstream's), # and making the rewrite ownership-aware is not possible with a substring # pass while the host may live in a sibling `registry:` key. } @test "a legitimate rc mention outside image position does not fail promotion" { # The fail-OPEN direction. Every other postcondition test asserts it fires; # this asserts it does not fire on prose, lockfiles or dependency pins that # merely contain an "X.Y.Z-rc.N" string. # # These are real: packages/apps/kubernetes/images/kubevirt-csi-driver/go.sum # pins github.com/golang/protobuf v1.4.0-rc.2, and v1.4.0-rc.2 is a cozystack # rc that was actually cut — an unfiltered postcondition would have aborted # that promotion on a Go checksum line. metallb_test.yaml names v1.5.0-rc.2 # in a comment, and the console pnpm-lock.yaml carries rolldown@1.0.0-rc.15. tmp=$(mktemp -d) # A fresh copy per version. Reusing one tree would run iterations 2 and 3 # against a tree the previous rewrite already mutated, so each would be # testing a slightly different input than the one it names. for v in 1.4.0-rc.2 1.5.0-rc.2 1.4.0-rc.4; do rm -rf "$tmp/packages" cp -a packages "$tmp/packages" rc=0 hack/promote-rewrite-tags.sh "$v" "${v%%-*}" "$tmp/packages" >"$tmp/log" 2>&1 || rc=$? if [ "$rc" -ne 0 ]; then echo "false positive: promotion of $v was aborted by the postcondition" >&2 cat "$tmp/log" >&2 rm -rf "$tmp" return 1 fi done rm -rf "$tmp" } @test "an unreadable file fails the promotion rather than being skipped" { # A read error is not "no match". Skipping it would leave a ref unrewritten # while the run still reported success — the same silent-skip shape as the # enumeration bug this script exists to fix. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/x/images" echo 'ghcr.io/cozystack/cozystack/x:v9.9.9-rc.9@sha256:a' > "$tmp/packages/system/x/images/x.tag" chmod 000 "$tmp/packages/system/x/images/x.tag" rc=0 hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >"$tmp/log" 2>&1 || rc=$? # Restore the mode first so the cleanup and any harness diagnostics work. chmod 644 "$tmp/packages/system/x/images/x.tag" if [ "$rc" -eq 0 ]; then echo "expected a non-zero exit for an unreadable file, got 0" >&2 rm -rf "$tmp"; return 1 fi grep -q 'is not readable' "$tmp/log" || { echo "expected the unreadable-file diagnostic; got:" >&2; cat "$tmp/log" >&2 rm -rf "$tmp"; return 1 } rm -rf "$tmp" } @test "collect_image_refs emits every first-party ref present in the tree" { # The completeness guard the collector lacked. The rewrite got "scan wider # than you rewrite" via its postcondition; the retag and the mirror got a # wider enumeration and nothing that would notice a NEW blind spot. # # Without this, a first-party ref added at an unenumerated path on a tag # carrying no version string (flux-plunger and keycloak-operator are # latest@-pinned today, so the profile exists) is never mirrored, and the # published nightly points at the private build registry — which the # contract calls the worst of the three failure modes. # # The oracle applies the SAME shape knowledge (collect_refs_from_file) to an # INDEPENDENTLY discovered file list. That is the right axis: what drifted # historically is which files each consumer visits, not how a ref is spelled # inside one. Shape coverage is pinned separately by the per-shape test # below, so DELETING a shape still fails something even though both sides of # this comparison would move together. # # Deleting a shape, and also narrowing one: the per-shape test asserts the # exact canonical repo@digest, so a shape that loses its `registry` rejoin # (the very regression shape 3 was added for) fails there too. What this # comparison itself contributes is only file coverage — for shape behaviour # it is inert, since both sides share the parser. # # An earlier revision derived the expected set from a grep for a CONTIGUOUS # 'ghcr.io/cozystack/cozystack/...@sha256:' string. That was blind to every # split-host shape — cilium, keycloak-operator, kubeovn, the OCI artifact and # seven others, 12 of 43 first-party refs — including keycloak-operator, # which this test's own rationale names as the motivating profile. . hack/lib/image-refs.sh tmp=$(mktemp -d) canon() { sed -E 's|^[^ ]*=||; s|:[^/@]*@|@|' | grep '^ghcr\.io/cozystack/cozystack/' | sort -u; } collect_image_refs packages | canon > "$tmp/collected" # Pre-filter to files that mention the registry at all before parsing them. # Parsing every YAML under packages/ costs ~30s; this is the same file set # for the purpose of finding FIRST-PARTY refs (canon discards everything # else anyway) and runs in about a second. The marker covers the split forms # too, so kubeovn is not filtered out. grep -rIlE --exclude-dir=charts --exclude='*.md' \ -e 'cozystack/cozystack' packages/ \ | grep -E '\.(yaml|yml|tag)$' \ | while IFS= read -r f; do collect_refs_from_file "$f"; done | canon > "$tmp/present" missed=$(comm -13 "$tmp/collected" "$tmp/present") if [ -n "$missed" ]; then echo "first-party refs present in the tree but NOT collected:" >&2 printf '%s\n' "$missed" >&2 echo "Declare their file in IMAGE_REF_EXTRA_FILES in hack/lib/image-refs.sh." >&2 rm -rf "$tmp" return 1 fi rm -rf "$tmp" } @test "every file carrying a first-party ref marker is enumerated or allowlisted" { # File-level backstop for the class no YAML oracle can reach: a ref inside a # file yq cannot parse. Helm templates are full of {{ }} and fail to parse # entirely, so a first-party ref planted in one is invisible to the # shape-based completeness check above — that is exactly how a reviewer's # split-host fixture survived every test. # # The markers are keyed to the JOIN SEMANTICS _collect_yaml_shapes # implements, not to the exact spellings that happen to be in the tree today. # An earlier revision keyed them to the latter and was evaded three ways, each # a shape the collector fully supports: `registry: ghcr.io/cozystack/cozystack` # + `repository: ` (kubeovn's layout under the key name keycloak-operator # and ingress-nginx use — marker 1 needed a trailing slash a complete-value # host does not have), `registry: ghcr.io/cozystack` + `repository: # cozystack/` (a host+org split, live precedent at # packages/system/fluxcd/values.yaml), and a single-quoted `repository:` # (the old marker admitted only double quotes). All three carried a # version-free `latest@sha256:` tag — the exact profile this check exists for # — and passed the entire suite. # # Coverage is in fact exhaustive, not merely broad. The collector joins # `registry + "/" + repository`, so a split can only fall on a path-separator # boundary of the full ref, giving four possible splits — and markers 1-3 # cover all four. A split at any other point (say `registry: ghcr.io/cozy` + # `repository: stack/cozystack/x`) does not need covering because it does not # produce a first-party ref: it joins to ghcr.io/cozy/stack/cozystack/x, # which every consumer's ownership filter discards anyway. # # Widening further would cost real precision for nothing: a bare # `cozystack/cozystack` marker matches 65 files and would need 23 allowlist # entries for Makefiles, Dockerfiles and helm-unittest fixtures. . hack/lib/image-refs.sh tmp=$(mktemp -d) image_ref_files packages | sort > "$tmp/enumerated" grep -rIlE --exclude-dir=charts --exclude='*.md' \ -e 'ghcr\.io/cozystack/cozystack/' \ -e "['\"]?(repository|image)['\"]?[[:space:]]*:[[:space:]]*['\"]?cozystack/" \ -e "['\"]?(registry|address)['\"]?[[:space:]]*:[[:space:]]*['\"]?ghcr\.io/cozystack" \ packages/ | sort > "$tmp/marked" # Known carriers that are deliberately NOT enumerated. Each needs a reason; # this list is the place a new exception has to be argued for, rather than # discovered during a release. # - talos-csr-signer/Dockerfile: a ':' placeholder in build # instructions, not a runtime ref — nothing stamps a version there. # - capi-providers-cpprovider/files/control-plane-components.yaml: the # documented gzip gap. The chart ships files/components.gz built from it, # so rewriting only this copy would diverge the two. kamaji is # component-versioned, so no tag rewrite is owed. cat > "$tmp/allow" <<'ALLOW' packages/apps/kubernetes/images/talos-csr-signer/Dockerfile packages/system/capi-providers-cpprovider/files/control-plane-components.yaml ALLOW sort -o "$tmp/allow" "$tmp/allow" unexplained=$(comm -13 "$tmp/enumerated" "$tmp/marked" | comm -13 "$tmp/allow" -) if [ -n "$unexplained" ]; then echo "files carry a first-party image ref but are neither enumerated nor allowlisted:" >&2 printf '%s\n' "$unexplained" >&2 echo "Declare each in IMAGE_REF_EXTRA_FILES (hack/lib/image-refs.sh), or add it to the" >&2 echo "allowlist in this test WITH A REASON if it is not a runtime image ref." >&2 rm -rf "$tmp" return 1 fi rm -rf "$tmp" } @test "collect_refs_from_file understands every YAML shape" { # Pins shape coverage directly, so deleting a shape fails here even though # the completeness oracle above shares this code and would move with it. . hack/lib/image-refs.sh tmp=$(mktemp -d) D=$(printf 'a%.0s' $(seq 64)) # A second, distinct digest so a per-file (rather than per-map) digest # binding is detectable. D2=$(printf 'f%.0s' $(seq 64)) printf 'image: ghcr.io/cozystack/cozystack/one:v1@sha256:%s\n' "$D" > "$tmp/s1.yaml" # Tagless: a digest-only pin is an ordinary Helm spelling. Narrowing shape 1 # to require a ':tag' before the digest was otherwise undetected. printf 'image: ghcr.io/cozystack/cozystack/onebare@sha256:%s\n' "$D" > "$tmp/s1b.yaml" # Shape 2 WITH a sibling registry: the previous fixture kept the host inside # repository, so shape 2 losing its registry rejoin was invisible here and # caught only by a single nightly-mirror fixture (no real package uses shape # 2 with a split host, so promote-retag misses it too). printf 'image:\n registry: ghcr.io\n repository: cozystack/cozystack/two\n tag: v1\n digest: "sha256:%s"\n' "$D" > "$tmp/s2.yaml" # TWO shape-3 maps with DISTINCT digests in one file. With a single pair, a # rule that binds one digest per file and reuses it for every repository # passes — and on the real linstor values that hands piraeus-server's digest # to linstor-csi, i.e. silently ships the wrong image under the right name. # The per-map digest binding is the property worth pinning here. printf 'image:\n registry: ghcr.io\n repository: cozystack/cozystack/three\n tag: "v1@sha256:%s"\nsecond:\n image:\n registry: ghcr.io\n repository: cozystack/cozystack/threeb\n tag: "v1@sha256:%s"\n' "$D" "$D2" > "$tmp/s3.yaml" # Shape 4 carries a digest-pinned sibling OUTSIDE global.images. The rule is # deliberately scoped to .global.images[] because the host is a # document-level key: binding it to any map in the file would staple # kubeovn's registry onto unrelated repositories and manufacture owned refs # that were never built. Broadening the rule to `..` otherwise passed every # suite, because asserting only that the expected ref appears cannot see an # unexpected one — hence the negative assertion below. # Two digest-pinned decoys, deliberately at different depths: one INSIDE # `global` but outside `global.images`, one outside `global` entirely. # Narrowing the negative to the outer decoy alone is not enough — mutating # the rule to `.global | ..` (rather than a document-wide `..`) then still # passes, while happily stapling the registry onto the inner map. printf 'global:\n registry:\n address: ghcr.io/cozystack/cozystack\n images:\n - repository: four\n tag: "v1@sha256:%s"\n sidecar:\n repository: someone-elses/inner\n tag: "v1@sha256:%s"\nunrelated:\n image:\n repository: someone-elses/thing\n tag: "v1@sha256:%s"\n' "$D" "$D" "$D" > "$tmp/s4.yaml" printf 'x:\n platformSourceUrl: oci://ghcr.io/cozystack/cozystack/five\n platformSourceRef: digest=sha256:%s\n' "$D" > "$tmp/s5.yaml" printf 'ghcr.io/cozystack/cozystack/six:v1@sha256:%s\n' "$D" > "$tmp/s6.tag" # The upstream ingress-nginx layout: the repository lives in `image`, not # `repository`, and the digest has its own key without an `@`, so neither # shape 1 nor shape 2 sees it. The non-string digest ahead of it would abort # the rule's test() for the whole file without a type guard. printf 'odd:\n image: x\n digest: 5\ncontroller:\n image:\n registry: ghcr.io\n image: cozystack/cozystack/seven\n tag: v1\n digest: sha256:%s\n' "$D" > "$tmp/s7.yaml" # Assert the EXACT canonical repo@digest, not a repository substring. A # substring match leaves the digest untested, and since the completeness # oracle shares this code, both sides of that comparison move together when # the digest handling breaks: corrupting the last hex digit of every emitted # digest previously left the rewrite, retag and mirror suites entirely green. # The digest is the only part that decides which bytes get retagged, so it is # the part most worth pinning. for n in one onebare two three threeb four five six seven; do want_d="$D" case "$n" in one) f=s1.yaml ;; onebare) f=s1b.yaml ;; two) f=s2.yaml ;; three) f=s3.yaml ;; threeb) f=s3.yaml; want_d="$D2" ;; four) f=s4.yaml ;; five) f=s5.yaml ;; six) f=s6.tag ;; seven) f=s7.yaml ;; esac want="ghcr.io/cozystack/cozystack/${n}@sha256:${want_d}" # Canonicalize away the cosmetic :tag that fixtures s1 and s6 carry through. got=$(collect_refs_from_file "$tmp/$f" | sed -E 's|:[^/@]*@|@|' | grep "cozystack/${n}@" || true) if [ "$got" != "$want" ]; then echo "shape for '$n' ($f):" >&2 echo " want: $want" >&2 echo " got: ${got:-}" >&2 rm -rf "$tmp"; return 1 fi done # Negative: the document-level host must not be stapled onto a map outside # global.images. Asserting only that the expected ref appears cannot catch # over-collection, and a manufactured "owned" ref would be retagged and # mirrored to a destination nothing ever built. if collect_refs_from_file "$tmp/s4.yaml" | grep -q 'cozystack/someone-elses'; then echo "shape 4 stapled the global registry onto a map outside global.images:" >&2 collect_refs_from_file "$tmp/s4.yaml" >&2 rm -rf "$tmp"; return 1 fi # Negative for the image/digest split map: an `image` key is not enough. A # map with no digest, an empty digest (the upstream default for an unpinned # image) or a digest but no `image` must yield nothing, or a bare # "@" or "@sha256:..." fragment reaches every consumer. printf 'a:\n registry: ghcr.io\n image: cozystack/cozystack/nodigest\n tag: v1\nb:\n registry: ghcr.io\n image: cozystack/cozystack/emptydigest\n tag: v1\n digest: ""\nc:\n registry: ghcr.io\n tag: v1\n digest: sha256:%s\n' "$D" > "$tmp/near.yaml" near=$(collect_refs_from_file "$tmp/near.yaml") if [ -n "$near" ]; then echo "the image/digest split-map rule matched a near-miss:" >&2 printf '%s\n' "$near" >&2 rm -rf "$tmp"; return 1 fi rm -rf "$tmp" } @test "a declared extra file actually yields its ref, not just its filename" { # image_ref_files listing a path proves only that the path is visited. # Declaring an extra whose ref the parser cannot reach leaves it enumerated # but uncollected, and both completeness checks pass — the marker check # compares filenames, and the oracle shares the same parser. Assert the ref # itself comes out. # # The fixture is an unparseable Helm template — the case parsing alone cannot # reach — carrying a contiguous ref, single-quoted so the scrape's # quote-tolerance is exercised too. # # Two things this test got wrong before, both of which made it pass while the # thing it guards was broken. Its fixture was a SPLIT-host ref, which a # textual scrape can never reconstruct: only the contiguous `tag` value is # recoverable, so ref_repo() reduces it to `latest` and every consumer drops # it at the ownership filter — enumerated-but-uncollected, exactly the # conflation this test exists to catch. And it asserted `grep -q # "@sha256:$D"`, which any token containing the digest satisfies. Gutting the # scrape to emit a bare `@sha256:` with no repository left the whole # suite green. Assert the exact canonical ref instead. . hack/lib/image-refs.sh tmp=$(mktemp -d) D=$(printf 'b%.0s' $(seq 64)) mkdir -p "$tmp/system/awkward/templates" { echo '{{- if .Values.enabled }}' echo 'spec:' printf " image: 'ghcr.io/cozystack/cozystack/awkward:latest@sha256:%s'\n" "$D" echo '{{- end }}' } > "$tmp/system/awkward/templates/thing.yaml" IMAGE_REF_EXTRA_FILES="system/awkward/templates/thing.yaml" image_ref_files "$tmp" | grep -q 'system/awkward/templates/thing.yaml' || { echo "declared extra was not enumerated" >&2; rm -rf "$tmp"; return 1 } if ! collect_image_refs "$tmp" | sed -E 's|:[^/@]*@|@|' \ | grep -qx "ghcr.io/cozystack/cozystack/awkward@sha256:${D}"; then echo "declared extra was enumerated but yielded no usable ref:" >&2 collect_image_refs "$tmp" >&2 rm -rf "$tmp"; return 1 fi rm -rf "$tmp" # Recorded limitation, so the comment above does not overclaim: a SPLIT-host # ref in an unparseable file is recoverable by neither route — parsing fails # on the template, and a scrape cannot rejoin `registry` to `repository`. The # file-level marker check is what covers that case, by refusing to let such a # file go undeclared in the first place. } @test "the cozystack-packages OCI artifact is collected" { # Shape 5 (platformSourceUrl/platformSourceRef). Deleting that rule left # every suite green, so stable promotion could silently stop retagging the # packages artifact — the one object the installer resolves to find # everything else. . hack/lib/image-refs.sh collect_image_refs packages | grep -q 'cozystack-packages@sha256:[0-9a-f]\{64\}' } @test "digests are never altered by the rewrite" { # Promotion retags by digest and must not rebuild; if the rewrite could touch # a digest, the stable image would stop being bit-for-bit the tested rc. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/x/images" d=$(printf 'e%.0s' $(seq 64)) echo "ghcr.io/cozystack/cozystack/x:v9.9.9-rc.9@sha256:$d" > "$tmp/packages/system/x/images/x.tag" hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >/dev/null 2>&1 || { rm -rf "$tmp"; return 1 } grep -q "@sha256:$d\$" "$tmp/packages/system/x/images/x.tag" || { echo "digest changed: $(cat "$tmp/packages/system/x/images/x.tag")" >&2 rm -rf "$tmp"; return 1 } rm -rf "$tmp" } @test "postcondition fails when a ref hides in an unenumerated location" { # The guard that makes a future blind spot loud instead of silent. A ref in a # location neither glob nor IMAGE_REF_EXTRA_FILES covers must abort the # promotion, not sail through as it did for v1.6.0. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/surprise/deeply/nested" echo 'image: ghcr.io/cozystack/cozystack/surprise:v9.9.9-rc.9@sha256:f' \ > "$tmp/packages/system/surprise/deeply/nested/manifest.yaml" rc=0 hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >"$tmp/log" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then echo "expected a non-zero exit for an unenumerated ref, got 0" >&2 rm -rf "$tmp"; return 1 fi grep -q 'still carry the rc version' "$tmp/log" || { echo "expected the postcondition's message; got:" >&2; cat "$tmp/log" >&2 rm -rf "$tmp"; return 1 } rm -rf "$tmp" } @test "postcondition fires on a split tag/digest ref, not just an @sha256 one" { # Covers the postcondition's key-position branch specifically. Replacing the # whole pattern with the naive "${RC}@sha256:" — the narrowing that was # explicitly rejected because cilium keeps `tag:` and `digest:` under # separate keys — previously left every test green, so the branch that makes # the split shape visible was asserted by nothing. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/surprise/files" { echo 'image:' echo ' repository: ghcr.io/cozystack/cozystack/surprise' echo ' tag: v9.9.9-rc.9' printf ' digest: "sha256:%s"\n' "$(printf 'a%.0s' $(seq 64))" } > "$tmp/packages/system/surprise/files/x.yaml" rc=0 hack/promote-rewrite-tags.sh 9.9.9-rc.9 9.9.9 "$tmp/packages" >"$tmp/log" 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then echo "expected a non-zero exit for a split tag/digest ref at an unenumerated path, got 0" >&2 cat "$tmp/log" >&2 rm -rf "$tmp"; return 1 fi grep -q 'still carry the rc version' "$tmp/log" || { echo "expected the postcondition diagnostic; got:" >&2; cat "$tmp/log" >&2 rm -rf "$tmp"; return 1 } rm -rf "$tmp" } @test "malformed versions are rejected before any file is touched" { # RC_VERSION becomes a sed pattern and STABLE_VERSION its replacement, so an # unvalidated argument is an injection surface as well as a correctness one. tmp=$(mktemp -d) mkdir -p "$tmp/packages/system/x/images" echo 'ghcr.io/cozystack/cozystack/x:v1.0.0@sha256:a' > "$tmp/packages/system/x/images/x.tag" orig=$(cat "$tmp/packages/system/x/images/x.tag") rc=0 hack/promote-rewrite-tags.sh 'not-a-version' 9.9.9 "$tmp/packages" >/dev/null 2>&1 || rc=$? [ "$rc" -ne 0 ] rc=0 hack/promote-rewrite-tags.sh 9.9.9-rc.9 'v9.9.9; rm -rf /' "$tmp/packages" >/dev/null 2>&1 || rc=$? [ "$rc" -ne 0 ] # Nothing was modified by either rejected invocation. [ "$(cat "$tmp/packages/system/x/images/x.tag")" = "$orig" ] rm -rf "$tmp" } @test "image_ref_files enumerates all three storage shapes" { # Direct cover for the shared enumeration, so a regression there is # attributed to the library rather than surfacing only through its consumers. . hack/lib/image-refs.sh files=$(image_ref_files packages) echo "$files" | grep -q '^packages/system/objectstorage-controller/values.yaml$' echo "$files" | grep -q '^packages/system/grafana-operator/images/grafana-dashboards.tag$' echo "$files" | grep -q '^packages/system/multus/templates/multus-daemonset-thick.yml$' # And it must not reach into vendored charts, whose values `make update` # regenerates and whose images the build neither pushes nor stamps. if echo "$files" | grep -q '/charts/'; then echo "enumeration leaked into a vendored charts/ subtree:" >&2 echo "$files" | grep '/charts/' >&2 return 1 fi } @test "collect_image_refs finds refs the depth-2 values.yaml glob misses" { # The mirror and the retag both filter on ownership, so a ref the collector # never emits is silently skipped rather than failing. Assert the two shapes # that were missing are present in the real tree's collection. . hack/lib/image-refs.sh refs=$(collect_image_refs packages) echo "$refs" | grep -q 'cozystack/grafana-dashboards@sha256:\|cozystack/grafana-dashboards:[^ ]*@sha256:' # The whole ref, not just the name: the completeness oracle above builds its # expected side with collect_refs_from_file, which cannot parse this file at # all, so this is where the multus entry is held to a real digest. echo "$refs" | grep -qE 'multus-cni[^[:space:]]*@sha256:[0-9a-f]{64}' }