CI that keeps up

How do I lint shell scripts with ShellCheck in CI?

To lint shell scripts with ShellCheck in CI, check every script with one pinned ShellCheck version, and fail the build on findings. Keep the configuration in the repository, and skip the scripts that the repository does not own. A find command, a pre-commit hook, and a CI job meet these requirements by hand. Dagger meets the same requirements in one place. Its official ShellCheck module checks every .sh file in a pinned ShellCheck container, on a laptop and in Cloud Checks.

What a good ShellCheck setup needs

A ShellCheck setup for CI has five requirements. Each one comes from the ShellCheck docs:

  1. Check every script. ShellCheck checks only the files that it gets. “By default, shellcheck does not support a -r option”, so a command such as find gives it the scripts.
  2. Pin the ShellCheck version. The ShellCheck README recommends that you install a specific version in your build. That “avoids any surprise build breaks when a new version with new warnings is published”.
  3. Fail on findings. ShellCheck “makes canonical use of exit codes”. It exits with code 1 when it finds issues, and the default minimum severity is style (ShellCheck manual).
  4. Keep the configuration in the repository. Directives in the scripts and a .shellcheckrc file hold the configuration. ShellCheck looks for .shellcheckrc “in the script’s directory and each parent directory”.
  5. Skip scripts you do not own. Vendored and generated scripts stay out of the check. The Recursiveness page excludes a directory from find, and a disable=all directive skips one file.

Run ShellCheck by hand

On a laptop, find gives ShellCheck every .sh file and skips the vendor directory:

find . -type f -name '*.sh' ! -path './vendor/*' -exec shellcheck {} +
In ./ci/build.sh line 4:
rm -rf $target/build
       ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
rm -rf "$target"/build


In ./scripts/main.sh line 2:
. ./lib.sh
  ^------^ SC1091 (info): Not following: ./lib.sh was not specified as input (see shellcheck -x).

For more information:
  https://www.shellcheck.net/wiki/SC1091 -- Not following: ./lib.sh was not s...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

The command exits with code 1, so a CI step that runs it fails. Scripts with no extension, such as scripts/env, need a search by shebang, as the Recursiveness page shows. In this guide’s project, that search also found Git’s sample hooks in .git/hooks, so it needs a second exclusion.

Both findings above are info findings. To fail only on warnings and errors, raise the minimum severity:

shellcheck --severity=warning ci/build.sh; echo "exit code $?"
exit code 0

A .shellcheckrc file at the project root applies to every script below it. In this guide’s project, a root .shellcheckrc with disable=SC2086 stopped the finding in ci/build.sh.

To check scripts before each commit, the README gives a pre-commit hook from koalaman/shellcheck-precommit, with the ShellCheck version in rev. In CI, a job runs the same find command. GitHub’s Ubuntu 24.04 runner image includes ShellCheck, and its readme lists the version. To pin a version, the job installs a file from ShellCheck releases or runs a koalaman/shellcheck image with a version tag.

Where the hand-built setup strains

The commands work, but each requirement lives in more than one place:

  • Pin the ShellCheck version. The version is in the pre-commit rev, in the CI install step, and in each developer’s package manager. In this guide’s project, Homebrew installed ShellCheck 0.11.0, and the Ubuntu 24.04 runner image lists 0.9.0. A script can then pass in one place and fail in the other.
  • Check every script and skip scripts you do not own. The CI job and the pre-commit hook each select the scripts, so a new exclusion must go in both.
  • Fail on findings. The minimum severity is a command-line flag, so each place that runs ShellCheck sets it again.

Check every script with the Dagger ShellCheck module

The official ShellCheck module finds every .sh file in the project and runs ShellCheck on each one. A Dagger module is a package of functions that build, test, or check a project. The Dagger team keeps a catalog of official modules for common tools. The project needs a Git repository and a dagger.toml file from the Quickstart. Install the ShellCheck module from the project root:

dagger module install dagger.io/shellcheck
dagger.io/shellcheck ➡️ https://github.com/dagger/shellcheck#main
Installed module "shellcheck" in /work/dagger.toml

The module installs under the name shellcheck. The command adds it to dagger.toml:

[modules.shellcheck]
source = "dagger.io/shellcheck"

The module finds scripts at or below the directory where dagger runs. The scripts function returns the scripts that the module found. Each script has a path. Print the paths:

dagger -c 'shellcheck | scripts | path'
ci/build.sh
scripts/compile.sh
scripts/lib.sh
scripts/main.sh
vendor/lib/bad.sh

The module reference says that a script must use the .sh extension for the module to find it. In this guide’s test, the module skipped a script named scripts/env that had a shebang but no extension. To check a script like that, rename it to end in .sh.

Each script runs in its own container, so Dagger caches the result for each script. The module README states that an edit to one script checks only that script again. In this guide’s run, the trace after a change to scripts/lib.sh showed one new shellcheck run, for that script only.

Pin the ShellCheck version with dagger.lock

The module source runs ShellCheck from the koalaman/shellcheck-alpine image with no tag. The ShellCheck README describes that image as an Alpine Linux image with ShellCheck installed. Dagger pins the image in dagger.lock, a file that it writes when it runs. In this guide’s run, the file pinned the image to v0.11.0 by digest, and the module to a Git commit:

