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
name: CI
on:
push:
branches:
- main
# Every pull request, whatever it is aimed at. Filtering on `main` left a
# pull request stacked on another branch with no checks at all — Labeler,
# the one workflow that does not filter, was the only thing that ran on it,
# which looks like a green tick from a distance.
pull_request:
# A new push supersedes the run already in flight for the same branch. Pull
# request runs are cancelled; runs on main are not, because those are the record
# of what was merged.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
jobs:
# Formatting, lints and the documentation build, which share a toolchain and
# a cache and cost nothing extra together. There is no separate build job:
# `clippy --all-targets --all-features` type-checks the library, the tests,
# the examples and the benches, and `cargo test` links what it runs.
check:
name: Format, clippy, docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# The `ci` job's display name and the `main` ruleset are one edit apart,
# and the script's own header has what that costs when they drift. Why the
# step is *here*: `ci` is what is being checked, and a gate whose name has
# drifted no longer reports the context the ruleset waits for — its own
# failure would be the one thing nobody is waiting for. `check` gates, runs
# on every event, and needs nothing but the checkout for either of these,
# which is why both come before the toolchain: a disagreement is red in
# seconds rather than after clippy.
- name: The ruleset still requires the name the gate reports under
env:
GH_TOKEN: ${{ github.token }}
run: .github/required-context.sh
- name: Check the ruleset reader
run: .github/required-context.test.sh
# The version a release PR carries is read from labels by this script,
# and it runs only on a push to main, where nothing waits on it; its
# table is checked here, where a pull request that breaks it is red.
- name: Check the release version reader
run: .github/next-version.test.sh
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libwayland-dev libxkbcommon-dev
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
# And again with the features a consumer actually gets. Every other job
# that denies warnings passes --all-features, so code kept alive only by
# an optional feature can go dead in a default build and nothing here
# objects — `RegionRect::contains` and the offscreen render target did,
# and the first to read about it was whoever depends on guido.
- name: Run clippy with the default features
run: cargo clippy --all-targets -- -D warnings
# The guard that refuses a re-bless and a commit on main decides by the
# position a word occupies, not by whether it appears. Getting that wrong
# is how it came to refuse a sed expression and to read the branch of the
# wrong repository. The table says what it has to get right.
- name: Check the commit guard
run: .claude/hooks/guard-bash.test.sh
# A broken intra-doc link is a broken promise in the published API
# reference, and it costs one compile to find.
- name: Build the API documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --all-features --no-deps
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libwayland-dev libxkbcommon-dev
- name: Run tests
run: cargo test --all-features
# A test file that needs an optional feature and does not say so compiles
# under --all-features and nowhere else. The check job's default-features
# clippy run covers this too and denies warnings on it; this stays because
# it runs in a job with its own cache, and finds it without one.
- name: Build the tests with the default features
run: cargo check --tests
# The book teaches through samples — two fifths of its lines are inside a
# ```rust fence — and until this ran, a rename was invisible in every one
# of them. It lives here rather than in the Book job because it is rustdoc
# and needs the compiled crate, which this job already has.
#
# WAYLAND_DISPLAY is unset deliberately: `mdbook test` *runs* what it
# compiles, and a sample that starts an application would dial a
# compositor. Every such sample is `no_run`, and this is the belt to that
# pair of braces.
- uses: peaceiris/actions-mdbook@v2
with:
mdbook-version: latest
# A dedicated target directory, and not `target/debug/deps`. `mdbook test`
# gives rustdoc `-L` and no `--extern`, so `extern crate guido` resolves by
# searching that path — and a shared target directory holds one guido
# artifact per feature set, which is `E0464: multiple candidates`. This one
# is written by a single build and holds exactly one.
- name: Build the library for the book
run: cargo build --all-features --target-dir target/book
- name: Compile the book's samples
run: env -u WAYLAND_DISPLAY -u DISPLAY mdbook test book -L target/book/debug/deps
# Keyed `golden` because the goldens are why this job has a rasterizer, and
# named for what it does because it runs the whole suite. Renaming it costs
# nothing once the `main` ruleset requires the `ci` job at the bottom of this
# file and nothing else: what gates a merge is that job's `needs:`.
golden:
name: Everything, on a rasterizer
runs-on: ubuntu-latest
env:
# Without an adapter every test that needs one skips itself, which would
# report green here for the one reason that matters. In CI a skip is a
# failure. Two names for the one idea, because the goldens got theirs
# first and the rest of the suite got another later — see #286.
GUIDO_GOLDEN_REQUIRED: "1"
GUIDO_GPU_REQUIRED: "1"
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwayland-dev libxkbcommon-dev \
mesa-vulkan-drivers vulkan-tools
# lavapipe is Mesa's software Vulkan implementation, and it is the
# rasterizer the goldens were blessed against: a hosted runner has no GPU,
# and a golden is only reproducible against one rasterizer anyway. The
# file is named differently across distributions, so it is found, not
# assumed.
- name: Point the Vulkan loader at lavapipe
run: |
icd=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -n 1)
if [ -z "$icd" ]; then
icd=$(dpkg -L mesa-vulkan-drivers 2>/dev/null \
| grep -E 'icd\.d/.*\.json$' | grep -i lvp | head -n 1)
fi
if [ -z "$icd" ]; then
echo "::error::No lavapipe ICD found. What is installed:"
dpkg -L mesa-vulkan-drivers 2>/dev/null | grep -E '\.json$' || true
ls -la /usr/share/vulkan/icd.d/ 2>/dev/null || true
exit 1
fi
echo "VK_ICD_FILENAMES=$icd" >> "$GITHUB_ENV"
echo "Using $icd"
- name: Report the rasterizer
run: vulkaninfo --summary | head -n 40
# Everything, rather than the targets somebody remembered. This job used
# to name them one at a time — the goldens, then the lib — and two test
# files that need an adapter were never in the list: `headless_app`, which
# is the entire application harness, and `text_at_angles`. Both skipped
# themselves in every run since they were written, and reported green.
#
# `--tests` is the lib's unit tests and every integration target, and not
# the sixty examples: a plain `cargo test` builds those too, and they carry
# no tests. Nothing has to be added
# here when a target is added, which is the property the old list did not
# have.
- name: Run every test, against a rasterizer
run: cargo test --all-features --tests
- name: Upload the pixels that moved
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: target/golden-failures/
if-no-files-found: ignore
book:
name: Book
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: peaceiris/actions-mdbook@v2
with:
mdbook-version: latest
# The book is deployed from main. Building it here means a chapter that
# stopped compiling is found in the pull request that broke it, rather
# than in the deploy that follows the merge.
- name: Build the book
run: mdbook build book
mutants:
name: Mutation testing
if: github.event_name == 'pull_request'
# The shards skip the unmutated baseline, so something has to have shown the
# suite passes first: this job runs the same tests, with every feature and
# the same adapter.
needs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
# Four shards mutating in place instead of `-j` copies each built from
# scratch: 36.8 minutes became 13.5 on #277's diff (#278).
matrix:
shard:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
# One writer for the cache the four shards share a key for.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ matrix.shard == 0 }}
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants
# With an adapter, because a mutant only a test that needs one could kill
# was otherwise counted as missed: the golden, headless-app and GPU unit
# tests skip themselves without it.
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwayland-dev libxkbcommon-dev mesa-vulkan-drivers
- name: Point the Vulkan loader at lavapipe
run: |
icd=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -n 1)
if [ -z "$icd" ]; then
echo "::error::No lavapipe ICD found"
exit 1
fi
echo "VK_ICD_FILENAMES=$icd" >> "$GITHUB_ENV"
# Only the lines this pull request touched. Mutating the whole crate takes
# hours and would say the same thing every time; mutating the diff asks
# the one question worth asking of a change — if this new code were
# wrong, would anything have noticed.
- name: The diff this pull request makes
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: git diff "$BASE"...HEAD > /tmp/pull-request.diff
# `--lib --tests` because a plain `cargo test` also builds all sixty
# examples, which carry no tests of their own and are compiled by the Test
# job. No debug info: what matters here is whether a test failed, not
# where.
#
# It costs the doc tests, which target selection excludes and which cargo
# refuses to combine with `--doc`. A mutant only a doc test would have
# killed now reports as missed.
#
# `--all-features` because `src/testing.rs` and everything behind a
# feature is otherwise not compiled at all, and a mutant in code the build
# never sees builds in no time and is reported as missed.
#
# Every shard gets the same diff and the same arguments, which is what
# makes `--shard k/4` divide the same list of mutants. With the baseline
# skipped cargo-mutants cannot time the suite itself, so `--timeout` is
# set: the slowest test run of any mutant on #277's diff took 10 seconds.
- name: Mutate what changed
env:
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_PROFILE_TEST_DEBUG: 0
# A test that needs an adapter fails rather than skips. With no
# baseline to fail first, that would read as every mutant caught, so
# the lavapipe step above is what stops a runner without one.
GUIDO_GOLDEN_REQUIRED: "1"
GUIDO_GPU_REQUIRED: "1"
run: >
cargo mutants --in-diff /tmp/pull-request.diff --all-features
-C --lib -C --tests --no-shuffle
--shard ${{ matrix.shard }}/4 --in-place --baseline=skip --timeout 120
- name: Upload what survived
if: failure()
uses: actions/upload-artifact@v4
with:
name: mutants-out-${{ matrix.shard }}
path: mutants.out/
if-no-files-found: ignore
golden-guard:
name: Golden re-bless needs a label
runs-on: ubuntu-latest
# There is no pull request to guard on a push to `main`, and this job used
# to say so with an `if:` of its own — which skips the job, and a gating job
# may not skip; see `ci` at the bottom of this file. So it runs on every
# event and the condition sits on its steps, where a skipped step still
# leaves the job green.
steps:
- uses: actions/checkout@v6
if: github.event_name == 'pull_request'
with:
fetch-depth: 0
# Re-blessing a golden is the one edit that makes a failing pixel test
# pass without changing a pixel back. It stays possible, and it stops
# being able to happen quietly.
#
# Only a rewrite or a deletion, never an addition: a new scenario brings
# a new reference with it, and that is ordinary work. Asking for the
# label there too would put it on most pull requests, and a label
# everybody applies is a label nobody reads.
- name: Refuse an unlabelled re-bless
if: github.event_name == 'pull_request'
env:
LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
changed=$(git diff --name-only --diff-filter=MD "$BASE" "$HEAD" -- tests/golden/ tests/snapshots/)
if [ -z "$changed" ]; then
echo "No reference artifacts touched."
exit 0
fi
echo "Reference artifacts changed by this pull request:"
echo "$changed"
if echo "$LABELS" | grep -q '"golden-update"'; then
echo "Carries the golden-update label — somebody decided this."
exit 0
fi
echo "::error::This pull request rewrites reference artifacts without \
the golden-update label. A changed golden is a changed pixel on \
someone's screen: look at the diff, then add the label to say you did."
exit 1
# The one check the `main` ruleset requires, in place of the five display
# strings it held until #290 — a repository setting nothing here can read,
# which is what left #288 green and unmergeable when one of the five was
# renamed. Everything above gates a merge by being in this `needs:`, a list in
# this file.
#
# It is still a list somebody has to remember, so it is read instead of
# remembered: `tests/ci_gate_waits_on_every_job.rs` refuses a job that is in
# neither this list nor the opt-out list it keeps, and refuses a gating job
# that can skip.
#
# `name:` is the last string this repository shares with that setting, and the
# `check` job asks GitHub what the ruleset requires and compares — so renaming
# it out of step is now a red check that says so, rather than a pull request
# that merges and blocks every one after it. The order still matters, because
# the check cannot merge a change it refuses: it is the sequence #289 worked
# out by hand, the old name out of the ruleset first, then the rename, then
# the new name in. Adding the new beside the old blocks just as hard, because
# a required check that never reports stays pending for ever.
ci:
name: CI
needs:
# Without `always()` this job skips the moment a need fails — and GitHub
# counts a skipped required check as a passing one, so the check the ruleset
# waits for would report green exactly when CI is red. Running is not gating
# either, which is what the step below is for.
if: always()
runs-on: ubuntu-latest
# One `jq` and nothing else. The required check is the one that must never
# hang: at the default of six hours a wedged runner leaves a pull request
# pending rather than refused, which looks the same as #288 from outside.
timeout-minutes: 5
steps:
- name: Every job this one waits on has to have succeeded
env:
RESULTS: ${{ toJson(needs) }}
run: |
echo "$RESULTS"
# `success`, and nothing else. `skipped` fails here too: no gating job
# is allowed a condition of its own, so a skip means a need did not
# run when it should have.
did_not=$(echo "$RESULTS" | jq -r 'to_entries[]
| select(.value.result != "success") | "\(.key): \(.value.result)"')
if [ -n "$did_not" ]; then
echo "::error::These jobs did not succeed, so this pull request does not merge:"
echo "$did_not"
exit 1
fi
echo "Every gating job succeeded."