Project-Template/docs/architecture/scripts/verify.sh

695 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Run every check this project has, in one command, and print one table saying
# which passed, which failed and which did not run at all.
#
# bash scripts/verify.sh # everything
# bash scripts/verify.sh --quick # skip the test step, name it as skipped
# bash scripts/verify.sh --list # show the plan, run nothing
# bash scripts/verify.sh --only npm # only steps whose id contains 'npm'
# VERIFY_GUARD_DIR=scripts/verify.d bash scripts/verify.sh
#
# Exit codes: 0 everything that ran passed. 1 something failed, or the run
# could not start at all — a usage error, a bad VERIFY_GUARD_DIR, not a git
# repository. 2 nothing was verified: either no checks were detected, or every
# detected step skipped. Two is not a pass and CI must not treat it as one.
#
# One and two are both non-zero on purpose, so every refusal fails closed. If
# you need to tell "a check failed" apart from "it never got to run", read the
# message — a failed check always prints a table first.
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Copy to `scripts/verify.sh`, `chmod +x` it, and wire it in: for a Node
# project add `"verify": "bash scripts/verify.sh"` to package.json; for anything
# else call it from `pre-commit`. Then read the CONFIGURATION block below —
# two environment variables (VERIFY_GUARD_DIR, VERIFY_SLOW_STEP) and six lists
# of candidate script and target names. Every one is optional except the one
# you need, which is GUARD_DIR.
#
# Unlike its sibling release.sh this script has no host, image or container to
# get wrong, so it defaults to DETECTING rather than to refusing. The rule it
# inherits unchanged is the important half: it never claims to have checked
# something it did not check. A detector that finds nothing says so and exits
# non-zero; it does not print a green table.
#
# Assumes: bash, coreutils, git. Each detector additionally needs the toolchain
# it detects, and says so by name when that toolchain is missing instead of
# quietly dropping the step.
#
# ## Why this exists
#
# "Did I break anything" should not be a judgement call, and in a project with
# three separate check commands it always is. The commands live in different
# places — a package.json script, a Makefile target, a lint you have to
# remember — so the honest answer to "did you run everything" is usually "I ran
# the one I remembered". The suite passes, the typecheck was never run, and the
# breakage is found by the deploy.
#
# One entry point removes the judgement. It also removes the excuse: there is
# no "I ran the important one" when running all of them is the same amount of
# typing.
#
# The second reason is the summary. A chain of `&&` stops at the first failure,
# so a run tells you about one broken thing at a time and you pay the whole
# cost again to find the next. This runs every step even after one fails, so a
# single run tells you everything that is broken.
#
# ## Guard tests are the point
#
# This project family keeps getting bitten by bugs that are invisible to
# ordinary tests, because the test and the bug agree with each other:
#
# - A Date serialised across a SQL boundary came back shifted by the server's
# timezone. Every test asserted against the same shifted value, so the
# suite was green and the dates were wrong.
# - A React prop was spread over a form field and silently overwrote its
# `name`. The component rendered, the test rendered it, and the field
# submitted under the wrong key.
# - A `redirect()` was called inside a `try` block. Next.js implements
# redirect by throwing, so the `catch` swallowed it and every successful
# action reported failure.
#
# None of those is a logic error a unit test would catch. All three are SHAPES
# in the source: a shape you can grep for. The fix is a guard test — a script
# that greps the source for the shape and exits non-zero when it reappears —
# and this script is where guard tests belong, because verify.sh is the thing
# that actually gets run.
#
# Write them as small executables in GUARD_DIR. One shape per file, named for
# the bug, exiting non-zero with a message naming the file and line. They cost
# milliseconds, they run first here for exactly that reason, and they are the
# only mechanism in the repository that catches a bug the tests cannot see.
#
# ## SKIPPED is not PASS
#
# A step that did not run gets its own state in the table and its own colour,
# and it is never folded into the pass count. This matters more than it looks:
# the failure this script exists to prevent is a green table produced by
# checking nothing, and every path to that failure runs through a skip that was
# reported as a success. So a run where nothing actually executed exits 2 even
# though nothing failed, and --quick names the step it dropped rather than
# quietly shortening the table.
#
# ## What it deliberately does not do
#
# It does not fix anything. No --fix, no formatter writing to your files: this
# runs immediately before a commit, and a verify that edits the tree changes
# what you were about to commit into something you have not read.
#
# It does not touch git — no staging, no committing, no stash, no branch check.
# It answers one question about the working tree as it stands.
#
# It does not walk into workspaces or sub-packages. Detection runs at the
# repository root, once. A monorepo wants a guard script per package, or a
# Makefile target that fans out, and either is a step this will find.
#
# It has no --dry-run because it writes nothing OF ITS OWN: no file is created,
# deleted, truncated or moved anywhere in this script. That is not the same as
# "changes nothing", and the difference matters. Every step it runs is somebody
# else's program — a package.json script, a Makefile target, an executable in
# GUARD_DIR — and those inherit no restraint from here. Dropping a test
# database is the usual one.
#
# --list is the dry-run equivalent: it shows the exact command each step would
# run, and runs none of them. Read it once, on a new project, before trusting
# this in a hook. That is the only place the full list is visible before it
# executes.
set -uo pipefail
say() { printf '\033[1mverify:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mverify:\033[0m %s\n' "$*" >&2; exit 1; }
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| die "not inside a git repository. Detection runs from the repository root so every detector looks in one place; cd into the repo and run this again."
# Empty is checked separately from failed. `cd ""` succeeds and stays put, so an
# empty ROOT would not fail here — it would silently detect in whatever
# directory you happened to be in, which is the wrong answer delivered
# confidently. A bare repository is the way it happens.
[ -n "$ROOT" ] \
|| die "git reported an empty repository root — this looks like a bare repository, which has no working tree to check. Run this from a normal checkout."
cd "$ROOT" || die "cannot cd to $ROOT"
# Resolved once, with symlinks collapsed, so the guard-directory containment
# check below compares two paths of the same kind.
ROOT_ABS=$(pwd -P)
# ---------------------------------------------------------------------------
# CONFIGURATION
# ---------------------------------------------------------------------------
# Where guard scripts live, relative to the repository root. Empty by default:
# an unset path is a path that cannot point at the wrong thing, and this is the
# one value worth setting by hand — see "Guard tests are the point" above.
#
# Set and missing is a hard failure, not a skip. A guard directory that got
# renamed is precisely the case where silently running zero guards looks
# identical to running them all.
#
# IT MUST BE A DEDICATED DIRECTORY. Every executable file in it is RUN, with no
# allowlist — unlike the npm and make candidate lists below, which name the
# handful of scripts they are willing to invoke. That asymmetry is the whole
# hazard: point this at scripts/ and verify.sh runs release.sh; point it at a
# bin directory and it runs every binary there. detect_guards refuses the cases
# it can prove wrong (outside the repository, the repository root itself, the
# directory holding this script), but it cannot tell a guard from a deploy
# script that happens to sit beside one. Give guards their own directory and
# put nothing else in it.
GUARD_DIR="${VERIFY_GUARD_DIR:-}"
# Which step --quick drops, by exact id (the left column of the table). Empty
# means "every step classified as a test", which is the right guess almost
# everywhere and is stated out loud when it is used.
SLOW_STEP="${VERIFY_SLOW_STEP:-}"
# Candidate script and target names, checked against what the project actually
# has. Nothing here is run unless it exists — these are search terms, not
# defaults, and the distinction is load-bearing: `npm run lint` on a project
# without a lint script fails, and a verify that invents work to do fails for
# reasons that have nothing to do with the code.
#
# It is also an allowlist, and that is the other half of why it exists. A
# package.json contains scripts named `deploy` and `db:reset`. Running
# everything found would be a verify that publishes.
NODE_TYPECHECK_SCRIPTS="typecheck type-check tsc types"
NODE_LINT_SCRIPTS="lint lint:ci eslint format:check fmt:check"
NODE_TEST_SCRIPTS="test test:ci"
# `verify` and `all` are absent on purpose: a Makefile in a repository holding
# this script very likely has a `verify` target that calls this script, and
# that is an infinite loop rather than a failed check. The guard below catches
# it anyway, for the `check` target that turns out to do the same thing.
MAKE_LINT_TARGETS="lint fmt-check format-check"
MAKE_TYPECHECK_TARGETS="typecheck type-check types"
MAKE_TEST_TARGETS="test check"
# ---------------------------------------------------------------------------
# Refuse to be re-entered.
#
# A detected step that calls this script back — `make check` running
# scripts/verify.sh is the way it happens — recurses until the machine gives
# up, and the symptom is a hang rather than an error. Dying on the second entry
# turns that into one legible failure naming the step that did it.
# ---------------------------------------------------------------------------
if [ -n "${VERIFY_RUNNING:-}" ]; then
die "verify.sh invoked itself — a detected step calls this script back, which would recurse forever. Remove that target from the candidate lists in this file, or stop it calling verify."
fi
export VERIFY_RUNNING=1
QUICK=""
LIST=""
ONLY=""
while [ $# -gt 0 ]; do
case "$1" in
--quick) QUICK="yes" ;;
--list) LIST="yes" ;;
--only)
shift
[ $# -gt 0 ] || die "--only needs a pattern. Usage: --only <substring of a step id>"
ONLY="$1" ;;
--only=*) ONLY="${1#--only=}" ;;
*) die "unknown argument '$1'. Usage: verify.sh [--quick] [--list] [--only <pattern>]" ;;
esac
shift
done
if [ -t 2 ]; then
C_PASS=$'\033[32m'; C_FAIL=$'\033[31;1m'; C_SKIP=$'\033[33m'; C_OFF=$'\033[0m'
else
C_PASS=""; C_FAIL=""; C_SKIP=""; C_OFF=""
fi
# ---------------------------------------------------------------------------
# The plan. Built completely before anything runs, so that "nothing was
# detected" is discovered before a single command executes rather than after a
# five-minute suite.
#
# Parallel arrays indexed together, iterated with a counted loop: `${arr[@]}` on
# an empty array is an unbound-variable error under `set -u` in bash 3.2, which
# is the bash on every stock macOS, and the empty case is the one that matters
# most here.
# ---------------------------------------------------------------------------
STEP_ID=()
STEP_CLASS=()
STEP_CMD=()
STEP_SKIP=()
STEP_RESULT=()
STEP_SECS=()
add_step() { STEP_ID+=("$1"); STEP_CLASS+=("$2"); STEP_CMD+=("$3"); STEP_SKIP+=(""); }
# A step that was found but cannot run. Recorded rather than dropped, because
# "this project has a mypy config and mypy is not installed" is information,
# and a silently shorter table is not.
add_skip() { STEP_ID+=("$1"); STEP_CLASS+=("skip"); STEP_CMD+=(""); STEP_SKIP+=("$2"); }
q() { printf '%q' "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }
# ---------------------------------------------------------------------------
# Guards first. They are greps: they finish before the toolchain has finished
# starting, and they are the only steps that catch the bug class described in
# the header. Putting them last would mean the cheapest answer arrives after
# the most expensive one.
# ---------------------------------------------------------------------------
detect_guards() {
[ -n "$GUARD_DIR" ] || return 0
[ -d "$GUARD_DIR" ] \
|| die "VERIFY_GUARD_DIR is set to '$GUARD_DIR' but there is no such directory. Create it, or unset the variable — a missing guard directory would otherwise run zero guards and look exactly like running all of them."
# -------------------------------------------------------------------------
# Containment. Everything below this point EXECUTES every file it finds, so a
# mis-set GUARD_DIR is not a wrong answer — it is arbitrary code with the
# developer's credentials, launched by the one command they were told is safe
# to run before every commit.
#
# The three refusals below are the cases that can be proven wrong rather than
# guessed at. Resolved with `cd`+`pwd -P` so that symlinks, `..` and relative
# paths all collapse to one comparable form before being judged.
# -------------------------------------------------------------------------
local guard_abs self_abs
guard_abs=$(cd "$GUARD_DIR" 2>/dev/null && pwd -P) \
|| die "VERIFY_GUARD_DIR is set to '$GUARD_DIR' but that directory could not be entered (permissions?)."
# Outside the repository. `..`, an absolute path and a symlink pointing out of
# the tree all land here. A guard is a check on THIS repository's source; a
# directory outside it holds someone else's programs.
case "$guard_abs/" in
"$ROOT_ABS"/*) ;;
*) die "VERIFY_GUARD_DIR '$GUARD_DIR' resolves to '$guard_abs', which is outside this repository ('$ROOT_ABS'). Every executable file in it would be RUN. Guard scripts belong in a dedicated directory inside the repository." ;;
esac
# The repository root itself. Running every executable at the root means
# running whatever release, deploy or reset script the project keeps there.
[ "$guard_abs" != "$ROOT_ABS" ] \
|| die "VERIFY_GUARD_DIR points at the repository root. Every executable file at the root would be RUN as a guard, including any release, deploy or database script. Put guards in a dedicated subdirectory — scripts/verify.d is the convention."
# The directory holding this script. This is the likely mistake, because it is
# where scripts live and it reads as the obvious answer: this template ships
# verify.sh beside release.sh, backup.sh and migrate.sh, and pointing the
# guard directory here would run all three and call the result a passing
# check. Skipped when $0 cannot be resolved, which loses nothing — the two
# refusals above still apply.
self_abs=$(cd "$(dirname -- "$0")" 2>/dev/null && pwd -P) || self_abs=""
if [ -n "$self_abs" ] && [ "$guard_abs" = "$self_abs" ]; then
die "VERIFY_GUARD_DIR points at the directory holding verify.sh itself ('$guard_abs'). Every executable file beside this script — release.sh, backup.sh, migrate.sh — would be RUN as a guard. Put guards in a dedicated subdirectory of their own."
fi
local f path found=""
while IFS= read -r f; do
[ -n "$f" ] || continue
found="yes"
# Made explicitly relative so the guard runs whether or not its directory is
# on PATH — but only when it is not already absolute, since './' in front of
# an absolute path silently resolves somewhere else entirely.
case "$f" in /*) path="$f" ;; *) path="./$f" ;; esac
# A guard that lost its executable bit never runs and nothing notices. That
# is the same failure the whole script is about, one directory down. So is a
# guard symlinked in from a shared directory whose target has since moved:
# both are recorded, neither is dropped.
if [ -x "$f" ]; then
add_step "guard:$(basename "$f")" guard "$(q "$path")"
elif [ ! -e "$f" ]; then
add_skip "guard:$(basename "$f")" "broken symlink — it points at nothing, so this guard has not run since the target moved"
else
add_skip "guard:$(basename "$f")" "not executable — chmod +x it, or it will never run again either"
fi
# -L so a symlinked guard, and a symlinked guard DIRECTORY, are both seen.
# Sharing one guard across sibling repositories by symlink is the normal way
# to do it, and plain `-type f` does not match a symlink: those guards were
# not skipped, not listed and not run, which is the silent-zero failure this
# script exists to prevent. `! -type d` rather than `-type f` so a broken
# symlink still surfaces above instead of vanishing again.
done < <(find -L "$GUARD_DIR" -maxdepth 1 ! -type d 2>/dev/null | LC_ALL=C sort)
[ -n "$found" ] || say "note: $GUARD_DIR is configured but empty. See 'Guard tests are the point' at the top of this file for what belongs there."
}
# ---------------------------------------------------------------------------
# Node. The script names come from package.json and nowhere else.
#
# Read with node rather than grepped, because a grep for '"test"' matches keys
# in devDependencies, in a nested tool config, and in lint-staged — and
# `npm run` on a name that is not a script exits non-zero, so the guess would
# surface as a failing check with no failing code behind it.
# ---------------------------------------------------------------------------
detect_node() {
[ -f package.json ] || return 0
if ! have npm || ! have node; then
add_skip "npm" "package.json is here but node/npm is not on PATH — none of its checks could run"
return 0
fi
local scripts rc
scripts=$(node -e 'try{const s=require(process.cwd()+"/package.json").scripts||{};process.stdout.write(Object.keys(s).join("\n"))}catch(e){process.exit(1)}' 2>/dev/null); rc=$?
# Kept separate from "there are no scripts" below. Both end in zero npm steps
# and they need different answers: one is a project that has no checks yet,
# the other is a file this script could not read, which is also about to break
# every npm command anyone else runs today.
if [ "$rc" -ne 0 ]; then
add_skip "npm" "package.json could not be parsed by node — fix the JSON; no npm check could be read from it"
return 0
fi
if [ -z "$scripts" ]; then
add_skip "npm" "package.json declares no scripts — this Node project was not checked at all"
return 0
fi
local class list name matched=""
for class in typecheck lint test; do
case "$class" in
typecheck) list="$NODE_TYPECHECK_SCRIPTS" ;;
lint) list="$NODE_LINT_SCRIPTS" ;;
test) list="$NODE_TEST_SCRIPTS" ;;
esac
for name in $list; do
printf '%s\n' "$scripts" | grep -qx -- "$name" || continue
add_step "npm:$name" "$class" "npm run $(q "$name")"
matched="yes"
# One per class. Two matches usually means `test` and `test:ci` are the
# same suite twice, and paying for a suite twice is how people stop
# running verify.
break
done
done
if [ -z "$matched" ]; then
say "note: package.json has scripts, but none named like a check. Looked for:"
say " $NODE_TYPECHECK_SCRIPTS $NODE_LINT_SCRIPTS $NODE_TEST_SCRIPTS"
say " Add the real names to the candidate lists near the top of this file."
fi
}
detect_make() {
local mk="" f
for f in Makefile makefile GNUmakefile; do
if [ -f "$f" ]; then mk="$f"; break; fi
done
[ -n "$mk" ] || return 0
if ! have make; then
add_skip "make" "$mk is here but make is not on PATH"
return 0
fi
local class list target matched=""
for class in typecheck lint test; do
case "$class" in
typecheck) list="$MAKE_TYPECHECK_TARGETS" ;;
lint) list="$MAKE_LINT_TARGETS" ;;
test) list="$MAKE_TEST_TARGETS" ;;
esac
for target in $list; do
# Anchored at the start of the line so this matches a rule and not a
# .PHONY declaration or a variable that happens to contain the word.
grep -qE "^${target}[[:space:]]*:" "$mk" || continue
add_step "make:$target" "$class" "make $(q "$target")"
matched="yes"
break
done
done
# Said out loud, for the same reason the npm detector says it. A Makefile
# whose target is `tests` or `ci` contributes nothing here, and in a project
# that ALSO has a package.json the run still prints a full green table — one
# that silently excludes the Makefile's suite. Contributing zero steps is
# information; contributing zero steps quietly is the failure.
if [ -z "$matched" ]; then
say "note: $mk is here, but none of its targets are named like a check. Looked for:"
say " $MAKE_TYPECHECK_TARGETS $MAKE_LINT_TARGETS $MAKE_TEST_TARGETS"
say " Add the real names to the candidate lists near the top of this file."
fi
}
detect_cargo() {
[ -f Cargo.toml ] || return 0
if ! have cargo; then
add_skip "cargo" "Cargo.toml is here but cargo is not on PATH"
return 0
fi
if cargo fmt --version >/dev/null 2>&1; then
add_step "cargo:fmt" lint "cargo fmt --all -- --check"
else
add_skip "cargo:fmt" "rustfmt is not installed (rustup component add rustfmt)"
fi
if cargo clippy --version >/dev/null 2>&1; then
# `-D warnings` is not strictness for its own sake: without it clippy prints
# its findings and exits 0, so the step passes whatever it found — a check
# that cannot fail. Loosen it here if this project has warnings it has
# decided to keep, but loosen it visibly.
add_step "cargo:clippy" lint "cargo clippy --all-targets -- -D warnings"
else
add_skip "cargo:clippy" "clippy is not installed (rustup component add clippy)"
fi
add_step "cargo:test" test "cargo test"
}
detect_python() {
[ -f pyproject.toml ] || return 0
PY_MATCHED=""
# Configured in pyproject means the project uses it. A tool that merely
# happens to be installed on this machine is not a check this project has,
# and running it would invent a standard the repository never agreed to.
py_tool() { # section-regex binary class command
grep -qE "$1" pyproject.toml || return 0
PY_MATCHED="yes"
if have "$2"; then
add_step "py:$2" "$3" "$4"
else
add_skip "py:$2" "configured in pyproject.toml but '$2' is not on PATH — activate the virtualenv, or install it"
fi
}
py_tool '^\[tool\.ruff' ruff lint "ruff check ."
py_tool '^\[tool\.black' black lint "black --check ."
py_tool '^\[tool\.mypy' mypy typecheck "mypy ."
# pytest is matched anywhere in the file, not only as a [tool.pytest] section:
# most projects configure nothing and simply depend on it.
py_tool 'pytest' pytest test "pytest"
if [ -z "$PY_MATCHED" ]; then
say "note: pyproject.toml is here, but it configures none of the tools this"
say " looks for (ruff, black, mypy, pytest). No Python check was run."
fi
}
detect_go() {
[ -f go.mod ] || return 0
if ! have go; then
add_skip "go" "go.mod is here but go is not on PATH"
return 0
fi
add_step "go:vet" lint "go vet ./..."
add_step "go:test" test "go test ./..."
}
detect_guards
detect_node
detect_make
detect_cargo
detect_python
detect_go
# ---------------------------------------------------------------------------
# Nothing detected is a failure, and the loudest one here.
#
# A verify that checks nothing and prints success is worse than no verify at
# all: it converts "I have not checked" into "it passed" for everyone
# downstream, including the hook that trusts the exit code.
# ---------------------------------------------------------------------------
if [ "${#STEP_ID[@]}" -eq 0 ]; then
say "NO CHECKS DETECTED. Nothing was verified and nothing passed."
say ""
say "Looked at the repository root for: package.json scripts, a Makefile"
say "target, Cargo.toml, pyproject.toml, go.mod, and VERIFY_GUARD_DIR"
say "(currently ${GUARD_DIR:-unset})."
say ""
say "Give it something to run: add a check script to package.json, add a"
say "target to the Makefile, or set VERIFY_GUARD_DIR to a directory of"
say "executable guard scripts. Then run this again."
exit 2
fi
if [ -n "$ONLY" ]; then
n=${#STEP_ID[@]}
kept=""
for ((i = 0; i < n; i++)); do
case "${STEP_ID[$i]}" in
*"$ONLY"*) kept="yes" ;;
# An existing reason wins. A step already skipped because its tool is not
# installed is not skipped BY --only, and overwriting the reason would
# report that it was filtered out when in fact it could not have run —
# turning a real gap in the checks into a deliberate-looking choice.
*) [ -n "${STEP_SKIP[$i]}" ] || STEP_SKIP[$i]="--only $ONLY" ;;
esac
done
[ -n "$kept" ] || die "--only '$ONLY' matched none of the detected steps: $(printf '%s ' "${STEP_ID[@]}")"
fi
# --quick, resolved against the plan rather than assumed. A SLOW_STEP naming a
# step this project does not have is a stale config that would silently skip
# nothing while reporting that it skipped, so it is refused.
if [ -n "$QUICK" ]; then
n=${#STEP_ID[@]}
hit=""
for ((i = 0; i < n; i++)); do
if [ -n "$SLOW_STEP" ]; then
[ "${STEP_ID[$i]}" = "$SLOW_STEP" ] || continue
else
[ "${STEP_CLASS[$i]}" = "test" ] || continue
fi
[ -n "${STEP_SKIP[$i]}" ] || STEP_SKIP[$i]="--quick"
hit="yes"
done
if [ -z "$hit" ]; then
if [ -n "$SLOW_STEP" ]; then
die "VERIFY_SLOW_STEP is '$SLOW_STEP' but no detected step has that id: $(printf '%s ' "${STEP_ID[@]}")"
fi
say "note: --quick had no effect — no detected step is a test step."
elif [ -z "$SLOW_STEP" ]; then
say "--quick: skipping the test steps (set VERIFY_SLOW_STEP to an id to pick a different one)."
fi
fi
n=${#STEP_ID[@]}
if [ -n "$LIST" ]; then
say "$n step(s) detected. Nothing was run:"
for ((i = 0; i < n; i++)); do
if [ -n "${STEP_SKIP[$i]}" ]; then
say " $(printf '%-18s' "${STEP_ID[$i]}") SKIPPED — ${STEP_SKIP[$i]}"
else
say " $(printf '%-18s' "${STEP_ID[$i]}") ${STEP_CMD[$i]}"
fi
done
exit 0
fi
# ---------------------------------------------------------------------------
# Run everything. No early exit on failure: the point of one entry point is one
# answer, and an answer that stops at the first broken thing makes you pay the
# whole runtime again for the second.
#
# Output is streamed rather than captured, so a step that hangs is visible
# while it hangs instead of after it is killed.
# ---------------------------------------------------------------------------
started=$SECONDS
failed=0
passed=0
skipped=0
for ((i = 0; i < n; i++)); do
if [ -n "${STEP_SKIP[$i]}" ]; then
STEP_RESULT[$i]="SKIPPED"
STEP_SECS[$i]="-"
skipped=$((skipped + 1))
continue
fi
say "── ${STEP_ID[$i]}${STEP_CMD[$i]}"
t0=$SECONDS
eval "${STEP_CMD[$i]}"
code=$?
STEP_SECS[$i]=$((SECONDS - t0))
if [ "$code" -eq 0 ]; then
STEP_RESULT[$i]="PASS"
passed=$((passed + 1))
else
STEP_RESULT[$i]="FAIL"
failed=$((failed + 1))
say "${STEP_ID[$i]} failed (exit $code) — continuing so this run reports everything."
fi
done
# ---------------------------------------------------------------------------
# The table. Three states, three words, three colours, and the word alone is
# enough when the colour is gone — this gets piped into files and hook output
# where nothing is a terminal.
# ---------------------------------------------------------------------------
say ""
say "───────────────────────────────────────────────────────────"
for ((i = 0; i < n; i++)); do
case "${STEP_RESULT[$i]}" in
PASS) colour="$C_PASS" ;;
FAIL) colour="$C_FAIL" ;;
*) colour="$C_SKIP" ;;
esac
line="$(printf ' %s%-8s%s %-26s %5s' \
"$colour" "${STEP_RESULT[$i]}" "$C_OFF" "${STEP_ID[$i]}" "${STEP_SECS[$i]}")"
if [ "${STEP_RESULT[$i]}" = "SKIPPED" ]; then
line="${line} ${STEP_SKIP[$i]}"
else
line="${line}s"
fi
say "$line"
done
say "───────────────────────────────────────────────────────────"
elapsed=$((SECONDS - started))
# Counts are stated only for things that were measured. "0 failed" alongside
# four skips is true and misleading, so the skip count is never omitted and the
# exit code below refuses to call an all-skipped run a pass.
summary="${passed} passed"
[ "$failed" -eq 0 ] || summary="${summary}, ${failed} FAILED"
[ "$skipped" -eq 0 ] || summary="${summary}, ${skipped} SKIPPED (not run — not verified)"
say "${summary} [${elapsed}s]"
if [ "$failed" -gt 0 ]; then
say ""
say "Fix the FAILED step(s) above. To re-run one on its own:"
for ((i = 0; i < n; i++)); do
# Both halves shell-quoted, because both routinely contain spaces: this
# family of projects lives under directories like "Privacy LLC", and a step
# id is a guard's filename. Printed unquoted, the command this suggests
# parses as extra arguments and dies on the copy-paste.
[ "${STEP_RESULT[$i]}" = "FAIL" ] \
&& say " bash $(q "$0") --only $(q "${STEP_ID[$i]}")"
done
exit 1
fi
# Nothing failed and nothing ran. That is not a pass, and the exit code is the
# only part of this output a hook or a CI job will read.
if [ "$passed" -eq 0 ]; then
say ""
say "NOTHING WAS VERIFIED — every detected step skipped. Exiting non-zero:"
say "a clean table over an empty run is the one outcome this script exists"
say "to prevent."
exit 2
fi
exit 0