diff --git a/Makefile b/Makefile index 4951e30..0121d55 100644 --- a/Makefile +++ b/Makefile @@ -23,22 +23,34 @@ ZSH_FILES := $(shell git ls-files '*.zsh' ':!:core/**') # Identical to the reusable gate's env, so a local pass means a CI pass. export SHELLCHECK_OPTS := -e SC1090 -e SC1091 -e SC2015 -e SC2088 -.PHONY: help check shell zsh actions md secrets verify-core dry-run hooks capabilities +# The canonical fleet `make` vocabulary (dotfiles-core#691): every repo that vendors +# Core answers to the SAME verbs — help, lint, check, dry-run, packages-check, +# core-verify, test — so a contributor moving between OS repos never has to relearn the +# buttons. Where this repo already had a name (verify-core), the historical spelling is +# kept as a .PHONY alias rather than deleted. See VENDORING.md, "The `make` vocabulary, +# and the test floor", in Core. +.PHONY: help lint check shell zsh actions md secrets packages-check core-verify verify-core dry-run test hooks capabilities help: @echo 'dotfiles-Alpine — local gates (mirror of CI)' @echo '' - @echo ' make check every gate below, in order' - @echo ' make shell shellcheck + bash -n on repo-owned *.sh' - @echo ' make zsh zsh -n on repo-owned *.zsh (incl. zsh/zshenv.zsh)' - @echo ' make actions actionlint on .github/workflows' - @echo ' make md markdownlint-cli2 on tracked markdown' - @echo ' make secrets gitleaks over the working tree' - @echo ' make verify-core is the vendored core/ still pristine vs core.lock?' - @echo ' make dry-run preview the bootstrap wiring; change nothing' - @echo ' make hooks install the pre-commit hooks' - -check: shell zsh actions md secrets capabilities + @echo ' make lint shellcheck + zsh -n + actionlint + markdown + secrets (== CI lint gate)' + @echo ' make check lint + the capability-schema gate; the full local sweep' + @echo ' make test run the test/ suite (currently: packages-check)' + @echo ' make packages-check do all install/packages.txt names resolve on this Alpine branch?' + @echo ' make dry-run preview the bootstrap wiring; change nothing' + @echo ' make core-verify is the vendored core/ still pristine vs core.lock?' + @echo '' + @echo ' individual lint legs: shell, zsh, actions, md, secrets, capabilities' + @echo ' make hooks install the pre-commit hooks' + +# The reusable CI gate's legs, in one word. lint.yml calls dotfiles-core's +# lint-call.yml, which runs exactly shell + zsh + actionlint + markdown + gitleaks over +# the repo-owned tree; a green `make lint` here means a green lint check on the PR. +lint: shell zsh actions md secrets + @echo '✓ lint clean' + +check: lint capabilities @echo '✓ all local gates passed' shell: @@ -93,13 +105,36 @@ secrets: # produce a lock that disagrees with the fleet. The header is an upstream doc bug. CORE_REPO ?= ../dotfiles-core -verify-core: +core-verify: @[ -x "$(CORE_REPO)/scripts/core-integrity.sh" ] || { \ echo '- no dotfiles-core checkout at $(CORE_REPO) — SKIP'; \ - echo ' (clone it beside this repo, or: make verify-core CORE_REPO=/path/to/dotfiles-core)'; \ + echo ' (clone it beside this repo, or: make core-verify CORE_REPO=/path/to/dotfiles-core)'; \ exit 0; }; \ "$(CORE_REPO)/scripts/core-integrity.sh" --self "$(CURDIR)" +# Historical spelling, kept so `make verify-core` still works. The canonical fleet verb +# is core-verify (dotfiles-core#691); this is a one-line alias, not a second copy. +verify-core: core-verify + +# Does every apk name in install/packages.txt still resolve on this branch? Installs +# nothing — see the header of the script for why `apk add --simulate` is the right probe. +# This is the smallest useful member of the test/ suite below. +packages-check: + @./test/check-packages.sh install/packages.txt + +# The fleet test floor (dotfiles-core#691): a real suite under test/, run here. Every +# executable script in test/ runs; today that is just check-packages.sh, but new checks +# drop in without touching this target. `test` is a canonical verb with no stub form — +# a `test:` that ran nothing would read as no-op in Core's register. +test: + @rc=0; found=0; \ + for t in test/*.sh; do \ + [ -e "$$t" ] || continue; found=1; \ + echo ":: $$t"; "$$t" || rc=1; \ + done; \ + if [ "$$found" -eq 0 ]; then echo '!! test/ has no *.sh — the fleet test floor requires at least one'; rc=1; fi; \ + [ $$rc -eq 0 ] && echo '✓ test suite passed'; exit $$rc + dry-run: @./bootstrap.sh --dry-run diff --git a/test/check-packages.sh b/test/check-packages.sh new file mode 100755 index 0000000..96df732 --- /dev/null +++ b/test/check-packages.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# test/check-packages.sh +# ────────────────────────────────────────────────────────────────────────────── +# Does every package name in install/packages.txt still RESOLVE on this Alpine +# branch — WITHOUT installing anything? +# +# bootstrap.sh's apk_install() is deliberately forgiving: a bulk `apk add` that fails +# retries package-by-package and prints "skipped (unavailable on this box?)" for each +# casualty. That resilience is right for a live box — one dead name +# should not sink the whole install — but it means a typo, a rename, or a package that +# moved out of `community` is easy to miss: the run is noisy, never fatal, and reads as +# success. This turns that drift into a gate. It installs NOTHING. +# +# RESOLUTION, via `apk add --simulate`, NOT `apk search` or `apk policy`: +# • `apk policy ` exits 0 and prints an empty policy — useless as a gate. +# • `apk search -e -x ` matches on the index's NAME field only, so it misses +# single-provider virtuals and `provides=` names (e.g. openssh-client-default, +# yq-go) that `apk add` resolves perfectly happily — a false "missing". +# • `apk add --simulate` runs apk's REAL resolver without touching the system, so it +# agrees with what `apk add` would actually do: real packages and single-provider +# virtuals resolve, unknown names error. It is the Alpine analogue of Debian's +# `apt-get install -s`. +# +# There is deliberately NO version-floor check here (unlike the Debian sibling). Alpine +# carries no `# min:X.Y.Z` floors in install/packages.txt: the one floor that matters, +# tree-sitter-cli's, lives in bootstrap.sh as TREESITTER_FLOOR and is enforced there by +# a version guard, precisely because apk resolves the name on every branch but only +# clears the floor on some (see install/packages.txt). Resolution is the whole check. +# +# RUN IT WHERE THE ANSWER IS TRUE. Availability is a property of the apk repositories on +# the box, so v3.21 and edge disagree by design (gron, yazi and friends landed in +# `community` on different branches — see install/packages.txt). Locally this is a smoke +# test against whatever branch you track; the authoritative run is on a pinned Alpine. +# +# Exit codes: +# 0 every name resolves (or a clean skip: no apk on this host) +# 1 usage/environment failure +# 2 one or more names did NOT resolve — the drift signal +# +# Usage: +# test/check-packages.sh # install/packages.txt +# test/check-packages.sh install/packages.txt +# ────────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +# `set -e` is deliberately off here (the exit code IS the result), so guard the cd +# explicitly — continuing in the wrong directory would read the wrong manifest. +cd -- "$REPO_ROOT" || exit 1 + +if [[ -r core/lib/ux.sh ]]; then + # shellcheck source=core/lib/ux.sh + source core/lib/ux.sh +fi +say() { printf '%s::%s %s\n' "${UX_BLU:-}" "${UX_RST:-}" "$*"; } +ok() { printf '%s%s%s %s\n' "${UX_GRN:-}" "${UX_OK:-+}" "${UX_RST:-}" "$*"; } +bad() { printf '%s%s%s %s\n' "${UX_YEL:-}" "${UX_WARN:-!}" "${UX_RST:-}" "$*" >&2; } + +command -v apk >/dev/null 2>&1 || { + say "no apk on this host — skipping (run this on Alpine, or in CI)." + exit 0 +} + +manifest="${1:-install/packages.txt}" +[[ -f "$manifest" ]] || { bad "manifest not found: $manifest"; exit 1; } + +# Reuse Core's parser rather than re-implementing the comment/whitespace rules: it is +# the SAME function bootstrap.sh feeds apk, so this checks exactly the names that would +# really be installed, including inline-comment stripping. +if [[ -r core/lib/bootstrap-lib.sh ]]; then + # shellcheck source=core/lib/bootstrap-lib.sh + source core/lib/bootstrap-lib.sh +else + bad "core/lib/bootstrap-lib.sh not found — is the core/ subtree vendored?" + exit 1 +fi + +# Name the branch so a local run's answer is interpretable. +branch="$(sed -n 's/^VERSION_ID=//p' /etc/os-release 2>/dev/null | head -1 | tr -d "\"'")" +say "Alpine branch in view: ${branch:-unknown}" + +mapfile -t pkgs < <(blib_read_pkgs "$manifest") +((${#pkgs[@]})) || { bad "$manifest parsed to zero package names"; exit 1; } +say "$manifest — ${#pkgs[@]} names" + +# apk resolves against the cached index; a box that never ran `apk update` has none. +if [[ -z "$(ls -A /var/cache/apk 2>/dev/null)" && ! -s /lib/apk/db/installed ]]; then + say "apk index looks empty — running apk update first" + apk update >/dev/null 2>&1 || bad "apk update failed; results may be wrong" +fi + +# Privilege: mirror bootstrap.sh's selection — root uses nothing, else doas (Alpine's +# default), else sudo. But --simulate WRITES nothing and on a normal box apk's db is +# world-readable, so the unprivileged call is the fast common path (it is why this gate +# runs green as a plain user). We escalate through $SU only when apk cannot OPEN its own +# lock/db — never gratuitously, which would make the gate prompt for a doas password it +# does not need. +if [[ "$(id -u)" -eq 0 ]]; then SU="" +elif command -v doas >/dev/null 2>&1; then SU="doas" +elif command -v sudo >/dev/null 2>&1; then SU="sudo" +else SU=""; fi + +# A lock/permission error is apk failing to read its OWN database — an environment +# problem (unprivileged with no working escalator, a held lock), NOT a bad package name. +# Misreporting it as drift (exit 2) is exactly the false failure this must avoid. +env_failure() { printf '%s' "$1" | grep -qiE 'permission denied|unable to lock|failed to open apk database|could not (open|read)'; } + +# apk's real resolver, run without root first; on a lock/permission error, retry once +# under $SU (if any). Prints the final combined output and returns apk's status. +sim() { + local out rc + out="$(apk add --simulate --quiet "$@" 2>&1)"; rc=$? + if ((rc != 0)) && [[ -n "$SU" ]] && env_failure "$out"; then + out="$($SU apk add --simulate --quiet "$@" 2>&1)"; rc=$? + fi + printf '%s' "$out" + return "$rc" +} + +# Turn a lock/permission failure into a clean env exit (1), the way a missing manifest +# or empty parse already does — distinct from the drift exit (2) below. +bail_env() { + bad "apk could not open its database (lock/permission), even under '${SU:-root}' — this" + bad "is an ENVIRONMENT failure, not package drift. Run as root, or configure doas/sudo:" + printf '%s\n' "$1" | grep -iE 'ERROR|denied' | head -3 | sed 's/^/ /' >&2 + exit 1 +} + +# ── resolution ──────────────────────────────────────────────────────────────── +# Bulk first, then per-name — the same bulk-then-retry shape as bootstrap.sh's +# apk_install, and for the same reason. The bulk pass proves something no per-name +# probe can: that the whole set is CO-INSTALLABLE (no two names conflict). +missing=() +# Capture output and status in separate statements: `out=$(...)` does set $? to the +# command's status, but that is easy to break with any later edit that inserts a +# statement between the two. Assign, then read $? on its own line. +bulk_out="$(sim "${pkgs[@]}")"; bulk_rc=$? +if ((bulk_rc == 0)); then + ok "all ${#pkgs[@]} names resolve, and the set is co-installable." +elif env_failure "$bulk_out"; then + bail_env "$bulk_out" +else + bad "the bulk resolve failed — narrowing down per package" + for p in "${pkgs[@]}"; do + out="$(sim "$p")" && continue + env_failure "$out" && bail_env "$out" + case "$out" in + *"no such package"*) missing+=("$p — absent from ${branch:-this branch}") ;; + *"unable to select packages"*) missing+=("$p — unsatisfiable (conflict or missing dependency)") ;; + *) missing+=("$p — $(printf '%s' "$out" | grep -iE 'ERROR' | head -1)") ;; + esac + done +fi + +echo +if ((${#missing[@]})); then + bad "${#missing[@]} package name(s) did NOT resolve against ${branch:-this branch}:" + printf ' %s\n' "${missing[@]}" >&2 + cat >&2 <<'EOF' + +A non-resolving name is one of: + • a rename — find the new name and update install/packages.txt + • a drop — remove it, or build it from source in bootstrap.sh (as duf/glow are) + • a typo — fix it + • branch drift — real in `community` on one branch, absent on another (gron, yazi, …) + +apk_install (bootstrap.sh) skips an unresolvable name per-package rather than aborting, +so a box still provisions — but the tool it names silently never arrives. Fix the list. +EOF + exit 2 +fi + +ok "all ${#pkgs[@]} names resolve on ${branch:-this branch}." +exit 0