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