thermograph/.forgejo/workflows/shell-lint.yml

76 lines
3.4 KiB
YAML
Raw Normal View History

shell: add shellcheck CI guard and drive the tree to zero findings No static analysis has ever run over the ~2k lines of shell that deploy, provision secrets, and bootstrap hosts as root over SSH. Add shell-lint.yml (pinned shellcheck v0.11.0 + sha256, -x, default severity, fail on any finding) and fix everything it reports, plus two defects it structurally cannot see. Not path-filtered, matching secrets-guard's call: a backstop that only runs when you expect it to isn't a backstop. Scripts are discovered with find, so new ones are covered on landing. The version is pinned to a static release rather than apt's, so a drifted shellcheck can't fail CI on an unrelated push. render-secrets.sh: the mktemp holding DECRYPTED vault contents was only removed on the success path and one failure branch, so a sops decrypt failure left plaintext POSTGRES_PASSWORD in /tmp indefinitely on a live host. A RETURN trap makes removal unconditional, and the two sops calls now `|| return 1` explicitly instead of relying on the caller's set -e (a bare set -e abort skips the trap). The function stays free of `set -e` itself -- it is sourced, and shell options would leak into the caller. autoscale.sh: ran `set -eu` without pipefail while piping docker stats into awk, so a failed left side was swallowed and the loop scaled on empty input. Promoted to pipefail with avg_cpu's failure treated as a missed sample, so a daemon hiccup can't kill the autoscaler. Verified busybox ash in docker:27-cli supports pipefail and the script still parses there. capture-fixtures.sh: `jq . || cat` ran cat after jq had already consumed stdin, silently writing a truncated fixture; now a real if/else that fails loudly. deploy.sh/deploy-stack.sh: `# shellcheck source=` paths corrected for the monorepo layout, and /etc/thermograph.env marked unfollowable (it is rendered at deploy time and cannot exist at lint time).
2026-07-23 21:59:47 +00:00
name: shell-lint
# Shellcheck over every *.sh in the tree. These scripts run as root over SSH
# against production hosts (deploy, secrets provisioning, host bootstrap) with
# no test suite in front of them -- a quoting bug or an unset-variable typo is
# found *on the host* or not at all. The tree was driven to zero findings in
# one pass; this guard keeps it there.
#
# Deliberately NOT path-filtered (same call as secrets-guard): a backstop that
# only runs when you expect it to isn't a backstop, and shellcheck over the
# ~26 scripts here is seconds. Scripts are discovered with `find`, not listed,
# so a new script is covered the moment it lands -- that is the entire point.
#
# Shellcheck is installed from the pinned official static-binary release, NOT
# `apt-get install shellcheck` (the observability-validate precedent): the
# distro version drifts with the job image, and a drifted shellcheck can grow
# NEW warnings that fail CI on an unrelated push. The pin (v0.11.0, matching
# the koalaman/shellcheck:stable image the zero-findings pass was run against)
# plus the sha256 makes the check reproducible; bump both together, and expect
# to fix any new findings in the same PR as the bump.
on:
pull_request:
push:
branches: [dev, main, release]
env:
SHELLCHECK_VERSION: v0.11.0
SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198
jobs:
shellcheck:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install shellcheck ${{ env.SHELLCHECK_VERSION }} (pinned static binary)
run: |
set -euo pipefail
# node:20-bookworm (this runner's job image) ships curl, tar and xz,
# so the pinned tarball needs no apt-get at all -- faster than the
# distro install it replaces.
curl -fsSL -o /tmp/shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
echo "${SHELLCHECK_SHA256} /tmp/shellcheck.tar.xz" | sha256sum -c -
tar -xJf /tmp/shellcheck.tar.xz -C /tmp
install -m 0755 "/tmp/shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
shellcheck --version
- name: Shellcheck every *.sh in the tree
run: |
set -euo pipefail
mapfile -t files < <(find . -name '*.sh' -not -path './.git/*' | sort)
if [ ${#files[@]} -eq 0 ]; then
echo "no *.sh files yet — nothing to lint"
exit 0
fi
echo "shellcheck over ${#files[@]} scripts"
# -x follows sourced files: several scripts carry
# `# shellcheck source=` directives that only resolve with it.
# Default severity, no excludes: the tree is at zero findings, so
# anything shellcheck reports is a regression, not noise.
if shellcheck -x -f gcc "${files[@]}" > /tmp/findings.txt; then
echo "ok: all scripts clean"
exit 0
fi
# gcc format is file:line:col: level: message — reshape each line
# into a ::error annotation so findings land on the diff in the PR
# view. `rest` soaks up any further colons inside the message.
while IFS=: read -r f l _ rest; do
[ -n "$l" ] || continue
echo "::error file=${f#./},line=${l}::${rest# }"
done < /tmp/findings.txt
echo "shellcheck found problems in the files above"
exit 1