fix(lint): hard-fail on main when a release tag is needed
.pre-commit-hooks.yaml now exposes hooks to external consumers pinning rev: <tag>, but nothing enforced that a tag actually gets cut when the files it references change — relying on memory is exactly what this repo's governance rules say to avoid for a repeatable, deterministic check. scripts/check-release-needed.sh hard-fails at pre-push, but only when PRE_COMMIT_REMOTE_BRANCH (set by pre-commit's hook-impl) is refs/heads/main: it diffs .pre-commit-hooks.yaml's referenced paths against the last tag reachable from HEAD, and fails if either no tag exists yet or something changed since. It's 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 risk the repo: local (vs. pinned self- reference) decision in ADR-0014 already avoids for this repo's own dev-time gate. Verified against the real git pre-push hook path (not just the script in isolation): simulated stdin matching git's pre-push protocol through .git/hooks/pre-push, confirmed it correctly fires and fails when targeting main with no tag, and is silent otherwise. ADR: 0014 Refs: #87
This commit is contained in:
@@ -70,6 +70,15 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
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
|
- id: validate-plugins
|
||||||
name: Validate plugins
|
name: Validate plugins
|
||||||
description: Run claude plugin validate --strict on every plugin directory
|
description: Run claude plugin validate --strict on every plugin directory
|
||||||
|
|||||||
@@ -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.
|
`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
|
- 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.
|
`.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.
|
||||||
|
|||||||
56
scripts/check-release-needed.sh
Executable file
56
scripts/check-release-needed.sh
Executable file
@@ -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: <tag>` 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
|
||||||
97
tests/test-check-release-needed.sh
Executable file
97
tests/test-check-release-needed.sh
Executable file
@@ -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 ]]
|
||||||
Reference in New Issue
Block a user