waterui 0.5.1

A modern UI framework for Rust
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
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 a compile-check gate, not a test-certifying one: format and
# default-features clippy on Linux, plus the checks a change can break, chosen
# per change by the `changes` job below. Running anything is `test.yml` with
# `full: false`, and every job in that workflow except `lint` is gated on
# `full`, so no test executes here — that is the #458 decision, "dev is the
# compile-checked, not test-certified channel". The tests themselves, every
# other clippy shape, macOS and Windows, the all-features runs, the macOS
# suites, the examples, coverage and the `cargo hack` sweep are `nightly.yml`,
# once a day against `dev` (#923). The backend e2e nightlies in the backend
# repositories drive every example on their platform; they do not run the
# framework's suite.
#
# So a green gate here says the change compiles and lints, and says nothing
# about whether it works. A change whose correctness matters is run locally by
# whoever writes it, and the nightly is what certifies `dev`; a release
# (a pull request into `main`) requires that certification below.
on:
  # Integration branches only: a branch that also has a PR would otherwise run
  # the whole gate twice for one commit. On dev pushes every job skips itself;
  # the dev-push lint signal stays with dev.yml.
  push:
    branches: [main, dev]
  pull_request:
    branches: ["**"]
    # edited covers retargeting the pull request between dev and main.
    types: [opened, reopened, synchronize, edited]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  # cargo/rustup retry network failures three times by default; ten
  # matches the budget install-action sets for its own downloads.
  CARGO_NET_RETRY: "10"
  RUSTUP_MAX_RETRIES: "10"
  # rust-cache and the install-tool binary caches are the only cache
  # layers 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 root manifest whose
  # metadata names its Android floor; `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
    # The compile-check gate does not run on dev pushes (dev.yml owns that
    # signal); every job below either needs this one or skips itself.
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
    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' }}
      scripts: ${{ steps.filter.outputs.scripts == '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/**"
              - "Cargo.toml"
            deps:
              - "**/Cargo.toml"
              - "**/Cargo.lock"
              - "deny.toml"
            scripts:
              - ".github/scripts/**"
              - "Cargo.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
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      # Pinned like every other tool install: a new dictionary turns a
      # green job red with no change to the repository. crate-ci/typos
      # downloaded this same binary on every run; install-tool caches it.
      - uses: ./.github/actions/install-tool
        with:
          tool: typos@1.49.0
          path: ~/.cargo/bin/typos*
      - run: typos

  # The release scripts derive the certified `framework.json` from the root
  # manifest; their tests pin that derivation to the tree, so they run whenever
  # the scripts or the manifest change.
  scripts:
    name: Release scripts
    needs: changes
    if: needs.changes.outputs.scripts == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - name: Manifest derivation tests
        run: uv run --with pytest pytest .github/scripts

  # 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
      # 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: ./.github/actions/setup-rust
        with:
          shared-key: cargo-home-${{ runner.os }}
          cache-targets: false
          save-if: false
          cache-on-failure: true
      # 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 -o Acquire::Retries=10 install -y nasm
      # 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: ./.github/actions/install-tool
        with:
          tool: cargo-audit@0.22.2
          path: ~/.cargo/bin/cargo-audit*
      - name: Security audit
        run: cargo audit
      - uses: ./.github/actions/install-tool
        with:
          tool: cargo-deny@0.20.2
          path: ~/.cargo/bin/cargo-deny*
      - name: License and ban check
        run: cargo deny check licenses bans
      - uses: ./.github/actions/install-tool
        with:
          tool: cargo-machete@0.9.2
          path: ~/.cargo/bin/cargo-machete*
      - name: Unused dependencies
        run: |
          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
      # 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
      # 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: ./.github/actions/setup-rust
        with:
          targets: aarch64-linux-android
          components: clippy
          shared-key: cargo-home-${{ runner.os }}
          cache-targets: false
          save-if: false
          cache-on-failure: true
      # `--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
      # The Android API floor lives in the root manifest's
      # `[package.metadata.waterui]` table — the channel `cargo metadata`
      # reads without scraping source, and the same table every published
      # `framework.json` carries verbatim.
      - name: Read the framework's Android API floor
        run: |
          set -euo pipefail
          api="$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "waterui") | .metadata.waterui["android-min-api-level"] | numbers')"
          if ! [[ "$api" =~ ^[0-9]+$ ]]; then
            echo "::error file=Cargo.toml::package.metadata.waterui.android-min-api-level must be an integer API level (got '$api')"
            exit 1
          fi
          echo "WATERUI_ANDROID_MIN_API_LEVEL=$api" >> "$GITHUB_ENV"
      # `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 the framework declares.
      - name: Point cargo at the NDK toolchain
        env:
          ANDROID_NDK_ROOT: ${{ steps.ndk.outputs.ndk-path }}
        run: |
          set -euo pipefail
          api="$WATERUI_ANDROID_MIN_API_LEVEL"
          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 --profile ci-check -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

  # The nightly is the only run of the full suite, so its result gates the
  # release: a pull request into `main` merges only a certified `dev`.
  # `nightly.yml`'s promote job records a green run as an immutable
  # `nightly-*` prerelease at the tested commit, which makes certification a
  # git fact — the newest completed nightly run on dev is healthy exactly
  # when a `nightly-*` tag points at its head, and the line being released is
  # certified when that tag's commit is an ancestor of it.
  #
  # Only `main` is gated. A red nightly is fixed by ordinary pull requests
  # into `dev`, which this check never blocks, so it cannot deadlock its own
  # repair; what it blocks is publishing a `dev` the nightly has not passed.
  nightly-healthy:
    name: Nightly healthy
    if: github.event_name == 'pull_request' && github.base_ref == 'main'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read # checkout
      actions: read  # list nightly workflow runs
      issues: read   # name the open tracking issue in the failure message
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      # dev is fetched so the latest nightly run's head and the tag targets
      # resolve even when this pull request's own history does not reach them.
      - name: Fetch tags and the dev line
        run: git fetch --tags --quiet origin dev
      - name: Require a certified line
        env:
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          HEAD_REF: ${{ github.event.pull_request.head.ref }}
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail

          # Which line is being published decides what certifies it. `dev` is
          # certified by the nightly, and that is what the rest of this step
          # checks. The release line is not: a `hotfix/*` branch cut from
          # `main`, and the release-plz bump that follows it, publish `main` —
          # already certified when it was released — plus their own delta,
          # which their own checks gate. They can never descend from a
          # certification recorded on `dev`, so holding them to one does not
          # make them safer, it makes them impossible. Hold them to descending
          # from `main` instead, which is what stops this exit from being used
          # to route code onto the release line around `dev`.
          if [ "${HEAD_REF}" != "dev" ]; then
            if ! git merge-base --is-ancestor "${BASE_SHA}" "${HEAD_SHA}"; then
              echo "::error::${HEAD_SHA} publishes the release line but does not descend from main (${BASE_SHA}); cut it from main"
              exit 1
            fi
            echo "release line: ${HEAD_SHA} descends from main (${BASE_SHA}); the nightly certifies dev, not this"
            exit 0
          fi

          # The newest certification, selected the way promote records it in
          # .github/scripts/framework_manifest.py: among published non-draft
          # prereleases matching the nightly naming, the latest published_at.
          tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate | jq -s -r '
            [ .[][] | select(.prerelease and (.draft | not)
                and (.tag_name | test("^nightly-[0-9]{8}-[0-9a-f]{12}$"))) ]
            | max_by(.published_at).tag_name // empty')"
          if [ -z "${tag}" ]; then
            echo "::error::no nightly certification exists; nothing is released until the nightly matrix produces one"
            exit 1
          fi
          certified="$(git rev-parse "${tag}^{commit}")"

          # The line being released (the pull request head, normally the dev
          # tip) must contain the last certification; main itself only gains
          # certification through this merge.
          if ! git merge-base --is-ancestor "${certified}" "${HEAD_SHA}"; then
            echo "::error::${HEAD_SHA} does not descend from the last certified commit ${certified} (${tag})"
            exit 1
          fi

          # A certification only counts while the nightly still says so: the
          # newest completed nightly run on dev must carry a `nightly-*` tag
          # at the commit it tested. A red or cancelled run certifies
          # nothing, so the line stays uncertified until a run goes green.
          run="$(gh api \
            "repos/${GITHUB_REPOSITORY}/actions/workflows/nightly.yml/runs?branch=dev&status=completed&per_page=1" \
            --jq 'if (.workflow_runs | length) == 0 then "" else .workflow_runs[0] | .head_sha + " " + .html_url end')"
          if [ -z "${run}" ] || [ -z "$(git tag --points-at "${run%% *}" --list 'nightly-*')" ]; then
            issue="$(gh api "repos/${GITHUB_REPOSITORY}/issues?state=open&per_page=100" --jq '
              [ .[] | select((.pull_request | not) and (.title == "nightly: the full matrix is red")) | .html_url ]
              | .[0] // "none found"')"
            echo "::error::the latest nightly run (${run#* }) is not certified; the last certified commit is ${certified} (${tag}); open nightly issue: ${issue}"
            exit 1
          fi

          echo "nightly line healthy: ${tag} certifies ${certified}, and the latest nightly run is certified"