Skip to content

Commit 47eec01

Browse files
feat(release): add Cut Pre-release Tag workflow (#2677)
Pre-release tags (vX.Y.Z-alpha.N / -beta.N / -rc.N) were cut by a manual `git push origin HEAD:refs/tags/<tag>`. That leaves the immutable-tag work in #2677 only partly enforceable: a repo ruleset cannot restrict `v*` tag *creation* to CI while still letting maintainers hand-push pre-releases (fnmatch has no way to match "v* except v*-rc.*"), so "do not hand-cut stable tags" stays a matter of discipline backed only by an in-workflow guard. Add cut-prerelease.yaml — a workflow_dispatch that becomes the sole entry point for creating a pre-release tag: - Generates the CozyStack CI app token (same app as promote-rc.yaml) and pushes the tag as the app, so tags.yaml fires (a tag pushed with the default GITHUB_TOKEN would not trigger it) and the push-event base_ref is populated (tags.yaml's Get-base-branch step needs it). - Validates ^v\d+\.\d+\.\d+-(alpha|beta|rc)\.\d+$ and rejects stable vX.Y.Z — stable tags are cut write-once by the promote flow at a PR merge commit. - Enforces dispatch from main or the matching release-X.Y branch, and refuses to move an existing tag (write-once). - Pushes HEAD:refs/tags/<tag> from the branch tip — the exact form docs/release.md prescribes — on ubuntu-latest (no build runner needed). With every pre-release cut by CI, repo admins can then enable a "Restrict creations" tag ruleset on `v*` with the CI app as the sole bypass actor, turning tag-creation discipline into hard enforcement. Also update tags.yaml's two human-facing error messages and docs/release.md (the RC and patch flows, the workflow list, and a new repo-side tag-ruleset enforcement note) to point at the workflow instead of a manual push. Co-Authored-By: Claude Opus 4.8 <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent f91841f commit 47eec01

3 files changed

Lines changed: 168 additions & 12 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
name: Cut Pre-release Tag
2+
3+
# Cut a pre-release tag (vX.Y.Z-alpha.N / -beta.N / -rc.N) as the CozyStack CI
4+
# app, so that no human ever pushes a v* tag by hand.
5+
#
6+
# This is the single entry point for creating a pre-release. Stable vX.Y.Z tags
7+
# are never cut here (or by hand) — they are created write-once by
8+
# pull-requests-release.yaml finalize at a promote PR's merge commit. See
9+
# docs/release.md and #2677.
10+
#
11+
# Dispatch from the branch the tag belongs to: `main` for a new minor's
12+
# pre-release (vX.Y.0-rc.N), or `release-X.Y` for a patch line (vX.Y.Z-rc.N).
13+
# The workflow tags that branch's HEAD.
14+
#
15+
# Why a workflow instead of a manual `git push`:
16+
# 1. The tag is pushed with the CI app token. A tag pushed with the default
17+
# GITHUB_TOKEN would NOT trigger tags.yaml (GitHub suppresses workflow
18+
# recursion from GITHUB_TOKEN); the app token is a distinct identity, so
19+
# tags.yaml fires exactly as it does for a maintainer's manual push.
20+
# 2. It pushes `HEAD:refs/tags/<tag>` from the checked-out branch tip, so the
21+
# push event's base_ref is populated — tags.yaml's "Get base branch" step
22+
# needs it to detect main vs release-X.Y.
23+
# 3. Once every pre-release is cut here, repo admins can add a "Restrict
24+
# creations" tag ruleset on v* with this app as the sole bypass actor,
25+
# turning "don't hand-push tags" from convention into hard enforcement.
26+
27+
on:
28+
workflow_dispatch:
29+
inputs:
30+
tag:
31+
description: "Pre-release tag to cut, e.g. v1.6.0-rc.1 (must be -alpha.N / -beta.N / -rc.N)"
32+
required: true
33+
type: string
34+
35+
concurrency:
36+
# One global lane: two dispatches racing to push overlapping tags would
37+
# confuse the write-once guard. Pre-release cuts are rare, so serialize.
38+
group: cut-prerelease
39+
cancel-in-progress: false
40+
41+
# The tag write happens via the app token, not GITHUB_TOKEN, so the job needs
42+
# only read scope on the default token.
43+
permissions:
44+
contents: read
45+
46+
jobs:
47+
cut:
48+
name: Cut ${{ inputs.tag }}
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 10
51+
52+
steps:
53+
- name: Generate GitHub App token
54+
id: app-token
55+
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
56+
with:
57+
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
58+
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
59+
owner: cozystack
60+
# Scope the token to this repo only — it never touches other repos.
61+
repositories: ${{ github.event.repository.name }}
62+
63+
# Validate the dispatch input against a strict pre-release pattern before
64+
# it reaches git. A stable vX.Y.Z is rejected on purpose: stable tags are
65+
# cut only by the promote flow, at a PR merge commit.
66+
- name: Parse and validate tag
67+
id: parse
68+
env:
69+
TAG: ${{ inputs.tag }}
70+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
71+
with:
72+
script: |
73+
const tag = process.env.TAG.trim();
74+
const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.(\d+)$/);
75+
if (!m) {
76+
core.setFailed(
77+
`tag '${tag}' must match 'vX.Y.Z-(alpha|beta|rc).N'. ` +
78+
`Stable vX.Y.Z tags are cut by the promote flow, not here.`
79+
);
80+
return;
81+
}
82+
core.setOutput('tag', tag);
83+
core.setOutput('line', `${m[1]}.${m[2]}`); // 1.6
84+
85+
- name: Check out the dispatched branch
86+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
87+
with:
88+
fetch-depth: 0
89+
fetch-tags: true
90+
# Authenticate origin with the app token so the tag push below is
91+
# attributed to the app (and thus triggers tags.yaml).
92+
token: ${{ steps.app-token.outputs.token }}
93+
94+
# Refuse to cut from anything but main or a release-X.Y line, and make the
95+
# tag's version line agree with a release-X.Y dispatch branch. The pushed
96+
# tag's base_ref (= the dispatch branch) must be one tags.yaml accepts.
97+
- name: Validate dispatch branch
98+
env:
99+
REF_NAME: ${{ github.ref_name }}
100+
REF_TYPE: ${{ github.ref_type }}
101+
LINE: ${{ steps.parse.outputs.line }}
102+
run: |
103+
if [ "$REF_TYPE" != "branch" ]; then
104+
echo "::error::Dispatch this workflow from a branch (main or release-X.Y), not a tag or SHA."
105+
exit 1
106+
fi
107+
case "$REF_NAME" in
108+
main)
109+
;;
110+
release-*)
111+
if [ "release-$LINE" != "$REF_NAME" ]; then
112+
echo "::error::Tag line $LINE does not match dispatch branch '$REF_NAME'. Cut v$LINE.* from 'main' or 'release-$LINE'."
113+
exit 1
114+
fi
115+
;;
116+
*)
117+
echo "::error::Dispatch from 'main' or 'release-X.Y' only. Got '$REF_NAME'."
118+
exit 1
119+
;;
120+
esac
121+
122+
# Pre-release tags are write-once. Fail early with a clear message rather
123+
# than letting the (non-forced) push reject cryptically.
124+
- name: Refuse to move an existing tag
125+
env:
126+
TAG: ${{ steps.parse.outputs.tag }}
127+
run: |
128+
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
129+
echo "::error::Tag $TAG already exists locally — pre-release tags are write-once."
130+
exit 1
131+
fi
132+
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
133+
echo "::error::Tag $TAG already exists on origin — pre-release tags are write-once."
134+
exit 1
135+
fi
136+
137+
# Push HEAD (the dispatch branch tip) straight to a tag ref — the exact
138+
# form docs/release.md prescribes for a manual cut, so GitHub populates the
139+
# push event's base_ref. No local tag object, no -f: a lightweight,
140+
# branch-anchored, write-once tag. tags.yaml takes it from here (build +
141+
# e2e + draft rc release + staging branch).
142+
- name: Cut and push the pre-release tag
143+
env:
144+
TAG: ${{ steps.parse.outputs.tag }}
145+
run: |
146+
git push origin "HEAD:refs/tags/$TAG"
147+
echo "Pushed $TAG at $(git rev-parse HEAD) from '$GITHUB_REF_NAME'."
148+
echo "tags.yaml will now build, e2e, and draft the pre-release."

