Repository navigation
328 lines (296 loc) · 13.2 KB
/
Copy pathchanged-paths.yml
File metadata and controls
328 lines (296 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
name: Changed paths
# description: |
# Tells callers whether a pull request touches anything relevant to a given concern:
# go sources for the 'go' preset, documentation sources for the 'doc' preset, an npm
# application for the 'front-end' preset. The 'none' preset watches nothing on its own,
# for a concern no preset describes: the caller states every path through extra-paths.
#
# The point is to let a workflow skip its expensive jobs on a pull request that cannot
# affect them, while still reporting a status: a job skipped by an "if:" costs no runner
# minute, but the workflow -- and therefore its gate -- keeps reporting to the branch
# protection rules. Filtering with "on.paths-ignore" instead would prevent the workflow
# from starting at all, leaving a required check hanging at "Expected -- waiting for
# status to be reported".
#
# Works on pull requests (through the pull request files API, so fork pull requests are
# fine and no checkout is needed) and on pushes (compared against github.event.before).
# Any other event gives no range to compare against, so it reports "changed".
#
# CALLERS MUST GRANT "pull-requests: read" (at workflow or at job level), on top of
# "contents: read". A called workflow cannot hold more permission than its caller, and
# an omitted scope is 'none', so a caller that skips it gets its run rejected outright:
# "requires pull-requests: read but has none". That one is not covered by the fail-open
# behaviour below -- it is refused before any step runs.
#
# Detection is ADVISORY and FAILS OPEN. Every uncertain situation resolves to
# "changed=true", i.e. do the work. Jobs must never be skipped as the side effect of a
# rate limit, a missing token scope or an API quirk. See the "Decide" step below for the
# full list of cases.
permissions:
contents: read
pull-requests: read
on:
workflow_call:
inputs:
preset:
description: |
Which set of paths to watch: 'go', 'doc', 'front-end' or 'none'.
'go' watches go sources and anything that alters a build or a test outcome.
'doc' watches markdown, the hugo doc site and the markdown/spellcheck linter
configurations.
'front-end' watches the sources, lock file and tool configuration of an npm
application -- whatever decides what `npm ci` installs and what the build emits.
The presets are deliberately generous: one pattern too many only means the work
runs when it need not, one pattern too few means it is silently skipped when it
should have run.
'none' watches nothing at all, and is the escape hatch for a concern no preset
describes: the caller redefines the whole set through extra-paths, which is then
required. Being exact is the caller's problem, and the trade-off above says a
preset is the better answer whenever one fits.
type: string
required: true
extra-paths:
description: |
Extra glob patterns to watch on top of the preset, one per line. The whole set
under the 'none' preset, where they are the only patterns and cannot be empty.
Repositories that do not follow the go-openapi layout declare the difference here,
e.g. for a doc site whose content sits at the repository root:
extra-paths: |
docs/**
Patterns are picomatch globs, relative to the repository root.
type: string
required: false
default: ''
force:
description: |
Set to 'true' to bypass detection altogether and always report 'changed=true'.
Use it for scheduled or manually dispatched runs that must do the work whatever
changed, and as the opt-out for repositories that do not want path-based skipping.
type: string
required: false
default: 'false'
outputs:
changed:
description: |
'true' when a watched path changed, or when detection could not be trusted.
'false' only when we positively established that nothing relevant changed.
value: ${{ jobs.changed-paths.outputs.changed }}
reason:
description: 'Plain English explanation of how the decision was reached.'
value: ${{ jobs.changed-paths.outputs.reason }}
defaults:
run:
shell: bash
jobs:
changed-paths:
name: detect changed paths
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.decide.outputs.changed }}
reason: ${{ steps.decide.outputs.reason }}
steps:
-
name: Build the filter spec
id: spec
env:
PRESET: ${{ inputs.preset }}
EXTRA_PATHS: ${{ inputs.extra-paths }}
run: |
# Watched paths per preset. Leading blanks are trimmed when the lists are
# emitted, so these stay aligned with the surrounding script.
GO_PATHS='**/*.go
**/*.gotmpl
**/go.mod
**/go.sum
**/go.work
**/go.work.sum
**/testdata/**
.golangci.yml
.golangci.yaml
.codecov.yml
.github/workflows/**'
# The go-openapi layout is: markdown content under docs/doc-site, hugo
# configuration, layouts and themes under hack/doc-site/hugo. Repositories that
# keep their content elsewhere add it through extra-paths.
DOC_PATHS='**/*.md
**/*.markdown
docs/doc-site/**
hack/doc-site/**
.markdownlint.yml
.markdownlint.yaml
.spellcheck.yml
.spellcheck.yaml
.wordlist.txt'
# An npm application: sources in whichever dialect and framework, the tool
# configuration, and above all the lock file -- a dependency bump changes no
# source at all and is exactly what the front-end lane exists to catch.
FRONT_END_PATHS='**/*.ts
**/*.tsx
**/*.js
**/*.jsx
**/*.mjs
**/*.cjs
**/*.svelte
**/*.vue
**/*.css
**/*.scss
**/*.html
**/package.json
**/package-lock.json
**/npm-shrinkwrap.json
**/yarn.lock
**/pnpm-lock.yaml
**/tsconfig*.json
**/vite.config.*
**/vitest.config.*
**/svelte.config.*
.github/workflows/**'
case "${PRESET}" in
go)
base="${GO_PATHS}"
;;
doc)
base="${DOC_PATHS}"
;;
front-end)
base="${FRONT_END_PATHS}"
;;
none)
# Everything comes from extra-paths. The emptiness check below is what keeps
# this from degenerating into a filter that matches nothing.
base=''
;;
*)
# A bad preset is a caller mistake, not an uncertain detection: fail loudly
# rather than fall back on watching nothing.
echo "::error title=changed-paths::unknown preset '${PRESET}': expected 'go', 'doc', 'front-end' or 'none'"
exit 1
;;
esac
spec="${RUNNER_TEMP}/paths-filter.yml"
echo "changed:" > "${spec}"
# Single quotes are dropped: they would break the YAML scalar and no legitimate
# glob needs them. The "|| [[ -n ... ]]" tail keeps the last line when the input
# does not end with a newline.
emit_globs() {
local glob
while IFS=$' \t' read -r glob || [[ -n "${glob}" ]] ; do
glob="${glob//\'/}"
if [[ -z "${glob}" ]] ; then
continue
fi
printf " - '%s'\n" "${glob}" >> "${spec}"
done
}
printf '%s\n' "${base}" | emit_globs
printenv EXTRA_PATHS | emit_globs
# A spec holding its "changed:" header and nothing else watches no path, and
# "no watched path changed" is reported as a skip: the caller would get a gate
# that never runs anything. Only 'none' with an empty extra-paths can land here,
# and that is a caller mistake, so it fails the same way a bad preset does.
if (( $(wc -l < "${spec}") < 2 )) ; then
echo "::error title=changed-paths::preset '${PRESET}' watches no path: state the paths to watch through 'extra-paths'"
exit 1
fi
echo "::group::paths filter spec (preset: ${PRESET})"
cat "${spec}"
echo "::endgroup::"
{
echo "filters<<PATHS_FILTER_SPEC"
cat "${spec}"
echo "PATHS_FILTER_SPEC"
} >> "${GITHUB_OUTPUT}"
-
name: Checkout, for the git comparison
# Only needed on push: a pull request is resolved through the API, with no
# checkout at all.
if: ${{ inputs.force != 'true' && github.event_name == 'push' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Enough history to reach the commit the push started from. A push to a
# protected branch lands a single squashed or merge commit, so this is already
# generous. Should the base be out of reach anyway -- a long direct push, a
# force push, a brand new branch -- detection errors out and we do the work.
fetch-depth: 50
-
name: Detect changed paths
id: filter
# Two ways in, both handled by the same action against the same glob spec:
#
# * pull_request: the changed files come from the pull request files API of the
# BASE repository, so this works for fork pull requests (a fork's read-only
# token is still a token on the base repo) and needs no checkout.
# * push: compared against github.event.before, from the checkout above.
#
# Any other event -- schedule, workflow_dispatch, workflow_run, merge_group --
# gives us no range to compare, so we do the work. Note workflow_run in that
# list: a workflow chained behind another one cannot filter this way, it has to
# be triggered by the push itself.
#
# continue-on-error is what makes the failure open rather than closed: a rate
# limit, an API hiccup, an unreachable base commit, all leave the decision below
# free to fall back on doing the work.
#
# NOT covered by that, because it never reaches this step: a caller that does not
# grant "pull-requests: read". A called workflow cannot hold more than its caller,
# and an omitted scope is 'none', so the run is REJECTED at validation time with
# "requires pull-requests: read but has none". Callers must grant it, at workflow
# or at job level.
if: >-
${{
inputs.force != 'true' &&
(github.event_name == 'pull_request' ||
github.event_name == 'pull_request_target' ||
github.event_name == 'push')
}}
continue-on-error: true
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
filters: ${{ steps.spec.outputs.filters }}
-
name: Decide
id: decide
env:
FORCE: ${{ inputs.force }}
EVENT_NAME: ${{ github.event_name }}
# Empty outside of a pull request context.
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
FILTER_OUTCOME: ${{ steps.filter.outcome }}
FILTER_CHANGED: ${{ steps.filter.outputs.changed }}
run: |
# The pull request files API returns at most this many entries. At or above the
# cap the list is truncated, so "nothing relevant changed" cannot be concluded
# from it. Large regeneration pull requests do reach this.
API_FILE_CAP=3000
decision() {
local changed="$1" reason="$2"
printf 'changed=%s\n' "${changed}" >> "${GITHUB_OUTPUT}"
printf 'reason=%s\n' "${reason}" >> "${GITHUB_OUTPUT}"
if [[ "${changed}" == "true" ]] ; then
echo "::notice title=changed-paths::proceeding: ${reason}"
else
echo "::notice title=changed-paths::skipping: ${reason}"
fi
exit 0
}
if [[ "${FORCE}" == "true" ]] ; then
decision true "detection bypassed by the 'force' input"
fi
case "${EVENT_NAME}" in
pull_request|pull_request_target|push)
;;
*)
decision true "event '${EVENT_NAME}' gives no range of changed files to compare"
;;
esac
if [[ "${CHANGED_FILES}" =~ ^[0-9]+$ ]] && (( CHANGED_FILES >= API_FILE_CAP )) ; then
decision true \
"this pull request reports ${CHANGED_FILES} changed files, at or over the ${API_FILE_CAP}-file cap of the pull request files API: the list is truncated"
fi
if [[ "${FILTER_OUTCOME}" != "success" ]] ; then
decision true "changed paths could not be determined (detection outcome: '${FILTER_OUTCOME}')"
fi
if [[ "${FILTER_CHANGED}" == "true" ]] ; then
decision true "a watched path changed"
fi
decision false "no watched path changed"