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
20 changes: 20 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ jobs:
bash .ci-workflows/scripts/shell_gate.sh --base "${{ steps.base.outputs.ref }}"
fi

- name: Settings gate
if: inputs.tier == 'a'
# Resolves every script path settings.json references and asserts it exists
# and is executable. Rewrites the "$HOME"/.claude/ prefix to the repo root,
# because CI has no ~/.claude and every hook would otherwise read as missing.
run: bash .ci-workflows/scripts/settings_gate.sh

- name: Hook smoke
if: inputs.tier == 'a'
# Only runs hooks that advertise a --dry-run flag. A hook is arbitrary code
# with side effects (one commits and pushes), so anything without a declared
# no-op mode is reported as skipped rather than executed.
run: bash .ci-workflows/scripts/hook_smoke.sh

- name: Frontmatter gate
if: inputs.tier == 'a'
# A skill with no description never triggers, and never errors either. It
# looks installed and does nothing.
run: bash .ci-workflows/scripts/frontmatter_gate.sh

- name: Set up Node
if: inputs.tier == 'b' && inputs.build_cmd != ''
uses: actions/setup-node@v4
Expand Down
113 changes: 113 additions & 0 deletions scripts/frontmatter_gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# frontmatter_gate.sh: every skill and agent must declare a name and a description.
#
# WHY THIS EXISTS
#
# A skill's description is the only thing the model sees when deciding whether the
# skill applies. A skill with no description, or with frontmatter that failed to
# parse, does not error: it simply never triggers. It looks installed and does
# nothing, which is the most expensive kind of broken because nobody investigates a
# feature they believe is working.
#
# Frontmatter is parsed by hand rather than with PyYAML, which is not guaranteed to
# be present on a runner. Checking that two keys exist and are non-empty does not
# need a YAML parser, and adding a dependency to a gate makes the gate fragile.
#
# SCOPE, AND WHY IT IS NARROW
#
# Only files that MUST carry frontmatter are checked. An earlier version also swept
# `agents/*.md`, which produced 55 failures against gtmify-config, every one of them
# a false positive: git pathspec wildcards match across slashes by default, so that
# glob reached `agents/instructions/*.md`, which are Paperclip instruction files and
# prose documentation with no frontmatter by design. `:(glob)` magic is used below
# precisely so `*` stops at a slash.
#
# Pass --glob to add targets, for example a repo that keeps real Claude Code agent
# definitions in .claude/agents/.
#
# Usage: frontmatter_gate.sh [--glob <pathspec>]...
set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"

globs=(':(glob)skills/*/SKILL.md' ':(glob).claude/agents/*.md')

while [ $# -gt 0 ]; do
case "$1" in
--glob) shift; globs+=(":(glob)${1:-}") ;;
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "frontmatter_gate: unknown argument: $1" >&2; exit 2 ;;
esac
shift
done

targets=()
while IFS= read -r -d '' f; do targets+=("$f"); done < <(
{ git ls-files -z -- "${globs[@]}" 2>/dev/null || true; }
)

if [ "${#targets[@]}" -eq 0 ]; then
echo ">> frontmatter gate: no skills or agents in this repo, nothing to check."
exit 0
fi

echo ">> frontmatter gate: checking ${#targets[@]} file(s)"

failures=0

for f in "${targets[@]}"; do
problem="$(F="$f" python3 <<'PY'
import os, sys

path = os.environ["F"]
with open(path, encoding="utf-8", errors="replace") as fh:
lines = fh.read().split("\n")

if not lines or lines[0].strip() != "---":
sys.stdout.write("no YAML frontmatter; file must open with ---")
sys.exit(0)

body = []
closed = False
for line in lines[1:]:
if line.strip() == "---":
closed = True
break
body.append(line)

if not closed:
sys.stdout.write("frontmatter is never closed with a second ---")
sys.exit(0)

found = {}
for line in body:
if line[:1].isspace() or not line.strip() or line.lstrip().startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
found[key.strip()] = value.strip().strip('"').strip("'")

missing = [k for k in ("name", "description") if not found.get(k)]
if missing:
sys.stdout.write("missing or empty: " + ", ".join(missing))
PY
)"