‎.github/workflows/tags.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ jobs:
104104
- name: Reject hand-pushed stable tags
105105
if: steps.check_release.outputs.release_exists == 'false' && steps.tag.outputs.is_stable == 'true'
106106
run: |
107-
echo "::error::Stable tag ${GITHUB_REF#refs/tags/} has no pre-existing draft release. Stable tags come from promote-rc.yaml — push a release candidate (vX.Y.Z-rc.N) and promote it; do not push vX.Y.Z by hand."
107+
echo "::error::Stable tag ${GITHUB_REF#refs/tags/} has no pre-existing draft release. Stable tags come from promote-rc.yaml — cut a pre-release (vX.Y.Z-rc.N) with the 'Cut Pre-release Tag' workflow and promote it; do not push vX.Y.Z by hand."
108108
exit 1
109109
110110
# Detect base branch (main or release-X.Y) the tag was pushed from
@@ -116,7 +116,7 @@ jobs:
116116
script: |
117117
const baseRef = context.payload.base_ref;
118118
if (!baseRef) {
119-
core.setFailed(`❌ base_ref is empty. Push the tag via 'git push origin HEAD:refs/tags/<tag>'.`);
119+
core.setFailed(`❌ base_ref is empty — the tag was not pushed at a branch tip. Cut pre-release tags with the 'Cut Pre-release Tag' workflow (workflow_dispatch), which pushes the tag correctly.`);
120120
return;
121121
}
122122
const branch = baseRef.replace('refs/heads/', '');