# Generated by Dagger. Pins the images and git refs this workspace uses.
# Any Dagger command may update this file; always commit it with your changes.
# Run `dagger lock update` to refresh the pinned versions.
[["version","2"]]
["","git-latest",["github.com/dagger/shellcheck"],"refs/heads/main"]
["","git-sha",["github.com/dagger/shellcheck","refs/heads/main"],"756497171e9185db48dc7961649f466b4003cfa9"]
["","oci-latest",["docker.io/koalaman/shellcheck-alpine"],"v0.11.0"]
["","oci-sha",["docker.io/koalaman/shellcheck-alpine:v0.11.0"],"sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002"]
["","vanity-url",["https://dagger.io/shellcheck"],"https://github.com/dagger/shellcheck"]

Commit dagger.lock, and every laptop and Cloud Checks run the same ShellCheck. To move to a new ShellCheck release, run dagger lock update and commit the change.

Fail on findings with dagger check

The module adds one check, check, which fails when any script has a finding. A check is a function that passes or fails, such as a test or a linter. dagger check runs every check in the project. List the checks:

dagger check -l
MODULE      CHECK  DESCRIPTION
shellcheck  check  Run shellcheck on all shell scripts at or below the client's working

Run every check in the project, or run only the ShellCheck check:

dagger check
dagger check dag://shellcheck/check

The check runs shellcheck <path> on each script, with no severity flag. The module has no severity setting, so the default minimum severity, style, applies, and info findings also fail the check. In this run, the check failed on vendor/lib/bad.sh, a vendored script:

== CHECKS ==  ✘ 1 failed
✘ dag://?check=shellcheck/check 5.8s ERROR
  ✘ withExec shellcheck vendor/lib/bad.sh 0.0s ERROR
  ┃ 
  ┃ In vendor/lib/bad.sh line 2:
  ┃ cd $1
  ┃ ^---^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
  ┃    ^-- SC2086 (info): Double quote to prevent globbing and word splitting.
  ┃ 
  ┃ Did you mean:
  ┃ cd "$1" || exit
  ┃ 
  ┃ For more information:
  ┃   https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
  ┃   https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
  ! exit code: 1

== RUN LOCALLY ==
dagger check "dag://?check=shellcheck/check"

Each finding has a code, such as SC2086, and a link to its page on the ShellCheck wiki. The command under RUN LOCALLY runs the same check again. To accept a finding, use a directive, as the next section shows.

Keep the configuration in the repository, in directives

Put ShellCheck configuration in the scripts as directives. The module mounts only the script file into the container. So ShellCheck does not see a project .shellcheckrc or the files that a script sources. In this guide’s run, a root .shellcheckrc with disable=SC2086 did not stop the SC2086 finding above.

A directive is a comment that starts with # shellcheck. A directive right after the shebang applies to the whole script. Otherwise it applies to the next command. This version of ci/build.sh passes the check, because the parameter expansion is in quotes:

#!/bin/sh
target=$1
rm -rf "${target:?}/build"

To accept a finding on one command, put a disable directive above it. The ShellCheck wiki recommends a comment that says why. This scripts/compile.sh passes the check:

#!/bin/sh
# shellcheck disable=SC2086 # FLAGS must split into separate arguments
gcc $FLAGS main.c

A script that sources another file gets SC1091, because ShellCheck cannot follow the sourced file:

  ┃ In scripts/main.sh line 2:
  ┃ . ./lib.sh
  ┃   ^------^ SC1091 (info): Not following: ./lib.sh was not specified as input (see shellcheck -x).

The module still checks lib.sh as a separate script. To tell ShellCheck not to follow the sourced file, put a source=/dev/null directive above the line. This scripts/main.sh passes the check:

#!/bin/sh
# shellcheck source=/dev/null
. ./lib.sh
greet

Skip scripts you do not own with a directive

Skip a script that the repository does not own with a disable=all directive at the top of the file. The ShellCheck wiki documents this directive for ShellCheck 0.8 and newer. This vendor/lib/bad.sh passes the check:

#!/bin/sh
# shellcheck disable=all
cd $1

Run ShellCheck locally and with Cloud Checks

dagger check runs the same ShellCheck check on any machine that has the Dagger CLI. Commit the configuration and the lock file:

git add dagger.toml dagger.lock
git commit -m "Check shell scripts with ShellCheck through Dagger"

To run the check in CI, use Cloud Checks. Cloud Checks run your checks on Dagger’s Cloud Engines for every push and pull request to a GitHub repository. You do not need a CI workflow file. The Git origin remote of the project must point to the GitHub repository. Enable Cloud Checks from the project root:

dagger cloud checks on

In an interactive terminal, the command offers to complete the steps that are not done, such as a Dagger Cloud sign-in and the GitHub connection. dagger workspace activity lists the recent runs. If the ShellCheck check fails in Cloud Checks, run dagger check on your machine to get the same findings.

Bottom line: To lint shell scripts with ShellCheck in CI, check every script with a pinned version, fail on findings, and keep the configuration in the repository. By hand, a find command, a pre-commit hook, and a CI job each repeat the version and the script selection. The Dagger ShellCheck module checks every .sh file in a container that dagger.lock pins, with configuration in directives. dagger cloud checks on runs the same check on every push.