From acaff1154bdb1d422d200b70eecf49bfe841494e Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Fri, 13 Nov 2026 21:31:30 +0100 Subject: [PATCH] fix(renovate): move concurrency to job level A workflow level concurrency group queues every triggering event before any job's if runs. One Renovate run rebasing several pull requests fires a burst of pull_request edited events, all of them ones the anti-loop guard would have skipped. They pile into that queue, get cancelled while merely pending, and report as a failed check on each rebased pull request. The guard is correct and never executes. That is the whole defect, and the opposite of what the symptom suggests. At job level the if runs first, so a run that was always going to be skipped is reported skipped rather than cancelled. A per pull request group was rejected: Renovate processes the whole repository on every run, so two runs admitted to different groups are two concurrent Renovate processes against one repository, which the guard does nothing to prevent. Dropping the pull_request trigger was rejected because it costs the rebase checkbox. Signed-off-by: Simon Ostendorf --- .github/workflows/reusable-renovate.yaml | 91 +++++++++++++++++++----- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/.github/workflows/reusable-renovate.yaml b/.github/workflows/reusable-renovate.yaml index ec0e893..0ac7f06 100644 --- a/.github/workflows/reusable-renovate.yaml +++ b/.github/workflows/reusable-renovate.yaml @@ -72,22 +72,6 @@ on: permissions: contents: read -# One Renovate run at a time for the calling repository. "github.workflow" -# inside a called reusable workflow resolves to the CALLER's workflow name, -# not this file's own "name:" above, because the called job runs as part of -# the caller's run (confirmed against GitHub's own docs and community -# guidance on reusable workflows). Concurrency groups are themselves scoped -# to one repository, never shared across repositories, so two different -# repositories each calling this workflow from a caller also named -# "renovate" do not collide with each other; only runs within the same -# repository share this group. That is exactly the old central workflow's -# "group: ${{ github.workflow }}" intent, carried over unchanged: serialise -# every trigger (schedule, manual dispatch, a rebase checkbox edit) for one -# repository's own Renovate run, rather than letting them overlap. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - jobs: renovate: name: Self-hosted Renovate @@ -103,11 +87,16 @@ jobs: # all read the CALLER's own triggering event here, exactly as they would # read in the caller's own workflow file (this is the same mechanism, # just one level deeper, as "github.workflow" resolving to the caller's - # workflow name in the concurrency comment above). Centralizing the + # workflow name in the concurrency comment below). Centralizing the # condition here means one copy of this safety-critical check instead # of one per rollout repository, removing the copy-paste risk of each # caller having to reproduce it on its own. # + # This "if:" is deliberately evaluated before "concurrency:" below, + # not the other way around; see that key's own comment for why the + # order matters for what a skipped run reports on the triggering pull + # request. + # # What fires: # - every "schedule" tick on the caller; # - every manual "workflow_dispatch" on the caller; @@ -160,6 +149,74 @@ jobs: github.event.pull_request.user.login == 'ostendorf-cloud-dependencies[bot]' ) ) + # One Renovate run at a time for the calling repository: "github.workflow" + # inside a called reusable workflow resolves to the CALLER's workflow + # name, not this file's own "name:" above, because the called job runs + # as part of the caller's run (confirmed against GitHub's own docs and + # community guidance on reusable workflows). Concurrency groups are + # themselves scoped to one repository, never shared across + # repositories, so two different repositories each calling this + # workflow from a caller also named "renovate" do not collide with each + # other; only runs within the same repository share this group. That is + # exactly the old central workflow's "group: ${{ github.workflow }}" + # intent, carried over unchanged: serialise every genuine trigger + # (schedule, manual dispatch, a real rebase checkbox edit) for one + # repository's own Renovate run, rather than letting them overlap. + # + # This key is deliberately job-level, not workflow-level (it was + # workflow-level originally; moved here to fix a real incident). GitHub + # Actions evaluates a job's own "if:" above before deciding whether that + # job joins its job-level concurrency group at all; a job whose "if:" + # is false never joins the group and is reported as "skipped" by that + # job, full stop. Workflow-level concurrency does not get that + # ordering: the whole workflow run is placed into its group as soon as + # the triggering event arrives, before any job's "if:" runs, so a run + # that was always going to skip still occupies a concurrency slot. + # GitHub keeps only the newest pending run per group and cancels every + # other pending one; a run cancelled while merely pending never reaches + # its job, so its "if:" is never evaluated and the anti-loop guard + # above never gets a chance to produce "skipped" for it. That cancelled + # run then reports as a failed check on whatever pull request triggered + # it, unrelated to that pull request's own contents. + # + # This is exactly what happened in practice: one Renovate run rebasing + # several pull requests fires one "pull_request: edited" event per + # rebase, every one of them sent by the dependencies bot itself, so + # every one of them is an event the "if:" above was always going to + # evaluate to false for. With concurrency at the workflow level, all of + # those runs piled into the single per-repository group before any of + # them reached their job; GitHub Actions cancelled all but the newest + # while they were still pending, and each caller pull request that had + # been rebased ended up with a cancelled, failing check that had + # nothing to do with the guard, or with its own contents. Moving + # "concurrency:" to this job means every one of those bot-sender runs + # now evaluates "if:" immediately, finds it false, and is reported as + # "skipped" before it could ever be queued, let alone cancelled. + # + # A genuine trigger (a "schedule" tick, a "workflow_dispatch", or a + # human actually ticking the rebase checkbox, all of which still + # satisfy "if:") still joins this same per-repository group and still + # serialises against every other genuine trigger exactly as before; + # only a run that the guard was always going to skip no longer competes + # for that slot, or shows up as a red check for it. + # + # Scoping the group per pull request instead (for example + # "${{ github.workflow }}-${{ github.event.pull_request.number }}") + # was considered and rejected: Renovate's autodiscover run processes + # every pull request of the repository in one process, not just the + # one that triggered it, so allowing two such runs for the same + # repository at once risks two Renovate processes rebasing, force + # pushing or editing the same branches and issue bodies concurrently. + # The anti-loop guard above does not cover that risk at all; it only + # ever decides whether a single run's job executes, never whether two + # already-admitted runs may execute at the same time. Dropping the + # caller's "pull_request: edited" trigger instead was also rejected: + # that trigger is what makes a human's rebase checkbox take effect + # within this run's own CI time rather than waiting for the next + # "schedule" tick, a genuinely useful mechanism this change keeps. + concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false # A "workflow_call"-only trigger, like reusable-dco.yaml and # reusable-renovate-config.yaml, so "inputs" is always populated by the # caller and never null at runtime. Unlike the old workflow, which also