Skip to content

Commit 8e1e523

Browse files
committed
feat(promote-release): promote the caller's own release and baseline on the Latest pointer
Two changes needed for a caller that publishes stable cuts with the GitHub "None" label (release.prerelease: auto + make_latest: false), where nothing ever flips the release afterwards: - New promote-self input: also promotes the CALLER repo's release (unset pre-release, set Latest), gated by the same backport check as :latest. Off by default, and only an exact "true" enables it, so a caller whose releases already go out promoted is unaffected. - The unscoped backport gate now compares against the release flagged isLatest, i.e. the last one actually promoted, instead of the newest isPrerelease == false. Under prerelease: auto an un-promoted stable cut is already non-prerelease, so the old baseline would refuse to advance :latest behind any newer un-promoted cut and strand it. Re-promoting the release that is already Latest now resolves as promotable too, so a partially failed promotion can simply be re-run. The line-scoped :{major}.{minor} gate keeps its shape comparison: GitHub has no per-line Latest pointer. Also document that this belongs on workflow_dispatch rather than release: types: [released] - with prerelease: auto that event fires at build time, and it resolves its workflow file from the release's own tag ref, so it never fires for tags on maintenance branches that predate the file. feat(release-notification): add a promote reminder and link both releases - needs_promotion + promote_workflow render a GitHub label field naming the promote workflow, since the release build is the only thing that tells the Release Captain a step is still outstanding. is_prerelease was declared but unused; it now feeds the same field. Fallback is the neutral "Release", so callers passing neither flag get no guessed label. - paired_repo links the release and changelog for BOTH repos. One vCluster cut publishes two releases, and the single link pointed at the private pro repo, the less useful of the two. All three are opt-in with defaults that reproduce the previous output exactly, so vCluster Platform banners are unchanged. Refs DEVOPS-1270
1 parent b3b82b8 commit 8e1e523

8 files changed

Lines changed: 391 additions & 114 deletions

