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
name: CI
permissions:
contents: read
checks: write
# dorny/paths-filter lists a pull request's changed files.
pull-requests: read
# The pull-request gate (#379); dev pushes use the lint-only dev.yml (#458).
# It is deliberately the default-features shape on Linux plus the checks a
# change can break, chosen per change by the `changes` job below. The full
# matrix — every clippy shape, macOS and Windows, the all-features runs, the
# macOS suites, coverage, the `cargo hack` sweep — is `nightly.yml`, once a
# day against `dev`. Nothing is checked less often than daily; nothing a
# change cannot break waits in front of it.
on:
# Integration branches only: a branch that also has a PR would otherwise run
# the whole gate twice for one commit.
push:
branches:
pull_request:
branches:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# rust-cache is the only cache layer here. GHA-backed sccache used to sit in
# front of it and measured a 2-8% hit rate while holding 38% of the
# repository's 10GB Actions cache quota, so its main effect was evicting the
# rust-cache tarballs that do work and putting every job back on a cold
# build. Local development still uses sccache; this is CI-only.
#
# The 10 GB quota is the budget every rust-cache block in this repository is
# written against (#328). The fast lint job now owns the target-cache budget
# (#458), including workspace check artifacts. Only its integration-branch
# fast runs write that cache; the full nightly lint is a restore-only reader.
# Daily test profiles no longer save their multi-GB target archives, which
# previously occupied the budget while every dev push cold-compiled clippy.
# The other jobs share Cargo sources under per-OS cargo-home keys, with one
# writer per OS. No test or platform coverage is removed by this allocation.
# The former full set of target archives measured ~27 GB against the cap.
# Incremental artifacts are per-machine rebuild state, useless to a fresh
# runner, so they stay disabled rather than bloating the restored cache.
CARGO_INCREMENTAL: 0
jobs:
# Which checks this change can reach. `code` is any changed file that is
# not prose or repository metadata, so a docs-only change runs the typo
# check and nothing else; `ffi` is the C ABI and the two backends that
# consume its header; `deps` is what the audit, license and unused-
# dependency checks read. A workflow change runs the whole gate, because a
# filter cannot see what a workflow edit reaches.
#
# Two filter steps, because dorny/paths-filter applies one quantifier to
# every filter it evaluates: `ffi`/`deps`/`workflows` match any pattern,
# while `code` matches a file outside every prose pattern. The second
# step combines negated patterns with `predicate-quantifier: every`. For a
# `pull_request` event the action reads the changed files from the API; a
# push compares against the commit before it on the same branch (`base` is
# the pushed ref, and is ignored for pull requests), which needs the history
# the checkout below fetches.
changes:
name: Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.prose.outputs.code == 'true' || steps.filter.outputs.workflows == 'true' }}
ffi: ${{ steps.filter.outputs.ffi == 'true' || steps.filter.outputs.workflows == 'true' }}
deps: ${{ steps.filter.outputs.deps == 'true' || steps.filter.outputs.workflows == 'true' }}
steps:
- uses: actions/checkout@v4
if: github.event_name == 'push'
with:
fetch-depth: 0
- uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.ref_name }}
filters: |
ffi:
- "ffi/**"
- "backends/apple"
- "backends/android"
deps:
- "**/Cargo.toml"
- "**/Cargo.lock"
- "deny.toml"
workflows:
- ".github/workflows/**"
- ".github/actions/**"
- uses: dorny/paths-filter@v3
id: prose
with:
base: ${{ github.ref_name }}
predicate-quantifier: every
filters: |
code:
- "!**/*.md"
- "!docs/**"
- "!LICENSE*"
- "!.github/ISSUE_TEMPLATE/**"
typos:
name: Typos
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# Pinned: on `@master` the tool upgrades itself, and a new dictionary
# turns a green job red with no change to the repository.
- uses: crate-ci/typos@v1.49.0
# Security audit, license check and unused-dependency check are each a cheap
# metadata pass over the dependency graph, but were previously three separate
# jobs that each paid full runner startup, checkout and cache-restore cost.
# They share nothing that isolates them from each other, so one job running
# them sequentially pays that fixed cost once instead of three times.
hygiene:
name: Hygiene
needs: changes
if: needs.changes.outputs.deps == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
# Only the license check below needs a native dependency (nasm, for a
# build script) and it never runs a GUI/audio/video stack, so this job
# does not need the full setup-linux-deps package list.
- uses: ./.github/actions/refresh-apt-index
- run: sudo apt-get install -y nasm
# Restore-only: this job and Features share `ci-ubuntu`, and two jobs
# saving the same key race for the reservation, so one of them always
# loses and uploads nothing. Features owns the write because it builds
# the larger graph.
- uses: Swatinem/rust-cache@v2
with:
shared-key: cargo-home-${{ runner.os }}
cache-targets: false
save-if: false
cache-on-failure: true
# The audit and deny checks run against the COMMITTED Cargo.lock — the
# dependency set the workspace actually builds. A `cargo generate-lockfile`
# step used to precede them; it silently re-resolved everything to the
# newest compatible versions first, so the checks audited a hypothetical
# future resolution instead of the real one. That is exactly backwards
# for supply-chain checks: the August 2026 `arrayref 0.3.10` compromise
# (see the [bans] section in deny.toml) would have been *pulled in* by
# that step while the committed lock stayed clean.
# Prebuilt, like cargo-deny below: `rustsec/audit-check` compiles
# cargo-audit from a fresh resolution on every run, which put a broken,
# newly published dependency of the *tool* on our critical path (#316).
- uses: taiki-e/install-action@cargo-audit
- name: Security audit
run: cargo audit
- uses: taiki-e/install-action@cargo-deny
- name: License and ban check
run: cargo deny check licenses bans
# `--config` goes before the subcommand: the cargo-deny that
# install-action provides takes it as a global option, and rejects the
# `check --config` spelling that newer builds accept.
- name: License and ban check (kit)
if: ${{ hashFiles('kit/Cargo.toml') != '' }}
working-directory: kit
run: cargo deny --config ../deny.toml check licenses bans
- uses: taiki-e/install-action@cargo-machete
- name: Unused dependencies
run: |
printf 'kit\nutils/nami\n' > .ignore
cargo machete --with-metadata
ffi-header:
name: FFI Header
needs: changes
if: needs.changes.outputs.ffi == 'true'
# The header is platform-dependent: the Apple exports (Metal, CEF) only
# appear when cbindgen expands on an Apple target, and the committed header
# contains them. Regenerating anywhere else would delete them and break the
# Apple backend, so macOS is the canonical generation host.
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# The toolchain pin, the cache and the verification all live in the
# action, which `ffi-header-cache.yml` runs on pushes to `dev` with
# `save: 'true'`. That warmer is the only writer; this job reads.
#
# The cache hits here where the old one never did: the generator is its
# own crate whose build costs cbindgen alone, the one framework compile
# left is cbindgen's check-profile expansion — rmeta, a fraction of the
# 0.8 GB dev-profile tarball this job measured when it last tried a target
# cache — and the nightly it runs on is a fixed date instead of a channel
# that moved out from under the key every day.
- uses: ./.github/actions/ffi-header
with:
save: 'false'
android-ffi:
name: Android FFI
needs: changes
if: needs.changes.outputs.ffi == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
# `waterui-ffi` selects its ABI with mutually exclusive features, and
# `ffi/src/lib.rs` turns "both" into a `compile_error!`. Every other leg
# therefore builds the `c-api` half, and nothing at all compiled
# `ffi/src/jni/` or the `cfg(target_os = "android")` bodies it reaches — a
# compile error there used to arrive on a device rather than in CI.
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
components: clippy
# `--all-targets` reaches the crate's dev-dependency graph, part of which
# is built for the host: without this the Linux-only `cros-libva` build
# script fails on the missing `libva-dev`, long before anything Android
# is compiled.
- uses: ./.github/actions/setup-linux-deps
- uses: nttld/setup-ndk@v1
id: ndk
with:
# The version the CLI's toolchain check installs and `water build`
# uses; keeping CI on it means CI compiles against the same sysroot a
# developer's Android build does.
ndk-version: r29
# Downloaded fresh every run, on purpose. The repository cache sits at
# the 10 GiB eviction limit, so a 708 MiB NDK entry is evicted and
# re-created continuously, and a restore that races an eviction hands
# the probe below a tree with the clang wrappers but no clang (#284).
# A miss costs ~79 s against ~53 s for a hit, on a 9-12 minute job.
local-cache: false
# Its own key: this is the only leg that compiles the dependency graph
# for an Android target, so it shares no artifacts with the others and
# storing it under theirs would evict a tarball nothing else can use.
- uses: Swatinem/rust-cache@v2
with:
shared-key: cargo-home-${{ runner.os }}
cache-targets: false
save-if: false
cache-on-failure: true
# `ring` compiles C for the target, so the NDK's clang wrapper is
# required even though clippy itself never links. The wrapper is named
# after the API level, which the CLI already owns — read it from there
# instead of freezing a second copy of the number in this file.
- name: Point cargo at the NDK toolchain
env:
ANDROID_NDK_ROOT: ${{ steps.ndk.outputs.ndk-path }}
run: |
set -euo pipefail
api="$(sed -n 's/^pub(crate) const ANDROID_MIN_API_LEVEL: u32 = \([0-9][0-9]*\);$/\1/p' cli/src/android/mod.rs)"
if [ -z "$api" ]; then
echo "::error file=cli/src/android/mod.rs::could not read ANDROID_MIN_API_LEVEL"
exit 1
fi
bin="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin"
# The wrapper is a shell script that execs `clang` beside it, so a
# half-restored NDK fails inside a `cc` build script six minutes
# later with nothing but "exit status 127". Prove the toolchain runs
# here instead, where the evidence is still on screen.
if ! "$bin/aarch64-linux-android$api-clang" --version; then
echo "::error::the NDK toolchain at $bin cannot compile for API $api"
find "$bin" -maxdepth 1 | head -60
exit 1
fi
{
echo "ANDROID_NDK_ROOT=$ANDROID_NDK_ROOT"
echo "ANDROID_NDK_HOME=$ANDROID_NDK_ROOT"
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$bin/aarch64-linux-android$api-clang"
echo "CC_aarch64_linux_android=$bin/aarch64-linux-android$api-clang"
echo "CXX_aarch64_linux_android=$bin/aarch64-linux-android$api-clang++"
echo "AR_aarch64_linux_android=$bin/llvm-ar"
} >> "$GITHUB_ENV"
- name: Clippy (aarch64-linux-android)
run: cargo clippy -p waterui-ffi --no-default-features --features std,android-jni,gpu --target aarch64-linux-android --all-targets -- -D warnings
test:
name: Test
needs: changes
if: needs.changes.outputs.code == 'true'
uses: ./.github/workflows/test.yml
with:
full: false
test-report:
name: Test Report
needs: test
if: always() && needs.test.result != 'skipped'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: mkdir -p artifacts
- uses: actions/download-artifact@v4
continue-on-error: true
with:
pattern: test-snapshots-*
path: artifacts/
- name: Generate Step Summary
run: python3 .github/scripts/generate_test_summary.py artifacts >> "$GITHUB_STEP_SUMMARY"