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
103 changes: 103 additions & 0 deletions .claude/hooks/linkage-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# PreToolUse(Bash) guard — gate ONLY release tagging. ~0 tokens. Normal push/merge untouched.
# Fires ONLY on a real git tag/push command, never on text that merely mentions one.
# Bypass a deliberate release: LINKAGE_OFF=1 <cmd>
#
# This is the source every Galaxio repository copies. Change it here and carry the
# change outward; linkage-guard_test.sh beside it is the contract those copies
# share. It reached this shape after two repositories hit the same failure
# independently — a `gh pr create` whose body documented the hook was blocked by
# it, reporting a version that came from prose.
#
# One divergence, carried back the other way: the push rule does not treat a
# release/N branch as a release. Publishing is what the tag does, a branch push
# publishes nothing, and the version was being read out of the branch name — but
# a release branch serves a whole minor line (release/1.2.0 -> v1.2.0, v1.2.1, …),
# so for every patch release that name is the wrong version and the guard went
# looking for a milestone that never existed. It blocked the documented release
# procedure at its second step.
#
# This is the fast in-session failure, not the whole gate: it only ever sees
# commands an agent runs, so a push from a terminal or an IDE never reaches it.
# .githooks/pre-push catches those by reading the refs git hands it. Both run
# locally and can be skipped; nothing checks server-side unless the project's
# release workflow runs scripts/check-linkage.sh --for-tag itself.
set -uo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
[ -n "$cmd" ] || exit 0
[ "${LINKAGE_OFF:-}" = "1" ] && exit 0
case "$cmd" in *LINKAGE_OFF=1*) exit 0 ;; esac
case "$cmd" in *git*) ;; *) exit 0 ;; esac
root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
checker="$root/scripts/check-linkage.sh"
block() { printf 'BLOCKED by linkage-guard:\n%s\n' "$1" >&2; exit 2; }

# Scope the guard to what the shell would actually RUN. Matching the raw command string
# made any *argument* mentioning a tag look like a release — a `gh pr create` whose heredoc
# body documented this hook was blocked, reporting a version that came from prose.
# So: drop heredoc bodies, split on shell separators, keep only segments that invoke git.
strip_heredocs() {
local line trimmed term="" re='<<-?[[:space:]]*["'"'"']?([A-Za-z_][A-Za-z0-9_]*)["'"'"']?'
while IFS= read -r line; do
if [ -n "$term" ]; then
trimmed="${line#"${line%%[![:space:]]*}"}"
[ "$trimmed" = "$term" ] && term=""
continue
fi
printf '%s\n' "$line"
[[ $line =~ $re ]] && term="${BASH_REMATCH[1]}"
done
}

split_segments() {
local s="$1"
s="${s//&&/$'\n'}"; s="${s//||/$'\n'}"; s="${s//;/$'\n'}"; s="${s//|/$'\n'}"
printf '%s\n' "$s"
}

# Peel leading env assignments and wrappers so `rtk proxy git tag …` still counts as git.
peel() {
local s="$1" head
s="${s#"${s%%[![:space:]]*}"}"
while :; do
head="${s%% *}"
case "$head" in
[A-Za-z_]*=*) s="${s#"$head"}" ;;
rtk|proxy|sudo|env|nohup|time|command|builtin|exec|xargs) s="${s#"$head"}" ;;
*) break ;;
esac
s="${s#"${s%%[![:space:]]*}"}"
done
printf '%s' "$s"
}

