mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-07 20:43:54 +09:00
## What this PR does Adds `cut-prerelease.yaml` — a `workflow_dispatch` that becomes the **sole entry point for creating a pre-release tag** (`vX.Y.Z-alpha.N` / `-beta.N` / `-rc.N`), replacing the manual `git push origin HEAD:refs/tags/<tag>`. ## Why #2677 makes stable tags immutable, but tag *creation* was still only convention-enforced for **pre-releases**. Repo rulesets **can** already restrict *stable* `vX.Y.Z` creation to CI — a "Restrict creations" ruleset targeting `v*` with an exclude pattern for `v*-*` covers stable tags without any new workflow. What that approach **cannot** cover is pre-releases: `-rc` / `-alpha` / `-beta` tags were hand-pushed, so they can't be locked to CI while they still have a human entry point. This workflow closes that gap by making CI the sole creator of pre-release tags too. With it in place, an admin can extend "Restrict creations" (CI app as sole bypass) to **all** of `v*` — pre-release and stable alike — instead of only the stable subset. _(Correction: an earlier version of this description claimed a ruleset couldn't express "stable-only" creation. It can, via include + exclude patterns — thanks @IvanHunters. The workflow's real value is extending creation-control to pre-release tags, plus the audit trail, single entry point, guaranteed `base_ref`, and app-identity push that makes `tags.yaml` fire.)_ ## How it works - 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) and the push event's `base_ref` is populated (`tags.yaml`'s *Get base branch* step requires 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, never here. - Gates on the dispatcher having **write access** to the repo (`workflow_dispatch` already requires it; the explicit check is auditable defense-in-depth that fails closed). - Enforces dispatch from `main` (new minor's `vX.Y.0-*` only) or the matching `release-X.Y` branch; write-once (refuses an existing tag) with a stale-tip guard so a branch that advanced since dispatch fails cleanly instead of burning the tag name. - Pushes `HEAD:refs/tags/<tag>` from the branch tip — the exact form `docs/release.md` prescribes. Runs on `ubuntu-latest` (no build runner needed). Also updates `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. Completes SHA-pinning on the touched release workflows (`nightly.yaml`, `promote-rc.yaml`, `retention.yaml`) — the zizmor "Audit workflows" gate was red on `main` for those. ## Rollout ordering The two `v*` tag rulesets have a hard order (now documented in `docs/release.md`): 1. **Immutability** (*Restrict updates* + *deletions*, no bypass) — safe to enable at any time; nothing in the flow ever moves or deletes a `v*` tag. 2. **Creation control** (*Restrict creations*, CI app sole bypass) — enable **only after this PR ships**, since this workflow is what makes CI the sole creator of pre-release tags. Enabling it earlier would lock maintainers out of cutting rc/alpha/beta. ## Validation - `actionlint` — clean. - `act -l` — job graph resolves (`cut` / `workflow_dispatch`). - `zizmor --offline` — no findings across the whole workflows dir (SHA-pinned actions, least-privilege `contents: read`). - Run blocks `bash -n` syntax-checked. - **Untested locally:** the workflow runtime (app-token auth, the real tag push, `base_ref` population, the `getCollaboratorPermissionLevel` gate) needs a live run with the CI secrets — the same "orchestration is unproven until it runs" caveat #3017 carried. ## Review follow-up Addressed the non-blocking findings from @IvanHunters' review (commit `fix(ci): address review findings`): 1. **[MINOR]** `main` dispatch now requires patch `Z == 0` (a new minor's pre-release), so a patch-line rc can't be cut from `main` content. 2. **[MINOR]** Added a stale-tip guard: if the dispatch branch advanced between dispatch and push, fail with a re-run hint instead of tagging a stale commit / burning the write-once name on an empty-`base_ref` failure. 3. **[MINOR]** Corrected the PR-body rationale above (rulesets can express stable-only creation via include + exclude). 4. **[NIT]** `ls-remote` now distinguishes "tag absent" (exit 2) from a transport/auth failure. 5. **[NIT]** Shallow checkout (`fetch-depth: 1`, no `fetch-tags`); dropped the redundant local tag pre-check. 6. **[NIT]** Pure-input validation now runs before the token mint + checkout (fail-fast). Plus a **write-access gate** on the dispatcher, requested separately. Part of #2677. ## Release note ```release-note NONE ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a controlled workflow to create pre-release tags (alpha/beta/rc) with write-once behavior. * Added stronger enforcement so pre-releases are cut from the correct release line. * **Bug Fixes** * Improved guidance when stable tags are rejected or when base branch info is missing. * Added safeguards to prevent cutting tags from an out-of-date branch tip. * **Documentation** * Updated release process docs, including new tag protection rulesets for `v*` tags. * **Chores** * Pinned multiple CI workflow actions to fixed versions for reliability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
6 files changed
+247
-17
No files matched your search
@@ -0,0 +1,222 @@
|
||||
name: Cut Pre-release Tag
|
||||
|
||||
# Cut a pre-release tag (vX.Y.Z-alpha.N / -beta.N / -rc.N) as the CozyStack CI
|
||||
# app, so that no human ever pushes a v* tag by hand.
|
||||
#
|
||||
# This is the single entry point for creating a pre-release. Stable vX.Y.Z tags
|
||||
# are never cut here (or by hand) — they are created write-once by
|
||||
# pull-requests-release.yaml finalize at a promote PR's merge commit. See
|
||||
# docs/release.md and #2677.
|
||||
#
|
||||
# Dispatch from the branch the tag belongs to: `main` for a new minor's
|
||||
# pre-release (vX.Y.0-rc.N), or `release-X.Y` for a patch line (vX.Y.Z-rc.N).
|
||||
# The workflow tags that branch's HEAD.
|
||||
#
|
||||
# Why a workflow instead of a manual `git push`:
|
||||
# 1. The tag is pushed with the CI app token. A tag pushed with the default
|
||||
# GITHUB_TOKEN would NOT trigger tags.yaml (GitHub suppresses workflow
|
||||
# recursion from GITHUB_TOKEN); the app token is a distinct identity, so
|
||||
# tags.yaml fires exactly as it does for a maintainer's manual push.
|
||||
# 2. It pushes `HEAD:refs/tags/<tag>` from the checked-out branch tip, so the
|
||||
# push event's base_ref is populated — tags.yaml's "Get base branch" step
|
||||
# needs it to detect main vs release-X.Y.
|
||||
# 3. It makes CI the sole creator of pre-release tags. Repo rulesets can
|
||||
# already restrict *stable* tag creation to CI (a "Restrict creations"
|
||||
# ruleset targeting v* while excluding v*-*), but pre-releases were
|
||||
# hand-pushed, so they could not be CI-locked. With this workflow as their
|
||||
# only entry point, admins can extend "Restrict creations" (CI app the sole
|
||||
# bypass) to *all* of v* — pre-release and stable alike.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Pre-release tag to cut, e.g. v1.6.0-rc.1 (must be -alpha.N / -beta.N / -rc.N)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
# One global lane: two dispatches racing to push overlapping tags would
|
||||
# confuse the write-once guard. Pre-release cuts are rare, so serialize.
|
||||
group: cut-prerelease
|
||||
cancel-in-progress: false
|
||||
|
||||
# The tag write happens via the app token, not GITHUB_TOKEN, so the job needs
|
||||
# only read scope on the default token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
name: Cut ${{ inputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
# Pure-input validation first — no token minted, no checkout, so a bad tag
|
||||
# or wrong dispatch branch fails in seconds.
|
||||
|
||||
# Validate the dispatch input against a strict pre-release pattern. A
|
||||
# stable vX.Y.Z is rejected on purpose: stable tags are cut only by the
|
||||
# promote flow, at a PR merge commit.
|
||||
- name: Parse and validate tag
|
||||
id: parse
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG.trim();
|
||||
const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.(\d+)$/);
|
||||
if (!m) {
|
||||
core.setFailed(
|
||||
`tag '${tag}' must match 'vX.Y.Z-(alpha|beta|rc).N'. ` +
|
||||
`Stable vX.Y.Z tags are cut by the promote flow, not here.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.setOutput('tag', tag);
|
||||
core.setOutput('line', `${m[1]}.${m[2]}`); // 1.6
|
||||
core.setOutput('patch', m[3]); // 0
|
||||
|
||||
# Refuse to cut from anything but main or the *matching* release-X.Y line.
|
||||
# The pushed tag's base_ref (= the dispatch branch) must be one tags.yaml
|
||||
# accepts, and the branch must actually contain the version being cut.
|
||||
- name: Validate dispatch branch
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
LINE: ${{ steps.parse.outputs.line }}
|
||||
PATCH: ${{ steps.parse.outputs.patch }}
|
||||
run: |
|
||||
if [ "$REF_TYPE" != "branch" ]; then
|
||||
echo "::error::Dispatch this workflow from a branch (main or release-X.Y), not a tag or SHA."
|
||||
exit 1
|
||||
fi
|
||||
case "$REF_NAME" in
|
||||
main)
|
||||
# From main only a *new minor's* pre-release (vX.Y.0-*) is valid.
|
||||
# A patch-line pre-release (Z>0) built from main content would land
|
||||
# on a wrong-tree release-X.Y.Z-rc.N staging branch — it must be cut
|
||||
# from its own release-X.Y line instead.
|
||||
if [ "$PATCH" != "0" ]; then
|
||||
echo "::error::From 'main' only vX.Y.0-* pre-releases may be cut (got patch .$PATCH). Cut v$LINE.$PATCH-* from 'release-$LINE'."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
release-*)
|
||||
if [ "release-$LINE" != "$REF_NAME" ]; then
|
||||
echo "::error::Tag line $LINE does not match dispatch branch '$REF_NAME'. Cut v$LINE.* from 'main' or 'release-$LINE'."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Dispatch from 'main' or 'release-X.Y' only. Got '$REF_NAME'."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
with:
|
||||
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
|
||||
owner: cozystack
|
||||
# Scope the token to this repo only — it never touches other repos.
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
|
||||
# Cutting a release is privileged. workflow_dispatch already requires write
|
||||
# access to trigger, so this is defense-in-depth: it makes the requirement
|
||||
# explicit and auditable, and fails closed if that platform default ever
|
||||
# changes or the job is reached by another path. Reads permission via the
|
||||
# app token (fails the step if the API errors — fail closed).
|
||||
- name: Require write access
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor,
|
||||
});
|
||||
// getCollaboratorPermissionLevel's `permission` field returns only
|
||||
// the legacy roles admin/write/read/none (maintain collapses to
|
||||
// write, triage to read), so admin+write is the whole "can push" set.
|
||||
const allowed = ['admin', 'write'];
|
||||
if (!allowed.includes(data.permission)) {
|
||||
core.setFailed(
|
||||
`@${context.actor} has '${data.permission}' access — cutting a pre-release tag ` +
|
||||
`requires write access to ${context.repo.owner}/${context.repo.repo}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`@${context.actor}: '${data.permission}' access — authorized.`);
|
||||
|
||||
# Minimal checkout — just enough to configure origin with the app-token
|
||||
# credential (persist-credentials, default on) so the bare push below is
|
||||
# app-authenticated, and to resolve HEAD. One ref, shallow, no tags.
|
||||
- name: Check out the dispatched branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Cut and push the pre-release tag
|
||||
env:
|
||||
TAG: ${{ steps.parse.outputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# Write-once: the tag must not already exist on origin. Distinguish
|
||||
# "absent" (git ls-remote --exit-code exits 2) from a transport/auth
|
||||
# failure (any other non-zero) so an infra blip cannot masquerade as
|
||||
# "free to create".
|
||||
set +e
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "::error::Tag $TAG already exists on origin — pre-release tags are write-once."
|
||||
exit 1
|
||||
elif [ "$rc" -ne 2 ]; then
|
||||
echo "::error::Could not query origin for $TAG (git ls-remote exit $rc) — refusing to proceed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stale-tip guard: workflow_dispatch checks out the dispatch-time commit,
|
||||
# not the branch tip at push time (the gap includes any wait in the
|
||||
# cut-prerelease concurrency queue). If the branch advanced meanwhile,
|
||||
# pushing HEAD would tag a stale commit and yield an empty base_ref —
|
||||
# tags.yaml would then fail, having already burned this write-once name.
|
||||
# Refuse and ask for a re-run instead.
|
||||
local_head="$(git rev-parse HEAD)"
|
||||
# Resolve the remote tip without a pipe masking ls-remote's exit code
|
||||
# (mirrors the write-once query above): a transport/auth failure must
|
||||
# not read as "branch not found".
|
||||
set +e
|
||||
remote_ls="$(git ls-remote origin "refs/heads/$REF_NAME")"
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::Could not query origin for the tip of '$REF_NAME' (git ls-remote exit $rc) — refusing to proceed."
|
||||
exit 1
|
||||
fi
|
||||
remote_head="$(printf '%s' "$remote_ls" | cut -f1)"
|
||||
if [ -z "$remote_head" ]; then
|
||||
echo "::error::Could not resolve the tip of '$REF_NAME' on origin (no such branch?)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$local_head" != "$remote_head" ]; then
|
||||
echo "::error::'$REF_NAME' advanced since dispatch ($local_head -> $remote_head). Re-run the workflow to tag the current tip."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Push HEAD straight to a tag ref: no local tag object, non-forced. The
|
||||
# branch-tip anchoring makes GitHub populate the push event's base_ref
|
||||
# (tags.yaml's Get-base-branch step requires it); non-forced makes the
|
||||
# tag write-once at the git layer. tags.yaml takes it from here (build +
|
||||
# e2e + draft pre-release + staging branch).
|
||||
git push origin "HEAD:refs/tags/$TAG"
|
||||
echo "Pushed $TAG at $local_head from '$REF_NAME'."
|
||||
echo "tags.yaml will now build, e2e, and draft the pre-release."
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
echo "Resolved main build: sha=$sha version=$version packages_ref=$packages_ref"
|
||||
|
||||
- name: Checkout source commit
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout source commit
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ needs.mirror.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
packages: read
|
||||
steps:
|
||||
- name: Checkout source commit
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ needs.mirror.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
# The rc's digest-vendored tree (the staging branch the rc build pushed).
|
||||
- name: Checkout rc staging branch
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.parse.outputs.rc_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
with:
|
||||
app-id: ${{ secrets.COZYSTACK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }}
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Reject hand-pushed stable tags
|
||||
if: steps.check_release.outputs.release_exists == 'false' && steps.tag.outputs.is_stable == 'true'
|
||||
run: |
|
||||
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."
|
||||
echo "::error::Stable tag ${GITHUB_REF#refs/tags/} has no pre-existing draft release. Stable tags are finalized by the promote flow at the merge commit — 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."
|
||||
exit 1
|
||||
|
||||
# Detect base branch (main or release-X.Y) the tag was pushed from
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
script: |
|
||||
const baseRef = context.payload.base_ref;
|
||||
if (!baseRef) {
|
||||
core.setFailed(`❌ base_ref is empty. Push the tag via 'git push origin HEAD:refs/tags/<tag>'.`);
|
||||
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.`);
|
||||
return;
|
||||
}
|
||||
const branch = baseRef.replace('refs/heads/', '');
|
||||
|
||||
+18
-10
@@ -6,7 +6,7 @@ This document is both the process design (what the release model looks like) and
|
||||
|
||||
You are about to:
|
||||
|
||||
- Tag a release candidate (`vX.Y.0-rc.N`).
|
||||
- Cut a pre-release (`vX.Y.0-rc.N`, or `vX.Y.0-alpha.N` / `-beta.N`).
|
||||
- Cut a regular release (`vX.Y.0`).
|
||||
- Cut a patch release (`vX.Y.Z` with `Z > 0`).
|
||||
- Triage cherry-picks before a patch release.
|
||||
@@ -25,7 +25,7 @@ There are three types of releases:
|
||||
- **Regular Releases** – Final versions (e.g., `v1.2.0`) that are feature-complete and thoroughly tested.
|
||||
- **Patch Releases** – Bugfix-only updates (e.g., `v1.2.1`) made after a stable release, based on a dedicated release branch.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Release Candidates
|
||||
|
||||
@@ -55,7 +55,7 @@ gitGraph
|
||||
|
||||
A regular release sequence runs as follows:
|
||||
|
||||
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.
|
||||
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.
|
||||
2. Validate the rc. New features and fixes can still land on `main` and be picked up by a later rc.
|
||||
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:
|
||||
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
|
||||
|
||||
When all relevant patch commits are cherry-picked, the branch is ready for release.
|
||||
|
||||
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.
|
||||
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.
|
||||
3. Validate the rc.
|
||||
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.
|
||||
|
||||
@@ -176,12 +176,13 @@ gitGraph
|
||||
|
||||
## What CI does during the release process
|
||||
|
||||
The numbered process above is implemented by four workflows. Knowing which job does what makes the failure modes much easier to diagnose.
|
||||
The numbered process above is implemented by five workflows. Knowing which job does what makes the failure modes much easier to diagnose.
|
||||
|
||||
1. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `generate-changelog`, then `update-website-docs`.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
2. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `generate-changelog`, then `update-website-docs`.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
### Phase 1 — `prepare-release` (hard gate)
|
||||
|
||||
@@ -249,7 +250,7 @@ A stable `vX.Y.Z` is created only by **promoting an existing release-candidate**
|
||||
|
||||
- [`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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Nightly builds
|
||||
|
||||
@@ -553,6 +554,13 @@ Published tags are **write-once** — once a `vX.Y.Z` or rc tag is pushed it is
|
||||
|
||||
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.
|
||||
|
||||
### Repo-side enforcement (tag-protection rulesets)
|
||||
|
||||
The guarantees above are enforced in the workflows. Repo admins back them with two GitHub **tag rulesets** targeting `v*`:
|
||||
|
||||
- **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.
|
||||
- **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.)
|
||||
|
||||
## Splitting a release-blocking bundle PR
|
||||
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user