#!/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 " ONLY="$1" ;; --only=*) ONLY="${1#--only=}" ;; *) die "unknown argument '$1'. Usage: verify.sh [--quick] [--list] [--only ]" ;; 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