# Claude Code Plugin Release Automation in GitHub Actions

> A plugin release can install cleanly and ship half a bundle. Here is the GitHub Actions pipeline that gates it: strict manifest validation, the paths and hook scripts nobody checks, a behaviour gate built from the headless CLI, and tagging that survives a failed push.

URL: https://www.matthewswong.com/en/blog/claude-code-plugin-ci-release-automation/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-09-06
- Updated: 2026-09-06
- Category: DevOps

> **Key takeaway:** A Claude Code plugin release pipeline needs three gates: claude plugin validate with --strict, so warnings the loader tolerates fail the build; a headless claude -p run to prove a skill still fires; and claude plugin tag on a clean tree. Users only see a release when the version field in plugin.json changes.

The tag pushed, the marketplace refreshed, and two of the plugin's three skills were simply not there. The install printed no error, and claude plugin list showed the plugin enabled at the new version. I had added a third skill under an extras directory and declared that directory in the manifest, and the commands field replaces the default commands scan instead of adding to it, so the two skills already sitting in commands stopped being loaded the moment the new one arrived.

That is the class of bug plugin CI exists to catch: not a crash, but a bundle that loads and does less than it says. This is the GitHub Actions pipeline I run over a plugin repository now. What claude plugin validate covers, how to turn its report into a gate, where the version bump and claude plugin tag belong in the job graph, and what actually has to change before a user's plugin update offers them anything. Every field name and flag below is checked against the Claude Code plugins reference.

## What the loader requires, and what it merely tolerates

The name field is the only required one in .claude-plugin/plugin.json, and it is the failure you will never ship: a manifest without it is rejected with a validation error naming the field as expected string, received undefined. Everything else is optional, including version, description, the component path fields and dependencies. That optionality is the whole problem, because a field allowed to be absent is also allowed to be wrong in ways nothing has to complain about.

Two rules decide whether a mistake is loud or quiet. Claude Code ignores top-level fields it does not recognise, so a misspelled key is a warning from claude plugin validate and nothing at all at runtime, and a plugin whose only findings are unrecognised fields passes validation and loads. A wrong type is usually louder: for most fields the plugin fails to load outright, though a non-object experimental or metadata value is ignored with a warning instead. So the manifest above is what CI checks, and --strict is what makes the warnings count for something.

## The failures that do not error

Five ways a plugin repository ships a bundle that installs cleanly and does less than its manifest claims. Not one of them fails the install, which is why each needs a step of its own in CI rather than a careful reviewer.

| What you see | Cause | The line responsible |
| --- | --- | --- |
| A skill disappeared when you added another | The commands field replaces the default scan instead of adding to it | commands set to the extras directory alone |
| The plugin loads with no components at all | Components were placed inside the .claude-plugin folder | Only plugin.json belongs in there |
| One component missing, the rest fine | A component path resolves outside the plugin root and is refused | agents pointing at a shared folder one level up |
| The hook registers and never fires | The script is in the bundle without the execute bit | chmod +x was never committed |
| A whole field is ignored | The key is a character off a real field name, so it is only a warning | mcpServer written instead of mcpServers |

The path fields are worth learning by heart, because they do not all behave the same way. commands, agents, workflows and outputStyles replace their default directory; skills adds to it. That asymmetry is the most expensive line in the plugin schema, and it is documented, so getting it wrong costs you a release rather than a bug report.

## The whole pipeline in one workflow file

Three jobs, and the split matters more than the contents. The bundle job is the cheap deterministic half: schema, paths, permissions, version agreement, and it needs no model credentials because nothing in it calls a model. The behaviour job is the half that costs money and can be flaky, so it depends on bundle and never runs once bundle has failed. The release job runs only on a manual dispatch, because cutting a version should be a decision rather than a side effect of merging.

Two details there are ones I got wrong first. The concurrency group exists because two release runs overlapping is how a tag ends up on the wrong commit, and cancel-in-progress stays false because a cancelled release can have pushed the version commit without pushing the tag, which leaves the default branch claiming a version no tag resolves to. And the pinned CLI version is not caution for its own sake: the --json report on validate needs v2.1.259 or later, so a workflow that installs the newest CLI has its parsing contract decided by whatever shipped that morning.

## Make validate a gate, not a report

