@@ -3,19 +3,46 @@ name: CI
33on :
44 push :
55 branches : [main]
6+ # No `paths:` filter here on purpose. Required status checks that live in this
7+ # workflow (Lint & Format, Selftests Status) must report on *every* PR. A
8+ # workflow-level path filter skips the whole run for docs/website-only PRs, so
9+ # those required checks never report and the PR is blocked forever ("Expected
10+ # — waiting for status to be reported"). Instead the workflow always runs and a
11+ # `changes` job below gates the expensive code jobs.
612 pull_request :
7- paths :
8- - ' src/**'
9- - ' selftests/**'
10- - ' examples/**'
11- - ' docker/**'
12- - ' pyproject.toml'
13- - ' uv.lock'
14- - ' .github/workflows/**'
15- - ' .github/zizmor.yml'
16- - ' .github/dependabot.yml'
1713
1814jobs :
15+ # Detect whether this PR/push touches code that the CI jobs actually validate.
16+ # Expensive jobs gate on `relevant`; the always-run required checks (lint and
17+ # the Selftests Status shim) report success even when the code jobs are skipped.
18+ changes :
19+ runs-on : ubuntu-latest
20+ permissions :
21+ contents : read
22+ pull-requests : read
23+ outputs :
24+ relevant : ${{ steps.filter.outputs.relevant }}
25+ steps :
26+ - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
27+ with :
28+ persist-credentials : false
29+ - uses : dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
30+ id : filter
31+ with :
32+ filters : |
33+ relevant:
34+ - 'src/**'
35+ - 'selftests/**'
36+ - 'examples/**'
37+ - 'docker/**'
38+ - 'pyproject.toml'
39+ - 'uv.lock'
40+ - '.github/workflows/**'
41+ - '.github/zizmor.yml'
42+ - '.github/dependabot.yml'
43+
44+ # Ruff is cheap (seconds) and "Lint & Format" is a required check, so it always
45+ # runs — no `changes` gate — to guarantee the required status always reports.
1946 lint :
2047 name : Lint & Format
2148 runs-on : ubuntu-latest
3865
3966 typecheck :
4067 name : Type Check
68+ needs : [changes]
69+ if : needs.changes.outputs.relevant == 'true'
4170 runs-on : ubuntu-latest
4271 steps :
4372 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
5685
5786 actions-lint :
5887 name : Actions Lint (zizmor)
88+ needs : [changes]
89+ if : needs.changes.outputs.relevant == 'true'
5990 runs-on : ubuntu-latest
6091 steps :
6192 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
93124
94125 audit :
95126 name : Dependency Audit
127+ needs : [changes]
128+ if : needs.changes.outputs.relevant == 'true'
96129 runs-on : ubuntu-latest
97130 steps :
98131 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -114,6 +147,8 @@ jobs:
114147
115148 selftest :
116149 name : Selftests (${{ matrix.os }} / ${{ matrix.python-version }})
150+ needs : [changes]
151+ if : needs.changes.outputs.relevant == 'true'
117152 runs-on : ${{ matrix.os }}
118153 strategy :
119154 fail-fast : false
@@ -148,14 +183,21 @@ jobs:
148183 name : selftest-results-${{ matrix.os }}-py${{ matrix.python-version }}
149184 path : selftest-results.xml
150185
186+ # Always-run required check. Reports success when selftests pass OR when they
187+ # were skipped (docs/website-only PR), so the required "Selftests Status" check
188+ # always reports and never leaves the PR stuck waiting.
151189 selftests-status :
152190 name : Selftests Status
153191 if : always()
154- needs : [selftest]
192+ needs : [changes, selftest]
155193 runs-on : ubuntu-latest
156194 steps :
157195 - name : Check selftest matrix result
158196 run : |
197+ if [ "${{ needs.changes.result }}" = "failure" ] || [ "${{ needs.changes.result }}" = "cancelled" ]; then
198+ echo "Change-detection job failed; cannot confirm selftest scope"
199+ exit 1
200+ fi
159201 if [ "${{ needs.selftest.result }}" = "failure" ] || [ "${{ needs.selftest.result }}" = "cancelled" ]; then
160202 echo "Selftests failed or were cancelled"
161203 exit 1
@@ -164,12 +206,16 @@ jobs:
164206
165207 lockfile-guard :
166208 name : Lockfile Guard
209+ needs : [changes]
167210 # Dependabot legitimately ships lockfile-only updates (in-range bumps never
168211 # touch pyproject.toml), so it is exempt; the guard targets bot-generated
169- # plan/docs PRs that carry a stale uv.lock by accident.
212+ # plan/docs PRs that carry a stale uv.lock by accident. A stray uv.lock in an
213+ # otherwise docs-only PR still flips `relevant` (uv.lock is in the filter), so
214+ # gating on it keeps the guard effective while skipping true docs-only PRs.
170215 if : >-
171216 github.event_name == 'pull_request' &&
172- github.event.pull_request.user.login != 'dependabot[bot]'
217+ github.event.pull_request.user.login != 'dependabot[bot]' &&
218+ needs.changes.outputs.relevant == 'true'
173219 runs-on : ubuntu-latest
174220 steps :
175221 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -200,7 +246,8 @@ jobs:
200246
201247 agentic-lockfile-guard :
202248 name : Agentic Workflow Lockfile Guard
203- if : github.event_name == 'pull_request'
249+ needs : [changes]
250+ if : github.event_name == 'pull_request' && needs.changes.outputs.relevant == 'true'
204251 runs-on : ubuntu-latest
205252 permissions :
206253 contents : read
0 commit comments