‎docs/release.md‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This document is both the process design (what the release model looks like) and
66

77
You are about to:
88

9-
- Tag a release candidate (`vX.Y.0-rc.N`).
9+
- Cut a pre-release (`vX.Y.0-rc.N`, or `vX.Y.0-alpha.N` / `-beta.N`).
1010
- Cut a regular release (`vX.Y.0`).
1111
- Cut a patch release (`vX.Y.Z` with `Z > 0`).
1212
- Triage cherry-picks before a patch release.
@@ -25,7 +25,7 @@ There are three types of releases:
2525
- **Regular Releases** – Final versions (e.g., `v1.2.0`) that are feature-complete and thoroughly tested.
2626
- **Patch Releases** – Bugfix-only updates (e.g., `v1.2.1`) made after a stable release, based on a dedicated release branch.
2727

28-
All three are matched by `tags.yaml`'s regex `^v\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$`. The tag push is the trigger for the whole pipeline. **Always push tags with `git push origin HEAD:refs/tags/<tag>`** so GitHub fills `base_ref` — `tags.yaml`'s `Get base branch` step refuses tags pushed without a base.
28+
All three are matched by `tags.yaml`'s regex `^v\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$`. The tag push is the trigger for the whole pipeline. **Pre-release tags (`-alpha` / `-beta` / `-rc`) are cut with the [`Cut Pre-release Tag`](../.github/workflows/cut-prerelease.yaml) workflow** (manual dispatch from `main` or `release-X.Y`) — it pushes the tag as the CI app at the dispatch branch's tip, so GitHub fills `base_ref` (`tags.yaml`'s `Get base branch` step refuses tags pushed without a base) and the push triggers `tags.yaml` (a tag pushed with the default `GITHUB_TOKEN` would not). Stable `vX.Y.Z` tags are never pushed by hand at all — they are cut write-once by the promote flow at a PR merge commit.
2929

3030
## Release Candidates
3131

@@ -55,7 +55,7 @@ gitGraph
5555

5656
A regular release sequence runs as follows:
5757

58-
1. Tag the last good commit on `main` as a release candidate (`v1.2.0-rc.N`) and push it with `git push origin HEAD:refs/tags/v1.2.0-rc.N`. CI builds the rc images, drafts the rc release, and pushes the digest-vendored `release-1.2.0-rc.N` staging branch.
58+
1. Cut a release candidate (`v1.2.0-rc.N`) from the last good commit on `main`: run the [`Cut Pre-release Tag`](../.github/workflows/cut-prerelease.yaml) workflow (manual dispatch **from `main`**) with the tag. It tags `main`'s HEAD as the CI app; `tags.yaml` then builds the rc images, drafts the rc release, and pushes the digest-vendored `release-1.2.0-rc.N` staging branch.
5959
2. Validate the rc. New features and fixes can still land on `main` and be picked up by a later rc.
6060
3. Once an rc is green, run the [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) workflow (manual dispatch) with that rc tag. Promotion is **transactional** — it performs no registry mutation at dispatch. It:
6161
1. Pushes the `release-1.2.0` branch: the rc's digest-vendored tree with the rc version substring rewritten to `1.2.0` (the digests are the rc's — unchanged).
@@ -142,7 +142,7 @@ gitGraph
142142