The validate command exits 0 when validation passes, 1 when it fails, and 2 when the validation run itself fails, for instance on an unreadable path. The third case is the one that bites, because on exit 2 the command writes nothing to stdout and sends its message to stderr, so a step piping the output into jq gets an empty file rather than a false verdict. Set pipefail before the pipe and the exit code survives it; without pipefail the step reads the exit status of tee, which is always zero.

With --json the report arrives as one object: success repeating the exit code, strict saying whether warnings were treated as errors, target naming the resolved path, manifest holding the manifest's own result, and contents carrying per-file errors, warnings and notes. I keep that file as a build artefact rather than parsing past success, because per-file findings are useful when a run fails and a liability when a workflow depends on their shape.

> The --strict flag is not optional in CI. Without it, a manifest whose only findings are unrecognised fields passes validation and loads, so mcpServer written for mcpServers ships as a warning nobody reads. Validate flags a key a character or two off a real name with a suggestion, which is exactly the class of typo --strict turns into exit code 1.

*Each gate proves something narrower than the one above it, and the bottom band is the one to argue with: nothing in the plugin CLI checks whether the agent behaves, so that gate is either written by you or missing.*

## The behaviour gate you have to write yourself

The documented plugin CLI stops short of behaviour. Its subcommands are init, install, uninstall, prune, enable, disable, update, list, details, validate and tag, and validate is the only checker among them: it checks syntax and schema. Nothing in that list runs the plugin and judges the result, so a gate on whether the skill still fires is something you build out of the headless CLI and jq.

- A machine-readable verdict rather than prose. Pairing --output-format json with --json-schema puts the answer in structured_output, which jq can test; the result field is free text and drifts on wording alone.
- A bounded cost. The --max-turns flag caps the run, and total_cost_usd in the JSON response tells you when the gate itself has got expensive. It is a client-side estimate, but it is the figure you see before the invoice.
- A restricted tool set. Naming only Read and Grep in --allowedTools stops the run from passing by doing the work by hand instead of through the skill under test.

Be honest about what this gate is. It samples one prompt against one model, so a pass is evidence and not proof, and a single red run is as likely to be variance as regression. I treat one failure as a re-run and two in a row as a block, which is a threshold I picked rather than measured. The other trap is mechanical: --max-turns exits with an error when the limit is reached, so a run that merely took a longer route fails in exactly the same way as a run that did the wrong thing. Read the log before you believe the verdict.

## Bump, commit, then tag, in that order

The claude plugin tag command derives the tag name from the manifest and the enclosing marketplace entry, and before it creates anything it validates the plugin contents, checks that plugin.json and the marketplace entry agree on the version, requires a clean working tree under the plugin directory, and refuses if the tag already exists. Each of those four preconditions dictates a line in the release job.

1. Bump the version in .claude-plugin/plugin.json and in the marketplace entry in the same commit, because the tag command compares the two and refuses a mismatch.
2. Commit before tagging. The clean-tree requirement applies to the plugin directory, so an uncommitted bump is enough on its own to stop the release.
3. Run the tag command with --dry-run on pull requests. It validates and checks the version agreement without creating anything, which turns the whole precondition list into a pull-request check.
4. Tag with --push on the release run only. The tag format is the plugin name, two dashes, a v and the version, parsed as a prefix match on the full name so hyphenated plugin names still resolve correctly.

The failure worth planning for is the push. If pushing the tag fails, the tag still exists locally and the command exits with an error, so on a hosted runner you lose the tag along with the workspace, but on a self-hosted runner a blind retry hits the tag-already-exists refusal and reports a broken release when the only thing that broke was the network. Delete the local tag before retrying, or pass --force knowingly. A force-moved tag does get a fresh cache directory on the next install, because the cache name carries a twelve-character commit SHA, which limits the damage without excusing it.

## Publishing so plugin update finds the version

The version is the cache key. Claude Code computes the plugin's current version and skips the update when it matches what is installed, so with an explicit version in plugin.json a user gets a release only when that string changes. Push as many commits as you like and plugin update reports that they are already at the latest version. That is the property you want from a published plugin, and the property that makes a forgotten bump look exactly like a broken pipeline.

The alternative is deliberate. Omit version from both plugin.json and the marketplace entry and the version resolves from the source's commit SHA, so users update whenever the resolved commit changes. That suits an internal plugin under active development and is wrong for a published one, because every merge becomes a release. Marketplaces also do not poll: claude plugin marketplace update refreshes them to pick up new plugins and version changes, and a marketplace added with a pinned branch or tag updates to the latest commit of that ref rather than the default branch.