File tree

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

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ any configured suffix variant such as `-fips`, `-amd64`, `-fips-arm64v8`) onto
55
the digest of an already published, already signed version tag — a
66
digest-preserving retag via `crane tag`, never a rebuild, so cosign signatures
77
(OCI 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`).
8+
promotes the caller's own release (`promote-self`) and a paired public release
9+
in a companion repo (unsets `prerelease`, sets `latest`).
1010

1111
`crane tag` is used rather than `docker buildx imagetools create`: imagetools
1212
is digest-preserving only when the source is already a multi-arch index. For a
@@ -17,27 +17,38 @@ manifest digest for both single-platform manifests and indexes, so it covers
1717
the whole moving-tag matrix — including the per-arch tags — without breaking
1818
signatures.
1919

20-
Wire this from `on: release: types: [released]` on the repo that owns the
21-
moving tags. That event only fires when a human — not `GITHUB_TOKEN`/a bot —
22-
flips a release from pre-release to a full release (verified live for
23-
DEVOPS-1083); a bot-authored release publish never triggers it, so there is
24-
no risk of the build itself re-entering this action.
20+
Wire this from `on: workflow_dispatch` on the repo that owns the moving tags,
21+
taking the version to promote as an input. Do **not** wire it from
22+
`on: release: types: [released]` when the caller publishes stable cuts with the
23+
GitHub "None" label (`release.prerelease: auto` in goreleaser): `released` fires
24+
at build time for such a cut, which would promote it unvetted. `release` events
25+
are also resolved from the release's own tag ref, so they never fire for tags on
26+
maintenance branches whose tree predates the workflow file — a
27+
`workflow_dispatch` always runs the default branch's copy and therefore covers
28+
every release line with no backporting.
2529

2630
Only acts on a stable `vX.Y.Z` version (no prerelease suffix); any other shape
2731
is a no-op, since moving tags and "latest" promotion aren't meaningful for
2832
`-rc`/`-alpha`/`-next` cuts.
2933

3034
**Backport-safe:** before advancing `:latest`/`:{major}` (or `--latest` on
31-
`oss-repo`), the action checks whether `version` is actually the newest
32-
stable release on the caller's own repo (`GITHUB_REPOSITORY`, set
33-
automatically by Actions) / on `oss-repo`. Promoting an older line's patch
34-
after a newer stable is already `:latest` skips `:latest`/`:{major}`, so they
35-
never move backwards. `:{major}.{minor}` is scoped to its own line and gets
36-
its own check: it advances only when `version` is the newest stable *within
37-
that `{major}.{minor}` line*, so an out-of-order same-line promotion (e.g.
38-
un-checking pre-release on `v9.9.5` after `v9.9.6` already moved `:9.9`) can't
39-
regress it either. A failure to even list releases fails the run closed rather
40-
than risk a silent downgrade.
35+
`oss-repo` / the caller's own release), the action checks whether `version` is
36+
newer than the release currently flagged **Latest** on the caller's own repo
37+
(`GITHUB_REPOSITORY`, set automatically by Actions) / on `oss-repo`. That Latest
38+
pointer is the baseline, *not* "newest non-prerelease": under
39+
`release.prerelease: auto` a published-but-un-promoted stable cut is already
40+
non-prerelease, so the Latest flag is the only durable record of what `:latest`
41+
actually tracks — reading the prerelease flag instead would strand `:latest`
42+
behind any newer un-promoted cut. Promoting an older line's patch after a newer
43+
stable is already `:latest` skips `:latest`/`:{major}`, so they never move
44+
backwards. Re-promoting the release that is already Latest is allowed, so a
45+
partially failed promotion can simply be re-run. `:{major}.{minor}` is scoped to
46+
its own line and gets its own check: it advances only when `version` is the
47+
newest stable *within that `{major}.{minor}` line* (GitHub has no per-line
48+
equivalent of the Latest pointer), so an out-of-order same-line promotion (e.g.
49+
promoting `v9.9.5` after `v9.9.6` already moved `:9.9`) can't regress it either.
50+
A failure to even list releases fails the run closed rather than risk a silent
51+
downgrade.
4152

4253
Optionally also promotes a Homebrew tap (`homebrew-tap-repo` +
4354
`homebrew-formula-paths`) — a metadata patch, not a rebuild. A formula's
@@ -56,12 +67,13 @@ as an all-or-nothing skip — a formula has no line-scoped equivalent to
5667
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
5768
|------------------------|--------|----------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
5869
| 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. |
70+
| dry-run | string | false | `"false"` | Fail-closed: a real promotion runs only <br>on an exact "false" (the default, so a plain dispatch still promotes for real). Any <br>other value ("true", a typo, etc.) is a dry-run <br>that only prints the planned retags/promotion. |
6071
| github-token | string | true | | Token with GHCR write:packages, and contents:write <br>on oss-repo and homebrew-tap-repo if set. |
6172
| 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. |
6273
| 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. |
6374
| 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. |
6475
| oss-repo | string | false | | owner/repo whose matching <version> release should <br>also be promoted (prerelease unset, latest set). Leave empty <br>to skip. |
76+
| promote-self | string | false | `"false"` | Set to "true" to also promote <br>the CALLER repo's own <version> release <br>(unset pre-release, set Latest). Required when the caller publishes <br>stable cuts with the GitHub "None" <br>label, since nothing else ever flips <br>them. Latest is gated by the <br>same backport check as :latest. |
6577
| version | string | true | | The promoted release tag, e.g. v0.37.1. |
6678

6779
<!-- AUTO-DOC-INPUT:END -->
@@ -70,12 +82,15 @@ as an all-or-nothing skip — a formula has no line-scoped equivalent to
7082

7183
```yaml
7284
on:
73-
release:
74-
types: [released]
85+
workflow_dispatch:
86+
inputs:
87+
version:
88+
description: "Stable version to promote, e.g. v0.37.1"
89+
type: string
90+
required: true
7591

7692
jobs:
7793
promote:
78-
if: github.event.release.prerelease == false
7994
runs-on: ubuntu-latest
8095
permissions:
8196
packages: write
@@ -84,7 +99,8 @@ jobs:
8499
- name: Promote release
85100
uses: loft-sh/github-actions/.github/actions/promote-release@promote-release/v1
86101
with:
87-
version: ${{ github.event.release.tag_name }}
102+
version: ${{ inputs.version }}
103+
promote-self: "true"
88104
oss-repo: loft-sh/vcluster
89105
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
90106
docker-username: ${{ secrets.DOCKER_USERNAME }}
@@ -105,10 +121,23 @@ jobs:
105121
106122
This action only *retags* — the moving tags must not already exist from the
107123
build. The caller's `.goreleaser.yaml` (or equivalent) must publish only the
108-
immutable `:<version>` tag at build time and never the moving tags, and the
109-
GitHub Release itself must be created as a pre-release (`prerelease: true`,
110-
not `auto`) so there is a real pre-release → full-release edit for a human to
111-
make — otherwise `release: types: [released]` never fires for a stable cut.
124+
immutable `:<version>` tag at build time and never the moving tags.
125+
126+
The GitHub Release itself should be published with the "None" label, i.e.
127+
`release.prerelease: auto` (a stable `vX.Y.Z` is not flagged pre-release, every
128+
suffixed tag is) plus `make_latest: false` (nothing is Latest at build time).
129+
The release is then listed and downloadable at its exact tag, while every
130+
*pointer* a consumer follows — `releases/latest`, the moving docker tags, a
131+
stable Homebrew formula — stays behind this action.
132+
133+
Note that goreleaser re-asserts both flags on every run: a re-run of the build
134+
against an already-promoted tag will clear its Latest flag (`make_latest: false`
135+
is sent again on publish). Re-run this action afterwards to restore it; it is
136+
idempotent.
137+
138+
A caller still on `prerelease: true` also works: `--prerelease=false` is part of
139+
what `promote-self`/`oss-repo` apply, so a legacy tag built under the old config
140+
promotes through the same dispatch.
112141

113142
### GHCR login
114143

@@ -119,6 +148,15 @@ config that step writes, so no separate crane login is needed. The login is
119148
skipped automatically when `dry-run: true`. `action.yml` also installs crane
120149
(`imjasonh/setup-crane`), so callers don't need to install it themselves.
121150

151+
### promote-self
152+
153+
Off by default. Set it to `"true"` when the caller's own GitHub Release needs
154+
flipping as part of the promotion — which is the case whenever stable cuts are
155+
published with the "None" label, since nothing else ever promotes them. The
156+
release is edited to `--prerelease=false --latest`, with `--latest` withheld on a
157+
backport promotion (same gate as `:latest`). A missing release warns and skips.
158+
Only an exact `"true"` enables it.
159+
122160
### oss-repo
123161

124162
If set, and a release matching `version` exists on `oss-repo`, it is edited

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

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,35 @@ description: |
88
`docker buildx imagetools create` because imagetools re-wraps a bare
99
single-platform manifest in a new index (changing its digest, orphaning the
1010
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).
11+
(per-arch tags) and multi-arch indexes. Optionally also promotes the caller's
12+
own release (`promote-self`) and the paired public release in a companion
13+
repo (unsets prerelease, sets latest).
1314
14-
Wire this from `on: release: types: [released]` on the repo that owns the
15-
moving tags. That event only fires when a human -- not GITHUB_TOKEN/a bot --
16-
flips a release from pre-release to a full release (verified live for
17-
DEVOPS-1083); a bot-authored release publish never triggers it, so there is
18-
no risk of the build itself re-entering this action.
15+
Wire this from `on: workflow_dispatch` on the repo that owns the moving tags,
16+
with the version to promote as an input. A `release: types: [released]`
17+
trigger is NOT suitable for a caller that publishes stable cuts as the GitHub
18+
"None" label (`release.prerelease: auto`): `released` fires at build time for
19+
such a cut, which would self-promote it unvetted. It is also resolved from the
20+
release's own tag ref, so it never fires for tags on maintenance branches that
21+
predate the workflow file, whereas `workflow_dispatch` always runs the default
22+
branch's copy and therefore covers every release line.
1923
2024
Only acts on a stable `vX.Y.Z` version (no prerelease suffix); any other
2125
shape is a no-op, since moving tags and "latest" promotion aren't meaningful
2226
for -rc/-alpha/-next cuts.
2327
2428
Backport-safe: before advancing `:latest`/`:{major}` (or `--latest` on
25-
oss-repo), checks whether `version` is actually the newest stable release
26-
on the caller's own repo (`GITHUB_REPOSITORY`, set automatically by
27-
Actions) / on `oss-repo` respectively. Promoting an older line's patch
28-
after a newer stable is already `:latest` only advances `:{major}.{minor}`
29-
(scoped to that line), never `:latest`/`:{major}` backwards. A failure to
30-
even list releases fails the run closed rather than risking a silent
31-
downgrade.
29+
oss-repo / the caller's own release), checks whether `version` is newer than
30+
the release currently flagged Latest on the caller's own repo
31+
(`GITHUB_REPOSITORY`, set automatically by Actions) / on `oss-repo`
32+
respectively. The baseline is that Latest pointer, not "newest
33+
non-prerelease": under `release.prerelease: auto` an un-promoted stable cut is
34+
already non-prerelease, so only the Latest flag records what `:latest`
35+
actually tracks. Promoting an older line's patch after a newer stable is
36+
already `:latest` only advances `:{major}.{minor}` (scoped to that line),
37+
never `:latest`/`:{major}` backwards. A failure to even list releases fails
38+
the run closed rather than risking a silent downgrade. Re-promoting the
39+
current Latest is allowed, so a partially failed promotion can be re-run.
3240
3341
Optionally also promotes a Homebrew tap: a metadata patch, not a rebuild.
3442
A formula's per-platform sha256 values are exactly what's already in
@@ -54,6 +62,10 @@ inputs:
5462
`<image>:latest-amd64` etc. crane preserves its digest, so its cosign
5563
signature stays valid.
5664
required: true
65+
promote-self:
66+
description: 'Set to "true" to also promote the CALLER repo''s own <version> release (unset pre-release, set Latest). Required when the caller publishes stable cuts with the GitHub "None" label, since nothing else ever flips them. Latest is gated by the same backport check as :latest.'
67+
required: false
68+
default: 'false'
5769
oss-repo:
5870
description: 'owner/repo whose matching <version> release should also be promoted (prerelease unset, latest set). Leave empty to skip.'
5971
required: false
@@ -73,7 +85,7 @@ inputs:
7385
required: false
7486
default: '[]'
7587
dry-run:
76-
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.'
88+
description: 'Fail-closed: a real promotion runs only on an exact "false" (the default, so a plain dispatch still promotes for real). Any other value ("true", a typo, etc.) is a dry-run that only prints the planned retags/promotion.'
7789
required: false
7890
default: 'false'
7991
runs:
@@ -97,6 +109,7 @@ runs:
97109
GH_TOKEN: ${{ inputs.github-token }}
98110
INPUT_VERSION: ${{ inputs.version }}
99111
INPUT_IMAGES: ${{ inputs.images }}
112+
INPUT_PROMOTE_SELF: ${{ inputs.promote-self }}
100113
INPUT_OSS_REPO: ${{ inputs.oss-repo }}
101114
INPUT_HOMEBREW_TAP_REPO: ${{ inputs.homebrew-tap-repo }}
102115
INPUT_HOMEBREW_FORMULA_PATHS: ${{ inputs.homebrew-formula-paths }}

0 commit comments

Comments
 (0)