Skip to content

Shell Preamble Exemptions

Every bash script in this repo must include set -euo pipefail (or set -eu for POSIX sh) within its first 50 lines. The rule is enforced by tools/ci/check-shell-preamble.sh, wired into pre-commit and the reusable-shell-lint.yml CI job. Tests pin the contract under tests/unit/security/test_shell_preamble_lint.sh.

Why the preamble is mandatory

set -e fails the script on the first error. set -u rejects reads of unset variables. set -o pipefail propagates the exit code of the last failing command in a pipeline rather than only the final command's. Together they convert silent failures into loud ones — which is the only way to catch them.

Real example from this repo's history: executable_dot-load-benchmark shipped with only set -e. A failing pipeline (e.g. cmd | grep) where grep succeeded would still report success even when cmd crashed. The full triple catches this. The benchmark was upgraded in commit 970b631d under #854.

Exemption categories

Two kinds of files legitimately can't enforce the preamble locally:

1. Sourced libraries

Files loaded via source / . into a caller's shell context. The caller already has set -euo pipefail; the library inherits it. Adding set -e locally would persist after the library returns and break the caller's own error-handling logic.

These files must carry a comment header in their first 15 lines:

# Sourced by <parent>.sh; inherits set -euo pipefail.

The lint accepts files with this marker regardless of preamble state. Current adopters:

Path Sourced by
dot_config/shell/00-core-paths.sh.tmpl shell init (zsh/bash/fish)
dot_config/shell/05-core-safety.sh shell init
dot_config/shell/10-secrets.sh shell init
dot_config/shell/40-fzf-defaults.sh.tmpl shell init
dot_config/shell/40-ls-colors.sh shell init
dot_config/shell/50-logic-functions-core.sh.tmpl shell init + fish bridge
dot_config/shell/50-logic-functions.sh.tmpl shell init + fish bridge
dot_config/shell/51-logic-functions-extra.sh.tmpl shell init (lazy)
dot_config/shell/90-ux-aliases.sh.tmpl shell init + fish bridge
dot_config/shell/91-ux-aliases-lazy.sh.tmpl shell init (lazy)
scripts/dot/lib/bento.sh dot CLI commands
scripts/dot/lib/log.sh dot CLI + diagnostics
scripts/dot/lib/platform.sh dot CLI commands
scripts/dot/lib/ui.sh dot CLI + diagnostics + ops
scripts/dot/lib/utils.sh dot CLI
scripts/ops/heal-chezmoi.sh scripts/ops/heal.sh
scripts/ops/heal-system.sh scripts/ops/heal.sh
scripts/ops/heal-tools.sh scripts/ops/heal.sh

2. Bulk-sourced fragments (path-skipped)

Three directories contain hundreds of alias / function / PATH snippets that are sourced into the shell. Annotating each one individually would be 300+ marker comments with no extra signal. The lint skips them by path:

Path Contents
.chezmoitemplates/aliases/**/*.aliases.sh alias definitions sourced into the user's shell.
.chezmoitemplates/functions/** shell-function definitions sourced into the user's shell.
.chezmoitemplates/paths/** PATH=…:$PATH snippets concatenated by the shell init.

3. Test scripts (path-skipped)

tests/** — every file under the test tree is invoked through tests/framework/test_runner.sh which manages shell options for its children. The runner itself has the full preamble; children that source the framework inherit it.

4. Explicit opt-outs

Init fragments and completion scripts that must NOT carry their own preamble (e.g., dot_local/bin/executable_dot_completion, a zsh completion sourced into the interactive shell) can carry an explicit marker:

# preamble:skip — opt-out for completion / init fragments.

The lint accepts these. Use sparingly.

Adding a new exemption

When you need to mark a new file:

  1. Decide whether it's actually sourced or executed. If a user ever invokes it directly (chmod +x + ./file.sh), add the preamble. If it's only ever sourced, mark it.

  2. Pick the right marker.

  3. Sourced library: # Sourced by <caller>; inherits set -euo pipefail.
  4. Completion / init fragment: # preamble:skip — <why>.

  5. Run the checker:

./tools/ci/check-shell-preamble.sh path/to/file.sh

Exit 0 = lint passes.

  1. If you're adding a new entire directory of fragments (rare — alias buckets, plugin trees), edit the path-skip rule in tools/ci/check-shell-preamble.sh and document it in this page's "Bulk-sourced fragments" table above.

CI integration

  • Pre-commit hook (config/pre-commit-config.yamlshell-preamble-check): blocks git commit if a staged shell file fails the lint.
  • CI (.github/workflows/reusable-shell-lint.yml): runs the full-repo scan on every PR.

A change to either the checker or the marker convention must update this page in the same PR.

References

  • tools/ci/check-shell-preamble.sh — the lint.
  • tests/unit/security/test_shell_preamble_lint.sh — contract test.
  • config/pre-commit-config.yaml (shell-preamble-check hook).
  • .github/workflows/reusable-shell-lint.yml — CI invocation.
  • Issue #854.