Skip to content

Commit b63990f

Browse files
feat(promote-release): use crane tag so per-arch moving tags stay signed (#197)
* feat(promote-release): use crane tag so per-arch moving tags stay signed docker buildx imagetools create is digest-preserving only for an already multi-arch index. On a bare single-platform manifest (per-arch tags like :vX.Y.Z-amd64) it wraps the manifest in a NEW index, changing the digest and orphaning the digest-scoped cosign signature. That blocks promoting the per-arch moving tags (latest-amd64, latest-fips-arm64v8, ...), which GHCR pull stats show are heavily used. Switch the retag primitive to `crane tag`, which re-points a tag at the exact same manifest digest for both single-platform manifests and multi-arch indexes -- one uniform path covering the whole moving-tag matrix, per-arch included, with signatures intact. Pre-flight existence check moves from `imagetools inspect` to `crane digest`. action.yml installs crane (imjasonh/setup-crane) and crane reads the docker config the existing GHCR login writes, so no auth change is needed. Verified live (real GHCR + cosign): crane preserves per-arch and index digests and cosign verify passes on the retagged per-arch tag, while imagetools changes the per-arch digest and cosign verify then fails. DEVOPS-1083 * chore(promote-release): skip crane install on dry-run Match the guard on the GHCR login step (if: inputs.dry-run != 'true'). crane is only invoked on a real run (DRY_RUN != "true"), so installing it during an exact dry-run is wasted work; the guard keeps the two setup steps consistent. * test(promote-release): assert dry-run prints the full retag matrix The dry-run test spot-checked only the first planned `crane tag` line, so an early loop exit that only manifested under dry-run would still pass. Pin the second entry's line too, so the plural "prints planned retags" claim holds across entries. * docs(promote-release): fix stale test stub reference (docker -> crane) The Testing section still said the bats suite runs with a stubbed `docker` on PATH; this branch deletes docker_mock.bash and stubs `crane` instead.
1 parent d2e92f3 commit b63990f

6 files changed

Lines changed: 212 additions & 156 deletions

File tree

.github/actions/promote-release/README.md

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
# Promote Release
22

33
Retags the moving docker tags (`:latest`, `:{major}`, `:{major}.{minor}`, and
4-
any configured suffix variant such as `-fips`) onto the digest of an already
5-
published, already signed version tag — a manifest copy via
6-
`docker buildx imagetools create`, never a rebuild, so cosign signatures (OCI
7-
referrers, digest-scoped) stay valid with no re-signing. Optionally also
4+
any configured suffix variant such as `-fips`, `-amd64`, `-fips-arm64v8`) onto
5+
the digest of an already published, already signed version tag — a
6+
digest-preserving retag via `crane tag`, never a rebuild, so cosign signatures
7+
(OCI referrers, digest-scoped) stay valid with no re-signing. Optionally also
88
promotes a paired public release in a companion repo (unsets `prerelease`,
99
sets `latest`).
1010

