Skip to content

build(nix): single-source the Rust toolchain and rustfmt edition #569

build(nix): single-source the Rust toolchain and rustfmt edition

build(nix): single-source the Rust toolchain and rustfmt edition #569

Workflow file for this run

name: CI
on:
pull_request:
push:
branches: [main]
jobs:
# ── One cheap job computes which areas a PR touches; the heavy jobs gate on its
# outputs (`needs` + job-level `if`), so an irrelevant area's job never starts
# (no runner spun up — notably the Windows one). A skipped job doesn't satisfy a
# required status check, so the single required check is the `ci` aggregator at
# the bottom, not the individual jobs. ──
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.detect.outputs.frontend }}
rust: ${{ steps.detect.outputs.rust }}
desktop: ${{ steps.detect.outputs.desktop }}
nix: ${{ steps.detect.outputs.nix }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Detect changes
id: detect
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
else
base="${{ github.event.before }}"
head="${{ github.sha }}"
fi
# No usable base (first push, force-push, shallow clone) → run everything.
if [ -z "$base" ] || ! git cat-file -e "$base" 2>/dev/null; then
{
echo "frontend=true"
echo "rust=true"
echo "desktop=true"
echo "nix=true"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# Three-dot: diff from the merge-base, not base..head directly. When the
# branch is behind the base (main advanced after it forked — the common
# case), a two-dot diff would also list every file main changed since,
# falsely flagging areas the PR never touched. This mirrors GitHub's own
# PR diff semantics.
files=$(git diff --name-only "$base...$head")
match() { printf '%s\n' "$files" | grep -Eq "$1"; }
fe=false; rs=false; dk=false; nx=false
match '^(frontend/|package\.json|bun\.lock|biome\.json)' && fe=true
match '^(crates/|src-tauri/|Cargo\.(toml|lock)|flake\.(nix|lock))' && rs=true
match '^(crates/|src-tauri/|frontend/|Cargo\.(toml|lock))' && dk=true
match '^(nix/|flake\.(nix|lock))' && nx=true
{
echo "frontend=$fe"
echo "rust=$rs"
echo "desktop=$dk"
echo "nix=$nx"
} >> "$GITHUB_OUTPUT"
# ── Frontend: bun workspace (lockfile at the repo root), build, tests, i18n.
# Formatting/lint (biome) is covered by the `treefmt` job, not here. ──
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Install dependencies
# The bun workspace is rooted at the repo; install from the root so the
# frontend member and its deps resolve against the single bun.lock.
run: bun install --frozen-lockfile
- name: Build (tsc + vite)
working-directory: frontend
run: bun run build
- name: Unit tests
working-directory: frontend
run: bunx vitest run
- name: i18n dictionaries
run: bun run frontend/scripts/check-i18n.ts
# ── Rust workspace: clippy, tests, and generated-bindings drift, all in
# the flake devshell (carries the rust toolchain + webkit for src-tauri). ──
rust:
needs: changes
if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14
with:
extra-conf: |
accept-flake-config = true
# ── Nix store cache (cachix). Pulls the devshell closure from our public
# cache as a substituter and pushes anything newly built at the end. Reads
# need no token (public cache), so fork PRs benefit too; pushes use the
# CACHIX_AUTH_TOKEN secret (absent on forks → read-only there). Replaces the
# retired magic-nix-cache, whose GHA backend was a no-op. ──
- name: Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kasumi-proxy
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
# ── Cargo registry — dependency sources only, no compiled artifacts, so it's
# portable across toolchain bumps and keyed on Cargo.lock alone. ──
- name: Cache cargo registry
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-registry-${{ runner.os }}-
# ── Workspace build artifacts (./target). The compile of clippy --all-targets +
# test + codegen (hundreds of crates: tauri, specta, webkit bindings) is the
# bulk of this job; an empty target means a full rebuild every run. But the
# build-script binaries under target are linked against the devshell's glibc,
# so a target restored from a different flake.lock points its ELF interpreter
# at a /nix/store loader that's no longer there and fails to exec ("No such
# file or directory"). So the key AND the restore fallback are both bound to
# flake.lock: a Cargo.lock-only bump still reuses target (only the changed
# crates recompile), but a flake.lock/toolchain bump starts clean instead of
# restoring a poisoned target. The devshell sets no CARGO_HOME /
# CARGO_TARGET_DIR, so cargo uses ~/.cargo and ./target on the host. ──
- name: Cache cargo build
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: target
key: cargo-target-${{ runner.os }}-${{ hashFiles('flake.lock') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-target-${{ runner.os }}-${{ hashFiles('flake.lock') }}-
- name: Stub the libcronet bundle resource (clippy/codegen build the app)
run: |
# clippy/codegen compile kasumi-desktop, whose tauri.linux.conf.json
# declares the libcronet.so resource; tauri-build checks the path exists.
# The real lib is fetched only for the bundle — a placeholder suffices.
mkdir -p src-tauri/binaries
: > src-tauri/binaries/libcronet.so
- name: clippy + test + codegen drift
run: |
nix develop --command bash -euo pipefail -c '
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
# The generated frontend bindings/schemas/defaults must match the Rust
# source — regenerate and fail on any diff.
cargo run -p kasumi-desktop --bin codegen
git diff --exit-code -- frontend/src/generated
'
# ── Realise the desktop package derivation. The `rust` job only runs cargo in the
# devshell, never `nix build .#kasumi-desktop`, so installPhase / wrapper / icon
# staging were unchecked on PRs. Path-gated on the nix expressions. ──
nix-build:
needs: changes
if: needs.changes.outputs.nix == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14
with:
extra-conf: |
accept-flake-config = true
- name: Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kasumi-proxy
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build the desktop package
run: nix build .#kasumi-desktop --accept-flake-config --print-build-logs
# ── The formatting gate for the whole tree — the single source of truth. This
# realises the flake's own treefmt check derivation (the exact `nix fmt` config,
# no duplicated formatter list), which runs rustfmt, biome (format + lint),
# shellcheck, and the nix formatters (alejandra/statix/deadnix) over every tracked
# file. Because it covers all of them, the per-area jobs no longer re-run
# `cargo fmt` / `biome` / `shellcheck`. Ungated on purpose: it runs on every PR so
# no area can slip an unformatted file past a path filter (a Rust-only or
# scripts-only change used to skip the comprehensive check entirely). ──
treefmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14
with:
extra-conf: |
accept-flake-config = true
- name: Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kasumi-proxy
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: treefmt check (fails on any unformatted file)
run: nix build .#checks.x86_64-linux.treefmt --accept-flake-config --print-build-logs
# ── Desktop compile smoke (Linux + Windows) on the plain rustup toolchain — the
# path release/nightly actually bundle with (the nix `nix-build` job realises the
# flake package; this proves the non-nix one). Compile-only (no bundle): catches
# platform/linker breakage without the slow installer. macOS is still deferred
# (no Platform port yet). ──
desktop-linux:
needs: changes
if: needs.changes.outputs.desktop == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- name: Linux desktop deps
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \
libayatana-appindicator3-dev patchelf
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build the UI (generate_context embeds frontendDist)
run: |
bun install --frozen-lockfile
( cd frontend && bun run build )
- name: Stub the libcronet bundle resource (compile-smoke doesn't bundle)
run: |
# tauri-build validates tauri.linux.conf.json's libcronet.so resource at
# compile time; the real lib is fetched only for the bundle. Placeholder.
mkdir -p src-tauri/binaries
: > src-tauri/binaries/libcronet.so
- name: Compile the Tauri app (no bundle)
run: cargo build -p kasumi-desktop
# ── Windows desktop compile smoke. Mirrors desktop-linux: the `rust` job runs on
# Linux and never compiles the #[cfg(windows)] Platform + Win32 code, so this is
# what catches a Windows-build break in the PR instead of the next nightly.
# (The Android daemon needs no such job: it has no cfg(target_os) gates — just
# portable Rust shelling out to ip/iptables — so the `rust` workspace job already
# compiles it on every PR. Only the rarer NDK cross-link is left to nightly.) ──
desktop-windows:
needs: changes
if: needs.changes.outputs.desktop == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
targets: x86_64-pc-windows-msvc
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build the UI (generate_context embeds frontendDist)
shell: bash
run: |
bun install --frozen-lockfile
( cd frontend && bun run build )
- name: Stub the bundle resources (compile-smoke doesn't bundle)
shell: bash
run: |
# tauri-build validates declared bundle.resources at compile time. The
# real wintun.dll / libcronet.dll / msys-2.0.dll are fetched only for the
# nightly/release bundle; placeholders satisfy the path check for this
# compile-only job.
mkdir -p src-tauri/binaries
: > src-tauri/binaries/wintun.dll
: > src-tauri/binaries/libcronet.dll
: > src-tauri/binaries/msys-2.0.dll
- name: Compile the Tauri app (no bundle)
run: cargo build -p kasumi-desktop
# ── The single required status check. It always runs and aggregates the gated
# jobs above: a skipped area (its files weren't touched) is fine; only a real
# failure or cancellation fails CI. Point branch protection at THIS context so
# path-skipped jobs never block a merge. ──
ci:
needs: [changes, frontend, rust, nix-build, treefmt, desktop-linux, desktop-windows]
if: always()
runs-on: ubuntu-latest
steps:
- name: Verify no required job failed
run: |
results="${{ join(needs.*.result, ' ') }}"
echo "job results: $results"
for r in $results; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "a CI job did not pass"
exit 1
fi
done
echo "all CI jobs passed or were skipped"