Skip to content

Commit 5d8dd51

Browse files
fix(release): make promote-retag digest verification media-type-agnostic
skopeo inspect --format '{{.Digest}}' fails on OCI artifacts: for cozystack-packages (config.mediaType application/vnd.cncf.flux.config.v1+json) it prints nothing and exits nonzero, so the v1.6.0 finalize aborted its post-copy verification right after both copies had already succeeded, leaving 32 of 43 repos without stable tags and skipping the cozy-installer publish. --dry-run never executes this branch, so rehearsals could not catch it. Compute the digest as sha256 of the raw manifest instead, which is how registries define it and works for any media type. The helper gates the hash on skopeo's exit status via a temp file rather than a pipeline: a missing tag still yields an empty string (pre-check reads that as "tag absent, proceed to copy"), never the empty-input hash sha256:e3b0c442..., and never an abort under set -eu. Verified against live GHCR: the raw-manifest hash of cozystack-packages:v1.6.0 matches the digest finalize expected (bf68208730860fa8...), and agrees byte-for-byte with the old method on regular multi-arch images. New cozytest cases cover the OCI-artifact digest, a post-copy mismatch, and the missing-tag path. Assisted-By: GPT-5 <[email protected]> Assisted-By: Claude <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent c503ae2 commit 5d8dd51

2 files changed

Lines changed: 129 additions & 8 deletions

File tree

‎hack/promote-retag.sh‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
# files that covers), which is expected to be the promoted stable
2525
# digest-vendored tree (the release-X.Y.Z branch, whose digests are the rc's —
2626
# only the cosmetic tag string differs).
27-
# Requires: yq (mikefarah), skopeo, and a registry login already done.
27+
# Requires: yq (mikefarah), skopeo, sha256sum, and a registry login already done.
2828
#
2929
# The repo/tag split (ref_repo below) strips the :tag from the last path
3030
# component only, so registry hosts that carry a :port are preserved.
@@ -49,6 +49,7 @@ command -v yq >/dev/null || { echo "yq (mikefarah) is required" >&2; exit 1;
4949
yq --version 2>&1 | grep -q mikefarah || { echo "yq (mikefarah) is required" >&2; exit 1; }
5050
# skopeo is only needed to actually copy; a --dry-run just prints the plan.
5151
[ "$DRY_RUN" -eq 1 ] || command -v skopeo >/dev/null || { echo "skopeo is required" >&2; exit 1; }
52+
[ "$DRY_RUN" -eq 1 ] || command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; }
5253

5354
# Ref collection (which files are scanned, and the YAML shapes within them) is
5455
# shared with hack/nightly-mirror.sh and hack/promote-rewrite-tags.sh — see
@@ -81,6 +82,17 @@ ref_repo() {
8182
}
8283
ref_digest() { printf '%s' "${1##*@}"; } # sha256:...
8384

