Repository navigation
RFC: immutable release tags & rc-promotion flow #2677
Description
TL;DR
Replace the current "tag first, force-retag after digest bake" release flow with one where every git tag is write-once. Stable vX.Y.Z tags are created exclusively by the release bot by promoting an existing rc. Maintainers and a nightly job push only rc tags. api/apps/v1alpha1/vX.Y.Z is created only with stable, never for rc.
Current state (problem)
Today (see docs/release.md:70, 164) the maintainer pushes the final vX.Y.Z tag up front. CI builds, creates a "Prepare release" commit on release-X.Y.Z with vendored image digests, and on PR merge force-moves vX.Y.Z from the maintainer's commit to the merge commit. Same flow force-creates api/apps/v1alpha1/vX.Y.Z.
This breaks tag immutability:
- Go module proxy / pkg.go.dev cache poisoning on
api/apps/v1alpha1/* - SBOM and provenance attestation drift
- Registry/scanner alerts on mutated tags
Force-retag sites in CI:
.github/workflows/tags.yaml:147-148.github/workflows/tags.yaml:200-201.github/workflows/pull-requests-release.yaml:63-64, 97-103.github/workflows/auto-release.yaml:170-187
Goals
- No tag is ever force-pushed or moved. Once created, the commit it points at is permanent.
- Stable
vX.Y.Zis created exclusively by the bot, exclusively by promoting an existing rc. api/apps/v1alpha1/vX.Y.Zis created only with stable. RCs do not get Go module tags.- Nightly rc tags are produced automatically from
mainand active maintenance branches. - Preparing a release (push rc) and blessing a release (promote rc → stable) are decoupled operations.
Non-goals
- Changing release tarball / OCI artifact / GHCR layout.
- Changing semver.
- Migrating already-published tags. New rules apply to tags created after rollout.
Tag taxonomy
| Tag | Created by | Trigger | Points at | Mutable? |
|---|---|---|---|---|
vX.Y.Z-nightly.YYYYMMDD |
bot | scheduled cron | main or release-X.Y HEAD at run time |
no |
vX.Y.Z-rc.N |
maintainer or bot | workflow_dispatch / manual git push |
commit on main or release-X.Y |
no |
vX.Y.Z |
bot only | promotion of an existing rc |
merge commit of the digest-bake PR for that rc | no |
api/apps/v1alpha1/vX.Y.Z |
bot only | created together with stable vX.Y.Z |
same commit as stable | no |
Enforced by GitHub tag protection: no human or workflow may push -f to refs/tags/v* or refs/tags/api/apps/v1alpha1/*.
Lifecycle
Nightly rc
gitGraph
commit id: "main HEAD"
commit id: "later commit" tag: "v0.43.0-nightly.20260520"
Cron job. For each tracked target (main and active release-X.Y): skip if HEAD unchanged since last nightly; otherwise create <version>-nightly.<date> and push. Idempotent — re-runs on the same day are a no-op.
Promote rc → stable
gitGraph
commit id: "main HEAD"
commit id: "feature" tag: "v0.42.0-rc.3"
branch promote-v0.42.0
commit id: "Prepare release v0.42.0 (bake digests)"
checkout main
merge promote-v0.42.0 id: "PR merge" tag: "v0.42.0"
- Maintainer triggers
Promote RCworkflow withrc_tag=v0.42.0-rc.3. - Bot validates: rc exists, stable does not, rc check suites green, optional approval gate.
- Bot checks out the rc commit, runs
make buildto bake digests, commits "Prepare release vX.Y.Z" onpromote-vX.Y.Z, opens PR (squash disabled). - Maintainer reviews/merges.
- On merge: bot creates
vX.Y.Zandapi/apps/v1alpha1/vX.Y.Zat the merge commit (git tag, no-f). Bot publishes the GitHub Release.
Because the stable tag attaches to a commit that did not exist before the promotion PR opened, there is no prior SHA to overwrite — force is impossible by construction.
Workflow inventory
| Workflow | Trigger | Action | Tag mutations |
|---|---|---|---|
nightly-rc.yaml |
schedule |
Creates *-nightly.<date> tags |
create only |
tag-build.yaml |
rc/nightly/alpha/beta tag push | Build images, upload assets to draft prerelease | none |
promote-rc.yaml |
workflow_dispatch |
Opens digest-bake PR promote-vX.Y.Z |
none |
finalize-stable.yaml |
merge of promote-* PR |
Creates vX.Y.Z + api/apps/v1alpha1/vX.Y.Z, publishes release |
create only |
retention.yaml |
schedule |
Prunes old *-nightly.* tags / releases / GHCR images |
delete only |
Retired/changed:
auto-release.yaml— old "auto-bump patch stable" behavior dropped; stable always requires explicit promote.tags.yaml— split intotag-build.yaml+promote-rc.yaml.pull-requests-release.yaml— replaced byfinalize-stable.yaml; force-tag step removed.
Retention
| Kind | Retention | Cleanup |
|---|---|---|
*-nightly.* git tags |
last 14 per target | retention.yaml |
*-nightly.* GitHub Releases |
aligned with tags | same |
*-nightly.* GHCR image tags |
aligned with tags | same |
-rc.N tags |
never auto-pruned | manual |
vX.Y.Z tags |
never pruned | n/a |
Repo settings
- Tag protection rule: pattern
v*— onlycozystack-ci[bot]can create; no one can delete/update. - Tag protection rule: pattern
api/apps/v1alpha1/*— same. - "Limit branches/tags updated in a single push" = 1.
Rollout plan
Land in stages, each independently revertible.
- Stage 1 — defensive cleanup (no flow change). Add compare-before-force guards to existing workflows. Independently useful.
- Stage 2 — nightly rc + retention. Add
nightly-rc.yaml(disabled flag for first week) andretention.yaml(dry-run mode first). - Stage 3 — split build from retag. Extract image build into
tag-build.yaml; leave existing stable-tag path intact for now. - Stage 4 — promotion path. Add
promote-rc.yaml+finalize-stable.yaml; add tag protection rules; updatedocs/release.md. - Stage 5 — retire old paths. Delete digest-bake-and-retag steps from
tags.yaml; clean upauto-release.yaml,pull-requests-release.yaml.
Open questions
- Squash vs merge commits. Promotion flow needs a real merge commit. Either disallow squash on
promote-*PRs, or tag the rebased commit produced by squash. Recommend: disallow squash on these PRs. - What
make buildwrites to source. If digests can be moved out of source entirely (release manifest only), the promotion step shrinks to "tag the rc commit directly" — no PR, no merge commit. Worth investigating in parallel; not required to ship this design. api/apps/v1alpha1during rc. Consumers testing against an rc would have to vendor by commit SHA. Acceptable, since publishing rc Go module tags would re-introduce the immutability problem.- In-flight
release-1.x. Don't migrate. New flow applies from the next minor onward.
Risks
| Risk | Mitigation |
|---|---|
Maintainer pushes vX.Y.Z manually |
Tag protection rule rejects |
| Promotion PR gets squash-merged | Disallow squash on promote-* PRs |
| Nightly tags balloon GHCR storage | Retention workflow enforces 14-day window |
finalize-stable.yaml re-runs after merge |
Workflow checks tag existence; logs+exits, never -f |
Related
- Move E2E tests out of PR pipeline to release/nightly #2397 — move E2E tests out of PR pipeline to release/nightly (overlaps with
nightly-rc.yamlbuild cost) - API stabilization and release v1.0.0 #1251 — API stabilization & release v1.0.0 (prior release-process epic)
- Use OCI container registry for non-release builds #1151 — OCI container registry for non-release builds
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
docs(release): expand release.md into release-engineer playbook (#2732)
fix(ci): scope nightly targets and gate api-tag/release-PR to stable
fix(ci): scope nightly targets and gate api-tag/release-PR to stable
fix(ci): scope nightly targets and gate api-tag/release-PR to stable
feat(release): immutable tags & rc→stable promotion (#2677) (#3017)
myasnikovdaniil commentedon Jul 8, 2026
Implemented — 2026-07-08
The flow shipped in #3017 (merged 2026-07-07), stacked on the #2937 build matrix. Verified against
main.nightly.yaml(write-once*-nightly.<date>, gated byNIGHTLY_ENABLED) +retention.yaml(14/line, dry-run default)promote-rc.yaml+hack/promote-retag.sh(skopeo copyby digest → stable == e2e-tested rc, bit-for-bit)auto-release.yamldeleted;pull-requests-release.yamlcuts the write-once stable tag at the merge commitEnabler: the operator's version is decoupled from the image bits (
COZYSTACK_VERSIONenv), so rc images retag to stable without a rebuild.Remaining (repo-admin — not doable from a PR):
v*andapi/apps/v1alpha1/*create-only by the CI app; "limit tags updated in a single push" = 1.NIGHTLY_ENABLED(andRETENTION_APPLYwhen ready) repo variables.Keeping this open until the admin settings are applied and 1.6.0 is cut on the new flow.
feat(release): add Cut Pre-release Tag workflow (#2677) (#3253)
feat(release): add Cut Pre-release Tag workflow (cozystack#2677)
myasnikovdaniil commentedon Aug 14, 2026
Implemented. #3017 landed immutable tags and rc→stable promotion, #3253 added the Cut Pre-release Tag workflow, #3449 moved release validation to rc time (mandatory e2e per
-rc.N, promote gated on green rc e2e), #3399 made cutting an rc freeze the line intorelease-X.Y. Closing.What was left here in July is repo-admin config (tag protection,
NIGHTLY_ENABLED) plus one release run through the flow, and v1.6.0 and v1.6.1 both went through it since. Tag protection doesn't need this RFC open to track it.