> The tag prefix is what lets one marketplace repository hold several plugins with independent version lines: the tag is the plugin name, then two dashes, then v and the semver. Because the separator is parsed as a prefix match on the full plugin name, a plugin whose own name contains hyphens still resolves to its own tags and not a sibling's.

*The panels run from the easiest failure to notice to the hardest, so the last one is where a CI step earns its keep: a silent no-op passes validation without --strict and looks identical to a working release.*

## Why plugin CI proves less than library CI

A library's tests execute the artefact. A plugin's artefact is instructions, meaning skill prose, agent descriptions and hook wiring, and the pipeline above executes almost none of it. What a green run proves is that the bundle parses, the declared paths exist, the components register, the hook scripts are runnable and the version is releasable. What it does not prove is that the agent reads the skill the way you meant, which is the only property a user notices.

Three things I do about that gap, none of which closes it. The behaviour gate is treated as a smoke test with a threshold, not a proof. The always-on surface is kept small, because claude plugin details prints the tokens a plugin adds to every session whether or not a component fires, and a smaller always-on surface leaves a regression fewer places to hide. And the release notes are written as a behaviour diff rather than a file diff, because that is the only artefact a user can check against what they observe. I still ship wording regressions no gate here catches; the gate that would catch them is a person reading a transcript.

The rule I would carry to any plugin repository: gate on what the loader can be made to answer, and say out loud that the rest is unautomated. Schema, declared paths, component registration and version agreement are all decidable, and --strict plus pipefail plus a clean tree turns them into exit codes. Behaviour is not decidable in a workflow, so give it a sampled gate and an honest release note rather than a green tick that means something else.

## Sources

- Claude Code plugins reference: manifest schema, path behaviour rules, plugin validate and tag, and the documented load errors
- Constrain plugin dependency versions: the release tag convention and what claude plugin tag checks before it tags
- Plugin marketplaces: the marketplace entry schema and refreshing a marketplace with plugin marketplace update
- Claude Code headless mode: bare mode, structured output with a JSON schema, and the response fields
- Claude Code CLI reference: the plugin-dir, max-turns and allowedTools flags and the print-mode output formats

## Source links

- https://code.claude.com/docs/en/plugins-reference
- https://code.claude.com/docs/en/plugin-dependencies
- https://code.claude.com/docs/en/plugin-marketplaces
- https://code.claude.com/docs/en/headless
- https://code.claude.com/docs/en/cli-reference

## FAQ

### How do I validate a Claude Code plugin in CI?

Install the CLI and run claude plugin validate on the plugin directory with --strict, which treats warnings as errors and exits 1 on them. It exits 0 on pass, 1 on failure and 2 when the validation run itself fails, such as on an unreadable path. Add --json if you want the report as one object with success, strict, target, manifest and per-file contents.

### Why did my plugin install without errors but a skill is missing?

The commands, agents, workflows and outputStyles fields replace their default directory rather than adding to it, so declaring one custom path stops the default directory being scanned. Only the skills field adds to its default. A path that resolves outside the plugin root is also refused with a path escapes plugin directory error, and the plugin still loads without that component.

### Can GitHub Actions bump and tag a plugin release automatically?

Yes, but the order matters. claude plugin tag validates the plugin, checks that plugin.json and the marketplace entry agree on the version, requires a clean working tree under the plugin directory, and refuses when the tag already exists. So the job must bump both files, commit, and only then tag with --push.

### Why does plugin update say I am already at the latest version?

Claude Code uses the plugin version as the cache key for updates. When plugin.json sets an explicit version, users receive a release only when that string changes, so pushing commits without bumping the field has no effect. Omitting version from both plugin.json and the marketplace entry switches resolution to the source commit SHA instead.

### Can CI test whether a plugin's skill actually behaves correctly?

Only partially. The documented plugin CLI checks syntax and schema, not behaviour, so a behaviour gate has to be built from a headless claude -p run with --plugin-dir, --json-schema and jq -e on the structured_output field. Treat it as a sampled smoke test with a failure threshold, because one prompt against one model is evidence rather than proof.

---

- HTML version: https://www.matthewswong.com/en/blog/claude-code-plugin-ci-release-automation/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/claude-code-plugin-ci-release-automation/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
