From 9c3d65406aa54ed78272edc192e6ddf3e215d532 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Wed, 30 Sep 2026 17:37:57 -0400 Subject: [PATCH 1/3] fix(release-please): serialize runs per repo with a concurrency group GitHub occasionally delivers a single push to main as two workflow runs. Both runs build the identical release commit, and whichever loses the race fails with "Error updating ref heads/release-please--branches--main--...", leaving a red run on main even though the release PR is correct (chart-oce-openemr run 36780271346). Put the job in a repo-wide concurrency group without cancel-in-progress so the second run queues, then no-ops against the already-updated PR. Group per repo rather than per ref because release-please always targets the default branch, and use a name callers won't reuse to avoid a caller/callee deadlock. Assisted-by: Claude Code --- .github/workflows/release-please-reusable.yml | 11 +++++++++++ README.md | 2 ++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/release-please-reusable.yml b/.github/workflows/release-please-reusable.yml index a3da862..925c8f3 100644 --- a/.github/workflows/release-please-reusable.yml +++ b/.github/workflows/release-please-reusable.yml @@ -116,6 +116,17 @@ jobs: permissions: contents: write pull-requests: write + # Serialize runs within the caller's repo. GitHub occasionally delivers + # one push as two workflow runs; unserialized, both build the same release + # commit and the slower one fails updating the release branch ref. The + # group is repo-wide, not per-ref, because release-please always targets + # the default branch. Queued runs wait rather than cancel the running one; + # a newer queued run supersedes an older one, which is safe because it + # sees a later HEAD. The name is distinct from any group a caller might + # declare, since a shared name would deadlock caller and callee. + concurrency: + group: release-please-reusable + cancel-in-progress: false # Hoist credentials to job env so step-level `if:` can read them — GitHub # allows the `env` context in step `if:` only when env is defined at the # workflow or job level, and the `secrets` context isn't allowed in `if:` diff --git a/README.md b/README.md index ebee68d..d67dd87 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ jobs: Treat `body`, `pr`, `prs` and `name` as untrusted text: they come from commit messages and PR titles. Pass them to a script through `env:` — never interpolate them directly into a `run:` block. +The job runs in a repo-wide `release-please-reusable` concurrency group without `cancel-in-progress`, so overlapping pushes (or a single push GitHub delivers twice) queue instead of racing to update the release PR branch. Don't declare a concurrency group of the same name in the caller; caller and callee would deadlock on it. + Inputs: | Name | Type | Default | Description | From 135755edd140b72ae56cf58be9d4b93cdf4b0a02 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Wed, 30 Sep 2026 23:05:50 -0400 Subject: [PATCH 2/3] docs(release-please): say what happens to a third overlapping run The concurrency group's comment and README paragraph said overlapping runs queue. GitHub keeps only one pending run per group, so a third overlapping run cancels the one already waiting, and the canceled run shows as a failed check on its commit. No release work is lost, because release-please reads the default branch's current HEAD rather than the triggering commit, but callers should expect the canceled run. `queue: max` raises the limit to 100 pending runs. actionlint rejects the key today, so that change is tracked in #40. Also drop the claim that the group name is distinct from any name a caller might declare. Nothing enforces that; the README warning is the only guard. Assisted-by: Claude Code --- .github/workflows/release-please-reusable.yml | 9 +++++---- README.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-please-reusable.yml b/.github/workflows/release-please-reusable.yml index 925c8f3..50039f4 100644 --- a/.github/workflows/release-please-reusable.yml +++ b/.github/workflows/release-please-reusable.yml @@ -120,10 +120,11 @@ jobs: # one push as two workflow runs; unserialized, both build the same release # commit and the slower one fails updating the release branch ref. The # group is repo-wide, not per-ref, because release-please always targets - # the default branch. Queued runs wait rather than cancel the running one; - # a newer queued run supersedes an older one, which is safe because it - # sees a later HEAD. The name is distinct from any group a caller might - # declare, since a shared name would deadlock caller and callee. + # the default branch. GitHub keeps one pending run per group and cancels + # it when a newer run queues. That loses no release work, because + # release-please reads the branch's current HEAD; `queue: max` would avoid + # the canceled run once actionlint accepts the key (#40). A caller that + # declares a group of this name deadlocks with this job. concurrency: group: release-please-reusable cancel-in-progress: false diff --git a/README.md b/README.md index d67dd87..003f885 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ jobs: Treat `body`, `pr`, `prs` and `name` as untrusted text: they come from commit messages and PR titles. Pass them to a script through `env:` — never interpolate them directly into a `run:` block. -The job runs in a repo-wide `release-please-reusable` concurrency group without `cancel-in-progress`, so overlapping pushes (or a single push GitHub delivers twice) queue instead of racing to update the release PR branch. Don't declare a concurrency group of the same name in the caller; caller and callee would deadlock on it. +The job runs in a repo-wide `release-please-reusable` concurrency group without `cancel-in-progress`, so overlapping pushes (or a single push GitHub delivers twice) wait instead of racing to update the release PR branch. GitHub keeps one pending run per group, so a third overlapping run cancels the one already waiting. The canceled run shows as a failed check on its commit, but no release work is lost: the replacement reads the branch's current HEAD, not the commit that triggered it. [#40](https://github.com/openCoreEMR/github-workflows-public/issues/40) tracks removing that case with `queue: max`. Don't declare a concurrency group of the same name in the caller; caller and callee would deadlock on it. Inputs: From b9ab5ef3599d218603d44fcf407124f90e884c1c Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Wed, 30 Sep 2026 23:05:58 -0400 Subject: [PATCH 3/3] fix(release-please): time out the job so a hung run releases the group The job had no timeout, so it inherited GitHub's 360-minute default. That was harmless while runs were independent. Now that the job holds a repo-wide concurrency group, a hung run would stall every later release PR update and release in that repo for up to six hours. Cap the job at 30 minutes. The longest job in the last 100 Release Please runs of any caller took 277 seconds, so the cap leaves about six times that. Assisted-by: Claude Code --- .github/workflows/release-please-reusable.yml | 4 ++++ README.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-please-reusable.yml b/.github/workflows/release-please-reusable.yml index 50039f4..3fc4423 100644 --- a/.github/workflows/release-please-reusable.yml +++ b/.github/workflows/release-please-reusable.yml @@ -128,6 +128,10 @@ jobs: concurrency: group: release-please-reusable cancel-in-progress: false + # Bound a hung run: it holds the concurrency group, so no later run in + # the repo can start until it ends. The slowest caller's job takes just + # under five minutes. + timeout-minutes: 30 # Hoist credentials to job env so step-level `if:` can read them — GitHub # allows the `env` context in step `if:` only when env is defined at the # workflow or job level, and the `secrets` context isn't allowed in `if:` diff --git a/README.md b/README.md index 003f885..71a9abc 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ jobs: Treat `body`, `pr`, `prs` and `name` as untrusted text: they come from commit messages and PR titles. Pass them to a script through `env:` — never interpolate them directly into a `run:` block. -The job runs in a repo-wide `release-please-reusable` concurrency group without `cancel-in-progress`, so overlapping pushes (or a single push GitHub delivers twice) wait instead of racing to update the release PR branch. GitHub keeps one pending run per group, so a third overlapping run cancels the one already waiting. The canceled run shows as a failed check on its commit, but no release work is lost: the replacement reads the branch's current HEAD, not the commit that triggered it. [#40](https://github.com/openCoreEMR/github-workflows-public/issues/40) tracks removing that case with `queue: max`. Don't declare a concurrency group of the same name in the caller; caller and callee would deadlock on it. +The job runs in a repo-wide `release-please-reusable` concurrency group without `cancel-in-progress`, so overlapping pushes (or a single push GitHub delivers twice) wait instead of racing to update the release PR branch. GitHub keeps one pending run per group, so a third overlapping run cancels the one already waiting. The canceled run shows as a failed check on its commit, but no release work is lost: the replacement reads the branch's current HEAD, not the commit that triggered it. [#40](https://github.com/openCoreEMR/github-workflows-public/issues/40) tracks removing that case with `queue: max`. The job times out after 30 minutes so a hung run cannot hold the group indefinitely. Don't declare a concurrency group of the same name in the caller; caller and callee would deadlock on it. Inputs: