Skip to content

feat(release): cutting an rc freezes the line into release-X.Y - #3399

Merged
myasnikovdaniil merged 13 commits into
mainfrom
feat/rc-freeze-release-branch
Aug 4, 2026
Merged

myasnikovdaniil merged 13 commits into
mainfrom
feat/rc-freeze-release-branch

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Cutting the first vX.Y.0-rc.N now creates release-X.Y at the tagged commit and closes the line. Every later cut for that line must be dispatched from the branch; a dispatch from main is refused.

Before this, release-X.Y was created only when the stable promote PR merged. So rc.2 and rc.3 were cut from main's tip and silently absorbed every feature merged since rc.1 — the shipped release could contain code that no rc had ever validated. main now reopens for the next minor immediately, and fixes reach the release by cherry-pick.

Commits

Commit What
feat(release) cut-prerelease.yaml: create release-X.Y after the tag push; refuse a frozen line from main
ci(backport) backport.yaml: target the newest existing release-X.Y branch
docs(release) Rewrite the RC/Regular/Patch sections, diagrams, and workflow reference

Behaviour changes worth a close look

The promote PR base flips from main to release-X.Y on its own. No code change causes this — promote-rc.yaml already prefers release-X.Y whenever it exists, and after the freeze it always does. Consequence: the digest-vendored Prepare release vX.Y.0 commit stays on the release line and no longer lands on main. This matches how patch releases already work, but it is a real change for .0 releases and is easy to miss in review.

finalize's Ensure maintenance branch becomes a no-op. The merge commit is already the branch tip, so the fast-forward has nothing to do. No change was needed there.

Backport targeting changes during a freeze window. backport.yaml derived its target from getLatestRelease, which returns the newest published stable. Since release-X.Y now exists before vX.Y.0 ships, that name lagged by a full line for the whole freeze window — a backport on a fix for the release being stabilised would have landed on the previous line and missed the release it was written for. It now enumerates real release-X.Y branches and takes the newest. Outside a freeze window the result is unchanged.

This also drops a latent bug: previous was computed as min - 1, which names a branch that does not exist whenever a minor is skipped. Sorting is now numeric, so release-1.10 correctly outranks release-1.9.

A note on image pins

