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