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 intersectionea119d8added 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, andea119d8set the precedent by fixing the same failure class script-only.1614bceverified 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
227 lines
9.0 KiB
Bash
Executable File
227 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Fails the push when root apm.yml's executables.allow key stops naming
|
|
# 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.
|
|
|
|
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
# A nonexistent REPO_ROOT fails loudly rather than falling through to the
|
|
# "no kyberforge plugin here" no-op below: that no-op is for a repo that
|
|
# legitimately does not ship the plugin, not for a typo'd or stale path, and
|
|
# exit 0 would read as "checked, in sync" when nothing was compared at all.
|
|
if [[ ! -d "$REPO_ROOT" ]]; then
|
|
echo "FAIL: executables-allow sync check: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
|
|
echo " Fix: run this from the repo root, or pass a real repo path as the first argument." >&2
|
|
exit 1
|
|
fi
|
|
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
|
|
|
PLUGIN_DIR="$REPO_ROOT/plugins/kyberforge"
|
|
PLUGIN_MANIFEST="$PLUGIN_DIR/apm.yml"
|
|
ROOT_MANIFEST="$REPO_ROOT/apm.yml"
|
|
|
|
# Only a repo with no kyberforge plugin at all is a legitimate no-op — there is
|
|
# no version to pin and no hook to deploy.
|
|
if [[ ! -d "$PLUGIN_DIR" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
if [[ ! -f "$PLUGIN_MANIFEST" ]]; then
|
|
echo "FAIL: plugins/kyberforge/ exists but has no apm.yml, so its version cannot be read." >&2
|
|
echo " Why: this gate compares that version against root apm.yml's executables.allow key;" >&2
|
|
echo " without the manifest it would exit 0 having compared nothing." >&2
|
|
echo " Fix: restore plugins/kyberforge/apm.yml, or remove plugins/kyberforge/ entirely." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "$ROOT_MANIFEST" ]]; then
|
|
echo "FAIL: root apm.yml is missing, so kyberforge's executables allow key cannot be verified." >&2
|
|
echo " Why: apm reads executables.allow from the consuming package's manifest; with no root" >&2
|
|
echo " manifest nothing grants kyberforge's hooks/ and bin/ permission to deploy." >&2
|
|
echo " Fix: restore apm.yml at the repo root." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest facts
|
|
# ---------------------------------------------------------------------------
|
|
# Both readers emit the same line protocol, so the rest of the script does not
|
|
# care which ran:
|
|
#
|
|
# v<TAB><kyberforge version> exactly once
|
|
# a<TAB>present|absent exactly once — is executables.allow a mapping?
|
|
# k<TAB><allow key> zero or more
|
|
#
|
|
# python3 + PyYAML is preferred where importable; the fallback below is not a
|
|
# YAML parser, it recognises only the two shapes these manifests use. PyYAML
|
|
# stays optional so a missing pip package can't block every push (ADR-0019).
|
|
|
|
read_facts_python() {
|
|
python3 - "$ROOT_MANIFEST" "$PLUGIN_MANIFEST" << 'PY'
|
|
import sys
|
|
import yaml
|
|
|
|
|
|
def load(path):
|
|
with open(path) as fh:
|
|
data = yaml.safe_load(fh)
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
root, plugin = load(sys.argv[1]), load(sys.argv[2])
|
|
|
|
version = plugin.get("version")
|
|
# A 2-component version parses as a YAML float and would render back as e.g.
|
|
# "1.5" from 1.50 — a mismatch invented by the reader rather than found in the
|
|
# files. Refuse instead of guessing; semver keys here are always strings.
|
|
if version is not None and not isinstance(version, str):
|
|
sys.stderr.write(
|
|
"kyberforge's version: is not a string (%r) — quote it so the "
|
|
"executables.allow key can be compared verbatim.\n" % (version,)
|
|
)
|
|
raise SystemExit(2)
|
|
print("v\t%s" % ("" if version is None else version))
|
|
|
|
executables = root.get("executables")
|
|
allow = executables.get("allow") if isinstance(executables, dict) else None
|
|
if isinstance(allow, dict):
|
|
print("a\tpresent")
|
|
for key in allow:
|
|
print("k\t%s" % key)
|
|
else:
|
|
print("a\tabsent")
|
|
PY
|
|
}
|
|
|
|
# Strips one layer of matching quotes plus surrounding whitespace from a scalar.
|
|
unquote() {
|
|
local value="$1"
|
|
read -r value <<< "$value"
|
|
case "$value" in
|
|
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
|
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
|
esac
|
|
printf '%s\n' "$value"
|
|
}
|
|
|
|
read_facts_bash() {
|
|
local line version="" allow_state="absent" in_executables=0 in_allow=0 key
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
[[ "$line" == version:* ]] || continue
|
|
version="$(unquote "${line#version:}")"
|
|
break
|
|
done < "$PLUGIN_MANIFEST"
|
|
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
# Blank and full-line comments carry no structure at any depth.
|
|
case "$line" in
|
|
'' | '#'*) continue ;;
|
|
esac
|
|
# A top-level key (column 0) closes whatever block was open. Checked before
|
|
# anything else so `marketplace:` after `executables:` cannot leak keys in.
|
|
if [[ "$line" != [[:space:]]* ]]; then
|
|
if [[ "$line" == executables:* ]]; then
|
|
in_executables=1
|
|
else
|
|
in_executables=0
|
|
fi
|
|
in_allow=0
|
|
continue
|
|
fi
|
|
[[ $in_executables -eq 1 ]] || continue
|
|
# 2-space indent: a key directly under executables:. `allow:` opens the
|
|
# mapping this gate reads; any sibling key closes it.
|
|
if [[ "$line" =~ ^\ \ [^[:space:]#] ]]; then
|
|
if [[ "$line" =~ ^\ \ allow: ]]; then
|
|
in_allow=1
|
|
allow_state="present"
|
|
else
|
|
in_allow=0
|
|
fi
|
|
continue
|
|
fi
|
|
# 4-space indent while inside allow: — an allow key. Keys contain '#' by
|
|
# construction ("kyberforge#1.5.0"), so a trailing-comment strip would eat
|
|
# them; there is none, and inline comments are not used on these lines.
|
|
if [[ $in_allow -eq 1 && "$line" =~ ^\ \ \ \ ([^[:space:]#][^:]*): ]]; then
|
|
key="$(unquote "${BASH_REMATCH[1]}")"
|
|
printf 'k\t%s\n' "$key"
|
|
fi
|
|
done < "$ROOT_MANIFEST"
|
|
|
|
printf 'v\t%s\n' "$version"
|
|
printf 'a\t%s\n' "$allow_state"
|
|
}
|
|
|
|
if command -v python3 > /dev/null 2>&1 && python3 -c 'import yaml' > /dev/null 2>&1; then
|
|
READER="python3 + PyYAML"
|
|
if ! FACTS="$(read_facts_python)"; then
|
|
echo "FAIL: could not read apm.yml / plugins/kyberforge/apm.yml (see the parser error above)." >&2
|
|
echo " Why: this gate compares kyberforge's version against root apm.yml's executables.allow" >&2
|
|
echo " key; an unreadable manifest means the comparison did not happen." >&2
|
|
echo " Fix: make both manifests valid YAML, then re-run." >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
READER="shape-scan fallback (PyYAML unavailable)"
|
|
FACTS="$(read_facts_bash)"
|
|
fi
|
|
|
|
VERSION="$(printf '%s\n' "$FACTS" | sed -n 's/^v\t//p')"
|
|
ALLOW_STATE="$(printf '%s\n' "$FACTS" | sed -n 's/^a\t//p')"
|
|
ALLOW_KEYS="$(printf '%s\n' "$FACTS" | sed -n 's/^k\t//p')"
|
|
|
|
if [[ -z "$VERSION" ]]; then
|
|
echo "FAIL: plugins/kyberforge/apm.yml declares no version:, so no allow key can be checked against it." >&2
|
|
echo " Why: apm's executables.allow lookup is keyed on '<package>#<version>' exactly; with no" >&2
|
|
echo " version there is nothing for root apm.yml's key to stay in sync with." >&2
|
|
echo " Fix: give plugins/kyberforge/apm.yml a top-level version: (read by $READER)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
EXPECTED_KEY="kyberforge#$VERSION"
|
|
|
|
if [[ "$ALLOW_STATE" != "present" ]]; then
|
|
echo "FAIL: root apm.yml has no executables.allow mapping, but plugins/kyberforge is version $VERSION." >&2
|
|
echo " Why: without an allow entry apm refuses to deploy kyberforge's hooks/ and bin/, so the" >&2
|
|
echo " SessionStart hook that keeps this install level with the remote never runs and the" >&2
|
|
echo " deployed skills go stale silently (ADR-0019)." >&2
|
|
echo " Fix: add to root apm.yml:" >&2
|
|
echo " executables:" >&2
|
|
echo " allow:" >&2
|
|
echo " $EXPECTED_KEY:" >&2
|
|
echo " hooks: true" >&2
|
|
echo " bin: true" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if grep -qxF "$EXPECTED_KEY" <<< "$ALLOW_KEYS"; then
|
|
exit 0
|
|
fi
|
|
|
|
STALE_KEYS="$(printf '%s\n' "$ALLOW_KEYS" | grep '^kyberforge#' || true)"
|
|
|
|
echo "FAIL: root apm.yml's executables.allow has no '$EXPECTED_KEY' key, but that is kyberforge's version." >&2
|
|
if [[ -n "$STALE_KEYS" ]]; then
|
|
echo " Found instead:" >&2
|
|
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
|
|
fi
|
|
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
|