cli: install herdr fleet-wide on the terminal-ANSI bus (#814) (#880) #1127
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Per-host build + lint/format gate (nix flake check). The explicit job | |
| # `name:` is load-bearing — keep it keyed on matrix.arch alone, or the | |
| # auto-generated check names stop matching branch protection on main. | |
| # Per-knob rationale for this workflow: docs/ci.md. Framework decision: | |
| # docs/decisions/ADR-025-ci-in-flake.md. | |
| name: flake-check | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| concurrency: | |
| # PR runs: group by branch, cancel stale. Main runs: group by run_id, | |
| # never cancel (one in-flight per push is the worst case anyway). | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| # Permissions pinned to the minimum (whitelist > blanket). actions: write | |
| # is what lets cache-nix-action's DELETE succeed on the push-to-main run, | |
| # so main's resave lands; PR runs restore only. See docs/ci.md §Permissions. | |
| permissions: | |
| contents: read | |
| actions: write | |
| jobs: | |
| flake-check: | |
| name: flake-check (${{ matrix.arch }}) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: [x86_64-linux, aarch64-darwin] | |
| include: | |
| - arch: x86_64-linux | |
| runner: ubuntu-24.04 | |
| # Darwin runner pinned to macos-15 (current Apple-Silicon line, | |
| # billed-free on public repos; -large/-xlarge/-intel are not, | |
| # and *-latest moves under us). nix flake check realises the | |
| # celaeno derivation but runs no nix-darwin activation, so CI | |
| # is side-effect-free. See docs/ci.md §Runners. | |
| - arch: aarch64-darwin | |
| runner: macos-15 | |
| runs-on: ${{ matrix.runner }} | |
| # Wall-time backstop, generous over the cold-cache worst case (a | |
| # lockfile bump can force a ~30-40 min Darwin closure rebuild, per | |
| # ADR-025 §History) plus the 3× retry, but far short of the 360 min | |
| # default — a hung leg fails fast instead of burning a runner-hour. | |
| # See docs/ci.md §Timeouts. | |
| timeout-minutes: 60 | |
| steps: | |
| # fetch-depth: 0 fetches with the all-heads refspec, which is what puts | |
| # `origin/<base>` in the clone for the `changed` step's diff basis. | |
| # See docs/ci.md §"Docs-only short-circuit". | |
| - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 | |
| with: | |
| fetch-depth: 0 | |
| # Docs-only short-circuit: a whitelist of paths that cannot affect any | |
| # host closure; any unmatched path forces the full run (fail-safe | |
| # toward building). See docs/ci.md §"Docs-only short-circuit". | |
| - name: classify changed paths | |
| id: changed | |
| if: github.event_name == 'pull_request' | |
| run: | | |
| # Diff basis is the base branch's current tip (origin/<base>), not the | |
| # PR's frozen base.sha — a stale base.sha folds main's newer commits | |
| # into the diff. See docs/ci.md §"Docs-only short-circuit". | |
| # --no-renames: rename detection reports only the destination, so | |
| # `git mv code.nix docs/x.md` would otherwise class as docs-only. | |
| files=$(git diff --no-renames --name-only "origin/$GITHUB_BASE_REF"...HEAD || true) | |
| skippable='^(docs/.*\.md|[^/]*\.md|\.github/[^/]*\.md|LICENSE)$' | |
| # Deliberately never skippable: the design-note lint reads the | |
| # template as its prompt source, so editing it changes a gate. | |
| never='^docs/design/_template\.md$' | |
| code=true | |
| if [ -n "$files" ] && | |
| ! grep -qE "$never" <<<"$files" && | |
| ! grep -qvE "$skippable" <<<"$files"; then | |
| code=false | |
| fi | |
| echo "code=$code" >>"$GITHUB_OUTPUT" | |
| - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31 | |
| with: | |
| # GH token for Nix's github.com fetches. Every input is public, | |
| # so this buys the authenticated rate limit, not authorisation — | |
| # the repo-scoped default token is enough. See docs/ci.md | |
| # §GitHub fetch token. | |
| github_access_token: ${{ secrets.GITHUB_TOKEN }} | |
| # accept-flake-config = false carries the whitelist stance into | |
| # CI (no transitive input can add a substituter). No niri | |
| # substituter is needed since #763: niri comes from nixpkgs, so | |
| # cache.nixos.org serves it. See docs/ci.md §Substituters. | |
| extra_nix_config: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = false | |
| # Build-output cache (actions/cache-shaped storage, NOT a | |
| # substituter) to amortise cold-run rebuilds of the desktop closure. | |
| # Why cache-nix-action over Cachix/Attic, the cache-key semantics, | |
| # and the save-time GC sweep: docs/ci.md §Cache. | |
| # | |
| # Skipped on the docs-only path as a cost saving: a full-store restore | |
| # plus a GC pass on a leg that cannot save anything useful anyway (a PR | |
| # run's save no-ops — see docs/ci.md §Cache). | |
| - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 | |
| if: steps.changed.outputs.code != 'false' | |
| with: | |
| primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.lock') }} | |
| restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}- | |
| gc-max-store-size-linux: 5G | |
| # 8G holds the ~8.0 GiB Darwin closure with headroom; a lower | |
| # ceiling forces save-time eviction and warm-cache re-fetch. | |
| # Nothing on this leg is rooted, so the ceiling is still purely an | |
| # eviction dial. See docs/ci.md §"Cache sweep". | |
| gc-max-store-size-macos: 8G | |
| purge: true | |
| purge-prefixes: nix-${{ runner.os }}-${{ runner.arch }}- | |
| purge-created: 0 # no created-age gate; last-accessed is the real filter | |
| purge-last-accessed: 604800 | |
| # always: on push-to-main, without it the primary-key hit makes the | |
| # resave a no-op and a stale cache is cemented. Inert on PR runs — | |
| # the DELETE is ref-scoped. See docs/ci.md §Cache. | |
| purge-primary-key: always | |
| # Bounded blind retry (3x, 30s backoff): fetchGit eval-fetches | |
| # (e.g. niri's pipewire-rs) aren't covered by Nix's download-attempts, | |
| # so a transient forge blip would red main. Blind, not regex-gated, | |
| # per ADR-032 Rule 1. See docs/ci.md §Retry. | |
| - name: nix flake check | |
| env: | |
| # Empty unless the `changed` step ran (push: main never sets it), | |
| # which falls through to the full check. See docs/ci.md | |
| # §"Docs-only short-circuit". | |
| CODE: ${{ steps.changed.outputs.code }} | |
| run: | | |
| run_checks() { | |
| if [ "$CODE" = "false" ]; then | |
| # No `nix flake check --no-build` pre-step: host eval reads a | |
| # base16-schemes store path (Stylix's palette engine) that this | |
| # cold-store leg must realise. See docs/ci.md §Docs-only. | |
| nix build --no-link --print-build-logs '.#checks-without-hosts' | |
| else | |
| nix flake check --print-build-logs | |
| fi | |
| } | |
| attempts=3 | |
| delay=30 | |
| for attempt in $(seq 1 "$attempts"); do | |
| echo "::group::nix flake check (attempt $attempt/$attempts)" | |
| if run_checks 2>&1; then | |
| echo "::endgroup::" | |
| exit 0 | |
| fi | |
| echo "::endgroup::" | |
| if [ "$attempt" -lt "$attempts" ]; then | |
| echo "::warning::nix flake check failed on attempt $attempt; retrying in ${delay}s" | |
| sleep "$delay" | |
| continue | |
| fi | |
| echo "::error::nix flake check failed after $attempts attempts" | |
| exit 1 | |
| done | |
| exit 1 | |
| # Root the packages no substituter can supply, before the cache step's | |
| # post-phase GC. `nix flake check` leaves no GC roots, so `nix store gc` | |
| # deletes in directory-read order and has been discarding noctalia — a | |
| # ~23 min C++ build with no binary cache anywhere — from the x86_64 | |
| # entry on every save (#712). Everything else in the store is cheaper to | |
| # re-substitute than to keep, so it is deliberately left unrooted and the | |
| # ceilings above are unchanged. The two legs with nothing to root build | |
| # an empty farm, which keeps this one command under one condition. | |
| # | |
| # The `if:` must stay byte-identical to the cache step's. Diverging is | |
| # not an abstract hazard: on the docs-only path `nix flake check` never | |
| # realises noctalia, so this step running there would *build* it — 23 | |
| # minutes on a leg whose whole point is being cheap. | |
| # See docs/ci.md §"Cache sweep". | |
| - name: root the unsubstitutable outputs | |
| if: steps.changed.outputs.code != 'false' | |
| run: nix build --out-link "$RUNNER_TEMP/ci-gc-root" '.#ci-gc-root' |