#!/usr/bin/env bash # Validation entry point for CI and local/agent review. # # Default mode (what CI runs on every push/PR): fmt-check, statix, and eval # are scoped to files that actually changed against a base ref, plus # whichever hosts/packages those changes can affect. This exists because # the unscoped sweep below is slow enough to time out CI runners -- see # --full-check. # # --full-check: the historical full sweep (every host, every package, # fmt --check ./statix check . over the whole tree). Slow -- minutes, not # seconds. CI never passes this; run it locally before a release or after # touching modules/common/*, flake.nix, or variables.nix if you want extra # confidence beyond what the changed-files scope already covers for those # paths (see below). # # --dry-run: adds `nix build --dry-run --no-link` for whatever scope is # active (changed-files scope by default, full scope under --full-check). set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/nix-bootstrap.sh source "${script_dir}/lib/nix-bootstrap.sh" # shellcheck source=lib/nix-eval.sh source "${script_dir}/lib/nix-eval.sh" repo_root="$(cd "${script_dir}/.." && pwd)" cd "$repo_root" full_check=false dry_run=false usage() { cat <<'EOF' Usage: scripts/codex-maintenance.sh [--full-check] [--dry-run] --full-check Run the full sweep: fmt-check and statix over the whole repo, eval every host and package. Slow. Never run by CI. --dry-run Additionally run `nix build --dry-run --no-link` for whatever scope is active. With neither flag (the CI default), fmt-check/statix/eval are scoped to files changed against a base ref (env MAINT_BASE_SHA, else the PR base, else HEAD^), plus the hosts/packages those changes can affect. EOF } for arg in "$@"; do case "$arg" in --full-check) full_check=true ;; --dry-run) dry_run=true ;; -h|--help) usage; exit 0 ;; *) echo "Unknown argument: $arg" >&2 usage >&2 exit 1 ;; esac done ensure_nix_profile if ! command -v nix >/dev/null 2>&1; then echo "ERROR: nix is not available in PATH. Run bash scripts/codex-setup.sh first." >&2 exit 127 fi echo "Checking for obvious committed secrets..." if grep -RInE 'github_pat_|ghp_|access-tokens|hashedPassword[[:space:]]*=' \ --exclude-dir=.git \ --exclude=flake.lock \ .; then echo echo "WARNING: Potential secrets or password hashes found. Review before committing." else echo "No obvious token patterns found." fi mapfile -t all_hosts < <(list_flake_targets .) mapfile -t all_packages < <(nix eval --json "${NIX_EVAL_FLAGS[@]}" .#packages.x86_64-linux --apply builtins.attrNames | jq -r '.[]') # host_targets_for_dir # Prints the nixosConfigurations target names whose hostPath is # ./hosts//host.nix, derived straight from flake.nix's generatedTargets # (one mkTarget { ... } call per line) rather than a hand-maintained table, # so it can't drift the way a copied mapping would. host_targets_for_dir() { local dir="$1" grep -oE '^[[:space:]]*[A-Za-z0-9_-]+ = mkTarget \{[^}]*hostPath = \./hosts/'"${dir}"'/host\.nix;[^}]*\};' flake.nix \ | sed -E 's/^[[:space:]]*([A-Za-z0-9_-]+) = mkTarget.*/\1/' \ || true } declare -a changed_files=() scope_desc="full repo" if ! $full_check; then resolve_base_ref() { if [[ -n "${MAINT_BASE_SHA:-}" ]] && git cat-file -e "${MAINT_BASE_SHA}^{commit}" 2>/dev/null; then echo "$MAINT_BASE_SHA" return fi if git rev-parse --verify -q HEAD^ >/dev/null 2>&1; then echo "HEAD^" return fi git hash-object -t tree /dev/null } base_ref="$(resolve_base_ref)" echo echo "Changed-files scope: diffing against ${base_ref}" mapfile -t changed_files < <(git diff --name-only --diff-filter=ACMR "$base_ref" -- . | sort -u) if [[ ${#changed_files[@]} -eq 0 ]]; then echo "No changed files detected." else printf ' %s\n' "${changed_files[@]}" fi scope_desc="changed files only (base: ${base_ref})" fi # Whole-tree fmt/lint always run under --full-check; otherwise scoped below. declare -a changed_nix_files=() for f in "${changed_files[@]:-}"; do [[ "$f" == *.nix && -f "$f" ]] && changed_nix_files+=("$f") done echo echo "Checking Nix formatting with nixpkgs-fmt..." if $full_check; then nix run "${NIX_EVAL_FLAGS[@]}" github:NixOS/nixpkgs/nixos-25.11#nixpkgs-fmt -- --check . elif [[ ${#changed_nix_files[@]} -gt 0 ]]; then nix run "${NIX_EVAL_FLAGS[@]}" github:NixOS/nixpkgs/nixos-25.11#nixpkgs-fmt -- --check "${changed_nix_files[@]}" else echo "No changed .nix files; skipping." fi echo echo "Running statix lint..." if $full_check; then nix run "${NIX_EVAL_FLAGS[@]}" github:NixOS/nixpkgs/nixos-25.11#statix -- check . elif [[ ${#changed_nix_files[@]} -gt 0 ]]; then for f in "${changed_nix_files[@]}"; do nix run "${NIX_EVAL_FLAGS[@]}" github:NixOS/nixpkgs/nixos-25.11#statix -- check "$f" done else echo "No changed .nix files; skipping." fi # Figure out which hosts/packages this run needs to eval (and, under # --dry-run, build). full_check always means "everything"; otherwise a # change to flake.nix/flake.lock/variables.nix/modules/common/* (repo-wide # inputs) or to any other modules/*.nix outside platforms//build-types # (whose blast radius isn't safely inferable from the path alone -- see # CLAUDE.md's "Grep modules/build-types/*.nix for each build type's imports # list") also falls back to everything, on the same reasoning CLAUDE.md # already gives interactive sessions for when to run the full sweep. # Anything more targeted -- a host.nix, a platform module, a build-type # module -- narrows to just the hosts it can affect. declare -A affected_hosts=() eval_packages=false if $full_check; then for h in "${all_hosts[@]}"; do affected_hosts[$h]=1; done eval_packages=true else full_fallback=false for f in "${changed_files[@]:-}"; do case "$f" in flake.nix|flake.lock|variables.nix|modules/common/*) full_fallback=true ;; esac done if ! $full_fallback; then for f in "${changed_files[@]:-}"; do case "$f" in hosts/*/*) hostdir="${f#hosts/}" hostdir="${hostdir%%/*}" while IFS= read -r t; do [[ -n "$t" ]] && affected_hosts[$t]=1 done < <(host_targets_for_dir "$hostdir") ;; modules/platforms/*.nix) platform="$(basename "$f" .nix)" for h in "${all_hosts[@]}"; do [[ "$h" == "${platform}-"* ]] && affected_hosts[$h]=1 done ;; modules/build-types/*.nix) buildtype="$(basename "$f" .nix)" for h in "${all_hosts[@]}"; do [[ "$h" == *"-${buildtype}" ]] && affected_hosts[$h]=1 done ;; modules/installer/*) # iso.nix (imported by both the "installer" nixosConfigurations # target and netbootSystem, which backs packages.pxe) pulls in # common.nix, so a common.nix change reaches all three. affected_hosts[installer]=1 eval_packages=true ;; modules/pxe-boot/*) # stage-installer-artifacts.nix is imported by # modules/build-types/pxe-boot.nix only -- same blast radius as a # build-types/*.nix change, not a packages one. for h in "${all_hosts[@]}"; do [[ "$h" == *"-pxe-boot" ]] && affected_hosts[$h]=1 done ;; modules/*) full_fallback=true ;; esac done fi if $full_fallback; then echo echo "Changed files affect shared config; falling back to evaluating every host/package." for h in "${all_hosts[@]}"; do affected_hosts[$h]=1; done eval_packages=true fi fi mapfile -t hosts < <(for h in "${!affected_hosts[@]}"; do echo "$h"; done | sort) echo echo "Checking nix-cache host key for drift..." if bash "${script_dir}/secrets/sync-nix-cache-host-key.sh" --check; then : else drift_status=$? if [[ "$drift_status" -eq 2 ]]; then echo "nix-cache unreachable from here -- skipping host-key drift check." else echo "WARNING: nix-cache's host key has drifted from variables.nix (see above)." >&2 echo " Run 'bash scripts/secrets/sync-nix-cache-host-key.sh' to fix." >&2 fi fi echo if [[ ${#hosts[@]} -eq 0 ]]; then echo "No hosts affected by changed files; skipping host eval." else echo "Evaluating host toplevel derivations (${scope_desc})..." for host in "${hosts[@]}"; do echo "==> $host" nix eval --raw "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.toplevel.drvPath" # lxc-* hosts deploy via a directly pct-restore-able tarball instead of # nixos-install (see docs/auto-installer.md); proxmox-* hosts can # alternatively be built as a standalone disk image (see # docs/proxmox-images.md). Both are otherwise-unvalidated buildable # surface, easy to silently break without this. case "$host" in lxc-*) echo "==> $host (tarball)" nix eval --raw "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.tarball.drvPath" ;; proxmox-*) echo "==> $host (diskoImagesScript)" nix eval --raw "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.diskoImagesScript.drvPath" ;; esac done fi echo if ! $eval_packages; then echo "No packages affected by changed files; skipping package eval." else echo "Evaluating buildable packages..." for pkg in "${all_packages[@]}"; do echo "==> packages.x86_64-linux.${pkg}" nix eval --raw "${NIX_EVAL_FLAGS[@]}" ".#packages.x86_64-linux.${pkg}" done fi if $dry_run; then echo echo "Running dry-run builds for the active scope. This will not create result symlinks." for host in "${hosts[@]:-}"; do echo "==> Dry-run build: $host" nix build --dry-run --no-link "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.toplevel" case "$host" in lxc-*) echo "==> Dry-run build: $host (tarball)" nix build --dry-run --no-link "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.tarball" ;; proxmox-*) echo "==> Dry-run build: $host (diskoImagesScript)" nix build --dry-run --no-link "${NIX_EVAL_FLAGS[@]}" ".#nixosConfigurations.${host}.config.system.build.diskoImagesScript" ;; esac done if $eval_packages; then echo echo "Running dry-run builds for packages." for pkg in "${all_packages[@]}"; do echo "==> Dry-run build: packages.x86_64-linux.${pkg}" nix build --dry-run --no-link "${NIX_EVAL_FLAGS[@]}" ".#packages.x86_64-linux.${pkg}" done fi fi echo echo "Maintenance checks complete."