An earlier revision of this PR also replaced the committed ghcr.io/cozystack/cozystack/* digests on main with an unpullable sentinel tag, on the theory that they are a build leftover that nothing restores — and that the freeze makes this worse, since the promote PR no longer merges into main to refresh them.

That was wrong and has been dropped. Per #3143, PR CI rebuilds only the packages a diff touches (hack/build-matrix.sh "emits only the units whose package dir changed") and the installer bundles the whole digest-patched tree, so every untouched package deploys from its committed pin. A sentinel would have made those unpullable across E2E.

The staleness problem is real, though, and #3143 already tracks it as a systemic gap. Evidence gathered while investigating is posted there rather than acted on here.

Testing

  • Full hack/helm-unit-tests.sh green
  • Branch-selection logic tested against the live 424-branch list, including double-digit minors (1.10 > 1.9) and skipped minors; outside a freeze window the result is unchanged (release-1.5 / release-1.4)
  • zizmor clean on both workflows
  • No values.yaml or images/*.tag file is touched by this PR

Release note

Cutting a release candidate now creates the `release-X.Y` branch and freezes the release line. Features merged to `main` after the first rc no longer ship in that release; fixes must be cherry-picked or backported onto `release-X.Y`.

Summary by CodeRabbit

  • New Features
    • Release branches are now created and frozen when the first release candidate is published.
    • Later pre-releases and patches use the frozen release branch while main advances independently.
    • Release-line package artifacts can be generated manually, including for recovery.
    • Backport targets are selected from existing release branches, with clearer failures when a prior line is unavailable.
  • Documentation
    • Updated release, patch-release, workflow, promotion, finalization, and cleanup guidance to reflect the new freeze model.

@github-actions github-actions Bot added area/release Issues or PRs related to release tooling (changelog, backport, release pipeline) kind/feature Categorizes issue or PR as related to a new feature size/XL This PR changes 500-999 lines, ignoring generated files labels Jul 21, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a more robust release process by freezing the release line upon the creation of the first release candidate. By establishing a dedicated 'release-X.Y' branch at the start of the RC phase, the system isolates the release from ongoing feature development on 'main'. Furthermore, it addresses the risk of stale image pins by implementing a sentinel tag system ('v0.0.0-dev') on 'main', which forces developers to perform a local build to generate valid image references, thereby ensuring deployment integrity.

Highlights

  • Release Line Freeze: Cutting the first release candidate now automatically creates a 'release-X.Y' branch, effectively freezing the release line and preventing new features from 'main' from being silently included.
  • Sentinel Image Pins: Introduced 'v0.0.0-dev' sentinel tags for image pins on 'main' to ensure build-time safety and prevent the execution of stale or incorrect image versions.
  • Workflow Enforcement: Added 'hack/check-image-pins.sh' and integrated it into 'pre-commit' to validate that image pins remain at the sentinel tag until a proper build is performed.
  • Documentation Updates: Rewrote release documentation to clearly outline the new RC freeze, backporting procedures, and the updated release promotion workflow.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: .github/workflows/** (2)
    • .github/workflows/backport.yaml
    • .github/workflows/cut-prerelease.yaml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added area/ci Issues or PRs related to CI workflows, GitHub Actions, automation release Releasing a new Cozystack version labels Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The release workflows now freeze release-X.Y at the first RC, dispatch the initial release build, and select backport targets from existing release branches. Release documentation and contract tests reflect the updated release process.

Changes

Release and image policy

Layer / File(s) Summary
Release freeze and branch targeting
.github/workflows/cut-prerelease.yaml
The workflow records the pre-release kind, checks for frozen lines, and creates release-X.Y for the first RC.
Initial release-line artifact dispatch
.github/workflows/cut-prerelease.yaml, .github/workflows/build-release.yaml, hack/release-freeze-contract.bats
Newly created release branches dispatch build-release.yaml. The build workflow supports manual recovery runs. Contract tests validate the dispatch conditions.
Backport release-line selection
.github/workflows/backport.yaml, hack/release-freeze-contract.bats
Backport targets come from numerically sorted existing release-X.Y branches. The workflow rejects missing target lines and excludes non-release branches.
Release process documentation
docs/release.md
The documentation describes RC freezing, maintenance-branch promotion, patch releases, changelog backstopping, backport targeting, workflow responsibilities, and cleanup.
Release freeze contract validation
hack/release-freeze-contract.bats
The Bats suite validates freeze conditions, refusal ordering, non-forced pushes, existing-branch handling, build dispatching, and backport selection.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Main
  participant CutPrerelease
  participant GitHub
  participant BuildRelease
  Main->>CutPrerelease: Dispatch first vX.Y.0-rc.N
  CutPrerelease->>GitHub: Check release-X.Y
  GitHub-->>CutPrerelease: Branch absent
  CutPrerelease->>GitHub: Push tag and release branch
  CutPrerelease->>BuildRelease: Dispatch release-line build
  BuildRelease-->>GitHub: Publish packages artifact
Loading

Suggested reviewers: lexfrei

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: creating a frozen release-X.Y branch when cutting the first release candidate.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rc-freeze-release-branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to enforce that in-tree image pins on the main branch are set to a sentinel tag (v0.0.0-dev) to prevent stale pins from quietly running old code. It adds a pre-commit hook and a validation script hack/check-image-pins.sh to check and reset these pins, updates the release documentation to reflect this workflow, and resets various package image tags to the sentinel. The review feedback suggests improving the robustness of hack/check-image-pins.sh by explicitly verifying that rg (ripgrep) is installed to avoid silent failures, and dynamically extracting the parent path of split-shape pins using ${dig_path%.*} instead of assuming a .digest suffix.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread hack/check-image-pins.sh Outdated
# Usage:
# hack/check-image-pins.sh # check (default); non-zero on violation
# hack/check-image-pins.sh --reset # rewrite every non-orphan pin to the sentinel
set -eu

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The script relies on rg (ripgrep) to scan the codebase for image pins. However, if rg is not installed, the command substitution on line 106 will silently fail (due to 2>/dev/null || true) and return an empty scan result, causing the check to pass falsely.

We should explicitly verify that rg is installed at the beginning of the script to prevent silent failures.

Suggested change
set -eu
set -eu
if ! command -v rg >/dev/null 2>&1; then
echo "ERROR: ripgrep (rg) is required but not installed." >&2
exit 1
fi

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was filed against f85ca33, before the branch was rewritten. hack/check-image-pins.sh and the sentinel image-pin work it belonged to are no longer part of this PR — the diff is now limited to cut-prerelease.yaml, backport.yaml, and docs/release.md. Closing this out as no longer applicable; if the pin check returns in its own PR the point stands and will be addressed there.

Comment thread hack/check-image-pins.sh Outdated
# digest alone yields a malformed "…:v0.0.0-dev@".
if [ "$dig_path" != "-" ]; then
yq -i "${dig_path} = \"\"" "$file"
yq -i "${dig_path%.digest}.useDigest = false" "$file"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using ${dig_path%.digest} assumes that the digest path always ends with the exact suffix .digest. To make this helper more robust and future-proof for other split-shape pins, we can use ${dig_path%.*} to strip the last dot-separated component (the field name) and get the parent path dynamically.

Suggested change
yq -i "${dig_path%.digest}.useDigest = false" "$file"
yq -i "${dig_path%.*}.useDigest = false" "$file"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was filed against f85ca33, before the branch was rewritten. hack/check-image-pins.sh and the sentinel image-pin work it belonged to are no longer part of this PR — the diff is now limited to cut-prerelease.yaml, backport.yaml, and docs/release.md. Closing this out as no longer applicable; if the pin check returns in its own PR the point stands and will be addressed there.

Comment thread hack/check-image-pins.sh Outdated
Comment on lines +181 to +184
ud="$(yq -r "${dig_path%.digest}.useDigest // false" "$file")"
[ "$ud" = "false" ] || {
violations="${violations} ${file} (${dig_path%.digest}.useDigest): expected false, found ${ud}
"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, use ${dig_path%.*} here to dynamically target the parent path of the digest field instead of assuming a .digest suffix.

Suggested change
ud="$(yq -r "${dig_path%.digest}.useDigest // false" "$file")"
[ "$ud" = "false" ] || {
violations="${violations} ${file} (${dig_path%.digest}.useDigest): expected false, found ${ud}
"
ud="$(yq -r "${dig_path%.*}.useDigest // false" "$file")"
[ "$ud" = "false" ] || {
violations="${violations} ${file} (${dig_path%.*}.useDigest): expected false, found ${ud}
"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was filed against f85ca33, before the branch was rewritten. hack/check-image-pins.sh and the sentinel image-pin work it belonged to are no longer part of this PR — the diff is now limited to cut-prerelease.yaml, backport.yaml, and docs/release.md. Closing this out as no longer applicable; if the pin check returns in its own PR the point stands and will be addressed there.

@myasnikovdaniil
myasnikovdaniil force-pushed the feat/rc-freeze-release-branch branch from f85ca33 to e95a460 Compare July 21, 2026 12:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/release.md`:
- Line 39: Repair the Backporting link in the release documentation by changing
its fragment to match the actual rendered slug of the Backporting heading, or
rename that heading to produce `#backporting`. Ensure the link resolves correctly
and clears markdownlint MD051.
- Around line 146-150: The patch-release Mermaid diagram in docs/release.md
still depicts release-1.2 being created after v1.2.0. Update the diagram to show
the release-1.2 branch already existing from the RC freeze, consistent with the
surrounding cherry-pick procedure and release-X.Y freeze model.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d96060f9-7d2b-4c33-89bd-4be7d91fe449

📥 Commits

Reviewing files that changed from the base of the PR and between f85ca33 and e95a460.

📒 Files selected for processing (1)
  • docs/release.md

Comment thread docs/release.md Outdated
Comment thread docs/release.md
@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the rc-freeze mechanism, the backport-target rewrite, and the docs are correct and internally consistent; the one robustness gap in the freeze failure path is a strict improvement over today's behavior, not a regression, so it is a recommendation rather than a blocker.

Business context: cutting the first vX.Y.0-rc.N now creates release-X.Y at the tagged commit and freezes the line, so features merged to main after the rc no longer leak into the release, and later cuts plus fixes come off the branch.

I traced every doc claim against the actual workflow code and every failure path through the new shell, and the core design holds up: the freeze is create-only and idempotent (a later rc.N no-ops on the existing branch, the push is non-forced), it is correctly gated to kind == 'rc' && patch == '0', the refuse-from-main step blocks a frozen line while still allowing a genuinely new minor, and creating release-X.Y triggers no extra workflow (nothing keys on push to release-* or on create). The GitHub Actions default shell is bash -eo pipefail, so the freeze step's ls-remote | cut fails closed on a transport error rather than silently reading "absent".

Non-blocking follow-ups

  1. The tag push and the freeze push are not atomic, and the failure path has no clean re-run (worth hardening). The rc tag is pushed in one step, then release-X.Y is created in a later step — two independent git push calls. If the tag push succeeds and the freeze push then fails (transient blip, runner eviction), the tag exists but the line is not frozen, and a same-tag re-dispatch is refused by the write-once tag guard, so recovery is manual. I am flagging this as non-blocking rather than blocking because it never pushes a wrong ref or corrupts anything, it fails loudly, it degrades to exactly the behavior main has today (the maintenance branch is still created later by the finalize step's create-if-missing), and it is recoverable with a one-line push — so the change is a strict improvement that is never worse than the status quo. Cheapest hardening: for the first-rc case push both refs together (git push --atomic origin "HEAD:refs/tags/$TAG" "HEAD:refs/heads/release-$LINE") — after validating it does not disturb the populated base_ref the tag push relies on — or, at minimum, print the manual recovery command in the step's failure message so an operator is not left guessing.

  2. ls-remote exit-code handling is inconsistent in the freeze step. The refuse step, the write-once guard, and the stale-tip guard all carefully distinguish "absent" (exit 2) from a transport/auth failure; the freeze step's git ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1 does not. It is safe in practice (pipefail fails the step on a real error, and the follow-up non-forced push fails closed on any conflict), but aligning it with the pattern used three times above it removes a stumbling block for the next reader.

  3. Broken intra-doc anchor. docs/release.md line 39 links to #backporting, but the heading is ## Backports, whose slug is #backports; the link resolves nowhere. One-character fix.

  4. Backport label/reference descriptions lag the new behavior. The rewrite drops the contiguous-minor assumption (previous is now the second-newest existing line, not Y-1), but docs/release.md's backport-bot table still shows backport-previous -> release-X.(Y-1), and .github/labels.yml still describes the labels in the old terms. Tightening both to "newest / second-newest existing release line" keeps the reference matching the code.

  5. Patch-release diagram contradicts its own prose. The prose says release-X.Y already exists from the freeze ("there is no branch to create"), but the adjacent mermaid graphs still branch release-1.2 at the v1.2.0 commit. It reads as a simplification, but it visually contradicts the sentence right above it.

  6. The release label makes the release-asset check fail on this branch. A triage bot applied the bare release label, which activates the release-asset resolution job; that job expects a release-X.Y.Z-named branch and fails on this feature branch. This is a labeling artifact, not a defect in the diff — removing the release label clears the red check.

What I verified and found correct

  • The promote PR base flips to release-X.Y on its own: promote-rc.yaml already selects release-${LINE} when it exists, else main — the doc claim is accurate and needs no code change.
  • Finalize's "Ensure maintenance branch" becomes a no-op: it updates fast-forward-only (force:false, warns on non-fast-forward) and creates-if-missing, and with the promote PR merging into release-X.Y the merge commit is already the branch tip.
  • Backport rewrite: numeric descending sort fixes both the release-1.10 > 1.9 ordering and the min-1 non-existent-branch bug; the ^release-(\d+)\.(\d+)$ anchor correctly excludes release-X.Y.Z staging branches; pagination handles the large branch list.
  • The app token already creates release-* branches in the finalize step, so the freeze push is authorized by the same identity.

Comment thread .github/workflows/cut-prerelease.yaml Outdated
fi
# HEAD is the commit just tagged: the stale-tip guard above proved it
# still equals the remote branch tip, so branch and tag agree.
git push origin "HEAD:refs/heads/$BRANCH"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch push and the earlier tag push are two independent operations. If the tag push succeeds and this one fails (transient blip / runner eviction), the tag exists but the line is not frozen, and a same-tag re-dispatch is refused by the write-once tag guard above — so recovery is manual. Non-blocking: it fails loudly, pushes no wrong ref, and degrades to today's behavior (finalize still creates the branch later). Cheapest hardening for the first-rc case is an atomic push of both refs (git push --atomic origin "HEAD:refs/tags/$TAG" "HEAD:refs/heads/release-$LINE", after checking it does not disturb the base_ref the tag push relies on), or at minimum print the manual recovery command in this step's failure message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the second option in a3d1fb01b: the step now fails with the exact recovery command, naming both that the tag is already pushed and that a re-dispatch will be refused. I deliberately did not go atomic — the tag push depends on being anchored to the branch tip for GitHub to populate the push event's base_ref that tags.yaml's Get-base-branch step requires, and whether a multi-ref --atomic push preserves that is only observable on a live release cut. Not worth risking the release pipeline to close a window that already fails loudly and degrades to today's behaviour. Left a comment recording that reasoning.

Comment thread .github/workflows/cut-prerelease.yaml Outdated
TAG: ${{ steps.parse.outputs.tag }}
run: |
BRANCH="release-$LINE"
EXISTING="$(git ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking consistency: the refuse step, the write-once guard, and the stale-tip guard all distinguish "absent" (exit 2) from a transport/auth failure; this bare ls-remote | cut does not. It is safe here (pipefail fails the step on a real error, and the non-forced push below fails closed on a conflict), but matching the pattern used three times above it avoids a stumble for the next reader.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the inconsistency — fixed in 166992aa3. The probe now captures ls-remote's exit code and refuses on a non-zero, matching the write-once guard, the stale-tip guard, and the refuse step. Behaviour on the happy path is unchanged, as you noted; this just makes the fail-closed intent explicit instead of incidental.

Comment thread docs/release.md Outdated
**Cutting the first rc freezes the line.** [`cut-prerelease.yaml`](../.github/workflows/cut-prerelease.yaml) creates `release-X.Y` at the tagged commit, and from that point the release's content is closed:

- Every later cut for that line — `rc.2`, `rc.3`, an `alpha`/`beta`, or a patch-line rc — must be dispatched from `release-X.Y`. A dispatch from `main` is refused.
- Fixes reach the release only by cherry-pick or backport onto `release-X.Y` (see [Backporting](#backporting)).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this anchor points at #backporting, but the heading is ## Backports (slug #backports), so the link resolves nowhere. One-character fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e5b40212b — #backporting → #backports. Also checked the other four intra-doc anchors in the file; they all resolve.

@myasnikovdaniil myasnikovdaniil removed the release Releasing a new Cozystack version label Jul 23, 2026
@myasnikovdaniil
myasnikovdaniil force-pushed the feat/rc-freeze-release-branch branch from a3d1fb0 to b16fb51 Compare August 3, 2026 11:00

@lexfrei Aleksei Sviridkin (lexfrei) left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM. One thing left, same class as last round: the tree now disagrees with itself about which releases route their changelog through the backstop.

Business context: cutting the first vX.Y.0-rc.N now creates release-X.Y at the tagged commit and freezes the line, so later rcs and the promoted stable ship only rc-validated content instead of silently absorbing whatever merged to main since rc.1.

Stale comments

This PR rewrites docs/release.md to say the promote PR targets release-X.Y for every release now, and that the tags.yaml backstop is the only route a published changelog takes to main. promote-rc.yaml:846 prefers the line whenever it exists, and after the freeze it always does. Three comments still describe the old model where that was a patch-release corner case:

  • .github/workflows/tags.yaml:343-357, the generate-changelog header: "self-skips on the normal path ... finds the changelog already on origin/main", with "a patch release whose promote PR targeted release-X.Y" as case 2. After the freeze the port-to-main case is the normal path and the self-skip is the exception, so the framing is inverted.
  • .github/workflows/pull-requests-release.yaml:335-343: "a patch release whose promote PR targets release-X.Y would never be synced at all". The mechanism is right, the qualifier is wrong: that is every release now. You fixed this exact sentence in the doc's Phase 6 section.
  • hack/release-changelog-contract.bats:240, the comment above the ports-not-regenerates test: same "(a patch release)" qualifier.

These comments are the first thing whoever debugs a missing changelog reads, and they now point at the wrong model of which path is normal. Reword them to the freeze model; prose only, no test needed.

Non-blocking

build-release.yaml's new workflow_dispatch takes any ref, and the "Run workflow" UI preselects main, which is exactly where the recovery messages send the operator. A mis-click burns a 2-hour make build publishing cozystack-packages:main in a race with build-main.yaml, the same tag-collision class as #2711. Content is never mislabeled since IMAGE_TAG is github.ref_name, so a mis-dispatch wastes a runner and nothing else. A guard step refusing refs that do not match ^release-[0-9]+\.[0-9]+$ closes it in five lines.

release-* still has no protection ruleset (from last round; repo setting, not code). The freeze makes an unprotected branch the release's only content path for the whole stabilisation window, so this got more urgent.

# no-ops. Moving it would silently re-open the freeze and drag in
# whatever the new tip contains, defeating the whole mechanism.
- name: Freeze the line (create release-X.Y)
if: steps.parse.outputs.kind == 'rc' && steps.parse.outputs.patch == '0'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing pins this condition. Drop patch == '0', widen kind to alpha/beta, or turn the push at 314 into a force-push in some later refactor, and the freeze re-opens silently. hack/promote-gate-contract.bats already does this for the workflows this one hands off to, and the Makefile picks up hack/*.bats automatically.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinned in hack/release-freeze-contract.bats (e53284400). The condition is asserted as a single expression on a non-comment line, so dropping patch == '0', widening kind, or demoting the gate to a comment all fail the test. Two neighbouring tests cover the rest of what you flagged here: the push at the end of the step is asserted non-forced in every spelling, including a +refs/ refspec, and the refuse step is pinned to run before the tag push. Each is mutation-tested — widening the gate, commenting it out and force-pushing the branch all turn the suite red.

Cutting the first vX.Y.0-rc.N now creates release-X.Y at the tagged
commit, and every later cut for that line must be dispatched from the
branch — a dispatch from main is refused.

Before this, only the stable promote-PR merge created release-X.Y, so
rc.2 and rc.3 were cut from main's tip and silently picked up every
feature merged since rc.1. The release therefore shipped content no rc
had ever validated. Freezing at the rc closes that: main stays open for
the next minor, and fixes reach the release by cherry-pick onto the
branch.

promote-rc.yaml needs no change — it already prefers release-X.Y as the
promote PR base when that branch exists, so the stable release is now
cut from the frozen tree.

Gated to -rc (alpha/beta are pre-freeze builds off an open main) and to
patch 0 (a patch-line rc is cut from a release-X.Y that already exists).
The branch is create-only and never force-moved: moving it would re-open
the freeze and drag in whatever the new tip holds.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Determine the backport targets by enumerating real release-X.Y branches
instead of deriving them from getLatestRelease.

getLatestRelease returns the newest published stable. Now that cutting
an rc creates release-X.Y before vX.Y.0 exists, that name lagged by a
full line for the whole freeze window: a `backport` on a fix for the
release being stabilised would have landed on the previous line and
missed the release it was written for. The freeze window is when
backports matter most, since the frozen branch is the only way in.

Also drops the contiguous-minor assumption — `previous` was computed as
min-1, naming a branch that does not exist whenever a minor is skipped.
Sorting is numeric, so release-1.10 correctly outranks release-1.9.

Outside a freeze window the result is unchanged: with release-1.0..1.5
present it still resolves to release-1.5 / release-1.4.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
The Release Candidates section said the opposite of current behaviour —
"new features and changes can still be added before the regular release"
— so it is rewritten around the freeze, along with the Regular Release
step list, both gitGraph diagrams, and the workflow reference sections.

The promote PR now targets release-X.Y rather than main, so the diagrams
show main forking away at the freeze and never receiving the release
commit. Patch Releases collapses to "same as above, the branch already
exists", since after the freeze both flows are identical.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Address review feedback from lexfrei and coderabbitai on docs/release.md:39:
the anchor pointed at #backporting, but the heading is '## Backports'
(slug #backports), so the link resolved nowhere and tripped markdownlint
MD051.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Address review feedback from coderabbitai on docs/release.md:150: the
patch-release gitGraphs still branched release-1.2 off main after the
v1.2.0 commit, depicting the branch as created at the stable release.
Under the rc freeze the line forks at vX.Y.0-rc.1 and the release commit
never lands on main, as the surrounding prose already states.

Fork release-1.2 at the rc.1 commit and place 'Release v1.2.0' on the
release line in all three patch-section diagrams.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Address review feedback from lexfrei on .github/workflows/cut-prerelease.yaml:286:
the release-X.Y existence probe piped ls-remote straight into cut, so a
transport or auth failure produced empty output and read as "branch not
found", steering into the create path. The write-once tag guard, the
stale-tip guard, and the refuse step above all make this distinction;
match them here.

Behaviour is unchanged on the happy path — pipefail already failed the
step on a real error, and the non-forced push below fails closed on a
conflict. This makes the intent explicit rather than incidental.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Address review feedback from lexfrei on .github/workflows/cut-prerelease.yaml:293:
the tag push and this branch push are independent, so a transient failure
here leaves the line tagged but not frozen, and the write-once tag guard
refuses a same-tag re-dispatch — recovery is manual.

Fail with the exact push command rather than leaving the operator to
reconstruct it. Deliberately not an atomic two-ref push: the tag push
relies on being anchored to the branch tip for GitHub to populate the
push event's base_ref, which tags.yaml requires, and whether a multi-ref
atomic push preserves that is only observable on a live release cut.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
The freeze changed three things the prose still described the old way.

The backport table claimed `backport-previous` resolves to
`release-X.(Y-1)` and that resolution runs through `getLatestRelease`.
Neither is true any more: the job enumerates the real `release-X.Y`
branches, sorts them numerically descending, and takes the newest and
second-newest existing lines. The `Y-1` derivation is the min-1 bug this
branch dropped. Line 155 already carried the correct description, so the
file contradicted itself.

The freeze window's effect on backport targets lived only in a code
comment. During it `backport` aims at the line being stabilised and
`backport-previous` at the last published stable, so the line one step
further back has no label for the duration. Document the trade-off where
the labels are described.

The changelog backstop and Phase 6 both framed a promote PR targeting
`release-X.Y` as "a patch release". After the freeze every promote PR
targets the release line, minor and patch alike, which makes the
backstop the only path a `.0` changelog takes to `main`.

Also note that re-cutting a line after an unusable rc.1 means deleting
`release-X.Y` by hand — the refuse step blocks every other route, by
design.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
build-release.yaml publishes `cozystack-packages:<line>` on push to a
release line, and pull-requests.yaml's overlay reads it so a release-line
PR tests its own line's binaries instead of main's (#3471, #3437).

It cannot fire for the push that creates the line. The freeze points
release-X.Y at a commit that is already on main, so the push carries no
new commits, and GitHub does not run a workflow whose paths/paths-ignore
filter finds no changed files ("If there are no files changed, the
workflow will not run"). The line would therefore have no artifact until
its first cherry-pick merged, and in that window the overlay finds
nothing to pull and leaves every package on its committed ref — at
freeze time the previous release's, which is exactly the
cross-generation mix #3437 fixed. The old flow had no such window: it
branched at the promote merge commit, which carried real commits and its
own release's refs.

Add workflow_dispatch to build-release.yaml and have the freeze step
dispatch it for the branch it just created, so the artifact exists from
the moment the line does. The dispatch is non-fatal: without it the
overlay no-ops and early cherry-pick PRs test their committed refs,
which is where they were before #3471. The tag is pushed and the line is
frozen by that point, so failing there would misreport both.

The trigger also gives a line build a re-run button, which previously
needed an empty commit pushed to the line.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Neither cut-prerelease.yaml nor backport.yaml had any test coverage, and
neither can be exercised by a PR lane: the first runs only on
workflow_dispatch and its whole purpose is a one-way write-once side
effect, the second only on a merged main-targeted PR. Every invariant
here is one that would otherwise first report a slip at a real release.

Pinned, following promote-gate-contract.bats so a guard demoted to a
comment cannot satisfy a pin:

  - the freeze is gated to kind == 'rc' && patch == '0'. Widening either
    half re-opens a frozen line with no error surfacing.
  - neither the branch create nor the tag push is forced, in any spelling
    including a `+refs/` refspec. A force-push on the branch would drag
    in everything merged since the freeze.
  - an existing release-X.Y is left untouched rather than reused.
  - the refuse gate is gated on a main dispatch and runs BEFORE the tag
    push, so a mistaken dispatch cannot burn a write-once tag name; the
    freeze runs after it, at the commit that was actually tagged.
  - backport targets come from enumerated branches, with no
    getLatestRelease and no min-1 arithmetic, and `previous` is the
    second-newest existing line.
  - the comparator sorts numerically descending, which is the only reason
    release-1.10 outranks release-1.9. A slip to the default
    lexicographic sort aims every backport at a stale line silently.
  - freezing a line dispatches its first build-release run.

backport.yaml's logic is a github-script block whose comments name
getLatestRelease and min-1, so the assertions over it strip `//`
comments; the YAML `#` filter alone would let a comment satisfy a pin.

The Makefile discovers hack/*.bats, so this runs under `make unit-tests`
with no wiring.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil
myasnikovdaniil force-pushed the feat/rc-freeze-release-branch branch from b16fb51 to e532844 Compare August 4, 2026 06:23
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Aug 4, 2026
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) Both blocking items are addressed, and the build-release.yaml question turned out to be answerable — it is a real gap, so it is fixed here too. The branch is rebased onto main, which it needed for that last one.

Docs — 24e9c5396. The getLatestRelease line is gone and the table now describes the newest and second-newest existing release-X.Y branches; the prose says the job enumerates branches and sorts them numerically descending, notes that release-1.10 outranks release-1.9, and records that the Y-1 derivation was the min - 1 bug. The freeze-window trade-off is documented where the labels are described rather than only in a code comment, including the consequence you named — the line one step further back has no label pointing at it for the length of the window. Lines 265 and 301 no longer frame a release-X.Y-targeted promote PR as a patch release: after the freeze that is every release, minor and patch alike, which is what makes the changelog backstop the only route a .0 changelog takes to main. The RC section now says re-cutting a line means deleting release-X.Y by hand.

Tests — e53284400, hack/release-freeze-contract.bats, following promote-gate-contract.bats so a guard demoted to a comment cannot satisfy a pin. It covers what you listed: the refuse step gated on github.ref_name == 'main' and ordered before the tag push, the freeze if: pinned as one expression, neither push forced in any spelling including a +refs/ refspec, previous as the second-newest existing line with no min - 1 and no getLatestRelease, and the comparator as numeric descending. backport.yaml's logic is a github-script block whose own comments name getLatestRelease and min-1, so those assertions strip // comments too — the # filter alone would have let a comment satisfy the pin. Every pin is mutation-tested: widening the freeze gate, commenting it out, force-pushing either ref, unscoping the refuse gate, relocating it after the tag push, breaking the comparator three ways, restoring min - 1 and restoring getLatestRelease each turn the suite red. make bats-unit-tests is green at 414 tests.

build-release.yaml — your reading is correct, and it is documented rather than merely likely. The workflow-syntax reference says of paths/paths-ignore: "If there are no files changed, the workflow will not run." A freeze push points release-X.Y at a commit already on main, so it carries no commits and no changed files — the build cannot fire for the push that creates the line, and the artifact would not exist until the first cherry-pick merged. 5ed6b2204 adds workflow_dispatch to build-release.yaml and has the freeze step dispatch it for the branch it just created. Non-fatal: the tag is pushed and the line is frozen by that point, so failing there would misreport both, and the fallback is the pre-#3471 behaviour you described. The trigger also gives a line build a re-run button, which previously needed an empty commit.

release-* protection — confirmed: /branches/release-1.6/protection 404s and /rules/branches/release-1.6 is []. That is a repo setting rather than anything in this diff, so it is not in these commits, but I agree it is the other half of the change and wants to be in place before the first freeze, since the vX.Y.0 promote PR now merges into an unguarded branch.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build-release.yaml:
- Around line 38-50: Add an initial guard to the manually dispatched release
workflow, before checkout and credential setup, requiring github.ref_type to be
branch and github.ref_name to match the release-X.Y pattern; reject all other
refs before publishing. Apply this invariant to each affected workflow section
and add matching assertions to hack/release-freeze-contract.bats.

In `@hack/release-freeze-contract.bats`:
- Around line 85-99: Update the force-detection assertions in both freeze step
blocks, including the block covered by the second referenced range, to reject
push refspecs beginning with “+” such as “+HEAD:refs/heads/$BRANCH”. Extend the
checks around the existing git push validation without changing the required
non-forced create-push assertion.
- Around line 74-82: Update the assertions in the affected tests around the
freeze, subsequent release, and related step blocks to run each condition grep
against its already extracted step_block rather than the complete workflow.
Within the freeze step block, also assert that id: freeze is present, ensuring
the guarded assertions cannot be satisfied by unrelated steps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 463c1edb-d9e3-4c54-989b-582d7470c0b3

📥 Commits

Reviewing files that changed from the base of the PR and between b16fb51 and e532844.

📒 Files selected for processing (5)
  • .github/workflows/backport.yaml
  • .github/workflows/build-release.yaml
  • .github/workflows/cut-prerelease.yaml
  • docs/release.md
  • hack/release-freeze-contract.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/backport.yaml

Comment thread .github/workflows/build-release.yaml
Comment thread hack/release-freeze-contract.bats
Comment thread hack/release-freeze-contract.bats
…model

Three comments still described the changelog reaching main by default,
with a release-X.Y-targeted promote PR as a patch-release corner case.
After the freeze that is inverted: promote-rc.yaml bases the promote PR
on release-X.Y whenever the branch exists, and cutting vX.Y.0-rc.1
creates it, so every release merges its changelog onto the maintenance
line and none reach main on the way. The tags.yaml backstop is now the
normal route, and its self-skip on `exists` is the exception.

These comments are the first thing anyone debugging a missing changelog
reads, so pointing at the wrong model of which path is normal costs real
time. Reworded in place: the generate-changelog job header, the
three-paths comment above check_changelog, its inline at_tag note,
finalize's explanation of why update-releasenotes.yaml is not trusted
with the release body, and the contract test that pins the porting
behaviour. Prose only, no behaviour change.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
The workflow_dispatch trigger added for the freeze takes any ref, and the
"Run workflow" ref selector preselects the default branch — which is
exactly where this workflow's own recovery advice, and cut-prerelease's
warning, send an operator. IMAGE_TAG is github.ref_name, so a dispatch
from main spends a two-hour `make build` republishing
cozystack-packages:main and every main image tag while build-main.yaml
may be writing the same ones: the 409 tag-collision class #2711 fixed.

Nothing is ever mislabeled, since the content really is that ref, so the
cost is a wasted runner rather than a wrong artifact. Guard it anyway: it
is five lines, and the mis-click is easy.

Anchored at both ends so the per-release staging branches (release-1.6.1,
release-1.6.0-rc.4) do not qualify — those are built by tags.yaml — and
gated on ref_type so a tag named release-1.6 cannot pass the pattern on
its own. Runs before the checkout and the OCIR login, so a refused
dispatch never touches the credentials. Fails rather than skips: a
skipped job reads in the run list like one that produced the artifact.

The push trigger was already filtered to release-X.Y; this closes the
dispatch path to the same set.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
Three assertions extracted a step block, checked it was non-empty, and
then ran their grep over the whole workflow. An unrelated step carrying
the same condition would keep them green after the protected step lost
its own guard, which is precisely the slip they exist to catch. Each now
greps its own block, and `id: freeze` moved into the freeze step's test
where it belongs.

The force-detection greps looked for `+refs/heads/` and `+refs/tags/`,
which is not how git spells a forced refspec: the `+` leads the refspec,
so `git push origin "+HEAD:refs/heads/$BRANCH"` force-moves the branch
and passed both checks. Match a leading `+` on any push refspec instead.

Adds a test for the new build-release dispatch guard: the pattern
anchored at both ends, the ref_type comparison and not merely its env
wiring, a hard failure rather than a skip, and the guard ordered before
the checkout.

Every pin mutation-tested — forcing either push with a `+` refspec,
parking the freeze condition on another step, dropping `id: freeze`,
removing or unanchoring the dispatch guard, demoting it to exit 0, and
relocating it after the checkout all turn the suite red.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) Both items addressed, and the build-release.yaml point you added since submitting is fixed here too.

Stale comments — e8675dd32. All three reworded, plus two more of the same class in tags.yaml that were not on your list: the three-paths comment above check_changelog said exists being true "is the normal outcome now that promote-rc.yaml puts it there", and the inline at_tag note carried the same (a patch release) qualifier as the header. Reworded together, since fixing only the header would have left the file disagreeing with itself two screens down.

The framing they all now share: promote-rc.yaml bases the promote PR on release-X.Y whenever the branch exists, cutting vX.Y.0-rc.1 creates it, so every release — minor and patch alike — merges its changelog onto the line and none reach main on the way. Porting in the backstop is the normal path; the self-skip on exists is the exception, and now means the file got to main some other way (a hand-commit, or an earlier run of this job). I also corrected pull-requests-release.yaml further than the qualifier: the "it would race this job on the very same push" hazard is not narrower now, it is moot, because with the promote PR merging into release-X.Y there is no push to main at promote time at all. Stated as history rather than as a live concern.

build-release.yaml dispatch — dfb1a41f2, and your reading of the cost is right: IMAGE_TAG is github.ref_name, so nothing is ever mislabeled and a mis-dispatch burns a runner rather than corrupting an artifact. Guarded anyway. Two things beyond the five lines you sketched: the pattern is anchored at both ends, because unanchored it admits the per-release staging branches (release-1.6.1, release-1.6.0-rc.4) that tags.yaml builds; and it is gated on ref_type too, since a tag named release-1.6 satisfies the name pattern on its own and would publish cozystack-packages:release-1.6 from a frozen commit rather than the line's tip. It runs before the checkout and the OCIR login so a refused dispatch never touches the credentials, and it fails rather than skips — a skipped job reads in the run list exactly like one that produced the artifact.

Tests — 83fce0383. While pinning the guard I found two holes in what I shipped last round, both flagged by CodeRabbit and both real. Three of my tests extracted a step_block, asserted it non-empty, then ran the grep over the whole workflow, so block was dead and an unrelated step carrying the same condition would have kept them green after the protected step lost its guard — exactly the slip they exist to catch. And the force-detection greps looked for +refs/heads/ / +refs/tags/, which is not how git spells a forced refspec: the + leads it, so git push origin "+HEAD:refs/heads/$BRANCH" force-moves the branch and passed both checks. Each test now greps its own block, id: freeze moved into the freeze step's test, and a leading + on any push refspec is rejected. Ten mutations verified red: forcing either push with a + refspec, parking the freeze condition on another step, dropping id: freeze, removing the dispatch guard, unanchoring its pattern, dropping its ref_type check with the env wiring left in place, demoting it to exit 0, and relocating it after the checkout. make bats-unit-tests is green at 415.

release-* protection ruleset — still open, and I agree the freeze raises it: for the whole stabilisation window an unprotected branch is the release's only content path. Nothing in this PR can close it, so it wants a repo-settings change and probably its own issue rather than a line in this diff. Say the word and I will file one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. All three comment sites now read correctly against the freeze model, and the two extra spots in tags.yaml were the same class, good catch. The build-release guard is stricter than what I asked for: ref_type plus the both-ends-anchored pattern also keep a release-named tag and the staging branches out, and the ordering pin puts it before checkout and the OCIR login. I mutation-tested the rescoped bats file: unanchoring the guard pattern, neutralizing the REF_TYPE comparison, and a flagless +HEAD: force refspec each turn the suite red.

One nit, not blocking: docs/release.md line 46 says promotion "fast-forwards the release-X.Y maintenance branch the freeze already created", but the promote PR merges into release-X.Y, so the merge commit already is the branch tip and finalize's ensure step is a no-op. Lines 102 and 297 of the same doc say exactly that; the line 46 wording reads like the branch moves at promotion.

The release-* protection ruleset from the previous round stays open as a repo setting.

@myasnikovdaniil
myasnikovdaniil merged commit 7cef85f into main Aug 4, 2026
18 of 23 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the feat/rc-freeze-release-branch branch August 4, 2026 13:53

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with non-blocking notes. The logic is correct, behavior matches the description, and the cross-file claims check out. No regressions; this is safe to merge. A few robustness notes below, none blocking.

Verified

  • kind parsing: m[4] of ^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.(\d+)$ is indeed the pre-release kind; the freeze gate kind == 'rc' && patch == '0' is right.
  • Guard ordering (refuse-from-main before the tag push, freeze after it) is correct; freeze is create-only and no-ops on a later rc.N, and the branch forks exactly from the tagged commit.
  • The "promote PR base flips to release-X.Y" claim holds: promote-rc.yaml already sets BASE="release-${LINE}" when the branch exists, and finalize does not filter on the PR base, so a promote PR into release-X.Y still finalizes and Ensure maintenance branch degrades to a fast-forward no-op. The docs narrative is accurate.
  • Backport sorting: numeric descending correctly orders release-1.10 > release-1.9, and ^release-(\d+)\.(\d+)$ does not match the release-X.Y.Z / release-X.Y.Z-rc.N staging branches.

Non-blocking notes

  1. Freeze guarantee has a residual hole in the documented branch-push-failure window (cut-prerelease.yaml, "Refuse to cut a frozen line from main"). The guard tests "does release-X.Y exist", but the invariant it protects is "no rc for this line was ever cut from main". These diverge exactly when the tag push of rc.1 succeeds and the freeze push fails: tag exists, branch does not. If the operator then dispatches rc.2 from main instead of running the recovery command, the guard passes, the tag lands on main's advanced tip (absorbing everything merged since rc.1), and the freeze step creates release-X.Y at that later tip. This is the exact silent feature leak the mechanism exists to prevent, and the step comment "Nothing is silently wrong in the meantime" does not hold for that path. Not a regression (there was no freeze before), but a cheap hardening is to also check for an already-cut tag of the line in the refuse step (e.g. git ls-remote --tags origin "refs/tags/v$LINE.0-rc.*"), so the guard fires on "tag exists, branch missing" too.

  2. A backport-previous request that cannot be satisfied also kills the valid current leg (backport.yaml, "Determine target branches"). core.setFailed fails the whole prepare job, so the backport job (needs: prepare) never starts, including the correct current-line backport. With only one release line present, a PR carrying both backport and backport-previous opens neither backport, so the current-line fix is silently missed. Same all-or-nothing shape as the previous getBranch probe, so not a regression, but this PR rewrites the block and the comment above it says enumeration made failures impossible. Consider not failing the current leg when only the previous line is unsatisfiable, or soften the comment.

  3. Leading zeros are accepted in the tag parse (cut-prerelease.yaml, parse step, pre-existing). (\d+) accepts v1.06.0-rc.1; the typo would create a stray release-1.06, and in backport.yaml both release-1.6 and release-1.06 parse to {maj:1,min:6}, a sort tie resolved by API listing order, which can reroute backports. Low probability, cheap to reject in the regex.

  4. Minor TOCTOU in the freeze-create step (cut-prerelease.yaml, "Freeze the line"). When the branch already exists the step prints "leaving it untouched" and exits 0 without checking EXISTING == HEAD. If the branch were concurrently created at a different commit, tag and branch would diverge while the run reports success. No concurrent creator exists in normal operation, so this is largely theoretical; an EXISTING == HEAD assertion would close it.

  5. The backport label's meaning shifts during a freeze window (by design). From rc.1 until vX.Y.0 ships, backport targets the frozen, not-yet-released line, and reaching the currently published stable requires backport-previous. In-flight PRs labeled backport under the old "latest published stable" semantics will land on the frozen line. Documented and intentional; noting it for any PRs already labeled when this merges.

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

Labels

area/ci Issues or PRs related to CI workflows, GitHub Actions, automation area/release Issues or PRs related to release tooling (changelog, backport, release pipeline) kind/feature Categorizes issue or PR as related to a new feature size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants