#!/usr/bin/env bash # Shellcheck + syntax-check every shell script in this repo. Severity: error. # # SINGLE SOURCE OF TRUTH for two callers: # .gitea/workflows/lint.yml — advisory, every branch push and PR # .gitea/workflows/docker-publish.yml — the release GATE (lint-gate job) # Extracted from lint.yml on 2026-09-08 rather than copied, because a second # copy is exactly the drift this repo has been bitten by (see skillset's # pi-extensions mirror, refreshed the same evening after sitting 9579 B behind). # # WHY THIS CHECK EXISTS AT ALL # actionlint shellchecks workflow `run:` steps only. The repo's own scripts — # entrypoint.sh, scripts/*.sh, and the extensionless tools under # rootfs/usr/local/bin/ — were never shellchecked. A sibling repo with the same # gap shipped a broken `echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)` # for two months: with no script argument python reads its SCRIPT from stdin, # so the heredoc IS stdin and json.load hits EOF. shellcheck flags that at # severity error (SC2259); nothing ever ran it. # # WHY THE RELEASE GATES ON IT (added 2026-09-08, the expensive way round) # v1.8.14's first attempt failed after build-base had already spent ~46 min: # scripts/smoke-test.sh had an apostrophe inside a single-quoted exec_test body # ("the fleet\'s"), which CLOSES the string, so the body truncated and its tail # ran on the CI runner instead of inside the image. shellcheck had already # caught it as SC2289 at severity error — the lint job went red on the very # push that introduced it and stayed red for 24 hours, unread. lint.yml # deliberately does not run on tag pushes (sound: the tagged tree was linted on # main, and a tag-ref lint run sorts above the publish run and makes a release # look finished early). The gap was never "lint the tag" — it was that a tree # whose lint FAILED could still be released. Hence a gate inside the publish # workflow, ~40 s, ahead of everything expensive. # # SEVERITY CHOICE # -S error is 0 findings across this repo when clean, so it is free to add. # -S warning is NOT free here (20x SC2088 tilde-in-quotes in # recreate-sanity-check.sh, plus assorted SC2016 — both intentional), and a # noisy gate trains people to ignore it. Error-only, matching the # SHELLCHECK_OPTS philosophy in lint.yml. # Reproduce the count before editing it (the `$ ` prefix is load-bearing: a # comment whose first word is "shellcheck" is parsed as a DIRECTIVE, and a # malformed one is SC1072/SC1073 at severity error — this gate caught exactly # that when the line was first written without it): # $ shellcheck -S warning -f gcc scripts/*.sh rootfs/usr/local/bin/* \ # entrypoint*.sh hooks/* | grep -c SC2088 # # Usage: bash scripts/lint-shell.sh [root] (default root: repo top level) set -uo pipefail root="${1:-}" if [ -z "$root" ]; then root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" fi cd "$root" || { echo "::error::cannot cd to $root"; exit 2; } # A gate that cannot run must not pass. Without this, a machine (or a CI job # whose install step was reordered away) without shellcheck would sail through # printing nothing, which is the failure mode this whole file exists to prevent. if ! command -v shellcheck >/dev/null 2>&1; then echo "::error::shellcheck not found — the gate cannot run, so it must not pass" >&2 echo " install it (apt-get install -y shellcheck) or run this in CI" >&2 exit 2 fi # Union of two signals, because either alone misses a real case: a shebang scan # misses a sourced fragment with no shebang, and a *.sh glob misses the # extensionless tools in rootfs/usr/local/bin/. Silent skipping is precisely the # failure mode this gate exists to prevent, so err toward over-collecting. # -print0/mapfile -d '' so a path containing a space cannot silently split. mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0) sh_files=() for f in "${all_files[@]}"; do case "$f" in *.sh) sh_files+=("$f"); continue;; esac if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then sh_files+=("$f") fi done echo "Checking ${#sh_files[@]} shell file(s) with $(shellcheck --version | awk '/version:/{print $2}')" # A green tick over an empty file set is not a check. if [ "${#sh_files[@]}" -eq 0 ]; then echo "::error::no shell files found — the shebang scan or the checkout is wrong" exit 1 fi rc=0 shellcheck -S error -f gcc "${sh_files[@]}" || rc=1 # bash -n catches a different class than shellcheck (unbalanced constructs it # declines to parse), so both run and both count. for f in "${sh_files[@]}"; do bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; } done if [ "$rc" -eq 0 ]; then echo "OK: ${#sh_files[@]} shell file(s) clean at severity error" fi exit "$rc"