diff --git a/scripts/check-release-needed.sh b/scripts/check-release-needed.sh index 63b1d3f..61b8b27 100755 --- a/scripts/check-release-needed.sh +++ b/scripts/check-release-needed.sh @@ -21,6 +21,27 @@ if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then exit 0 fi +# What is actually being pushed, which is only HEAD for the common +# `git push ` case. pre-commit's pre-push hook-impl +# exports the local sha of each pushed ref as PRE_COMMIT_TO_REF; a +# `git push topic:main` from a different checkout would otherwise be +# gated on the wrong tip — a false negative when HEAD is behind the pushed ref +# (unreleased changes sail through), a false positive when it is ahead. +# PRE_COMMIT_FROM_REF, the *remote's* current tip, is deliberately not used +# anywhere here: the baseline is the last release tag, not what the remote +# already has. Diffing from the remote tip would let an untagged +# release-relevant commit already on main excuse the next push from cutting a +# tag, which is precisely the drift this gate exists to catch. +PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}" + +# pre-commit passes an all-zeros sha (40 hex zeros under sha1, 64 under sha256) +# as the "to" ref when the push deletes a branch. Nothing is being shipped, and +# every rev-taking command below would fail on an unresolvable sha, so bail out +# rather than turning a branch deletion into a confusing "could not diff". +if [[ "$PUSHED_REF" =~ ^0+$ ]]; then + exit 0 +fi + REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" @@ -31,8 +52,10 @@ if [[ ! -f "$HOOKS_MANIFEST" ]]; then fi # 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)" +# experiment tag reachable from the pushed ref must not shift the diff baseline. +# The tag is resolved from $PUSHED_REF, not HEAD, for the same reason the diff +# is: a tag reachable only from HEAD is not part of the history being pushed. +LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "$PUSHED_REF" 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 @@ -68,16 +91,93 @@ add_release_path() { RELEASE_PATHS+=("$candidate") } +# Emits one "" line per hook so a rejected entry can +# name the hook a human has to go fix. The id sits on its own line above its +# entry: in YAML, so it is carried forward and then cleared; a hook that somehow +# has no id still reports something printable rather than an empty name. Kept in +# bash rather than awk: matching `[[:space:]]` inside a bracket expression is +# reliable in bash's own globs but not in the BWK awk macOS ships. `read -r` with +# a single variable is the trimmer — it strips leading and trailing whitespace +# while preserving anything in between, so a multi-token entry survives intact +# for the error message to quote back. +manifest_entries() { + local line id="" value + while IFS= read -r line; do + # Drop the indentation and the optional list dash, so that `- id: x` and + # ` entry: y` both reduce to the same bare "key: value" shape. + line="${line#"${line%%[![:space:]]*}"}" + if [[ "$line" == -* ]]; then + line="${line#-}" + line="${line#"${line%%[![:space:]]*}"}" + fi + case "$line" in + id:*) + read -r id <<< "${line#id:}" + ;; + entry:*) + read -r value <<< "${line#entry:}" + printf '%s\t%s\n' "${id:-(unnamed hook)}" "$value" + id="" + ;; + esac + done +} + +# A hook's script is legitimate if it exists in the working tree *or* at +# $LAST_TAG — the same union the pathspec itself spans. Checking per-scope +# instead would reject exactly the case this gate exists to flag: a script +# deleted since the tag while its entry survives (see the no -e filtering note +# further down) is a real deletion to report, not a malformed manifest. +entry_path_exists() { + local candidate="$1" + [[ -e "$candidate" ]] && return 0 + git cat-file -e "$LAST_TAG:$candidate" 2>/dev/null && return 0 + return 1 +} + # $1 selects where the "does this hook bundle an assets/ tree?" guard looks: # "worktree" probes the filesystem, anything else is a rev whose tree is probed # with git plumbing. Reading entry lines from stdin keeps one derivation for # both the tagged manifest and the current one. collect_release_paths() { - local scope="$1" entry bundle_root + local scope="$1" line hook_id entry bundle_root where local -a tokens - while IFS= read -r entry; do + if [[ "$scope" == "worktree" ]]; then + where="the working tree's $HOOKS_MANIFEST" + else + where="$HOOKS_MANIFEST at $scope" + fi + while IFS= read -r line; do + hook_id="${line%%$'\t'*}" + entry="${line#*$'\t'}" read -ra tokens <<< "$entry" [[ ${#tokens[@]} -eq 0 ]] && continue + # ADR-0014 binds every entry to a bare script path and nothing else, because + # pre-commit rewrites only entry[0] into the hook-repo clone. That is a + # constraint nothing else enforces, and the sibling .pre-commit-config.yaml + # already ships the multi-token `bash