Repository navigation
feat(release): cutting an rc freezes the line into release-X.Y - #3399
Conversation
Summary of ChangesHello, 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
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
Using Gemini Code AssistThe 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
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 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
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe release workflows now freeze ChangesRelease and image policy
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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| # 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" |
There was a problem hiding this comment.
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.
| yq -i "${dig_path%.digest}.useDigest = false" "$file" | |
| yq -i "${dig_path%.*}.useDigest = false" "$file" |
There was a problem hiding this comment.
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.
| ud="$(yq -r "${dig_path%.digest}.useDigest // false" "$file")" | ||
| [ "$ud" = "false" ] || { | ||
| violations="${violations} ${file} (${dig_path%.digest}.useDigest): expected false, found ${ud} | ||
| " |
There was a problem hiding this comment.
Similarly, use ${dig_path%.*} here to dynamically target the parent path of the digest field instead of assuming a .digest suffix.
| 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} | |
| " |
There was a problem hiding this comment.
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.
f85ca33 to
e95a460
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
docs/release.md
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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
-
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.Yis created in a later step — two independentgit pushcalls. 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 behaviormainhas 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 populatedbase_refthe 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. -
ls-remoteexit-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'sgit ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1does not. It is safe in practice (pipefailfails 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. -
Broken intra-doc anchor.
docs/release.mdline 39 links to#backporting, but the heading is## Backports, whose slug is#backports; the link resolves nowhere. One-character fix. -
Backport label/reference descriptions lag the new behavior. The rewrite drops the contiguous-minor assumption (
previousis now the second-newest existing line, notY-1), butdocs/release.md's backport-bot table still showsbackport-previous -> release-X.(Y-1), and.github/labels.ymlstill describes the labels in the old terms. Tightening both to "newest / second-newest existing release line" keeps the reference matching the code. -
Patch-release diagram contradicts its own prose. The prose says
release-X.Yalready exists from the freeze ("there is no branch to create"), but the adjacent mermaid graphs stillbranch release-1.2at thev1.2.0commit. It reads as a simplification, but it visually contradicts the sentence right above it. -
The
releaselabel makes the release-asset check fail on this branch. A triage bot applied the barereleaselabel, which activates the release-asset resolution job; that job expects arelease-X.Y.Z-named branch and fails on this feature branch. This is a labeling artifact, not a defect in the diff — removing thereleaselabel clears the red check.
What I verified and found correct
- The promote PR base flips to
release-X.Yon its own:promote-rc.yamlalready selectsrelease-${LINE}when it exists, elsemain— 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 intorelease-X.Ythe merge commit is already the branch tip. - Backport rewrite: numeric descending sort fixes both the
release-1.10 > 1.9ordering and themin-1non-existent-branch bug; the^release-(\d+)\.(\d+)$anchor correctly excludesrelease-X.Y.Zstaging 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.
| 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" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| TAG: ${{ steps.parse.outputs.tag }} | ||
| run: | | ||
| BRANCH="release-$LINE" | ||
| EXISTING="$(git ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1)" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| **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)). |
There was a problem hiding this comment.
Non-blocking: this anchor points at #backporting, but the heading is ## Backports (slug #backports), so the link resolves nowhere. One-character fix.
There was a problem hiding this comment.
Fixed in e5b40212b — #backporting → #backports. Also checked the other four intra-doc anchors in the file; they all resolve.
a3d1fb0
a3d1fb0 to
b16fb51
Compare
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]>
b16fb51 to
e532844
Compare
|
Aleksei Sviridkin (@lexfrei) Both blocking items are addressed, and the Docs — Tests —
|
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
.github/workflows/backport.yaml.github/workflows/build-release.yaml.github/workflows/cut-prerelease.yamldocs/release.mdhack/release-freeze-contract.bats
🚧 Files skipped from review as they are similar to previous changes (1)
- .github/workflows/backport.yaml
…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]>
|
Aleksei Sviridkin (@lexfrei) Both items addressed, and the Stale comments — The framing they all now share:
Tests —
|
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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.
IvanHunters
left a comment
There was a problem hiding this comment.
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
kindparsing:m[4]of^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.(\d+)$is indeed the pre-release kind; the freeze gatekind == '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.yamlalready setsBASE="release-${LINE}"when the branch exists, and finalize does not filter on the PR base, so a promote PR intorelease-X.Ystill finalizes andEnsure maintenance branchdegrades 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 therelease-X.Y.Z/release-X.Y.Z-rc.Nstaging branches.
Non-blocking notes
-
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 "doesrelease-X.Yexist", but the invariant it protects is "no rc for this line was ever cut frommain". These diverge exactly when the tag push ofrc.1succeeds and the freeze push fails: tag exists, branch does not. If the operator then dispatchesrc.2frommaininstead of running the recovery command, the guard passes, the tag lands onmain's advanced tip (absorbing everything merged sincerc.1), and the freeze step createsrelease-X.Yat 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. -
A
backport-previousrequest that cannot be satisfied also kills the validcurrentleg (backport.yaml, "Determine target branches").core.setFailedfails the wholepreparejob, so thebackportjob (needs: prepare) never starts, including the correct current-line backport. With only one release line present, a PR carrying bothbackportandbackport-previousopens neither backport, so the current-line fix is silently missed. Same all-or-nothing shape as the previousgetBranchprobe, 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. -
Leading zeros are accepted in the tag parse (
cut-prerelease.yaml, parse step, pre-existing).(\d+)acceptsv1.06.0-rc.1; the typo would create a strayrelease-1.06, and inbackport.yamlbothrelease-1.6andrelease-1.06parse 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. -
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 checkingEXISTING == 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; anEXISTING == HEADassertion would close it. -
The
backportlabel's meaning shifts during a freeze window (by design). Fromrc.1untilvX.Y.0ships,backporttargets the frozen, not-yet-released line, and reaching the currently published stable requiresbackport-previous. In-flight PRs labeledbackportunder 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.
What this changes
Cutting the first
vX.Y.0-rc.Nnow createsrelease-X.Yat the tagged commit and closes the line. Every later cut for that line must be dispatched from the branch; a dispatch frommainis refused.Before this,
release-X.Ywas created only when the stable promote PR merged. Sorc.2andrc.3were cut frommain's tip and silently absorbed every feature merged sincerc.1— the shipped release could contain code that no rc had ever validated.mainnow reopens for the next minor immediately, and fixes reach the release by cherry-pick.Commits
feat(release)cut-prerelease.yaml: createrelease-X.Yafter the tag push; refuse a frozen line frommainci(backport)backport.yaml: target the newest existingrelease-X.Ybranchdocs(release)Behaviour changes worth a close look
The promote PR base flips from
maintorelease-X.Yon its own. No code change causes this —promote-rc.yamlalready prefersrelease-X.Ywhenever it exists, and after the freeze it always does. Consequence: the digest-vendoredPrepare release vX.Y.0commit stays on the release line and no longer lands onmain. This matches how patch releases already work, but it is a real change for.0releases and is easy to miss in review.finalize'sEnsure maintenance branchbecomes 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.yamlderived its target fromgetLatestRelease, which returns the newest published stable. Sincerelease-X.Ynow exists beforevX.Y.0ships, that name lagged by a full line for the whole freeze window — abackporton 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 realrelease-X.Ybranches and takes the newest. Outside a freeze window the result is unchanged.This also drops a latent bug:
previouswas computed asmin - 1, which names a branch that does not exist whenever a minor is skipped. Sorting is now numeric, sorelease-1.10correctly outranksrelease-1.9.A note on image pins
An earlier revision of this PR also replaced the committed
ghcr.io/cozystack/cozystack/*digests onmainwith 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 intomainto 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
hack/helm-unit-tests.shgreen1.10 > 1.9) and skipped minors; outside a freeze window the result is unchanged (release-1.5/release-1.4)zizmorclean on both workflowsvalues.yamlorimages/*.tagfile is touched by this PRRelease note
Summary by CodeRabbit
mainadvances independently.