advanced20 min

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 situation

Try 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.

Loading editor…

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.

Loading editor…

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.

Loading editor…

Stuck? Get a hint.

Guided local lab

Create a CI-Friendly Verification Script with Cleanup and Useful Exit Codes

Runs on your computer
This lab runs on your own computer, in your own terminal and editor — not in your browser. VisaSparkSchools does not execute, run, or verify these commands for you. Follow the verification steps yourself to confirm your result.

Write 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

  1. Open a terminal.
  2. Create a dedicated practice folder: `mkdir -p ~/shell-lab/ci-verify/sample-project && cd ~/shell-lab/ci-verify`.
  3. 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.sh

Starter 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.sh
  • Run 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 verify

    Expected: no leftover temp file remains after any run, including the failing ones

Troubleshooting

  • The script always exits 0, even when a check should failWith 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 runConfirm 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 fromThis 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

Knowledge check

1. Why can a script that works perfectly when run by hand fail mysteriously when the exact same script is run by cron?
2. Why use distinct exit codes (like 1 for invalid arguments, 2 for a missing file, 3 for a failed operation) instead of one generic non-zero code for every failure?
3. Why does unattended execution (cron, CI) make clear logging MORE important than interactive use, not less?

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.