What is a git tag for, if nobody can install the tag?
I mean that as a real question. A Claude Code plugin marketplace has no registry... there's no publish step, no proxy, and no immutable artefact sitting somewhere with a checksum. The version lives in a .claude-plugin/plugin.json inside a git repository, and everyone installs from main.
The tag is decoration, and worse than that it can be wrong
I went looking for a tool to do this and couldn't find one, which surprised me more than it probably should have.
The nearest fit was cocogitto, and it's a perfectly good tool doing a job it wasn't really shaped for. Even in its monorepo mode (I looked at 7.0.0) it wants a tag per package and owns the changelogs, and I had one repository holding eight independently-versioned plugins, eight manifests with a version inside each, and no tag anyone could install.
So the versions were internal to the plugins, the tags were vanity, and nothing was keeping the two in agreement. That's fine for a while... until the vanity becomes inaccurate, and then you have a number people are trusting for no good reason.
The gap a release MR leans on
The release-merge-request pattern is a good one and I've written about it working well. Features land on main. A bot keeps a release MR open with the version bump and the changelog in it. When you want to cut a release you merge that MR, and the tag and the artefact go out together.
It works because there's a gap. Between a feature merging and the release MR merging, main runs ahead of the last published version, and that's pretty much harmless, since hardly anyone consumes main. They consume the registry, and the registry still has the old version on it, correctly labelled.
Take the registry away and that gap becomes a window with people standing in it. Anyone installing while three features have merged and the release MR has not gets those three features, in code that announces itself as the version before them. I'd call that wrong rather than stale, since it'll tell you, in its own manifest, something about itself that isn't true.
When main is the distribution channel, the gap has nowhere to hide. So the version has to move in the same commit as the change, or it lies.
Three ways to make that happen, and two of them can't work
Once you accept "same commit", it stops being a policy question. Something has to work the version out and get it into the commit, and there are only really three candidates.
The obvious one is a pre-commit hook. I disliked the look of it straight away and couldn't say why (usually a sign I've noticed something I haven't put into words yet). The bump is derived from the conventional-commit type, and at pre-commit time the commit doesn't exist. The hook can't tell whether it's looking at a fix or a feat, since the message hasn't been written yet.
Right, so let CI write it. This one fails for a much better reason, one I was slow to find. A push made with a job token creates no pipeline. That's true on GitLab, it's equally true of GitHub's GITHUB_TOKEN, and both do it for a good reason: otherwise CI would trigger itself round and round forever.
So a bump written by CI lands as a new head commit that no pipeline will validate, and the merge request then sits there blocking on its own must-pass rule, waiting patiently for a run that can never be scheduled (two perfectly reasonable features, and between them they'd built me a deadlock).
That leaves the boring option: the job only reads. It works out what the version should be, holds it against what the manifests say, and fails with the thing that's wrong and the command that fixes it. You run skillup apply, commit the result, and the pipeline that checks that commit is an ordinary one off an ordinary push.
I wanted one of the first two, obviously. They don't interrupt anyone. The option that actually survives contact makes a person stop and type a command, and I've mostly stopped resenting it.
What it cost in places that aren't code
A gate's only a gate if you can't walk round it, so this reached out into the repository settings: branch protection on main, merge requests mandatory, and fork contributions unsupported (for now), because a fork pipeline can't be trusted to have run the check.
That last one's a real cost. It's a public marketplace that a stranger can't currently contribute to without me changing something first.
That's the trade I made to stop the version numbers lying. It may not survive the first person who wants to send me a plugin.
Two details that cost an afternoon each
No shelling out. skillup talks to git through the estate's own repository library rather than os/exec. The temptation to shell out is strongest on small tools, which is when the reasoning gets skipped: this one isn't important enough to earn an exception to a security rule.
Clone depth. The gate sets GIT_DEPTH: "0", and the comment above it is the useful part. skillup reads each manifest's whole history to work out what's changed since its last bump, and it needs no tags at all to do that. Under the default shallow clone it doesn't error.
It gives you a confidently wrong answer instead. In a job whose one purpose is deciding whether a version number is telling the truth!
Somebody else will hit this next
What I keep coming back to is that I had to build it at all.
Plugin marketplaces, agent skill libraries, dotfiles repos, anything installed by pointing a tool at a git branch: I suspect they all have this problem in one form or another. Most of release engineering assumes a registry sitting between you and your consumers, and take it away and the tooling has very little to say. Eighteen hundred lines of Go, for a job that a registry would have done for free.
And skillup's own releases, incidentally, go out through a release merge request. Tagged, versioned, published, the pattern working as it should, because skillup has somewhere to be published to. The eight plugins it looks after don't. That's the difference between the two, and it was the better part of two weeks before I saw it clearly enough to build for it.
Originally published at phpboyscout.uk on 4 November 2026.
