Skip to content

Commit bb3fc0c

Browse files
authored
Merge pull request #1265 from ThrowTheSwitch/ci-sha-scoped-concurrency
Fix CI double-build race by keying concurrency on commit SHA
2 parents 656ab0e + 546322e commit bb3fc0c

1 file changed

Lines changed: 51 additions & 72 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
# and release.yml, which trigger only on intentional Git tags.
1515
#
1616
# Triggers:
17-
# - Push to any branch
17+
# - Push to a long-lived integration branch (master, next_version) only —
18+
# a feature/topic branch gets checks exclusively through the pull_request
19+
# event once a PR exists (see the push: trigger below for why)
1820
# - Pull request targeting any branch (a PR can be opened against any
1921
# integration branch, not just master, as the project matures multiple
20-
# branches at once)
22+
# branches at once); skipped for a still-draft PR until marked ready
2123
# - Manual dispatch (workflow_dispatch)
2224
#
2325
# Skip-CI:
@@ -33,13 +35,22 @@
3335
---
3436
name: CI
3537

36-
# Triggers the workflow on push to any branch (tag pushes excluded — handled by
37-
# prerelease.yml and release.yml), pull requests targeting any branch, or manual dispatch
38+
# Triggers the workflow on push to a long-lived integration branch (tag pushes
39+
# excluded — handled by prerelease.yml and release.yml), pull requests targeting
40+
# any branch, or manual dispatch
3841
on:
3942
push:
40-
branches:
41-
- '**' # All branches; branches: ['**'] scopes push to refs/heads/ only,
42-
# excluding refs/tags/ pushes — see Skip-CI above
43+
# Deliberately narrow, not '**': a feature/topic branch's own push event used
44+
# to also trigger this workflow, so a push immediately followed by opening a
45+
# PR from it fired two runs for the identical commit -- push and pull_request
46+
# are independent, asynchronously-fired events with no ordering guarantee
47+
# between them, so no point-in-time check on either side can reliably catch
48+
# this before the fact (see the concurrency: block below for the backstop
49+
# that still exists for whatever this doesn't prevent). Scoping push: to only
50+
# the branches that are never themselves the source of a PR removes the
51+
# redundant trigger at its source instead: a feature branch now gets CI
52+
# exactly once, via the PR opened from it.
53+
branches: [master, next_version]
4354
pull_request:
4455
# No branch restriction: a PR merging into any branch gets checks. The one
4556
# exclusion is gh-pages, which only ever receives generated site output and
@@ -48,79 +59,50 @@ on:
4859
workflow_dispatch:
4960

5061

51-
# Cancel any in-progress run for the same unit of work when a new event arrives.
52-
# github.head_ref carries the bare source branch name for a pull_request event and
53-
# is empty for a push, while github.ref_name carries that same bare branch name for
54-
# a push (and an unrelated merge-ref name for a pull_request, which is why it's only
55-
# ever reached as the fallback here) -- so `head_ref || ref_name` resolves to the
56-
# identical branch name for both event types on the same branch. github.event_name
57-
# is included specifically so push and pull_request runs for that same branch land
58-
# in *separate* groups: a push to a branch that also has an open PR fires both
59-
# triggers for the very same commit, and without the event name in the group key,
60-
# whichever run happened to start second would cancel the other -- sometimes the
61-
# pull_request-attached run, which is the one whose checks actually matter to a
62-
# reviewer. Each event type still dedupes/cancels superseded runs of its own kind;
63-
# see the check-duplicate-push job below for how the redundant push-triggered run
64-
# is skipped instead of run to completion in parallel.
62+
# Cancel any in-progress run for the same commit when a new event arrives.
63+
# github.event.pull_request.head.sha is the PR branch's real head commit on a
64+
# pull_request event (github.sha there is the synthetic PR-merge commit, not
65+
# useful for this); github.sha is the pushed commit on a push event. For the
66+
# identical commit, both resolve to the same group key -- so a push to a
67+
# branch that also gets a PR opened from it (immediately or later, whenever
68+
# a runner picks up the pull_request-triggered run) lands its push and
69+
# pull_request runs in the *same* group, and GitHub's own cancel-in-progress
70+
# cancels whichever started first the instant the second one starts. This is
71+
# keyed on the commit rather than the branch specifically so a *different*,
72+
# newer commit pushed to that same branch never collides with -- and so
73+
# never wrongly cancels -- an older commit's still-running pull_request
74+
# check: each commit gets its own group.
6575
concurrency:
66-
group: ci-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
76+
group: ci-${{ github.event.pull_request.head.sha || github.sha }}
6777
cancel-in-progress: true
6878

6979

7080
permissions:
7181
contents: read
72-
pull-requests: read
7382

7483

