Skip to content

Commit c40c1db

Browse files
feat(promote-release): retag docker moving tags + promote paired release on human promotion (#194)
* feat(promote-release): retag docker moving tags + promote paired release on human release-promotion DEVOPS-1083. Wires on: release: types: [released] (verified live: fires only on a human editing prerelease->false, never on a GITHUB_TOKEN/bot release publish) to a digest-preserving docker buildx imagetools retag of :latest/:X/:X.Y (and suffix variants), then optionally un-prereleases + latests a paired release in another repo. Image list and oss-repo are caller-supplied inputs so this isn't vcluster-pro-specific. * fix(promote-release): use docker-username input, not github.actor, for GHCR login Matches the existing vcluster-pro release.yaml pattern (DOCKER_USERNAME secret paired with the GH_ACCESS_TOKEN PAT), instead of assuming the release-promoting human's own GitHub login has GHCR write access. * docs(promote-release): regenerate README input table with auto-doc The hand-written table drifted from auto-doc's exact formatting (alphabetical ordering, quoted default values), which check-docs caught. * test(promote-release): add missing CI workflow, use fake fixtures, fix bats negation - Add .github/workflows/test-promote-release.yaml - every other tested action has one; this one was missing, so the bats suite never actually ran in CI (only actionlint/zizmor/check-docs did). - Replace real-looking test fixtures (v0.37.1, ghcr.io/loft-sh/vcluster-pro, loft-sh/vcluster) with obviously-fake ones (v9.9.9, example-org/...) so nobody mistakes test data for a real artifact. - Fix two bare '! grep' assertions (shellcheck SC2314): a bash negation outside 'run' doesn't reliably fail a bats test. Use 'run !' instead. * fix(promote-release): guard :latest/:major against backport downgrades Addresses PR review on #194: - blocking: promoting an older line's backport/patch after a newer stable is already :latest silently moved :latest/:{major} backwards. Added is_latest_stable(), checked against the caller's own repo (docker tags) and oss-repo (the paired release's --latest) independently. :{major}.{minor} is unaffected (line-scoped, always safe). A failure to list releases fails closed, matching the existing Homebrew-tap downgrade guard's precedent rather than risking a silent downgrade. - consider: pre-flight now checks the source manifest actually exists (docker buildx imagetools inspect) before any retag starts, so a suffix variant that wasn't built for this version can't leave earlier entries partially retagged. - consider: gh release edit failure now warns instead of hard-failing - the docker retags already succeeded by that point, so a transient API error on the advisory oss-repo step shouldn't read as a total failure. - nit: README usage snippet now pins with the promote-release/v1 tag, matching every sibling action README instead of a <sha> placeholder. 6 new bats cases (backport on each repo independently, missing-manifest pre-flight, release-list fail-closed, edit-failure soft-warn); 19 total, all green. zizmor/shellcheck clean. * test(promote-release): cover same-major, older-minor backport ordering Confirms the backport guard also catches promoting e.g. v0.35.6 after v0.36.0 already exists, not just a major-version jump - sort -V's minor component comparison is a distinct thing to get right from the major one already covered. * test(promote-release): explicit coverage for every unstable version shape The existing stable-version regex (anchored, no room for a suffix) already rejects rc/alpha/beta/next/next.internal before any docker or gh call - a human can uncheck pre-release on ANY release regardless of its tag shape, so release:released can fire for these too. Only one generic -rc.1 case was actually exercised; add explicit cases for every named category. * feat(promote-release): promote Homebrew taps as a metadata patch, not a rebuild New optional inputs: homebrew-tap-repo, homebrew-formula-paths. A formula's per-platform sha256 values are exactly what's already in oss-repo's <version> release checksums.txt (already published, already cosign-signed) - verified by hand against the real loft-sh/homebrew-tap vcluster.rb and its matching v0.36.0 OSS release checksums.txt, which match byte-for-byte. So promotion patches the existing formula file via the GitHub Contents API (no git clone/push) instead of re-templating it: swap the version and every url's tag segment, then rewrite each artifact's sha256 on the line immediately after its url, sourced by filename match against checksums.txt. Everything else in the formula (deps, install blocks, test block) is preserved byte-for-byte. Same backport guard as the docker tags, but all-or-nothing: a formula has no line-scoped equivalent to :{major}.{minor}, so a backport/patch promotion skips Homebrew entirely rather than partially. Missing checksums, download failures, and contents API failures all warn and skip rather than fail the run - the docker retags (and oss-repo promotion, if configured) have already succeeded by this point. 11 new bats tests (32 total): happy path incl. static-content preservation, backport skip, validation errors, download/GET/PUT failure handling, dry-run, multiple formulas, missing-checksum partial-patch. zizmor/shellcheck clean. * fix(promote-release): harden advisory paths and close test-coverage gaps Address PR #194 review: - guard homebrew grep|head with || true so a no-match warn-skips under pipefail instead of hard-failing the run - guard jq .sha with // empty so a missing field warn-skips instead of PUTting the literal "null" and 422ing - compute oss-repo latest-stable once and reuse it for the advisory homebrew gate, so it isn't re-listed and can't hit a second hard-failing list call after everything else succeeded - point the tap-update failure warning at re-running the idempotent action instead of an incomplete manual gh api command - document contents:write on homebrew-tap-repo in the token scope - cover newest-among-non-empty-prior-stables, homebrew-skip all-or-nothing invariant, and second-formula PUT body References DEVOPS-1083 * docs(promote-release): regenerate README inputs table for token scope * fix(promote-release): harden tag guards, dry-run, and homebrew patching per review Addresses the review feedback on #194: - docker moving tags: gate :{major}.{minor} on a line-scoped is_latest_stable so a same-line out-of-order promotion can't regress the line tag, and widen the release-list window to 1000 so a line's siblings can't scroll out and fail the guard open. - dry-run: fail closed like vcluster-release - only an exact "false" promotes for real; any other value warn-skips, so an intended preview can't fire a real retag or release flip. - oss-repo --latest gate: soft-fail a post-retag `gh release list` blip (warn + skip --latest) instead of hard-exiting after the irreversible retags, and keep it distinct from a confirmed backport via a four-state OSS_IS_LATEST so the homebrew skip reports accurately and stays retryable. - homebrew patching: rewrite tags/artifacts with literal awk string ops (no regex/sed interpolation of formula-controlled values), anchor the version rewrite to the top-level line, validate the download tag shape, and guard the base64 decode so the advisory path never hard-fails. - docs: correct the token/permissions guidance in both READMEs and the action.yml dry-run description. - tests: cover every fix above; add per-repo list-failure and bad-base64 mock knobs. * docs(promote-release): regenerate inputs table for fail-closed dry-run description * docs: run make generate-docs before pushing commits
1 parent ed40924 commit c40c1db

10 files changed

Lines changed: 1782 additions & 2 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Promote Release
2+
3+
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
8+
promotes a paired public release in a companion repo (unsets `prerelease`,
9+
sets `latest`).
10+
11+
Wire this from `on: release: types: [released]` on the repo that owns the
12+
moving tags. That event only fires when a human — not `GITHUB_TOKEN`/a bot —
13+
flips a release from pre-release to a full release (verified live for
14+
DEVOPS-1083); a bot-authored release publish never triggers it, so there is
15+
no risk of the build itself re-entering this action.
16+
17+
Only acts on a stable `vX.Y.Z` version (no prerelease suffix); any other shape
18+
is a no-op, since moving tags and "latest" promotion aren't meaningful for
19+
`-rc`/`-alpha`/`-next` cuts.
20+
21+
**Backport-safe:** before advancing `:latest`/`:{major}` (or `--latest` on
22+
`oss-repo`), the action checks whether `version` is actually the newest
23+
stable release on the caller's own repo (`GITHUB_REPOSITORY`, set
24+
automatically by Actions) / on `oss-repo`. Promoting an older line's patch
25+
after a newer stable is already `:latest` skips `:latest`/`:{major}`, so they
26+
never move backwards. `:{major}.{minor}` is scoped to its own line and gets
27+
its own check: it advances only when `version` is the newest stable *within
28+
that `{major}.{minor}` line*, so an out-of-order same-line promotion (e.g.
29+
un-checking pre-release on `v9.9.5` after `v9.9.6` already moved `:9.9`) can't
30+
regress it either. A failure to even list releases fails the run closed rather
31+
than risk a silent downgrade.
32+
33+
Optionally also promotes a Homebrew tap (`homebrew-tap-repo` +
34+
`homebrew-formula-paths`) — a metadata patch, not a rebuild. A formula's
35+
per-platform `sha256` values are exactly what's already in `oss-repo`'s
36+
`version` release `checksums.txt` (already published, already cosign-signed),
37+
so nothing is re-hashed; only the `version` line and each `url`/`sha256` pair
38+
are rewritten in place, with everything else in the formula (deps, install
39+
blocks, `test do`) preserved byte-for-byte. Same backport rule applies, but
40+
as an all-or-nothing skip — a formula has no line-scoped equivalent to
41+
`:{major}.{minor}`.
42+
43+
## Inputs
44+
45+
<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->
46+
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. |
57+
58+
<!-- AUTO-DOC-INPUT:END -->
59+
60+
## Usage
61+
62+
```yaml
63+
on:
64+
release:
65+
types: [released]
66+
67+
jobs:
68+
promote:
69+
if: github.event.release.prerelease == false
70+
runs-on: ubuntu-latest
71+
permissions:
72+
packages: write
73+
contents: read
74+
steps:
75+
- name: Promote release
76+
uses: loft-sh/github-actions/.github/actions/promote-release@promote-release/v1
77+
with:
78+
version: ${{ github.event.release.tag_name }}
79+
oss-repo: loft-sh/vcluster
80+
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
81+
docker-username: ${{ secrets.DOCKER_USERNAME }}
82+
images: |
83+
[
84+
{"image": "ghcr.io/loft-sh/vcluster-pro"},
85+
{"image": "ghcr.io/loft-sh/vcluster-pro", "suffix": "-fips"},
86+
{"image": "ghcr.io/loft-sh/vcluster-pro-fips"},
87+
{"image": "ghcr.io/loft-sh/vcluster-oss"},
88+
{"image": "ghcr.io/loft-sh/vcluster-cli"}
89+
]
90+
homebrew-tap-repo: loft-sh/homebrew-tap
91+
homebrew-formula-paths: |
92+
["Formula/vcluster.rb", "Formula/vcluster-experimental.rb"]
93+
```
94+
95+
### Why this needs a build-time gating change too
96+
97+
This action only *retags* — the moving tags must not already exist from the
98+
build. The caller's `.goreleaser.yaml` (or equivalent) must publish only the
99+
immutable `:<version>` tag at build time and never the moving tags, and the
100+
GitHub Release itself must be created as a pre-release (`prerelease: true`,
101+
not `auto`) so there is a real pre-release → full-release edit for a human to
102+
make — otherwise `release: types: [released]` never fires for a stable cut.
103+
104+
### GHCR login
105+
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.
111+
112+
### oss-repo
113+
114+
If set, and a release matching `version` exists on `oss-repo`, it is edited
115+
to `--prerelease=false --latest`. If no matching release exists, this step is
116+
skipped with a warning — it does not fail the docker retagging.
117+
118+
### homebrew-tap-repo
119+
120+
Requires `oss-repo` to be set — the formula's checksums come from
121+
`oss-repo`'s `version` release `checksums.txt`, matched to each formula's
122+
existing `url` lines by artifact filename (e.g. `vcluster-darwin-amd64`).
123+
`github-token` needs `contents: write` on `homebrew-tap-repo` (via the
124+
GitHub Contents API, not a git clone/push). Each formula's `version` line and
125+
matched `url`/`sha256` pairs are rewritten; an artifact with no matching
126+
checksum keeps its previous `sha256` and logs a warning rather than failing.
127+
Failures here (checksums download, contents fetch, or the update itself) warn
128+
and skip — they never fail the run, since the docker retags (and `oss-repo`
129+
promotion, if configured) have already succeeded by this point.
130+
131+
## Testing
132+
133+
```bash
134+
make test-promote-release
135+
```
136+
137+
Runs the bats suite in `test/` against `src/action.sh` with stubbed `docker`
138+
and `gh` on `PATH`.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Promote release
2+
description: |
3+
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).
9+
10+
Wire this from `on: release: types: [released]` on the repo that owns the
11+
moving tags. That event only fires when a human -- not GITHUB_TOKEN/a bot --
12+
flips a release from pre-release to a full release (verified live for
13+
DEVOPS-1083); a bot-authored release publish never triggers it, so there is
14+
no risk of the build itself re-entering this action.
15+
16+
Only acts on a stable `vX.Y.Z` version (no prerelease suffix); any other
17+
shape is a no-op, since moving tags and "latest" promotion aren't meaningful
18+
for -rc/-alpha/-next cuts.
19+
20+
Backport-safe: before advancing `:latest`/`:{major}` (or `--latest` on
21+
oss-repo), checks whether `version` is actually the newest stable release
22+
on the caller's own repo (`GITHUB_REPOSITORY`, set automatically by
23+
Actions) / on `oss-repo` respectively. Promoting an older line's patch
24+
after a newer stable is already `:latest` only advances `:{major}.{minor}`
25+
(scoped to that line), never `:latest`/`:{major}` backwards. A failure to
26+
even list releases fails the run closed rather than risking a silent
27+
downgrade.
28+
29+
Optionally also promotes a Homebrew tap: a metadata patch, not a rebuild.
30+
A formula's per-platform sha256 values are exactly what's already in
31+
oss-repo's `version` release `checksums.txt` (already published, already
32+
cosign-signed), so nothing is re-hashed - only the version line and each
33+
url/sha256 pair are rewritten in place, with everything else in the
34+
formula preserved byte-for-byte. Skipped entirely (not partially) on a
35+
backport/patch promotion - a formula has no line-scoped equivalent to
36+
`:{major}.{minor}`.
37+
inputs:
38+
version:
39+
description: 'The promoted release tag, e.g. v0.37.1.'
40+
required: true
41+
images:
42+
description: |
43+
JSON array of image entries to retag, each
44+
`{"image": "ghcr.io/loft-sh/x", "suffix": ""}` (suffix optional, default
45+
""). For each entry, copies `<image>:<version><suffix>` to
46+
`<image>:latest<suffix>`, `<image>:<major><suffix>`, and
47+
`<image>:<major>.<minor><suffix>`.
48+
required: true
49+
oss-repo:
50+
description: 'owner/repo whose matching <version> release should also be promoted (prerelease unset, latest set). Leave empty to skip.'
51+
required: false
52+
default: ''
53+
github-token:
54+
description: 'Token with GHCR write:packages, and contents:write on oss-repo and homebrew-tap-repo if set.'
55+
required: true
56+
docker-username:
57+
description: 'Username paired with github-token for the GHCR login (GHCR checks the token, but docker/login-action requires a username value).'
58+
required: true
59+
homebrew-tap-repo:
60+
description: 'owner/repo of a Homebrew tap to promote (e.g. loft-sh/homebrew-tap). Requires oss-repo to be set, since checksums come from its release. Leave empty to skip.'
61+
required: false
62+
default: ''
63+
homebrew-formula-paths:
64+
description: 'JSON array of formula file paths within homebrew-tap-repo to update, e.g. ["Formula/vcluster.rb"]. Required if homebrew-tap-repo is set.'
65+
required: false
66+
default: '[]'
67+
dry-run:
68+
description: 'Fail-closed: a real promotion runs only on an exact "false" (the default, so the release:released trigger still promotes for real). Any other value ("true", a typo, etc.) is a dry-run that only prints the planned retags/promotion.'
69+
required: false
70+
default: 'false'
71+
runs:
72+
using: composite
73+
steps:
74+
- name: Log in to GHCR
75+
if: inputs.dry-run != 'true'
76+
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
77+
with:
78+
registry: ghcr.io
79+
username: ${{ inputs.docker-username }}
80+
password: ${{ inputs.github-token }}
81+
- name: Promote release
82+
shell: bash
83+
env:
84+
GH_TOKEN: ${{ inputs.github-token }}
85+
INPUT_VERSION: ${{ inputs.version }}
86+
INPUT_IMAGES: ${{ inputs.images }}
87+
INPUT_OSS_REPO: ${{ inputs.oss-repo }}
88+
INPUT_HOMEBREW_TAP_REPO: ${{ inputs.homebrew-tap-repo }}
89+
INPUT_HOMEBREW_FORMULA_PATHS: ${{ inputs.homebrew-formula-paths }}
90+
INPUT_DRY_RUN: ${{ inputs.dry-run }}
91+
run: ${{ github.action_path }}/src/action.sh
92+
branding:
93+
icon: 'upload-cloud'
94+
color: 'orange'

0 commit comments

Comments
 (0)