fix(gates): close the review findings in the gates and their docs

Two reproduced bugs in check-skill-version-bump:

- The origin/main-tip check fired even when the pushed skill was
  byte-identical to main's tip, so a cherry-pick or backport failed a
  push that ships nothing. The merge-base intersection ea119d8 added
  covers that only when some base carries the content, which a
  criss-cross history gives and a linear one does not. A new
  same_subtree compares tree object ids, so the exemption holds
  whatever route the history took.
- The failure line reported "baseline: none" when the skill was absent
  at every merge-base but present at the tip, and the Fix: line then
  named no version. The author writes the natural 1.0.0 and gets a
  second blocked push. It now falls back to the tip's version.

ADR-0022 is not amended: the documented behaviour does not change, and
ea119d8 set the precedent by fixing the same failure class script-only.

1614bce verified that executables.allow grants are version-blind and
corrected ADR-0019, gates.md and apm.yml, but missed the gate script's
own header and its operator-facing FAIL message, which still told the
reader deployment was silently broken, and gates.md's hook summary,
which still called it a silent-failure guard. All three now match.

Also: README's offline guarantee carries the populated-apm_modules
condition gates.md and AGENTS.md already state; the scripts/ layout row
drops "sync" for the three deleted sync scripts; the check-rtk-prefix
README rationale names the 12 subdirectory READMEs that survive rather
than the skill-root ones this branch deleted; gates.md re-cites its
three head -1 sites by enclosing function per its own :238 rule; and
deploy-manifest drops a pointer to a provider-manifest.sh that has
never existed on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwD8Egs5r4ndqeFLmhusX2
This commit is contained in:
2026-09-20 12:33:50 +00:00
parent 1614bcef23
commit 8cfd54f925
7 changed files with 145 additions and 30 deletions

View File

@@ -2,11 +2,15 @@
set -euo pipefail
# Fails the push when root apm.yml's executables.allow key stops naming
# kyberforge's actual version. apm matches that key by exact dict lookup
# (apm_cli/security/executables.py) — a version bump that misses the key
# update deploys nothing, with no error anywhere. See ADR-0019, "The allow
# key is version-pinned, and that is a live failure mode", for the full
# argument; nothing else in the pre-push gate compares these two files.
# kyberforge's actual version. The grant itself is version-blind — apm matches
# the version-less name alongside '<package>#<version>' (exec_gate.py builds
# the candidate list, _map_grants matches it) — so a stale key keeps granting
# kyberforge's hooks/ and bin/ and deployment does not break. What this gate
# buys is repo-level, not apm-level: a version bump without the matching key
# edit fails this repo's own pre-push, so the key stays an accurate record of
# what was approved. See ADR-0019's 2026-09-19 correction and docs/spec/gates.md,
# "check-executables-allow-sync"; nothing else in the pre-push gate compares
# these two files.
#
# Run from repo root or pass REPO_ROOT as arg.
@@ -213,10 +217,10 @@ if [[ -n "$STALE_KEYS" ]]; then
echo " Found instead:" >&2
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
fi
echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2
echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2
echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2
echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2
echo " Why: nothing is broken right now — apm's grant is version-blind, so a key naming another" >&2
echo " version still grants kyberforge's hooks/ and bin/. This gate is a repo-level record" >&2
echo " check: a version bump without the matching key edit fails here, which is what keeps" >&2
echo " the key an accurate record of what was approved (ADR-0019, 2026-09-19 correction)." >&2
echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2
echo " plugins/kyberforge/apm.yml is not complete without it." >&2
exit 1

View File

@@ -36,7 +36,11 @@ set -euo pipefail
# without a conflict, so the merge-base alone would let main ship both under
# one version. When <main> has not moved since the merge-base, the two
# baselines are one commit and the skill is checked once. The tip is read as
# last fetched.
# last fetched. A skill whose directory at the pushed commit is the SAME TREE
# OBJECT as at the tip skips the tip comparison: it ships exactly what main
# ships, so there is nothing to announce. The merge-base intersection catches
# that only when some base carries the content — true of the criss-cross shape
# above, false of a branch that cherry-picks a fix main already has.
#
# Pushing main itself: with origin/main as the baseline, a push of main diffs
# the new commits against what the remote already has, so it is covered. A
@@ -281,6 +285,17 @@ in_tree() {
git rev-parse --verify -q "$1:$2" > /dev/null
}
# same_subtree <commit-a> <commit-b> <path>: both name <path> with the same
# object, so the two commits ship byte-identical content there. Compared as
# object ids rather than by diffing: a tree id is the content, whatever route
# the history took to it. A path missing on either side is not a match.
same_subtree() {
local a b
a="$(git rev-parse --verify -q "$1:$3")" || return 1
b="$(git rev-parse --verify -q "$2:$3")" || return 1
[[ "$a" == "$b" ]]
}
OFFENDERS=()
for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
# Bases the skill exists at, in merge-base order, with the version read at
@@ -306,7 +321,18 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
break
fi
done
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md"; then
# A skill byte-identical to the tip's copy of it is already what main ships,
# so there is nothing left for a bump to announce. The merge-base rule alone
# would already have exempted it, but only when a base carries that same
# content — which a criss-cross history gives and a linear one does not. A
# branch cut before a fix landed on main and then cherry-picking that fix has
# one merge-base, predating the fix, so the skill counts as changed against
# it and reaches the tip comparison carrying exactly the tip's version. Same
# content, same version, and the only escapes would be a spurious bump —
# leaving main carrying two versions of identical content — or a rebase the
# push does not otherwise need.
if [[ "$tip_is_base" == false ]] && in_tree "$MAIN_TIP" "$dir/SKILL.md" \
&& ! same_subtree "$MAIN_TIP" "$PUSHED_COMMIT" "$dir"; then
at_tip=true
fi
# Absent at every baseline: new, renamed-to, or merged-into. Exempt.
@@ -317,15 +343,22 @@ for dir in ${SKILL_DIRS[@]+"${SKILL_DIRS[@]}"}; do
tip_ver=""
if $at_tip; then version_at "$MAIN_TIP" "$dir/SKILL.md"; tip_ver="$VERSION"; fi
# The baseline the two messages below name. A skill added on main after the
# branch was cut is absent at every merge-base, so no base names a version
# while the tip does — and the tip's is the version the push is actually held
# to. Reporting "none" there sends the author to the natural 1.0.0 and costs
# them a second blocked push on the same mistake.
report_ver="${first_base_ver:-$tip_ver}"
if ! in_tree "$PUSHED_COMMIT" "$dir/SKILL.md"; then
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${first_base_ver:-none})")
OFFENDERS+=("$dir: SKILL.md missing at $PUSHED_REF (baseline: ${report_ver:-none})")
continue
fi
version_at "$PUSHED_COMMIT" "$dir/SKILL.md"
cur_ver="$VERSION"
if [[ -z "$cur_ver" ]]; then
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${first_base_ver:-none})")
OFFENDERS+=("$dir: metadata.version missing or not MAJOR.MINOR.PATCH at $PUSHED_REF (baseline: ${report_ver:-none})")
continue
fi
# Named by sha only when there is more than one base to tell apart; a

View File

@@ -20,5 +20,3 @@ DEPLOY_EXECUTABLES=(
DEPLOY_DIRS=(
"core:.claude/core"
)
# Provider skill adapters are declared in providers/*/provider-manifest.sh, not here.