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
47 changes: 47 additions & 0 deletions .github/workflows/pr-require-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,7 @@ jobs:
.github/workflows/pr-bump-plugin-version.yml
.github/workflows/post-merge-detect-silent-revert.yml
.github/workflows/pr-test-windows.yml
.github/workflows/release-plugins.yml

- name: Verify shebang files are executable
id: exec_bit
Expand Down Expand Up @@ -1420,6 +1421,10 @@ jobs:
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
# The head branch of a pull request from this repository, never a
# fork's: scripts/lib/changelog-fragments.sh reads `release/plugins`
# here as the release pull request.
CHANGELOG_HEAD_REF: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }}
run: |
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against."
Expand Down Expand Up @@ -1450,6 +1455,45 @@ jobs:
continue-on-error: true
run: scripts/check-changelog-parity.sh --check-order

# Changelog fragments (ADR 0048). A plugin in scripts/fragment-plugins.txt
# takes a fragment under .changes/<plugin>/ in each pull request, and the
# release pull request (branch release/plugins) bumps it. --check
# validates every fragment in the tree; --check-required asks a change to
# such a plugin for one. --check-release keeps a release from merging past
# a fragment it never consumed: on the release pull request against the
# base, and in the merge queue against HEAD^1, the tree the queued squash
# commit lands on (main plus every entry ahead of it).
- name: Verify every changelog fragment is well formed
id: changelog_fragments_check
continue-on-error: true
run: scripts/check-changelog-fragments.sh --check
- name: Verify a change to a fragment-mode plugin adds a fragment
id: changelog_fragments_required
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
CHANGELOG_HEAD_REF: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }}
run: |
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against."
exit 0
fi
scripts/check-changelog-fragments.sh --check-required "origin/$BASE_REF"
- name: Verify a release consumes every pending fragment for the plugins it bumps
id: changelog_fragments_release
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
CHANGELOG_HEAD_REF: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }}
run: |
if [ "$GITHUB_EVENT_NAME" = merge_group ]; then
scripts/check-changelog-fragments.sh --check-release 'HEAD^1'
elif [ "$GITHUB_EVENT_NAME" = pull_request ] && [ "$CHANGELOG_HEAD_REF" = release/plugins ]; then
scripts/check-changelog-fragments.sh --check-release "origin/$BASE_REF"
else
echo "::notice::Declined: only the release pull request and the merge queue release plugins."
fi

# 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
Expand Down Expand Up @@ -1634,6 +1678,9 @@ jobs:
changelog-parity-bump=${{ steps.changelog_parity_bump.outcome }}
changelog-parity-preserved=${{ steps.changelog_parity_preserved.outcome }}
changelog-parity-order=${{ steps.changelog_parity_order.outcome }}
changelog-fragments=${{ steps.changelog_fragments_check.outcome }}
changelog-fragments-required=${{ steps.changelog_fragments_required.outcome }}
changelog-fragments-release=${{ steps.changelog_fragments_release.outcome }}
vendor-version-bump=${{ steps.vendor_version_bump.outcome }}
marketplace-schema=${{ needs.select-tests.outputs.run_manifests == 'false' && 'success' || steps.marketplace_schema.outcome }}
plugin-schema=${{ needs.select-tests.outputs.run_manifests == 'false' && 'success' || steps.plugin_schema.outcome }}
Expand Down
311 changes: 311 additions & 0 deletions .github/workflows/release-plugins.yml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ still respects a body hold and human comments, so never override one. The hold c
`docs/conventions/loop-lane/README.md`.

In this repository a small unrelated review fix in the same plugin as the PR also goes into the PR
(shared version bump and CHANGELOG line); the rule itself is the scope test in
(shared version bump and CHANGELOG line, or shared changelog fragment for a plugin in
`scripts/fragment-plugins.txt`); the rule itself is the scope test in
`plugins/source-control/reference/review-discipline.md`.