143143
When all relevant patch commits are cherry-picked, the branch is ready for release.
144144

145-
2. The maintainer tags the `HEAD` commit of branch `release-1.2` as a release candidate (`v1.2.1-rc.N`) and pushes it. CI builds the rc images, drafts the rc release, and pushes the `release-1.2.1-rc.N` staging branch.
145+
2. The maintainer cuts a release candidate (`v1.2.1-rc.N`) via the [`Cut Pre-release Tag`](../.github/workflows/cut-prerelease.yaml) workflow (manual dispatch **from `release-1.2`**), which tags that branch's `HEAD`. CI builds the rc images, drafts the rc release, and pushes the `release-1.2.1-rc.N` staging branch.
146146
3. Validate the rc.
147147
4. Once the rc is green, run [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) (manual dispatch) with that rc tag. It pushes the `release-1.2.1` branch (rc digests, tag string rewritten to `1.2.1`), drafts the `v1.2.1` release with the restamped assets, and opens the promote PR into `release-1.2` (labelled `release` + `full-e2e`). As with a regular release the retag is deferred to the merge — no registry mutation at dispatch.
148148

@@ -176,12 +176,13 @@ gitGraph
176176

177177
## What CI does during the release process
178178

179-
The numbered process above is implemented by four workflows. Knowing which job does what makes the failure modes much easier to diagnose.
179+
The numbered process above is implemented by five workflows. Knowing which job does what makes the failure modes much easier to diagnose.
180180

181-
1. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `generate-changelog`, then `update-website-docs`.
182-
2. [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) — manual dispatch: stages the `release-X.Y.Z` tree (rc digests, tag string rewritten to stable), drafts the stable release, and opens the `release-X.Y.Z` promote PR. No registry mutation — transactional.
183-
3. [`pull-requests-release.yaml`](../.github/workflows/pull-requests-release.yaml) — fires when the `release-X.Y.Z` PR merges; finalizes the release: cuts the write-once stable + Go-module tags, publishes, then retags the rc images to stable by digest (`:latest` gated on newest-stable) and publishes the stable chart.
184-
4. [`update-releasenotes.yaml`](../.github/workflows/update-releasenotes.yaml) — fires on every push to `main`; syncs `docs/changelogs/v*.md` content into the corresponding GitHub Release body.
181+
1. [`cut-prerelease.yaml`](../.github/workflows/cut-prerelease.yaml) — manual dispatch (from `main` or `release-X.Y`): the sole entry point for creating a pre-release tag. Validates the `-alpha`/`-beta`/`-rc` tag, then pushes it (write-once, at the branch tip) as the CI app so `tags.yaml` fires. Stable tags are never created here.
182+
2. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `generate-changelog`, then `update-website-docs`.
183+
3. [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) — manual dispatch: stages the `release-X.Y.Z` tree (rc digests, tag string rewritten to stable), drafts the stable release, and opens the `release-X.Y.Z` promote PR. No registry mutation — transactional.
184+
4. [`pull-requests-release.yaml`](../.github/workflows/pull-requests-release.yaml) — fires when the `release-X.Y.Z` PR merges; finalizes the release: cuts the write-once stable + Go-module tags, publishes, then retags the rc images to stable by digest (`:latest` gated on newest-stable) and publishes the stable chart.
185+
5. [`update-releasenotes.yaml`](../.github/workflows/update-releasenotes.yaml) — fires on every push to `main`; syncs `docs/changelogs/v*.md` content into the corresponding GitHub Release body.
185186

186187
### Phase 1 — `prepare-release` (hard gate)
187188

@@ -249,7 +250,7 @@ A stable `vX.Y.Z` is created only by **promoting an existing release-candidate**
249250

