You inherit a repository with several hundred shell scripts and a currently green CI pipeline, and you want ShellCheck enforced going forward. ShellCheck has no built-in baseline feature. How would you roll it out so the build stays green and the finding count can only go down?
answer
- no flag day, no blanket disable
- enforce a checked-in file list
- new or touched files must join it
- two ratchets: files in, threshold down
- pin the version or upgrades break builds
basics
~20 sEnforce it on a checked-in list of already-clean files rather than on the whole tree, add files to that list as they are fixed, and require every new script to join it. Ratchet the severity threshold down over time.
solid answer
~50 sI would not do a flag day, and I would not blanket-disable the codes that currently fire — both end with a linter nobody trusts. Instead I make the enforced set explicit and monotonic. Pick the subset of scripts that already pass at `--severity=error`, check that list in, and fail CI only on it; every new or substantially-edited script must join the list, which is a one-line diff a reviewer can see. Meanwhile run the full tree in a non-blocking mode so the remaining count is visible. Then ratchet on two axes: move files from the unenforced pool into the list as they are cleaned, and lower the severity threshold from `error` to `warning` once the enforced set is stable. Pin the ShellCheck version in CI and in pre-commit, so a tool upgrade cannot turn a green build red on code nobody touched — and schedule the upgrade as its own deliberate change.
go deeper
Know that a linter can be introduced gradually rather than all at once, and that turning off the rules that fail is not the same as adopting it.
Be able to describe an enforced file list that grows over time, and explain why a version pin prevents unrelated builds from breaking.
Design the mechanism: how the list grows, how the backlog stays visible, how local and CI invocations are kept identical, and how suppressions stay reviewable.
Own the tradeoff between cleanup and replacement, defend why you refused both the flag day and the blanket disable, and name the metric that tells you the ratchet is working rather than being gamed.
## Why the obvious options both fail Two approaches present themselves immediately and both make the situation worse. **The flag day** — turn ShellCheck on across the tree and fix everything first. On several hundred scripts this is a large, un-reviewable change touching code nobody currently owns, most of it untested. Shell has no type checker and often no test suite, so "mechanically fix all the quoting" is a change with real regression risk and no way to verify it. The change either stalls or lands unreviewed. **The blanket disable** — put `disable=SC2086,SC2164,SC2155,...` in `.shellcheckrc` so the build passes today, intending to revisit. Those are precisely the codes that catch the silent-wrong bugs the tool exists for, and the revisit never happens. You now have a lint step that is green and worthless, which is worse than no lint step because it creates the belief that the scripts have been checked. ## The ratchet What you want is an enforcement set that only ever grows, with the build green at every commit. ShellCheck has no `--baseline` flag, so the set is expressed as data you check in. **1. Measure first.** Run the whole tree, group by code, and look at the shape. Usually a small number of codes account for most findings, and a small number of files account for most of the count. That tells you both where the mechanical wins are and which files are hopeless enough to consider rewriting rather than fixing. **2. Establish the clean set.** Run at a strict-but-reachable threshold (`--severity=error` is a good start) and record the files that already pass, one path per line, in something like `.shellcheck-enforced`: ```bash shfmt -f . | while read -r f; do shellcheck -x --severity=error "$f" >/dev/null 2>&1 && printf '%s\n' "$f" done | sort > .shellcheck-enforced ``` CI then lints only that list, and fails hard on it. On day one the build is green, and a real gate exists. **3. Make the ratchet mechanical.** The gate needs a second rule or it never grows: any script that is newly added, or substantially modified, must be in the enforced list. That is a visible one-line diff in the pull request, and it makes the conversation concrete — a reviewer can ask why a touched file was not added. Some teams enforce this with a check that every new file under `scripts/` appears in the list. **4. Keep the remainder visible.** Run the full tree in a non-blocking job and publish the count. An invisible backlog is a backlog that grows. A single number that goes down each week is what sustains the effort; a wall of findings nobody is accountable for is not. **5. Lower the threshold.** Once the enforced set is most of the repository at `error`, move it to `warning` and repeat the cycle. Two ratchets — files in, threshold down — reach full enforcement without ever asking for a big-bang change. ## The things that break a rollout **An unpinned tool.** ShellCheck adds checks between releases. If CI installs the latest version, a release lands one morning and every build fails on code nobody edited — and the fastest way out is to disable the new code, permanently. Pin the version (a specific release tag, or a pinned container image), and treat upgrading as its own change with its own fixes. **Drift between local and CI.** If developers run a different version, or without `-x`, or from a different directory so relative source paths resolve differently, CI fails on things nobody could reproduce. Put the exact invocation in one script that both pre-commit and CI call — the pipeline should run the same entry point a developer runs. **Missing files.** A `*.sh` glob silently skips extensionless scripts, container entrypoints and hooks, which are often the riskiest ones. Discover by shebang instead. **Uncontrolled suppressions.** Per-line `# shellcheck disable` directives are the escape hatch that lets the enforced set grow, and also the hole the whole gate can leak through. Require a justification comment beside each one, and audit the total periodically — a rising suppression count means files are being added to the enforced list without being fixed. ## Knowing when it is not the answer The honest principal-level caveat: a 900-line shell script accumulating dozens of findings is telling you something about the tool choice, not just the code quality. If it manages state, parses structured input, or needs real error handling and tests, the correct outcome of the audit may be to rewrite that one in a language with types and a test framework, and spend the linting effort on the scripts that should stay scripts. The rollout plan should have a bucket for "do not fix, replace".
- Why is pinning the ShellCheck version more important here than for most tools?Because new releases add checks. On an unpinned runner, a release lands and hundreds of previously green files start failing on code nobody touched, in a build blocking unrelated work. The pressure in that moment is to disable the new code permanently, which is the opposite of the ratchet. Pin it, and take upgrades as a deliberate change with the resulting fixes in the same pull request.
- A developer proposes adding `# shellcheck disable=SC2086` at the top of ten files so they can join the enforced list this sprint. What is your response?That converts the gate into decoration for those files: SC2086 is the code catching the bug class the rollout is for, and a file-wide directive silences future instances too. If the goal is progress this sprint, add fewer files and fix them properly. I would accept per-line directives with a stated reason, and treat a rising suppression count as the metric that tells us the ratchet is being gamed.
- How do you decide which scripts to fix and which to replace outright?By what the script is actually doing. Glue that invokes tools in sequence stays a script and gets fixed. Anything holding state, parsing structured input, retrying with backoff, or carrying branching business logic is past what shell does well — its finding count is a symptom. Those go in a replace bucket so the cleanup effort is not spent hardening code that should not exist in bash.
saying these in an interview costs you the question
- Disable every code that currently fires and revisit later
- Fix all several hundred scripts in one pull request
- Install the latest ShellCheck in CI so it is always current
- A *.sh glob is enough to find the repository's scripts
- Once CI is green the rollout is finished