#!/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: ` 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 ` 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" 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 # /../assets/vale/.vale.ini plus the sibling styles/ tree — which # makes /../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 "" 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