Skip to content

RFC: immutable release tags & rc-promotion flow #2677

Closed

Description

@myasnikovdaniil

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

  1. No tag is ever force-pushed or moved. Once created, the commit it points at is permanent.
  2. Stable vX.Y.Z is created exclusively by the bot, exclusively by promoting an existing rc.
  3. api/apps/v1alpha1/vX.Y.Z is created only with stable. RCs do not get Go module tags.
  4. Nightly rc tags are produced automatically from main and active maintenance branches.
  5. 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"
Loading

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"
Loading
  1. Maintainer triggers Promote RC workflow with rc_tag=v0.42.0-rc.3.
  2. Bot validates: rc exists, stable does not, rc check suites green, optional approval gate.
  3. Bot checks out the rc commit, runs make build to bake digests, commits "Prepare release vX.Y.Z" on promote-vX.Y.Z, opens PR (squash disabled).
  4. Maintainer reviews/merges.
  5. On merge: bot creates vX.Y.Z and api/apps/v1alpha1/vX.Y.Z at 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 into tag-build.yaml + promote-rc.yaml.
  • pull-requests-release.yaml — replaced by finalize-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

  1. Tag protection rule: pattern v* — only cozystack-ci[bot] can create; no one can delete/update.
  2. Tag protection rule: pattern api/apps/v1alpha1/* — same.
  3. "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) and retention.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; update docs/release.md.
  • Stage 5 — retire old paths. Delete digest-bake-and-retag steps from tags.yaml; clean up auto-release.yaml, pull-requests-release.yaml.

Open questions

  1. 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.
  2. What make build writes 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.
  3. api/apps/v1alpha1 during 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.
  4. 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

Activity

  1. added
    releaseReleasing a new Cozystack version
    epicA large development increment that brings definite value to Cozystack users
    on May 19, 2026
  2. added
    area/releaseIssues or PRs related to release tooling (changelog, backport, release pipeline)
    triage/acceptedIndicates an issue is ready to be actively worked on
    priority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to complete
    on Jul 1, 2026
  3. myasnikovdaniil commented on Jul 8, 2026

    @myasnikovdaniil
    ContributorAuthor

    Implemented — 2026-07-08

    The flow shipped in #3017 (merged 2026-07-07), stacked on the #2937 build matrix. Verified against main.

    Stage Status
    1 — defensive compare-before-force guards ✅ all 5 force-retag sites removed
    2 — nightly rc + retention ✅ nightly.yaml (write-once *-nightly.<date>, gated by NIGHTLY_ENABLED) + retention.yaml (14/line, dry-run default)
    3 — split build from retag ✅ folded into the matrix build
    4 — promotion path ✅ promote-rc.yaml + hack/promote-retag.sh (skopeo copy by digest → stable == e2e-tested rc, bit-for-bit)
    5 — retire old paths ✅ auto-release.yaml deleted; pull-requests-release.yaml cuts the write-once stable tag at the merge commit

    Enabler: the operator's version is decoupled from the image bits (COZYSTACK_VERSION env), so rc images retag to stable without a rebuild.

    Remaining (repo-admin — not doable from a PR):

    • Tag-protection rules: v* and api/apps/v1alpha1/* create-only by the CI app; "limit tags updated in a single push" = 1.
    • Enable NIGHTLY_ENABLED (and RETENTION_APPLY when ready) repo variables.
    • Prove the orchestration live — logic is unit-tested, workflow runtime is not. Plan: shake it out by cutting v1.6.0 on the new promote flow.

    Keeping this open until the admin settings are applied and 1.6.0 is cut on the new flow.

  4. myasnikovdaniil commented on Aug 14, 2026

    @myasnikovdaniil
    ContributorAuthor

    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 into release-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area/releaseIssues or PRs related to release tooling (changelog, backport, release pipeline)epicA large development increment that brings definite value to Cozystack userspriority/important-longtermImportant over the long term, but may not be staffed and/or may need multiple releases to completereleaseReleasing a new Cozystack versiontriage/acceptedIndicates an issue is ready to be actively worked on

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions