From 7241049953877d16837ffa5b4b93d662554e577f Mon Sep 17 00:00:00 2001 From: "@scott-wueschinski-GTMify" Date: Wed, 29 Jul 2026 02:12:18 -0400 Subject: [PATCH] feat: junk-file gate, shell gate, and the reusable gate workflow First deliverable of the uniform-CI plan. One reusable workflow that many repos call, so a rule is fixed once instead of thirty times. WHY A PUBLIC REPO. A private reusable workflow can only be called from inside the same organization or user account, and 4 of the 10 active repos live under the personal scott-wueschinski-GTMify account rather than the GTMify org. An org-private host would have reached 6 of 10 and left the rest on a vendored copy that drifts. Nothing secret lives here: workflow YAML and check scripts only. THE JUNK-FILE GATE is the check that pays for itself. Nothing stopped 33 .claude/worktrees/* gitlinks and 40 .pos-supervisor/* files, one a SQLite analytics.db-wal, from becoming tracked in GTMify/GTMify. A write-ahead log is rewritten on nearly every run, so that repo was permanently dirty, the session-end auto-commit hook turned the dirt into a commit every time, and local master drifted 19 commits off origin carrying no app code at all. gtmify-config has a committed hooks/Icon for the same reason: no gate. Three modes: diff against a base (what a PR would add), --staged (for the pre-commit hook), and --audit (one-time cleanups). Only TRACKED paths can fail, because every mode enumerates through git; a gitignored file on disk is invisible by construction. The gate objects to junk being tracked, not to junk existing. .ci-junk-allowlist is a deliberate escape hatch, since a gate with no legitimate override gets switched off the first time it is wrong. Verified with 35 cases, one throwaway repo each, dirty in exactly one way. The two that carry the most weight: ignored_but_present proves the tracked-only property, and icon_in_a_longer_name pins the exact false-positive class that made auto_commit_on_exit.sh silently drop changes to a file named "My Icon Design.txt". The suite was also observed FAILING, not only passing. Removing the worktree pattern from the gate turns 3 cases red; restoring it returns 35/0. self-test.yml runs that mutation on every PR here, so the tests cannot quietly stop asserting. HOUSE STYLE IS OFF and the input is wired but defaulted false. The linter lives in the private GTMify/claude-house-style repo, which a public workflow cannot read without a token. That needs a decision (make the linter public, or mint a read-only PAT) rather than 10 repos each carrying a secret. ENFORCEMENT LIMIT, stated plainly: the org is on the free plan, where branch protection and rulesets return 403 on private repos. These checks run and show red but cannot be required. Real enforcement for the junk gate comes next, from a local pre-commit hook that blocks junk before it can be committed at all. One incidental finding worth recording: a comment line beginning with the word shellcheck is parsed as a directive and fails the file. shell_gate.sh's own docblock had to be reworded. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/gate.yml | 172 ++++++++++++++++++++++++++++ .github/workflows/self-test.yml | 80 +++++++++++++ .gitignore | 29 +++++ README.md | 77 ++++++++++++- scripts/junk_file_gate.sh | 197 ++++++++++++++++++++++++++++++++ scripts/shell_gate.sh | 132 +++++++++++++++++++++ tests/junk_file_gate_test.sh | 181 +++++++++++++++++++++++++++++ 7 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gate.yml create mode 100644 .github/workflows/self-test.yml create mode 100644 .gitignore create mode 100755 scripts/junk_file_gate.sh create mode 100755 scripts/shell_gate.sh create mode 100755 tests/junk_file_gate_test.sh diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..173a61e --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,172 @@ +name: gate + +# Shared quality gate, called by every GTMify repo except GTMify/GTMify (the app), +# which has its own three workflows built around platformOS constraints that apply +# nowhere else. +# +# WHY THIS LIVES IN A PUBLIC REPO +# +# A private reusable workflow can only be called from within the same organization +# or user account. Four of the active repos live under the personal +# scott-wueschinski-GTMify account rather than the GTMify org, so an org-private +# host would have reached six of ten and left the rest on a vendored copy that +# drifts. A public workflow is callable from any repo, private ones included. +# Nothing secret lives here: workflow YAML and check scripts only. +# +# ENFORCEMENT, AND ITS LIMIT +# +# The GTMify org is on the free plan, where branch protection and rulesets return +# 403 on private repos. These checks therefore RUN and show red on a pull request, +# but cannot be made required. Real enforcement for the junk gate comes from the +# local pre-commit hook in gtmify-config, which blocks junk before it can be +# committed at all, and does not depend on a GitHub plan. + +on: + workflow_call: + inputs: + tier: + description: "a = config repo, b = builds and deploys, c = content only" + required: true + type: string + build_cmd: + description: "Tier B only. Shell command that must succeed, e.g. 'npm ci && npm run build'" + required: false + type: string + default: "" + node_version: + description: "Tier B only. Node version for the build step." + required: false + type: string + default: "24" + house_style: + description: > + Off by default and deliberately so. The linter lives in the PRIVATE + GTMify/claude-house-style repo, which this public workflow cannot read + without a token. Turning this on requires passing house_style_token. + Resolve by either making that linter public or minting a read-only PAT; + until then house style is enforced by the local write-time hook, which + already covers the common case. + required: false + type: boolean + default: false + ci_ref: + description: "Ref of this repo to load scripts from. Keep in step with the caller's `uses:` tag." + required: false + type: string + default: "v1" + secrets: + house_style_token: + description: "Read access to GTMify/claude-house-style. Only needed when house_style is true." + required: false + +permissions: + contents: read + +jobs: + gate: + name: "gate (tier ${{ inputs.tier }})" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out the calling repository + uses: actions/checkout@v4 + with: + # Full history: the junk gate diffs against the pull request base, and a + # shallow clone has no base commit to diff against. + fetch-depth: 0 + + - name: Check out the shared check scripts + uses: actions/checkout@v4 + with: + repository: GTMify/ci-workflows + ref: ${{ inputs.ci_ref }} + path: .ci-workflows + + - name: Resolve the comparison base + id: base + # On a pull request, compare against its base branch. On a manual dispatch + # there is no base, so fall back to auditing every tracked file, which makes + # `workflow_dispatch` the cleanup tool for a repo that already carries junk. + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.ref }}" + git fetch --no-tags --quiet origin "$base" + echo "mode=diff" >> "$GITHUB_OUTPUT" + echo "ref=origin/$base" >> "$GITHUB_OUTPUT" + echo "comparing against origin/$base" + else + echo "mode=audit" >> "$GITHUB_OUTPUT" + echo "ref=" >> "$GITHUB_OUTPUT" + echo "no pull request base; auditing every tracked file" + fi + + - name: Junk-file gate + # Runs on every tier. This is the check that pays for itself: nothing + # stopped 33 worktree gitlinks and a SQLite write-ahead log from becoming + # tracked in the app repo, which drove local master 19 commits off origin. + run: | + set -euo pipefail + if [ "${{ steps.base.outputs.mode }}" = "audit" ]; then + bash .ci-workflows/scripts/junk_file_gate.sh --audit + else + bash .ci-workflows/scripts/junk_file_gate.sh --base "${{ steps.base.outputs.ref }}" + fi + + - name: Shell gate + if: inputs.tier == 'a' || inputs.tier == 'b' + # shellcheck is preinstalled on the ubuntu runner images. + run: | + set -euo pipefail + if [ "${{ steps.base.outputs.mode }}" = "audit" ]; then + bash .ci-workflows/scripts/shell_gate.sh --audit + else + bash .ci-workflows/scripts/shell_gate.sh --base "${{ steps.base.outputs.ref }}" + fi + + - name: Set up Node + if: inputs.tier == 'b' && inputs.build_cmd != '' + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + + - name: Build + if: inputs.tier == 'b' && inputs.build_cmd != '' + # The point is to fail on the pull request rather than at deploy time. + # Several of these repos deploy through Vercel, which builds anyway; the + # gap this closes is that a broken build was only discovered after merge. + run: ${{ inputs.build_cmd }} + + - name: Check out the house-style linter + if: inputs.house_style + uses: actions/checkout@v4 + with: + repository: GTMify/claude-house-style + path: .house-style + token: ${{ secrets.house_style_token }} + + - name: House-style gate + if: inputs.house_style + run: | + set -euo pipefail + if [ "${{ steps.base.outputs.mode }}" = "audit" ]; then + files="$(git ls-files -- '*.md')" + else + files="$(git diff --name-only --diff-filter=AM "${{ steps.base.outputs.ref }}...HEAD" -- '*.md')" + fi + if [ -z "$files" ]; then + echo ">> house style: no markdown in scope." + exit 0 + fi + failures=0 + while IFS= read -r f; do + [ -z "$f" ] && continue + rc=0 + python3 .house-style/lint/housestyle.py --file "$f" --persona gtmify || rc=$? + [ "$rc" -ne 0 ] && failures=$((failures + 1)) + done <<< "$files" + if [ "$failures" -ne 0 ]; then + echo "!! house style FAILED on ${failures} file(s)." + exit 1 + fi + echo ">> house style passed." diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml new file mode 100644 index 0000000..752b332 --- /dev/null +++ b/.github/workflows/self-test.yml @@ -0,0 +1,80 @@ +name: self-test + +# This repo cannot call its own reusable workflow to gate itself without a +# circular dependency, so it runs the same checks directly. +# +# Dogfooding matters more here than anywhere else: a defect in these scripts does +# not break one repo, it silently weakens the gate on every repo that calls them. +# The scripts are also the last place a broken check would be noticed, because a +# gate that wrongly passes looks exactly like a gate that works. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test and lint the gate scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Syntax-check every script + run: | + set -euo pipefail + for f in scripts/*.sh tests/*.sh; do + bash -n "$f" + echo "ok $f" + done + + - name: shellcheck + # Preinstalled on the ubuntu runner images. + run: shellcheck --severity=warning scripts/*.sh tests/*.sh + + - name: Run the junk-gate test suite + run: bash tests/junk_file_gate_test.sh + + - name: Prove the suite can fail + # A test suite that has only ever been seen passing is indistinguishable + # from one that asserts nothing. Break the gate on purpose, require the + # suite to go red, then restore it and require green. This exact check + # caught two false greens in the app repo. + run: | + set -euo pipefail + cp scripts/junk_file_gate.sh /tmp/gate.bak + + python3 <<'PY' + import pathlib, sys + p = pathlib.Path("scripts/junk_file_gate.sh") + t = p.read_text() + needle = ".claude/worktrees/*|*/.claude/worktrees/*)" + if needle not in t: + sys.exit("mutation target not found; update this step alongside the gate") + p.write_text(t.replace(needle, "__MUTANT_NEVER_MATCHES__)", 1)) + PY + + rc=0 + bash tests/junk_file_gate_test.sh >/tmp/mutant.out 2>&1 || rc=$? + cp /tmp/gate.bak scripts/junk_file_gate.sh + + if [ "$rc" -eq 0 ]; then + echo "!! The suite PASSED with the worktree pattern removed." + echo " The tests are not asserting what they claim to assert." + tail -20 /tmp/mutant.out + exit 1 + fi + echo ">> Mutation correctly turned the suite red." + + bash tests/junk_file_gate_test.sh + echo ">> Restored gate is green again." + + - name: Gate this repo with its own junk check + run: bash scripts/junk_file_gate.sh --audit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b5c8e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# This repo is the reference implementation for the junk-file gate, so it should +# not rely on that gate to keep itself clean. Ignore first, gate second. + +# Tool-managed git worktrees +.claude/worktrees/ + +# platformOS session telemetry +.pos-supervisor/ +pos-supervisor.jsonl + +# Credentials, never tracked. Templates and sops-encrypted files are allowed. +.pos +.pos-* +.siteglide-config +*.token +*.secret +.env +.env.local +.env.*.local + +# Generated +node_modules/ +__pycache__/ +*.pyc + +# macOS +.DS_Store +Icon? +._* diff --git a/README.md b/README.md index 265ebe8..1962422 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ # ci-workflows -Shared reusable CI gates for GTMify repos. Workflow YAML and check scripts only: no secrets, no business logic. + +Shared quality gates for GTMify repos. One reusable workflow, called by many repos, so a rule is fixed once rather than thirty times. + +**This repo is public on purpose, and holds no secrets.** Workflow YAML and check scripts only, no business logic and no repo data. It has to be public because a private reusable workflow can only be called from inside the same organization or user account, and several active repos live under the personal `scott-wueschinski-GTMify` account rather than the `GTMify` org. A public workflow is callable from any repo, private ones included. + +## Adding a repo + +Drop this in `.github/workflows/gate.yml` and set the tier: + +```yaml +name: gate +on: [pull_request, workflow_dispatch] +jobs: + gate: + uses: GTMify/ci-workflows/.github/workflows/gate.yml@v1 + with: + tier: c +``` + +Pin to `@v1`, never `@main`. `v1` is a tag that moves only deliberately, so a bad push here cannot break every repo at once. + +`workflow_dispatch` is worth keeping. With no pull request base to compare against, the gate switches to auditing every tracked file, which makes manual dispatch the cleanup tool for a repo that already carries junk. + +## Tiers + +| Tier | For | Checks | +| :-- | :-- | :-- | +| `a` | The config repo | junk, shell, and the config-specific checks | +| `b` | Repos that build and deploy | junk, shell, plus `build_cmd` must succeed | +| `c` | Content and docs repos | junk only | + +Tier B passes its build command in: + +```yaml + with: + tier: b + build_cmd: npm ci && npm run build +``` + +`GTMify/GTMify` is deliberately excluded. The app has its own three workflows built around platformOS constraints (tests execute on a deployed instance, so they cannot run locally) that apply to no other repo. Do not point it here. + +## The junk-file gate + +`scripts/junk_file_gate.sh` refuses to let ephemeral, generated, or credential files become tracked content. + +It exists because nothing stopped 33 `.claude/worktrees/*` gitlinks and 40 `.pos-supervisor/*` files, one of them a SQLite `analytics.db-wal`, from becoming tracked in `GTMify/GTMify`. A write-ahead log is rewritten on nearly every run, so that repo was permanently dirty, the session-end auto-commit hook turned the dirt into a commit every time, and local `master` drifted 19 commits off origin with no app code in any of them. `gtmify-config` carries a committed `hooks/Icon` for the same reason. + +What it rejects: worktree gitlinks, `.pos-supervisor/`, SQLite `-wal`/`-shm`/`-journal` sidecars, `.pos` and `.pos-*`, `.siteglide-config`, `*.token`, `*.secret`, `.env` and friends, `node_modules/`, `__pycache__/` and `*.pyc`, and macOS `.DS_Store`, `Icon`, and `._*` artifacts. + +Two properties worth knowing: + +**Only tracked paths can fail.** Every mode enumerates paths through git, so a file that exists on disk but is gitignored is invisible by construction. The gate objects to junk being tracked, not to junk existing. + +**`.env.template`, `.env.sops`, `.env.example` and `.env.sample` are allowed.** Templates and sops-encrypted files are meant to be shared. Everything else beginning `.env` is treated as secret-bearing. + +If a flagged path is genuinely intended content, add it to `.ci-junk-allowlist` in the repo root, one glob per line, with a comment saying why. A gate with no legitimate override gets switched off the first time it is wrong. + +## Enforcement, and its limit + +The GTMify org is on the free plan, where branch protection and rulesets return `403 Upgrade to GitHub Pro` on private repos. These checks therefore **run and show red on a pull request but cannot be made required**. + +Real enforcement for the junk gate comes from the local pre-commit hook in `gtmify-config`, which runs `junk_file_gate.sh --staged` and blocks junk before it can be committed at all, with no dependence on a GitHub plan. That is earlier than a required check would catch it. + +## House style + +Off by default. The linter lives in the private `GTMify/claude-house-style` repo, which this public workflow cannot read without a token. Turning it on means either making that linter public or minting a read-only PAT and passing it as `house_style_token`. Until that is decided, house style is enforced by the local write-time hook, which already covers the common case. + +## Tests + +```bash +tests/junk_file_gate_test.sh +``` + +35 cases, one throwaway git repo each, dirty in exactly one way. Two of them carry most of the value: `ignored_but_present` proves the tracked-only property, and `icon_in_a_longer_name` pins a real false-positive class that once made `auto_commit_on_exit.sh` silently drop work whose only sin was a filename containing the word Icon. + +The suite has been observed failing, not just passing. Removing the worktree pattern from the gate turns 3 cases red, which is the check that the tests are actually asserting something. diff --git a/scripts/junk_file_gate.sh b/scripts/junk_file_gate.sh new file mode 100755 index 0000000..3784810 --- /dev/null +++ b/scripts/junk_file_gate.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# +# junk_file_gate.sh: refuse to let ephemeral, generated, or credential files +# become tracked content. +# +# WHY THIS EXISTS +# +# Nothing stopped 33 `.claude/worktrees/*` gitlinks and 40 `.pos-supervisor/*` +# files, including a SQLite `analytics.db-wal`, from becoming tracked in +# GTMify/GTMify. A write-ahead log is rewritten on nearly every run, so the repo +# was permanently dirty, the session-end auto-commit hook turned that into a +# commit every time, and local `master` ended up 19 commits off origin with no +# app code in any of them. Unwinding it cost a session. `gtmify-config` has a +# committed `hooks/Icon` for the same reason: no gate. +# +# This is cheap to run and it is the one check that pays for itself immediately, +# so it runs on every tier. +# +# ONLY TRACKED PATHS CAN FAIL. Every mode below enumerates paths through git, so +# a file that exists on disk but is gitignored is invisible here by construction. +# That is deliberate: the gate objects to junk being *tracked*, not to junk +# existing. +# +# Modes: +# (default) compare against a base ref; what a pull request would add +# --staged inspect the index; used by the pre-commit hook +# --audit inspect every tracked file; used for one-time cleanups +# +# Usage: +# junk_file_gate.sh [--base ] +# junk_file_gate.sh --staged +# junk_file_gate.sh --audit +set -euo pipefail + +MODE="diff" +BASE="" +CR=$'\r' + +while [ $# -gt 0 ]; do + case "$1" in + --audit) MODE="audit" ;; + --staged) MODE="staged" ;; + --base) shift; BASE="${1:-}" ;; + -h|--help) + sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "junk_file_gate: unknown argument: $1" >&2; exit 2 ;; + esac + shift +done + +git rev-parse --show-toplevel >/dev/null 2>&1 || { + echo "junk_file_gate: not inside a git repository" >&2 + exit 2 +} +REPO_ROOT="$(git rev-parse --show-toplevel)" +ALLOWLIST="$REPO_ROOT/.ci-junk-allowlist" + +# Returns 0 and prints a reason when the path is junk; returns 1 when it is fine. +# +# Directory prefixes are matched with a leading-or-nested pair so that both +# `node_modules/x` and `packages/a/node_modules/x` are caught. Everything else is +# matched on the basename, so nested copies are caught too. An earlier version of +# this idea in auto_commit_on_exit.sh matched "space followed by the name" against +# raw porcelain output, which silently only ever caught noise at the repo root. +junk_reason() { + local p="$1" base + base="${p##*/}" + + case "$p" in + .claude/worktrees/*|*/.claude/worktrees/*) + echo "tool-managed git worktree gitlink; changes whenever any session commits in its own worktree"; return 0 ;; + .pos-supervisor/*|*/.pos-supervisor/*) + echo "platformOS session telemetry; regenerated every run"; return 0 ;; + node_modules/*|*/node_modules/*) + echo "installed dependencies; restore with the lockfile instead"; return 0 ;; + __pycache__/*|*/__pycache__/*) + echo "Python bytecode cache"; return 0 ;; + esac + + # Anything named .env is treated as secret-bearing unless it is explicitly a + # template or a sops-encrypted file, which are safe and are meant to be shared. + case "$base" in + .env*) + case "$base" in + *.sops|*.template|*.example|*.sample) ;; + *) echo "environment file; may carry secrets, commit a .template or .sops instead"; return 0 ;; + esac ;; + esac + + case "$base" in + pos-supervisor.jsonl) + echo "platformOS session telemetry; regenerated every run"; return 0 ;; + .pos|.pos-*) + echo "platformOS admin token file; NEVER commit"; return 0 ;; + .siteglide-config) + echo "SiteGlide admin token file; NEVER commit"; return 0 ;; + *.token|*.secret) + echo "credential file; NEVER commit"; return 0 ;; + *.db-wal|*.db-shm|*.db-journal) + echo "SQLite sidecar; rewritten on nearly every run"; return 0 ;; + *.pyc) + echo "Python bytecode"; return 0 ;; + .DS_Store) + echo "macOS directory metadata"; return 0 ;; + "Icon"|"Icon${CR}") + echo "macOS custom-icon artifact"; return 0 ;; + ._*) + echo "macOS resource fork"; return 0 ;; + esac + + return 1 +} + +# A gate with no legitimate override gets switched off the first time it is +# wrong, so `.ci-junk-allowlist` holds one glob per line, with # comments. +is_allowlisted() { + local p="$1" pat trimmed + [ -f "$ALLOWLIST" ] || return 1 + while IFS= read -r pat || [ -n "$pat" ]; do + pat="${pat%%#*}" + trimmed="${pat#"${pat%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [ -z "$trimmed" ] && continue + # shellcheck disable=SC2254 + case "$p" in + $trimmed) return 0 ;; + esac + done < "$ALLOWLIST" + return 1 +} + +resolve_base() { + if [ -n "$BASE" ]; then + printf '%s\n' "$BASE" + return 0 + fi + local head + if head="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)"; then + printf '%s\n' "${head#refs/remotes/}" + return 0 + fi + echo "junk_file_gate: cannot determine a base ref; pass --base " >&2 + return 1 +} + +collect() { + case "$MODE" in + audit) + git ls-files -z ;; + staged) + git diff --cached --name-only --diff-filter=AM -z ;; + diff) + local base + base="$(resolve_base)" + git diff --name-only --diff-filter=AM -z "$base...HEAD" ;; + esac +} + +offenders=() +reasons=() +checked=0 + +while IFS= read -r -d '' path; do + [ -z "$path" ] && continue + checked=$((checked + 1)) + if is_allowlisted "$path"; then + continue + fi + if reason="$(junk_reason "$path")"; then + offenders+=("$path") + reasons+=("$reason") + fi +done < <(collect) + +if [ "${#offenders[@]}" -eq 0 ]; then + echo ">> junk-file gate passed. ${checked} path(s) checked, mode=${MODE}." + exit 0 +fi + +echo "!! junk-file gate FAILED: ${#offenders[@]} of ${checked} tracked path(s) should not be in git." +echo +for i in "${!offenders[@]}"; do + printf ' %s\n' "${offenders[$i]}" + printf ' %s\n' "${reasons[$i]}" +done +echo +echo " To fix, untrack them and add the pattern to .gitignore:" +echo +for o in "${offenders[@]}"; do + printf ' git rm --cached -- %q\n' "$o" +done +echo +echo " Untracking does NOT delete your local copy." +echo " If one of these is genuinely intended content, add its path to" +echo " .ci-junk-allowlist with a comment explaining why." +exit 1 diff --git a/scripts/shell_gate.sh b/scripts/shell_gate.sh new file mode 100755 index 0000000..2399742 --- /dev/null +++ b/scripts/shell_gate.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# +# shell_gate.sh: parse and lint shell scripts before they can break a session. +# +# WHY THIS EXISTS +# +# `hooks/auto_commit_on_exit.sh` shipped two real defects that no review caught: +# a noise filter that only ever matched at the repo root, and a pattern loose +# enough that a file named "My Icon Design.txt" was classified as noise and its +# changes silently dropped. That second one is data loss inside a safety net. +# The linter flags the class of sloppiness both came from, and it costs seconds. +# (This sentence deliberately avoids opening with the linter's own name: a comment +# line beginning with that word is parsed as a directive, which fails the file.) +# +# Hooks are the highest-blast-radius code in this stack: a syntax error in one +# runs on every session, on every machine, for every repo. +# +# Modes: +# (default) check scripts changed against a base ref +# --audit check every tracked shell script +# +# Usage: +# shell_gate.sh [--base ] +# shell_gate.sh --audit +set -euo pipefail + +MODE="diff" +BASE="" + +while [ $# -gt 0 ]; do + case "$1" in + --audit) MODE="audit" ;; + --base) shift; BASE="${1:-}" ;; + -h|--help) + sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "shell_gate: unknown argument: $1" >&2; exit 2 ;; + esac + shift +done + +resolve_base() { + if [ -n "$BASE" ]; then printf '%s\n' "$BASE"; return 0; fi + local head + if head="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)"; then + printf '%s\n' "${head#refs/remotes/}"; return 0 + fi + echo "shell_gate: cannot determine a base ref; pass --base " >&2 + return 1 +} + +collect() { + if [ "$MODE" = "audit" ]; then + git ls-files -z + else + local base + base="$(resolve_base)" + git diff --name-only --diff-filter=AM -z "$base...HEAD" + fi +} + +# A shell script is anything ending .sh or .bash, plus any extensionless file +# whose first line is a shell shebang. The latter matters here because git hooks +# are conventionally extensionless. +is_shell() { + local p="$1" + case "$p" in + *.sh|*.bash) return 0 ;; + esac + [ -f "$p" ] || return 1 + case "$p" in + *.*) return 1 ;; + esac + head -n1 -- "$p" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b' && return 0 + return 1 +} + +scripts=() +while IFS= read -r -d '' p; do + [ -z "$p" ] && continue + if is_shell "$p"; then scripts+=("$p"); fi +done < <(collect) + +if [ "${#scripts[@]}" -eq 0 ]; then + echo ">> shell gate: no shell scripts in scope, nothing to check." + exit 0 +fi + +echo ">> shell gate: checking ${#scripts[@]} script(s)." +failures=0 + +for s in "${scripts[@]}"; do + # `cmd || rc=$?` rather than bracketing with `set +e` / `set -e`. Re-enabling + # errexit inside a loop turns it back on for everything after, and a later + # non-zero status then kills the script before it can report a total. + rc=0 + bash -n -- "$s" 2>/tmp/shellgate.err || rc=$? + if [ "$rc" -ne 0 ]; then + printf ' FAIL (syntax) %s\n' "$s" + sed 's/^/ /' /tmp/shellgate.err + failures=$((failures + 1)) + continue + fi + printf ' ok (syntax) %s\n' "$s" +done + +if command -v shellcheck >/dev/null 2>&1; then + for s in "${scripts[@]}"; do + rc=0 + shellcheck --severity=warning --format=tty -- "$s" >/tmp/shellcheck.out 2>&1 || rc=$? + if [ "$rc" -ne 0 ]; then + printf ' FAIL (shellcheck) %s\n' "$s" + sed 's/^/ /' /tmp/shellcheck.out + failures=$((failures + 1)) + else + printf ' ok (shellcheck) %s\n' "$s" + fi + done +else + echo " NOTE: shellcheck not installed; ran syntax checks only." +fi + +rm -f /tmp/shellgate.err /tmp/shellcheck.out + +if [ "$failures" -ne 0 ]; then + echo + echo "!! shell gate FAILED: ${failures} check(s) did not pass." + exit 1 +fi + +echo ">> shell gate passed." +exit 0 diff --git a/tests/junk_file_gate_test.sh b/tests/junk_file_gate_test.sh new file mode 100755 index 0000000..ce3206e --- /dev/null +++ b/tests/junk_file_gate_test.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# +# Tests for scripts/junk_file_gate.sh. +# +# One throwaway git repo per scenario, dirty in exactly one way, asserting the +# gate's exit code. This is the same shape that caught two real defects in +# hooks/auto_commit_on_exit.sh, where a filter that looked obviously correct was +# in fact only ever matching at the repo root. +# +# The two cases that matter most are the last ones. `ignored_but_present` proves +# the gate objects to junk being TRACKED rather than to junk existing, and +# `filename_merely_contains_icon` pins the exact false-positive class that made +# the old hook silently drop real work. +# +# Usage: tests/junk_file_gate_test.sh +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATE="$HERE/../scripts/junk_file_gate.sh" +[ -f "$GATE" ] || { echo "cannot find $GATE" >&2; exit 2; } + +pass_count=0 +fail_count=0 + +# new_repo: prints the path to a fresh git repo with one committed file. +new_repo() { + local d + d="$(mktemp -d)" + git -C "$d" init -q + git -C "$d" config user.email t@t.t + git -C "$d" config user.name t + mkdir -p "$d/src" + echo baseline > "$d/src/keep.js" + git -C "$d" add -A >/dev/null 2>&1 + git -C "$d" commit -qm baseline >/dev/null 2>&1 + printf '%s\n' "$d" +} + +# check +check() { + local name="$1" expect="$2" mode="$3" d="$4" rc=0 out + out="$(cd "$d" && bash "$GATE" "$mode" 2>&1)" || rc=$? + local got="pass" + [ "$rc" -ne 0 ] && got="fail" + if [ "$got" = "$expect" ]; then + printf ' ok %-38s expected %-4s got %s\n' "$name" "$expect" "$got" + pass_count=$((pass_count + 1)) + else + printf ' FAIL %-38s expected %-4s got %s\n' "$name" "$expect" "$got" + printf '%s\n' "$out" | sed 's/^/ /' + fail_count=$((fail_count + 1)) + fi + rm -rf "$d" +} + +# track [content] +track() { + local d="$1" p="$2" + mkdir -p "$d/$(dirname "$p")" + printf '%s\n' "${3:-x}" > "$d/$p" + git -C "$d" add -Af -- "$p" >/dev/null 2>&1 + git -C "$d" commit -qm "add $p" >/dev/null 2>&1 +} + +echo "== audit mode: paths that MUST be caught ==" +for spec in \ + "worktree_gitlink:.claude/worktrees/wt-a" \ + "nested_worktree_gitlink:pkg/.claude/worktrees/wt-b" \ + "pos_supervisor_dir:.pos-supervisor/analytics.db" \ + "sqlite_wal:.pos-supervisor/analytics.db-wal" \ + "sqlite_wal_at_root:data.db-wal" \ + "sqlite_shm:cache.db-shm" \ + "supervisor_jsonl:pos-supervisor.jsonl" \ + "pos_token:.pos" \ + "pos_token_variant:.pos-production" \ + "siteglide_token:.siteglide-config" \ + "dotenv:.env" \ + "dotenv_local:.env.local" \ + "credential_token:deploy.token" \ + "credential_secret:api.secret" \ + "nested_node_modules:packages/a/node_modules/x.js" \ + "pycache:__pycache__/m.pyc" \ + "nested_pycache:tools/__pycache__/m.pyc" \ + "nested_dsstore:sub/.DS_Store" \ + "macos_icon:hooks/Icon" \ + "macos_resource_fork:._hidden" \ + ; do + name="${spec%%:*}"; path="${spec#*:}" + d="$(new_repo)"; track "$d" "$path" + check "$name" fail --audit "$d" +done + +echo +echo "== audit mode: paths that MUST be allowed ==" +for spec in \ + "ordinary_source:src/app.js" \ + "dotenv_template:.env.template" \ + "dotenv_sops:.env.sops" \ + "dotenv_example:.env.example" \ + "readme:README.md" \ + "icon_in_a_longer_name:My Icon Design.txt" \ + "iconography_doc:docs/Iconography.md" \ + "db_file_itself:data.db" \ + ; do + name="${spec%%:*}"; path="${spec#*:}" + d="$(new_repo)"; track "$d" "$path" + check "$name" pass --audit "$d" +done + +echo +echo "== tracked versus merely present ==" +# Junk on disk but gitignored, so never tracked. Must pass: the gate objects to +# junk being tracked, not to it existing. +d="$(new_repo)" +track "$d" ".gitignore" ".pos-supervisor/" +mkdir -p "$d/.pos-supervisor" +echo churn > "$d/.pos-supervisor/analytics.db-wal" +check "ignored_but_present" pass --audit "$d" + +echo +echo "== allowlist escape hatch ==" +d="$(new_repo)" +track "$d" ".ci-junk-allowlist" ".claude/worktrees/deliberate" +track "$d" ".claude/worktrees/deliberate" +check "allowlisted_path" pass --audit "$d" + +d="$(new_repo)" +track "$d" ".ci-junk-allowlist" ".claude/worktrees/deliberate" +track "$d" ".claude/worktrees/something-else" +check "allowlist_does_not_over_match" fail --audit "$d" + +echo +echo "== staged mode, used by the pre-commit hook ==" +d="$(new_repo)" +mkdir -p "$d/.pos-supervisor" +echo churn > "$d/.pos-supervisor/analytics.db-wal" +git -C "$d" add -Af -- .pos-supervisor/analytics.db-wal >/dev/null 2>&1 +check "staged_junk_is_caught" fail --staged "$d" + +d="$(new_repo)" +echo more > "$d/src/keep.js" +git -C "$d" add -A >/dev/null 2>&1 +check "staged_real_work_passes" pass --staged "$d" + +echo +echo "== diff mode against a base ref ==" +d="$(new_repo)" +git -C "$d" branch -q base-ref +track "$d" "data.db-wal" +rc=0 +out="$(cd "$d" && bash "$GATE" --base base-ref 2>&1)" || rc=$? +if [ "$rc" -ne 0 ]; then + echo " ok diff_mode_catches_new_junk expected fail got fail" + pass_count=$((pass_count + 1)) +else + echo " FAIL diff_mode_catches_new_junk expected fail got pass" + printf '%s\n' "$out" | sed 's/^/ /' + fail_count=$((fail_count + 1)) +fi +rm -rf "$d" + +d="$(new_repo)" +git -C "$d" branch -q base-ref +track "$d" "src/feature.js" +rc=0 +out="$(cd "$d" && bash "$GATE" --base base-ref 2>&1)" || rc=$? +if [ "$rc" -eq 0 ]; then + echo " ok diff_mode_allows_real_work expected pass got pass" + pass_count=$((pass_count + 1)) +else + echo " FAIL diff_mode_allows_real_work expected pass got fail" + printf '%s\n' "$out" | sed 's/^/ /' + fail_count=$((fail_count + 1)) +fi +rm -rf "$d" + +echo +echo "════════════════════════════════════════" +echo " ${pass_count} passed, ${fail_count} failed" +echo "════════════════════════════════════════" +[ "$fail_count" -eq 0 ] || exit 1