fix(ci): stop labeled events from cancelling and re-running backports - #3569
Conversation
|
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 backport workflow now isolates label-triggered runs, preserves active labeled runs, filters labels by event type, selects release targets from existing branches, and limits job execution time. Contract tests cover the updated concurrency, routing, and timeout expressions. ChangesBackport workflow behavior
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
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.
Actionable comments posted: 1
🤖 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/backport.yaml:
- Around line 14-25: Update the `prepare` job’s event condition so labeled
events proceed only when `github.event.label.name` is a backport or
backport-previous label, while preserving the existing label-set check for
merge-triggered events. Ensure unrelated label additions do not queue another
backport run.
🪄 Autofix
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: 4301b12e-65d2-4fe7-917c-909797096101
📒 Files selected for processing (4)
.github/workflows/backport.yaml.github/workflows/pull-requests.yamlhack/promote-gate-contract.batshack/release-freeze-contract.bats
## What this PR does The comment above the top-level `permissions:` block said jobs request the minimum extra scopes on top of it. That's backwards. A job-level block replaces the workflow-level one, and [every scope it does not name is set to `none`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions). Write `packages: write` alone in a job and it loses `contents: read` and can't check the repo out. Same wording in every file that carried the old line. It matches what #3574 uses for the workflow it adds. `backport.yaml` and `pull-requests.yaml` also appear in open #3569. Its changed lines there are the `cancel-in-progress` expressions two lines below the comment, and a three-way merge is clean in either order. Comment-only: every changed line starts with `#`, the files still parse, and actionlint reports the same 45 pre-existing findings before and after. ### Screenshots Not a UI change. ### Downstream repositories - [x] No downstream repository is affected by this change - [ ] [cozystack/website](https://github.com/cozystack/website) - follow-up: - [ ] [cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack) - follow-up: - [ ] [cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack) - follow-up: - [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up: - [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up: - [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) - follow-up: - [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) - follow-up: - [ ] [cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server) - follow-up: - [ ] [cozystack/external-apps-example](https://github.com/cozystack/external-apps-example) - follow-up: - [ ] [cozystack/examples](https://github.com/cozystack/examples) - follow-up: Walked the trigger map against the diff. The only row that names a file here is the ccp one on `.github/workflows/tags.yaml`, and it triggers on release-prep behaviour, which this doesn't touch. ### Release note ```release-note docs(ci): the comment above the top-level `permissions` block in the workflow files now says a job-level block replaces it instead of adding to it ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Clarified workflow permission guidance across automation processes. * Documented that the default token permissions are read-only. * Clarified that job-level permissions replace top-level defaults and must explicitly declare all required scopes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
IvanHunters
left a comment
There was a problem hiding this comment.
Overview
This PR makes cancel-in-progress an expression on .github/workflows/pull-requests.yaml and .github/workflows/backport.yaml so a labeled event queues instead of cancelling the in-flight run for the same PR, with a full-e2e carve-out on the PR workflow. I checked the expressions against every relevant github.event_name/github.event.action combination:
backport.yaml:cancel-in-progress: ${{ github.event.action != 'labeled' }}— correct for both subscribed actions (closed,labeled).pull-requests.yaml:cancel-in-progress: ${{ github.event.action != 'labeled' || github.event.label.name == 'full-e2e' }}— correct for all four subscribed actions (opened,synchronize,reopened,labeled), operator precedence is right (!=/==bind tighter than||), and it mirrors the identical, already-shipped idiom used elsewhere in the same file. No typo collapses the group.- The new
hack/promote-gate-contract.bats/hack/release-freeze-contract.batspins grep the exact literal strings that are actually in the files, so they'd genuinely catch a regression totrue.
In isolation, the change is sound. The reason this can't merge as-is is structural, not a logic bug in the diff itself.
Blocking
-
pull-requests.yamlis not a clean rebase — it's superseded by a different, already-shipped design (mergeable=CONFLICTING). At the PR's actual base commit,pull-requests.yamlstill had the plaingroup: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}/cancel-in-progress: true. Currentmainhas since replaced that with a group-key-splitting mechanism instead:group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}${{ (github.event.action == 'labeled' && github.event.label.name != 'full-e2e') && '-label' || '' }}withcancel-in-progress: trueleft unconditional. This landed to fix the identical incident this PR describes (the same live-run IDs are referenced in both this PR's description and the commit that introduced the group split), and was subsequently refined again (a later commit reversed part of the split specifically forfull-e2eto stop two runs from publishing conflicting "E2E Tests" statuses on one SHA). Resolving the current merge conflict by simply keeping this PR'spull-requests.yamlhunk would discardmain's current, incident-tested, multi-commit-refined isolation strategy for a simpler one that has already been superseded. This needs a real rebase-and-reconcile against currentmain, not a mechanical conflict resolution in the PR's favor. Thebackport.yamlhalf is unaffected by this —mainhas not touched that file's concurrency logic, so that portion of the fix is still needed as-is (aside from routine rebase). -
backport.yaml'spreparejob scope gap (CodeRabbit's open, unaddressed finding) becomes materially worse under this change..github/workflows/backport.yaml:prepare'sif:matches oncontains(github.event.pull_request.labels.*.name, 'backport')/'backport-previous'— i.e. the PR's cumulative label set — regardless of which label the currentlabeledevent actually added. Once a merged PR carries thebackportlabel, any later unrelated label event (e.g. an automatedsize/*orkind/*labeler) re-satisfies this condition. Before this PR, that redundant run's owncancel-in-progress: truemeant it usually got cancelled by whatever followed in the same burst; after this PR,cancel-in-progress: ${{ github.event.action != 'labeled' }}lets it queue and reliably run to completion. That's usually harmless (redundant), but not always: per the PR's own description, if the original backport PR is already merged and its branch deleted before the queued run pushes,backport-actionopens a duplicate PR for already-landed work. This is disclosed in the code comments and PR body, and CodeRabbit supplied a concrete, minimal diff to scope thelabeledbranch togithub.event.label.namebeing a backport label. Given this PR is exactly what turns the redundant run from "usually cancelled" into "reliably executes," this fix should land in the same PR rather than be deferred.
Note on mergeability
mergeable: CONFLICTING is confirmed directly (both via PR metadata and by diffing the PR's base-commit file content against current main). Please rebase onto current main, reconcile item 1 above (likely dropping or redesigning the pull-requests.yaml hunk to compose with main's existing group-split mechanism rather than replace it), and apply CodeRabbit's fix for item 2, then re-request review.
Closing
Good root-cause diagnosis and solid test coverage for what's in scope, but the PR needs to catch up with concurrent work already on main before it's safe to merge, plus the one open scoping gap in backport.yaml.
9f3ca1e to
5ac2109
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@hack/release-freeze-contract.bats`:
- Around line 382-395: The test for label-event concurrency routing must also
assert that the conditional branch emits the “-label” suffix. Extend the
assertion around the extracted backport group line in “a label event that
requests no backport gets its own concurrency group” to verify the true branch
contains “-label”, while preserving the existing predicate-count checks.
🪄 Autofix
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: 272dcaef-85aa-44fb-8e14-4657b15f3e3d
📒 Files selected for processing (2)
.github/workflows/backport.yamlhack/release-freeze-contract.bats
5ac2109 to
6758997
Compare
6758997 to
bd9f526
Compare
970d566 to
d67429f
Compare
d67429f to
de7ac1d
Compare
9f4b289 to
3a5e861
Compare
A run joins its concurrency group before any job-level `if` is evaluated, so `prepare`'s guard cannot stop a label event from disturbing the run already backporting a PR. Labels set with the default GITHUB_TOKEN start no run, but labels set by third-party GitHub Apps do, and they arrive in bursts, so each one cancelled the backport the merge had just started. The delivery survived anyway, which is why this went unnoticed: the old guard admitted a run on the PR's cumulative labels, so the last label of a burst requalified and redid the work. In the bursts on record the kill lands within seconds, well before the cherry-pick, so the cost was a restart rather than a lost backport. Move a label event that requests no backport into a group of its own, and let one that does request a backport queue rather than cancel. Both halves are needed. `backport` and `backport-previous` are separate requests that both have to complete, so they share a group and must not cancel each other, while every other label does no work here and only has to stay out of the way. Scope `prepare` to the labels each trigger is entitled to read at the same time. Reading the cumulative label set on a `labeled` event let any later unrelated label on a merged PR still carrying `backport` re-enter the job to redo a backport already delivered. That redo is also what redelivered a killed backport, so narrowing the guard alone would turn a noisy delivery into a missing one: neither change is safe to land without the other. Bound both jobs with `timeout-minutes` for the same reason, since a queued request now waits on the run ahead of it rather than replacing it. The ceiling bounds execution and not the wait for a runner, which no job-level key reaches, so 30 is generous rather than calculated: every execution measured on this workflow has been seconds, against minutes of queue that nothing here can bound. Document the recovery path the narrowing removes: with no accidental re-run left, re-applying whichever backport label the PR already carries is how an operator retries a merge-triggered run that died. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
3a5e861 to
e0d6c6b
Compare
|
A live instance turned up on an unrelated branch while this was in review, and it happens to separate the two workflows on a single event. Setting three labels while opening a PR produced this at
The survivor is run
One limit worth stating: the concurrency group a run joined is not exposed by the API, so the attribution above is the group key read from the workflow source plus the outcome observed here, not a direct observation of group membership. Three labels at creation is an ordinary thing for an author to do, not a rare interleaving. The burst does not need a bot. |
IvanHunters
left a comment
There was a problem hiding this comment.
LGTM.
Verified the concurrency and triggering logic against the workflow and the new contract tests:
- Group key.
...number}${{ (action == 'labeled' && label.name != 'backport' && label.name != 'backport-previous') && '-label' || '' }}routes only irrelevant label events into a sibling group;closedand the two backport label events stay in the main per-PR group so the two backport requests queue behind the merge run instead of being isolated from it. Operator precedence (&&over||) resolves as intended for both branches, andgithub.event.labelbeing absent onclosedyields null (no error, condition false). cancel-in-progress: ${{ github.event.action == 'closed' }}stops label bursts (from third-party Apps, which unlike the default token do start runs) from cancelling the in-flight backport, while still letting the merge trigger cancel. Positive form is correct: the!= 'labeled'alternative would fail open iftypes:ever grew.prepareif-narrowing is right:closedreads the cumulative label set,labeledreads only the added label, so a later unrelated label on a merged PR no longer re-enters and redoes a delivered backport. Each re-entrant matrix leg's state is already covered by the existing merged-backport / 422 / retry guards.- Timeouts land on both queue-affected jobs (
prepare,backport); the run-level wedge case is out of scope and correctly documented indocs/release.mdas an operator step. - Security posture unchanged —
pull_request_targettrigger, top-level read-only default and per-job permission blocks are untouched. - Contract tests pin both halves separately (group key + cancel-in-progress), the exact label pair (not just the operator count), the
-labelsuffix, the trigger surface, and the timeout bounds on both sides.release-freeze-contract.batsruns inmake bats-unit-tests.
What this PR does
A run joins its concurrency group before any job-level
ifis evaluated, soprepare's guard inbackport.yamlis too late to help. By the time it works out that a label event is irrelevant, that run has already cancelled whatever was in flight for the same PR. Labels set with the defaultGITHUB_TOKENstart no run, which is why the in-repo labelers never showed this. Third-party GitHub Apps do start runs, and they arrive in bursts.The delivery survived, which is why this went unnoticed for so long. The old guard admitted a run on the PR's cumulative label set, so the last label of a burst, the one nothing cancels, requalified and redid the work. In the bursts on record the kill lands within seconds of the merge run starting, well before the cherry-pick, so the cost was a restart rather than a lost backport, recorded as nothing more than a cancelled run sitting next to a green one.
Two changes to the concurrency key, and both are needed. A label event that requests no backport now goes into a group of its own, so it can neither cancel nor displace the run doing the work. A label event that does request one queues behind that run instead of killing it, because
backportandbackport-previousare separate requests and both have to finish. Splitting alone would letbackportcancel the merge-triggered run. Queuing alone would let an unrelated label evict a pending request, since the group holds a single pending run and a newer one replaces it.prepare's condition is scoped in the same commit. It read the PR's cumulative label set, so once a merged PR carriedbackport, any later unrelated label re-entered the job to redo a backport already delivered. That run used to get cancelled by the next label in the burst. After the change above it runs to completion, so the scoping has to land here rather than as a follow-up.The coupling runs the other way too, and it is the sharper half. That redundant run is exactly what redelivered the backport the burst had just killed, so narrowing the guard on its own would convert a noisy delivery into a missing one. Neither change is safe to land without the other.
Both jobs also gain a
timeout-minutesceiling, which is a consequence of the queuing rather than housekeeping. A cancelling key disposed of a stuck run by killing it; queuing makes the next genuine request wait behind it instead, on the six-hour job default, which turns one wedged job into a six-hour hole in the release line. That ceiling reaches a stuck job and nothing else: a run can also wedge at the run level with every job already finished, andtimeout-minuteshas no run-level equivalent, so nothing in the workflow reaches that state.docs/release.mdnow tells an operator to look for a run still in flight and clear it before re-applying the label, because the retry queues behind it rather than replacing it. The ceiling bounds execution, not the wait for a runner, and thirty minutes is generous rather than calculated. Execution on run 29844315422 was six seconds forprepareand two and sixteen for the matrix legs, against 9m24s, 5m28s and 5m45s of runner queue for the same three jobs. That queue is a third unbounded case: GitHub's limits page says "job execution time" for the six-hour job cap while spelling out that the 35-day run cap "includes execution duration, and time spent on waiting and approval", and orgs/community#50926 shows a job sitting six hours on "Waiting for a runner to pick up this job" undertimeout-minutes: 5. So the value is picked to leave room for a cherry-pick far larger than any on record while replacing the six-hour default, and nothing here is sized against the queue, because nothing here can be.This removes a mitigation, and the trade is worth naming on both sides. Under an unconditional cancel a run wedged at the run level cleared itself, because the next event killed whatever was sitting in the group. That is the same behaviour that killed live backports, so the mitigation is inseparable from the bug being fixed: keeping it means keeping label events that cancel a backport in flight. What it buys is a change of failure mode rather than one fewer failure. A backport lost silently, discovered weeks later as a fix missing from a release line, becomes a delay the operator can see in the run list, with the recovery written into
docs/release.md. There is no lossless alternative here:queue: maxremoves the eviction but only by dropping the conditional cancel entirely, and the group key cannot tell a manual retry from a second genuine request.The run cited above, 31232645133, is still live and retained on purpose: it is the only observed instance of the run-level wedge, so clearing it would remove the evidence for the paragraph that describes it. Its exposure is one pull request, #3262, which carries no backport label, so nothing is queued behind it.
The old condition also carried a disjunct that could never decide anything. A
labeledpayload already lists the new label inpull_request.labels, socontainswas true whenever the explicitgithub.event.label.namecheck was, and the name check only ever agreed with acontainsthat had already matched. The restructure drops it by construction rather than by deletion: the name check is now the whole of what alabeledevent may match on.Why this no longer touches
pull-requests.yamlmainfixed it there by splitting the group key, then refined that so afull-e2elabel stays in the main group instead of publishing a secondE2E Testsstatus on one SHA. That is the better mechanism, it neither queues nor delays, andgate-concurrency-contract.batsnow pinscancel-in-progress: truein that file, so what this PR originally did would turn it red. Nothing was left to add there, so the hunk is dropped and its pin with it. The split-key idea is applied inbackport.yamlinstead, where it did not exist.One claim from the earlier description is withdrawn. It said the leftover risk was a duplicate backport PR, opened when the original had merged and its branch had been deleted before the queued run pushed.
mainhas since gained a guard that lists closed PRs on thebackport-<n>-to-<target>branch and skips the target when one of them merged. Branch deletion does not defeat it:GET /branches/backport-3510-to-release-1.6is 404 while the same head filter on/pulls?state=closedstill returns the merged PR. So a redundant run only cost runner minutes, and the scoping above removes the run.Screenshots
Not applicable, no UI change.
Downstream repositories
Walked the trigger map in
docs/agents/contributing.mdagainst the diff. The only workflow it cites istags.yaml, for the release-prep behaviourcozystack/ccpdocuments. Everyhack/trigger it lists either names a specific file another repository mirrors or gates on, or covers moving and renaming underhack/. This PR appends tests to a contract file the map does not name, and moves nothing. No package, chart, values file, CRD, image reference, namespace, variant or node prerequisite in the diff.Release note