From 388961fd31853acc5c8d11412e200df23d4ce394 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:30:31 -0400 Subject: [PATCH 1/3] feat(scripts): add changelog-fragment tooling with no plugin opted in Phase 1 of ADR 0048 (#6006). Adds the fragment tooling and the shared bump predicate; scripts/fragment-plugins.txt is empty, so every plugin keeps the per-PR bump and no gate changes its answer. - scripts/new-changelog-fragment.sh writes .changes//-<8 hex>.md with the front matter. - scripts/check-changelog-fragments.sh validates fragments (--check), requires one for a fragment-mode plugin whose shipped files changed (--check-required ), and fails a release that leaves a base fragment unconsumed for a plugin it bumps (--check-release ). - scripts/release-plugins.sh raises each plugin once at its highest bump, writes one CHANGELOG entry merged section by section in fragment commit order, sets version in plugin.json only, and deletes consumed fragments; all-none plugins lose their fragments without a bump. - scripts/lib/changelog-fragments.sh holds the shared predicate "version moved, or a fragment-mode plugin added a non-none fragment", now used by check-vendor-version-bump.sh, sync-shared-copies.sh --check-bump and check-standards-contract-bump.sh. Part of #6006. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check-changelog-fragments.sh | 173 +++++++++++++ scripts/check-changelog-fragments.test.sh | 236 ++++++++++++++++++ scripts/check-script-contract.test.sh | 13 + scripts/check-standards-contract-bump.sh | 11 +- scripts/check-standards-contract-bump.test.sh | 16 ++ scripts/check-vendor-version-bump.sh | 18 +- scripts/check-vendor-version-bump.test.sh | 30 +++ scripts/fragment-plugins.txt | 5 + scripts/lib/changelog-fragments.sh | 166 ++++++++++++ scripts/lib/changelog-fragments.test.sh | 103 ++++++++ scripts/lib/gate-entry.sh | 4 +- scripts/lib/gate-entry.test.sh | 1 + scripts/new-changelog-fragment.sh | 58 +++++ scripts/new-changelog-fragment.test.sh | 90 +++++++ scripts/release-plugins.sh | 192 ++++++++++++++ scripts/release-plugins.test.sh | 217 ++++++++++++++++ scripts/sync-shared-copies.sh | 12 +- scripts/sync-shared-copies.test.sh | 19 ++ 18 files changed, 1355 insertions(+), 9 deletions(-) create mode 100755 scripts/check-changelog-fragments.sh create mode 100755 scripts/check-changelog-fragments.test.sh create mode 100644 scripts/fragment-plugins.txt create mode 100644 scripts/lib/changelog-fragments.sh create mode 100755 scripts/lib/changelog-fragments.test.sh create mode 100755 scripts/new-changelog-fragment.sh create mode 100755 scripts/new-changelog-fragment.test.sh create mode 100755 scripts/release-plugins.sh create mode 100755 scripts/release-plugins.test.sh diff --git a/scripts/check-changelog-fragments.sh b/scripts/check-changelog-fragments.sh new file mode 100755 index 0000000000..92fae27545 --- /dev/null +++ b/scripts/check-changelog-fragments.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Gates for changelog fragments (ADR 0048). +# +# scripts/check-changelog-fragments.sh --check +# every file under .changes/ is a valid fragment: .changes// +# -<8 hex>.md for a plugin in fragment mode, front matter +# with one valid `bump`, and a body of known `###` sections (or, for +# `bump: none`, a non-empty reason) +# scripts/check-changelog-fragments.sh --check-required +# a change set that changes a fragment-mode plugin's shipped files adds or +# modifies a fragment for it; a fragment it adds must not already exist at +# +# scripts/check-changelog-fragments.sh --check-release +# for the release pull request: holds no fragment this release +# left unconsumed for a plugin whose version it bumps +# +# Shipped files are everything under plugins// except CHANGELOG.md and a +# plugin.json edit to `version` alone: those two are what a release writes, so +# the release pull request needs no fragment of its own. +# +# The diff modes read the change set as check-changelog-parity.sh does: fork +# point to branch tip, with a pull_request merge commit resolved to its branch +# tip. +# +# Exit 0 clean, 1 findings, 2 environment or usage; findings on stderr. That is +# the check-script family contract stated in README.md. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 2 +cd "$SCRIPT_DIR/.." || exit 2 +# shellcheck source=lib/gate-entry.sh +. "$SCRIPT_DIR/lib/gate-entry.sh" || exit 2 +# shellcheck source=lib/changelog-fragments.sh +. "$SCRIPT_DIR/lib/changelog-fragments.sh" || exit 2 + +self="$(basename "$0")" +GE_FLAGS=("--check" "--check-required:ref" "--check-release:ref") +GE_BAD_REF_MSG="$self: base ref '${2-}' is not a resolvable commit." +# shellcheck disable=SC2310 # the non-zero return IS the handled case +if ! gate_entry::classify "$@"; then + echo "usage: $self [--check | --check-required | --check-release ]" >&2 + exit 2 +fi +mode="$GE_MODE" +base="$GE_REF" + +if [[ "$mode" == --check ]]; then + fragments=() + if [[ -d .changes ]]; then + mapfile -t fragments < <(find .changes -type f | LC_ALL=C sort) + fi + invalid=0 + for path in ${fragments[@]+"${fragments[@]}"}; do + changelog_fragments::validate "$path" + case $? in + 0) ;; + 1) invalid=$((invalid + 1)) ;; + *) exit 2 ;; + esac + done + if ((invalid > 0)); then + echo "$invalid of ${#fragments[@]} changelog fragment(s) are invalid; see docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md for the format." >&2 + gate_entry::finish 1 + fi + echo "All ${#fragments[@]} changelog fragment(s) under .changes/ are valid." + gate_entry::finish 0 +fi + +if ! jq --version >/dev/null 2>&1; then + echo "$self: jq is required to read manifest versions; refusing to pass without it." >&2 + exit 2 +fi + +head_commit=HEAD +if git rev-parse -q --verify 'HEAD^2' >/dev/null 2>&1 && git merge-base --is-ancestor 'HEAD^1' "$base" 2>/dev/null; then + head_commit='HEAD^2' +fi +if ! merge_base="$(git merge-base "$base" "$head_commit")"; then + echo "$self: 'git merge-base $base $head_commit' failed; refusing to pass without checking." >&2 + exit 2 +fi +status_file="$(mktemp)" || exit 2 +trap 'rm -f "$status_file"' EXIT +if ! git diff --name-status --no-renames -z "$merge_base" "$head_commit" >"$status_file"; then + echo "$self: the diff from $merge_base to $head_commit failed; refusing to pass without checking." >&2 + exit 2 +fi + +manifest_version() { git show "$1:$2" 2>/dev/null | jq -r '.version // empty' 2>/dev/null; } +manifest_sans_version() { git show "$1:$2" 2>/dev/null | jq -cS 'del(.version)' 2>/dev/null; } + +declare -A shipped=() covered=() bumped=() consumed=() +added_fragments=() +while IFS= read -r -d '' status && IFS= read -r -d '' path; do + case "$path" in + .changes/*/*) + rest="${path#.changes/}" + case "$status" in + A) + covered["${rest%%/*}"]=1 + added_fragments+=("$path") + ;; + M) covered["${rest%%/*}"]=1 ;; + D) consumed["$path"]=1 ;; + *) ;; + esac + ;; + plugins/*/CHANGELOG.md) ;; + plugins/*/.claude-plugin/plugin.json) + rest="${path#plugins/}" + name="${rest%%/*}" + if [[ "$(manifest_version "$merge_base" "$path")" != "$(manifest_version "$head_commit" "$path")" ]]; then + bumped["$name"]=1 + fi + if [[ "$(manifest_sans_version "$merge_base" "$path")" != "$(manifest_sans_version "$head_commit" "$path")" ]]; then + shipped["$name"]=1 + fi + ;; + plugins/*/*) + rest="${path#plugins/}" + shipped["${rest%%/*}"]=1 + ;; + *) ;; + esac +done <"$status_file" + +if [[ "$mode" == --check-required ]]; then + findings=0 + for path in ${added_fragments[@]+"${added_fragments[@]}"}; do + if git cat-file -e "$base:$path" 2>/dev/null; then + echo "FRAGMENT PATH TAKEN: $path already exists at $base; create a new one with scripts/new-changelog-fragment.sh." >&2 + findings=$((findings + 1)) + fi + done + checked=0 + for name in "${!shipped[@]}"; do + changelog_fragments::in_mode "$name" + case $? in + 0) ;; + 1) continue ;; + *) exit 2 ;; + esac + checked=$((checked + 1)) + [[ -z "${covered[$name]:-}" ]] || continue + echo "MISSING FRAGMENT: this change set changes files under plugins/$name/ but adds or edits no fragment under .changes/$name/." >&2 + echo " Run scripts/new-changelog-fragment.sh $name and describe the change; use none, with a reason, when it needs no release." >&2 + findings=$((findings + 1)) + done + if ((findings > 0)); then + gate_entry::finish 1 + fi + echo "Every fragment-mode plugin this change set changes ($checked) has a fragment, and no added fragment path exists at $base." + gate_entry::finish 0 +fi + +# --check-release +findings=0 +for name in "${!bumped[@]}"; do + if ! pending="$(git ls-tree -r --name-only "$base" -- ".changes/$name/")"; then + echo "$self: 'git ls-tree $base -- .changes/$name/' failed; refusing to pass without checking." >&2 + exit 2 + fi + while IFS= read -r path; do + [[ -n "$path" && -z "${consumed[$path]:-}" ]] || continue + echo "UNCONSUMED FRAGMENT: $path is on $base but this release bumps $name without it; rebuild the release from $base." >&2 + findings=$((findings + 1)) + done <<<"$pending" +done +if ((findings > 0)); then + gate_entry::finish 1 +fi +echo "This release leaves no fragment on $base unconsumed for the ${#bumped[@]} plugin(s) it bumps." +gate_entry::finish 0 diff --git a/scripts/check-changelog-fragments.test.sh b/scripts/check-changelog-fragments.test.sh new file mode 100755 index 0000000000..88bbb8781a --- /dev/null +++ b/scripts/check-changelog-fragments.test.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Unit tests for check-changelog-fragments.sh. Each case builds a throwaway git +# repo holding the gate, scripts/lib/ and a scripts/fragment-plugins.txt, and +# runs the gate against it. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SELF_DIR/check-changelog-fragments.sh" +. "$SELF_DIR/test-git-helpers.sh" + +# shellcheck source=lib/test-harness.sh +. "$SELF_DIR/lib/test-harness.sh" +# shellcheck source=lib/fixture-tree.sh +. "$SELF_DIR/lib/fixture-tree.sh" + +f="" + +# base_fixture [listed...] — plugins alpha and beta at 1.0.0, the +# named ones in fragment mode, committed on main. +base_fixture() { + local dir name + fixture_tree::build "$1" --sut "$SCRIPT" --git --plugins --label fragments || return 1 + dir="${!1}" + shift + for name in alpha beta; do + mkdir -p "$dir/plugins/$name/.claude-plugin" "$dir/plugins/$name/skills" + printf '{\n "name": "%s",\n "version": "1.0.0"\n}\n' "$name" >"$dir/plugins/$name/.claude-plugin/plugin.json" + printf '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n### Added\n\n- First.\n' >"$dir/plugins/$name/CHANGELOG.md" + printf 'v1\n' >"$dir/plugins/$name/skills/a.md" + done + printf '%s\n' '# fragment mode' "$@" >"$dir/scripts/fragment-plugins.txt" + git_test_config "$dir" add -A + git_test_config "$dir" commit -qm base + git_test_config "$dir" branch -q -M main +} + +# fragment [body] +fragment() { + local dir="$1" plugin="$2" name="$3" bump="$4" body="${5-### Fixed + +- A fix.}" + mkdir -p "$dir/.changes/$plugin" + printf -- '---\nbump: %s\n---\n\n%s\n' "$bump" "$body" >"$dir/.changes/$plugin/$name.md" +} + +commit() { git_test_config "$1" add -A && git_test_config "$1" commit -qm "$2"; } + +run_gate() ( + cd "$1" && shift && bash scripts/check-changelog-fragments.sh "$@" +) + +# expect