## When to stop and when to keep going
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,28 @@ version bumps and CHANGELOG entries.
leaves it alone, so its CI can finish. A fragment for a plugin the release does not bump waits
for the next release; that plugin's version does not move, so its newer code ships under no
wrong version.
- A rebuild is the same reset and `createCommitOnBranch` write described below, so it restarts
- A rebuild is the same `createCommitOnBranch` write and ref update described below, so it restarts
the release pull request's CI. Rebuilds happen only as often as fragments for the plugins in
the open release land, not on every main commit.
- **Release check:** a `check-changelog-fragments.sh --check-release <base>` step, run only on the
`release/plugins` pull request, fails when the base holds an unconsumed fragment for a plugin the
release bumps. Every rebuild re-runs it. That alone does not stop a stale merge: main's status
release bumps. Every rebuild re-runs it. The gates recognize the release pull request by its head
branch alone: CI passes `CHANGELOG_HEAD_REF`, the head branch of a pull request from this
repository (empty for a fork), and the scripts compare it to `release/plugins`. A path-based
exemption could not tell a release from a hand-written bump, which is the change the gates must
reject. That alone does not stop a stale merge: main's status
checks are not strict (see Context), so a green result from before a fragment landed still allows
a merge while the push-triggered rebuild is in flight. The merge path closes that gap (next
bullet).
- **Merge path:** the release pull request merges only through the workflow's own `merge` step,
never the merge button. The step runs `--check-release` against main's live tip, then calls the
merge endpoint with the release head SHA it checked; a failing check starts a rebuild instead of
a merge. That leaves only the seconds between the check and the merge call. Once main's merge
queue is active, `--check-release` also runs on `merge_group`, which tests the release against the
exact base it lands on and closes the window.
never the merge button. The step runs `--check-release` against main's live tip, then queues the
release head SHA it checked (`enqueuePullRequest` with `expectedHeadOid`; `mergePullRequest`
with the same pin only when main has no merge queue); a failing check starts a rebuild instead of
a merge. Main's merge queue went live on 2026-10-03 (ruleset 24422263, squash), so
`--check-release` also runs on every `merge_group` commit with `HEAD^1` as its base: the tree the
queued squash commit lands on, which is main plus every entry ahead of it. `merge_group.base_sha`
alone would miss a fragment queued ahead of the release. A queued commit that bumps no plugin
with pending fragments passes, so the step needs no release detection there.
- **Aggregation**, by a new `scripts/release-plugins.sh` the workflow runs:
1. Group the fragments under `.changes/<plugin>/`.
2. The new version is the manifest's current version raised once at the highest `bump` among
Expand All @@ -131,12 +138,18 @@ version bumps and CHANGELOG entries.
4. Set `version` in `plugin.json` only. No marketplace entry carries a version, and the bot does
not add one.
5. Delete the consumed fragments.