if [ -n "$problem" ]; then
printf ' FAIL %s\n %s\n' "$f" "$problem"
failures=$((failures + 1))
else
printf ' ok %s\n' "$f"
fi
done

if [ "$failures" -ne 0 ]; then
echo
echo "!! frontmatter gate FAILED: ${failures} file(s) would never trigger."
exit 1
fi

echo ">> frontmatter gate passed."
99 changes: 99 additions & 0 deletions scripts/hook_smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# hook_smoke.sh: run each hook's own safe self-check so a broken hook is caught
# here rather than on somebody's machine mid-session.
#
# THE SAFETY RULE THAT SHAPES THIS SCRIPT
#
# A hook is arbitrary code with side effects. auto_commit_on_exit.sh commits and
# pushes; log_session_transcript.sh writes to Supabase. Executing hooks blindly in
# CI would be reckless, so this script NEVER runs a hook unless the hook itself
# advertises a no-op flag: it greps for --dry-run handling and only then invokes it
# with --dry-run. A hook without that flag is reported as skipped, not run.
#
# That means coverage here is partial by design, and the report says so rather than
# implying every hook was exercised. A silent partial pass reading as full coverage
# is the exact failure this repo's other checks exist to prevent.
#
# Usage: hook_smoke.sh [--dir <hooks dir>]
set -euo pipefail

HOOK_DIR="hooks"

while [ $# -gt 0 ]; do
case "$1" in
--dir) shift; HOOK_DIR="${1:-}" ;;
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "hook_smoke: unknown argument: $1" >&2; exit 2 ;;
esac
shift
done

REPO_ROOT="$(git rev-parse --show-toplevel)"
DIR="$REPO_ROOT/$HOOK_DIR"

if [ ! -d "$DIR" ]; then
echo ">> hook smoke: no $HOOK_DIR/ directory, nothing to check."
exit 0
fi

ran=0
skipped=0
failures=0

echo ">> hook smoke: scanning $HOOK_DIR/"

while IFS= read -r -d '' h; do
name="$(basename "$h")"

# Skip macOS artifacts, which is not hypothetical: every repo in this workspace
# has an `Icon` file inside .git/hooks, and gtmify-config tracks one in hooks/.
case "$name" in
Icon|Icon*|.DS_Store|._*) continue ;;
esac

case "$name" in
*.sh) ;;
*) continue ;;
esac

if ! grep -q -- '--dry-run' "$h"; then
printf ' skip %-32s no --dry-run flag; not safe to execute in CI\n' "$name"
skipped=$((skipped + 1))
continue
fi

rc=0
# `cmd || rc=$?` rather than set +e / set -e around it: 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 print a total.
timeout 60 bash "$h" --dry-run >/tmp/hooksmoke.out 2>&1 || rc=$?

if [ "$rc" -eq 0 ]; then
printf ' ok %-32s --dry-run exited 0\n' "$name"
ran=$((ran + 1))
else
printf ' FAIL %-32s --dry-run exited %s\n' "$name" "$rc"
sed 's/^/ /' /tmp/hooksmoke.out | head -20
failures=$((failures + 1))
fi
done < <(find "$DIR" -maxdepth 1 -type f -print0)

rm -f /tmp/hooksmoke.out

echo
echo " ran ${ran}, skipped ${skipped} (no safe flag), failed ${failures}"

if [ "$failures" -ne 0 ]; then
echo "!! hook smoke FAILED."
exit 1
fi

if [ "$ran" -eq 0 ]; then
echo ">> hook smoke: nothing was executable in dry-run mode. Coverage here is zero,"
echo " which is a real gap rather than a pass. Add --dry-run support to a hook to"
echo " bring it under this check."
exit 0
fi