7584
jobs:
76-
# Job: Skip this run's real work if it's a redundant push-triggered duplicate
77-
# A push to a branch with an open PR fires both a `push` event and a
78-
# `pull_request: synchronize` event for the very same commit -- the separate
79-
# concurrency groups above (keyed on event name) mean neither run can cancel
80-
# the other, so without this check both would run the full matrix in
81-
# parallel for every such commit. This job only performs the check for
82-
# `push` events; for `pull_request`/`workflow_dispatch` events `skip` stays
83-
# empty (falsy), so every downstream job's `if:` proceeds normally.
84-
check-duplicate-push:
85-
name: "Check for Duplicate PR-Triggered Run"
86-
runs-on: ubuntu-latest
87-
outputs:
88-
skip: ${{ steps.check.outputs.skip }}
89-
steps:
90-
- name: Check whether this push's branch already has an open PR
91-
id: check
92-
if: github.event_name == 'push'
93-
env:
94-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95-
run: |
96-
numbers=$(gh pr list --repo "${{ github.repository }}" --head "${{ github.ref_name }}" --state open --json number --jq '.[].number')
97-
if [ -n "$numbers" ]; then
98-
echo "Branch '${{ github.ref_name }}' has an open PR (#$(echo "$numbers" | paste -sd, -)) -- skipping this redundant push-triggered run."
99-
echo "skip=true" >> "$GITHUB_OUTPUT"
100-
else
101-
echo "No open PR found for branch '${{ github.ref_name }}' -- proceeding normally."
102-
echo "skip=false" >> "$GITHUB_OUTPUT"
103-
fi
104-
105-
10685
# Job: Build MkDocs HTML documentation bundle for gem inclusion
10786
generate-docs:
10887
name: "Generate Local Docs Bundle for Gem Inclusion"
109-
needs: check-duplicate-push
110-
# A custom `if:` replaces GitHub's default (implicit) `success()` check on
111-
# `needs:` entirely rather than being ANDed with it -- `success()` is
112-
# included explicitly on every job below that gained this skip guard, to
113-
# preserve each job's original "only run if its other needs succeeded"
114-
# behavior alongside the new check.
115-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
88+
# pull_request fires for a draft PR the same as a ready one; github.event.pull_request
89+
# is only ever populated on that event type, so this has no effect on push or
90+
# workflow_dispatch runs. Holds off the full matrix's cost until a PR is actually
91+
# ready for review -- a draft's own incremental commits get this workflow's checks
92+
# for free the moment it's marked ready, no separate action needed.
93+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
11694
uses: ./.github/workflows/_generate-docs.yml
11795

11896

11997
# Job: Linux test suite
12098
tests-linux:
12199
name: "Linux Test Suite"
122-
needs: [check-duplicate-push, generate-docs]
123-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
100+
needs: generate-docs
101+
# success() is spelled out explicitly here (not left implicit) because a custom
102+
# if: replaces GitHub's default success()-on-needs: check entirely rather than
103+
# being ANDed with it -- every job below with a needs: dependency carries this
104+
# same explicit success() alongside its own draft-PR guard for that reason.
105+
if: success() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
124106
runs-on: ubuntu-latest
125107
strategy:
126108
fail-fast: false
@@ -277,8 +259,8 @@ jobs:
277259
# Job: Windows test suite
278260
tests-windows:
279261
name: "Windows Test Suite"
280-
needs: [check-duplicate-push, generate-docs]
281-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
262+
needs: generate-docs
263+
if: success() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
282264
runs-on: windows-latest
283265
strategy:
284266
fail-fast: false
@@ -361,8 +343,8 @@ jobs:
361343
# Note for the future: Ruby 3.1 will itself be dropped starting with macOS 26.
362344
tests-macos:
363345
name: "macOS Test Suite"
364-
needs: [check-duplicate-push, generate-docs]
365-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
346+
needs: generate-docs
347+
if: success() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
366348
runs-on: macos-latest
367349
strategy:
368350
fail-fast: false
@@ -450,8 +432,7 @@ jobs:
450432
# a path-based Bundler deployment (deploy_gem) that does not require a gem build.
451433
tests-linux-locale:
452434
name: "Linux Test Suite (ja_JP.UTF-8 Locale)"
453-
needs: check-duplicate-push
454-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
435+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
455436
runs-on: ubuntu-latest
456437
steps:
457438
- uses: actions/cache@v6
@@ -523,8 +504,7 @@ jobs:
523504
# deployment (deploy_gem) that does not require a gem build.
524505
tests-linux-encoding-stress:
525506
name: "Linux Encoding Stress Test (C/POSIX)"
526-
needs: check-duplicate-push
527-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
507+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
528508
runs-on: ubuntu-latest
529509
steps:
530510
- uses: actions/cache@v6
@@ -594,13 +574,12 @@ jobs:
594574
build-gem:
595575
name: "Validate Ceedling Gem Build"
596576
needs:
597-
- check-duplicate-push
598577
- tests-linux
599578
- tests-windows
600579
- tests-macos
601580
- tests-linux-locale
602581
- tests-linux-encoding-stress
603-
if: success() && needs.check-duplicate-push.outputs.skip != 'true'
582+
if: success() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
604583
runs-on: ubuntu-latest
605584

606585
steps:

0 commit comments

Comments
 (0)