Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
@@ -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."
80 changes: 80 additions & 0 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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?
._*
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading