Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/requirements-ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ pytest==9.1.1 \
# (#2569): YAML has no jq, and a hand-rolled walk lost three review rounds to
# spellings it had not anticipated. Unlike the pure-Python pins above this is a
# C-extension wheel, so its hashes are ABI-specific — the cp314 set covers the
# CI interpreter (3.14) on Linux x64 plus the platforms the fleet develops on;
# CI interpreter (3.14, pinned in the root .python-version that every ci.yml
# setup-python step resolves; a bump there and the cpXY hash sets here move
# together) on Linux x64 plus the platforms the fleet develops on;
# the cp311 set covers the cloud VM system Python (3.11) and the same fleet
# platforms (#2654). scripts/check-hook-exec-form.sh reads this pin directly
# and borrows the same version through `uv run --with` when a local python
Expand Down
78 changes: 73 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
python-version-file: .python-version
- name: Run disk-hygiene GuardTests on Windows
working-directory: plugins/disk-hygiene/skills/clean/scripts
run: python -m unittest -v test_hygiene.GuardTests
Expand All @@ -453,7 +453,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
python-version-file: .python-version
- name: Run disk-hygiene tests
working-directory: plugins/disk-hygiene/skills/clean/scripts
run: python -m unittest -v test_hygiene
Expand Down Expand Up @@ -757,7 +757,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
python-version-file: .python-version
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install the pinned YAML reader
Expand Down Expand Up @@ -791,7 +791,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
python-version-file: .python-version
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install the pinned YAML reader
Expand Down Expand Up @@ -830,6 +830,44 @@ jobs:
- name: Run silent-skip-gate tests
run: bash scripts/check-silent-skips.test.sh

# A skip_case that vacates the only discriminating assertions in a case group
# lets a suite report green while proving nothing — the vacuous-pass shape
# the git copy-pairing suites already shipped. The check existed (and
# plugins/testing's audit skill cites it as this repo's own CI gate) but ran
# in no workflow, so it enforced nothing. The self-test runs first so a
# broken gate cannot mask a regression.
discriminating-test-skip-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run discriminating-test-skip gate tests
run: bash scripts/check-discriminating-test-skips.test.sh
- name: Check plugin test suites for discriminating skip_case sites
run: scripts/check-discriminating-test-skips.sh

# Regression sweep for the video-digest skill rename: the sweep script
# promised to break CI loudly when the skill's PRE-rename name resurfaces
# (its own header spells the hunted token; repeating it here would trip the
# sweep), but it ran in no workflow, so a revert artifact or vendored doc
# could reintroduce the old name with nothing to catch it. The self-test
# runs first so a broken sweep cannot mask a regression.
rename-sweep-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run rename-sweep gate tests
run: bash scripts/check-rename-sweep.test.sh
- name: Check for resurfacing pre-rename skill references
run: scripts/check-rename-sweep.sh

# #1547: a routine `git merge origin/main` silently dropped
# plugins/guardrails/.claude-plugin/plugin.json for ~14 minutes on a branch
# while .claude-plugin/marketplace.json still catalogued "guardrails" at that
Expand Down Expand Up @@ -1044,6 +1082,33 @@ jobs:
- name: Verify every changelog reads newest-first with no duplicate versions
run: scripts/check-changelog-parity.sh --check-order

# Vendor version bump: ADR 0019's intra-plugin sharing keeps ONE committed
# copy at the plugin root (vendor/), so no sync-*.sh byte-drift gate applies;
# the invariant that replaces it is delivery-by-version — a vendor/ source
# change must bump the plugin's manifest version, the update cache key, or
# installed consumers never receive it. Only prose enforced that, and two
# unbumped vendor edits shipped (b3445bc2, b01dace3). General over
# plugins/*/vendor/, not a per-plugin list. The self-test runs unconditionally
# so a broken gate cannot mask a regression, and the PR-diff step is
# event-gated.
vendor-version-bump-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the vendor version-bump gate
run: bash scripts/check-vendor-version-bump.test.sh
- name: Verify a vendor/ source change bumps the plugin's version
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-vendor-version-bump.sh --check-bump "origin/$BASE_REF"

