#!/usr/bin/env bash # # check-workflow-concurrency.sh # # Every workflow declares this concurrency block: # # concurrency: # group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }} # cancel-in-progress: true # # A pull request's newer push supersedes its older run. A push (to main, or a # tag) takes a group of its own, its commit, so no later push cancels its run. # A scheduled run keeps its branch's group, which no push shares, so only the # next scheduled run supersedes it. # # Why a pull request supersedes, and why that is a gate and not a convention. # None of the 15 workflows declared `concurrency:` until 2026-10-05. A session # pushing four versions of a branch therefore queued four complete sets of # runs, all of which had to drain: one PR's 21 contexts settled none in 70 # minutes behind a backlog of 25 runs that grew faster than it cleared. The # repo's CI became unusable for every other session, and two PRs were merged on # locally-executed equivalents instead of on the gate. A gate that cannot run # is not a gate. One omitted `concurrency:` block reintroduces that for the # whole repo, and it is invisible: the workflow still passes, it just queues # forever under load. # # Why a push to main does not. Work lands as direct commits to main, several an # hour, and Forgejo hands runners the oldest queued job first. Superseding on # main replaced each cancelled run at the back of the queue, so while the # linux-arm64 queue was hours deep a workflow touched more often than that # never reached the front: in the day to 2026-11-10 01:11Z, 578 of the 810 runs # main's pushes started were cancelled, and after 13:58Z on 11-09 no run of # joule-moq-room on main finished (31 of the next 32 cancelled). That held the # wai 0.6.0 release, whose check reads each workflow at the first main commit # where it ran to the end. One workflow grouped per ref alone brings it back, # for that workflow, as invisibly as a missing block does. # # Why `cancel-in-progress` must be `true`. Forgejo 16.0.6 evaluates both keys # as expressions (EvaluateWorkflowConcurrency in the runner's jobparser), but # `false` with a group is its QueueBehind mode (services/actions/workflows.go, # ConfigureActionRunConcurrency): a run is held until every older run of the # group has finished (models/actions/task.go, getConcurrencyCondition) and none # is ever dropped. That is GitHub's `false` without its one-pending limit: it # serialises a pull request's runs and keeps all of them. Left out, Forgejo # supersedes only on a push or a pull request's new commits, so a scheduled run # that a host left waiting is never superseded by the next one. # # What bounds the queue. A pull request holds one run per workflow; main holds # one per push that touches the workflow, until a runner takes it. Forgejo # cancels a job left waiting 24 hours (ABANDONED_JOB_TIMEOUT, checked every 6 # hours), so a runner host that is down cannot leave more than a day's pushes # queued behind it. # # Runs in seconds with no toolchain, as a step inside an existing job — adding # a job of its own would spend runner capacity to protect runner capacity. set -euo pipefail cd "$(dirname "$0")/.." # The block, as it must read; printed when one is missing. canonical() { echo " concurrency:" echo " group: \${{ github.workflow }}-\${{ github.event_name == 'push' && github.sha || github.ref }}" echo " cancel-in-progress: true" } fail=0 count=0 for f in .github/workflows/*.yml; do count=$((count + 1)) name=$(basename "$f") # A top-level key, so match at column zero: a `concurrency:` nested under a # job is a different (and legal) thing that does not bound the workflow. if ! grep -qE '^concurrency:' "$f"; then echo "::error file=$f::$name declares no top-level \`concurrency:\` block." echo " Add:" canonical fail=1 continue fi # The block's own lines: those after the key, up to the next top-level line. block=$(awk '/^concurrency:/ { on = 1; next } on && /^[^ ]/ { exit } on { print }' "$f") group=$(grep -E '^ group:' <<< "$block" || true) # The group must vary by ref. A fixed group serialises every branch against # every other, which is worse than no group at all: one busy branch then # blocks the repo instead of only itself. if ! grep -qE 'github\.ref' <<< "$group"; then echo "::error file=$f::$name has a concurrency group that does not vary by \`github.ref\`." echo " A fixed group serialises unrelated branches against each other." fail=1 fi # A push takes a group of its own. Grouped per ref alone, the next push to # main cancels the run, and its replacement starts again at the back. if ! grep -qF "github.event_name == 'push' && github.sha" <<< "$group"; then echo "::error file=$f::$name does not give a push a group of its own (its commit)." echo " Grouped per ref alone, each push to main cancels the run before it, and" echo " a workflow main touches more often than its queue drains never finishes." canonical fail=1 fi if ! grep -qE '^ cancel-in-progress: true[[:space:]]*$' <<< "$block"; then echo "::error file=$f::$name does not set \`cancel-in-progress: true\`." echo " On Forgejo, \`false\` holds each run of a group behind the one before it and" echo " keeps every one; left out, a scheduled run never supersedes the one before it." fail=1 fi done if [ "$count" -eq 0 ]; then echo "::error::no workflows found — this check looked in the wrong place." exit 1 fi if [ "$fail" -ne 0 ]; then echo echo "checked $count workflow(s): at least one would queue without bound, or cancel main's runs." exit 1 fi echo "workflow concurrency: $count workflow(s), each superseding per ref, with a push in a group of its own."