Running Scripts Unattended: cron and CI Execution
Why a script that works perfectly in an interactive terminal can behave differently when run unattended by cron or a CI pipeline, and how to write scripts that produce genuinely useful, actionable exit codes.
What you'll learn
- Explain why a script's environment (PATH, working directory, env vars) can differ between an interactive shell and cron/CI
- Design a script's exit codes so a caller (cron, CI, or another script) can distinguish success, expected failure, and unexpected failure
- Explain why unattended execution makes clear logging and explicit error handling more important, not less
Prerequisites
Explanation
Every real command below runs only in your own terminal — this lesson's exercises model these decisions as data, never executing a real script, cron job, or CI pipeline.
A script run unattended — by cron on a schedule, or by a CI system on every push — runs in a genuinely different environment than the one you tested it in interactively, and this difference is a real, common source of "it works on my machine" failures. Cron in particular runs jobs with a minimal environment: a much shorter PATH than your interactive shell's, no inherited shell aliases or functions, and a working directory that is not wherever the script happens to live — a script that calls a tool by name (assuming it's on PATH) or reads a file by a relative path (assuming a particular working directory) can work flawlessly when you run it by hand and then fail mysteriously the first time cron runs it. The fix is to be explicit: use full, absolute paths for tools and files where possible, or explicitly cd to a known directory and set PATH at the top of the script, rather than relying on whatever the invoking environment happens to provide.
Exit codes matter enormously more for unattended execution than for interactive use, because there's no human watching the output to interpret it — cron and CI systems make real, automated decisions based on a script's exit code alone (send a failure notification, fail the build, block a deployment). A well-designed script uses distinct, documented exit codes for distinct situations — conventionally, 0 for success, and small positive integers for specific, distinguishable failure categories (for example, 1 for "invalid arguments," 2 for "a required file was missing," 3 for "the actual operation failed") — rather than a single generic non-zero code for every possible failure, which forces anything downstream to guess at what actually went wrong.
Because there's no live human to notice something looks wrong, unattended scripts also need to be more explicit about logging and error handling than an interactive script, not less — every meaningful step should log enough that a failure can be diagnosed later purely from the log output, since by the time anyone looks, the terminal session that ran it is long gone.
Example
Modeling cron's minimal PATH problem and a script's distinct, documented exit-code categories, as data.
function toolIsFindable(toolName, pathEntries) {
// Models whether a bare command name would resolve, given a specific PATH.
const knownLocations = { git: "/usr/bin/git", node: "/usr/local/bin/node", customtool: "/home/user/bin/customtool" };
const location = knownLocations[toolName];
if (!location) return false;
return pathEntries.some((dir) => location.startsWith(dir + "/"));
}
const interactivePath = ["/usr/local/bin", "/usr/bin", "/home/user/bin"];
const cronMinimalPath = ["/usr/bin"]; // cron's PATH is often much shorter
console.log(toolIsFindable("customtool", interactivePath)); // true -- found interactively
console.log(toolIsFindable("customtool", cronMinimalPath)); // false -- silently NOT found under cron's minimal PATH
function exitCodeFor(situation) {
const codes = { success: 0, "invalid-args": 1, "missing-file": 2, "operation-failed": 3 };
return codes[situation] ?? 1;
}
console.log(exitCodeFor("missing-file")); // 2 -- a caller can distinguish this from other failures
console.log(exitCodeFor("operation-failed")); // 3 -- a genuinely different, distinguishable situationTry it yourself
Call toolIsFindable with 'git' and cronMinimalPath (['/usr/bin']), and confirm a commonly pre-installed tool is still found even under a minimal PATH.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
This models distinct exit-code assignment only -- no real script runs. Write chooseExitCode(situation): 'success' -> 0, 'invalid-args' -> 1, 'missing-file' -> 2, 'operation-failed' -> 3, anything else -> 1 (a safe generic-failure default).
Checks: maps success to exit code 0 · maps a specific failure category to its own distinct code · falls back safely for an unrecognized situation
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
This models detecting a relative-path assumption that would break under cron's different working directory -- no real script runs. Write assumesRelativeCwd(scriptLine), returning true if scriptLine references a path that does NOT start with '/' and does NOT start with '$' (a simplified model of a hardcoded, cwd-dependent relative path).
Checks: correctly flags a bare relative path · correctly does not flag an absolute path · correctly does not flag a variable-based path
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Create a CI-Friendly Verification Script with Cleanup and Useful Exit Codes
Runs on your computerWrite a real, local Bash script that verifies a small project folder's structure and reports success or a specific, distinguishable failure via its exit code — the kind of script a CI pipeline would run as a quality gate. Every command below runs in YOUR terminal; this platform does not execute any of them.
Required tools
- A Linux terminal (or macOS Terminal, or WSL on Windows) (any current version)
- Bash (5.x (or a compatible shell))
Setup
- Open a terminal.
- Create a dedicated practice folder: `mkdir -p ~/shell-lab/ci-verify/sample-project && cd ~/shell-lab/ci-verify`.
- Inside sample-project/, create a README.md and a src/ directory (see starter files below).
Project structure
~/shell-lab/ci-verify/
sample-project/
README.md
src/
main.txt
verify.shStarter files
sample-project/README.md
# Sample Project A minimal sample project used only to practice writing a verification script.
sample-project/src/main.txt
placeholder source file
verify.sh
#!/usr/bin/env bash set -euo pipefail # This script is designed to run unattended (like in CI), so it must: # - not assume a particular working directory (accept the project path as $1) # - use distinct exit codes: 0 success, 1 usage error, 2 missing README.md, 3 missing src/ # - clean up any temp file it creates, even if it exits early (trap ... EXIT) # - log each check it performs, with a clear PASS or FAIL # TODO: implement the checks described above
Requirements
- verify.sh begins with set -euo pipefail and accepts the project directory as $1 (not a hardcoded or assumed path).
- Running verify.sh with no arguments exits with code 1 and a clear usage message.
- Running verify.sh against a directory missing README.md exits with code 2 and a clear message identifying exactly what's missing.
- Running verify.sh against a directory missing src/ exits with code 3 and a clear message identifying exactly what's missing.
- Running verify.sh against sample-project/ (which has both) exits with code 0 and prints a PASS line for each check.
- A temp file created during the run (for example, to collect check results) is removed via a trap ... EXIT, even when the script exits early with a non-zero code.
Commands to run
Make the script executable
chmod +x verify.shRun with no arguments (expect exit code 1)
./verify.sh; echo "exit code: $?"Run against the real sample project (expect exit code 0)
./verify.sh sample-project; echo "exit code: $?"Run against a folder missing README.md (expect exit code 2)
mkdir -p /tmp/broken-project/src && ./verify.sh /tmp/broken-project; echo "exit code: $?"
Expected behavior
With no arguments: usage message, exit code 1. Against sample-project/: a PASS line for the README.md check, a PASS line for the src/ check, and exit code 0. Against a folder missing README.md: a clear FAIL message naming README.md specifically, and exit code 2 -- distinguishable from the exit code 3 a missing src/ would produce.
Verify it yourself
./verify.sh; echo $?Expected: prints a usage message; exit code is 1
./verify.sh sample-project; echo $?Expected: prints PASS for each check; exit code is 0
ls /tmp | grep -i verifyExpected: no leftover temp file remains after any run, including the failing ones
Troubleshooting
- The script always exits 0, even when a check should fail — With set -e active, an early `exit N` inside an if block is the clearest way to stop and report a specific code -- confirm each failing check actually calls exit with its intended code, rather than just printing a message and continuing.
- A leftover temp file remains after a failing run — Confirm the trap is registered (`trap cleanup EXIT`) near the TOP of the script, before the temp file is even created -- a trap registered too late won't cover an early failure.
- The script behaves differently depending on which directory you run it from — This is exactly the cron/CI trap this lesson covers -- confirm every path the script touches is either absolute, built from $1 (the passed-in project directory), or explicitly relative to a known location, never assumed relative to 'wherever this script happens to be run from.'
Stuck? Get a hint.
Extension challenge
Add a fourth check (for example, that src/ contains at least one file) with its own distinct exit code (4), and confirm the script still cleans up its temp file correctly when this new check is the one that fails.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Assuming a script's interactive PATH, working directory, or environment variables will be identical under cron or CI -- cron in particular often runs with a minimal PATH and an unrelated working directory, silently breaking a script that relied on either.
- Using a single generic non-zero exit code for every possible failure -- this forces cron, CI, or a calling script to guess at what actually went wrong, instead of reacting differently to a distinguishable, documented failure category.
- Assuming unattended execution needs LESS logging than an interactive run, since 'no one is watching' -- the opposite is true, since there's no live human to notice something looks wrong in real time; the log output IS the only record.
Knowledge check
Takeaway
A script's interactive success doesn't guarantee unattended success -- cron and CI provide a different, more minimal environment, so be explicit about paths and working directories. Design distinct, documented exit codes so a caller can react to specifically what went wrong. Log generously, since the log output is the only record anyone will have after the fact.
Summary
cron and CI run scripts with a different environment than an interactive shell -- often a shorter PATH and an unrelated working directory -- so relying on either without being explicit is a real, common source of unattended-only failures. Distinct exit codes (not one generic non-zero code) let a caller react to a specific failure category. Because no human watches unattended execution live, clear, contextual logging becomes more important, not less.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.