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 fromlib/*.sh.- Logging is auto-imported first if not explicitly requested to ensure logs are available.
shlib_import_allis 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- enableslog_debugoutput in logging.DOWNLOAD_USE_DIALOG- download UI preference forfile::download_file:auto(default): use dialog gauge ifdialogis 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 (portsmodule). Default:false.FRONTEND_HOST,FRONTEND_PORT- host/port forbrowser::open_frontend_when_ready.
Using as a library in other projects#
- Vendor this folder under
scripts/script-helpersor similar. - Source
helpers.shfrom 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 theadbmodule 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>. Runscripts/adb_tool.sh --helpfor details. Seemodules/adb.md. -
bin/webshot: screenshots of a running web app and HTML-to-PDF, with Playwright.bin/webshot ensureinstalls 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 amanifest.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. Seemodules/webshot.md. -
scripts/prune_branches.sh- removes branches whose work has already landed, including the squash and rebase mergesgit branch --mergedcannot see, and only when the branch gained nothing afterwards. A dry run by default;--applyto act and--remotebefore a remote branch is considered at all. Options:--repo,--base,--remote-name,--keep <glob>,--no-fetch.--remote --applyrefuses 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. Runscripts/prune_branches.sh --helpfor details. Seemodules/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:
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.
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:
.githooks/, when bothpre-commitandpre-pushare there;git-hooks/next to thesetup-hooks.shthat was run: the hooks bundled with that copy of script-helpers, wherever it is vendored, symlinked copies and runs from a subdirectory included;scripts/script-helpers/scripts/git-hooks, thenscripts/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:
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_DIRif 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:
- The check is a no-op on non-
release/*branches. --fetch-tagsreads tags from theoriginremote by default; pass--remotewhen 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 arelease/*branch, so it is safe on every pull request.--changelogis relative to--repo. - A pre-release section does not count for the final version:
release/0.2.0is 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
###headingschangelog_new_sectionwrites, fails. changelog_check_header(run bymake 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.ZorvX.Y.Z, or the same prefix as--tag) other than this version. Floating tags such asproductionare ignored; they sit on the release commit and would empty the range. For a final release, pre-release tags are skipped too, so0.2.0is described from0.1.0, not from0.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 ofX.Y.ZandvX.Y.Zexists is used. - The commit fallback needs full history. In a shallow clone it exits 1 and
says so; use
fetch-depth: 0withactions/checkout. A CHANGELOG section needs no history. --changelogis relative to--repo;--outputis 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 reachghor 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
.envis created from.env.testing, then.env.example, only when there is none, andkey:generateruns only whenAPP_KEYis 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-passwordis the MySQL image's root account, not the application's. Empty starts the image withMYSQL_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-imageruns every step in that image instead of on the host, on a shared docker network with the database. The officialphpimages 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-imagesays 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_URLand the discreteDB_*variables -- not written into a settings file. Code that readsos.environworks unchanged in CI and locally. - In Docker mode the steps join a network with the database and reach it by
container name; with
--no-dockerthe port is published to loopback instead. --env NAME=VALUEis repeatable, and is howFLASK_APPor any other per-project variable reaches the steps.scripts/ci_django.shremains the runner for a Django project: it also creates.env, applies migrations and knows whatmanage.pyis.
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_URLand the discreteDJANGO_DB_*variables -- not written into a settings file. A settings module that readsos.environworks 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-commandrunsmakemigrations --check --dry-runbetween the schema and the tests, as its own step. A model changed without a migration generated for it is invisible to the suite --migrateapplies what exists and the tests pass against it -- and it breaks a deployment rather than a test. Its own step so the failure saysMigrations failedrather 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.shstages what ships (production dependencies vendored, built assets in, development files out, honouring.distignore) and zips it.ci_wp_phpunit.shprovisions the WordPress test library and core, starts the database, runs the suite and removes the database.--wp-tests-dirreachesrm -rf, so it must be an absolute path outside the application.
Common snippets#
Minimal logging
Load .env and require variables
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):
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 |
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 inCLOUDFLARE_PROTECTED_ENVS) asks you to type the environment name. With no terminal it returns5immediately rather than blocking on a read nobody can answer - pass--yes, or setCLOUDFLARE_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.