is_tag=0
hit=""
segments=$(split_segments "$(printf '%s\n' "$cmd" | strip_heredocs)")
while IFS= read -r seg; do
seg=$(peel "$seg")
case "$seg" in git|git\ *) ;; *) continue ;; esac
# Non-release subcommands are out of scope. Scoped to THIS segment: the old global
# reset let `git commit -m … && git tag vX.Y.Z` through ungated.
printf '%s' "$seg" | grep -qE '^git[[:space:]]+(commit|log|show)\b' && continue
# Any `git tag …` carrying a vX.Y.Z, in any flag order — upstream required the version
# to sit immediately after `tag`, so the documented annotated form (`git tag -a vX.Y.Z -m …`)
# slipped through. Read-only/destructive subcommands (-l/--list, -d/--delete, -v/--verify,
# -n) are not release creation and stay out of scope.
if printf '%s' "$seg" | grep -qE '^git[[:space:]]+tag\b' \
&& printf '%s' "$seg" | grep -qE '\bv?[0-9]+\.[0-9]+\.[0-9]+' \
&& ! printf '%s' "$seg" | grep -qE '^git[[:space:]]+tag[[:space:]]+(-l\b|--list\b|-d\b|--delete\b|-v\b|--verify\b|-n)'; then
is_tag=1; hit="$seg"; break
fi
if printf '%s' "$seg" | grep -qE '^git[[:space:]]+push\b' \
&& printf '%s' "$seg" | grep -qE '(--tags|refs/tags/|[[:space:]](origin[[:space:]]+)?v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$))'; then
is_tag=1; hit="$seg"; break
fi
done <<<"$segments"
[ "$is_tag" = 1 ] || exit 0
[ -x "$checker" ] || block "checker missing ($checker) — release cannot be verified"
ver=$(printf '%s' "$hit" | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ver" ] || block "tag/release push without explicit vX.Y.Z — verify: scripts/check-linkage.sh --for-tag <version>"
out=$("$checker" --for-tag "$ver" 2>&1) || block "$out"
exit 0
107 changes: 107 additions & 0 deletions .claude/hooks/linkage-guard_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# linkage-guard_test.sh — regression suite for .claude/hooks/linkage-guard.sh.
#
# This suite travels with the guard. It is the contract the copies in every
# Galaxio repository share, and each case below is a shape the guard got wrong
# at some point — keep them, and add to them rather than trimming.
#
# The guard must gate release tagging and nothing else. Two symmetric failures matter:
# blocking a command that merely *mentions* tagging, and waving through a real tag hidden
# in a compound command. Both are covered below.
#
# The real scripts/check-linkage.sh is replaced by a stub, so the suite is deterministic,
# offline, and independent of milestone state. It asserts only the guard's scope decision:
# exit 0 = passed through, exit 2 = gated.
#
# Usage: bash scripts/test-linkage-guard.sh [GUARD=/path/to/linkage-guard.sh]
# Exit: 0 all green | 1 at least one case failed
set -uo pipefail

# The suite sits beside the hook it tests, as .githooks/pre-push_test.sh does.
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GUARD="${GUARD:-$here/linkage-guard.sh}"
[ -r "$GUARD" ] || { echo "guard not found: $GUARD" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }

fake=$(mktemp -d)
trap 'rm -rf "$fake"' EXIT
mkdir -p "$fake/scripts"
cat >"$fake/scripts/check-linkage.sh" <<'STUB'
#!/usr/bin/env bash
echo "stub check-linkage: $*"
exit 1
STUB
chmod +x "$fake/scripts/check-linkage.sh"

pass=0
fail=0

run() { # run <expected rc> <label> <command>
local want="$1" label="$2" cmd="$3" out rc
out=$(jq -n --arg c "$cmd" '{tool_input:{command:$c}}' \
| CLAUDE_PROJECT_DIR="$fake" bash "$GUARD" 2>&1)
rc=$?
if [ "$rc" = "$want" ]; then
pass=$((pass + 1))
printf ' ok [%s] %s\n' "$rc" "$label"
else
fail=$((fail + 1))
printf ' FAIL want=%s got=%s %s\n cmd: %s\n out: %s\n' \
"$want" "$rc" "$label" "$cmd" "$out"
fi
}

echo "== must pass through (0) — not a release =="
run 0 'gh pr create, heredoc body mentions tagging' \
"$(printf 'gh pr create --body-file - <<%sEOF%s\nRatified at v1.0.0.\nlinkage-guard blocks git tag vX.Y.Z until the milestone passes.\nEOF' "'" "'")"
run 0 'gh pr create, quoted body mentions tagging' \
'rtk proxy gh pr create --title "x" --body "the guard blocks git tag v1.2.3"'
run 0 'gh issue create quoting a tag command' \
'gh issue create --body "repro: git tag v2.3.4 then git push origin v2.3.4"'
run 0 'echo of a tag command' 'echo "git tag v9.9.9"'
run 0 'git status' 'git status --short'
run 0 'git log over a tag range' 'git log --oneline v1.0.6..origin/main'
run 0 'git show a tag' 'git show v1.0.6'
run 0 'git push a feature branch' 'git push -u origin feat/some-work'
run 0 'git commit mentioning a tag' 'git commit -m "docs: describe git tag v1.0.7 flow"'
run 0 'git tag --list glob' "git tag -l 'v1.*'"
run 0 'git tag --list long flag' 'git tag --list v1.0.0'
run 0 'git tag --delete' 'git tag -d v1.0.0'
run 0 'git tag --verify' 'git tag -v v1.0.0'
run 0 'git branch' 'git branch -a'
run 0 'LINKAGE_OFF prefix bypass' 'LINKAGE_OFF=1 git tag v1.0.7'
run 0 'no git anywhere' 'ls -la'

echo "== must gate (2) — a real release =="
run 2 'lightweight tag' 'git tag v1.0.7'
run 2 'annotated tag' 'git tag -a v1.0.7 -m "release 1.0.7"'
run 2 'tag behind the rtk wrapper' 'rtk proxy git tag v1.0.7'
run 2 'push a version tag' 'git push origin v1.0.7'
run 2 'push --tags' 'git push --tags'
run 2 'push refs/tags' 'git push origin refs/tags/v1.0.7'
# parsec diverges here, and the divergence belongs upstream too: publishing is
# what the tag does, and a release branch serves a whole minor line, so its name
# is the wrong version for every patch release in the repository that found it.
run 0 'push a release branch' 'git push origin release/1.0'
run 0 'fast-forward the release branch' 'git push origin origin/main:refs/heads/release/0.0.0'
run 2 'commit && tag' 'git commit -m "prep" && git tag v1.0.7'
run 2 'cd && tag' 'cd /tmp && git tag v1.0.7'
run 2 'log && tag' 'git log --oneline -1 && git tag v1.0.7'
run 2 'tag behind sudo' 'sudo git tag v1.0.7'
run 2 'push behind xargs' 'xargs git push --tags'
run 2 'push behind an env assignment' 'GIT_SSH_COMMAND=ssh git push origin v1.0.7'
run 2 'heredoc first, tag after' \
"$(printf 'cat > /tmp/notes.md <<%sMD%s\n## v1.0.7\nMD\ngit tag v1.0.7' "'" "'")"

echo "== the version handed to the checker =="
out=$(jq -n --arg c 'git tag v1.0.7' '{tool_input:{command:$c}}' \
| CLAUDE_PROJECT_DIR="$fake" bash "$GUARD" 2>&1 || true)
if printf '%s' "$out" | grep -q -- '--for-tag v1.0.7'; then
pass=$((pass + 1)); printf ' ok [-] the checker is asked about the tag being created\n'
else
fail=$((fail + 1)); printf ' FAIL the checker was asked: %s\n' "$out"
fi

printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" = 0 ]
68 changes: 68 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# pre-push — refuse to publish a release tag whose milestone is not ready.
#
# git hands this hook the refs it is about to push, one line per ref:
#
# <local ref> <local sha1> <remote ref> <remote sha1>
#
# so there is nothing to parse and nothing to guess. That is the whole reason
# the gate lives here. It used to live in a Claude Code PreToolUse hook that
# read the raw text of every Bash command and tried to work out whether it was
# a release; eleven shapes walked straight through it — a wrapper like `xargs`
# or `sudo`, a `bash -c` body, a subshell, a line continuation, a heredoc
# anywhere in the command — and two ordinary `git commit` messages were blocked
# for quoting the word push. None of that is expressible here: git says which
# refs are moving, whoever asked it to.
#
# It also covers pushes that hook never sees at all — from a terminal, from an
# IDE, from a script — because git runs this for every client.
#
# It is not a guarantee. A local hook is opt-in (core.hooksPath) and can be
# skipped with --no-verify, and nothing server-side repeats the check unless
# the project's own release workflow runs `scripts/check-linkage.sh --for-tag`.
# Even then the workflow starts after the tag is public, so it can hold back the
# release but not the tag. This is the fast failure that saves you from finding
# out after the tag is public and cannot be taken back.

set -uo pipefail

# Bypass a deliberate release: LINKAGE_OFF=1 git push ...
[ "${LINKAGE_OFF:-}" = "1" ] && exit 0

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
checker="$root/scripts/check-linkage.sh"

status=0

while read -r _local_ref local_sha remote_ref _remote_sha; do
# Only a tag publishes a release. A branch push moves no version.
case "$remote_ref" in
refs/tags/v[0-9]*) ;;
*) continue ;;
esac

# A deletion carries the all-zero object id on the local side. Deleting a tag
# publishes nothing, and the rule against deleting a released tag is a matter
# for review, not for this hook — refusing here would only teach --no-verify.
case "$local_sha" in
*[!0]*) ;;
*) continue ;;
esac

version=${remote_ref#refs/tags/}

if [ ! -x "$checker" ]; then
printf 'pre-push: %s is missing, so %s cannot be verified\n' "$checker" "$version" >&2
status=1
continue
fi

if ! out=$("$checker" --for-tag "$version" 2>&1); then
printf '%s\n' "$out" >&2
printf 'pre-push: refusing to publish %s — its milestone is not ready\n' "$version" >&2
status=1
fi
done

exit "$status"
Loading
Loading