Skip to content

Usage#

In your script#

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

# Adjust the path to where you vendored this repo
SCRIPT_HELPERS_DIR="${SCRIPT_HELPERS_DIR:-$(dirname "$0")/script-helpers}"

# 1) Load the loader
source "$SCRIPT_HELPERS_DIR/helpers.sh"

# 2) Import the modules you need
shlib_import logging docker dialog file json env ports browser traps certs hosts clipboard ollama os

# 3) Call functions
print_info "Project root: $(get_project_root)"
docker_status

Version bumping and comparison

shlib_import logging env version

# Bump patch version in VERSION at the project root (or pass -f to override)
version_bump patch

# Compare semantic versions (ignores leading v/V and suffixes)
version_compare "v1.10.0-rc1" "1.9.9"
case $? in
  0) echo "Same version";;
  1) echo "Left is newer";;
  255) echo "Left is older";; # 255 represents -1 from version_compare
  2) echo "Missing args";;
  3) echo "Invalid input";;
esac

Import patterns#

  • shlib_import name [name ...] sources the given modules from lib/*.sh.
  • Logging is auto-imported first if not explicitly requested to ensure logs are available.
  • shlib_import_all is available when you want everything (handy for interactive sessions).

Environment variables#

  • SCRIPT_HELPERS_DIR - absolute or relative path to this repository. The loader auto-detects; set explicitly when needed.
  • DEBUG=true - enables log_debug output in logging.
  • DOWNLOAD_USE_DIALOG - download UI preference for file::download_file:
  • auto (default): use dialog gauge if dialog is installed.
  • true/1: force dialog if available.
  • false/0/never: disable dialog UI.
  • DIALOG_DOWNLOAD_SHOW_ERROR_DIALOG - controls dialog download error popups:
  • default 1: show dialog on download failure.
  • 0/false/never: suppress popup and return non-zero to caller.
  • PORT_DETECTION_ALLOW_SUDO - allow sudo when probing ports (ports module). Default: false.
  • FRONTEND_HOST, FRONTEND_PORT - host/port for browser::open_frontend_when_ready.

Using as a library in other projects#

  • Vendor this folder under scripts/script-helpers or similar.
  • Source helpers.sh from your scripts and import the modules you need.
  • Keep examples in scripts/ handy for quick reference; you can copy/paste and adapt.

Bundled CLIs#

  • scripts/adb_tool.sh - a ready-to-use CLI over the adb module for inspecting and debugging Android devices (multi-device safe). Subcommands: list, status <serial>, ip <serial> [iface], install <serial> <apk>, install-all <apk>, push <serial> <local> <remote>, pull <serial> <remote> [local], shell <serial> <cmd...>, logcat <serial> [regex], clear-logcat <serial>, uninstall <serial> <package>. Run scripts/adb_tool.sh --help for details. See modules/adb.md.

  • bin/webshot: screenshots of a running web app and HTML-to-PDF, with Playwright. bin/webshot ensure installs Playwright into a cached venv once; bin/webshot capture <spec.json> <out_dir> saves one PNG per page listed in the spec (login profiles, clicks and fills before the capture, element clips, hidden elements, ${VAR} expansion for credentials) plus a manifest.json; bin/webshot pdf <in.html> <out.pdf> [--format Letter|A4] [--footer TEXT] prints a document with backgrounds and page numbers. Keep the spec next to the app so the same screenshots can be rebuilt after every UI change. See modules/webshot.md.

  • scripts/prune_branches.sh - removes branches whose work has already landed, including the squash and rebase merges git branch --merged cannot see, and only when the branch gained nothing afterwards. A dry run by default; --apply to act and --remote before a remote branch is considered at all. Options: --repo, --base, --remote-name, --keep <glob>, --no-fetch. --remote --apply refuses to run without a successful fetch, and each remote deletion is leased on the tip that was classified, so a branch that moved in the meantime is kept. Run scripts/prune_branches.sh --help for details. See modules/git_branches.md.

  • scripts/check_private_names.sh - refuses text that names a private repository of yours, before it becomes public. Scans the tracked tree (--tree), the messages of commits about to be pushed (--commits <range>), a pull request or issue body written to a file (--file), or standard input (--stdin).

The rule is about what becomes public. With --only-public the check does nothing at all in a repository your list marks private - a private repository naming another private repository is fine, and only the public ones are gated. A repository the list has never heard of is checked rather than assumed private: an unknown repository is most likely one created since the list was made, and those are exactly where a wrong guess would hide a leak. That is what lets one hook be installed everywhere instead of only where someone remembered to configure it.

Matching is grep -iF, deliberately not -w: a hyphen is a word boundary, so -w misses a name inside a longer path or a possessive. A name your list flags ambiguous - one that is also an ordinary word - must instead appear as a whole token, not preceded or followed by a letter or digit, so beaconed and unbeaconed stop matching while beacon, owner/beacon and <beacon>/path still do. Ambiguous names are checked everywhere, including the tree.

Exit codes: 0 clean, 1 a name was found, 2 it could not check. A missing or empty list is a failure, never a pass, because a green line from a check that scanned nothing is read as evidence.

Sometimes it will be wrong - certainly so, for a name that is also an everyday word. Three ways past it, and each says what it allowed rather than shrinking the check silently:

override scope
PRIVATE_NAMES_ALLOW="term,term" one run
.git/private-names-allow one repository, from every worktree of it (inside .git, so it cannot be committed)
<config>/script-helpers/private-names-allow every repository on this machine

Each prints what it allowed, so an override is never silent. An allowed word is freed as a word, not as a name: it is still looked for in the shape of a repository reference (beacon.git, "the beacon repo", cd beacon, a clone target, ~/Projects/beacon/, host/owner/beacon), in the tree as well, and a hit warns, or fails under --strict-ambiguous; a boat beacon, app/beacon/ and beacon in back-ticks stay silent. git push --no-verify remains git's own escape. Run scripts/check_private_names.sh --help for details.

PRIVATE_NAMES_SELF=<name> is not an override: it names the repository being checked, whose own name is then not a hit in any shape. prepare_public.sh sets it from --self, so a README that clones the repository passes.

The machine's list starts from config/private-names-allow: ordinary English words that show up in code and docs everywhere (windows, repository, code, writing, ...), never a repository name. It is copied to <config>/script-helpers/private-names-allow when that file is missing or holds no terms; a list you wrote, or emptied on purpose with only comments, is left alone. Add your own words there, one per line; # starts a comment. A word that is ordinary English for everyone belongs in the shipped file, by pull request.

  • scripts/refresh_private_names.sh - builds that list from every account the token can see: you, plus your organizations.
scripts/refresh_private_names.sh                  # all of them
scripts/refresh_private_names.sh --owner <o> --ttl 0   # re-index one, now
scripts/refresh_private_names.sh --check          # compare with GitHub, write nothing

It writes ${XDG_CONFIG_HOME:-~/.config}/script-helpers/private-names.tsv: one file per machine serving every repository on it, and outside every working tree, because a file listing your private repositories is precisely what the gate exists to keep out of public ones.

The format, tab-separated with # comments, one row per repository:

# private-names v1
# generated: 2026-09-20  source: <namespaces>
# visibility  namespace   name    code    flags
private   acme-corp   widget-core -   never-name
private   someuser    beacon  R-588   ambiguous
public    someuser    script-helpers  -

The columns are located by that header line, not by position. That is not ceremony: the format gained the namespace column, and a parser reading the new file by the old positions matched namespaces instead of repository names - so it reported "no private repository is named" over text that named one. A silent pass is the worst failure this check has, and a self-describing file makes that particular mistake unrepresentable. A file with no header is read as the v1 order.

flags is space-separated. never-name - no acceptable public reference exists at all, so the refusal says to remove it rather than offering a code. ambiguous - also an everyday word. qualified-only - a bare mention is not a reference.

ambiguous and qualified-only mean the same thing to the matcher: only namespace/name counts. A bare "search" or "core" in English cannot be told from a reference to a repository of that name - measured, matching those bare produced 50 hits in this repository alone, in prose and in identifiers, and blocked a commit whose only sin was a shell function called search(). Requiring the qualified form takes the same tree to zero. The matcher also applies that rule to any name of four characters or fewer, anything beginning with a dot, well-known directory names, and words that are in half the file names of any repository (coverage), whatever the file says - because whether a bare mention can be a reference is a property of the name, and a generator that forgot to flag one should not silently turn this check into noise.

A row whose name is * marks the namespace itself. It takes --owner repeatably, or PRIVATE_NAMES_OWNERS; it does not infer the owner from the repository you happen to be in, because a refresh run inside a third party's repository would rebuild your machine-wide dictionary from their namespace. It needs gh, authenticated, and exits 3 when that is missing - distinctly from "your account has no private repositories", which is exit 2 and writes nothing, since an empty list would leave a check that scans for nothing and reports success.

This is the only part that touches the network, and that is deliberate. A check that called GitHub would add a round trip to every commit and every push, to catch an event - a repository being created, or changing visibility - that you perform deliberately a few times a month. Refresh after one of those, or on a cadence of days. The list carries a # generated: header and the gate reads its age from that, never from mtime, which any copy or restore rewrites: past seven days it says so, past thirty it says so loudly, and age alone never refuses a push, because a month-old list still holds nearly every name and blocking work over a stale file is how a gate gets uninstalled.

The code column is for citing a private repository in public text without naming it. If you have no such scheme it stays - and the gate simply names what it found instead of advising a convention you do not have.

Shared include and dependency check#

Recommended layout in the consuming repo (supports direct calls and symlinks):

./
  scripts/
    include.sh
    update.sh
    script-helpers/    # git submodule
    build.sh
  update -> scripts/update.sh
  build -> scripts/build.sh

How to create scripts/include.sh#

1) Create scripts/include.sh with the loader below. 2) Source it from every script: source "$SCRIPT_DIR/include.sh". 3) Call require_script_helpers <modules...> at the top of each script.

scripts/include.sh (loader for all scripts):

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

INCLUDE_SOURCE="${BASH_SOURCE[0]}"
while [ -h "$INCLUDE_SOURCE" ]; do
  INCLUDE_DIR="$(cd "$(dirname "$INCLUDE_SOURCE")" && pwd)"
  INCLUDE_SOURCE="$(readlink "$INCLUDE_SOURCE")"
  if [[ "$INCLUDE_SOURCE" != /* ]]; then
    INCLUDE_SOURCE="$INCLUDE_DIR/$INCLUDE_SOURCE"
  fi
done

SCRIPT_DIR="$(cd "$(dirname "$INCLUDE_SOURCE")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SCRIPT_HELPERS_DIR="${SCRIPT_HELPERS_DIR:-$ROOT_DIR/scripts/script-helpers}"
HELPERS_PATH="$SCRIPT_HELPERS_DIR/helpers.sh"

script_helpers_hint() {
  printf "ERROR: script-helpers dependency not found.\n" >&2
  printf "Run: git submodule update --init --recursive\n" >&2
  printf "Or:  git clone <repo-url> scripts/script-helpers\n" >&2
  printf "Or:  ./update\n" >&2
}

load_script_helpers() {
  # Shared loader so both call sites stay in sync.
  if [[ ! -f "$HELPERS_PATH" ]]; then
    script_helpers_hint
    return 1
  fi
  # shellcheck source=/dev/null
  source "$HELPERS_PATH"
  if [[ "$#" -gt 0 ]]; then
    shlib_import "$@"
  fi
}

require_script_helpers() {
  # Fail fast with a friendly prompt instead of sourcing a missing file.
  load_script_helpers "$@" || return 1
}

load_script_helpers_if_available() {
  # Soft guard; prints a hint but returns success when missing.
  if [[ ! -f "$HELPERS_PATH" ]]; then
    script_helpers_hint
    return 0
  fi
  load_script_helpers "$@"
}

If script-helpers is missing, the loader prevents a hard error and tells the user to install it or run ./update. Use require_script_helpers for scripts that must stop when helpers are missing, and load_script_helpers_if_available for bootstrap scripts that should continue (like ./update).

scripts/build.sh (safe source for direct call or symlink):

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

SCRIPT_SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SCRIPT_SOURCE" ]; do
  SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
  SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")"
  if [[ "$SCRIPT_SOURCE" != /* ]]; then
    SCRIPT_SOURCE="$SCRIPT_DIR/$SCRIPT_SOURCE"
  fi
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"

# shellcheck source=/dev/null
source "$SCRIPT_DIR/include.sh"
require_script_helpers logging help

print_info "script-helpers is available"

scripts/update.sh (submodule bootstrap):

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

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# shellcheck source=/dev/null
source "$ROOT_DIR/scripts/include.sh"
load_script_helpers_if_available help

if ! command -v git >/dev/null 2>&1; then
  echo "Error: git is not installed."
  exit 1
fi

cd "$ROOT_DIR"
git submodule sync --recursive
git submodule update --init --recursive --remote

Create root symlinks:

ln -s ./scripts/update.sh ./update
ln -s ./scripts/build.sh ./build

CI helper scripts#

These scripts help reproduce CI steps locally and default to Docker-based execution (use --no-docker to run on the host). They are intended for local use only and will refuse to run when CI=true.

./scripts/ci_node.sh --workdir frontend
./scripts/ci_python.sh --workdir backend --requirements requirements.txt --test-cmd "pytest -q"
./scripts/ci_flutter.sh --workdir . --skip-test --build-cmd "flutter build appbundle --release"
./scripts/ci_gradle.sh --workdir . --skip-detekt
./scripts/ci_go.sh --workdir scanner
./scripts/ci_security.sh --workdir backend --python-req requirements.txt

Override Docker image versions with --version (tag) or --image (full reference):

./scripts/ci_node.sh --version 22-bookworm
./scripts/ci_python.sh --version 3.12-slim
./scripts/ci_go.sh --image golang:1.23-alpine
./scripts/ci_security.sh --gitleaks-version v8.21.2

For enhanced supply-chain security, pin images to a specific digest:

./scripts/ci_flutter.sh --digest sha256:d18e043566d957a358fdfa063e53381a303245b4b68e7c0e2ece82b71183537c
./scripts/ci_security.sh --gitleaks-digest sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789

Services and a repository's own verbs: ./dev start#

start, stop, restart, status and logs are the same in every repository. Each tries, in order: project_<verb> in scripts/project.sh, then service when the repository declares SVC_BACKEND (SVC_PROCS for native processes, SVC_COMPOSE_FILES for compose), then "not applicable" (exit 3). stop without SVC_BACKEND still stops a compose file's stack with containers kept; logs android and logs ios stay device logs. status also prints SVC_STATUS_URL when it is set.

Any other verb runs project_<verb>, with its arguments as typed (a - in the verb is _ in the name: ./dev user-add bob runs project_user_add bob), and ./dev --help lists those verbs. A repository may add verbs, not redefine a standard one. ./dev.ps1 does the same with Project-<Verb> in scripts/project.ps1, and runs the service verbs through Git Bash, so there is one implementation.

Root shims: bash scripts/install_dev_cli.sh --repo <path> --shims service writes start stop restart status logs, each execing ./dev <verb>.

Exit Meaning
0 done
1 failed
2 unknown verb or bad options
3 not applicable in this repository

Security scan: ./dev scan#

./dev scan (bash and PowerShell) runs only the secret and dependency scan, preflight.sh --security-only. gitleaks (and foxguard) scan the repository once. The dependency audits run in each Python and Node project preflight knows, from .preflight or detection, in that project's directory, each with its own summary line (python (backend/) dependency audit): pip-audit reads a requirements file, or else the pyproject.toml (pip-audit .); safety needs a requirements file; bandit leaves .venv, venv, node_modules, build and dist out; npm audit needs a lockfile. An audit that could not run (a missing tool, no manifest, no lockfile) is a SKIP line with the reason, never a PASS. No stack is needed. --docker runs the tools from the pinned images. The --quick run (./dev test) leaves the audits out (pip-audit goes to the network for each project, about 15 s) and says so in one SKIP dependency audits line.

It fails (exit 1) on findings, through ci_security.sh --fail-on-findings, and gitleaks then scans what git tracks, history included. Files git ignores are left out: a secret in .env belongs there, and third-party code in .venv or node_modules is not the repository's. A secret that was ever committed is still found after the file is deleted, because it is still in the history; rotate it. --workdir limits the dependency audits to that directory; the secret scan still reads the whole repository's history, from the repository top, so its .gitleaks.toml and .gitleaksignore apply.

A full ./dev preflight and the --quick run split the step in two. The secret scan (gitleaks, git mode) blocks: a committed secret fails the run. (The pre-push hook scans the pushed commits itself; see Security gates in the git hooks.) It reads only the commits no remote has yet (--gitleaks-log-opts "HEAD --branches --tags --not --remotes"), which is what a push can send, from any branch; a test fixture flagged in history that is already pushed does not block. ./dev scan still reads the whole history. A blocked push names each finding's rule, file, line and fingerprint, with the secret redacted. A real secret: remove it from the commit and rotate it. A false positive: add its fingerprint to .gitleaksignore, or end the line with # gitleaks:allow in the commit that adds it. foxguard and the dependency audits stay report only: they print findings, do not block, and their summary lines say (report only). A local preflight installs a missing gitleaks or foxguard before it scans: the pinned release for Linux, macOS or Windows (Git Bash), checked against the SHA-256 in lib/ci_defaults.sh, into ~/.cache/script-helpers/. No sudo. A gitleaks already on PATH (brew, apt, scoop) is used instead. PREFLIGHT_NO_INSTALL=1 turns this off. When the install fails (offline), both report SKIP gitleaks secret scan instead of implying secrets were scanned. Preflight never runs in CI, so CI is unaffected. To install by hand: bash scripts/ci_security.sh --install-gitleaks (or --install-foxguard).

The scan also runs foxguard, a static analysis scanner, at the version pinned in lib/ci_defaults.sh, on the host in both modes (there is no image of it). Its findings count only in a repository that has a foxguard config: .foxguard.yml, .foxguard.yaml, foxguard.yml or foxguard.yaml, found from the scan directory upward, as foxguard finds it; elsewhere ./dev scan prints how many there are and passes. On this library its bash rules flagged 93 ordinary lines such as rm -f "$tmp", so a repository opts in once it has tuned them: disabled rules or a baseline in .foxguard.yml, or # foxguard: ignore[rule-id] on a line. Submodules are excluded; their findings belong to the vendored project. A config above the repository (a stray ~/.foxguard.yml) is ignored, so every machine gets the same answer: the scan always names its config with --config.

Situation Result
no foxguard config the number of findings printed, exit 0
a foxguard config, findings or a scan error listed, exit 1 (the pre-push run reports only)
foxguard not installed skipped; SKIP foxguard code scan in the summary
a foxguard other than the pinned version a warning; it still runs

bash scripts/ci_security.sh --install-foxguard downloads the pinned binary, checks it against the SHA-256 pinned beside the version, and caches it under ~/.cache/script-helpers/foxguard/; that copy is used before one on PATH. --skip-foxguard leaves it out.

./dev scan
./dev scan --docker
bash scripts/ci_security.sh --no-docker --fail-on-findings   # the same, directly
bash scripts/ci_security.sh --install-foxguard               # the pinned foxguard, checksum-checked

Browser tests: ./dev e2e#

./dev e2e (bash and PowerShell) runs Playwright in every Playwright project: a directory with a playwright.config.* whose package.json depends on @playwright/test or playwright. It runs npx playwright install, then npx playwright test with any extra arguments. Configs are found among the files git sees (tracked, or untracked and not ignored), so node_modules, ignored directories and git submodules (script-helpers itself, usually) are left out. Each directory runs once, however many configs it holds. It is not part of preflight: a browser run is too slow for every push.

Situation Result
no playwright.config.* not applicable, exit 3
a config whose package.json does not depend on Playwright (a vendored copy) skipped, with a note
Playwright in no node_modules from the project up to the repository root (a workspace install at the root counts) exit 1: run ./dev install first
tests fail exit 1
project_e2e / Project-E2e defined that runs instead

Browsers are downloaded on the first run and cached (~/.cache/ms-playwright). PLAYWRIGHT_BROWSERS="chromium" limits the download to the browsers you name.

./dev e2e
./dev e2e tests/login.spec.ts --headed
PLAYWRIGHT_BROWSERS=chromium ./dev e2e

Git hook setup and local test runners#

Git hooks is the guide: what runs on commit and push, setup, settings. The details of the installer follow. Install the shared git hooks from your repository root. The command differs depending on whether you are working inside the script-helpers repo itself or in a consuming repo that vendors it as a submodule:

# Inside the script-helpers repo
bash scripts/setup-hooks.sh

# In a consuming repo, from wherever script-helpers is vendored
bash scripts/script-helpers/scripts/setup-hooks.sh
bash vendor/script-helpers/scripts/setup-hooks.sh

The installer picks the hook directory in this order:

  1. .githooks/, when both pre-commit and pre-push are there;
  2. git-hooks/ next to the setup-hooks.sh that was run: the hooks bundled with that copy of script-helpers, wherever it is vendored, symlinked copies and runs from a subdirectory included;
  3. scripts/script-helpers/scripts/git-hooks, then scripts/git-hooks.

Only the hooks bundled with the setup-hooks.sh that runs (2.) are set in core.hooksPath directly. .githooks/ or another vendored copy (1., 3.) runs chained after the current security checks, so an old vendored copy cannot leave a clone without them. Otherwise it stores a repo-relative path in core.hooksPath. Shared hooks still defer to a matching repo-local .githooks/pre-commit or .githooks/pre-push when one is present (same-file recursion guard included), so a repository whose .githooks/ holds only pre-push, such as a protected-refs guard, gets the shared hooks and still runs its own.

What a push checks, the settings and exceptions, and long pushes over SSH are on Git hooks.

The same hooks also refuse credentials, personal identifiers and PII, and print foxguard warnings; see Security gates in the git hooks, including the machine-wide install (setup-hooks.sh --global).

Stopping a private repository's name before it is published#

Three hooks call scripts/check_private_names.sh, covering three surfaces:

hook surface stops
commit-msg the commit message the message becoming a commit object
pre-push the tracked tree, and every commit message being pushed it reaching the remote
claude-hooks/pretooluse_private_names.sh a pull request or issue body the tool call running at all

The commit check is a commit-msg hook rather than part of pre-commit for a mechanical reason: a pre-commit hook runs before the message is written and cannot see it.

The third is not a git hook. gh pr create --body ... never touches git, so no git hook can see a pull request body - and that is the surface where this rule has actually been broken. It is a Claude Code PreToolUse hook that inspects the title, body and message arguments of a publishing command and blocks the call before it runs. It checks only the text being published, not the whole command line, so gh pr create --repo <a-private-repo> still works: a pull request into the private repository is exactly where its name belongs. It also refuses --no-verify on commit and push, since an agent has no legitimate reason to skip a local gate - a human at a terminal is unaffected, because the hook only ever sees an agent's calls. Wire it up with:

"hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command",
  "command": "bash <path>/script-helpers/scripts/claude-hooks/pretooluse_private_names.sh" } ] } ] }

Indexing#

refresh_private_names.sh with no arguments indexes every account the token can see: you, plus your organizations, read from gh api user and gh api user/orgs. Never from the repository you are standing in -- that would rebuild the machine's dictionary from someone else's namespace.

One cache per owner under ~/.config/script-helpers/owners/, so a re-index touches what changed:

cold, five owners, 5700 repos ~60s
everything cached ~3s
one owner re-indexed (--owner X --ttl 0) ~5s

An owner must be a GitHub login (ASCII letters, digits, hyphens and _); anything else exits 2.

--owner X refreshes that owner's cache and rebuilds the file from all the caches, so indexing one organization never drops another. Default TTLs: 1 day for your own account, 14 for an organization; --ttl <days> overrides.

The listing uses GraphQL, which paginates properly. Measured against gh repo list it is the same speed (45.8s versus 49s for 5042 repositories -- the cost is 51 sequential pages, not payload), but there is no --limit to guess at. --no-graphql falls back.

An organization that reports repositories the token cannot list is warned about by name rather than skipped silently: usually SSO authorisation, and it means those names match nothing.

The pre-push hook refreshes in the background when the list is older than PRIVATE_NAMES_REFRESH_DAYS (7). It never blocks or fails a push, holds a lock so two pushes do not start two refreshes, and PRIVATE_NAMES_AUTO_REFRESH=false turns it off. A scheduler was deliberately not used: git runs hooks under bash on Linux, macOS and Windows alike, so there is nothing to install.

The pull request hook passes --strict-ambiguous; the others do not. A name that is also an everyday word is reported as a warning by default, because a person is at a terminal and can read it. Nobody reads a warning printed by a hook that then allows the call: the pull request is created, the text is public, and the warning scrolls past.

A hook that cannot check - no name list on this machine - says so and lets the commit or push through. That is deliberate and is why "could not check" has its own exit code: a setup problem must not look like a clean tree, and it must not block every commit on a new machine either.

The shared pre-commit hook refuses a staged .env or .env.* file anywhere in the tree; the committed templates .env.example, .env.sample, .env.template and .env.dist are allowed. Staged paths are read NUL-separated, so a path git would quote -- non-ASCII characters, a tab, a quote or a newline, such as ünï/.env -- is checked like any other.

Run the language-specific local test scripts directly when you want the same entry points outside a hook. Use --quick for the fast test-only path. The Python runner installs requirements.txt when present; projects using only pyproject.toml should prepare their test environment before invoking it.

The command prefix depends on context (same two-variant pattern as setup-hooks.sh above):

# Inside the script-helpers repo
bash scripts/local_test_node.sh --quick
bash scripts/local_test_python.sh --quick --dir backend
bash scripts/local_test_go.sh --quick
bash scripts/local_test_rust.sh --quick

By default the Rust gate runs against rustup's stable - what CI installs - rather than whatever cargo is first on PATH; a distribution cargo shadowing it fails with a lockfile error that names the wrong thing. RUST_TOOLCHAIN=1.80.0 pins a channel or version; --any-cargo opts out for a repository that genuinely targets the system toolchain.

preflight runs that same gate, so it needs rustup too, and it has no flag to forward. Without rustup it reports the Rust project as skipped, naming both remedies, rather than failing the run on advice the reader cannot act on. Set PREFLIGHT_RUST_ANY_CARGO=true to check against PATH's cargo instead of skipping.

bash scripts/local_test_flutter.sh --quick --dir app
bash scripts/local_test_php.sh --quick --dir backend
bash scripts/local_test_gradle.sh --quick --dir android --android

# In a consuming repo (script-helpers as a submodule under scripts/script-helpers)
bash scripts/script-helpers/scripts/local_test_node.sh --quick
bash scripts/script-helpers/scripts/local_test_python.sh --quick --dir backend
bash scripts/script-helpers/scripts/local_test_go.sh --quick
bash scripts/script-helpers/scripts/local_test_rust.sh --quick
bash scripts/script-helpers/scripts/local_test_flutter.sh --quick --dir app
bash scripts/script-helpers/scripts/local_test_php.sh --quick --dir backend
bash scripts/script-helpers/scripts/local_test_gradle.sh --quick --dir android --android

The Gradle runner uses the project's gradlew. It runs lint, unit tests and the assemble step; --quick runs the unit tests only. --android picks the Android task names (lintDebug, testDebugUnitTest, assembleDebug); without it they are detected from the build files. ps/scripts/local_test_gradle.ps1 is the Windows equivalent (-Quick, -Dir, -Android).

The PHP runner targets PHP/Laravel projects: it runs composer install (skipped with --quick), checks style with Laravel Pint when vendor/bin/pint is present, then runs the suite via php artisan test (falling back to vendor/bin/phpunit). When tests are not skipped and no runner is found, the script exits non-zero so callers do not treat "no runner" as a passing suite. Set SKIP_PHP_TESTS=1 for a style-only run that skips the (often DB-backed) test suite - handy for a pre-push on a machine without a local database. In scripts/run_repo_tests.sh, composer.json is authoritative: a repo that has it runs the PHP suite rather than falling through to the Node runner, even when a package.json is also present for Vite. If PHP or Composer is not installed, it prints a message and skips the PHP checks.

Release branch checks#

Use this script in local pre-commit hooks or CI pipelines to ensure VERSION matches release/[v]X.Y.Z[-rcN|-rc.N] branch naming. Unlike the local-only ci_{go,node,python,...}.sh wrapper scripts that enforce a CI=true guard, check_release_version.sh works in both environments:

./scripts/check_release_version.sh --version-file VERSION --fetch-tags

Reuse in other repositories#

Add a pre-commit hook in another repo that delegates to the shared script:

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

ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SCRIPT_HELPERS_DIR="${SCRIPT_HELPERS_DIR:-$ROOT_DIR/scripts/script-helpers}"

bash "$SCRIPT_HELPERS_DIR/scripts/check_release_version.sh" --version-file VERSION --fetch-tags

Notes:

  • Set SCRIPT_HELPERS_DIR if your submodule lives elsewhere.

Reusable workflow helper scripts#

These scripts are intended to be called from reusable GitHub Actions workflows or from local CI debugging sessions where you want the same shell entry points. Unlike the local-only ci_{go,node,python,...}.sh wrappers above, the ci_gitleaks_report.sh, ci_pimcore_bundle_check.sh, and ci_wp_plugin_check.sh helpers in this section are safe to call with CI=true.

Check whether a release tag already exists for a release branch:

./scripts/check_release_tag.sh --branch release/0.12.2 --repo . --fetch-tags --print-version
  • The check is a no-op on non-release/* branches.
  • --fetch-tags reads tags from the origin remote by default; pass --remote when the repository uses a different remote name.

Require that the version being released has been written up:

./scripts/check_changelog_section.sh
./scripts/check_changelog_section.sh --version 1.4.0 --changelog docs/CHANGELOG.md
  • Like check_release_version.sh, a no-op away from a release/* branch, so it is safe on every pull request. --changelog is relative to --repo.
  • A pre-release section does not count for the final version: release/0.2.0 is not satisfied by ## 2026-09-01 — v0.2.0-rc.1.
  • The section must have entries. A header with nothing under it, or only the empty ### headings changelog_new_section writes, fails.
  • changelog_check_header (run by make lint-docs) only inspects the newest header. A release branch whose version was never added to the CHANGELOG passed that and then published a release body built from commit subjects. This closes the gap.

Produce the body of a GitHub Release:

./scripts/release_notes.sh --version 1.4.0 --output body.md
./scripts/release_notes.sh --version 1.4.0 --tag v1.4.0 --repo ../other-repo
  • The CHANGELOG section for the version is preferred. With no section, or a section with no entries (a warning says so), it falls back to the commit subjects since the previous tag, and with no previous tag to the whole history.
  • "Previous tag" means the nearest version-shaped tag (X.Y.Z or vX.Y.Z, or the same prefix as --tag) other than this version. Floating tags such as production are ignored; they sit on the release commit and would empty the range. For a final release, pre-release tags are skipped too, so 0.2.0 is described from 0.1.0, not from 0.2.0-rc.1; a pre-release is described from the nearest version tag of either kind.
  • A clone with no tags other than the release tag gets the whole history and a warning, because from inside the clone a first release and tags that were never fetched look the same.
  • Without --tag, whichever of X.Y.Z and vX.Y.Z exists is used.
  • The commit fallback needs full history. In a shallow clone it exits 1 and says so; use fetch-depth: 0 with actions/checkout. A CHANGELOG section needs no history.
  • --changelog is relative to --repo; --output is relative to the current directory.
  • It says on stderr which of those it used, and when there is genuinely nothing to report it names the version and the source rather than printing a bare placeholder such as * No changes listed., which reads the same as a range computed wrongly.
  • Prefer --output: a release body should reach gh or an action through a file (body_path:), not through an interpolated string.

Normalize and evaluate a Gitleaks SARIF report:

./scripts/ci_gitleaks_report.sh \
  --output results.sarif \
  --requested-output test/tmp/results.sarif \
  --fail-on-findings true

Run the Pimcore bundle CI helper locally:

Use a compose file from the consuming repository; test/docker-compose.yml below is a caller-side placeholder.

./scripts/ci_pimcore_bundle_check.sh \
  --compose-file test/docker-compose.yml \
  --bundle-src . \
  --db-service db \
  --php-service php \
  --out-dir test/tmp

Run the WordPress plugin CI helper locally:

Use a compose file from the consuming repository; test/docker-compose.yml below is a caller-side placeholder.

./scripts/ci_wp_plugin_check.sh \
  --compose-file test/docker-compose.yml \
  --plugin-slug my-plugin \
  --plugin-src . \
  --wpcli-service wpcli \
  --wordpress-service wordpress \
  --db-service db \
  --out-dir test/tmp \
  --exclude-directories ".script-helpers,vendor,node_modules,test" \
  --exclude-files ".gitignore,.gitattributes"

--plugin-src . scans the repository, and a repository is more than the plugin. wp plugin check reports every file it is given, so a vendor/ tree, a test/ harness and the dotfiles git needs all become findings -- the hidden_files check fires once per file under a dot directory. Exclude what is not shipped.

The .script-helpers entry matters when this runs from ci-helpers' wp-plugin-check.yml, which clones this library into the caller's workspace. On one plugin that alone was 145 of 146 errors. Both options default to empty, so a caller scanning an already-packaged plugin passes neither.

Run a Laravel application's tests, locally or in CI:

# sqlite, no infrastructure at all
./scripts/ci_laravel.sh --workdir .

# against a real server; the image name decides mysql or pgsql
./scripts/ci_laravel.sh --workdir . --db-image mysql:8.0 --db-name app_test
./scripts/ci_laravel.sh --workdir . --db-image postgres:16
  • .env is created from .env.testing, then .env.example, only when there is none, and key:generate runs only when APP_KEY is missing. Laravel refuses to boot without the key, and the error it gives says nothing about the cause.
  • sqlite is a file under database/, named for the run and removed afterwards, not :memory:. Each step is a separate process -- a separate container with --php-image -- so an in-memory database dies with the step that migrated it.
  • --db-root-password is the MySQL image's root account, not the application's. Empty starts the image with MYSQL_ALLOW_EMPTY_PASSWORD; postgres ignores it. Without one of the two the image exits during its entrypoint, and what the caller sees is the readiness poll timing out with nothing about a password.
  • --php-image runs every step in that image instead of on the host, on a shared docker network with the database. The official php images ship no composer and no PDO drivers; the script names which one is missing and how to supply it rather than failing with exit 127.

Run a Python project's tests, with a database when it needs one:

# no database: the default, and what five of six measured Flask apps want
./scripts/ci_python.sh --workdir . --extra-install pytest

# with one, for the sixth
./scripts/ci_python.sh --workdir . --db-image postgres:16 --db-name app_test \
  --extra-install 'pytest psycopg[binary]'

# and anything else the steps need in their environment
./scripts/ci_python.sh --workdir . --env FLASK_APP=web:app
  • No database is started unless --db-image says so. Of the six Flask applications this was measured against, one used a database and five did not, so a runner that started postgres by default would be wrong five times in six.
  • The connection arrives as environment -- DATABASE_URL and the discrete DB_* variables -- not written into a settings file. Code that reads os.environ works unchanged in CI and locally.
  • In Docker mode the steps join a network with the database and reach it by container name; with --no-docker the port is published to loopback instead.
  • --env NAME=VALUE is repeatable, and is how FLASK_APP or any other per-project variable reaches the steps.
  • scripts/ci_django.sh remains the runner for a Django project: it also creates .env, applies migrations and knows what manage.py is.

Run a Django project's tests, locally or in CI:

# sqlite, no infrastructure at all
./scripts/ci_django.sh --workdir .

# against a real server; the image name decides the engine
./scripts/ci_django.sh --workdir . --db-image postgres:16 --db-name app_test
./scripts/ci_django.sh --workdir . --db-image mysql:8.0
  • The connection is exported as environment -- DATABASE_URL and the discrete DJANGO_DB_* variables -- not written into a settings file. A settings module that reads os.environ works unchanged in CI and on a laptop; one that CI rewrites only works where CI rewrote it.
  • sqlite is a file beside manage.py, named for the run and removed afterwards, not :memory:. Each step is its own process -- a separate container with --python-image -- so an in-memory database dies with the step that migrated it.
  • --check-command runs makemigrations --check --dry-run between the schema and the tests, as its own step. A model changed without a migration generated for it is invisible to the suite -- migrate applies what exists and the tests pass against it -- and it breaks a deployment rather than a test. Its own step so the failure says Migrations failed rather than naming whichever test happened to touch the changed model. Set it to '' for a repository that generates migrations in CI on purpose.

The default passes --noinput, and a replacement should too. --check and --dry-run both stop Django writing the migration, but neither stops the autodetector asking: a renamed field reaches input() before either flag is consulted, and the step then hangs on Was thing.old_name renamed ...? [y/N] until the job's time limit. - --python-image runs every step in that image, on a shared docker network with the database, as the invoking user so nothing lands in the project root-owned. - scripts/ci_python.sh remains the runner for a project that needs neither a database nor migrations. - With --python-image, every step is its own container, so packages are installed into .ci-python-packages inside the project and PYTHONPATH points there. A plain pip install would otherwise land in a container that is discarded, and the next step would fail with ModuleNotFoundError after the install step reported success. The directory is removed on the way out.

Build the package a WordPress plugin ships, and run a plugin's own test suite (all three WordPress scripts: Stacks):

./scripts/ci_wp_build.sh --workdir . --slug my-plugin --out-dir build
./scripts/ci_wp_phpunit.sh --wp-version 7.1 --workdir . --test-command './vendor/bin/phpunit'
  • ci_wp_build.sh stages what ships (production dependencies vendored, built assets in, development files out, honouring .distignore) and zips it.
  • ci_wp_phpunit.sh provisions the WordPress test library and core, starts the database, runs the suite and removes the database. --wp-tests-dir reaches rm -rf, so it must be an absolute path outside the application.

Common snippets#

Minimal logging

source "$SCRIPT_HELPERS_DIR/helpers.sh"
shlib_import logging
print_success "It works!"

Load .env and require variables

shlib_import logging env
load_env .env
require_env DATABASE_URL SECRET_KEY

Download with a progress gauge

shlib_import dialog file logging
dialog_download_file "https://example.com/file.iso" "/tmp/file.iso" auto
# or via file::download_file which uses the gauge automatically when available
download_file "https://example.com/file.iso" "/tmp/file.iso"

Docker compose detection and status

shlib_import logging docker
compose_cmd=$(get_docker_compose_cmd)
log_info "Compose: $compose_cmd"
docker_status

Packaging scaffolding and builds#

Commands assume script-helpers is the submodule at scripts/script-helpers; use your own path if the repository keeps it elsewhere.

Generate packaging files (debian/, RPM spec, PKGBUILD, Homebrew formula):

./scripts/script-helpers/scripts/packaging_init.sh --repo .

Build packages (expects packaging files to exist):

./scripts/script-helpers/scripts/build_deb_artifacts.sh --repo .
./scripts/script-helpers/scripts/build_rpm_artifacts.sh --repo .
./scripts/script-helpers/scripts/build_arch_artifacts.sh --repo .

Render a Homebrew formula with a release tarball SHA:

./scripts/script-helpers/scripts/render_brew_formula.sh --repo . --url <tarball_url> --sha256 <sha256>

It exits 1 when the checksum is empty or still the scaffold's REPLACE_WITH_SHA256 placeholder. Metadata values are inserted literally, so & and other awk replacement characters in commands and URLs are safe.

Publish the formula to a tap repository:

HOMEBREW_TAP_TOKEN=... ./scripts/script-helpers/scripts/publish_homebrew.sh \
  --formula packaging/brew/myapp.rb --tap-repo owner/homebrew-example

Pass the token in HOMEBREW_TAP_TOKEN rather than --tap-token: a command-line argument is visible to other users in ps. The token never reaches git's argv, the tap clone's .git/config or a bash -x trace; it is sent as an http.https://github.com/.extraheader in git's environment config. An extraheader the caller already has for github.com (a global gitconfig, GIT_CONFIG_COUNT or GIT_CONFIG_PARAMETERS, as actions/checkout sets) is reset first, so git sends exactly one Authorization header.

build_brew_tarball.sh never packs .git, .env or .env.* files (the committed templates .env.example, .env.sample, .env.template and .env.dist are kept), nor earlier <name>-*.tar.gz tarballs in an output directory inside the repo. --exclude patterns are applied first and still win.

macOS, iOS and bash 3.2#

Stock macOS runs bash 3.2 as /bin/bash. Every module here works on it; see the README for the exceptions and for how ./dev selects a newer bash when one is installed.

Verifying portability without a Mac#

make test-bash32                 # whole suite under a real bash 3.2 (Docker)
bash scripts/local_test_bash32.sh --test tests/help_test.sh
bash scripts/local_test_bash32.sh --shell   # poke around in bash 3.2
bash tests/portability_test.sh   # static scan; also runs as part of `make test`

tests/portability_test.sh fails the build on GNU-only utilities and bash-4-only syntax. The failures it prevents are the silent kind: BSD grep does not reject \s, it just never matches, and an associative array on bash 3.2 becomes an indexed one and returns plausible nonsense.

The ios preflight stack#

preflight detects an ios stack for a Flutter project that has an ios/ directory or a Podfile, and runs scripts/ci_ios.sh against it. The stack directory is the Flutter project root, not ios/.

./dev preflight                  # includes the ios stack on macOS
bash scripts/preflight.sh --list # show detected "<stack><TAB><dir>" pairs
bash scripts/preflight.sh --stack ios

Off macOS, or without Xcode, the iOS checks report SKIP with a reason rather than failing - a skip is reported separately so it cannot be mistaken for a pass. The Flutter stack builds an APK only when it can: without an android/ directory or an Android SDK it skips that step, so a Mac set up only for iOS work is not asked for the Android toolchain.

--quick skips the iOS stack entirely, and says so. Its analyze and test steps belong to the flutter stack for the same directory, so the build is the whole of what the iOS step does - and --quick is defined as skipping builds. The old behaviour ran ci_ios.sh with every step disabled and still reported a passed "ios build", which is a claim about a build that never happened.

When a check cannot run#

The Go, Node and Python runners exit 3 when they find nothing they can check, and give one reason; preflight (bash and PowerShell) reports it as SKIP with that reason, separately from passes, and the run still succeeds:

Runner Exit 3 when
local_test_go.sh no module has Go packages (a module without them is skipped, the others are tested)
local_test_node.sh package.json has no test script, or npm's placeholder that only fails
local_test_python.sh pytest, or a ruff the project configures, is not installed (the reason names the install command), or pytest collects no tests
SKIP  node (frontend/) lint + test - package.json declares no test script; nothing to test

Code that does not compile, and tests that fail, still fail. Exit 3 counts as SKIP only with the runner's reason: a test command's own exit 3 (pytest's internal error, a test script's process.exit(3)) is reported as a failure.

Pinning stacks with .preflight, and ./dev detection#

A .preflight file at the repo root lists exactly what preflight checks, one <stack> [dir] per line; blank lines and # comments are ignored. The last line counts whether or not the file ends with a newline, and an unknown stack on any line exits 2.

./dev install, ./dev build and ./dev clean find nested projects (a Flutter app in mobile/, for example) through preflight --list. That detection also works under CI=true: preflight itself refuses to run in CI, so the dev CLI clears CI for the --list call only, which detects and runs no checks.

Options that forward a value (--stack, --dir, --platform, --out, --seconds, --size, --bitrate) keep that value with the flag, so ./dev preflight --stack ios does not take ios as the target. A next word that starts with - is another option, not the value: ./dev preflight --stack --help shows help, and --dir --release still sets --release. The flag is then forwarded alone and the script it goes to reports the missing value.

Deploying to an iOS simulator#

./dev deploy ios                   # build for the simulator, install, launch
./dev deploy ios --device <udid>   # when more than one simulator is booted
./dev run ios                      # flutter run against a booted simulator
./dev build ios --release          # signed IPA when IOS_EXPORT_OPTIONS_PLIST is
                                   # set, an unsigned .app when it is not

A debug deploy ios builds with flutter build ios --simulator; a plain flutter build ios targets a physical device and produces an .app that simctl install cannot use. With no simulator booted, or with more than one and no --device, it fails with a message naming the fix rather than guessing.

The two modes are not interchangeable. --release builds a signed .ipa and installs it through devicectl onto an attached device, so it resolves an attached device rather than a simulator, and stops at installed - simctl launch has no devicectl equivalent that works without a debug session. A debug deploy targets a booted simulator.

build and deploy differ on the plist deliberately. build ios --release without one is fine: ios_build_release falls back to flutter build ios --release --no-codesign, and an unsigned .app is a legitimate build output. Installing one is not, so IOS_EXPORT_OPTIONS_PLIST is required for deploy ios --release, and is checked before the build starts. Without that check the same fallback runs, writing an unsigned .app and nothing under build/ios/ipa, and the install step would pick up a stale .ipa left by an earlier signed build.

Set IOS_EXPORT_OPTIONS_PLIST for a signed release build, and IOS_DEVICE to pin a simulator without passing --device each time.

Deploying to Cloudflare: one definition, two lanes#

./dev deploy cloudflare --env staging --config wrangler.toml --status-path /api/status
./dev deploy cloudflare --env production --yes        # unattended; see below
./dev deploy cloudflare --env staging --dry-run       # build and validate, deploy nothing

Options after the target word are passed straight to cloudflare_deploy, so ./dev is a thin entry point rather than a second place where deploy options are enumerated and then drift.

The same function is what a CI job calls. A reusable workflow supplies the credentials, the environment gate and the kill switch, then invokes ./dev deploy cloudflare as its deploy command - so the laptop and the pipeline run one sequence, not two that have to be kept in step by hand.

Where CI has already decided a value it exports it, and the matching function returns it verbatim instead of computing its own:

Exported by CI Consumed by
CF_DEPLOY_VERSION cloudflare_version_string
CF_DEPLOY_BASE_URL cloudflare_base_url

A single run therefore never has two live definitions of the version string. CI's definition wins in CI, this library's wins on a laptop, and neither is a copy of the other.

Two behaviours worth knowing before you automate anything:

  • production (and anything in CLOUDFLARE_PROTECTED_ENVS) asks you to type the environment name. With no terminal it returns 5 immediately rather than blocking on a read nobody can answer - pass --yes, or set CLOUDFLARE_DEPLOY_YES=1.
  • The smoke test asserts the version, not just a 200. Give it --status-path; without one it only proves something answered, which is the failure it exists to catch, and it warns saying so.

Secret scanning#

.gitleaksignore carries one line per finding a person has looked at and accepted, as path:rule:line. Two entries today, both false positives on a variable name rather than a value: an empty db_password="" default, and a dummy tap token in a test whose whole point is asserting the token never reaches argv or .git/config.

Fingerprints, not allowlists, and that distinction is measured rather than assumed. A .gitleaks.toml allowlist keyed on a path excludes that entire file from every rule - even with condition = "AND", which reads as though it should narrow it. Two randomly generated secrets planted in the allowlisted files took this repository from 4 findings to 0. The same two planted with the fingerprint file in place are still reported. A fingerprint names one rule at one line and cannot do anything else.

So after editing that file, check it the only way that means anything:

# plant a random secret in one of the ignored files, confirm it is still caught
printf '\napi_key="%s"\n' "$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 40)" \
  >> scripts/ci_pimcore_bundle_check.sh
# the pinned image and version live in lib/ci_defaults.sh, so this does not go stale
. lib/ci_defaults.sh
docker run --rm -v "$PWD:/repo" -w /repo \
  "${CI_DEFAULT_GITLEAKS_IMAGE}:${CI_DEFAULT_GITLEAKS_VERSION}" \
  detect --source=. --no-git --redact
git checkout -- scripts/ci_pimcore_bundle_check.sh

Scan with --source=. rather than an absolute path: a fingerprint records the path as the scanner saw it, and --source=/repo writes /repo/... into it, which then matches nothing anywhere else.

TruffleHog has no fingerprint file, and its only narrow control is a trufflehog:ignore comment on the offending line - which a line ending in a \ continuation cannot carry. Rather than excluding whole paths, run it against the working tree and read the unverified results:

trufflehog filesystem . --exclude-paths <(echo '^\.git/')   # commit SHAs are 40 hex
                                                            # chars and match the
                                                            # Cloudflare key shape

Excluding .git is not suppression of content - it is scanning the working tree rather than the object store, and it removed 18 of 19 findings here. The one that remains is the dotenv parser's connection-string fixture, reported unverified. Leaving it visible and triaged is the intended state; a gate that blocks should use --results=verified.