Skip to content

fix(ci): stop labeled events from cancelling and re-running backports - #3569

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/labeled-cancel-in-progress
Aug 11, 2026
Merged

fix(ci): stop labeled events from cancelling and re-running backports#3569
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
fix/labeled-cancel-in-progress

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What this PR does

A run joins its concurrency group before any job-level if is evaluated, so prepare's guard in backport.yaml is 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 default GITHUB_TOKEN start 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 backport and backport-previous are separate requests and both have to finish. Splitting alone would let backport cancel 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 carried backport, 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-minutes ceiling, 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, and timeout-minutes has no run-level equivalent, so nothing in the workflow reaches that state. docs/release.md now 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 for prepare and 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" under timeout-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: max removes 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 labeled payload already lists the new label in pull_request.labels, so contains was true whenever the explicit github.event.label.name check was, and the name check only ever agreed with a contains that had already matched. The restructure drops it by construction rather than by deletion: the name check is now the whole of what a labeled event may match on.

Why this no longer touches pull-requests.yaml

main fixed it there by splitting the group key, then refined that so a full-e2e label stays in the main group instead of publishing a second E2E Tests status on one SHA. That is the better mechanism, it neither queues nor delays, and gate-concurrency-contract.bats now pins cancel-in-progress: true in 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 in backport.yaml instead, 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. main has since gained a guard that lists closed PRs on the backport-<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.6 is 404 while the same head filter on /pulls?state=closed still 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.md against the diff. The only workflow it cites is tags.yaml, for the release-prep behaviour cozystack/ccp documents. Every hack/ trigger it lists either names a specific file another repository mirrors or gates on, or covers moving and renaming under hack/. 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

fix(ci): stop `labeled` pull request events from cancelling the automatic backport run already in flight. A burst of labels from a third-party app could previously kill the backport the merge had just started, leaving the work to be redone by whichever label arrived last.

@coderabbitai

coderabbitai Bot commented Aug 5, 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 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.

Changes

Backport workflow behavior

Layer / File(s) Summary
Workflow concurrency, routing, and timeouts
.github/workflows/backport.yaml
The workflow separates label events, avoids canceling active labeled runs, applies event-specific label filtering, selects sorted release branches, reports missing targets, and sets 10-minute timeouts for prepare and backport.
Workflow contract checks
hack/release-freeze-contract.bats
Tests verify label-specific concurrency groups, cancellation behavior, job timeouts, and event-specific label filtering.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing labeled events from canceling and re-running backport workflows.
✨ 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 fix/labeled-cancel-in-progress

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.

@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review August 5, 2026 15:38

@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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between df157da and 9f3ca1e.

📒 Files selected for processing (4)
  • .github/workflows/backport.yaml
  • .github/workflows/pull-requests.yaml
  • hack/promote-gate-contract.bats
  • hack/release-freeze-contract.bats

Comment thread .github/workflows/backport.yaml Outdated
@github-actions github-actions Bot added size/M This PR changes 30-99 lines, ignoring generated files area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/bug Categorizes issue or PR as related to a bug labels Aug 5, 2026
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 7, 2026
## 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 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.

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.bats pins grep the exact literal strings that are actually in the files, so they'd genuinely catch a regression to true.

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

  1. pull-requests.yaml is not a clean rebase — it's superseded by a different, already-shipped design (mergeable=CONFLICTING). At the PR's actual base commit, pull-requests.yaml still had the plain group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }} / cancel-in-progress: true. Current main has 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' || '' }} with cancel-in-progress: true left 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 for full-e2e to stop two runs from publishing conflicting "E2E Tests" statuses on one SHA). Resolving the current merge conflict by simply keeping this PR's pull-requests.yaml hunk would discard main'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 current main, not a mechanical conflict resolution in the PR's favor. The backport.yaml half is unaffected by this — main has not touched that file's concurrency logic, so that portion of the fix is still needed as-is (aside from routine rebase).

  2. backport.yaml's prepare job scope gap (CodeRabbit's open, unaddressed finding) becomes materially worse under this change. .github/workflows/backport.yaml: prepare's if: matches on contains(github.event.pull_request.labels.*.name, 'backport')/'backport-previous' — i.e. the PR's cumulative label set — regardless of which label the current labeled event actually added. Once a merged PR carries the backport label, any later unrelated label event (e.g. an automated size/* or kind/* labeler) re-satisfies this condition. Before this PR, that redundant run's own cancel-in-progress: true meant 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-action opens 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 the labeled branch to github.event.label.name being 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.

@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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9f3ca1e and 5ac2109.

📒 Files selected for processing (2)
  • .github/workflows/backport.yaml
  • hack/release-freeze-contract.bats

Comment thread hack/release-freeze-contract.bats
@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/M This PR changes 30-99 lines, ignoring generated files labels Aug 10, 2026
@lexfrei Aleksei Sviridkin (lexfrei) changed the title fix(ci): stop labeled events from cancelling in-flight PR runs fix(ci): stop labeled events from cancelling and re-running backports Aug 10, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/labeled-cancel-in-progress branch 12 times, most recently from 970d566 to d67429f Compare August 10, 2026 12:27
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>
@lexfrei

Copy link
Copy Markdown
Contributor Author

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 2026-08-11T07:37:28-30Z, inside two seconds:

workflow runs outcome
Automatic Backport 6 2 skipped, 4 cancelled, none doing work
Pull Request 7 5 cancelled, 1 skipped, 1 survived

The survivor is run 31469718022, and it is the real one rather than a discarded label run: Plan build, Unit & controller tests, Build Talos and Finalize all succeeded in it. So the same burst that left Automatic Backport with nothing running left Pull Request with its actual run intact.

pull-requests.yaml routes a labeled event carrying anything but full-e2e into a -label group, so the label runs there cancel each other and the run doing the work is in a different group. backport.yaml had no such split before this change, which is why all six died.

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

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; closed and 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, and github.event.label being absent on closed yields 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 if types: ever grew.
  • prepare if-narrowing is right: closed reads the cumulative label set, labeled reads 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 in docs/release.md as an operator step.
  • Security posture unchangedpull_request_target trigger, 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 -label suffix, the trigger surface, and the timeout bounds on both sides. release-freeze-contract.bats runs in make bats-unit-tests.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit cf6bf2e into main Aug 11, 2026
17 of 19 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/labeled-cancel-in-progress branch August 11, 2026 13:50
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 kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants