Repository navigation
412 lines (389 loc) · 21 KB
/
Copy pathpr-labels.yml
File metadata and controls
412 lines (389 loc) · 21 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
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# SPDX-License-Identifier: Apache-2.0
name: PR labels
# Two halves of one rule: apply every label CI can derive, and refuse the pull
# request when the one label it cannot derive is missing.
#
# Why `pull_request_target` and not `pull_request`: a `pull_request` run
# triggered from a FORK receives a read-only GITHUB_TOKEN, so every step below
# would succeed at doing nothing the first time an outside contributor opens a
# pull request -- the silent no-op this repository keeps writing rules against.
# `pull_request_target` runs in the base repository's context with a writable
# token.
#
# That event carries a documented footgun: it runs the workflow from the BASE
# branch with secrets available, so checking out and executing the pull
# request's code would hand a stranger this token. NOTHING HERE CHECKS ANYTHING
# OUT, and nothing may be added that does. actions/labeler reads its
# configuration over the API rather than from the workspace, and the remaining
# steps read a title, a body and a label list. Untrusted text reaches the shell
# through `env:` only, never interpolated into a script body, so a title
# containing shell metacharacters is data rather than code.
#
# Why this is not a job in build.yml: `check-release-gate` there asserts that
# every job in that workflow appears in `release.needs`, so a labeling job added
# to it would make cutting a release depend on pull-request labeling.
on:
pull_request_target:
# `edited` catches a title corrected after opening, which is what the type
# inference reads. `labeled`/`unlabeled` re-run the gate when a human
# supplies the type by hand.
#
# A label applied with GITHUB_TOKEN does NOT trigger a workflow run. That is
# why the gate is a job in THIS workflow rather than a file of its own: run
# separately it would stay red after `apply` had already added the very
# label it was complaining about, and nothing would ever re-run it.
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
# `Require a type label` is a REQUIRED context, so it has to report inside a
# merge queue as well (#351) -- and this is not a matter of adding a row to an
# existing trigger list. `pull_request_target` does not fire on `merge_group`
# at all, so the whole workflow is built around an event the queue does not
# produce. The security note above is why it cannot simply become
# `pull_request`, and it stays true under this trigger too: a `merge_group` run
# takes the workflow from the base branch, and nothing here checks anything out.
#
# The gate could not be made a job in build.yml instead: `check-release-gate`
# asserts every job there appears in `release.needs`, so moving it would make
# cutting a release depend on pull-request labelling. That is the same reason
# this workflow exists separately at all, recorded above.
merge_group:
branches: [master]
# Default to nothing; each job names what it needs.
permissions: {}
concurrency:
# Two keys because there are two events, and getting this wrong is silent: on
# `merge_group` there is no `github.event.pull_request`, so a key naming only
# its number would collapse to the constant `pr-labels-` for EVERY queue entry
# and `cancel-in-progress` would then have each entry cancel the one before it.
# A cancelled required context is not a reported one, and the queue would stall
# on exactly the check this trigger was added to deliver.
group: pr-labels-${{ github.event.pull_request.number || github.event.merge_group.head_ref }}
# **`false`, and the reason is the sentence three lines above this one** (#761, #820).
#
# That paragraph already says a cancelled required context is not a reported one.
# It fixed the `merge_group` half by splitting the key, and left the
# `pull_request_target` half doing exactly what it describes -- because there the
# collision is not between two queue entries, it is between two runs answering for
# ONE pull request at ONE head SHA.
#
# The events this workflow listens for include `labeled`, `unlabeled` and `edited`,
# **none of which changes the head SHA**. So the superseding run reports on the same
# commit as the run it cancelled, and until it finishes the required context
# `Require a type label` on that commit is `cancelled` -- neither pass nor fail, and
# a blocked pull request.
#
# This is why `build.yml` keeps `cancel-in-progress: true` and is not a defect: it
# triggers on `push` and a bare `pull_request:` (`opened`, `synchronize`,
# `reopened`), so a superseding run there carries a NEW SHA and the contexts it
# cancels belong to a commit nothing gates on any more.
#
# Measured on this repository, the 100 most recent `pr-labels` runs
# (2026-09-10T00:34Z .. 20:36Z): **14 cancelled**, every one superseded by a
# same-branch run seconds to minutes later, in chains of up to three. Four inspected
# by hand all reported `Require a type label` as `completed/cancelled`, and in three
# of the four `Apply derivable labels` had already SUCCEEDED -- the labels were on
# the pull request and only the verdict was thrown away.
#
# The cost of `false` is a runner start: a superseded run finishes instead of being
# killed, and this workflow takes 20 s to 4 min. What it buys is that a verdict this
# repository requires is never discarded after being computed.
#
# What this does NOT claim: GitHub also cancels a run that is still PENDING when a
# newer one queues, and that happens whatever this setting says. Every one of the 14
# observed was cancelled while in progress, so that residual is unmeasured here
# rather than shown absent.
cancel-in-progress: false
jobs:
apply:
name: "Apply derivable labels"
runs-on: ubuntu-24.04
# There is no pull request to label inside a queue, and this job is not a
# required context, so it reports `skipped` there and the gate below runs
# anyway. It must not be reached by a `merge_group` run: every step here
# addresses `$PR`, which would be empty.
if: github.event_name == 'pull_request_target'
permissions:
contents: read
pull-requests: write
steps:
# area/* and os/* from the changed paths, off .github/labeler.yml.
#
# `sync-labels: true` removes an area label once a branch stops touching
# that tree, so the label keeps stating what is true rather than what was
# true at the first push.
#
# This block used to claim that type/, priority/ and status/ were "out of
# its reach by construction, since it only ever touches labels named in the
# config". The premise is true and the conclusion was false, and it cost
# three red gates in one evening before anyone read the timeline (#347).
#
# The deletion logic IS config-scoped:
#
# if (checkMatchConfigs(...)) { allLabels.add(label) }
# else if (syncLabels) { allLabels.delete(label) }
#
# but the WRITE is not. actions/labeler seeds `allLabels` from the labels it
# read when the run started and finishes with `setLabels(...)`, a full
# REPLACEMENT of the label set -- unconditionally, whether or not
# `sync-labels` is set. So a label added between that read and that write is
# silently destroyed, whatever its prefix and whatever the config says.
# Turning `sync-labels` off would NOT fix this.
#
# That is a lost update, and it explains an intermittency that read as gate
# flakiness: the action only writes `if (!isEqual(labelsToAdd, preexisting))`,
# so the window only bites on runs that actually had a label to add. A pull
# request labelled by hand while such a run is in flight loses the label; one
# labelled when nothing is in flight keeps it.
#
# The two steps around the action are the repair, and they are compensating
# actions rather than a cure: nothing here can stop the replacement, so this
# notices what it destroyed and puts it back.
#
# They protect everything the config CANNOT produce, and deliberately leave
# `area/` and `os/` unprotected. That asymmetry is the point rather than an
# oversight: dropping a stale `area/` label once a branch stops touching that
# tree is the entire job `sync-labels` was added to do, so protecting those
# would restore the label the action had just correctly removed and break the
# feature this workflow wants.
- name: "Remember the labels actions/labeler is about to overwrite"
id: keep
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# Everything the config cannot produce. `area/` and `os/` are the
# action's to manage -- removing a stale one is the job `sync-labels` is
# here for -- so they are deliberately NOT protected.
kept="$(gh pr view "$PR" --repo "$REPO" --json labels \
--jq '[.labels[].name | select((startswith("area/") or startswith("os/")) | not)] | join(",")')"
echo "protecting: ${kept:-<none>}"
echo "kept=${kept}" >> "$GITHUB_OUTPUT"
- name: "Label the areas and platforms from the changed paths"
uses: actions/labeler@v5
with:
sync-labels: true
- name: "Restore any label the replacement destroyed"
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
KEPT: ${{ steps.keep.outputs.kept }}
run: |
set -euo pipefail
if [[ -z "$KEPT" ]]; then
echo "nothing to protect"
exit 0
fi
now="$(gh pr view "$PR" --repo "$REPO" --json labels \
--jq '[.labels[].name] | join(",")')"
missing=()
IFS=',' read -ra before <<< "$KEPT"
for label in "${before[@]}"; do
[[ -n "$label" ]] || continue
case ",${now}," in
*",${label},"*) ;;
*) missing+=("$label") ;;
esac
done
if [[ ${#missing[@]} -eq 0 ]]; then
echo "no label was destroyed by the replacement"
exit 0
fi
# Loud on purpose. This is the only place the race is observable, and a
# silent repair would leave nobody knowing how often it fires -- which is
# how it survived being seen three times in one evening while reading as
# three different problems.
echo "::warning::actions/labeler's setLabels replacement destroyed ${#missing[@]} label(s) its own config cannot produce: ${missing[*]}. Restoring. See #347 and the comment above this step."
gh pr edit "$PR" --repo "$REPO" --add-label "$(IFS=,; printf '%s' "${missing[*]}")"
# type/* and breaking-change, but only when the title states them.
- name: "Infer the type from a conventional-commit title"
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
# A human's choice always wins. Re-running on a `labeled` event must
# not overwrite a type somebody just corrected by hand.
existing="$(gh pr view "$PR" --repo "$REPO" --json labels \
--jq '[.labels[].name | select(startswith("type/"))] | join(" ")')"
if [[ -n "$existing" ]]; then
echo "type already set ($existing) -- leaving it alone"
exit 0
fi
# Prefix -> label. A prefix this table does not know applies NOTHING.
# That is the point rather than an omission: this repository also
# writes scope-only prefixes (`net:`, `node:`, `cluster:`, `cmake:`)
# which name an area, not a type, and only about a tenth of its pull
# request titles are conventional commits at all. Mapping an unknown
# prefix onto a guess is how a refactor gets published under Features,
# and a release note that is confidently wrong is worse than one a
# human had to fill in. The gate below asks for that human instead.
declare -A TYPES=(
[feat]=type/feature
[fix]=type/bug
[perf]=type/perf
[docs]=type/docs
[test]=type/test
[refactor]=type/refactor
[chore]=type/chore
[ci]=type/chore
[build]=type/chore
)
labels=()
if [[ "$TITLE" =~ ^([a-z]+)(\([^\)]*\))?(!)?: ]]; then
prefix="${BASH_REMATCH[1]}"
bang="${BASH_REMATCH[3]}"
if [[ -n "${TYPES[$prefix]:-}" ]]; then
labels+=("${TYPES[$prefix]}")
else
echo "title prefix '$prefix' names no type -- leaving the type to a human"
fi
if [[ -n "$bang" ]]; then
labels+=(breaking-change)
fi
fi
# Conventional Commits states a breaking change in a footer as well as
# with `!`, and a pull request may carry the footer without the mark.
if printf '%s' "$BODY" | grep -qE '^BREAKING[ -]CHANGE:'; then
labels+=(breaking-change)
fi
if [[ ${#labels[@]} -eq 0 ]]; then
echo "nothing derivable from the title -- the gate will ask for a type"
exit 0
fi
# Deduplicate: `!` and the footer can both name breaking-change.
mapfile -t unique < <(printf '%s\n' "${labels[@]}" | sort -u)
echo "applying: ${unique[*]}"
gh pr edit "$PR" --repo "$REPO" --add-label "$(IFS=,; printf '%s' "${unique[*]}")"
gate:
name: "Require a type label"
needs: apply
# This job runs whatever `apply` did, and that is a safety property rather
# than a convenience.
#
# A skipped job REPORTS. Measured on this repository, on the head of the
# docs-only #356 (`b4777aa`): `Check C++ style`, `clang-tidy` and
# `macOS-clang-release` -- all three REQUIRED -- each produced a check run
# with `conclusion: skipped`, and the pull request merged. So a required
# context that skips is read by the ruleset as **passing**.
#
# (The opposite claim is also true, of a different situation, which is why
# both get made: a skipped MATRIX job never expands, so its per-leg contexts
# never exist and nothing reports at all. On that same commit `Linux-*` and
# `Windows-*` came back `success`, because #300 gates those on their STEPS
# for exactly this reason.)
#
# Two consequences, and the second was a real hole.
#
# `apply` is skipped inside a merge queue -- there is no pull request to
# label -- and a skipped dependency skips its dependents by default. That
# would leave this required context reporting `skipped` in the queue, which
# by the above is not a stall but a STUB: green, and indistinguishable from a
# working gate.
#
# And the same holds when `apply` FAILS. A condition excluding that result
# skips this job, the skip reads as green, and a pull request with no `type/`
# label becomes mergeable because the labeler broke. `Apply derivable labels`
# is not itself a required context, so nothing else closes it. That was true
# before this change too -- a bare `needs: apply` skipped the gate on a
# failure just the same -- and it is the failure class this file is about, so
# it is fixed here rather than filed.
#
# The gate does not need `apply` to have succeeded. It reads the labels FRESH
# from the API, precisely because the event payload is a stale snapshot, so a
# human-applied `type/` label is readable whether the labeler worked or not.
# If `apply` failed before it could derive one, this refuses and says which
# labels to add -- which is more useful than a skip, not less. `apply`'s
# failure stays visible as a failed job; it is not laundered through this one.
#
# `!cancelled()` rather than `always()`: `always()` runs even when the RUN is
# being cancelled, and a verdict on a cancelled run is not one anybody asked
# for. Any `if:` with a status function already overrides the `needs:` success
# requirement, so this runs on a successful, failed or skipped `apply` alike.
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
permissions:
pull-requests: read
steps:
- name: "Assert exactly one type/ label"
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
# `refs/heads/gh-readonly-queue/master/pr-358-<base sha>` inside a
# queue, empty otherwise.
QUEUE_REF: ${{ github.event.merge_group.head_ref }}
run: |
set -euo pipefail
valid="type/bug type/feature type/refactor type/perf type/docs type/test type/chore"
# Which pull requests this run is answering for. One on a
# `pull_request_target`; whatever the queue branch names on a
# `merge_group`, which is one entry today and is written to survive a
# batched ref naming several.
numbers=()
if [[ -n "$PR" ]]; then
numbers=("$PR")
elif [[ -n "$QUEUE_REF" ]]; then
# A merge candidate's label was already established as a condition of
# the pull request being mergeable at all, so the honest question here
# is not "is this labelled" but "can this run still SEE the pull
# request it is answering for". When it can -- and the queue branch
# names it -- the check is re-run for real, which catches the one
# thing entry cannot: a label removed while the entry sat in the
# queue. When it cannot, that is stated and the run passes on what
# entry proved, rather than inventing an assertion it cannot make.
#
# `mapfile < <(grep ...)` and not `grep | mapfile`: a `grep` that
# matches nothing exits 1, and in a PIPELINE under `pipefail` that
# would take the whole step down. Process substitution reports
# `mapfile`'s status, so "no match" arrives as an empty array, which
# is the case handled immediately below. (The inverse trap --
# `producer | grep -q` reporting the producer's SIGPIPE status -- is
# in .agent/rules/build-and-toolchain.md.)
mapfile -t numbers < <(grep -oE 'pr-[0-9]+' <<< "$QUEUE_REF" | cut -d- -f2)
if [[ ${#numbers[@]} -eq 0 ]]; then
echo "::notice::merge candidate '$QUEUE_REF' names no pull request, so the type label cannot be re-derived here. It was verified as a condition of entering the queue; this leg reports that and nothing more."
exit 0
fi
echo "merge candidate '$QUEUE_REF' -> re-checking #${numbers[*]}"
else
echo "::error::neither a pull request number nor a merge-queue ref is present on a '${{ github.event_name }}' event, so this gate cannot tell what it is gating. Refusing rather than passing."
exit 1
fi
failed=0
for number in "${numbers[@]}"; do
# Read the labels fresh rather than from the event payload: `apply`
# may have added one seconds ago, and the payload is a snapshot taken
# before this workflow started.
#
# The API call's status is captured rather than swallowed, so an
# unreachable API is reported as an unreachable API. Left to
# `mapfile`, a failed `gh` would arrive as zero labels and be reported
# as a missing one -- the right colour for the wrong reason, sending
# somebody to add a label that is already there.
if ! labels="$(gh pr view "$number" --repo "$REPO" --json labels \
--jq '.labels[].name | select(startswith("type/"))')"; then
echo "::error::could not read the labels of #${number}. This is an API failure, not a missing label -- the type may well be set."
failed=1
continue
fi
mapfile -t found < <(printf '%s' "$labels" | grep -v '^$' || true)
case "${#found[@]}" in
1)
echo "#${number} type: ${found[0]}"
;;
0)
echo "::error::Pull request #${number} carries no type/ label. Add exactly one of: $valid -- it decides which section of the release notes the change lands in (.github/release.yml). CI derives area/ and os/ from the changed paths, but not the type: a path says where a change landed, not what it was for."
failed=1
;;
*)
echo "::error::Pull request #${number} carries ${#found[@]} type/ labels (${found[*]}); exactly one is allowed. Release notes take the first matching category, so the others would be silently ignored."
failed=1
;;
esac
done
exit "$failed"