Files
joakimp 9e744d701f
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 15s
lint: shellcheck the repo's own shell scripts, not just workflow run: steps
lint.yml has shellchecked every workflow `run:` step since the dash-vs-bash
incidents, but nothing had ever pointed shellcheck at entrypoint.sh, scripts/*.sh
or the extensionless tools under rootfs/usr/local/bin/. That gap is not
hypothetical: the skillset repo's ci-release-watcher template shipped
`echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)` for two months, where
the heredoc IS python's stdin (no script arg) so the load hit EOF and the
function silently returned nothing. shellcheck names exactly that at severity
ERROR — SC2259, "This redirection overrides piped input" — and could have named
it the whole time.

New step in the existing actionlint job, so no second container pull: shellcheck
-S error plus bash -n over every shell file, discovered as *.sh UNION a shebang
scan (the glob alone misses pi-devbox-version, devbox-skill-reconcile, dot-watch
and studio-expose; a shebang scan alone would miss a sourced fragment without
one). Fails loudly on a zero-file match, because a green tick over an empty set
is not a check.

Severity chosen by measurement, not taste: -S error is 0 findings across all 11
shell files today, so the gate is green on arrival with no cleanup, while
-S warning is NOT free (19x SC2088 tilde-in-quotes in recreate-sanity-check.sh
plus assorted SC2016, all intentional) and would train everyone to ignore the
job — the same reasoning as the SHELLCHECK_OPTS exclusions already on the
actionlint step.
2026-08-25 21:11:55 +02:00

160 lines
7.5 KiB
YAML

name: Lint
# Durable guard against CI-workflow bugs — most importantly the recurring
# "bash-only syntax under the default `sh`/dash shell" footgun that broke
# resolve-versions (ed49b8d) and promote-base-latest (b7197e8 → run 418).
# actionlint runs shellcheck against each `run:` step using its *effective*
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any
# expensive build runs. This is cheap (~10s) and independent of the build
# pipeline, so it fires on every branch push/PR — not just on release tags,
# which is where the build workflow (docker-publish.yml) is otherwise only
# triggered.
#
# `branches: ['**']` (rather than a bare `push:`) deliberately EXCLUDES tag
# pushes. A bare `push:` also fires on `refs/tags/v*`, which was duplicate work —
# the tagged tree was already linted when the same commit was pushed to main
# (v1.6.4: lint id=529 on refs/heads/main, then id=531 again on
# refs/tags/v1.6.4, same sha e86e5df). The wasted compute is small (measured:
# lint here runs 0.3-0.9 min, against a 77.6 min release build for v1.6.4 — so
# runner contention is NOT a real argument in this repo, unlike opencode-devbox
# where actionlint installs shellcheck and takes 6-15 min). The substantive
# reason is discovery ambiguity: the runs listing is newest-first, so the
# tag-ref lint run sorts ABOVE the publish run, and "first run matching
# refs/tags/<tag>" picks lint — which goes green in under a minute while the
# image is still building, making a release look finished before anything is
# published. See AGENTS.md "Gitea API access" for the head_sha-filtered
# discovery pattern.
on:
push:
branches:
- '**'
pull_request:
workflow_dispatch:
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
actionlint:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
apt-get update
apt-get install -y --no-install-recommends shellcheck python3-yaml
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
# Gap being closed: everything else in this job shellchecks workflow
# `run:` steps ONLY, via actionlint. The repo's own shell scripts —
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
# rootfs/usr/local/bin/ — have never been shellchecked. That exact gap
# (a sibling repo with no shell-script lint at all) is how a defect
# shipped invisibly for two months: `echo "$json" | python3 <<'EOF'
# ... json.load(sys.stdin)` cannot work — with no script argument
# python reads its SCRIPT from stdin, so the heredoc IS stdin and the
# json.load call hits EOF. shellcheck flags exactly this at severity
# ERROR (SC2259, "This redirection overrides piped input"); nothing
# ever ran it. Measured before adding this gate: `-S error` is 0
# findings across every shell file in THIS repo today, so it is free
# to add. `-S warning` is NOT free here (19x SC2088 tilde-in-quotes in
# scripts/recreate-sanity-check.sh, plus assorted SC2016 — both
# intentional), so warning-level would train people to ignore the job;
# hence error-only, matching the SHELLCHECK_OPTS philosophy below.
#
# Discovery is *.sh UNION a shebang scan, because rootfs/usr/local/
# bin/{pi-devbox-version,devbox-skill-reconcile,dot-watch,studio-expose}
# are shell scripts with no extension. -print0/mapfile -d '' so a path
# with a space cannot silently split, and the file count is asserted
# non-zero — a green tick over an empty file set is not a check.
run: |
# Union of two signals, because either alone misses a real case:
# a shebang scan misses a sourced fragment with no shebang, and a
# *.sh glob misses the extensionless tools in rootfs/usr/local/bin/.
# Silent skipping is precisely the failure mode this gate exists to
# prevent, so err toward over-collecting.
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
sh_files=()
for f in "${all_files[@]}"; do
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
sh_files+=("$f")
fi
done
echo "Checking ${#sh_files[@]} shell file(s)"
if [ "${#sh_files[@]}" -eq 0 ]; then
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
exit 1
fi
shellcheck -S error -f gcc "${sh_files[@]}"
rc=0
for f in "${sh_files[@]}"; do
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
done
exit "$rc"
- name: Gitea shell guard (catches the actionlint blind spot)
# actionlint models GitHub Actions, where the default run shell is
# bash, so it does NOT flag bash syntax in a step that merely OMITS
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
# Gitea (default sh/dash). This guard enforces that every run: step
# resolves to bash under Gitea's real defaults. Run it BEFORE
# actionlint so the more precise diagnostic surfaces first.
run: bash scripts/check-workflow-shell.sh .gitea/workflows
- name: Install actionlint (pinned)
env:
ACTIONLINT_VERSION: 1.7.7
run: |
curl -fsSL \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin actionlint
actionlint --version
- name: Run actionlint
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
# so the guard stays focused on correctness bugs — crucially the
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
# other SC3xxx code.
env:
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
# Pass explicit paths: actionlint's no-arg mode auto-detects a
# project by looking for `.github/workflows`, which doesn't exist in
# this `.gitea/workflows` repo and hard-fails with exit 3
# ("no project was found"). Globbing the workflow files is the
# supported way to lint a non-GitHub layout.
run: actionlint -color .gitea/workflows/*.yml
hadolint:
# Lint the two Dockerfiles that ARE the project (the shell/actions linting
# above never looked at them). Config — ignored rules + failure threshold
# — lives in .hadolint.yaml, which hadolint reads automatically, so a local
# `hadolint Dockerfile.base` reproduces CI exactly.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install hadolint (pinned)
env:
HADOLINT_VERSION: 2.14.0
run: |
curl -fsSL \
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint
hadolint --version
- name: Run hadolint
run: hadolint Dockerfile.base Dockerfile.variant