guido 0.9.0

A reactive Rust GUI library using wgpu for Wayland layer shell widgets
Documentation
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: [golden]
    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: [0, 1, 2, 3]
    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: [check, test, golden, book, golden-guard]
    # 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."