Several previous chapters left behind promises that were intentionally postponed to this chapter. Chapter 3 and Chapter 4 touched on the set -u option, which causes Bash to "stop with an error when encountering an undefined variable," but it is only explained in full here. Chapter 5.2 referred to the anomaly of variables disappearing right after a pipeline completes as a "topic to be revisited when discussing traps and debugging in Chapter 25". Chapter 8 deferred "more mature error handling," Chapter 19 deferred the full trap mechanism for cleanup, and Chapter 23 deferred a full discussion of shellcheck. Meanwhile, Chapters 14, 15, 20, 21, and 24 repeatedly used the set -euo pipefail combination on the second line of each script without ever explaining the detailed operation of each option one by one. This chapter fulfills all those promises at once, divided into four parts: debug modes via set -x, set -e, set -u, and pipefail (25.1), trap for automatic cleanup (25.2), writing error messages that actually help troubleshooting (25.3), and using shellcheck as an automated safety net before scripts hit production (25.4).
25.1 Debug Modes: set -x, set -e, set -u, pipefail
These four options are flags enabled via the set builtin, each changing the shell behavior when running a script. According to the GNU Bash Reference Manual, -x, -e, and -u are part of the POSIX shell standard (and can also be used in pure sh), while pipefail is an extension popularized by the Korn shell (ksh) and adopted by Bash and Zsh, which is not available in minimal POSIX sh implementations like dash. The following five sub-sections dissect each option one by one, before bringing them together into a standard combination in 25.1.5.
25.1.1 Tracing Execution with set -x
set -x enables xtrace mode, which according to man bash prints each command along with its expanded arguments to stderr, prefixed by the character defined in the PS4 variable (defaulting to + ), right before that command is actually executed. This is most useful for inspecting actual variable values at runtime, rather than just guessing from the source code.
#!/bin/bash
set -x
name="Rudi"
total=$((3 + 4))
echo "Hello, $name! Total: $total"Running the script above prints each line along with its expansion results to the terminal:
+ name=Rudi
+ total=7
+ echo 'Hello, Rudi! Total: 7'
Hello, Rudi! Total: 7The line + echo 'Hello, Rudi! Total: 7' shows that the variables $name and $total have been replaced with their actual values before echo is executed, a detail invisible when merely reading the source code. This mode does not need to be permanently written inside the script; a more practical approach for temporary debugging is to invoke it directly from the command line without modifying the shebang or file contents at all, following the same pattern as bash --noprofile --norc -eo pipefail {0} used behind the scenes by GitHub Actions (Chapter 24.1.1).
bash -x script-name.shFor scripts split across multiple files using source (the topic of Chapter 26.1), customizing PS4 is worthwhile so that line traces show the file name and line number, rather than just a plain + sign.
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -xBASH_SOURCE and LINENO are built-in Bash variables storing the file name and line number of the script currently being executed, according to the variable list in the GNU Bash Reference Manual; this combination pinpoints the exact location of a problematic line much faster than manually sifting through the code line by line. One crucial warning touched upon in Chapter 24.1.4 must be repeated here: set -x prints the raw value of every variable, including cases where a variable happens to hold a password or API token. Never enable -x in sections of code handling secrets, especially in CI/CD environments where logs can be accessed by many people; if a trace is needed only in a specific section, enable and disable it tightly around the lines under inspection using set -x followed by set +x.
25.1.2 Terminating Scripts Automatically with set -e
set -e (long name errexit) causes the shell to exit immediately whenever a pipeline, list, or compound command completes with a non-zero exit status, instead of continuing to the next line as if nothing happened (the default Bash behavior without this option).
#!/bin/bash
set -e
echo "Step 1"
false
echo "Step 2 (will never be printed)"The false command above always exits with status 1, and set -e immediately stops the script right at that line, never reaching echo "Step 2". According to the GNU Bash Reference Manual, -e has a number of exceptions that must be memorized so its behavior does not come as a surprise: a failure does not terminate the script if the command is part of an if/while/until condition, part of an && or || list except for the final command, negated with !, or part of a pipeline other than the final command (unless pipefail is also enabled, discussed in 25.1.4).
That first exception explains precisely why the idiomatic pattern in Chapter 23.2.3 remains safe to use alongside set -e: id "$user", which exits with a non-zero status if the user does not exist, never terminates the script because the command is tested directly as an if condition.
if id "$user" &> /dev/null; then
echo "User $user already exists, skipping"
else
useradd -m -s /bin/bash "$user"
fiConversely, if the line useradd -m -s /bin/bash "$user" is called directly without an if wrapper on a user that happens to already exist, the exit status 9 (which according to official useradd documentation signifies "username or group name already in use") will immediately terminate the entire script under set -e, exactly as warned in Chapter 23.2.3. This is why the idempotent user creation function in that chapter must wrap calls to useradd inside explicit branching, rather than merely relying on set -e to "catch" failures that are not actually fatal failures.
A second trap far more frequently overlooked: the exit status of a command substitution combined directly with a local declaration gets masked by the exit status of the local command itself.
set -e
get_status() {
local result=$(curl -sf "https://api.example.com/health")
echo "$result"
}If curl above fails (for instance, if the endpoint is down), set -e still will not terminate the script, because the exit status actually checked is that of the local command, which is almost always 0, not the exit status of curl inside $(...). ShellCheck flags this pattern specifically via code SC2155 ("Declare and assign separately to avoid masking return values"), and the solution is to separate the declaration from the assignment into two distinct lines.
get_status() {
local result
result=$(curl -sf "https://api.example.com/health")
echo "$result"
}25.1.3 Detecting Unset Variables with set -u
set -u (long name nounset) changes Bash behavior when encountering a variable that has never been set: instead of silently treating it as an empty string as Bash normally does by default, the shell prints an error message to stderr and exits immediately with a non-zero status, in accordance with its official definition in the GNU Bash Reference Manual.
#!/bin/bash
set -u
echo "$TYPO_NAME"script-name.sh: line 4: TYPO_NAME: unbound variableThis is precisely the scenario touched upon in Chapter 3: a typo in a variable name that without set -u passes silently as an empty string, producing a confusing bug with no error message whatsoever. This option also fulfills the promise from Chapter 4 regarding accessing unpopulated array indices.
declare -a arr=(a b c)
set -u
echo "${arr[10]}"script-name.sh: line 3: arr[10]: unbound variableChapter 4 showed that without set -u, accessing ${arr[10]} on an array containing only three elements merely prints an empty string without any warning; with set -u enabled, index errors like this are caught immediately at runtime rather than silently producing incorrect output. One important exception should be noted: starting with Bash 4.4, expanding "${arr[@]}" on an empty array (rather than just an unpopulated index) is excluded from nounset checks, as recorded in the official CHANGES file accompanying that Bash release; for Bash versions older than 4.4, expanding an empty array under set -u mode can trigger the same error.
The ${VAR:-default} pattern from Chapter 6.4.4, which has been used repeatedly throughout this series (for example, "${1:-help}" in the dispatcher of Chapter 24.3.2), is intentionally designed not to trigger a nounset error even if VAR was never set; this parameter expansion mechanism is an official exception defined directly within its own syntax, not a loophole in set -u.
25.1.4 Catching Pipeline Failures with pipefail
By default, the exit status of a pipeline (a chain of commands linked by |, Chapter 5.3) is represented solely by the exit status of the last command, regardless of what occurred in preceding commands within the same pipe.
false | true
echo "$?"
# 0, even though "false" at the start of the pipe clearly failedset -o pipefail changes that rule; according to the GNU Bash Reference Manual, with this option active, the exit status of a pipeline becomes the exit status of the rightmost command that exited with a non-zero status, or zero if all commands in the pipe succeeded.
set -o pipefail
false | true
echo "$?"
# 1, now the failure of "false" is detectedThe usefulness of this option is most evident in scenarios touched upon in Chapter 24.1.1: a pipeline like curl ... | grep success that fails at the curl stage (e.g., due to a network drop) without pipefail would still be considered "successful" by a CI/CD step, because the exit status checked belongs only to grep at the end of the pipe. Meanwhile, grep itself might coincidentally find no matching pattern in empty output and exit with a non-zero status anyway, or conversely match an irrelevant error message. Without pipefail, mid-pipeline failures easily slip by undetected until their secondary effects surface much later in subsequent steps.
25.1.5 The set -euo pipefail Combination and Its Exceptions
The combination of the four options above, typically written as set -euo pipefail on the second line of a script (right after the shebang), has been used repeatedly throughout this series, from Chapters 14 and 15 to Chapters 21 and 24. The three letters e, u, and o in that command are simply shorthand for combining -e and -u, while pipefail must still be written via -o pipefail because it is not a single-letter option; this combination is exactly equivalent to writing three separate lines:
set -e
set -u
set -o pipefailThis combination is widely known among script writers as bash strict mode, an informal term popularized through community writings that does not appear under that name in the GNU Bash Reference Manual itself; the name is merely a practical label for the habit of enabling all three safety guards simultaneously at the start of a script.
| Option | Effect | Source | Primary Exceptions |
|---|---|---|---|
set -x | Print each command to stderr before execution | POSIX | Can leak secrets to logs (Chapter 24.1.4) |
set -e | Exit immediately when a command fails | POSIX | if/while/&&/|| conditions, and non-final pipeline commands |
set -u | Treat unset variables as an error | POSIX | ${var:-default} and similar expansions |
set -o pipefail | Pipeline exit status reflects mid-pipe failures | bash/ksh/zsh extension | Not enabled by default, unavailable in dash |
It is no coincidence that Chapter 20.4.2 and Chapter 21.4.2 deliberately omitted -e, using only set -uo pipefail: alert-notifikasi.sh in Chapter 20.4.2 required cek_service to run even if cek_disk above it failed first, and deploy-pipeline.sh in Chapter 21.4.2 needed the rollback logic at the end to execute even if the preceding restart step "failed" in terms of exit status, behavior that would be aborted if -e were active. Contrast this with deploy.sh in Chapter 21.3.2 and provision-server.sh in Chapter 24.4.1, which used full set -euo pipefail because neither had subsequent logic intended to run after a step failed. Mid-process failures (such as a broken git fetch or a failed package installation) were meant to halt the script immediately, rather than continuing on a damaged foundation. Deciding whether to use full -e is not a question of what is absolutely "safer," but whether the script contains cleanup or recovery steps that must execute even when a portion fails, a decision that can only be made by understanding the business logic of the script itself.
One advanced detail worth knowing for Bash 4.4 and later: the shopt -s inherit_errexit option causes command substitution ($(...)) to inherit the errexit flag from its parent shell, rather than automatically disabling it inside subshell command substitutions as is the default behavior, according to official shopt documentation in the GNU Bash Reference Manual. Without this option, command failures inside $(...) on older Bash versions can pass undetected even when set -e is enabled outside; adding shopt -s inherit_errexit right after set -euo pipefail closes that gap for Bash 4.4+.
25.1.6 Common Pitfalls: Omitting -o and Retrofitting Legacy Scripts
Two common errors frequently encountered when adoption of debug modes begins both fail to trigger syntax errors, allowing them to pass unnoticed. First is writing set -e pipefail without -o before pipefail. According to official POSIX Shell Command Language specifications for set, any argument after the last option not prefixed by a hyphen is treated as a new positional parameter rather than an additional option flag.
set -e pipefail
echo "$1"
# prints "pipefail", without enabling the pipefail option at allThe set -e pipefail line above only enables -e, while the word pipefail silently becomes the new value for $1, overwriting original arguments passed to the script via command line without any error message indicating a mistake. The correct syntax remains set -eo pipefail or set -e; set -o pipefail on two separate lines.
The second issue occurs when set -e is retrofitted into legacy scripts that previously ran without it. Many routine commands exit with non-zero status under benign conditions; for example, grep exits with status 1 when a pattern is not found (which does not mean grep failed to execute). Retrofitting -e into scripts containing such lines without an if wrapper causes the script to terminate unexpectedly at points previously considered normal. Adding set -euo pipefail to a long-running production script should not be done all at once and assumed complete; test scenarios representing normal and edge cases (empty inputs, failed searches, disconnected networks) should be run first to observe which lines halt unexpectedly. Those specific lines can then be wrapped in if statements or appended with || true if their status may be safely ignored, rather than removing -e entirely the moment a single problematic line is encountered.
25.2 Trap and Cleanup with trap ... EXIT
The four set options in 25.1 excel at detecting and stopping scripts when errors occur, but sudden termination carries its own risks: temporary files already created, locks already acquired, or connections already established may be left behind if the script stops mid-way. The trap builtin solves this issue by running specified commands right before the shell exits, regardless of the cause.
25.2.1 How trap and Signals Work in Bash
According to man bash, the basic syntax for trap is trap [-lp] [arg] [sigspec ...]: as soon as the shell receives one of the registered sigspec signals, command arg is read and executed. sigspec can be a standard POSIX signal name like INT (interrupt signal, sent when pressing Ctrl+C, signal number 2) or TERM (normal termination request signal, sent by kill without additional flags, signal number 15), per official definitions in the signal(7) man page on man7.org. Beyond standard signals, Bash provides several special pseudo-signals: EXIT triggered when the shell exits for any reason, ERR triggered when a command fails (subject to the same exceptions as set -e in 25.1.2), and DEBUG triggered prior to the execution of every simple command.
Two signals, SIGKILL (number 9) and SIGSTOP, cannot be caught, blocked, or ignored by any process, including Bash; this is not a Bash limitation but a universal kernel-level rule enforced by both the GNU Bash Reference Manual and the signal(7) man page. The practical consequence: running kill -9 against a script will never trigger any registered trap; the script is forcibly terminated by the kernel without executing any cleanup lines. ShellCheck includes a specific rule highlighting this mistake, SC2173 ("SIGKILL/SIGSTOP can not be trapped"), which flags attempts to write trap ... KILL or trap ... STOP, constructs that can never function regardless of the command specified. Using trap -p helps inspect active traps in a shell, providing a quick way to confirm that intended traps are properly set while debugging complex scripts.
25.2.2 Cleaning Up Temporary Files with trap ... EXIT
The most common use case for trap occurs when a script creates a temporary file using mktemp, and that file must be deleted regardless of whether the script completes normally, halts due to set -e, or is aborted manually by the user.
#!/bin/bash
set -euo pipefail
TMP_FILE=$(mktemp)
trap 'rm -f "$TMP_FILE"' EXIT
echo "Writing temporary data to $TMP_FILE"
echo "important data" > "$TMP_FILE"
# other operations using $TMP_FILE, including potential failures
grep "important" "$TMP_FILE"The line trap 'rm -f "$TMP_FILE"' EXIT is registered immediately after TMP_FILE is created, prior to any lines that could potentially fail. If grep on the final line fails to find the pattern and exits with a non-zero status, set -e stops the script immediately, but the EXIT trap runs first before the shell exits, ensuring $TMP_FILE is deleted. This resolves concerns regarding cleanup failing due to sudden set -e script termination: combining set -euo pipefail and trap ... EXIT complements error handling rather than conflicting with it.
25.2.3 Handling SIGINT and SIGTERM Explicitly
Chapter 19.4.2 introduced a simple trap for a monitoring dashboard, trap 'printf "\nDashboard stopped.\n"; exit 0' INT, which was sufficient at the time because the dashboard held no system resources beyond terminal output display. Once a script manages actual resources such as temporary files or active connections, a single trap for INT is no longer adequate, as the process can also be terminated via SIGTERM (e.g., by systemd when stopping a service, or by kill without extra flags). The most explicit and reliable way to ensure cleanup runs across all three scenarios (normal exit, Ctrl+C interruption, or SIGTERM termination) is to register the same trap for EXIT, INT, and TERM simultaneously within a single invocation, rather than assuming one trap automatically covers other signals.
#!/bin/bash
set -euo pipefail
TMP_FILE=$(mktemp)
cleanup() {
local exit_code=$?
printf '\nCleaning up temporary file...\n'
rm -f "$TMP_FILE"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
echo "Working with $TMP_FILE, press Ctrl+C to test cleanup"
sleep 30According to the GNU Bash Reference Manual, registering multiple sigspec values in a single trap call applies the same arg command to all specified signals, so cleanup EXIT INT TERM above binds the identical cleanup function to all three without requiring three separate trap statements. The local exit_code=$? line at the start of the function captures the exit status existing just before the trap fired, ensuring that exit "$exit_code" on the final line preserves the actual return code instead of overwriting it to 0. For scripts where exit codes should reflect the specific signal that terminated execution, the official standard documented in the "Exit Status" section of the GNU Bash Reference Manual is 128 + signal number, meaning Ctrl+C (SIGINT, number 2) conventionally yields exit code 130, while SIGTERM (number 15) yields 143; a complete list of exit codes is provided in Appendix B.
25.2.4 When Traps Are Not Required
Traps are not a universal cleanup mechanism for all resources. Chapter 23.2.4 demonstrated the pattern exec 200>/var/lock/security-toolkit.lock combined with flock -n 200, pointing out that the lock is automatically released when the script finishes or is forcibly terminated, because the kernel releases file locks as soon as the owning file descriptor is closed. Consequently, no extra trap is required to release it. The core difference lies in resource ownership: file descriptors and associated locks are managed directly by the kernel and automatically cleaned up when a process terminates for any reason, whereas standard files on disk (like mktemp output in 25.2.2) persist on the filesystem until explicitly removed. The kernel does not track files as temporary and will not delete them automatically.
A practical rule for deciding whether a trap is necessary: if a resource is automatically released by the kernel upon process termination (file descriptors, flock-based locks, socket connections closing on process termination), an explicit trap adds complexity without functional benefit. If a resource persists on the filesystem or exists outside the life cycle of the process itself (temporary files, added lines in authorized_keys, database entries), a trap should be set so those artifacts are not left behind.
25.3 Writing Informative Error Messages
Debug modes in 25.1 and traps in 25.2 allow scripts to handle failures technical correctness, but neither automatically ensures that human-readable error messages are clear. This section addresses that gap: how error messages should be written so readers (whether a colleague or yourself six months later) can immediately identify the issue without reading through source code.
25.3.1 Characteristics of Helpful Error Messages
A effective error message includes three components: context (what the script was attempting when it failed), cause (if determinable), and corrective action (if remedies can be suggested). Compare these two approaches for a missing configuration file case:
# Less informative
echo "Error" >&2
exit 1
# Significantly more helpful
echo "Error: configuration file '$CONFIG_FILE' not found." >&2
echo "Run './setup.sh' first to create it." >&2
exit 1Technical details that must be followed in both versions above: error messages should always be redirected to stderr using >&2, adhering to the stdout/stderr separation emphasized since Chapter 5.2. This is a functional requirement: when a script is used in a pipeline (Chapter 5.3) or its stdout output is captured via command substitution (Chapter 3.4), misdirected error messages sent to stdout mix with data stream processing, disrupting downstream parsing without raising warnings.
25.3.2 Centralized error() and die() Functions
Repeatedly writing echo "Error: ..." >&2 across multiple locations can lead to inconsistencies, such as omitting >&2 or varying message formats. A cleaner approach uses function patterns from Chapter 8 to centralize error reporting through a single interface.
error() {
printf 'Error: %s\n' "$1" >&2
}
die() {
error "$1"
exit "${2:-1}"
}die() reuses error(), utilizing nested function calls introduced in Chapter 8.1.2 so message formatting is defined once and reused. The optional second parameter ("${2:-1}", using default parameter values from Chapter 6.4.4) allows callers to specify custom exit codes while falling back to 1 as a sensible default. Usage is considerably cleaner than retyping echo ... >&2; exit 1 at every validation point.
[[ -f "$CONFIG_FILE" ]] || die "Configuration file '$CONFIG_FILE' not found"
[[ -n "${API_KEY:-}" ]] || die "API_KEY variable must be set" 2Because these two functions are reused across scripts (similar to the log() pattern in Chapter 19.2 or run_remote() in Chapter 24.3.3), they are prime candidates to move into a shared lib.sh file loaded via source across scripts (an organizational structure discussed further in Chapter 26.1), rather than manually copying them into every new script.
25.3.3 Meaningful Exit Codes
Chapter 6.4 discussed $? as the exit status of the previous command, and Appendix B summarizes standard exit codes and their meanings. Key conventions apply when setting custom exit codes in scripts: 0 indicates success, while non-zero values signal various failure states. According to the TLDP Advanced Bash-Scripting Guide, exit code 126 conventionally indicates a command was found but was not executable (e.g., permissions issues), while 127 indicates a command was not found. The 128 + signal number format for process signal terminations was discussed in 25.2.3 and is documented in the GNU Bash Reference Manual.
An important technical constraint: exit statuses in POSIX shells (including Bash) fall within the range of 0 to 255, as the value is stored as an 8-bit integer at the operating system level. Consequently, exit 256 wraps around to exit status 0, and exit 257 becomes 1 due to modulo 256 arithmetic performed internally. Defining custom exit codes greater than 255 does not yield expected results and should be avoided.
For scripts called by other scripts (such as ops.sh calling deploy-pipeline.sh in Chapter 24.3.3), distinct exit codes for failure classes allow caller scripts to make decisions without parsing error message text.
EXIT_INVALID_ARG=2
EXIT_MISSING_DEP=3
EXIT_NETWORK_FAIL=4
[[ -n "${1:-}" ]] || die "Argument is required" "$EXIT_INVALID_ARG"
command -v curl &> /dev/null || die "curl not found, install it first" "$EXIT_MISSING_DEP"This pattern allows the invoking script to inspect $? after execution and branch based on failure type, such as retrying on EXIT_NETWORK_FAIL while failing immediately on EXIT_INVALID_ARG, which will not succeed on repeated attempts.
25.4 ShellCheck for Script Linting
The preceding three sections covered writing scripts that handle errors effectively, but implementation relies on manual discipline to apply practices across every line. shellcheck automates checks through static analysis, analyzing code without executing it, introduced in Chapter 23.1.4 as a method to verify quoting practices and catch dangerous patterns automatically.
25.4.1 Installing and Basic Usage of ShellCheck
ShellCheck is available through package managers across distros and major platforms covered in Chapter 1.3: apt install shellcheck on Debian/Ubuntu, dnf install ShellCheck on Fedora, brew install shellcheck on macOS via Homebrew, or by downloading static binaries directly from the official koalaman/shellcheck releases page on GitHub for environments without pre-built packages. Verify installation via shellcheck --version before proceeding; this command displays the ShellCheck version along with the target Bash version used for analysis by default.
The sample script below deliberately includes common mistakes related to command injection and unquoted variables from Chapter 23.1, verified using shellcheck rather than manual code review.
#!/bin/bash
BACKUP_DIR=$1
cd $BACKUP_DIR
tar -czf backup.tar.gz *shellcheck backup.shIn backup.sh line 3:
cd $BACKUP_DIR
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
In backup.sh line 3:
cd $BACKUP_DIR
^-- SC2164 (warning): Use 'cd ... || exit' in case cd fails.
In backup.sh line 4:
tar -czf backup.tar.gz *
^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.These findings address issues discussed in earlier chapters: SC2086 enforces variable quoting rules from Chapter 23.1.1, SC2164 warns that an unhandled cd failure allows subsequent lines to run inside an unintended directory (a risk if subsequent lines contain destructive operations), and SC2035 enforces adding -- or a ./ prefix before globs from Chapter 23.1.4, ensuring filenames prefixed with hyphens are not interpreted as command options by tar.
25.4.2 Understanding SC Codes and Severity Levels
Every ShellCheck finding has a unique code formatted as SCxxxx, corresponding to a page on the ShellCheck wiki (shellcheck.net/wiki/SCxxxx) detailing rationale, compliant examples, and non-compliant code. According to official documentation, ShellCheck categorizes findings into four severity levels: error for issues that are almost certainly bugs, warning for risky patterns that should be resolved, info for style improvements, and style for minor conventions.
The -S/--severity option limits output to specified severity levels or higher, useful for filtering minor findings when prioritizing critical issues.
shellcheck --severity=warning backup.shThe command above displays only findings with warning and error severities, ignoring SC2086 and SC2035 which are marked as info in example 25.4.1. For warnings intentionally bypassed for valid reasons (such as a variable validated prior to execution and safe to leave unquoted), add an inline directive comment above the targeted line rather than disabling checks globally across the entire file.
# shellcheck disable=SC2086
echo $VALIDATED_VARIABLE25.4.3 Integrating ShellCheck into Editors and CI/CD
Running shellcheck manually can be overlooked as script counts grow across projects (such as deploy-pipeline.sh, ops.sh, and provision-server.sh from Chapter 24). Two places to integrate ShellCheck as automated checks are code editors, via official VS Code extensions or Neovim/Vim plugins that invoke the engine real-time during editing, and CI/CD pipelines from Chapter 24.1, as a step prior to running a deploy job.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Lint scripts with ShellCheck
run: shellcheck deploy-pipeline.sh ops.sh provision-server.sh
deploy:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy via SSH
run: echo "Deploying after successful linting"The ubuntu-latest runner in GitHub Actions includes shellcheck by default, per the software manifest in the actions/runner-images repository (version 0.9.0 on Ubuntu 24.04), requiring no installation step for the lint job. Runner software manifests should be verified periodically as default images and versions update over time. Key to this integration is needs: lint in the deploy job: per GitHub Actions documentation, jobs with needs wait for specified jobs to complete successfully, and if those jobs fail, dependent jobs are skipped automatically. ShellCheck exits with status 1 when it encounters at least one finding at default severity levels; combined with set -e active by default in GitHub Actions run steps (Chapter 24.1.1), a single unquoted variable halts the lint job and prevents the deploy job from executing unverified code.
This chapter concludes detailed coverage of debug modes, traps, error messaging, and linting. The error() and die() functions from 25.3.2, alongside the cleanup() pattern from 25.2.3, provide functional utilities suitable for consolidation into a shared library file rather than manual copying. Chapter 26 covers organizing growing script suites using multi-file structures, source inclusion, usage documentation, and automated testing via bats, ensuring established practices remain consistent across growing codebases.