# Contract-slice prune: docs/topics/<slug>/ is Contract tier per
# docs/conventions/topic-docs/README.md — committed on a task branch only,
# pruned before merge. The convention specified this required check but it was
Expand Down Expand Up @@ -1186,7 +1251,7 @@ jobs:
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
python-version-file: .python-version
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install and verify ShellCheck toolchain
Expand Down Expand Up @@ -1621,6 +1686,8 @@ jobs:
- summary-reader-parity-gate
- plugin-options-docs-gate
- silent-skip-gate
- discriminating-test-skip-gate
- rename-sweep-gate
- stale-base-overlap-gate
- plugin-manifest-presence-gate
- plugin-catalog-enablement-gate
Expand All @@ -1629,6 +1696,7 @@ jobs:
- fleet-finding-test-coverage-gate
- fleet-audit-doc-grammar-gate
- changelog-parity-gate
- vendor-version-bump-gate
- contract-slice-prune-gate
- contract-clause-coverage-gate
- docs-only-gate
Expand Down
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
5 changes: 5 additions & 0 deletions docs/CATALOG-TAXONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@ truth for each plugin's `category` and the ordering key; `plugin.json` owns each
emits the grouped catalog section between markers in `docs/CATALOG.md`, and a CI check fails when the committed
section drifts from what the manifests would produce. This retires hand-maintained catalog duplication and
keeps the grouped view and the manifests from diverging.

The generator's render order is held to this document the same way: on every run it parses the two
Vocabulary tables above (the backticked first-column values, in order) and fails when its own list
disagrees, so a value added, dropped, renamed, or reordered on either side is loud. Keep that row shape
when editing the tables — a reshaping the parse cannot read fails the check rather than passing silently.
1 change: 1 addition & 0 deletions scripts/affected-tests-no-suite.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
.editorconfig
.shellcheckrc
.node-version
.python-version
*.toml

# Docs-sync generator executed directly by the plugin-options-docs-gate CI
Expand Down
122 changes: 122 additions & 0 deletions scripts/check-vendor-version-bump.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Gate: a change under a plugin's committed vendor/ tree must bump that
# plugin's manifest version in the same change set.
#
# scripts/check-vendor-version-bump.sh --check-bump <base-ref>
#
# WHY. ADR 0019 ("Intra-plugin sharing") collapses the cross-plugin sync
# machinery when the second consumer is another skill in the SAME plugin: one
# committed copy at the plugin root (vendor/), so nothing can byte-drift and no
# sync-*.sh gate applies. The invariant that REPLACES the byte-drift gate is
# delivery-by-version — editing the shared source obligates a plugin `version`
# bump, because the version is the update cache key and an unbumped plugin
# never delivers the change to consumers. Every cross-plugin cluster gets that
# half enforced by its sync gate's --check-bump; the intra-plugin shape had
# only prose, and two drifts shipped through it: b3445bc2 re-vendored
# knowledge's scene-detect.js with the release note folded into the
# already-released 0.10.9 section and no bump, and b01dace3 edited its
# vtt-parser.js with no bump at all.
#
# WHAT IS CHECKED. For every plugin whose tracked plugins/<name>/vendor/ tree
# differs from <base-ref> — an edit, an addition, or a deletion, since each is
# a source change installed consumers must receive — the plugin's
# .claude-plugin/plugin.json `version` must also differ from <base-ref>.
# General over plugins/*/vendor/ by construction, not a per-plugin list: a
# future plugin adopting the ADR's intra-plugin shape is covered the moment its
# vendor/ directory lands. A plugin absent at the base ref is new in this
# change set; its initial release already carries the vendored source. Whether
# the bump also writes its changelog entry is changelog-parity-gate's half.
#
# Exit: 0 no unbumped vendor change; 1 at least one; 2 usage, a base ref git
# cannot resolve, a failed diff, or missing tooling — a gate that cannot see
# must refuse to pass, never report "nothing changed".
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 2
cd "$SCRIPT_DIR/.." || exit 2