11+
`crane tag` is used rather than `docker buildx imagetools create`: imagetools
12+
is digest-preserving only when the source is already a multi-arch index. For a
13+
bare single-platform manifest (a per-arch tag such as `:{version}-amd64`) it
14+
wraps the manifest in a **new** index, changing its digest and orphaning the
15+
digest-scoped cosign signature. `crane tag` re-points a tag at the exact same
16+
manifest digest for both single-platform manifests and indexes, so it covers
17+
the whole moving-tag matrix — including the per-arch tags — without breaking
18+
signatures.
19+
1120
Wire this from `on: release: types: [released]` on the repo that owns the
1221
moving tags. That event only fires when a human — not `GITHUB_TOKEN`/a bot —
1322
flips a release from pre-release to a full release (verified live for
@@ -44,16 +53,16 @@ as an all-or-nothing skip — a formula has no line-scoped equivalent to
4453

4554
<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->
4655

47-
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
48-
|------------------------|--------|----------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
49-
| docker-username | string | true | | Username paired with github-token for the <br>GHCR login (GHCR checks the token, but docker/login-action requires a username value). |
50-
| dry-run | string | false | `"false"` | Fail-closed: a real promotion runs only <br>on an exact "false" (the default, so the release:released trigger still promotes for real). Any <br>other value ("true", a typo, etc.) is a dry-run <br>that only prints the planned retags/promotion. |
51-
| github-token | string | true | | Token with GHCR write:packages, and contents:write <br>on oss-repo and homebrew-tap-repo if set. |
52-
| homebrew-formula-paths | string | false | `"[]"` | JSON array of formula file paths <br>within homebrew-tap-repo to update, e.g. ["Formula/vcluster.rb"]. <br>Required if homebrew-tap-repo is set. |
53-
| homebrew-tap-repo | string | false | | owner/repo of a Homebrew tap to <br>promote (e.g. loft-sh/homebrew-tap). Requires oss-repo to be <br>set, since checksums come from its <br>release. Leave empty to skip. |
54-
| images | string | true | | JSON array of image entries to <br>retag, each `{"image": "ghcr.io/loft-sh/x", "suffix": ""}` (suffix optional, default <br>""). For each entry, copies `<image>:<version><suffix>` <br>to `<image>:latest<suffix>`, `<image>:<major><suffix>`, and `<image>:<major>.<minor><suffix>`. |
55-
| oss-repo | string | false | | owner/repo whose matching <version> release should <br>also be promoted (prerelease unset, latest set). Leave empty <br>to skip. |
56-
| version | string | true | | The promoted release tag, e.g. v0.37.1. |
56+
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
57+
|------------------------|--------|----------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
58+
| docker-username | string | true | | Username paired with github-token for the <br>GHCR login (GHCR checks the token, but docker/login-action requires a username value). |
59+
| dry-run | string | false | `"false"` | Fail-closed: a real promotion runs only <br>on an exact "false" (the default, so the release:released trigger still promotes for real). Any <br>other value ("true", a typo, etc.) is a dry-run <br>that only prints the planned retags/promotion. |
60+
| github-token | string | true | | Token with GHCR write:packages, and contents:write <br>on oss-repo and homebrew-tap-repo if set. |
61+
| homebrew-formula-paths | string | false | `"[]"` | JSON array of formula file paths <br>within homebrew-tap-repo to update, e.g. ["Formula/vcluster.rb"]. <br>Required if homebrew-tap-repo is set. |
62+
| homebrew-tap-repo | string | false | | owner/repo of a Homebrew tap to <br>promote (e.g. loft-sh/homebrew-tap). Requires oss-repo to be <br>set, since checksums come from its <br>release. Leave empty to skip. |
63+
| images | string | true | | JSON array of image entries to <br>retag, each `{"image": "ghcr.io/loft-sh/x", "suffix": ""}` (suffix optional, default <br>""). For each entry, copies `<image>:<version><suffix>` <br>to `<image>:latest<suffix>`, `<image>:<major><suffix>`, and `<image>:<major>.<minor><suffix>`. The <br>suffix is also how per-arch moving <br>tags are promoted: an entry with <br>suffix `-amd64` retags `<image>:<version>-amd64` (a bare single-platform manifest) to <br>`<image>:latest-amd64` etc. crane preserves its digest, <br>so its cosign signature stays valid. |
64+
| oss-repo | string | false | | owner/repo whose matching <version> release should <br>also be promoted (prerelease unset, latest set). Leave empty <br>to skip. |
65+
| version | string | true | | The promoted release tag, e.g. v0.37.1. |
5766

5867
<!-- AUTO-DOC-INPUT:END -->
5968

@@ -103,11 +112,12 @@ make — otherwise `release: types: [released]` never fires for a stable cut.
103112

104113
### GHCR login
105114

106-
`docker buildx imagetools create` needs to push to GHCR. `action.yml` already
107-
includes a `docker/login-action` step using `docker-username` + `github-token`
108-
(GHCR checks the token; `docker/login-action` still requires a username
109-
value), skipped automatically when `dry-run: true` — callers don't need to
110-
log in separately.
115+
`crane tag` needs to push to GHCR. `action.yml` includes a `docker/login-action`
116+
step using `docker-username` + `github-token` (GHCR checks the token;
117+
`docker/login-action` still requires a username value); crane reads the docker
118+
config that step writes, so no separate crane login is needed. The login is
119+
skipped automatically when `dry-run: true`. `action.yml` also installs crane
120+
(`imjasonh/setup-crane`), so callers don't need to install it themselves.
111121

112122
### oss-repo
113123

@@ -134,5 +144,5 @@ promotion, if configured) have already succeeded by this point.
134144
make test-promote-release
135145
```
136146

137-
Runs the bats suite in `test/` against `src/action.sh` with stubbed `docker`
147+
Runs the bats suite in `test/` against `src/action.sh` with stubbed `crane`
138148
and `gh` on `PATH`.

.github/actions/promote-release/action.yml

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
name: Promote release
22
description: |
33
Retags the moving docker tags (:latest, :{major}, :{major}.{minor}, and any
4-
configured suffix variant e.g. -fips) onto the digest of an already
5-
published, already signed version tag -- a manifest copy, never a rebuild,
6-
so cosign signatures (OCI referrers, digest-scoped) stay valid with no
7-
re-signing. Optionally also promotes the paired public release in a
8-
companion repo (unsets prerelease, sets latest).
4+
configured suffix variant e.g. -fips, -amd64, -fips-arm64v8) onto the digest
5+
of an already published, already signed version tag -- a digest-preserving
6+
`crane tag`, never a rebuild, so cosign signatures (OCI referrers,
7+
digest-scoped) stay valid with no re-signing. Uses `crane tag` rather than
8+
`docker buildx imagetools create` because imagetools re-wraps a bare
9+
single-platform manifest in a new index (changing its digest, orphaning the
10+
signature); crane preserves the digest for both single-platform manifests
11+
(per-arch tags) and multi-arch indexes. Optionally also promotes the paired
12+
public release in a companion repo (unsets prerelease, sets latest).
913
1014
Wire this from `on: release: types: [released]` on the repo that owns the
1115
moving tags. That event only fires when a human -- not GITHUB_TOKEN/a bot --
@@ -44,7 +48,11 @@ inputs:
4448
`{"image": "ghcr.io/loft-sh/x", "suffix": ""}` (suffix optional, default
4549
""). For each entry, copies `<image>:<version><suffix>` to
4650
`<image>:latest<suffix>`, `<image>:<major><suffix>`, and
47-
`<image>:<major>.<minor><suffix>`.
51+
`<image>:<major>.<minor><suffix>`. The suffix is also how per-arch
52+
moving tags are promoted: an entry with suffix `-amd64` retags
53+
`<image>:<version>-amd64` (a bare single-platform manifest) to
54+
`<image>:latest-amd64` etc. crane preserves its digest, so its cosign
55+
signature stays valid.
4856
required: true
4957
oss-repo:
5058
description: 'owner/repo whose matching <version> release should also be promoted (prerelease unset, latest set). Leave empty to skip.'
@@ -78,6 +86,11 @@ runs:
7886
registry: ghcr.io
7987
username: ${{ inputs.docker-username }}
8088
password: ${{ inputs.github-token }}
89+
- name: Install crane
90+
if: inputs.dry-run != 'true'
91+
uses: imjasonh/setup-crane@feee3b6bb0d4c68370f256a4502498c9227e5c6b # v0.7
92+
with:
93+
version: v0.20.2
8194
- name: Promote release
8295
shell: bash
8396
env:

.github/actions/promote-release/src/action.sh

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
#!/usr/bin/env bash
22
# Promote a just-published stable release: retag docker moving tags onto the
33
# version's already-published, already-signed manifest (a digest-preserving
4-
# copy via `docker buildx imagetools create`, never a rebuild -- cosign
5-
# signatures are digest-scoped OCI referrers, so the copy stays verifiable
6-
# with no re-signing), and optionally flip a paired public release off
7-
# pre-release + onto latest.
4+
# retag via `crane tag`, never a rebuild -- cosign signatures are
5+
# digest-scoped OCI referrers, so the copy stays verifiable with no
6+
# re-signing), and optionally flip a paired public release off pre-release +
7+
# onto latest.
8+
#
9+
# `crane tag`, not `docker buildx imagetools create`: imagetools is
10+
# digest-preserving only when the source is already a multi-arch index. For a
11+
# bare single-platform manifest (a per-arch tag like :vX.Y.Z-amd64) it wraps
12+
# the manifest in a NEW index, changing the digest and orphaning the
13+
# digest-scoped cosign signature. `crane tag` re-points a tag at the exact
14+
# same manifest digest for both single-platform manifests and indexes, so it
15+
# covers the whole moving-tag matrix -- including the per-arch tags -- without
16+
# breaking signatures. (Verified live, DEVOPS-1083.)
817
#
918
# Only acts on a stable vX.Y.Z version; any other shape (has a "-" suffix) is
1019
# a no-op, since moving tags and "latest" promotion aren't meaningful for
@@ -197,7 +206,7 @@ for ((i = 0; i < IMAGE_COUNT; i++)); do
197206
echo "::error::images[$i] is missing required \"image\" field: ${entry}" >&2
198207
exit 1
199208
fi
200-
if [[ "${DRY_RUN}" != "true" ]] && ! docker buildx imagetools inspect "${image}:${VERSION}${suffix}" >/dev/null 2>&1; then
209+
if [[ "${DRY_RUN}" != "true" ]] && ! crane digest "${image}:${VERSION}${suffix}" >/dev/null 2>&1; then
201210
echo "::error::source manifest ${image}:${VERSION}${suffix} does not exist; refusing to start retagging" >&2
202211
exit 1
203212
fi
@@ -221,7 +230,10 @@ for ((i = 0; i < IMAGE_COUNT; i++)); do
221230
for moving in "${moving_tags[@]}"; do
222231
dest="${image}:${moving}${suffix}"
223232
echo "Retagging ${dest} -> ${src}"
224-
run docker buildx imagetools create --tag "${dest}" "${src}"
233+
# crane tag SRC NEWTAG re-points NEWTAG (in SRC's repo) at SRC's exact
234+
# manifest digest -- digest-preserving for both single-platform manifests
235+
# and indexes, so per-arch moving tags stay cosign-verifiable (see header).
236+
run crane tag "${src}" "${moving}${suffix}"
225237
done
226238
done
227239

@@ -406,7 +418,7 @@ promote_homebrew_formula() {
406418
-f message="chore: bump ${formula_path} to ${VERSION}" \
407419
-f content="${new_content_b64}" \
408420
-f sha="${current_sha}" >/dev/null; then
409-
echo "::warning::failed to update ${tap_repo}/${formula_path} to ${VERSION}; docker retags (and oss-repo promotion, if configured) already succeeded. Re-run this action to retry the tap update - it is idempotent (imagetools create and the formula patch both re-apply cleanly)."
421+
echo "::warning::failed to update ${tap_repo}/${formula_path} to ${VERSION}; docker retags (and oss-repo promotion, if configured) already succeeded. Re-run this action to retry the tap update - it is idempotent (crane tag and the formula patch both re-apply cleanly)."
410422
fi
411423
}
412424

0 commit comments

Comments
 (0)