diff --git a/.github/workflows/helm-ci.yaml b/.github/workflows/helm-ci.yaml index 73d66219..d15f1029 100644 --- a/.github/workflows/helm-ci.yaml +++ b/.github/workflows/helm-ci.yaml @@ -54,9 +54,17 @@ jobs: # helm is not on the runner image at a version we choose, so it is # installed here at the pin this repo standardises on. That is - # environment bootstrapping, not a duplicated command -- the same - # distinction that keeps the shellcheck apt install in - # standard-checks.yml. Both `helm-lint` and `helm-vocab` shell out to + # environment bootstrapping, not a duplicated command. + # + # This comment used to cite the shellcheck apt install in + # standard-checks.yml as the parallel case. That install is gone (#753): + # its premise was wrong -- shellcheck IS on the runner image -- so it was + # never the same distinction, and it hung CI for 35 minutes. helm is the + # genuine case: pinned, absent from the image, and installed by a + # sha-pinned action rather than a package manager, which is what a + # bootstrap step should look like. + # + # Both `helm-lint` and `helm-vocab` shell out to # `helm`, and chart-env-vocabulary.sh's helper-backstop cases branch on # the helm VERSION (they self-skip below 3.16), so an unpinned runner # helm would silently change which assertions run. drift-checks.yaml diff --git a/.github/workflows/installer-tests.yaml b/.github/workflows/installer-tests.yaml index 73abcfeb..c167b0bc 100644 --- a/.github/workflows/installer-tests.yaml +++ b/.github/workflows/installer-tests.yaml @@ -2,7 +2,9 @@ name: Installer tests # Validates the curl/PowerShell installer (scripts/) across the breadth of # environments a customer might actually have: -# • static — shellcheck + bash -n + PSScriptAnalyzer +# • static — bash -n + PSScriptAnalyzer + the repo's drift guards +# (shellcheck is NOT here: it is the `quality / shellcheck` +# required check — see the `static` job for why) # • unit-bash — bats (mocked) for the bash installer # • unit-pester— Pester for the PowerShell installer, on Linux AND real Windows # • distro-prereqs — runs the REAL Linux prerequisite-install path (package @@ -66,22 +68,31 @@ jobs: | while IFS= read -r -d '' f; do bash -n "$f" || exit 1; done echo "all shell scripts parse" - - name: ShellCheck (libs + entrypoints) - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck --version | grep version - # Gate at error severity. The libs are sourced together as one program, - # so single-file shellcheck reports SC2034 "unused" false positives for - # shared vars (CURL_SECURE, ARCH_DL, colours…) that are defined in - # common.sh and consumed in other sourced files. Warnings are printed - # below for visibility but don't fail the gate. - shellcheck --severity=error --shell=bash \ - scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/check-facts.sh scripts/check-style.sh scripts/resolve-ingestor-digest.sh scripts/lib/*.sh \ - scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-full-seal.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh scripts/tests/chart-env-vocabulary.sh scripts/tests/env-vocabulary-agreement.sh scripts/tests/telemetry-vocabulary-agreement.sh - echo "── shellcheck warnings (advisory, non-blocking) ──" - shellcheck --severity=warning --shell=bash \ - scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/check-facts.sh scripts/check-style.sh scripts/resolve-ingestor-digest.sh scripts/lib/*.sh \ - scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-full-seal.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh scripts/tests/chart-env-vocabulary.sh scripts/tests/env-vocabulary-agreement.sh scripts/tests/telemetry-vocabulary-agreement.sh || true + # There is no shellcheck step here any more, and adding one back is a + # regression. shellcheck ran in THREE places in this repo (#753): here, + # `Standard checks / Lint`, and `quality / shellcheck` from + # tracebloc/.github's code-quality.yml. This copy is the one with no + # reason to exist: + # + # • It is not a REQUIRED check on develop (this whole job isn't), so it + # could never block anything — it was advice, not a gate. Making the + # R8 step below a gate is backend#2182 / #2183, a separate concern. + # • It ENUMERATED its file set. Measured on develop 2026-08-19: the list + # that used to sit here expanded to 34 files where deriving from the + # tree yields 44 — it had drifted past eight real scripts, including + # scripts/chart-version-guard.sh, scripts/check-digest-drift.sh and + # scripts/index-invariants.sh, and #747 then had to hand-edit the list + # in three places to add one new script. A parse error planted in + # index-invariants.sh was invisible to this step. + # • shellcheck is preinstalled on ubuntu-latest, so it needs no + # `apt-get install` — the very step that hung this job to its + # 10-minute timeout twice on 2026-08-19 and reported as `cancelled`. + # + # The two survivors are complementary rather than duplicated, and both + # derive their file set: `Standard checks / Lint` runs `make lint` over + # the WHOLE TREE, and `quality / shellcheck` scans the PR DIFF. Same + # severity (error) and same SC1091 exclusion as the step deleted here, so + # no verdict changes. - name: Installer manifest is current (supply-chain, R8) # The bootstrap verifies each sub-script against scripts/manifest.sha256 @@ -128,13 +139,47 @@ jobs: Write-Host "no PSScriptAnalyzer errors" unit-bash: - timeout-minutes: 10 + # 10 -> 15. At 10 the bounded install (390 s worst case) plus ~155 s of bats + # could still reach the cap and report `cancelled`, which is the #753 bug the + # bound exists to remove. Matches standard-checks.yml's `Unit tests`. + timeout-minutes: 15 name: bats (bash unit, mocked) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install bats - run: sudo apt-get update -qq && sudo apt-get install -y -qq bats + + # Bounded and retried, same shape as "Pull (bounded, retried)" + # below. This is not speculative hardening: this exact step stalled on + # 2026-08-19 at 11:58 and burned the whole `timeout-minutes: 10` budget, + # reporting `cancelled` with an empty log and `Run bats` skipped -- the + # #753 signature, on bats rather than shellcheck. shellcheck could be + # fixed by deleting the install (it is preinstalled); bats genuinely has + # to be installed, so it gets a bound instead. + # + # `sudo timeout`, not `timeout sudo`: timeout must run as root so it + # signals apt-get directly rather than signalling sudo and hoping the TERM + # is forwarded. `-qq` is dropped on the install so a slow mirror leaves + # something in the log, and `bats --version` is the proof-of-install + # marker whose absence identified the stall in the first place. + - name: Install bats (bounded, retried) + # Bounds sized against the job budget (see the twin step in + # standard-checks.yml for the arithmetic): worst case 3 x (60 + 60) + + # 30 s backoff = 390 s, plus ~155 s of bats, inside the 15 minutes this + # job now allows. `if`/`fi` not `[ ... ] &&` because the default shell is + # `bash -e` and a false test on the last iteration would abort before the + # ::error:: line printed. + run: | + for i in 1 2 3; do + if sudo timeout 60 apt-get update -qq \ + && sudo timeout 60 apt-get install -y bats; then + bats --version && exit 0 + fi + echo "::warning::bats install stalled or failed (attempt $i/3)" + if [ "$i" -lt 3 ]; then sleep $((i*10)); fi + done + echo "::error::could not install bats in 3 bounded attempts - runner-to-mirror connectivity, not this PR. Re-run this job." + exit 1 + - name: Run bats run: bats scripts/tests/*.bats diff --git a/.github/workflows/standard-checks.yml b/.github/workflows/standard-checks.yml index 5e3caced..b3a16d63 100644 --- a/.github/workflows/standard-checks.yml +++ b/.github/workflows/standard-checks.yml @@ -29,45 +29,90 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + # #753: this job had no timeout at all, so when `Install shellcheck` stalled + # on 2026-08-19 it drifted toward GitHub's 360-minute default instead of + # failing fast. Every job in this file now has a bound. + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # shellcheck itself is not on the runner image, so it is installed here. - # That is environment bootstrapping, not a duplicated command. - - name: Install shellcheck - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck --version | grep version + # The `Install shellcheck` step that used to sit here is gone, and the + # premise it was written on ("shellcheck itself is not on the runner + # image") is simply false — it IS preinstalled on ubuntu-latest (0.9.0, + # measured in run 32232682350), which is how `quality / shellcheck` runs + # without installing anything. The apt call bought nothing and cost this + # job 35+ minutes on 2026-08-19 (#753). - name: make lint - # `make lint` (backend#1606). This job used to restate the parse loop AND - # spell out the shellcheck file list inline, while the Makefile kept the - # same list in SHELLCHECK_FILES. The two had ALREADY drifted, and in the - # direction nobody notices: the Makefile carried 19 entries and this file - # 9, so ten scripts were shellchecked on a contributor's machine and NOT - # at the merge gate -- + # Calls the Makefile rather than restating the commands (backend#1606). + # That ticket's concern was a hand-written FILE LIST drifting between + # the Makefile and this workflow; it fixed the drift by re-copying the + # list, which only reset the clock — measured 2026-08-19, the list had + # drifted again, past eight scripts, and #747 then had to hand-edit it + # in three places to add one new script. `make lint` enumerates nothing: + # `parse` is a find, `shellcheck` derives from `git ls-files`. # - # scripts/gen-manifest.sh scripts/check-facts.sh - # scripts/check-style.sh scripts/lib/*.sh - # scripts/tests/check-drift.sh scripts/tests/e2e-full-seal.sh - # scripts/tests/e2e-journey.sh scripts/tests/path-persist.sh - # scripts/tests/chart-env-vocabulary.sh - # scripts/tests/env-vocabulary-agreement.sh + # WHOLE TREE, and that is the point of running it here (Arturo, #754 + # review). `quality / shellcheck` is required and derives the same way, + # but on a PR it scans only the DIFF and exits 0 on an empty one. If + # this job dropped shellcheck, no CI job would run a whole-tree + # error-severity sweep at all — the caller has no `schedule:` and passes + # `all-files: false` — so the superset claim behind #753 would have been + # true set-wise and false scope-wise. The two are complementary, not + # duplicates: diff-scoped gate + whole-tree gate, neither installing + # anything, neither holding a file list. # - # gen-manifest.sh is the installer's integrity manifest generator, so a - # shell defect there was invisible to this gate. Measured green across - # all 19 before this landed, so it arms a green check rather than - # importing a backlog. + # Verified green on the 44-file derived set under the runner's 0.9.0 + # (not just a newer local build) before arming it. run: make lint unit-tests: name: Unit tests runs-on: ubuntu-latest + # See the lint job: no job in this file had a timeout before #753. + # bats itself runs in ~2 min; the headroom is for the apt install. + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install bats - run: sudo apt-get update -qq && sudo apt-get install -y -qq bats + # bats genuinely has to be installed -- unlike shellcheck there is no + # preinstalled or reusable equivalent -- so it gets a bound rather than a + # deletion. Bounded and retried in the same shape as installer-tests.yaml's + # "Pull (bounded, retried)". + # + # This was left as plain apt in the first draft of #753, on the grounds + # that hardening installs was that ticket's own scope. Then the identical + # step in installer-tests.yaml's `unit-bash` stalled at 11:58 on + # 2026-08-19 and burned its whole 10-minute budget, reporting `cancelled` + # with an empty log. A required check is the wrong place to leave a known + # unbounded external call. + # + # `sudo timeout`, not `timeout sudo`: timeout must run as root to signal + # apt-get directly instead of signalling sudo and hoping TERM is + # forwarded. `-qq` dropped on the install so a stall is not silent, and + # `bats --version` is the proof-of-install marker. + - name: Install bats (bounded, retried) + # Bounds SIZED against the job budget, the way the distro-pull step + # sizes its own. Measured healthy: install 9 s, bats run 155 s. Worst + # case here is 3 x (60 + 60) + 10 + 20 = 390 s, plus ~155 s of bats = + # ~9 min, inside this job's 15. At the 120 s bound this first had, worst + # case was 780 s -- which would have blown a 10-minute budget and + # reported `cancelled` again, i.e. reintroduced the very bug. + # + # `if`/`fi` rather than `[ ... ] && sleep`: the default shell is + # `bash -e`, so a bare test returning false on the last iteration would + # abort the script before the ::error:: line ever printed. + run: | + for i in 1 2 3; do + if sudo timeout 60 apt-get update -qq \ + && sudo timeout 60 apt-get install -y bats; then + bats --version && exit 0 + fi + echo "::warning::bats install stalled or failed (attempt $i/3)" + if [ "$i" -lt 3 ]; then sleep $((i*10)); fi + done + echo "::error::could not install bats in 3 bounded attempts - runner-to-mirror connectivity, not this PR. Re-run this job." + exit 1 - name: Run bats (bash unit suite, mocked) run: bats scripts/tests/*.bats diff --git a/Makefile b/Makefile index 3b00d2d2..c9bf962d 100644 --- a/Makefile +++ b/Makefile @@ -9,36 +9,66 @@ # make setup install what those targets need, and a git pre-push hook # that runs `make check` (skip once with --no-verify). # -# This file is a THIN WRAPPER. Every command below is copied from the +# This file is a THIN WRAPPER. Almost every command below is copied from the # workflow that already runs it — standard-checks.yml, installer-tests.yaml # and helm-ci.yaml. It introduces no new tool, no new config, and no new # rule. When a workflow changes, change the matching line here. +# +# `shellcheck` is the one target not copied from a workflow LINE, but it is +# still exactly what a workflow runs: since #753, `Standard checks / Lint` runs +# `make lint`, so this file IS the definition rather than a copy of one. The org +# reusable job `quality / shellcheck` (tracebloc/.github) applies the same rule +# to the PR diff; that one cannot be copied from, so this target reproduces its +# classification and flags, and says so in its own comment. .DEFAULT_GOAL := help -# The shellcheck file set from installer-tests.yaml's `static` job (a -# superset of standard-checks.yml's list), at the same `error` severity. -SHELLCHECK_FILES := \ - scripts/install.sh \ - scripts/install-k8s.sh \ - scripts/gen-manifest.sh \ - scripts/check-facts.sh \ - scripts/check-style.sh \ - scripts/resolve-ingestor-digest.sh \ - scripts/lib/*.sh \ - scripts/tests/check-drift.sh \ - scripts/tests/distro-prereqs.sh \ - scripts/tests/e2e-auto-upgrade.sh \ - scripts/tests/e2e-seal-check.sh \ - scripts/tests/e2e-full-seal.sh \ - scripts/tests/e2e-cluster.sh \ - scripts/tests/e2e-journey.sh \ - scripts/tests/e2e-proxy.sh \ - scripts/tests/lib/e2e-common.sh \ - scripts/tests/path-persist.sh \ - scripts/tests/chart-env-vocabulary.sh \ - scripts/tests/env-vocabulary-agreement.sh \ - scripts/tests/telemetry-vocabulary-agreement.sh +# The shellcheck file set is DERIVED from the tree, never written down. +# +# It used to be a 19-entry SHELLCHECK_FILES list copied out of +# installer-tests.yaml. Measured on develop at 8de5d64: that list expanded to 34 +# files where the same classification applied to the tree found 42 (the live +# number is higher now and is printed by the target -- see `check` above on why +# it is not restated here). It had drifted past eight real scripts -- +# +# docker/k3s-cuda/build.sh docker/k3s-cuda/k3d-entrypoint-tracebloc-cdi.sh +# docs/migration-tools/generate.sh docs/migration-tools/migrate-tenant.sh +# scripts/chart-version-guard.sh scripts/check-digest-drift.sh +# scripts/index-invariants.sh scripts/tests/test_helper.bash +# +# -- and a parse error planted in index-invariants.sh was invisible to the list +# and caught by the derivation. This is the SECOND drift of this list; +# backend#1606 found the first and fixed it by re-copying, which reset the +# clock rather than stopping it. Enumerating a file set is the defect. +# +# The rule below is the one `quality / shellcheck` applies (the required check, +# in tracebloc/.github's code-quality.yml): classify by extension, else by +# shebang; skip .bats/.ps1/.psm1/.zsh; error severity; SC1091 excluded because +# a library sourced through a variable path can never be followed. Same rule, +# same flags. +# +# SCOPE, precisely, because #753's first draft got this wrong (Arturo review): +# * this target, run by `Standard checks / Lint` and by `make check` -- the +# WHOLE TREE, on every PR and every push. +# * `quality / shellcheck` -- the PR DIFF only. Its caller passes +# `all-files: false` and declares no `schedule:`, so on a PR touching no +# shell file it legitimately reports "Shell files to check: 0" and exits 0. +# Both are required checks and both derive. Dropping the whole-tree half would +# leave no CI job sweeping the full tree at error severity, which is what the +# old enumerated jobs did on every run. +# +# Versions differ, and that is not drift: the runner ships 0.9.0 (measured in +# run 32232682350), a dev box is typically newer -- 0.11.0 via brew today. The +# 44-file set was verified green under BOTH before this was armed. +# +# Honest limit, because a comment claiming "cannot drift" would be the thing +# this change is deleting: this MIRRORS the org job's rule across a repo +# boundary, it does not read it. If that classification changes, this needs the +# same edit. What is gone is the enumeration -- the part that actually drifted. +# +# Note `--shell=bash` is deliberately NOT passed (the old inline copies did). +# The gate infers the dialect from each file's shebang; forcing bash would make +# a local run disagree with it on the repo's `#!/bin/sh` scripts. # The bats total, DERIVED — never written down. It moves on most PRs that add a # test, nothing enforces it, and the help text had drifted from its hardcoded @@ -55,12 +85,13 @@ BATS_TEST_COUNT = $(shell grep -h '^@test' scripts/tests/*.bats 2>/dev/null | wc help: @echo "tracebloc/client — make targets" @echo - @echo " check lint + fast checks (~4 s) — run this before every push" + @echo " check lint + fast checks (~10 s) — run this before every push" @echo " check-all everything CI runs locally, including the $(BATS_TEST_COUNT)-test bats suite" @echo " setup check for / point at the tools these targets need; installs the pre-push hook" @echo " install-hooks (re)install the git pre-push hook that runs 'make check'" @echo - @echo " individual: lint bats helm-lint helm-vocab helm-template helm-unittest drift" + @echo " individual: lint (= parse + shellcheck) bats helm-lint helm-vocab" + @echo " helm-template helm-unittest drift" @echo @echo " NOT here (CI-only, by name): the 9-distro prereq matrix, e2e-cluster" @echo " (k3d), e2e-proxy (squid), path-persist, Pester, windows-e2e," @@ -68,8 +99,18 @@ help: # ---- check: the pre-push tier ------------------------------------ # -# Measured at ~4 s (macOS): bash -n 0.1 s, shellcheck 3.2 s, the three -# drift guards 0.2 s, helm lint 0.7 s. +# Re-measured 2026-08-19 (macOS, shellcheck 0.11.0), ~10 s total: parse 0.1 s, +# shellcheck 5.2 s, drift 0.4 s, helm-lint 0.7 s, helm-vocab 3.1 s. The old +# note here said 4 s and listed only four of the five targets -- it predated +# helm-vocab and never counted it. shellcheck got slower (3.2 -> 5.2 s) because +# #753 replaced the enumerated 34-file list with everything the derivation +# finds; that is the scripts nothing was checking, not a slowdown. +# Still six times inside the 60 s budget. +# +# No file count is written down here on purpose. It moves -- it was 42 when #753 +# was measured and 44 once #747 landed -- and a stale number in a comment is the +# defect this PR exists to remove. Both targets print their live count when they +# run, the same reasoning as BATS_TEST_COUNT above. # # The bats suite is deliberately NOT here. It is the repo's real unit # suite and it takes ~2 min serially on macOS — three times over the @@ -148,7 +189,10 @@ install-hooks: # ---- individual targets ------------------------------------------ -# lint: standard-checks.yml `Lint` + installer-tests.yaml `static`. +# lint: both halves, and `Standard checks / Lint` runs exactly this target, so +# the pre-push tier and the merge gate cannot disagree about what linting means +# (backend#1850). Neither half installs anything: bash is bash, and shellcheck is +# preinstalled on ubuntu-latest. # .bats files are bats DSL, not valid bash — they are exercised by # actually running them in the `bats` target. # @@ -158,12 +202,86 @@ install-hooks: # pipeline would still exit 0, so `make check` would cheerfully print # "all shell scripts parse" having parsed nothing. GitHub Actions uses # bash, so CI never showed it. xargs is POSIX, NUL-safe, and propagates -# a child failure as a non-zero exit. (Bugbot, #630.) +# a child failure as a non-zero exit. (Bugbot, #630.) The same hazard is why +# the `shellcheck` target materialises its file list instead of piping it. .PHONY: lint -lint: - @find scripts -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n - @echo "all shell scripts parse" - shellcheck --severity=error --shell=bash $(SHELLCHECK_FILES) +lint: parse shellcheck + +# parse: bash -n over every shell script under scripts/. Reached in CI through +# `make lint`, which is what `Standard checks / Lint` runs -- not as a target of +# its own. Needs nothing but bash, which is why it survived #753's install purge. +# +# Materialises the list and counts it, for the same reason the `shellcheck` +# target does (Arturo, #754 review). The previous one-liner was +# +# @find scripts -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n +# @echo "all shell scripts parse" +# +# and it FAILED OPEN in two ways at once. `find`'s exit status is lost across +# the pipe -- recipes run under dash, which has no `pipefail` -- and `xargs` +# with no input runs nothing and exits 0. So in a tree where `scripts/` is +# missing or renamed it printed +# +# find: scripts: No such file or directory +# all shell scripts parse +# +# and exited 0, under both dash and bash. That was survivable while an +# enumerated shellcheck line ran straight after it and would have failed on the +# same breakage; #753 made `parse` a load-bearing half of the required `Lint` +# check, which is exactly when a fail-open stops being tolerable. +# +# The count is derived and printed, so "parsed nothing" can no longer read +# identically to "parsed everything" -- the #630 hazard, one target over. +.PHONY: parse +parse: + @files=$$(mktemp); \ + if ! find scripts -type f -name '*.sh' -print > "$$files" 2>/dev/null; then \ + echo "parse: could not enumerate scripts/ -- refusing to report green"; \ + rm -f "$$files"; exit 1; \ + fi; \ + n=$$(wc -l < "$$files" | tr -d " "); \ + if [ "$$n" -eq 0 ]; then \ + echo "parse: found ZERO shell scripts under scripts/ -- refusing to report green"; \ + rm -f "$$files"; exit 1; \ + fi; \ + tr "\n" "\0" < "$$files" | xargs -0 -r -n1 bash -n; \ + rc=$$?; rm -f "$$files"; \ + if [ "$$rc" -eq 0 ]; then echo "all $$n shell scripts parse"; fi; \ + exit $$rc + +# shellcheck: run BOTH by the pre-push tier and by CI -- `Standard checks / +# Lint` calls `make lint`, which is this plus `parse`. No install anywhere: +# shellcheck is preinstalled on ubuntu-latest. One definition, so `make check` +# genuinely predicts the gate instead of merely resembling it (backend#1850). +# +# Two fail-closed properties, both of which the old one-liner lacked: +# * classifying ZERO files is a FAILURE, not a green run. A silent no-op is +# exactly how a broken derivation would look (backend#1729 rule 3). +# * the classifier's exit code is not swallowed by the pipe. Recipes run +# under /bin/sh -- dash on Debian, no pipefail -- so the file list is +# materialised first and shellcheck's own status is what propagates. +# The `; :` inside the classifier is load-bearing: `grep -q ... && printf` +# exits 1 on every non-shell file, which would otherwise make xargs return 123. +.PHONY: shellcheck +shellcheck: + @files=$$(mktemp); \ + git ls-files -z \ + | xargs -0 -r -n1 sh -c 'case "$$1" in \ + *.sh|*.bash|*.ksh) printf "%s\n" "$$1" ;; \ + *.bats|*.ps1|*.psm1|*.zsh) ;; \ + *) head -n 1 "$$1" 2>/dev/null \ + | grep -Eq "^#![[:space:]]*[^[:space:]]*(/|[[:space:]])(ba|da|k)?sh([[:space:]]|$$)" \ + && printf "%s\n" "$$1" ;; \ + esac; :' sh > "$$files"; \ + n=$$(wc -l < "$$files" | tr -d " "); \ + if [ "$$n" -eq 0 ]; then \ + echo "shellcheck: classified ZERO shell files -- the derivation above is broken."; \ + echo " Refusing to report green on an empty file set."; \ + rm -f "$$files"; exit 1; \ + fi; \ + echo "shellcheck: $$n file(s), severity=error"; \ + tr "\n" "\0" < "$$files" | xargs -0 -r shellcheck --severity=error --exclude=SC1091; \ + rc=$$?; rm -f "$$files"; exit $$rc # drift: the repo's duplicated-declaration guards. The first three come from # installer-tests.yaml's `static` job; all three are pure local file