if [[ "${1:-}" != "--check-bump" || -z "${2:-}" || $# -gt 2 ]]; then
echo "usage: $(basename "$0") --check-bump <base-ref>" >&2
exit 2
fi
base="$2"

# jq is how every manifest version is read below; without it the per-plugin
# reads all come back empty, which the loop would misread as "new plugin,
# exempt" for every plugin — a full-open gate. Assert it up front so absent
# tooling is its own loud exit, distinct from "nothing changed".
if ! jq --version >/dev/null 2>&1; then
echo "$(basename "$0"): jq is required to read manifest versions; refusing to pass without it" >&2
exit 2
fi

if ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null; then
echo "$(basename "$0"): cannot resolve base ref: $base" >&2
exit 2
fi

# One diff over the whole plugins/ tree, filtered structurally in the loop: a
# git pathspec glob ('plugins/*/vendor/') matches `*` across slashes, so it
# could not also hand back the plugin name the manifest lookup needs.
#
# --no-renames is load-bearing. Under rename detection (on by default for
# `git diff`), a file moved plugins/a/vendor/ -> plugins/b/vendor/ collapses to
# one R100 record whose --name-only line is the DESTINATION only, so plugin a's
# vendor deletion — a source change a's installed consumers must receive —
# would never reach the loop and bumping b alone would pass. Disabling
# detection reports the move as a delete plus an add, one path per side, and
# both plugins get checked.
# The diff's own status is checked before its output is consumed: inside the
# filter pipeline a git failure would drain to an empty plugin list, and an
# empty list reads as "nothing changed" — a gate that passes when it cannot
# see. A failed diff exits 2 instead.
if ! changed_paths="$(git diff --no-renames --name-only "$base" -- plugins/)"; then
echo "$(basename "$0"): git diff failed against $base; refusing to pass on a diff this gate could not read" >&2
exit 2
fi
changed_plugins="$(
printf '%s\n' "$changed_paths" | while IFS= read -r path; do
case "$path" in
plugins/*/vendor/*)
name="${path#plugins/}"
name="${name%%/*}"
# Only a vendor/ directly at the plugin root is the ADR's shape; a
# vendor/ nested deeper (e.g. a skill-private one) belongs to whatever
# gate owns that surface.
[[ "$path" == "plugins/$name/vendor/"* ]] && printf '%s\n' "$name"
;;
*) ;;
esac
done | sort -u
)"

if [[ -z "$changed_plugins" ]]; then
echo "No plugin vendor/ tree changed vs $base; no version bumps required."
exit 0
fi

stale=0
while IFS= read -r plugin; do
manifest="plugins/$plugin/.claude-plugin/plugin.json"
# A plugin absent at the base ref is new in this change set; its initial
# release already carries the vendored source.
base_version=$(git show "$base:$manifest" 2>/dev/null | jq -r '.version // empty' || true)
if [[ -z "$base_version" ]]; then
continue
fi
head_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null || true)
# A manifest gone (or versionless) at head while vendor/ files still changed
# is not a bump either; fail rather than skip, so deleting the manifest can
# never double as this gate's off switch.
if [[ "$head_version" == "$base_version" || -z "$head_version" ]]; then
echo "STALE VERSION: plugins/$plugin/vendor/ changed vs $base but $manifest is still ${head_version:-absent}" >&2
stale=1
fi
done <<<"$changed_plugins"

if [[ "$stale" -ne 0 ]]; then
echo "Bump the version of every plugin whose vendor/ source changed — the version is the update cache key, so an unbumped plugin never delivers the change to consumers (ADR 0019, intra-plugin sharing)." >&2
exit 1
fi

echo "Every plugin with a vendor/ change vs $base bumped its version."
Loading
Loading