The gate derived release-relevant paths from the dirname of each entry's --config target. Dropping --config from .pre-commit-hooks.yaml left that loop dead, silently removing both assets/vale/ trees from coverage — a Vale rule change could land on main without demanding a release tag, leaving consumers pinned to an old rev: with stale rules. Coverage now derives from tokens[0] instead: double-dirname for the .. normalization, guarded on the tree existing and on the bundle root not resolving to "." so skill-size-check.sh cannot invent a bogus path. The --config branch is removed rather than kept as dead code. Since pre-commit rewrites only entry[0], no argument in any entry can ever name a file this repo ships, so that shape is broken by design. Known gap: deleting a hook's entire assets/ tree is not flagged, as the candidate path stops existing. Deletions within a surviving tree are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
89 lines
4.4 KiB
Bash
Executable File
89 lines
4.4 KiB
Bash
Executable File
#!/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).
|
|
#
|
|
# Known gap: this only fires on a local `git push` through pre-commit's pre-push
|
|
# hook. A PR merged via Gitea's merge button (server-side, no local push) or a
|
|
# CI runner invoking `pre-commit run --hook-stage pre-push` directly does not set
|
|
# PRE_COMMIT_REMOTE_BRANCH and will not trigger this check — closing that
|
|
# requires a server-side CI job, which this repo does not have yet.
|
|
|
|
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"
|
|
|
|
HOOKS_MANIFEST=".pre-commit-hooks.yaml"
|
|
|
|
if [[ ! -f "$HOOKS_MANIFEST" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Derive release-relevant paths from .pre-commit-hooks.yaml's own entry: lines
|
|
# instead of hand-maintaining a parallel list — the manifest is the single
|
|
# source of truth for what external consumers actually pull at a pinned rev,
|
|
# so a hook added/removed/renamed there can't silently drift out of sync here.
|
|
# Everything is derived from tokens[0], the hook's script: pre-commit prefixes
|
|
# only entry[0] with the hook-repo clone path, so any later token that looks
|
|
# like a path resolves against the *consuming* repo and can never name a file
|
|
# this repo ships. A hook's bundled data therefore has to be self-located
|
|
# relative to the script — vale-wrap.sh reads its own
|
|
# <script-dir>/../assets/vale/.vale.ini plus the sibling styles/ tree — which
|
|
# makes <script-dir>/../assets release-relevant alongside the script itself.
|
|
# The ../ is normalised by stripping a path component rather than with
|
|
# `realpath -m`, which is a GNU-only extension. Two guards keep the derivation
|
|
# from inventing paths: a bundle root of "." is skipped, because a script in a
|
|
# top-level directory (scripts/skill-size-check.sh) would derive the repo's own
|
|
# shared assets/, which no hook owns and whose churn must not demand a release;
|
|
# and the directory is added only when it exists, since a hook that bundles
|
|
# nothing must not contribute a pathspec matching nothing.
|
|
RELEASE_PATHS=("$HOOKS_MANIFEST")
|
|
while IFS= read -r entry; do
|
|
read -ra tokens <<< "$entry"
|
|
[[ ${#tokens[@]} -eq 0 ]] && continue
|
|
RELEASE_PATHS+=("${tokens[0]}")
|
|
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
|
if [[ "$bundle_root" != "." && -d "$bundle_root/assets" ]]; then
|
|
RELEASE_PATHS+=("$bundle_root/assets")
|
|
fi
|
|
done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST")
|
|
|
|
# Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or
|
|
# experiment tag reachable from HEAD must not shift the diff baseline.
|
|
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 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
|
|
|
|
# No -e/existence filtering: a path deleted since $LAST_TAG is exactly the case
|
|
# that must be caught (external consumers pinning the old tag would hit a
|
|
# missing file), and `git diff` reports deletions fine without it existing at
|
|
# HEAD. A git failure (e.g. a shallow clone missing $LAST_TAG's history) must
|
|
# fail closed, not be swallowed into an empty, falsely-clean diff.
|
|
if ! CHANGED="$(git diff --name-only "$LAST_TAG"..HEAD -- "${RELEASE_PATHS[@]}")"; then
|
|
echo "FAIL: could not diff $LAST_TAG..HEAD to check for release-relevant changes (see git error above)." >&2
|
|
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
|
exit 1
|
|
fi
|
|
|
|
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
|