Files
fips/testing/check-shellcheck.sh
T
Johnathan Corgan a67aadbd49 Lint the OpenWrt package's shell scripts on every branch, not only on trunk pushes
The OpenWrt Package workflow was the only place the package's shell
scripts were linted, and it runs on trunk pushes, tags and pull requests
but never on a branch push. A shellcheck finding introduced on a topic
branch therefore first failed after the branch had landed, which is how
all three trunks went red on the gateway init script.

testing/check-shellcheck.sh now runs that pass: the scripts the package
ships as POSIX sh with the workflow's exclusions, and the nak installer
as bash. It also covers the maintainer scripts the workflow's list left
out: the postinst and prerm that both the .ipk and the .apk package ship,
and the preinst that the SDK feed Makefile ships. It fails when a shell
script under the package's files or scripts directory is not on its list,
so a new one cannot go unlinted. It exits 2 when shellcheck is missing or
cannot read a file, 1 on a finding or a missing script, and 0 when clean.

The OpenWrt Package workflow calls the guard in place of its two inline
steps, keeping its install-if-missing step, so the runners cannot drift
apart. ci-local.sh runs it as a static stage beside the other repository
guards, and ci.yml's ci-parity job runs it on every branch push.
2026-10-01 22:40:40 +00:00

145 lines
5.9 KiB
Bash
Executable File

#!/bin/bash
# ── OpenWrt shell-script lint guard ─────────────────────────────────────────
# Runs shellcheck over the shell scripts the OpenWrt packages ship, and over
# .github/scripts/install-nak.sh, which the OpenWrt Package workflow runs to
# fetch its publishing tool.
#
# This is the one copy of that lint. The OpenWrt Package workflow calls it
# after building each .ipk, and ci.yml and ci-local.sh call it too, because
# that workflow runs only on trunk pushes, tags and pull requests: without the
# other two, a finding in a script edited on a topic branch first shows up
# after the branch has reached a trunk.
#
# What is checked, and how:
# * Every script under packaging/openwrt-ipk/files/ and the maintainer
# scripts under packaging/openwrt-ipk/scripts/, as POSIX sh. Both the .ipk
# and the .apk package take their payload and maintainer scripts from these
# two directories (build-apk.sh wraps the maintainer scripts with one
# header line, which is not linted separately); the SDK feed Makefile ships
# preinst as well. On a router they run under busybox ash.
# * install-nak.sh as bash, with no exclusions. It is a CI script, not a
# shipped one, and the sh exclusion set below misfires on bash.
#
# The sh exclusions, with reason:
# SC1008 the init scripts' `#!/bin/sh /etc/rc.common` shebang, an
# interpreter line the linter does not recognise.
# SC2317 rc.common's start_service/stop_service/reload_service hooks,
# which nothing in the file itself calls.
# SC2034 the init scripts' USE_PROCD, START, STOP, EXTRA_COMMANDS and
# EXTRA_HELP, which rc.common reads rather than the script.
# SC3043 `local`, which POSIX leaves undefined and ash supports.
# SC2086, SC2089, SC2090 firewall.sh builds an nft match, quotes included,
# in one variable and relies on word splitting to pass it as
# separate arguments; nft parses the quotes itself.
#
# Every sh-family script in those two directories must be on the list below. A
# new one that is not fails the guard, so a script added to the package is not
# silently left unlinted.
#
# Exit 0 = clean. Exit 1 = a finding, a listed script missing, or a shipped
# script not on the list. Exit 2 = the guard could not run (shellcheck or git
# missing, or shellcheck could not process a file); never treated as a pass.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT" || { echo "check-shellcheck: cannot cd to $PROJECT_ROOT" >&2; exit 2; }
IPK=packaging/openwrt-ipk
SH_EXCLUDE=SC1008,SC2317,SC2034,SC3043,SC2086,SC2089,SC2090
SH_TARGETS=(
"$IPK/files/etc/init.d/fips"
"$IPK/files/etc/init.d/fips-gateway"
"$IPK/files/etc/fips/firewall.sh"
"$IPK/files/etc/hotplug.d/net/99-fips"
"$IPK/files/etc/uci-defaults/90-fips-setup"
"$IPK/files/usr/bin/fips-mesh-setup"
"$IPK/files/usr/bin/fips-ap-setup"
"$IPK/scripts/preinst"
"$IPK/scripts/postinst"
"$IPK/scripts/prerm"
)
BASH_TARGETS=(
".github/scripts/install-nak.sh"
)
if ! command -v shellcheck >/dev/null 2>&1; then
echo "check-shellcheck: shellcheck not found; cannot lint the shell scripts" >&2
echo "check-shellcheck: install it with 'apt-get install shellcheck'" >&2
exit 2
fi
if ! command -v git >/dev/null 2>&1; then
echo "check-shellcheck: git not found; cannot list the shipped scripts" >&2
exit 2
fi
shellcheck --version | sed -n 's/^version: /check-shellcheck: shellcheck /p'
findings=0
broken=0
# ── Completeness: every shipped sh-family script is on the list ─────────────
shipped=$(git ls-files -- "$IPK/files" "$IPK/scripts") || {
echo "check-shellcheck: git ls-files failed, refusing to pass" >&2
exit 2
}
if [[ -z "$shipped" ]]; then
echo "check-shellcheck: git ls-files found nothing under $IPK, refusing to pass" >&2
exit 2
fi
while IFS= read -r f; do
# A tracked file deleted from the working tree: if listed, the lint below
# reports it missing; if not, there is nothing to ship.
[[ -f "$f" ]] || continue
head -n 1 "$f" | grep -qE '^#![[:space:]]*[^[:space:]]*/(env[[:space:]]+)?(ba|a|da)?sh([[:space:]]|$)' || continue
listed=0
for t in "${SH_TARGETS[@]}"; do
[[ "$t" == "$f" ]] && { listed=1; break; }
done
if [[ $listed -eq 0 ]]; then
echo "FAIL: $f is a shipped shell script missing from SH_TARGETS in $0"
findings=1
fi
done <<< "$shipped"
# ── Lint ─────────────────────────────────────────────────────────────────────
lint() {
# lint <file> <shellcheck args...>: one file; sets findings or broken.
local f="$1" rc=0
shift
if [[ ! -f "$f" ]]; then
echo "FAIL: missing $f"
findings=1
return 0
fi
echo "==> shellcheck $* $f"
shellcheck "$@" "$f" || rc=$?
case $rc in
0) echo " PASS" ;;
1) echo " FAIL"; findings=1 ;;
*) echo " shellcheck exited $rc: could not check $f"; broken=1 ;;
esac
return 0
}
for f in "${SH_TARGETS[@]}"; do
lint "$f" --shell=sh --exclude="$SH_EXCLUDE"
done
for f in "${BASH_TARGETS[@]}"; do
lint "$f" --shell=bash
done
total=$(( ${#SH_TARGETS[@]} + ${#BASH_TARGETS[@]} ))
if [[ $broken -ne 0 ]]; then
echo "shellcheck could not check every script; refusing to pass"
exit 2
fi
if [[ $findings -ne 0 ]]; then
echo "shellcheck FAILED"
exit 1
fi
echo "shellcheck PASS ($total scripts: ${#SH_TARGETS[@]} as sh, ${#BASH_TARGETS[@]} as bash)"
exit 0