Skip to content

desktop: reconcile post-check 2 — normalise EOF newline before diffin… #1125

desktop: reconcile post-check 2 — normalise EOF newline before diffin…

desktop: reconcile post-check 2 — normalise EOF newline before diffin… #1125

Workflow file for this run

# 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'