Gunnar

nordisk/gunnar-ui

.github/workflows/ci.yml

name: ci

on:
  push:
    branches: [main]
  pull_request:

# **The token this workflow's jobs get, and it is read-only.**
#
# Without this block `GITHUB_TOKEN` carries the repository's default, which on
# an older repository is write to contents, packages and more — handed to every
# step, including `cargo install`, `trunk build` and every build script in the
# dependency graph. Nothing here writes to the repository: the jobs check
# formatting, lint, test and build a bundle. So the token is narrowed to the one
# thing `actions/checkout` needs, at the top, where a new job inherits it rather
# than having to remember it.
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  # The console is airgap-first; a build that reaches out is a build that
  # behaves differently on the machine that matters.
  CARGO_NET_OFFLINE: "false"

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      # Pinned by COMMIT, not by tag: `v4` is a branch-shaped pointer the
      # action's owner can move, so a compromised or coerced release moves
      # under this workflow without a commit here. The comment says which
      # release the digest is, so a human can read the intent and
      # dependabot can still offer the bump.
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

      - name: Install the pinned toolchain
        # rust-toolchain.toml carries the version, the components and the
        # wasm32 target, so there is nothing to repeat here.
        run: rustup show

      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2

      - name: Formatting
        run: cargo fmt --all -- --check

      # `cargo clippy --workspace` cannot work in either direction. The server
      # pulls in tokio, hyper and tonic's transport, none of which build for
      # wasm32; the console is a wasm binary whose host build is a stub `main`.
      # So the two halves are linted separately, and saying so here is what
      # stops someone "fixing" it back into one command.
      - name: Lint the server
        run: cargo clippy --workspace --exclude gunnar-ui-web --all-targets -- -D warnings

      - name: Lint the console
        run: cargo clippy -p gunnar-ui-web --target wasm32-unknown-unknown -- -D warnings

      - name: Tests
        run: cargo test --workspace

      - name: The paid half's tests
        # `metered` is off by default — the community appliance does not CONTAIN
        # the code — so `--workspace` above compiles none of `api::increase`,
        # `auth::signup` or `monetize`, and their tests have never run here. A
        # test that is never run is not a gate, and the money routes are where a
        # mistake is a charge.
        run: cargo test -p gunnar-ui-server --features metered

      - name: The functional matrix compiles and its own tests pass
        # NOT the matrix itself. Running it needs a gunnar-cli binary from a
        # different private repository and a headless browser, so the full
        # 57-situation walk runs as a DEPLOY GATE on a build host where both
        # exist — a CI job that silently skipped when a cross-repo token was
        # absent would be a check that can never fail, which is worse than no
        # check.
        #
        # What CI can honestly do is refuse a crate that no longer compiles,
        # and run the tests that need nothing: the JSON parser the CDP client
        # is built on, including the one asserting a missing key never reads as
        # a pass.
        working-directory: uitest
        run: cargo test --release

      - name: The contract test actually ran
        # A contract test that silently stops being compiled is worse than no
        # contract test: CI stays green while the vendored protos drift. This
        # asserts it produced results rather than trusting that it did.
        run: |
          count=$(cargo test -p gunnar-ui-server --test contract -- --list 2>/dev/null | grep -c ': test$')
          echo "contract tests found: $count"
          test "$count" -ge 5

  # **The dependency gate gunnar has had since 2026-08-31 and the console did
  # not.**
  #
  # A job of its own rather than a step in `check`, because it answers a
  # different question and has to be readable as its own red: `check` goes red
  # when somebody's code is wrong, this goes red when the world changed under
  # code nobody touched. The console is the half of the estate that faces the
  # public internet, and it was the half with no advisory check at all.
  #
  # The policy — including every triaged `ignore`, each with a dated
  # reachability argument — lives in `deny.toml` and nowhere else, so this job
  # and any future deploy door obey one set of rules rather than two copies of
  # them.
  dependencies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - run: rustup show
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2

      - name: Install the gate
        # Pinned exactly, like trunk below and for the same reason: a tool that
        # floats can turn a green tree red on a morning nobody pushed.
        run: cargo install cargo-deny --locked --version 0.20.2

      - name: Advisories, bans and sources
        # `check advisories` is the CVE half. `bans` and `sources` are the
        # supply-chain half: a crate pulled from a registry nobody named is a
        # thing to hear about before it ships, not after. `--all-features`
        # because `metered` is compiled out by default and an advisory in the
        # paid half is still an advisory in the tree.
        run: cargo deny --workspace --all-features check advisories bans sources

  console:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - run: rustup show
      - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2

      - name: Install trunk
        # EXACT, not `^0.21`: a floating version means the bundle CI
        # measures against `.github/wasm-baseline` can change because a
        # tool released, which reads as a code regression and is not one.
        run: cargo install trunk --locked --version 0.21.14

      - name: Build the console
        working-directory: crates/gunnar-ui-web
        run: trunk build --release

      - name: Bundle size
        # TWO checks, because they answer different questions.
        #
        # The ratchet, against `.github/wasm-baseline`, detects a CHANGE: a
        # dependency that arrived, a page that monomorphised badly. Its fix for
        # a legitimate increase is to commit the new number, and that is fine —
        # that is what a ratchet is for.
        #
        # The CEILING is the part that is a budget. A number that is raised
        # every time it binds constrains nothing, and the baseline had been
        # raised exactly that way. So the ceiling lives here, in the workflow,
        # where raising it is a visible edit to CI rather than a one-line bump
        # in a data file — and it is on the COMPRESSED size, because
        # `gunnar-ui-server` gzips the console (`serve::console_files`) and
        # compressed bytes are what a person actually waits for.
        #
        # 800 KiB gzipped, with the reason: the console is served by the
        # appliance itself, often over a link an operator brought up minutes
        # ago, and 800 KiB is about two seconds on 4 Mbit/s. It was 640 KiB
        # from 2026-08-31 (bundle then 520 239 gzipped, 23% headroom); the
        # usage, accounts and monetize work spent that headroom by 2026-09-07
        # (651 075, 99.3%) and the mirror pages crossed it on 2026-09-08
        # (716 684). Raised once, by decision, with the numbers, on
        # 2026-09-08 — to 800 KiB: 11% of headroom over the mirror bundle,
        # a real feature's worth and not a megabyte's. Merging /mirrors and
        # /mirrors/import into one route was measured 3 268 bytes LARGER, so
        # that is not the way back down; splitting the forge picker into a
        # lazily-loaded module would be.
        run: |
          set -euo pipefail
          bundle=$(ls crates/gunnar-ui-web/dist/*_bg.wasm)
          actual=$(stat -c%s "$bundle")
          served=$(gzip -9 -c "$bundle" | wc -c)
          # The baseline file carries its own rationale as `#` comments; the
          # number is the first line that is not one.
          baseline=$(grep -v '^[[:space:]]*#' .github/wasm-baseline | grep -m1 '[0-9]')
          limit=$(( baseline * 105 / 100 ))
          ceiling=$(( 800 * 1024 ))
          printf 'bundle   %s bytes on disk (%s KiB)\n' "$actual" "$(( actual / 1024 ))"
          printf 'served   %s bytes gzipped (%s KiB)\n' "$served" "$(( served / 1024 ))"
          printf 'baseline %s bytes, ratchet limit %s bytes\n' "$baseline" "$limit"
          printf 'ceiling  %s bytes gzipped\n' "$ceiling"
          fail=0
          if [ "$served" -gt "$ceiling" ]; then
            echo "::error::the console is now $served gzipped bytes, over the" \
                 "$ceiling ceiling. Raising the baseline does not fix this and" \
                 "is not the answer: split a route, drop a dependency, or load a" \
                 "page lazily. See .github/wasm-baseline for where the bytes go."
            fail=1
          fi
          if [ "$actual" -gt "$limit" ]; then
            echo "::error::the wasm bundle grew more than 5% over the baseline." \
                 "Find out WHERE before you raise it — .github/wasm-baseline has" \
                 "the twiggy recipe — then commit $actual with what grew and why."
            fail=1
          fi
          exit "$fail"

      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: console
          path: crates/gunnar-ui-web/dist