From 5626a45e6b43b0433309f813a02e6445a38b3568 Mon Sep 17 00:00:00 2001 From: Jeffrey Wall <265409180+jeffwall-curlewlabs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:33:31 -0700 Subject: [PATCH] Say that every workflow a slot reaches needs a concurrency group The README stated the rule only for a workflow running the slot action. It told a reconciler that declaring the dispatch type was the whole enrollment, because running it twice is harmless; running it twice at once is not, and enrolling makes that likely, since the clock's dispatch and GitHub's schedule run of one slot can start minutes apart. A chained workflow was told to use the slot action unchanged, with nothing saying the parent's group does not cover its own runs. The rule now has one section covering all three: which queue each needs and why, and the pull-request keying that was under the enrolling example. The reconciler example declares its group, and the slot action's header names chained workflows. --- README.md | 81 +++++++++++++++++++++++++++++++++++-------------- slot/action.yml | 7 +++-- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ec2b0a5..a16f59f 100644 --- a/README.md +++ b/README.md @@ -112,32 +112,13 @@ A workflow is **enrolled** when it declares `repository_dispatch` with the type name in its type, so the clock refuses it rather than sending its slots under a type it does not listen to. -**The concurrency group makes a slot run once.** The slot check and the record -are separate calls, so deliveries of one slot running side by side could each -find the slot owed and each do the work. Serialized, a later delivery starts -only after the earlier one has recorded, and it skips. +**The concurrency group makes a slot run once** ("One run at a time" says why, +and what other workflows declare). **Why a separate record job.** Recording needs `contents: write`, a token that can push to the repository. In its own job, holding nothing else, that grant is never in reach of the work's steps or of anything they install. -A workflow that also runs on pull requests, where a PR run only verifies or -dry-runs, can key that run by its run id so it never waits in the shared queue: - -```yaml -concurrency: - group: - ${{ github.event_name == 'pull_request' && format('backup-pr-{0}', - github.run_id) || 'backup' }} - queue: max - cancel-in-progress: false -``` - -GitHub reads `queue` only as a literal and refuses `queue: max` beside -`cancel-in-progress: true`, so one group cannot both queue deliveries and drop -a push's superseded run. A PR run that touches what the deliveries guard, such -as a plan that reads a live state lock, stays in the shared group instead. - ### The slot action's outputs - `scheduled`: `true` for a `schedule` or `repository_dispatch` delivery, or a @@ -186,8 +167,9 @@ A later delivery then redoes only the legs whose part is missing. ## Enrolling a reconciler A workflow that reads the current state on every run and changes only what is -out of date is harmless to run twice, so it needs no slot action, no ledger -and no record job. Declaring the dispatch type is the whole enrollment: +out of date is harmless to run again, so it needs no slot action, no ledger and +no record job. Declaring the dispatch type and a concurrency group is the whole +enrollment: ```yaml on: @@ -195,8 +177,14 @@ on: - cron: "17 * * * *" repository_dispatch: types: [github-cron-trigger/release-watch.yml] + +concurrency: + group: release-watch + cancel-in-progress: false ``` +"One run at a time" says why a reconciler needs the group. + Without the slot action, a line may fire more than once a day. Each dispatch names its exact slot, so nothing has to be attributed by time. @@ -219,6 +207,53 @@ Re-delivering a chained slot through its parent works only while that slot is still the parent line's newest, because the chained run takes the parent line's newest slot whatever the parent was sent. +A chained workflow declares a concurrency group as well, as any workflow +running the slot action does ("One run at a time"). + +## One run at a time + +Every enrolled workflow, and every workflow that runs the slot action, +declares a concurrency group its scheduled runs share. Enrolling makes runs +overlap where they rarely did: when GitHub's queue is short, its `schedule` run +and the clock's dispatch of the same slot start minutes apart, and a slow run +can still be going when the next slot arrives. + +- **A workflow running the slot action** needs the group to run a slot once. + The slot check and the record are separate calls, so deliveries of one slot + running side by side could each find the slot owed and each do the work. + Serialized, a later delivery starts only after the earlier one has recorded, + and it skips. Declare `queue: max` with `cancel-in-progress: false`, as the + example under "Enrolling a workflow" does: by default a group holds one + waiting run and cancels it when another arrives, and a cancelled delivery + leaves its slot to a later delivery, if one is coming. +- **A chained workflow** declares a group as well; the parent's declaration + does not reach it. The parent's group keeps the parent's deliveries apart, + but each one starts a chained run as it finishes, and the first chained run + can still be going when the second one starts. +- **A reconciler** needs one too. A reconciler is harmless to run again, not + necessarily to run twice at once: two runs that read the same state can both + act on it, as two that each open the same pull request would. + `cancel-in-progress: false` on the default queue is enough here, without + `queue: max`. A waiting run that a newer one replaces loses nothing, because + the newer one reconciles from later state. + +A workflow that also runs on pull requests, where a PR run only verifies or +dry-runs, can key that run by its run id so it never waits in the shared queue: + +```yaml +concurrency: + group: + ${{ github.event_name == 'pull_request' && format('backup-pr-{0}', + github.run_id) || 'backup' }} + queue: max + cancel-in-progress: false +``` + +GitHub reads `queue` only as a literal and refuses `queue: max` beside +`cancel-in-progress: true`, so one group cannot both queue deliveries and drop +a push's superseded run. A PR run that touches what the deliveries guard, such +as a plan that reads a live state lock, stays in the shared group instead. + ## The clock `tick` is the delivering half, run on a short interval by any scheduler outside diff --git a/slot/action.yml b/slot/action.yml index 2f6bc8c..30e9c92 100644 --- a/slot/action.yml +++ b/slot/action.yml @@ -7,9 +7,10 @@ # and skip it, and a manual or pull request run must behave exactly as it did # before enrollment. This action is that decision; the README has the contract. # -# The enrolling workflow must serialize its scheduled runs with a concurrency -# group: this check and the record the done action writes are separate calls, -# so deliveries of one slot running side by side could each find it owed. +# A workflow running this action, chained ones included, must serialize its +# scheduled runs with a concurrency group: this check and the record the done +# action writes are separate calls, so deliveries of one slot running side by +# side could each find it owed (the README's "One run at a time"). # # Runs the code beside it, so the workflow needs no checkout. Needs python3 # (3.10 or newer) and `gh` on the runner, and for a scheduled delivery, which