echo ">> hook smoke passed."
123 changes: 123 additions & 0 deletions scripts/settings_gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
#
# settings_gate.sh: verify a Claude Code config repo's settings.json is valid and
# every script it points at actually exists and can run.
#
# WHY THIS EXISTS
#
# settings.json wires hooks that fire on every session, on every machine. A typo in
# a path, or a hook that loses its executable bit, does not fail loudly at commit
# time; it fails later, quietly, on somebody's machine, usually as "why did the
# session not log anything". Checking it costs a second.
#
# THE PATH MAPPING THAT MAKES THIS WORK IN CI
#
# Hook commands are written as "$HOME"/.claude/hooks/x.sh because that is where they
# live on a workstation, where ~/.claude/hooks is a symlink into this repo. CI has no
# ~/.claude at all, so every $HOME/.claude/ prefix is rewritten to the repo root
# before the file is looked up. Without that rewrite this check would report every
# hook as missing.
#
# Usage: settings_gate.sh [--settings <path>]
set -euo pipefail

SETTINGS="settings.json"

while [ $# -gt 0 ]; do
case "$1" in
--settings) shift; SETTINGS="${1:-}" ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "settings_gate: unknown argument: $1" >&2; exit 2 ;;
esac
shift
done

REPO_ROOT="$(git rev-parse --show-toplevel)"

if [ ! -f "$REPO_ROOT/$SETTINGS" ]; then
echo ">> settings gate: no $SETTINGS in this repo, nothing to check."
exit 0
fi

echo ">> settings gate: checking $SETTINGS"

REPO_ROOT="$REPO_ROOT" SETTINGS="$SETTINGS" python3 <<'PY'
import json, os, re, stat, sys

root = os.environ["REPO_ROOT"]
rel = os.environ["SETTINGS"]
path = os.path.join(root, rel)

try:
with open(path) as f:
cfg = json.load(f)
except json.JSONDecodeError as e:
print(f"!! {rel} is not valid JSON: {e}")
sys.exit(1)

print(f" ok {rel} parses as JSON")

# Hook commands are authored for a workstation, where ~/.claude/hooks symlinks into
# this repo. Rewrite that prefix to the repo root so the files resolve in CI too.
def resolve(cmd_token):
t = cmd_token.strip().strip('"').strip("'")
for prefix in ('"$HOME"/.claude/', '$HOME/.claude/', '~/.claude/'):
if t.startswith(prefix):
return os.path.join(root, t[len(prefix):]), t
if t.startswith("/"):
return t, t
return os.path.join(root, t), t

failures = 0
checked = 0

def check_command(where, command):
global failures, checked
# Strip quotes BEFORE extracting paths. Commands are authored as
# "$HOME"/.claude/hooks/x.sh, and a character class that accepts a quote will
# happily start matching at the closing one, yielding $HOME"/.claude/... which
# then resolves to nothing. Removing quotes first makes the token unambiguous.
# (Found by running this against the real settings.json, where an earlier version
# of this regex reported all 9 hooks as missing.)
cleaned = command.replace('"', "").replace("'", "")
tokens = re.findall(r'[\w$./~\-]+\.(?:sh|py|js|mjs)', cleaned)
if not tokens:
return
for tok in tokens:
resolved, original = resolve(tok)
checked += 1
if not os.path.exists(resolved):
print(f" FAIL {where}: {original}")
print(f" does not exist (looked in {os.path.relpath(resolved, root)})")
failures += 1
continue
mode = os.stat(resolved).st_mode
if not (mode & stat.S_IXUSR):
print(f" FAIL {where}: {original}")
print(f" exists but is not executable; fix with: chmod +x {os.path.relpath(resolved, root)}")
failures += 1
continue
print(f" ok {where}: {original}")

for event, matchers in (cfg.get("hooks") or {}).items():
if not isinstance(matchers, list):
continue
for m in matchers:
for h in (m.get("hooks") or []):
cmd = h.get("command")
if isinstance(cmd, str):
check_command(f"hooks.{event}", cmd)

status_line = cfg.get("statusLine")
if isinstance(status_line, dict) and isinstance(status_line.get("command"), str):
check_command("statusLine", status_line["command"])

if checked == 0:
print(" NOTE: no script paths found in settings.json; nothing to resolve.")

if failures:
print(f"\n!! settings gate FAILED: {failures} of {checked} referenced script(s) unusable.")
sys.exit(1)

print(f"\n>> settings gate passed. {checked} referenced script(s) exist and are executable.")
PY
Loading