When no plugin gets a new version (every pending fragment is `bump: none`), the workflow opens
no release pull request: one titled `release 0 plugins` would only delete files. The `none`
fragments wait and go with the next release.
- **Token and signed commits:** a GitHub App installation token, not `GITHUB_TOKEN`. Pull request
events that `GITHUB_TOKEN` causes start no workflow runs or start them waiting for approval
(`dependabot-plugin-release.yml:15-20`), and the release pull request needs `ci-status` to run.
The bot resets `release/plugins` to main's tip with a ref update, then writes its one commit
through `createCommitOnBranch` with `expectedHeadOid`, the pattern at
`dependabot-plugin-release.yml:85-111`. GitHub signs that commit, which satisfies main's
The bot writes its one commit through `createCommitOnBranch` with `expectedHeadOid` (the
pattern at `dependabot-plugin-release.yml:85-111`) on a staging branch,
`release/plugins-next`, cut from main's tip, then moves `release/plugins` to that commit in one
ref update. A run cut short therefore never leaves the open release pull request with an empty
head. GitHub signs that commit, which satisfies main's
`required_signatures` rule.
- **Title and merge:** `chore(release): release <n> plugins`, which passes the Conventional Commits
title check. Its body lists each plugin's old and new version and the fragments consumed. Until
Expand All @@ -150,7 +163,7 @@ version bumps and CHANGELOG entries.
| `check-changelog-parity.sh --check`, `--check-preserved`, `--check-order` | Kept as they are. They guard CHANGELOG integrity, which now changes only in release pull requests |
| `--check-bump` | Kept for the release pull request. Its "bump without change" rule (`check-changelog-parity.sh:93-96`) counts a consumed fragment as the change. For a plugin in fragment mode, any other pull request that changes its `version` or adds a CHANGELOG heading fails with a message pointing at `.changes/` |
| New `check-changelog-fragments.sh` | Validates every added or modified fragment: path names an existing plugin, front matter has a valid `bump`, the body has at least one known `###` section (a `bump: none` fragment needs only a non-empty reason line instead), and an added fragment's path is new on the base |
| New `check-changelog-fragments.sh --check-required <base>` | For each fragment-mode plugin whose shipped files (anything under `plugins/<name>/`) the pull request changes, requires an added or modified fragment for that plugin, `bump: none` included. The release pull request is exempt |
| New `check-changelog-fragments.sh --check-required <base>` | For each fragment-mode plugin whose shipped files (anything under `plugins/<name>/`) the pull request changes, requires an added or modified fragment for that plugin, `bump: none` included. Only the release pull request's own writes (the root `CHANGELOG.md` and a version-only `plugin.json` edit) are exempt; the same edits in any other pull request need a fragment |
| New `check-changelog-fragments.sh --check-release <base>` | Runs on the release pull request only. Fails when the base holds an unconsumed fragment for a plugin the release bumps (see The release pull request) |
| `check-vendor-version-bump.sh` and the `sync-*.sh --check-bump` steps | One shared predicate in `scripts/lib/` replaces "manifest version moved" with "manifest version moved, or a fragment for that plugin with a `bump` other than `none` was added". The version-moved branch stays so legacy plugins pass during the dual mode |
| `check-stale-base-overlap.sh` | Unchanged. It stops firing on version files because pull requests no longer touch them |
Expand Down
4 changes: 4 additions & 0 deletions docs/migration-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@ Separate **plugin-owned** logic from **consumer-owned** extension points:
- **A `version` bump in `plugin.json` is the only delivery vehicle.** A consumer receives a change only
after the plugin's semver `version` increases. The version is the update cache key, so an unbumped
plugin never delivers, even when its files changed (see "Shared code across plugins" below).
A plugin listed in `scripts/fragment-plugins.txt` is the exception: its pull requests add a
changelog fragment (`scripts/new-changelog-fragment.sh <plugin> <bump>`) and leave the version
and `CHANGELOG.md` alone, and the release pull request bumps it
([ADR 0048](adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md)).
- **Consumers update deliberately** with `/plugin marketplace update <marketplace>`, which refetches
the marketplace. There is no silent auto-push of plugin changes to a consumer.
- **Breaking-change / changelog note per plugin.** A version bump that changes behavior a consumer
Expand Down
24 changes: 18 additions & 6 deletions scripts/check-changelog-fragments.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@
# scripts/check-changelog-fragments.sh --check-release <base-ref>
# for the release pull request: <base-ref> holds no fragment this release
# left unconsumed for a plugin whose version it bumps, and no fragment it
# consumed has changed on <base-ref> since the release was cut
# consumed has changed on <base-ref> since the release was cut. In the
# merge queue <base-ref> is HEAD^1, the tree the queued commit lands on
# (main plus every entry ahead of it); a change set that bumps no plugin
# with pending fragments passes, so the step runs on every queued commit
#
# Shipped files are everything under plugins/<name>/ 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.
# Shipped files are everything under plugins/<name>/. On the release pull
# request (changelog_fragments::is_release_pr) the root CHANGELOG.md and a
# plugin.json edit to `version` alone are left out: those are what a release
# writes, so it needs no fragment of its own. Any other pull request that edits
# them needs a fragment like any other change, and check-changelog-parity.sh
# --check-bump fails one that bumps the version or adds a CHANGELOG heading.
#
# 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
Expand Down Expand Up @@ -90,6 +96,11 @@ 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; }

