Key insight

Treat the moment before a customer-facing push as a checkpoint, not a continuation. A six-line script that runs and exits non-zero on any failure is enough; what matters is that the script is run, every time, by the human who is about to push.

The six checks, in order

Each check below is one command (or one small chain). All six together take under thirty seconds on a typical project. Run them as the first step of every customer-facing push, not as the last.

Check 1 — Internal documentation is not tracked

Internal documentation — design notes, road-map drafts, incident write-ups, decision records — lives in a known folder (typically docs-internal/, internal/, or similar). The folder is git-ignored, but git-ignore only takes effect on files git is not already tracking. A file accidentally git add-ed yesterday stays tracked tomorrow.

The check is one line: list the files git is currently tracking under the internal folder, expect zero. If the count is non-zero, the listed files are about to ship to a customer-facing remote.

Check 2 — Generated artefacts are not tracked

Build outputs — generated state files, cached lock files for tools that are not the canonical lock, output of code generators — have a habit of being accidentally committed during a long debugging session. The same check: list the files git is currently tracking that match the generated patterns. Expect zero.

This check is especially important for self-assessment outputs, telemetry exports, and anything written by the project’s own tooling at run time. Those files are usually full of timestamps, identifiers, and other content that should never be in version control.

Check 3 — The secret scanner is clean

Run the project’s secret scanner against the working tree. Modern open-source secret scanners (gitleaks, trufflehog, the ones built into the major code-hosting services) are fast enough to run pre-push without inconvenience. They catch the obvious patterns — API tokens, signing keys, connection strings — that occasionally end up in test fixtures or scratch files.

The check fails the release if there is any finding the operator has not explicitly accepted. Accept findings via the scanner’s allow-list mechanism (with a comment explaining why), not by ignoring the scanner.

Check 4 — No tenant identifiers in history

This is the check most often forgotten and most often expensive when forgotten. Any string that maps back to a real installation — a tenant identifier, a fully-qualified domain name, an IP address, a customer name, a customer-specific resource group — in git history is data leakage.

The check is a grep across the entire git history, scoped to the patterns you know about. The exact pattern set is project-specific; build it once and keep it in a small list in the project. Common entries: tenant identifier format (a UUID-shaped value), customer FQDN patterns, internal naming conventions for resources.

If the grep finds a match, the affected commit needs to be rewritten before the push. The standard tools (git filter-repo, git rebase --interactive) handle this; the operation is unpleasant but necessary. The lesson for next time is usually that the development workflow needs a hook to catch these strings on commit rather than on push.

Check 5 — Tag selection is deliberate

Pushing tags to a remote is one of the most common ways for internal versioning to leak into the customer’s view. Dev-only tags, internal version numbers, draft tags — all of them travel when git push --tags runs.

The check is two parts. First, list the local tags. Second, decide whether the next push is intended to include tags at all; if so, push only the specific tag you intend to publish, never the bulk --tags flag.

The pattern that works long-term: a small naming convention that separates customer-facing tags from internal ones. Customer-facing tags use the project’s public versioning scheme (v1.2.3); internal tags are prefixed (dev-, build-, or similar). The pre-push check verifies that no internal-prefixed tag is among the ones being pushed.

Check 6 — The pin file is real, not a placeholder

The bootstrap repository (chapter 5) ships a placeholder pin file initially, with a zeroed digest, so the install-script shape does not break before the first signed release exists. A common operator mistake is to forget to update the placeholder when cutting a real release.

The check reads the pin file and asserts that the digest does not match the placeholder pattern (@sha256:0000… or whatever the placeholder is). Asserts that the version field matches a non-zero semantic version. Asserts that the signing identity expression is not empty.

If any of those is false, the push is aborted. A customer running the install script against a placeholder pin file gets a verification failure at best; at worst, the install script gracefully proceeds with a zero digest and ends up in an undefined state.

Where to run the checks

Three places, in increasing order of robustness:

  1. A pre-push git hook in the operator’s checkout. Runs automatically before every push. Can be bypassed with a flag, but a flag is a deliberate choice rather than an accident.
  2. A CI job that runs on every push to the customer-facing remote and blocks the merge or release if any check fails. This catches the case where the local hook is skipped.
  3. A required check on the customer-facing repository’s branch-protection rules, gated on the CI job above. This makes “skip the check” require an explicit administrative override.

For small operations, the pre-push hook alone is sufficient. For larger operations with multiple operators, all three layers are worth the configuration cost.

What the script looks like

The script is short. The exact form below uses shell commands; equivalents in PowerShell are equally short. The point is that the script is small enough to read in thirty seconds and small enough to maintain over years.

#!/usr/bin/env bash
set -e
fail() { echo "FAIL: $1" >&2; exit 1; }

# 1. Internal docs not tracked
n=$(git ls-files docs-internal | wc -l); [ "$n" = "0" ] || fail "internal docs tracked"

# 2. Generated artefacts not tracked
git ls-files | grep -E '(\.generated|\.bak|self-assess)' && fail "generated artefacts tracked"

# 3. Secret scanner clean (use whichever scanner the project uses)
gitleaks detect --no-banner --redact --exit-code 1 || fail "secret scan"

# 4. No tenant identifiers in history (project-specific pattern file)
git log -p --all -G "$(cat .release/forbidden-strings.txt)" --oneline | head -n 1 \
  | grep . && fail "forbidden strings in history"

# 5. Tag selection deliberate (no dev- or build- prefixes)
git tag -l 'dev-*' 'build-*' | head -n 1 | grep . && fail "internal tags present"

# 6. Pin file is real
jq -e '.agent.digest | test("@sha256:0000")' pin.json >/dev/null && fail "placeholder pin"

echo "OK: pre-flight clean"

The script’s structure is deliberately uniform — each check is one or two lines, each failure raises a clear message with the check name. Operators learn the script quickly and trust its output because the failures are specific.

What the script does not check

The pre-flight script is the last line of defence, not the only line. It should not check things that have a better home:

The pre-flight script’s value is precisely that it is short. Each additional check is one more thing to maintain and one more potential false positive that erodes operator trust.

What happens when a check fires

The script exits non-zero with a specific message. The operator does not push. The fix is checked into the source repository as a normal commit; the pre-flight script is re-run; the push proceeds.

The instinct to add a bypass flag (--force, --skip-preflight) is strong and should be resisted in the local hook. The cost of fixing the issue properly is low; the cost of pushing past a real finding is variable but occasionally catastrophic. The CI version of the check can have a bypass for the rare legitimate case, but the bypass should be visible in the audit log.

References & further reading