Files
holocron/scripts/check-release-needed.sh
Defame1297 14c2c91521 fix(lint): flag release-relevant paths retired since the last tag
Coverage was derived from the worktree alone, so the -d guard on a
hook's bundled assets/ tree meant deleting the whole tree removed it
from the pathspec instead of flagging it — the gate stayed silent
about a change that breaks every consumer at the next rev:.

The path set is now derived twice, from the worktree manifest and from
the manifest at $LAST_TAG, then unioned. A path the tag exposed but
HEAD no longer does is a removal pinned consumers must be told about;
a path only HEAD exposes is new contract surface. Both need flagging.

Fails closed on an unreadable tagged tree (shallow clone), and treats
a readable root tree with no manifest as "added since the tag".

tokens[0] needed no exit-code fix — it carries no existence guard, so
both deletion cases already exited non-zero. What was wrong was the
reporting: a fully retired hook could no longer be named in the
failure message. The tagged manifest fixes that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
2026-08-09 13:32:30 +00:00

137 lines
6.9 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
# 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
# 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 assets/ directory is added only where it is known to exist, since a
# hook that bundles nothing must not contribute a pathspec matching nothing.
RELEASE_PATHS=("$HOOKS_MANIFEST")
add_release_path() {
local candidate="$1" existing
for existing in "${RELEASE_PATHS[@]}"; do
[[ "$existing" == "$candidate" ]] && return 0
done
RELEASE_PATHS+=("$candidate")
}
# $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 -a tokens
while IFS= read -r entry; do
read -ra tokens <<< "$entry"
[[ ${#tokens[@]} -eq 0 ]] && continue
add_release_path "${tokens[0]}"
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
[[ "$bundle_root" == "." ]] && continue
if [[ "$scope" == "worktree" ]]; then
[[ -d "$bundle_root/assets" ]] && add_release_path "$bundle_root/assets"
else
git cat-file -e "$scope:$bundle_root/assets" 2>/dev/null && add_release_path "$bundle_root/assets"
fi
done
return 0
}
# The worktree alone is not enough: a path is release-relevant if it was part of
# the contract at $LAST_TAG *or* is part of it at HEAD, so both trees have to be
# derived and unioned. Deriving only from the worktree meant that deleting a
# hook's entire assets/ tree made the `-d` guard drop the path from the pathspec
# altogether, and the deletion — which breaks every consumer at the next rev —
# diffed clean. The two manifests can genuinely disagree (an entry added,
# removed, or renamed since the tag), and the union is the conservative side of
# that disagreement: a path the tag exposed and HEAD no longer does is a removal
# consumers must be told about, and a path only HEAD exposes is new contract
# surface they cannot reach without a new tag. The union never over-fires on its
# own, either — any manifest edit that makes the two disagree already changes
# $HOOKS_MANIFEST, which is itself a release-relevant path.
collect_release_paths worktree < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST")
# A missing manifest at the tag is legitimate (the manifest was added since) but
# is indistinguishable from an unreadable tagged tree by its exit status alone,
# so the tag's root tree is verified separately. An absent tree object — a
# shallow clone, a truncated fetch — fails closed exactly like a `git diff`
# failure does, rather than silently degrading to worktree-only derivation.
if MANIFEST_AT_TAG="$(git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST" 2>/dev/null)"; then
collect_release_paths "$LAST_TAG" < <(printf '%s\n' "$MANIFEST_AT_TAG" | sed -n 's/^[[:space:]]*entry:[[:space:]]*//p')
elif ! git cat-file -e "$LAST_TAG^{tree}" 2>/dev/null; then
echo "FAIL: could not read the tree at $LAST_TAG to determine which paths that release exposed." >&2
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&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