diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a7e01b..375ae7a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,6 +70,15 @@ repos: pass_filenames: false always_run: true + - id: check-release-needed + name: Check a release tag covers .pre-commit-hooks.yaml's paths + description: On push to main only, fail if files exposed via .pre-commit-hooks.yaml changed since the last tag + entry: bash scripts/check-release-needed.sh + language: system + stages: [pre-push] + pass_filenames: false + always_run: true + - id: validate-plugins name: Validate plugins description: Run claude plugin validate --strict on every plugin directory diff --git a/docs/adr/0014-vale-prefilter-ships-from-the-plugin.md b/docs/adr/0014-vale-prefilter-ships-from-the-plugin.md index 2ac7ebc..13b175e 100644 --- a/docs/adr/0014-vale-prefilter-ships-from-the-plugin.md +++ b/docs/adr/0014-vale-prefilter-ships-from-the-plugin.md @@ -97,3 +97,13 @@ doesn't wonder if it was overlooked. `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has the matching glob section. - The first `vX.Y.Z` tag is cut once this change and its tests pass, giving external `.pre-commit-hooks.yaml` consumers something to pin. +- **Cutting the tag is not left to memory.** `scripts/check-release-needed.sh`, wired at + `pre-push`, hard-fails — but only when `PRE_COMMIT_REMOTE_BRANCH` (set by pre-commit's + `hook-impl` for pre-push hooks) is `refs/heads/main` — if any path `.pre-commit-hooks.yaml` + exposes changed since the last tag reachable from `HEAD`. It is a silent no-op on every other + branch: hard-failing on feature-branch pushes mid-review would force a premature tag on a + commit that might not survive a squash-merge, the exact problem `repo: local` (above) already + avoids for this repo's own dev-time gate. A tag not existing at all is also a hard fail on + `main`, covering the very first release. This is deterministic tooling, not a standing + instruction to remember — consistent with `check-manifests.sh`/`check-vale-style-sync.sh` + already using the same pre-push, main-agnostic-elsewhere pattern. diff --git a/scripts/check-release-needed.sh b/scripts/check-release-needed.sh new file mode 100755 index 0000000..3f2c5a7 --- /dev/null +++ b/scripts/check-release-needed.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Hard-fails only when pushing to main: if any file covered by .pre-commit-hooks.yaml +# (the external git-hook/CI contract, see ADR-0014) changed since the last tag, +# a release must be cut before landing on main, or external consumers pinning +# `rev: ` silently miss the change. Pre-commit sets PRE_COMMIT_REMOTE_BRANCH +# for pre-push hooks; on every other branch (feature work mid-review) this is a +# silent no-op — pushing WIP commits there must not be blocked on cutting a +# premature tag (see ADR-0014's repo: local vs pinned self-reference decision). + +TARGET_BRANCH="refs/heads/main" + +if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then + exit 0 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# Paths whose content .pre-commit-hooks.yaml exposes to external consumers. +# Keep in sync with .pre-commit-hooks.yaml's entry: paths. +RELEASE_PATHS=( + .pre-commit-hooks.yaml + scripts/skill-size-check.sh + plugins/kyberforge/skills/skill-audit/scripts + plugins/kyberforge/skills/skill-audit/assets/vale + plugins/kyberforge/skills/agent-audit/scripts + plugins/kyberforge/skills/agent-audit/assets/vale +) + +LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" + +if [[ -z "$LAST_TAG" ]]; then + echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2 + echo " Fix: cut the first release tag (e.g. v1.0.0) before this lands on main." >&2 + exit 1 +fi + +EXISTING_PATHS=() +for p in "${RELEASE_PATHS[@]}"; do + [[ -e "$p" ]] && EXISTING_PATHS+=("$p") +done + +if [[ ${#EXISTING_PATHS[@]} -eq 0 ]]; then + exit 0 +fi + +CHANGED="$(git diff --name-only "$LAST_TAG"..HEAD -- "${EXISTING_PATHS[@]}" 2>/dev/null || true)" + +if [[ -n "$CHANGED" ]]; then + echo "FAIL: files covered by .pre-commit-hooks.yaml changed since $LAST_TAG:" >&2 + echo "$CHANGED" | sed 's/^/ /' >&2 + echo " Fix: cut a new release tag — external consumers pinning rev: $LAST_TAG would miss this change." >&2 + exit 1 +fi diff --git a/tests/test-check-release-needed.sh b/tests/test-check-release-needed.sh new file mode 100755 index 0000000..53f4041 --- /dev/null +++ b/tests/test-check-release-needed.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/check-release-needed.sh" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Helper: a fixture repo with one release-relevant file, committed and tagged. +make_tagged_fixture() { + local dir + dir="$(mktemp -d)" + (cd "$dir" && git init -q && git config user.email t@t.t && git config user.name t) + mkdir -p "$dir/scripts" + echo "v1" > "$dir/scripts/skill-size-check.sh" + (cd "$dir" && git add -A && git commit -q -m "initial" && git tag v1.0.0) + echo "$dir" +} + +run_check() { + local dir="$1" branch="$2" + (cd "$dir" && PRE_COMMIT_REMOTE_BRANCH="$branch" bash "$SCRIPT" 2>&1) +} + +# --- 1. Not targeting main: silent no-op regardless of state --- +echo "" +echo "--- exits 0 when not pushing to main, even with no tags ---" +FIXTURE1="$(mktemp -d)" +trap 'rm -rf "$FIXTURE1"' EXIT +(cd "$FIXTURE1" && git init -q) +if run_check "$FIXTURE1" "refs/heads/feature-branch" > /dev/null; then + pass "exits 0 when target branch isn't main" +else + fail "exited non-zero on a non-main target branch" +fi + +# --- 2. Targeting main, no tag exists at all: hard fail --- +echo "" +echo "--- exits 1 when targeting main and no tag exists ---" +FIXTURE2="$(mktemp -d)" +trap 'rm -rf "$FIXTURE1" "$FIXTURE2"' EXIT +(cd "$FIXTURE2" && git init -q && git config user.email t@t.t && git config user.name t) +mkdir -p "$FIXTURE2/scripts" +echo "v1" > "$FIXTURE2/scripts/skill-size-check.sh" +(cd "$FIXTURE2" && git add -A && git commit -q -m "initial") +if run_check "$FIXTURE2" "refs/heads/main" > /dev/null; then + fail "exited 0 when targeting main with no tag — expected exit 1" +else + pass "exits non-zero when targeting main and no tag exists yet" +fi + +# --- 3. Targeting main, tag exists, no release-relevant changes since: passes --- +echo "" +echo "--- exits 0 when targeting main and nothing release-relevant changed since the tag ---" +FIXTURE3="$(make_tagged_fixture)" +trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3"' EXIT +echo "unrelated" > "$FIXTURE3/README.md" +(cd "$FIXTURE3" && git add -A && git commit -q -m "unrelated change") +if run_check "$FIXTURE3" "refs/heads/main" > /dev/null; then + pass "exits 0 when only unrelated files changed since the tag" +else + fail "exited non-zero despite no release-relevant changes since the tag" +fi + +# --- 4. Targeting main, tag exists, a release-relevant file changed since: hard fail --- +echo "" +echo "--- exits 1 when a release-relevant file changed since the tag ---" +FIXTURE4="$(make_tagged_fixture)" +trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4"' EXIT +echo "v2" > "$FIXTURE4/scripts/skill-size-check.sh" +(cd "$FIXTURE4" && git add -A && git commit -q -m "update release-relevant script") +OUT4=$(run_check "$FIXTURE4" "refs/heads/main" || true) +if echo "$OUT4" | grep -q "skill-size-check.sh"; then + pass "exits non-zero and names the changed file when a release-relevant path changed since the tag" +else + fail "did not flag the release-relevant file that changed since the tag" +fi + +# --- 5. Not targeting main even with release-relevant changes and a tag: still a no-op --- +echo "" +echo "--- exits 0 on a feature branch even with release-relevant changes since the tag ---" +FIXTURE5="$(make_tagged_fixture)" +trap 'rm -rf "$FIXTURE1" "$FIXTURE2" "$FIXTURE3" "$FIXTURE4" "$FIXTURE5"' EXIT +echo "v2" > "$FIXTURE5/scripts/skill-size-check.sh" +(cd "$FIXTURE5" && git add -A && git commit -q -m "update release-relevant script") +if run_check "$FIXTURE5" "refs/heads/some-feature" > /dev/null; then + pass "exits 0 on a feature branch regardless of un-tagged release-relevant changes" +else + fail "hard-failed on a feature branch — should only ever fail when targeting main" +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]]