mirror of
https://github.com/Monkopedia/rouse-context.git
synced 2026-10-22 14:50:33 +00:00
Seven of this repo's CI gates are shell scripts, five of them ship with
shell self-tests, and nothing linted any of them. Verified against the
base this branched from: `shellcheck` appears in zero of the seven
workflow files (control: `ktlint` appears in two, so the search reaches
them). Every agent that touched a gate ran shellcheck locally and
reported it clean -- a convention, not a gate.
Add `scripts/check-shell-lint.sh` and wire it into the Lint job, ahead of
the grep gates: those gates ARE shell, so if one is malformed the reason
lands in this step instead of as a confusing failure inside the gate.
Scope is every TRACKED `*.sh` in the repo, discovered rather than listed
-- 19 files, which is the seven gates plus five self-tests plus
deploy/deploy.sh, deploy/setup-vps.sh and relay/scripts/backup-acme-key.sh.
A hand-maintained list is the thing that drifted in #547 and an exemption
list is the thing that rots into matching nothing (#579), so there is
neither. `production-source-dirs.sh` is in because CI runs it and every
gate shells out to it; `backfill-relay-secrets.sh` is in because it
writes repo secrets against the live VPS, where a quoting bug is worse
than a red build. `gradlew` is vendored and is excluded by not being
named `*.sh`.
Severity: shellcheck's default, which is the strictest of its four
levels. Measured across the 19 scripts: 0 findings at `error`, 1 at
`warning`, `info` and `style` alike. That one is a dead `EXIT`
accumulator in check-zombie-screens.sh (SC2034) -- assigned in the loop,
never read, because the script has always exited on the ZOMBIES array --
and it is removed here, so the gate lands green at the strictest setting.
The OPTIONAL checks are not enabled: `-o all` is 501 findings, 485 of
them SC2250 bracing and SC2292 `[[` preference. The other 15 are
SC2310/SC2312 masked-return notes, which are on-topic but each need a
judgement call, and are left for follow-up rather than blocking this.
Honest scoping, because the obvious claim is wrong: this would NOT have
caught the #628 bug. Both #628 gates shipped with `grep` exiting 1 on no
match and `set -euo pipefail` propagating that out of a command
substitution, killing the script before it printed its finding. Probed
against that exact shape at default severity AND with
`-o check-extra-masked-returns,check-set-e-suppressed`: shellcheck 0.11.0
reports nothing, and cannot, because dying on non-zero status is what
`set -e` is for. It equally would not have caught #577/#590/#594/#597,
which are unanchored-filter logic defects in valid shell. What it does
catch, at default severity, is the neighbouring class: SC2155 masked
return values, SC2086 unquoted expansions, SC2181, SC2015, SC2034 --
and SC1073, which it hit in its own header on the first run, where a
comment line beginning `# shellcheck ...` parsed as a directive.
shellcheck is installed from a checksum-pinned release rather than taken
from the runner image. The image does ship one and the step prints which,
but `ubuntu-latest` is a moving alias, and a release that DROPPED a check
would weaken this silently. The script asserts the version it got, so
deleting the install step fails loudly instead of handing the gate back
to whatever the image carries.
A linter wrapper is the easiest gate of all to make vacuous -- hand
shellcheck an empty file list and it reads stdin, prints nothing and
exits 0, which is byte-identical to a clean tree, the same shape as the
`0 == 0` coverage control in #629. `scripts/tests/check-shell-lint-test.sh`
runs the real gate against throwaway work trees and asserts eleven cases:
that violations go red NAMING the file and rule code, that coverage
reaches scripts/tests/ and arbitrary depth, and that an empty tree, an
untracked-only tree, a non-git directory, a missing root, a mismatched
shellcheck version and a missing shellcheck all go RED rather than
reporting clean.
The one suppression in the tree is an SC2123 in that test, on the line
that deliberately narrows PATH until `command -v shellcheck` finds
nothing -- which is the case being tested.
Proven to fire in the real CI environment, not only in the self-test
harness. A probe commit added a file carrying a known masked-return
finding; the Lint job went red naming the file, line and rule code, and
the six steps after it (ktlint, detekt, the other four gates and their
self-tests) all show `skipped`, so nothing downstream could paper over
it. Discovery picked the new file up on its first run -- "over 19 tracked
shell script(s)" became "over 20" with no list edited anywhere. The probe
was then reverted to a byte-identical tree (6e753aad) and is not present
here.
It also caught a real defect in its own implementation while this branch
was being written: a header line that wrapped so the word after `# ` was
`shellcheck` parsed as a directive and failed the file with SC1073/SC1072.
Twice. That was caught locally rather than by CI -- the Lint run on that
commit was cancelled during the Debounce step before the gate ran -- and
it is the better of the two proofs precisely because it was not arranged.
Closes #592
Reviewer notes (monkopedia-reviewer), recorded here because the PR body
does not survive into history:
- The measured numbers in the check-shell-lint.sh header and in the
paragraph above about optional checks ("501 findings, 485 of them ...
the remaining 15", "all 18 tracked scripts") reproduce no tree.
Re-measured with the pinned shellcheck 0.11.0: the base tree is
514/495/18 over 17 scripts, and the tree merged here is 573/553/20 over
19. The PR body's figures are the correct ones. Tracked in #658 along
with the discovery-predicate question, which is that `*.sh` is a
name-based test for a content-based class -- correct today, since
gradlew is the only non-`*.sh` file that is either executable or
shell-shebanged, but able to drift the same way the hand-maintained
lists in #547 and #579 did.
- The workflow files carry 70 inline `run:` blocks, 372 lines of shell,
which this gate does not read; five live default-severity findings sit
in them today. Tracked in #659.
- #657 tracks the deferred SC2310/SC2312 triage. Its headline count of 18
is correct; its per-site list covers 15 of them, missing
deploy/deploy.sh:20 (x2) and relay/scripts/backup-acme-key.sh:38.
This commit is contained in:
parent
54fdcd4637
commit
9b50f469d3
4 changed files with 414 additions and 2 deletions
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
|
@ -18,6 +18,35 @@ jobs:
|
|||
- name: Debounce
|
||||
run: sleep 60
|
||||
- uses: actions/checkout@v4
|
||||
# shellcheck is INSTALLED at a pinned version rather than taken from the
|
||||
# runner image. The runner does ship one (this step prints which, so the
|
||||
# claim stays checkable), but `ubuntu-latest` is a moving alias that rolls
|
||||
# to a new image on GitHub's schedule, not ours -- and a shellcheck
|
||||
# release that DROPS or relaxes a check would weaken the gate below with
|
||||
# no signal at all. The checksum makes it a pin and not just a download.
|
||||
#
|
||||
# Bump in lockstep with EXPECTED_SHELLCHECK_VERSION in
|
||||
# scripts/check-shell-lint.sh, which asserts the two agree; that assertion
|
||||
# is what makes deleting this step fail loudly instead of quietly handing
|
||||
# the gate back to whatever the image happens to carry.
|
||||
- name: Install shellcheck (pinned)
|
||||
env:
|
||||
SHELLCHECK_VERSION: v0.11.0
|
||||
SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "runner image ships: $(shellcheck --version 2>/dev/null | sed -n 's/^version: /shellcheck /p' || echo 'no shellcheck')"
|
||||
url="https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
||||
curl -fsSL --retry 3 --retry-delay 5 -o /tmp/shellcheck.tar.xz "$url"
|
||||
echo "${SHELLCHECK_SHA256} /tmp/shellcheck.tar.xz" | sha256sum -c -
|
||||
tar -xJf /tmp/shellcheck.tar.xz -C /tmp
|
||||
sudo install -m 0755 "/tmp/shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
|
||||
shellcheck --version
|
||||
# The gates below ARE shell scripts, and so are their self-tests, so this
|
||||
# runs first: if one of them is malformed, the reason lands here instead
|
||||
# of as a confusing failure inside the gate itself (#592).
|
||||
- name: "Shell scripts pass shellcheck (issue #592)"
|
||||
run: bash scripts/check-shell-lint.sh
|
||||
# Grep gates run before the JDK/Gradle setup: they need nothing but the
|
||||
# checkout and finish in under a second, so a violation reports in seconds
|
||||
# instead of after a multi-minute ktlint/detekt pass.
|
||||
|
|
@ -40,6 +69,13 @@ jobs:
|
|||
run: bash scripts/tests/check-no-sensitive-logging-test.sh
|
||||
- name: "runBlocking gate self-test (issue #590)"
|
||||
run: bash scripts/tests/check-no-production-runblocking-test.sh
|
||||
# A linter wrapper is the easiest gate of all to make vacuous: hand
|
||||
# `shellcheck` an empty file list and it reads stdin, prints nothing and
|
||||
# exits 0 -- identical output to a clean tree. This asserts the discovery
|
||||
# goes RED on an empty set and that real violations are named by file and
|
||||
# rule code.
|
||||
- name: "shellcheck gate self-test (issue #592)"
|
||||
run: bash scripts/tests/check-shell-lint-test.sh
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
|
|
|
|||
152
scripts/check-shell-lint.sh
Executable file
152
scripts/check-shell-lint.sh
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs shellcheck over every tracked shell script in the repo (issue #592).
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Seven of this repo's CI gates ARE shell scripts, and five of them ship with
|
||||
# their own self-tests -- also shell. Nothing linted any of them. Every agent
|
||||
# that touched one ran `shellcheck` locally and reported it clean, which is a
|
||||
# convention, not a gate: the next change is one forgotten local run away from
|
||||
# landing unlinted, and these scripts decide whether a build ships.
|
||||
#
|
||||
# WHAT IT DOES AND DOES NOT CATCH -- measured, not assumed
|
||||
# --------------------------------------------------------
|
||||
# It would NOT have caught #577/#590/#594/#597: those are unanchored-filter
|
||||
# LOGIC defects in entirely valid shell, and no linter sees them.
|
||||
#
|
||||
# It would also NOT have caught the #628 defect, contrary to the obvious guess.
|
||||
# Both #628 gates shipped the same bug -- `grep` exits 1 on no match, and under
|
||||
# `set -euo pipefail` that propagated out of a command substitution and killed
|
||||
# the script BEFORE it printed its finding (exit 1, no output, reading as a
|
||||
# crash rather than as the finding it was). That was probed against this exact
|
||||
# shape at default severity AND with `-o check-extra-masked-returns,
|
||||
# check-set-e-suppressed`: shellcheck 0.11.0 reports nothing on it. It cannot,
|
||||
# because dying on a non-zero status is what `set -e` is FOR; only the author
|
||||
# knows the empty result was meant to be a finding. Those two bugs were caught
|
||||
# by their self-tests asserting the red cases actually SAY what is wrong, and
|
||||
# that remains the mechanism that catches them.
|
||||
#
|
||||
# What this gate does catch is the neighbouring class the gates are genuinely
|
||||
# exposed to, and at default severity: SC2155 (`local x=$(cmd)` masking a return
|
||||
# value), SC2086 (unquoted expansion word-splitting a path or a grep pattern),
|
||||
# SC2181 (`$?` checked indirectly), SC2015 (`a && b || c` read as if/else),
|
||||
# SC2034 (an accumulator assigned and never read -- one of those was live in
|
||||
# check-zombie-screens.sh and is fixed in the change that added this gate), and
|
||||
# SC1073 (a comment line beginning `# shellcheck ...` silently parsed as a
|
||||
# directive, which this file tripped on its own first run).
|
||||
#
|
||||
# WHAT IS COVERED
|
||||
# ---------------
|
||||
# Every tracked `*.sh` in the repo, discovered -- not listed. A hand-maintained
|
||||
# file list is the thing that rots (#547 drifted exactly that way), and an
|
||||
# exemption list is the thing that quietly grows until the gate matches nothing
|
||||
# (#579). There are no exemptions. `gradlew` is a vendored, upstream-owned
|
||||
# Gradle wrapper and is excluded by virtue of not being named `*.sh`; that is
|
||||
# deliberate, not an oversight.
|
||||
#
|
||||
# Discovery is `git ls-files`, so gitignored build output is never scanned. The
|
||||
# trade is that an UNTRACKED new script is invisible to this gate locally --
|
||||
# harmless in CI, where the checkout is fully tracked by construction, and the
|
||||
# reason `git add` before running this locally is worth the habit.
|
||||
#
|
||||
# SEVERITY
|
||||
# --------
|
||||
# The default severity (`style`) -- the strictest of the four levels, reporting
|
||||
# error + warning + info + style. Measured on the tree this landed against,
|
||||
# across all 18 tracked scripts: 0 findings at `error`, and 1 at `warning`,
|
||||
# `info` and `style` alike (the dead `EXIT` in check-zombie-screens.sh, fixed in
|
||||
# the same change). The strictest level lands clean, so there is no reason to
|
||||
# accept a weaker one, and no `-S` flag is passed.
|
||||
#
|
||||
# The OPTIONAL checks (`-o all`) are NOT enabled: 501 findings, 485 of them
|
||||
# SC2250 (`${var}` bracing) and SC2292 (`[[` over `[`) -- pure style, a
|
||||
# whole-tree rewrite for no defect-detection gain. The remaining 15 are
|
||||
# SC2310/SC2312 masked-return notes; those are on-topic for gates whose `grep`
|
||||
# exit status is load-bearing, but each needs its own judgement call (some of
|
||||
# the masking is deliberate), so they are tracked as follow-up rather than
|
||||
# either blocking this gate or being silenced wholesale.
|
||||
#
|
||||
# VERSION
|
||||
# -------
|
||||
# The version is asserted, not assumed. New shellcheck releases add checks (a
|
||||
# loud red) but can also relax or drop them (a SILENT weakening), and
|
||||
# `ubuntu-latest` is a moving target that will roll to a new image on GitHub's
|
||||
# schedule, not ours. CI installs the pinned build; this assertion is what makes
|
||||
# removing that install step fail loudly instead of silently handing the gate
|
||||
# back to whatever the runner image happens to ship.
|
||||
#
|
||||
# Not a theoretical gap: measured on the runner at the time this landed, the
|
||||
# `ubuntu-latest` image ships shellcheck 0.9.0 -- two feature releases behind
|
||||
# the 0.11.0 every finding count above was measured with. "The runner already
|
||||
# has it" is true and not sufficient. (And note the line break: a comment line
|
||||
# that STARTS with the word after `# ` being `shellcheck` is parsed as a
|
||||
# directive and fails the file with SC1072/SC1073. This header tripped that
|
||||
# twice while being written; the gate caught it both times.)
|
||||
#
|
||||
# Usage: check-shell-lint.sh [repo-root]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Bump deliberately, with the finding-count delta recorded in the PR. See the
|
||||
# pinned install in .github/workflows/ci.yml, which must move in lockstep.
|
||||
EXPECTED_SHELLCHECK_VERSION=0.11.0
|
||||
|
||||
root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
|
||||
|
||||
if [[ ! -d "$root" ]]; then
|
||||
echo "FAIL: no such directory: $root" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd "$root"
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "FAIL: $root is not a git work tree." >&2
|
||||
echo " Discovery is 'git ls-files', so outside a work tree this gate" >&2
|
||||
echo " would find nothing and 'pass'. It fails instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "FAIL: shellcheck is not installed." >&2
|
||||
echo " Install v$EXPECTED_SHELLCHECK_VERSION -- see the pinned install in" >&2
|
||||
echo " .github/workflows/ci.yml, or your distro's package." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_version=$(shellcheck --version | sed -n 's/^version: //p')
|
||||
if [[ "$actual_version" != "$EXPECTED_SHELLCHECK_VERSION" ]]; then
|
||||
echo "FAIL: shellcheck $actual_version, expected $EXPECTED_SHELLCHECK_VERSION." >&2
|
||||
echo " Version drift is not cosmetic here: a release that DROPS a check" >&2
|
||||
echo " weakens this gate silently. If the new version is intended, bump" >&2
|
||||
echo " EXPECTED_SHELLCHECK_VERSION here and the pinned install in" >&2
|
||||
echo " .github/workflows/ci.yml together, and record the finding-count" >&2
|
||||
echo " delta in the PR." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `git ls-files -z` + `mapfile -d ''` rather than a glob: NUL-delimited so a
|
||||
# path with a space cannot split, and recursive so scripts/tests/ and any future
|
||||
# directory are covered without this list being edited.
|
||||
files=()
|
||||
mapfile -d '' -t files < <(git ls-files -z -- '*.sh')
|
||||
|
||||
# A gate that finds nothing to check must go RED. `shellcheck` over an empty
|
||||
# argument list reads stdin and exits 0, and a `scripts/*.sh` glob that matches
|
||||
# nothing expands to a literal that "just" errors -- both of which are how a
|
||||
# gate rots into one that passes everything (#579). Asserted, so it cannot.
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "FAIL: no tracked *.sh files found under $root." >&2
|
||||
echo " This gate has nothing to check, which is a broken gate, not a" >&2
|
||||
echo " clean tree. Either the scripts moved or discovery is wrong." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "shellcheck $actual_version over ${#files[@]} tracked shell script(s):"
|
||||
printf ' %s\n' "${files[@]}"
|
||||
echo
|
||||
|
||||
# No `-S`: the default severity is the strictest, and `-f gcc` puts the file,
|
||||
# line and rule code on one grep-able line so a CI log names what to fix.
|
||||
shellcheck -f gcc -- "${files[@]}"
|
||||
|
||||
echo "OK: shellcheck clean across ${#files[@]} shell script(s)"
|
||||
|
|
@ -11,7 +11,8 @@ set -euo pipefail
|
|||
|
||||
SCREENS_DIR="app/src/main/java/com/rousecontext/app/ui/screens"
|
||||
PROD_ROOT="app/src/main"
|
||||
EXIT=0
|
||||
# Dead `EXIT` accumulator removed (#592): the script has always exited on the
|
||||
# ZOMBIES array below, so it was assigned and never read -- shellcheck SC2034.
|
||||
ZOMBIES=()
|
||||
|
||||
# Tracked-but-known zombies. Remove entries here once the corresponding issue
|
||||
|
|
@ -60,7 +61,6 @@ for file in "$SCREENS_DIR"/*.kt; do
|
|||
|
||||
if [[ "$found" == "no" ]]; then
|
||||
ZOMBIES+=("$file (functions: ${funcs[*]})")
|
||||
EXIT=1
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
|
|||
224
scripts/tests/check-shell-lint-test.sh
Executable file
224
scripts/tests/check-shell-lint-test.sh
Executable file
|
|
@ -0,0 +1,224 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression tests for scripts/check-shell-lint.sh (#592).
|
||||
#
|
||||
# The gate is a linter wrapper, which is precisely the shape that rots into a
|
||||
# no-op: point it at a path that no longer exists, hand `shellcheck` an empty
|
||||
# argument list, and it prints nothing and exits 0 -- indistinguishable from a
|
||||
# clean tree. #629's Roborazzi coverage control passed at `0 == 0` for exactly
|
||||
# that reason, so this asserts the gate FIRES and asserts it cannot pass
|
||||
# vacuously.
|
||||
#
|
||||
# Same harness shape as #577/#590/#594/#628: a throwaway git work tree, the REAL
|
||||
# gate run end-to-end against it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
gate="$repo_root/scripts/check-shell-lint.sh"
|
||||
|
||||
sandbox=$(mktemp -d)
|
||||
trap 'rm -rf "$sandbox"' EXIT
|
||||
|
||||
failures=0
|
||||
|
||||
# Reset the sandbox to an empty, tracked-nothing git work tree.
|
||||
reset_sandbox() {
|
||||
rm -rf "${sandbox:?}/repo"
|
||||
mkdir -p "$sandbox/repo"
|
||||
git -C "$sandbox/repo" init -q
|
||||
}
|
||||
|
||||
# write <relative-path> <<'EOF' ... : create a script and stage it, so
|
||||
# `git ls-files` sees it.
|
||||
write_script() {
|
||||
local rel=$1
|
||||
mkdir -p "$(dirname "$sandbox/repo/$rel")"
|
||||
cat > "$sandbox/repo/$rel"
|
||||
chmod +x "$sandbox/repo/$rel"
|
||||
git -C "$sandbox/repo" add -- "$rel"
|
||||
}
|
||||
|
||||
# expect <exit-status> <name> [<substring the output must contain>]...
|
||||
#
|
||||
# The substrings are the point. A gate that exits non-zero without naming what
|
||||
# is wrong is the #628 failure mode (exit 1, no output), so a red case here has
|
||||
# to SAY the file and the rule code, not merely go red.
|
||||
expect() {
|
||||
local want=$1 name=$2
|
||||
shift 2
|
||||
local out status must
|
||||
set +e
|
||||
out=$(bash "$gate" "$sandbox/repo" 2>&1)
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -ne "$want" ]]; then
|
||||
echo "FAIL: $name -- expected exit $want, got $status"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
for must in "$@"; do
|
||||
if ! printf '%s\n' "$out" | grep -qF -- "$must"; then
|
||||
echo "FAIL: $name -- exited $status but never said '$must'"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo "ok: $name (exit $status)"
|
||||
}
|
||||
|
||||
# --- green -------------------------------------------------------------------
|
||||
reset_sandbox
|
||||
write_script scripts/clean.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
target=${1:-.}
|
||||
echo "checking ${target}"
|
||||
EOF
|
||||
expect 0 "a clean tracked script passes" "OK: shellcheck clean" "scripts/clean.sh"
|
||||
|
||||
# --- the gate must FIRE ------------------------------------------------------
|
||||
# SC2155 is the masked-return-value check that IS on by default: `local x=$(cmd)`
|
||||
# discards cmd's exit status because `local` itself succeeds. This is the
|
||||
# closest default-severity relative of the load-bearing-exit-status shape these
|
||||
# gates are built out of.
|
||||
reset_sandbox
|
||||
write_script scripts/bad.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
index_of() {
|
||||
local hit=$(grep -nF -- "$1" /dev/null | head -1)
|
||||
echo "$hit"
|
||||
}
|
||||
index_of x
|
||||
EOF
|
||||
expect 1 "a masked return value is caught, named by file and rule" \
|
||||
"scripts/bad.sh" "SC2155"
|
||||
|
||||
# SC2086: an unquoted expansion word-splits. Every gate in scripts/ passes
|
||||
# directory lists and grep patterns through variables, so this is the live risk.
|
||||
reset_sandbox
|
||||
write_script scripts/bad.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
dirs=$(cat /dev/null)
|
||||
for d in $dirs; do
|
||||
grep -r foo $d
|
||||
done
|
||||
EOF
|
||||
expect 1 "an unquoted expansion is caught" "scripts/bad.sh" "SC2086"
|
||||
|
||||
# --- coverage is recursive, not just scripts/ --------------------------------
|
||||
# The self-tests under scripts/tests/ are the things that caught #577, #590 and
|
||||
# both #628 bugs, and they are shell too. A `scripts/*.sh` glob would miss them
|
||||
# entirely, so a violation one directory down must still go red.
|
||||
reset_sandbox
|
||||
write_script scripts/clean.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo ok
|
||||
EOF
|
||||
write_script scripts/tests/nested-test.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ $? -ne 0 ]; then echo bad; fi
|
||||
EOF
|
||||
expect 1 "a violation under scripts/tests/ is covered too" \
|
||||
"scripts/tests/nested-test.sh" "SC2181"
|
||||
|
||||
# Any depth, not a hardcoded two levels: deploy/ and relay/scripts/ hold shell
|
||||
# too, and a future directory must not need this gate edited to be covered.
|
||||
reset_sandbox
|
||||
write_script deploy/deep/nested/thing.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ $? -ne 0 ]; then echo bad; fi
|
||||
EOF
|
||||
expect 1 "a violation at arbitrary depth is covered" \
|
||||
"deploy/deep/nested/thing.sh" "SC2181"
|
||||
|
||||
# --- the gate must not pass vacuously ----------------------------------------
|
||||
# THE case this test exists for. `shellcheck` with no file arguments reads stdin
|
||||
# and exits 0, so a discovery that matches nothing would report success over an
|
||||
# empty set -- a gate that passes everything, forever, silently.
|
||||
reset_sandbox
|
||||
expect 1 "an empty work tree goes RED, not vacuously green" \
|
||||
"no tracked *.sh files"
|
||||
|
||||
# Untracked-only is the same hazard wearing a different hat: the files exist on
|
||||
# disk, so a human glancing at the directory would say the gate had work to do.
|
||||
reset_sandbox
|
||||
mkdir -p "$sandbox/repo/scripts"
|
||||
cat > "$sandbox/repo/scripts/untracked.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ $? -ne 0 ]; then echo bad; fi
|
||||
EOF
|
||||
expect 1 "an untracked-only tree goes RED rather than reporting clean" \
|
||||
"no tracked *.sh files"
|
||||
|
||||
# Outside a git work tree, `git ls-files` yields nothing; without the explicit
|
||||
# guard that would look identical to a clean tree.
|
||||
rm -rf "${sandbox:?}/repo"
|
||||
mkdir -p "$sandbox/repo"
|
||||
expect 1 "a non-git directory goes RED" "is not a git work tree"
|
||||
|
||||
# A path that does not exist at all -- the "someone moved scripts/" case.
|
||||
rm -rf "${sandbox:?}/repo"
|
||||
expect 1 "a missing root goes RED" "no such directory"
|
||||
mkdir -p "$sandbox/repo"
|
||||
|
||||
# --- the version pin is enforced ---------------------------------------------
|
||||
# The pin is the only thing standing between this gate and whatever shellcheck
|
||||
# the runner image happens to ship. If it were advisory, a release that DROPPED
|
||||
# a check would weaken the gate with no signal at all. Proven by putting a fake
|
||||
# `shellcheck` reporting a different version first on PATH.
|
||||
reset_sandbox
|
||||
write_script scripts/clean.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo ok
|
||||
EOF
|
||||
mkdir -p "$sandbox/fakebin"
|
||||
cat > "$sandbox/fakebin/shellcheck" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "ShellCheck - shell script analysis tool"
|
||||
echo "version: 0.0.1-fake"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$sandbox/fakebin/shellcheck"
|
||||
saved_path=$PATH
|
||||
PATH="$sandbox/fakebin:$PATH"
|
||||
expect 1 "a mismatched shellcheck version goes RED" "expected"
|
||||
PATH=$saved_path
|
||||
|
||||
# ...and with no shellcheck on PATH at all, it must fail rather than skip. The
|
||||
# minimal PATH below carries exactly what the gate and this harness need to run
|
||||
# (bash, git, sed, grep) and nothing else, so `command -v shellcheck` genuinely
|
||||
# finds nothing.
|
||||
mkdir -p "$sandbox/minbin"
|
||||
for tool in bash git sed grep; do
|
||||
ln -sf "$(command -v "$tool")" "$sandbox/minbin/$tool"
|
||||
done
|
||||
saved_path=$PATH
|
||||
# The only suppression in the tree, and it is the point of the case: SC2123
|
||||
# warns when PATH is REPLACED by a single directory rather than extended,
|
||||
# because that is normally a typo. Here it is exactly what is being tested --
|
||||
# a PATH deliberately narrowed until `command -v shellcheck` finds nothing.
|
||||
# Restored on the next line but one.
|
||||
# shellcheck disable=SC2123
|
||||
PATH="$sandbox/minbin"
|
||||
export PATH
|
||||
expect 1 "a missing shellcheck goes RED rather than being skipped" \
|
||||
"shellcheck is not installed"
|
||||
PATH=$saved_path
|
||||
export PATH
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo
|
||||
echo "$failures case(s) failed: scripts/check-shell-lint.sh is not behaving as"
|
||||
echo "its own tests describe. Fix the gate, not these tests."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "All check-shell-lint.sh cases behaved as expected."
|
||||
Loading…
Add table
Add a link
Reference in a new issue