85+
manifest_digest() {
86+
_ref="$1"
87+
_manifest="$(mktemp)"
88+
# Hash the raw bytes so this works for images and OCI artifacts alike. A
89+
# file preserves inspect's status without relying on non-POSIX pipefail.
90+
if skopeo inspect --raw "docker://$_ref" >"$_manifest" 2>/dev/null; then
91+
printf 'sha256:%s' "$(sha256sum "$_manifest" | cut -d' ' -f1)"
92+
fi
93+
rm -f "$_manifest"
94+
}
95+
8496
copy() {
8597
_src="$1"; _dst="$2"
8698
if [ "$DRY_RUN" -eq 1 ]; then
@@ -128,13 +140,14 @@ echo "$refs" | while IFS= read -r ref; do
128140
repo="${ref%@*}"
129141
digest="${ref##*@}"
130142
echo "▸ ${repo} ${digest}"
131-
# The stable tag is write-once at the image level: inspect the destination
132-
# before copying. No-op if it already points at this rc digest (idempotent
133-
# re-run), fail if it points elsewhere (a partial run or manual push already
134-
# put different bytes there) rather than mutate released bytes. :latest is
135-
# intentionally mutable and (re)pointed below only when MOVE_LATEST=1.
143+
# The stable tag is write-once at the image level: resolve the destination's
144+
# raw manifest digest before copying. No-op if it already points at this rc
145+
# digest (idempotent re-run), fail if it points elsewhere (a partial run or
146+
# manual push already put different bytes there) rather than mutate released
147+
# bytes. :latest is intentionally mutable and (re)pointed below only when
148+
# MOVE_LATEST=1.
136149
if [ "$DRY_RUN" -eq 0 ]; then
137-
cur="$(skopeo inspect --format '{{.Digest}}' "docker://${repo}:${STABLE}" 2>/dev/null || echo '')"
150+
cur="$(manifest_digest "${repo}:${STABLE}")"
138151
if [ -n "$cur" ] && [ "$cur" != "$digest" ]; then
139152
echo "::error::${repo}:${STABLE} already exists at '${cur}'; refusing to move it to '${digest}' (stable image tags are write-once)" >&2
140153
exit 1
@@ -150,7 +163,7 @@ echo "$refs" | while IFS= read -r ref; do
150163
[ "$MOVE_LATEST" = "1" ] && copy "$ref" "${repo}:latest"
151164
# Verify the stable tag now resolves to the exact rc digest (skip in dry-run).
152165
if [ "$DRY_RUN" -eq 0 ]; then
153-
got="$(skopeo inspect --format '{{.Digest}}' "docker://${repo}:${STABLE}" 2>/dev/null || echo '')"
166+
got="$(manifest_digest "${repo}:${STABLE}")"
154167
if [ "$got" != "$digest" ]; then
155168
echo "::error::${repo}:${STABLE} resolved to '${got}', expected '${digest}'" >&2
156169
exit 1

‎hack/promote-retag_test.bats‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,41 @@
2121
# (or `bats hack/promote-retag_test.bats` if the bats binary is
2222
# installed; cozytest.sh is the CI path.)
2323

24+
_make_registry_mocks() {
25+
t="$1"
26+
mkdir -p "$t/bin"
27+
cat >"$t/bin/yq" <<'EOF'
28+
#!/bin/sh
29+
set -eu
30+
if [ "${1:-}" = "--version" ]; then
31+
echo 'yq (https://github.com/mikefarah/yq/) version v4.45.1'
32+
else
33+
printf '%s\n' "$MOCK_REF"
34+
fi
35+
EOF
36+
cat >"$t/bin/skopeo" <<'EOF'
37+
#!/bin/sh
38+
set -eu
39+
printf '%s\n' "$*" >>"$MOCK_SKOPEO_LOG"
40+
case "$1" in
41+
inspect)
42+
[ "$2" = "--raw" ]
43+
if [ "$MOCK_MISSING_ONCE" = "1" ] && [ ! -f "$MOCK_STATE" ]; then
44+
exit 1
45+
fi
46+
printf '%s' "$MOCK_MANIFEST"
47+
;;
48+
copy)
49+
: >"$MOCK_STATE"
50+
;;
51+
*)
52+
exit 2
53+
;;
54+
esac
55+
EOF
56+
chmod +x "$t/bin/yq" "$t/bin/skopeo"
57+
}
58+
2459
@test "dry-run over the real tree retags only cozystack-owned refs" {
2560
tmp=$(mktemp -d)
2661
trap 'rm -rf "$tmp"' EXIT
@@ -160,3 +195,76 @@
160195
grep -q 'cozystack/grafana:v9.9.9' "$tmp/out"
161196
grep -q 'cozystack/multus-cni:v9.9.9' "$tmp/out"
162197
}
198+
199+
@test "raw manifest digest resolves for an OCI artifact" {
200+
tmp=$(mktemp -d)
201+
trap 'rm -rf "$tmp"' EXIT
202+
_make_registry_mocks "$tmp"
203+
204+
manifest='{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.flux.config.v1+json","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"layers":[]}'
205+
digest="sha256:$(printf '%s' "$manifest" | sha256sum | cut -d' ' -f1)"
206+
ref="example.com/cozystack/cozystack-packages:v1.6.0-rc.1@${digest}"
207+
208+
rc=0
209+
REGISTRY="example.com/cozystack" MOCK_REF="$ref" MOCK_MANIFEST="$manifest" \
210+
MOCK_MISSING_ONCE=0 MOCK_STATE="$tmp/state" MOCK_SKOPEO_LOG="$tmp/skopeo.log" \
211+
PATH="$tmp/bin:/usr/bin:/bin" hack/promote-retag.sh v1.6.0 \
212+
>"$tmp/out" 2>"$tmp/err" || rc=$?
213+
if [ "$rc" -ne 0 ]; then
214+
echo "promote-retag.sh exited $rc" >&2
215+
echo "--- stderr ---" >&2; cat "$tmp/err" >&2
216+
return "$rc"
217+
fi
218+
219+
grep -q "already at ${digest}; skipping stable copy" "$tmp/out"
220+
[ "$(grep -c '^inspect --raw ' "$tmp/skopeo.log")" -eq 2 ]
221+
! grep -q '^copy ' "$tmp/skopeo.log"
222+
}
223+
224+
@test "post-copy raw manifest digest mismatch fails verification" {
225+
tmp=$(mktemp -d)
226+
trap 'rm -rf "$tmp"' EXIT
227+
_make_registry_mocks "$tmp"
228+
229+
manifest='{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.flux.config.v1+json","digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"layers":[]}'
230+
expected_manifest='{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.flux.config.v1+json","digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"layers":[]}'
231+
actual="sha256:$(printf '%s' "$manifest" | sha256sum | cut -d' ' -f1)"
232+
expected="sha256:$(printf '%s' "$expected_manifest" | sha256sum | cut -d' ' -f1)"
233+
ref="example.com/cozystack/cozystack-packages:v1.6.0-rc.1@${expected}"
234+
235+
rc=0
236+
REGISTRY="example.com/cozystack" MOCK_REF="$ref" MOCK_MANIFEST="$manifest" \
237+
MOCK_MISSING_ONCE=1 MOCK_STATE="$tmp/state" MOCK_SKOPEO_LOG="$tmp/skopeo.log" \
238+
PATH="$tmp/bin:/usr/bin:/bin" hack/promote-retag.sh v1.6.0 \
239+
>"$tmp/out" 2>"$tmp/err" || rc=$?
240+
241+
[ "$rc" -ne 0 ]
242+
grep -q "resolved to '${actual}', expected '${expected}'" "$tmp/err"
243+
grep -q '^copy --multi-arch all ' "$tmp/skopeo.log"
244+
[ "$(grep -c '^inspect --raw ' "$tmp/skopeo.log")" -eq 2 ]
245+
}
246+
247+
@test "missing stable tag remains an empty digest and proceeds to copy" {
248+
tmp=$(mktemp -d)
249+
trap 'rm -rf "$tmp"' EXIT
250+
_make_registry_mocks "$tmp"
251+
252+
manifest='{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.flux.config.v1+json","digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"layers":[]}'
253+
digest="sha256:$(printf '%s' "$manifest" | sha256sum | cut -d' ' -f1)"
254+
ref="example.com/cozystack/cozystack-packages:v1.6.0-rc.1@${digest}"
255+
256+
rc=0
257+
REGISTRY="example.com/cozystack" MOCK_REF="$ref" MOCK_MANIFEST="$manifest" \
258+
MOCK_MISSING_ONCE=1 MOCK_STATE="$tmp/state" MOCK_SKOPEO_LOG="$tmp/skopeo.log" \
259+
PATH="$tmp/bin:/usr/bin:/bin" hack/promote-retag.sh v1.6.0 \
260+
>"$tmp/out" 2>"$tmp/err" || rc=$?
261+
if [ "$rc" -ne 0 ]; then
262+
echo "promote-retag.sh exited $rc" >&2
263+
echo "--- stderr ---" >&2; cat "$tmp/err" >&2
264+
return "$rc"
265+
fi
266+
267+
grep -q '^copy --multi-arch all ' "$tmp/skopeo.log"
268+
[ "$(grep -c '^inspect --raw ' "$tmp/skopeo.log")" -eq 2 ]
269+
grep -q 'Retagged image refs to v1.6.0' "$tmp/out"
270+
}

0 commit comments

Comments
 (0)