Ctrl K
ComunidadGuías…

hack/promote-rewrite-tags_test.bats

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 ':<chart-version>' 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: <name>` (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/<name>` (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 ':<chart-version>' 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:-<nothing>}" >&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
  # "<image>@" 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:<digest>` 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}'
}