diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23dce4d4..06d354f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,9 @@ jobs: - name: Check tracked files use portable line endings run: bash tests/release/tracked-eol.test.sh + - name: Check ShellCheck covers every tracked shell file + run: bash tests/release/shellcheck-coverage.test.sh + - name: Check systemd directory modes match tmpfiles run: bash tests/release/systemd-directory-modes.test.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72217af8..d547384f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,6 +30,7 @@ on: - "tests/release/**" - "scripts/**" - "assets/demo/**" + - ".githooks/**" - "apps/sysknife-cli/**" # scripts-lint builds sysknife-cli, which depends on every crate under # crates/. Without this the job could not be triggered by a change to the @@ -46,6 +47,7 @@ on: - "tests/release/**" - "scripts/**" - "assets/demo/**" + - ".githooks/**" - "apps/sysknife-cli/**" # scripts-lint builds sysknife-cli, which depends on every crate under # crates/. Without this the job could not be triggered by a change to the @@ -76,9 +78,11 @@ jobs: - name: ShellCheck maintained scripts run: | - find tests/e2e tests/release scripts assets/demo \ - -type f -name '*.sh' -print0 \ - | xargs -0 shellcheck --severity=warning + file_list="$(mktemp)" + trap 'rm -f "$file_list"' EXIT + scripts/shellcheck-files.sh >"$file_list" + mapfile -d '' files <"$file_list" + shellcheck --severity=warning "${files[@]}" - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 0ec09919..4f60253e 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -264,9 +264,11 @@ hygiene_yamllint() ( # since every script this task adds/touches must stay shellcheck-clean. hygiene_shellcheck() ( cd "$repo_root" || exit 1 - find tests/e2e tests/release scripts assets/demo \ - -type f -name '*.sh' -print0 \ - | xargs -0 shellcheck --severity=warning + file_list="$(mktemp)" + trap 'rm -f "$file_list"' EXIT + scripts/shellcheck-files.sh >"$file_list" || exit 1 + mapfile -d '' files <"$file_list" + shellcheck --severity=warning "${files[@]}" ) run_shell_tests() { diff --git a/scripts/shellcheck-files.sh b/scripts/shellcheck-files.sh new file mode 100755 index 00000000..44140b8b --- /dev/null +++ b/scripts/shellcheck-files.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +default_roots=(tests/e2e tests/release scripts assets/demo .githooks) + +if [[ "${1:-}" == "--print-roots" ]]; then + printf '%s\0' "${default_roots[@]}" + exit 0 +fi + +if (($# > 0)); then + roots=("$@") +else + roots=("${default_roots[@]}") +fi + +for root in "${roots[@]}"; do + [[ -d "$root" ]] || { + printf 'shellcheck-files: missing search root: %s\n' "$root" >&2 + exit 1 + } +done + +all_files="$(mktemp)" +trap 'rm -f "$all_files"' EXIT +find "${roots[@]}" -type f -print0 >"$all_files" + +files=() +while IFS= read -r -d '' file; do + first='' + IFS= read -r first <"$file" || true + if [[ "$first" == '#!'* && ("$first" == *bash* || "$first" == */sh) ]]; then + files+=("$file") + fi +done <"$all_files" + +((${#files[@]} > 0)) || { + printf 'shellcheck-files: no shell files found\n' >&2 + exit 1 +} + +printf '%s\0' "${files[@]}" diff --git a/tests/release/shellcheck-coverage.test.sh b/tests/release/shellcheck-coverage.test.sh new file mode 100755 index 00000000..bce0ab35 --- /dev/null +++ b/tests/release/shellcheck-coverage.test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +scripts/shellcheck-files.sh >"$tmp/scanned" +mapfile -d '' scanned <"$tmp/scanned" +((${#scanned[@]} > 150)) || fail "shellcheck scan covers only ${#scanned[@]} files" + +declare -A scanned_set=() +for file in "${scanned[@]}"; do + scanned_set["$file"]=1 +done + +missing=() +tracked_shell=0 +git ls-files -z >"$tmp/tracked" +while IFS= read -r -d '' file; do + [[ -f "$file" ]] || continue + first='' + IFS= read -r first <"$file" || true + is_shell=false + if [[ "$file" == *.sh ]]; then + is_shell=true + elif [[ "$first" == '#!'* && ("$first" == *bash* || "$first" == */sh) ]]; then + is_shell=true + fi + "$is_shell" || continue + tracked_shell=$((tracked_shell + 1)) + [[ -n "${scanned_set[$file]:-}" ]] || missing+=("$file") +done <"$tmp/tracked" + +((tracked_shell > 150)) || fail "tracked shell-file floor collapsed to $tracked_shell" +((${#missing[@]} == 0)) || fail "tracked shell files missing from ShellCheck scan: ${missing[*]}" +[[ -n "${scanned_set[.githooks/pre-commit]:-}" ]] || fail '.githooks/pre-commit is not ShellCheck-covered' +[[ -n "${scanned_set[.githooks/pre-push]:-}" ]] || fail '.githooks/pre-push is not ShellCheck-covered' + +scripts/shellcheck-files.sh --print-roots >"$tmp/roots" +roots_seen=0 +while IFS= read -r -d '' root; do + roots_seen=$((roots_seen + 1)) + trigger="$root/**" + trigger_count="$(grep -F -c -- "- \"$trigger\"" .github/workflows/e2e.yml || true)" + ((trigger_count == 2)) || fail "ShellCheck root $root must trigger both push and pull_request e2e jobs" +done <"$tmp/roots" +((roots_seen > 0)) || fail 'shellcheck-files.sh --print-roots produced no roots' + +grep -Fq 'scripts/shellcheck-files.sh >"$file_list"' scripts/ci-local.sh || fail 'ci-local shellcheck must use scripts/shellcheck-files.sh' +if grep -Fq -- "-name '*.sh'" scripts/ci-local.sh; then + fail "ci-local shellcheck must not maintain its own '*.sh' find" +fi + +if scripts/shellcheck-files.sh "$tmp/missing-root" >"$tmp/out" 2>"$tmp/err"; then + fail 'missing search root was accepted' +fi +grep -Fq 'missing search root:' "$tmp/err" || fail 'missing-root failure did not name the missing root' + +mkdir "$tmp/empty-root" +if scripts/shellcheck-files.sh "$tmp/empty-root" >"$tmp/out" 2>"$tmp/err"; then + fail 'empty search root was accepted' +fi +grep -Fq 'no shell files found' "$tmp/err" || fail 'empty-root failure did not explain the zero-file scan' + +printf 'ok: ShellCheck covers %d tracked shell files and refuses missing/empty roots\n' "$tracked_shell"