250251
- [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) is triggered manually once an rc has gone green. It rewrites the rc version substring in the vendored image tags (the digests stay the rc's) and opens the `release-X.Y.Z` staging PR — no registry mutation. Merging that PR (Phase 5) creates the write-once stable tag at the merge commit, then retags the rc's already-built, e2e-passed image digests to the stable tag **by digest** (no rebuild — see [`hack/promote-retag.sh`](../hack/promote-retag.sh)) and publishes the release. Because the copy source is the immutable rc digest, the stable image is bit-for-bit the rc image that passed e2e — and because the retag runs only after merge, an abandoned promotion never leaves stable-named bytes behind.
251252

252-
So a commit on a supported `release-X.Y` line ships when a maintainer promotes the next rc for that line — not automatically within 24h. Pushing a stable `vX.Y.Z` tag by hand is **not** a supported path: `tags.yaml` fails fast on a stable tag that has no pre-existing draft ("stable tags come from promote-rc.yaml"), and even so the finalize step would refuse to move a pre-existing tag. Release candidates (`vX.Y.Z-rc.N`) are still pushed by hand — [`tags.yaml`](../.github/workflows/tags.yaml) fires on the rc tag push, builds it, and publishes the rc release.
253+
So a commit on a supported `release-X.Y` line ships when a maintainer promotes the next rc for that line — not automatically within 24h. Pushing a stable `vX.Y.Z` tag by hand is **not** a supported path: `tags.yaml` fails fast on a stable tag that has no pre-existing draft ("stable tags come from promote-rc.yaml"), and even so the finalize step would refuse to move a pre-existing tag. Pre-releases (`vX.Y.Z-rc.N`, `-alpha.N`, `-beta.N`) are cut with the [`Cut Pre-release Tag`](../.github/workflows/cut-prerelease.yaml) workflow, which pushes the tag as the CI app; [`tags.yaml`](../.github/workflows/tags.yaml) then fires on that push, builds it, and publishes the pre-release. Cutting via the workflow (rather than a manual `git push`) is what lets repo admins lock `v*` tag creation to the CI app — see the tag-protection note below.
253254

254255
## Nightly builds
255256

@@ -553,6 +554,13 @@ Published tags are **write-once** — once a `vX.Y.Z` or rc tag is pushed it is
553554

554555
This is the immutable-tag + rc-promotion model from [#2677](https://github.com/cozystack/cozystack/issues/2677): stable `vX.Y.Z` is created only by promoting an existing rc, rc tags are write-once, and `api/apps/v1alpha1/vX.Y.Z` is created only on a stable release. The old nightly `auto-release.yaml` (which delete-recreated auto-bumped patch tags) has been removed. rc and stable tags accrete permanently; the only churning artifacts are GHCR nightlies, which [`retention.yaml`](../.github/workflows/retention.yaml) prunes (see [Nightly builds](#nightly-builds)) — GHCR storage is the cost vector to watch.
555556

557+
### Repo-side enforcement (tag-protection rulesets)
558+
559+
The guarantees above are enforced in the workflows. Repo admins back them with two GitHub **tag rulesets** targeting `v*`:
560+
561+
- **Immutability** — *Restrict updates* + *Restrict deletions*, **no bypass**. No actor (not even the CI app) can move or delete a shipped `v*` tag. Safe to enable at any time: nothing in the release flow ever moves or deletes a `v*` tag, it only creates new ones.
562+
- **Creation control** — *Restrict creations* with the **CI app as the sole bypass actor**, plus *Limit branches/tags updated in a single push* = 1. This turns "don't hand-push tags" from convention into hard enforcement: every `v*` tag — pre-release **and** stable — can then be created only by CI. Enable this **only after** [`cut-prerelease.yaml`](../.github/workflows/cut-prerelease.yaml) is in use, since that workflow is what makes CI the sole creator of pre-release tags; enabling it earlier would lock maintainers out of cutting rc/alpha/beta. (The Go-module tag `api/apps/v1alpha1/*` gets an equivalent pair of rulesets.)
563+
556564
## Splitting a release-blocking bundle PR
557565

558566
Sometimes the work that has to land before a release is a 40-commit grab bag (CI stabilization, dependency bumps, chart fixes). Splitting it makes it reviewable and survivable. The strategy that has worked:

0 commit comments

Comments
 (0)