From 1f8d4916cb83b02a9b886da3a32e9a2eb1de80f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:51:59 +0100 Subject: [PATCH 1/2] ci(release): fail a release whose plugin version does not match its tag Claude Code caches a plugin by the version in plugin.json, so a release that forgets to bump it leaves installed plugins on old content. The new Release workflow checks the version when a draft is saved or a release is published. On a mismatch it opens a PR that sets the version through the contents API, so GitHub signs the commit, links that PR in the job summary, and fails. When Actions may not create PRs, it links a compare page that opens the PR in one click. make plugin-version VERSION= sets the version for the PR made before tagging, and the AI instructions add this step to the release rules. Co-Authored-By: Claude Opus 5.5 --- .ai/base.md | 5 +- .github/copilot-instructions.md | 5 +- .github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++ Makefile | 10 ++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.ai/base.md b/.ai/base.md index 0828f89c..7b1b217f 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -125,13 +125,16 @@ When updating documentation in markdown files, make sure to run `make fmt-md` fo ### Claude Code plugin The repository ships a Claude Code plugin for framework consumers in `plugin/` (marketplace manifest at -`.claude-plugin/marketplace.json`). Two rules keep it accurate: +`.claude-plugin/marketplace.json`). Three rules keep it accurate: - Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the source under `docs/` and run `make sync-plugin`. The sync rewrites each relative link for the plugin layout and fails when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. +- Claude Code caches a plugin by the `version` in `plugin/.claude-plugin/plugin.json`, so an installed plugin updates + only when that version changes. Before you tag a release, merge a PR that runs `make plugin-version VERSION=`. + The Release workflow fails on a release whose plugin version does not match its tag, and opens that PR for you. ### Examples diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b672af30..714c58b5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,13 +125,16 @@ When updating documentation in markdown files, make sure to run `make fmt-md` fo ### Claude Code plugin The repository ships a Claude Code plugin for framework consumers in `plugin/` (marketplace manifest at -`.claude-plugin/marketplace.json`). Two rules keep it accurate: +`.claude-plugin/marketplace.json`). Three rules keep it accurate: - Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the source under `docs/` and run `make sync-plugin`. The sync rewrites each relative link for the plugin layout and fails when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. +- Claude Code caches a plugin by the `version` in `plugin/.claude-plugin/plugin.json`, so an installed plugin updates + only when that version changes. Before you tag a release, merge a PR that runs `make plugin-version VERSION=`. + The Release workflow fails on a release whose plugin version does not match its tag, and opens that PR for you. ### Examples diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0e908c55 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release + +on: + release: + types: [created, published] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-plugin-version-${{ github.event.release.tag_name }} + +jobs: + plugin-version: + name: Plugin version matches the release + # A release created as published fires both events; check it once, on publish. + if: github.event.action == 'published' || github.event.release.draft + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + MANIFEST: plugin/.claude-plugin/plugin.json + steps: + - name: Clone the release commit + uses: actions/checkout@v7 + with: + # A draft has no tag yet, so check the commit it will be tagged on. + ref: ${{ github.event.release.draft && github.event.release.target_commitish || github.event.release.tag_name }} + + - name: Compare the plugin version with the tag + id: compare + run: | + want="${TAG#v}" + have="$(jq -r .version "$MANIFEST")" + echo "want=$want" >> "$GITHUB_OUTPUT" + echo "have=$have" >> "$GITHUB_OUTPUT" + if [ "$have" = "$want" ]; then + echo "The plugin version $have matches $TAG." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Open a pull request that sets the plugin version + if: steps.compare.outputs.have != steps.compare.outputs.want + env: + WANT: ${{ steps.compare.outputs.want }} + HAVE: ${{ steps.compare.outputs.have }} + DRAFT: ${{ github.event.release.draft }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + repo="$GITHUB_REPOSITORY" + branch="chore/plugin-version-$WANT" + title="chore(plugin): set the plugin version to $WANT" + + link="" + main_version="$(gh api "repos/$repo/contents/$MANIFEST?ref=$DEFAULT_BRANCH" --jq .content | base64 -d | jq -r .version)" + if [ "$main_version" != "$WANT" ]; then + if ! gh api "repos/$repo/git/ref/heads/$branch" > /dev/null 2>&1; then + base_sha="$(gh api "repos/$repo/git/ref/heads/$DEFAULT_BRANCH" --jq .object.sha)" + gh api "repos/$repo/git/refs" -f ref="refs/heads/$branch" -f sha="$base_sha" > /dev/null + file="$(gh api "repos/$repo/contents/$MANIFEST?ref=$branch")" + content="$(jq -r .content <<< "$file" | base64 -d | sed -E 's/("version": *")[^"]*"/\1'"$WANT"'"/')" + # The contents API makes a commit that GitHub signs. The ruleset on the + # default branch rejects unsigned commits, which a git push from here makes. + gh api -X PUT "repos/$repo/contents/$MANIFEST" \ + -f message="$title" \ + -f branch="$branch" \ + -f sha="$(jq -r .sha <<< "$file")" \ + -f content="$(base64 -w0 <<< "$content")" > /dev/null + fi + + link="$(gh pr list --repo "$repo" --head "$branch" --state open --json url --jq '.[0].url // empty')" + if [ -z "$link" ]; then + body="Claude Code caches a plugin by the \`version\` in its \`plugin.json\`. Release $TAG has plugin version \`$HAVE\`, so installed plugins do not update. This PR sets the version to \`$WANT\`." + # Creating the pull request fails when the repository does not let + # GitHub Actions create pull requests. The compare page opens one in a click. + if ! link="$(gh pr create --repo "$repo" --base "$DEFAULT_BRANCH" --head "$branch" --title "$title" --body "$body")"; then + link="https://github.com/$repo/compare/$DEFAULT_BRANCH...$branch?expand=1" + fi + fi + fi + + if [ "$DRAFT" = "true" ] && [ -n "$link" ]; then + fix="Merge $link, then publish the draft from a commit that includes the change." + elif [ "$DRAFT" = "true" ]; then + fix="$DEFAULT_BRANCH already sets $WANT. Point the draft at a commit that includes it, then publish it." + elif [ -n "$link" ]; then + fix="$TAG points at a commit with plugin version $HAVE. Merge $link, then publish a patch release so installed plugins update." + else + fix="$TAG points at a commit with plugin version $HAVE. $DEFAULT_BRANCH already sets $WANT, so publish a patch release from it." + fi + + { + echo "### Plugin version does not match $TAG" + echo + echo "\`$MANIFEST\` sets \`$HAVE\`, and the release needs \`$WANT\`. Claude Code caches a plugin by this version, so installed plugins do not get the release." + echo + echo "$fix" + } >> "$GITHUB_STEP_SUMMARY" + echo "::error file=$MANIFEST::Plugin version $HAVE does not match $TAG. $fix" + exit 1 diff --git a/Makefile b/Makefile index 39339c38..d8e582b4 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,16 @@ sync-plugin: prettier ## Sync framework docs into the Claude plugin skill refere go run ./internal/pluginsync/syncplugin $(PRETTIER) --write --log-level warn '$(PLUGIN_SKILLS)/*/references/**/*.md' +PLUGIN_MANIFEST := plugin/.claude-plugin/plugin.json + +.PHONY: plugin-version +plugin-version: ## Set the Claude plugin version to the release tag VERSION (for example VERSION=v0.24.0). + @echo "$(VERSION)" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$$' || { \ + echo "VERSION must be a release tag such as v0.24.0, got '$(VERSION)'" >&2; exit 1; } + sed -i.bak -E 's/("version": *")[^"]*"/\1$(patsubst v%,%,$(VERSION))"/' $(PLUGIN_MANIFEST) + @rm -f $(PLUGIN_MANIFEST).bak + @grep '"version"' $(PLUGIN_MANIFEST) + .PHONY: prettier prettier: $(PRETTIER) ## Download prettier locally if necessary. $(PRETTIER): $(LOCALBIN) From b2b60b88392b7b5dd85ce175c5e43008356b7ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:01:09 +0100 Subject: [PATCH 2/2] fix(plugin): let each pinned framework tag install its own plugin Claude Code caches a plugin by the version in plugin.json, and consumers pin the marketplace ref to a framework tag. A version that a release forgets to change keeps pinned users on old skills, and a version bumped after the tag never reaches the tag at all. Without a version, Claude Code caches by commit, so each tag installs exactly the plugin it shipped. This replaces the release workflow and make target, which could only check the version after the tag existed, because GitHub runs no workflow for a draft release. Co-Authored-By: Claude Opus 5.5 --- .ai/base.md | 6 +- .github/copilot-instructions.md | 6 +- .github/workflows/release.yml | 100 ------------------------------ Makefile | 10 --- plugin/.claude-plugin/plugin.json | 1 - 5 files changed, 6 insertions(+), 117 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.ai/base.md b/.ai/base.md index 7b1b217f..d73bf4ec 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -132,9 +132,9 @@ The repository ships a Claude Code plugin for framework consumers in `plugin/` ( when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. -- Claude Code caches a plugin by the `version` in `plugin/.claude-plugin/plugin.json`, so an installed plugin updates - only when that version changes. Before you tag a release, merge a PR that runs `make plugin-version VERSION=`. - The Release workflow fails on a release whose plugin version does not match its tag, and opens that PR for you. +- Do not add a `version` to `plugin/.claude-plugin/plugin.json` or to the marketplace entry. Claude Code caches a plugin + by that version, and without one it uses the commit. Consumers pin the marketplace `ref` to a framework tag, so the + commit makes each tag install its own plugin. A version that a release forgets to change keeps them on old skills. ### Examples diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 714c58b5..e64018fa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -132,9 +132,9 @@ The repository ships a Claude Code plugin for framework consumers in `plugin/` ( when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. -- Claude Code caches a plugin by the `version` in `plugin/.claude-plugin/plugin.json`, so an installed plugin updates - only when that version changes. Before you tag a release, merge a PR that runs `make plugin-version VERSION=`. - The Release workflow fails on a release whose plugin version does not match its tag, and opens that PR for you. +- Do not add a `version` to `plugin/.claude-plugin/plugin.json` or to the marketplace entry. Claude Code caches a plugin + by that version, and without one it uses the commit. Consumers pin the marketplace `ref` to a framework tag, so the + commit makes each tag install its own plugin. A version that a release forgets to change keeps them on old skills. ### Examples diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0e908c55..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Release - -on: - release: - types: [created, published] - -permissions: - contents: write - pull-requests: write - -concurrency: - group: release-plugin-version-${{ github.event.release.tag_name }} - -jobs: - plugin-version: - name: Plugin version matches the release - # A release created as published fires both events; check it once, on publish. - if: github.event.action == 'published' || github.event.release.draft - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - MANIFEST: plugin/.claude-plugin/plugin.json - steps: - - name: Clone the release commit - uses: actions/checkout@v7 - with: - # A draft has no tag yet, so check the commit it will be tagged on. - ref: ${{ github.event.release.draft && github.event.release.target_commitish || github.event.release.tag_name }} - - - name: Compare the plugin version with the tag - id: compare - run: | - want="${TAG#v}" - have="$(jq -r .version "$MANIFEST")" - echo "want=$want" >> "$GITHUB_OUTPUT" - echo "have=$have" >> "$GITHUB_OUTPUT" - if [ "$have" = "$want" ]; then - echo "The plugin version $have matches $TAG." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Open a pull request that sets the plugin version - if: steps.compare.outputs.have != steps.compare.outputs.want - env: - WANT: ${{ steps.compare.outputs.want }} - HAVE: ${{ steps.compare.outputs.have }} - DRAFT: ${{ github.event.release.draft }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - repo="$GITHUB_REPOSITORY" - branch="chore/plugin-version-$WANT" - title="chore(plugin): set the plugin version to $WANT" - - link="" - main_version="$(gh api "repos/$repo/contents/$MANIFEST?ref=$DEFAULT_BRANCH" --jq .content | base64 -d | jq -r .version)" - if [ "$main_version" != "$WANT" ]; then - if ! gh api "repos/$repo/git/ref/heads/$branch" > /dev/null 2>&1; then - base_sha="$(gh api "repos/$repo/git/ref/heads/$DEFAULT_BRANCH" --jq .object.sha)" - gh api "repos/$repo/git/refs" -f ref="refs/heads/$branch" -f sha="$base_sha" > /dev/null - file="$(gh api "repos/$repo/contents/$MANIFEST?ref=$branch")" - content="$(jq -r .content <<< "$file" | base64 -d | sed -E 's/("version": *")[^"]*"/\1'"$WANT"'"/')" - # The contents API makes a commit that GitHub signs. The ruleset on the - # default branch rejects unsigned commits, which a git push from here makes. - gh api -X PUT "repos/$repo/contents/$MANIFEST" \ - -f message="$title" \ - -f branch="$branch" \ - -f sha="$(jq -r .sha <<< "$file")" \ - -f content="$(base64 -w0 <<< "$content")" > /dev/null - fi - - link="$(gh pr list --repo "$repo" --head "$branch" --state open --json url --jq '.[0].url // empty')" - if [ -z "$link" ]; then - body="Claude Code caches a plugin by the \`version\` in its \`plugin.json\`. Release $TAG has plugin version \`$HAVE\`, so installed plugins do not update. This PR sets the version to \`$WANT\`." - # Creating the pull request fails when the repository does not let - # GitHub Actions create pull requests. The compare page opens one in a click. - if ! link="$(gh pr create --repo "$repo" --base "$DEFAULT_BRANCH" --head "$branch" --title "$title" --body "$body")"; then - link="https://github.com/$repo/compare/$DEFAULT_BRANCH...$branch?expand=1" - fi - fi - fi - - if [ "$DRAFT" = "true" ] && [ -n "$link" ]; then - fix="Merge $link, then publish the draft from a commit that includes the change." - elif [ "$DRAFT" = "true" ]; then - fix="$DEFAULT_BRANCH already sets $WANT. Point the draft at a commit that includes it, then publish it." - elif [ -n "$link" ]; then - fix="$TAG points at a commit with plugin version $HAVE. Merge $link, then publish a patch release so installed plugins update." - else - fix="$TAG points at a commit with plugin version $HAVE. $DEFAULT_BRANCH already sets $WANT, so publish a patch release from it." - fi - - { - echo "### Plugin version does not match $TAG" - echo - echo "\`$MANIFEST\` sets \`$HAVE\`, and the release needs \`$WANT\`. Claude Code caches a plugin by this version, so installed plugins do not get the release." - echo - echo "$fix" - } >> "$GITHUB_STEP_SUMMARY" - echo "::error file=$MANIFEST::Plugin version $HAVE does not match $TAG. $fix" - exit 1 diff --git a/Makefile b/Makefile index d8e582b4..39339c38 100644 --- a/Makefile +++ b/Makefile @@ -100,16 +100,6 @@ sync-plugin: prettier ## Sync framework docs into the Claude plugin skill refere go run ./internal/pluginsync/syncplugin $(PRETTIER) --write --log-level warn '$(PLUGIN_SKILLS)/*/references/**/*.md' -PLUGIN_MANIFEST := plugin/.claude-plugin/plugin.json - -.PHONY: plugin-version -plugin-version: ## Set the Claude plugin version to the release tag VERSION (for example VERSION=v0.24.0). - @echo "$(VERSION)" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$$' || { \ - echo "VERSION must be a release tag such as v0.24.0, got '$(VERSION)'" >&2; exit 1; } - sed -i.bak -E 's/("version": *")[^"]*"/\1$(patsubst v%,%,$(VERSION))"/' $(PLUGIN_MANIFEST) - @rm -f $(PLUGIN_MANIFEST).bak - @grep '"version"' $(PLUGIN_MANIFEST) - .PHONY: prettier prettier: $(PRETTIER) ## Download prettier locally if necessary. $(PRETTIER): $(LOCALBIN) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index bcadef71..42a0c8fb 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,6 @@ { "name": "ocf", "displayName": "Operator Component Framework", - "version": "0.23.0", "description": "Skills, scaffolding commands, and a guidelines reviewer for building Kubernetes operators with the operator-component-framework.", "author": { "name": "sourcehawk",