What you will learn

Build a small CI-style pipeline runner in plain Bash: named stages executed in order, fail-fast on the first broken stage, per-stage log capture, and a packaged artifact at the end — no CI platform required.

Before you begin

This is meant for a self-contained build/deploy script that needs pipeline structure without adopting a full CI system, or for running the same stages locally that a real CI later wraps.

1. Define stages as an ordered list of function names

#!/usr/bin/env bash
set -Eeuo pipefail

STAGE_LOG_DIR="$(mktemp -d)"
trap 'echo "logs kept at: ${STAGE_LOG_DIR}"' EXIT

stage_lint()  { shellcheck ./scripts/*.sh; }
stage_test()  { ./run-tests.sh; }
stage_build() { tar -czf "${STAGE_LOG_DIR}/artifact.tar.gz" ./dist; }
stage_smoke() { ./dist/bin/myapp --version; }

STAGES=(lint test build smoke)

Naming each stage as stage_<name> and listing the names separately keeps the pipeline definition (the order) decoupled from the implementation (the function body) — reordering or removing a stage is a one-line change.

2. Run each stage, capturing output and stopping on first failure

run_pipeline() {
    local stage fn log start_ts end_ts

    for stage in "${STAGES[@]}"; do
        fn="stage_${stage}"
        log="${STAGE_LOG_DIR}/${stage}.log"
        start_ts="$(date +%s)"

        echo "=== [${stage}] starting ==="
        if "$fn" > "$log" 2>&1; then
            end_ts="$(date +%s)"
            echo "=== [${stage}] ok ($((end_ts - start_ts))s) ==="
        else
            end_ts="$(date +%s)"
            echo "=== [${stage}] FAILED ($((end_ts - start_ts))s) ===" >&2
            echo "--- last 20 lines of ${stage}.log ---" >&2
            tail -20 "$log" >&2
            return 1
        fi
    done
}

Redirecting each stage's own stdout/stderr into a per-stage log — while still printing a live starting/ok/FAILED line to the console — gives a readable top-level narrative during the run and full detail on disk for whichever stage actually breaks.

3. Summarize and exit with the right code

if run_pipeline; then
    echo "pipeline succeeded, artifact: ${STAGE_LOG_DIR}/artifact.tar.gz"
    exit 0
else
    echo "pipeline failed" >&2
    exit 1
fi

Example output

=== [lint] starting ===
=== [lint] ok (2s) ===
=== [test] starting ===
=== [test] FAILED (14s) ===
--- last 20 lines of test.log ---
FAIL: test_order_totals (expected 42.00, got 41.99)
pipeline failed
logs kept at: /tmp/tmp.9fKq2L

Verify the result

Make stage_test fail deliberately and confirm stage_build and stage_smoke never run — the pipeline must stop at the first failure, not continue and mask it.

Troubleshooting

If a stage's failure doesn't stop the pipeline, check that stage_build's tar call isn't being run in a context where set -e is suppressed (e.g. inside if stage_build; then conditions already handle their own exit status correctly here — the bug is usually a stage function backgrounding part of its own work with & and losing the exit code).

Next steps

Parameterize STAGES via getopts-cli-argument-parser so a caller can run a subset (--only lint,test), and route the failure block through webhook-alert-dispatcher.