waterui 0.4.0

A modern UI framework for Rust
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: [main]
  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"