feat: bake shellcheck and add a client-side pre-push lint gate
Lint / hadolint (push) Successful in 12s
Lint / actionlint (push) Successful in 19s

v1.8.14 made shell lint a RELEASE gate (scripts/lint-shell.sh, shared by lint.yml
and the new lint-gate job that resolve-versions depends on), and that script
correctly exits 2 when shellcheck is absent -- "a gate that cannot run must not
pass". Measured on v1.8.14 on 2026-09-09 by three routes (command -v, dpkg -l, a
filesystem search): shellcheck was NOT IN THE IMAGE AT ALL. So the gate could not
be run by a developer in any container, only in CI, and the loop stayed
write-shell -> push -> wait for CI -> discover. That is the loop the gate was
added to shorten, after v1.8.14's first attempt burned ~46 min on a tree whose
lint had already been red for 24 hours.

shellcheck 0.10.0-1 added to the Dockerfile.base apt block: ~39 MB installed
(Installed-Size 40112 KB), measured to pull ZERO additional packages under
--no-install-recommends because libc6/libffi8/libgmp10 are already present.
NOTE this forces one full base rebuild -- base-decide hashes Dockerfile.base +
rootfs/, so unlike a scripts/ change it cannot reuse the existing base- layer.

hooks/pre-push is opt-in per clone (git config core.hooksPath hooks), bypassable
with --no-verify, and execs scripts/lint-shell.sh rather than reimplementing it
-- one copy, because a duplicated check that drifts is the failure this repo
keeps paying for. Matches the idiom skillset/ and myconfigs/ already use.

WHY THIS REPO HAD NO HOOKS, since it was reported as drift and is not: a peer
asked tor-ms22 for core.hooksPath per clone on the premise that unset meant the
gates were unverified there. Measured: pi-devbox unset, skillset hooks, myconfigs
common/hooks, pi-toolkit unset -- but `git ls-files | grep -i hook` is EMPTY in
both pi-devbox and pi-toolkit, so there was nothing to point at on any machine
and unset was the only correct value. This closes the real half for pi-devbox;
pi-toolkit still ships none.

Verified, expected result written down before each check:
  * refusal paths -- shellcheck absent => rc 2 with the remedy named; linter
    missing => rc 2. Never waved through on the assumption CI will catch it.
  * the hook is IN the scan set -- "Checking 14 shell file(s)" with it present,
    13 with it moved aside, so the extensionless file is found by the shebang
    half of the linter's two-signal union. This check exists because the first
    attempt was ambiguous: a planted `[ $UNSET_VAR = "x" ]` was not reported,
    which could equally have meant "not scanned" or "below -S error". It was the
    latter. A count that moves is unambiguous; a clean run is not.
  * it catches the REAL v1.8.14 defect -- planting `echo 'the fleet\'s thing'`
    in hooks/pre-push yields SC1073/SC1072 at severity error, rc=1.

And the gate earned its keep inside this commit: the first version of the
smoke-test assertion carried a comment beginning "# shellcheck is a GATE
DEPENDENCY", and a comment whose first word is the tool's name is parsed as a
DIRECTIVE, not a comment. The new gate failed it with SC1073/SC1072 before the
push -- same family as the v1.8.14 apostrophe, a line that reads as prose to a
human and as syntax to the parser.
This commit is contained in:
2026-09-09 08:57:34 +02:00
parent 361babd4fd
commit 15a3728ae9
4 changed files with 181 additions and 0 deletions
+22
View File
@@ -101,6 +101,27 @@ ENV DEBIAN_FRONTEND=noninteractive
# container: `ss` lands at /usr/bin/ss, `ip` at /usr/sbin/ip
# (both already on the developer PATH), and `portcheck --all`
# then correctly identifies the socat listener on 8765.
# shellcheck — shell linter. Added 2026-09-09 to close a CAPABILITY gap, not
# a style preference. `scripts/lint-shell.sh` is the release
# GATE (the `lint-gate` job that `resolve-versions` depends
# on), and it correctly refuses to pass when shellcheck is
# missing — "a gate that cannot run must not pass". Measured on
# v1.8.14: shellcheck was absent from this image by all three
# routes (PATH, dpkg, filesystem), so `bash
# scripts/lint-shell.sh` exited 2 in EVERY devbox container and
# no developer could run the release gate locally at all. The
# loop was therefore write-shell → push → wait for CI → discover,
# which is the loop the gate was added to shorten: v1.8.14's
# first attempt burned ~46 min on a tree whose lint had already
# been red for 24 h. This is also what makes a client-side
# pre-push hook possible (see hooks/pre-push); without the
# binary that hook would refuse every push. ~39 MB installed
# (Installed-Size 40112 KB, shellcheck 0.10.0-1) and measured
# to pull ZERO additional packages under
# --no-install-recommends: its deps (libc6, libffi8, libgmp10)
# are already present. NOTE this file feeds the base-decide
# hash (Dockerfile.base + rootfs/), so adding it forces one
# full base rebuild.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
@@ -120,6 +141,7 @@ RUN apt-get update && \
make \
patch \
diffutils \
shellcheck \
git-crypt \
age \
file \