fix(lint): harden check-release-needed.sh, script the vale-style sync

A review of PR #85's last two commits (1164f3a, 4d018af) found the new
release-gate script fails open in four separate ways, and the new drift
check for the duplicated Vale styles only ever detects drift after a
human already hand-edited both copies out of sync.

check-release-needed.sh:
- The `-e` existence filter dropped a RELEASE_PATHS entry from the diff
  pathspec once it was deleted from the tree, so deleting a path exposed
  via .pre-commit-hooks.yaml since the last tag passed the gate clean —
  exactly the breakage the gate exists to catch. git diff reports
  deletions fine without an existence check; the filter is gone.
- `git diff ... 2>/dev/null || true` turned any git failure (a shallow
  clone missing the tag's objects, a corrupted ref) into an empty,
  falsely-clean diff. The diff result is no longer swallowed: a failure
  now hard-fails with the underlying git error visible.
- RELEASE_PATHS was a hand-maintained array duplicating
  .pre-commit-hooks.yaml's entry: paths with only a comment holding them
  in sync, and was already over-broad (it swept in validate.sh /
  validate-provenance.sh, which no hook entry references). It's now
  parsed straight from .pre-commit-hooks.yaml's entry: lines at
  runtime, so it can't drift from the manifest and only tracks what a
  hook actually exposes.
- `git describe --tags --abbrev=0` accepted any tag reachable from HEAD
  as the diff baseline, not just release tags. Added
  `--match 'v[0-9]*.[0-9]*.[0-9]*'` so an incidental checkpoint tag
  can't shift the baseline and mask a real release-relevant change.

check-vale-style-sync.sh still only detects drift between skill-audit's
and agent-audit's duplicated vale-wrap.sh/styles/Kyberforge copies
(both copies must exist independently per the plugin's no-cross-skill-
path packaging rule — a symlink would break at install time). Added
scripts/sync-vale-styles.sh to regenerate skill-audit's copy from
agent-audit's canonical one on demand, and pointed the sync check's
failure message at it, so fixing drift is one command instead of a
hand diff across two files.

Also recorded, rather than silently left unfixed: check-release-needed.sh
only fires on a local `git push` through pre-commit's pre-push hook — a
PR merged via Gitea's merge button, or CI invoking
`pre-commit run --hook-stage pre-push` directly, never sets
PRE_COMMIT_REMOTE_BRANCH and skips the gate entirely. Closing that needs
a server-side CI job this repo doesn't have yet; documented as a known
limitation in ADR-0014 rather than papered over.

Separately, LESSONS.md's "a clean check can mean nothing ran" entry was
marked **Graduated** without ever being promoted per the repo's own
graduation rule (3+ instances → a standing doc, marked
`[graduated → target file]`). Actually promoted it into
core/instructions/testing.md and fixed the marker.

tests/test-check-release-needed.sh gained 4 regression tests, one per
check-release-needed.sh fix above, each verified to fail against the
pre-fix script and pass against the current one.

Verification: bash tests/run-tests.sh (11 scripts + 125 bats, all
passing), pre-commit run --all-files, and
pre-commit run --all-files --hook-stage pre-push all clean.

ADR: 0014
This commit is contained in:
2026-08-09 11:20:55 +00:00
parent 4d018af03c
commit acd2f1d422
7 changed files with 161 additions and 33 deletions

View File

@@ -8,6 +8,12 @@ set -euo pipefail
# 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"
@@ -18,18 +24,34 @@ fi
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
# Paths whose content .pre-commit-hooks.yaml exposes to external consumers.
# Keep in sync with .pre-commit-hooks.yaml's entry: paths.
RELEASE_PATHS=(
.pre-commit-hooks.yaml
scripts/skill-size-check.sh
plugins/kyberforge/skills/skill-audit/scripts
plugins/kyberforge/skills/skill-audit/assets/vale
plugins/kyberforge/skills/agent-audit/scripts
plugins/kyberforge/skills/agent-audit/assets/vale
)
HOOKS_MANIFEST=".pre-commit-hooks.yaml"
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
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.
# Each entry is "<script> [--config <path>] [...]"; the script itself and the
# directory holding any --config target (vale-wrap.sh needs its .vale.ini's
# sibling styles/ tree, not just the ini file) are release-relevant.
RELEASE_PATHS=("$HOOKS_MANIFEST")
while IFS= read -r entry; do
read -ra tokens <<< "$entry"
[[ ${#tokens[@]} -eq 0 ]] && continue
RELEASE_PATHS+=("${tokens[0]}")
for ((i = 1; i < ${#tokens[@]}; i++)); do
if [[ "${tokens[i]}" == "--config" && -n "${tokens[i + 1]:-}" ]]; then
RELEASE_PATHS+=("$(dirname "${tokens[i + 1]}")")
fi
done
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
@@ -37,17 +59,17 @@ if [[ -z "$LAST_TAG" ]]; then
exit 1
fi
EXISTING_PATHS=()
for p in "${RELEASE_PATHS[@]}"; do
[[ -e "$p" ]] && EXISTING_PATHS+=("$p")
done
if [[ ${#EXISTING_PATHS[@]} -eq 0 ]]; then
exit 0
# 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
CHANGED="$(git diff --name-only "$LAST_TAG"..HEAD -- "${EXISTING_PATHS[@]}" 2>/dev/null || true)"
if [[ -n "$CHANGED" ]]; then
echo "FAIL: files covered by .pre-commit-hooks.yaml changed since $LAST_TAG:" >&2
echo "$CHANGED" | sed 's/^/ /' >&2