From 01e03b86ac95dbea846aae514ae38d5cff309983 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:11:02 -0400 Subject: [PATCH 1/5] feat(ci): add the release workflow and pilot changelog fragments on domain-driven-design Phase 2 of ADR 0048 (code half). The gates recognize the release pull request by its head branch, release/plugins, which CI passes as CHANGELOG_HEAD_REF for same-repository pull requests only. - check-changelog-parity.sh --check-bump: a fragment the change set deletes counts as the release's change for BUMP WITHOUT CHANGE. Any other change set that moves a fragment-mode plugin's version or adds a CHANGELOG heading fails FRAGMENT-MODE RELEASE, pointing at .changes/; the per-PR rules, PUBLISHED VERSION REUSE included, no longer apply to those plugins. - check-changelog-fragments.sh --check-required: CHANGELOG.md and version-only plugin.json edits are exempt on the release pull request only. - pr-require-checks.yml check-plugins: --check on every event, --check-required on pull requests, --check-release on the release pull request and on every merge_group commit against HEAD^1. - release-plugins.yml: workflow_dispatch only, build and merge actions, release App token, ref reset plus one createCommitOnBranch commit. - scripts/fragment-plugins.txt: domain-driven-design is the pilot. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pr-require-checks.yml | 47 +++ .github/workflows/release-plugins.yml | 298 ++++++++++++++++++ AGENTS.md | 3 +- ...nts-through-a-bot-maintained-release-pr.md | 25 +- docs/migration-playbook.md | 4 + scripts/check-changelog-fragments.sh | 24 +- scripts/check-changelog-fragments.test.sh | 50 ++- scripts/check-changelog-parity.sh | 69 +++- scripts/check-changelog-parity.test.sh | 69 ++++ scripts/fragment-plugins.txt | 1 + scripts/lib/changelog-fragments.sh | 12 + scripts/lib/changelog-fragments.test.sh | 8 + 12 files changed, 591 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/release-plugins.yml diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 7c8f346b4a..78a1643e0b 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -774,6 +774,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 @@ -1383,6 +1384,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." @@ -1413,6 +1418,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// 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 @@ -1597,6 +1641,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 }} diff --git a/.github/workflows/release-plugins.yml b/.github/workflows/release-plugins.yml new file mode 100644 index 0000000000..176302f465 --- /dev/null +++ b/.github/workflows/release-plugins.yml @@ -0,0 +1,298 @@ +name: release-plugins + +# The bot-maintained release pull request of ADR 0048 +# (docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md). +# `build` turns the changelog fragments on main into one commit on +# `release/plugins` (scripts/release-plugins.sh) and opens or updates the pull +# request; `merge` checks that pull request against main's live tip and queues +# the head it checked. +# +# Both run as the dedicated release GitHub App, never GITHUB_TOKEN: a pull +# request that GITHUB_TOKEN opens or updates starts no `pull_request` runs, and +# the release pull request needs `ci-status`. The commit goes through the GraphQL +# createCommitOnBranch mutation, which GitHub signs, so main's +# required_signatures rule accepts it (the pattern of pr-bump-plugin-version.yml). +on: + # The phase 2 pilot runs by hand only. A person starts `merge` until the pilot + # proves it (ADR 0048, Resolved decisions 1). + workflow_dispatch: + inputs: + action: + description: >- + build: open the release pull request, or rebuild it when main holds a + fragment it did not consume for a plugin it bumps, or when it has a + merge conflict. merge: check it against main's live tip, then queue + the head that passed; a failed check rebuilds it instead. + type: choice + options: [build, merge] + default: build + # Phase 3 (ADR 0048, Rollout 3) adds the schedule, every 3 hours (Resolved + # decisions 2), and the push of a fragment to main: + # + # schedule: + # - cron: '41 */3 * * *' + # push: + # branches: [main] + # paths: ['.changes/**'] + +permissions: {} + +# One release at a time. A newer run supersedes a running one: the newest run +# reads the newest main. +concurrency: + group: release-plugins + cancel-in-progress: true + +jobs: + merge: + if: github.ref == 'refs/heads/main' && inputs.action == 'merge' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + rebuild: ${{ steps.check.outputs.rebuild }} + steps: + - name: Mint the release App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.PLUGIN_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.PLUGIN_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Check out main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - name: Find the release pull request + id: pr + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head release/plugins --base main --state open --json id,number,headRefOid --jq '.[0] // empty')" + if [ -z "$pr" ]; then + echo "::error::No release pull request is open; run this workflow with action build first." + exit 1 + fi + { + echo "id=$(jq -r .id <<<"$pr")" + echo "number=$(jq -r .number <<<"$pr")" + echo "head=$(jq -r .headRefOid <<<"$pr")" + } >>"$GITHUB_OUTPUT" + # Read main at this moment, not at checkout: the check and the queue call + # are seconds apart, and the merge queue re-runs the check on the exact + # tree the release lands on (pr-require-checks.yml, check-plugins). + - name: Check the release against main's live tip + id: check + env: + HEAD_SHA: ${{ steps.pr.outputs.head }} + run: | + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' "$HEAD_SHA" + git checkout -q --detach "$HEAD_SHA" + rc=0 + scripts/check-changelog-fragments.sh --check-release origin/main || rc=$? + case "$rc" in + 0) echo "rebuild=false" >>"$GITHUB_OUTPUT" ;; + 1) + echo "::warning::The release pull request is stale against main; rebuilding it instead of merging." + echo "rebuild=true" >>"$GITHUB_OUTPUT" + ;; + *) exit "$rc" ;; + esac + # expectedHeadOid pins the queued head to the one checked above: a push to + # release/plugins in between makes the call fail rather than queue an + # unchecked head. The call fails too while `ci-status` is not green on it. + - name: Queue the checked head + if: steps.check.outputs.rebuild == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_ID: ${{ steps.pr.outputs.id }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head }} + run: | + owner="${GITHUB_REPOSITORY%/*}" + name="${GITHUB_REPOSITORY#*/}" + # shellcheck disable=SC2016 # the $ names are GraphQL variables, not shell + queue="$(gh api graphql -f owner="$owner" -f name="$name" \ + -f query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { mergeQueue(branch: "main") { id } } }' \ + --jq '.data.repository.mergeQueue.id // empty')" + # shellcheck disable=SC2016 # the $ names are GraphQL variables, not shell + if [ -n "$queue" ]; then + gh api graphql -f id="$PR_ID" -f head="$HEAD_SHA" \ + -f query='mutation($id: ID!, $head: GitObjectID!) { enqueuePullRequest(input: {pullRequestId: $id, expectedHeadOid: $head}) { mergeQueueEntry { position } } }' \ + --jq '.data.enqueuePullRequest.mergeQueueEntry.position' >/dev/null + echo "Queued release pull request #$PR_NUMBER at $HEAD_SHA." + else + gh api graphql -f id="$PR_ID" -f head="$HEAD_SHA" \ + -f query='mutation($id: ID!, $head: GitObjectID!) { mergePullRequest(input: {pullRequestId: $id, expectedHeadOid: $head, mergeMethod: SQUASH}) { pullRequest { number } } }' \ + --jq '.data.mergePullRequest.pullRequest.number' >/dev/null + echo "Merged release pull request #$PR_NUMBER at $HEAD_SHA." + fi + + build: + needs: [merge] + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && (inputs.action != 'merge' || needs.merge.outputs.rebuild == 'true') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Mint the release App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.PLUGIN_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.PLUGIN_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + # Full history: release-plugins.sh orders each plugin's fragments by the + # commit that added them, and refuses a shallow clone. A fresh checkout + # each run also means a run cut short can never leave a half-written tree + # for the next one. + - name: Check out main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + # ADR 0048, The release pull request: with one open, rebuild it only when + # main holds a fragment it did not consume for a plugin it bumps (or one it + # consumed has since changed), or when it has a merge conflict. Otherwise + # leave it alone so its CI can finish. + - name: Decide whether to build + id: decide + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REBUILD: ${{ needs.merge.outputs.rebuild }} + run: | + main_sha="$(git rev-parse HEAD)" + echo "main=$main_sha" >>"$GITHUB_OUTPUT" + pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head release/plugins --base main --state open --json number,headRefOid,mergeable --jq '.[0] // empty')" + build=false + if [ "$REBUILD" = true ]; then + echo "The merge check found the release stale; rebuilding." + build=true + elif [ -z "$pr" ]; then + echo "No release pull request is open; building one." + build=true + elif [ "$(jq -r .mergeable <<<"$pr")" = CONFLICTING ]; then + echo "Release pull request #$(jq -r .number <<<"$pr") has a merge conflict; rebuilding." + build=true + else + head="$(jq -r .headRefOid <<<"$pr")" + git fetch --no-tags origin "$head" + git checkout -q --detach "$head" + rc=0 + scripts/check-changelog-fragments.sh --check-release "$main_sha" || rc=$? + git checkout -q --detach "$main_sha" + case "$rc" in + 0) echo "Release pull request #$(jq -r .number <<<"$pr") consumes every pending fragment for the plugins it bumps; leaving it alone." ;; + 1) build=true ;; + *) exit "$rc" ;; + esac + fi + echo "build=$build" >>"$GITHUB_OUTPUT" + - name: Release the pending fragments + id: release + if: steps.decide.outputs.build == 'true' + run: | + report="$RUNNER_TEMP/release-report.txt" + scripts/release-plugins.sh | tee "$report" + released="$(grep -c ' -> ' "$report" || true)" + echo "released=$released" >>"$GITHUB_OUTPUT" + echo "report=$report" >>"$GITHUB_OUTPUT" + if [ "$released" = 0 ]; then + echo "::notice::No fragment on main asks for a release; nothing to write. A bump: none fragment waits for the next release." + fi + # Reset release/plugins to main's tip with a ref update, then write the + # release as one signed commit on it. expectedHeadOid makes the write fail + # if anything moved the branch after the reset. + - name: Write the release commit + if: steps.decide.outputs.build == 'true' && steps.release.outputs.released != '0' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + MAIN_SHA: ${{ steps.decide.outputs.main }} + RELEASED: ${{ steps.release.outputs.released }} + run: | + refs="repos/$GITHUB_REPOSITORY/git/refs" + if gh api "$refs/heads/release/plugins" --silent 2>/dev/null; then + gh api -X PATCH "$refs/heads/release/plugins" -f sha="$MAIN_SHA" -F force=true --silent + else + gh api -X POST "$refs" -f ref=refs/heads/release/plugins -f sha="$MAIN_SHA" --silent + fi + git add -A -- plugins .changes + # File contents go through stdin and files, never argv, so a large file fits. + adds="$RUNNER_TEMP/additions.jsonl" + dels="$RUNNER_TEMP/deletions.json" + payload="$RUNNER_TEMP/payload.json" + : >"$adds" + while IFS= read -r -d '' path; do + git show ":$path" | base64 -w0 | jq -Rsc --arg path "$path" '{path: $path, contents: .}' >>"$adds" + done < <(git diff --cached --name-only --diff-filter=AM -z) + git diff --cached --name-only --diff-filter=D -z | + jq -Rs 'split("\u0000") | map(select(length > 0) | {path: .})' >"$dels" + jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg oid "$MAIN_SHA" \ + --arg headline "chore(release): release $RELEASED plugins" \ + --slurpfile additions "$adds" \ + --slurpfile deletions "$dels" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: "release/plugins"}, + expectedHeadOid: $oid, + message: {headline: $headline}, + fileChanges: {additions: $additions, deletions: $deletions[0]} + }} + }' >"$payload" + gh api graphql --input - <"$payload" | jq -e '.data.createCommitOnBranch.commit.oid' + - name: Open or update the release pull request + if: steps.decide.outputs.build == 'true' && steps.release.outputs.released != '0' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + MAIN_SHA: ${{ steps.decide.outputs.main }} + RELEASED: ${{ steps.release.outputs.released }} + REPORT: ${{ steps.release.outputs.report }} + run: | + # release-plugins.sh prints one line per plugin: + # : -> () from fragment(s): ... + # : no release; deleted bump: none fragment(s): ... + released_re='^([^:]+): ([^ ]+) -> ([^ ]+) \(([a-z]+)\) from [0-9]+ fragment\(s\): (.*)$' + none_re='^([^:]+): no release; deleted [0-9]+ bump: none fragment\(s\): (.*)$' + code_list() { local p out=""; for p in $1; do out+="${out:+
}\`$p\`"; done; printf '%s' "$out"; } + body="$RUNNER_TEMP/body.md" + # shellcheck disable=SC2016 # backticks in single quotes are Markdown, not command substitution + { + echo "Releases $RELEASED plugins from the changelog fragments on \`main\` at $MAIN_SHA, per [ADR 0048](https://github.com/$GITHUB_REPOSITORY/blob/main/docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md)." + echo + echo "| Plugin | Old | New | Bump | Fragments consumed |" + echo "|---|---|---|---|---|" + while IFS= read -r line; do + if [[ "$line" =~ $released_re ]]; then + printf '| `%s` | %s | %s | %s | %s |\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" "$(code_list "${BASH_REMATCH[5]}")" + fi + done <"$REPORT" + while IFS= read -r line; do + if [[ "$line" =~ $none_re ]]; then + printf '\nDeleted with no release (`bump: none`) for `%s`: %s\n' "${BASH_REMATCH[1]}" "$(code_list "${BASH_REMATCH[2]}")" + fi + done <"$REPORT" + echo + echo "Merge it only by running the \`release-plugins\` workflow with action \`merge\`, never the merge button: that run checks it against main's live tip first." + } >"$body" + title="chore(release): release $RELEASED plugins" + number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head release/plugins --base main --state open --json number --jq '.[0].number // empty')" + if [ -n "$number" ]; then + gh pr edit "$number" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body" + echo "Updated release pull request #$number." + else + gh pr create --repo "$GITHUB_REPOSITORY" --base main --head release/plugins --title "$title" --body-file "$body" + fi diff --git a/AGENTS.md b/AGENTS.md index 89ee46a39b..5af7ca7b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md b/docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md index dc66674991..164fa428e9 100644 --- a/docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md +++ b/docs/adr/0048-release-plugins-from-changelog-fragments-through-a-bot-maintained-release-pr.md @@ -111,16 +111,23 @@ version bumps and CHANGELOG entries. the open release land, not on every main commit. - **Release check:** a `check-changelog-fragments.sh --check-release ` 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//`. 2. The new version is the manifest's current version raised once at the highest `bump` among @@ -131,6 +138,10 @@ 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. @@ -150,7 +161,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 ` | For each fragment-mode plugin whose shipped files (anything under `plugins//`) 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 ` | For each fragment-mode plugin whose shipped files (anything under `plugins//`) 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 ` | 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 | diff --git a/docs/migration-playbook.md b/docs/migration-playbook.md index 766baa139f..1b74069a9d 100644 --- a/docs/migration-playbook.md +++ b/docs/migration-playbook.md @@ -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 `) 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 `, 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 diff --git a/scripts/check-changelog-fragments.sh b/scripts/check-changelog-fragments.sh index e2589bc096..bfc9eafbcc 100755 --- a/scripts/check-changelog-fragments.sh +++ b/scripts/check-changelog-fragments.sh @@ -13,11 +13,17 @@ # 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, and no fragment it -# consumed has changed on since the release was cut +# consumed has changed on since the release was cut. In the +# merge queue 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// 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//. 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 @@ -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 @@ -106,12 +117,12 @@ 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 @@ -119,7 +130,8 @@ while IFS= read -r -d '' status && IFS= read -r -d '' path; do ;; plugins/*/*) rest="${path#plugins/}" - shipped["${rest%%/*}"]=1 + name="${rest%%/*}" + [[ -n "$release_pr" && "$rest" == "$name/CHANGELOG.md" ]] || shipped["$name"]=1 ;; *) ;; esac diff --git a/scripts/check-changelog-fragments.test.sh b/scripts/check-changelog-fragments.test.sh index d2a8248cea..3ba3642200 100755 --- a/scripts/check-changelog-fragments.test.sh +++ b/scripts/check-changelog-fragments.test.sh @@ -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