|
14 | 14 | # and release.yml, which trigger only on intentional Git tags. |
15 | 15 | # |
16 | 16 | # 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) |
18 | 20 | # - Pull request targeting any branch (a PR can be opened against any |
19 | 21 | # 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 |
21 | 23 | # - Manual dispatch (workflow_dispatch) |
22 | 24 | # |
23 | 25 | # Skip-CI: |
|
33 | 35 | --- |
34 | 36 | name: CI |
35 | 37 |
|
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 |
38 | 41 | on: |
39 | 42 | 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] |
43 | 54 | pull_request: |
44 | 55 | # No branch restriction: a PR merging into any branch gets checks. The one |
45 | 56 | # exclusion is gh-pages, which only ever receives generated site output and |
|
48 | 59 | workflow_dispatch: |
49 | 60 |
|
50 | 61 |
|
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. |
65 | 75 | concurrency: |
66 | | - group: ci-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} |
| 76 | + group: ci-${{ github.event.pull_request.head.sha || github.sha }} |
67 | 77 | cancel-in-progress: true |
68 | 78 |
|
69 | 79 |
|
70 | 80 | permissions: |
71 | 81 | contents: read |
72 | | - pull-requests: read |
73 | 82 |
|
74 | 83 |
|
75 | 84 | 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 | | -
|
106 | 85 | # Job: Build MkDocs HTML documentation bundle for gem inclusion |
107 | 86 | generate-docs: |
108 | 87 | 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 |
116 | 94 | uses: ./.github/workflows/_generate-docs.yml |
117 | 95 |
|
118 | 96 |
|
119 | 97 | # Job: Linux test suite |
120 | 98 | tests-linux: |
121 | 99 | 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) |
124 | 106 | runs-on: ubuntu-latest |
125 | 107 | strategy: |
126 | 108 | fail-fast: false |
@@ -277,8 +259,8 @@ jobs: |
277 | 259 | # Job: Windows test suite |
278 | 260 | tests-windows: |
279 | 261 | 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) |
282 | 264 | runs-on: windows-latest |
283 | 265 | strategy: |
284 | 266 | fail-fast: false |
@@ -361,8 +343,8 @@ jobs: |
361 | 343 | # Note for the future: Ruby 3.1 will itself be dropped starting with macOS 26. |
362 | 344 | tests-macos: |
363 | 345 | 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) |
366 | 348 | runs-on: macos-latest |
367 | 349 | strategy: |
368 | 350 | fail-fast: false |
@@ -450,8 +432,7 @@ jobs: |
450 | 432 | # a path-based Bundler deployment (deploy_gem) that does not require a gem build. |
451 | 433 | tests-linux-locale: |
452 | 434 | 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 |
455 | 436 | runs-on: ubuntu-latest |
456 | 437 | steps: |
457 | 438 | - uses: actions/cache@v6 |
@@ -523,8 +504,7 @@ jobs: |
523 | 504 | # deployment (deploy_gem) that does not require a gem build. |
524 | 505 | tests-linux-encoding-stress: |
525 | 506 | 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 |
528 | 508 | runs-on: ubuntu-latest |
529 | 509 | steps: |
530 | 510 | - uses: actions/cache@v6 |
@@ -594,13 +574,12 @@ jobs: |
594 | 574 | build-gem: |
595 | 575 | name: "Validate Ceedling Gem Build" |
596 | 576 | needs: |
597 | | - - check-duplicate-push |
598 | 577 | - tests-linux |
599 | 578 | - tests-windows |
600 | 579 | - tests-macos |
601 | 580 | - tests-linux-locale |
602 | 581 | - 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) |
604 | 583 | runs-on: ubuntu-latest |
605 | 584 |
|
606 | 585 | steps: |
|
0 commit comments