Files
holocron/scripts/check-release-needed.sh
Defame1297 6910f1b5a5 fix(lint): reject checkpoint-suffixed tags as the release-gate baseline
git describe --match is a shell glob, not a regex: the trailing `*`s
in 'v[0-9]*.[0-9]*.[0-9]*' match any suffix, so a tag like
v1.2.3-checkpoint or v1.2.3-rc1 satisfied the pattern and could be
picked as LAST_TAG instead of the true last release. That silently
shifts the diff baseline and can let a push skip a required release.
--exclude '*-*' rules out any tag carrying a hyphenated suffix.

Added a regression test that tags a release-relevant change with a
v1.0.1-checkpoint tag right after v1.0.0 and asserts the gate still
fires — confirmed it fails against the pre-fix script and passes
against the fix.
2026-08-09 19:55:36 +00:00

243 lines
12 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
# What is actually being pushed, which is only HEAD for the common
# `git push <remote> <current-branch>` case. pre-commit's pre-push hook-impl
# exports the local sha of each pushed ref as PRE_COMMIT_TO_REF; a
# `git push <remote> 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"
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 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.
# --match is a shell glob, not a regex: its trailing `*`s match any suffix, so
# without --exclude a pre-release/checkpoint tag like v1.2.3-checkpoint or
# v1.2.3-rc1 also satisfies 'v[0-9]*.[0-9]*.[0-9]*' and could be picked over the
# true last release tag. --exclude is glob syntax too, so '*-*' is what actually
# rules out any tag carrying a hyphenated suffix, leaving only bare vMAJOR.MINOR.PATCH.
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*' "$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
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")
}
# Emits one "<hook id><TAB><entry value>" 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" line hook_id entry bundle_root where
local -a tokens
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 <script>` shape one copy-paste away —
# so an entry like `bash scripts/foo.sh` would add "bash" as a pathspec that
# matches nothing and derive a bundle root of ".", dropping that hook's
# entire surface out of the gate silently. Both malformed shapes below fail
# loudly instead: silent degradation here is the same class of defect as the
# --config token already recorded in LESSONS.md.
if [[ ${#tokens[@]} -gt 1 ]]; then
echo "FAIL: hook '$hook_id' in $where has a multi-token entry: $entry" >&2
echo " Why: pre-commit rewrites only entry[0] into the hook-repo clone, so every later" >&2
echo " token resolves against the *consuming* repo and can never name a file this" >&2
echo " repo ships — and this gate would derive its release paths from '${tokens[0]}'." >&2
echo " Fix: make the entry a bare script path and have the script self-locate anything" >&2
echo " else from \${BASH_SOURCE[0]} (see ADR-0014, 'Consequences')." >&2
exit 1
fi
if ! entry_path_exists "${tokens[0]}"; then
echo "FAIL: hook '$hook_id' in $where names a path that exists neither in the working tree nor at $LAST_TAG: ${tokens[0]}" >&2
echo " Why: this gate derives its release-relevant pathspec from that path, so a name" >&2
echo " that resolves to no file silently drops the hook's whole surface from the diff." >&2
echo " Fix: point the entry at a script path this repo actually ships (see ADR-0014," >&2
echo " 'Consequences'); a bare command name is not a valid entry here." >&2
exit 1
fi
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 now, 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 < <(manifest_entries < "$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" | manifest_entries)
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 on the pathspec: 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 the pushed ref. 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".."$PUSHED_REF" -- "${RELEASE_PATHS[@]}")"; then
echo "FAIL: could not diff $LAST_TAG..$PUSHED_REF 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