release_pr=""
# shellcheck disable=SC2310 # the non-zero return IS the handled case
if changelog_fragments::is_release_pr; then
release_pr=1
fi
declare -A shipped=() covered=() bumped=() consumed=()
added_fragments=()
while IFS= read -r -d '' status && IFS= read -r -d '' path; do
Expand All @@ -106,20 +117,21 @@ while IFS= read -r -d '' status && IFS= read -r -d '' path; do
*) ;;
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
[[ -n "$release_pr" ]] || shipped["$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
name="${rest%%/*}"
[[ -n "$release_pr" && "$rest" == "$name/CHANGELOG.md" ]] || shipped["$name"]=1
;;
*) ;;
esac
Expand Down
50 changes: 48 additions & 2 deletions scripts/check-changelog-fragments.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ fragment() {

commit() { git_test_config "$1" add -A && git_test_config "$1" commit -qm "$2"; }

# HEAD_REF, when set, is the CHANGELOG_HEAD_REF the gate sees, as CI passes it.
HEAD_REF=""
run_gate() (
cd "$1" && shift && bash scripts/check-changelog-fragments.sh "$@"
cd "$1" && shift && CHANGELOG_HEAD_REF="$HEAD_REF" bash scripts/check-changelog-fragments.sh "$@"
)

# expect <label> <rc> <needle> <fixture> <args...>
Expand Down Expand Up @@ -176,7 +178,31 @@ git_test_config "$f" checkout -qb feat/x
printf '{\n "name": "alpha",\n "version": "1.0.1"\n}\n' >"$f/plugins/alpha/.claude-plugin/plugin.json"
printf '# Changelog\n\n## [1.0.1] - 2026-10-02\n\n### Fixed\n\n- x\n\n## [1.0.0] - 2026-10-01\n\n### Added\n\n- First.\n' >"$f/plugins/alpha/CHANGELOG.md"
commit "$f" release
expect "--check-required exempts a version-only manifest edit and CHANGELOG.md (the release shape)" 0 "has a fragment" "$f" --check-required main
expect "--check-required asks a non-release change set that bumps by hand for a fragment" 1 "MISSING FRAGMENT: this change set changes files under plugins/alpha/" "$f" --check-required main
HEAD_REF=release/plugins
expect "--check-required exempts the release pull request's version-only manifest edit and CHANGELOG.md" 0 "has a fragment" "$f" --check-required main
HEAD_REF=release/plugins-fork
expect "--check-required grants the exemption to the exact release branch only" 1 "MISSING FRAGMENT" "$f" --check-required main
HEAD_REF=""
rm -rf "$f"

base_fixture f alpha
git_test_config "$f" checkout -qb feat/x
printf '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n### Added\n\n- First, reworded.\n' >"$f/plugins/alpha/CHANGELOG.md"
commit "$f" reword
expect "--check-required asks for a fragment when a non-release change set edits CHANGELOG.md" 1 "MISSING FRAGMENT" "$f" --check-required main
fragment "$f" alpha feat-x-0123abcd none "Changelog wording only."
commit "$f" fragment
expect "--check-required accepts a bump: none fragment for a CHANGELOG.md edit" 0 "has a fragment" "$f" --check-required main
rm -rf "$f"

base_fixture f alpha
git_test_config "$f" checkout -qb release/plugins
printf 'v2\n' >"$f/plugins/alpha/skills/a.md"
commit "$f" smuggled
HEAD_REF=release/plugins
expect "--check-required still asks the release pull request for a fragment for a shipped file" 1 "MISSING FRAGMENT" "$f" --check-required main
HEAD_REF=""
rm -rf "$f"

base_fixture f alpha
Expand Down Expand Up @@ -237,6 +263,26 @@ git_test_config "$f" checkout -q release/plugins
expect "--check-release fails when a consumed fragment was edited on the base after the cut" 1 "EDITED FRAGMENT: .changes/alpha/feat-x-0123abcd.md" "$f" --check-release main
rm -rf "$f"

# The merge queue: each queued pull request is one squash commit on top of main
# and the entries ahead of it, and the gate reads HEAD^1 as the landing base.
base_fixture f alpha
release_branch "$f"
release_commit="$(git -C "$f" rev-parse HEAD)"
git_test_config "$f" checkout -qb queue-alone main
git_test_config "$f" cherry-pick "$release_commit" >/dev/null
expect "--check-release HEAD^1 passes a queued release that lands on the base it was cut from" 0 "for the 1 plugin(s) it bumps" "$f" --check-release 'HEAD^1'
git_test_config "$f" checkout -qb queue-behind main
fragment "$f" alpha queued-a-0badc0de patch
commit "$f" "fragment pull request ahead in the queue"
git_test_config "$f" cherry-pick "$release_commit" >/dev/null
expect "--check-release HEAD^1 ejects a queued release behind a fragment for a plugin it bumps" 1 "UNCONSUMED FRAGMENT: .changes/alpha/queued-a-0badc0de.md" "$f" --check-release 'HEAD^1'
git_test_config "$f" checkout -qb queue-other main
printf 'v2\n' >"$f/plugins/beta/skills/a.md"
printf '{\n "name": "beta",\n "version": "1.0.1"\n}\n' >"$f/plugins/beta/.claude-plugin/plugin.json"
commit "$f" "legacy pull request"
expect "--check-release HEAD^1 passes a queued pull request that bumps a plugin with no fragments" 0 "for the 1 plugin(s) it bumps" "$f" --check-release 'HEAD^1'
rm -rf "$f"

# --- usage ------------------------------------------------------------------------
base_fixture f alpha
expect "no arguments exits 2" 2 "usage" "$f"
Expand Down
Loading
Loading