README
¶
skillup
Version the segments of a Claude Code plugin marketplace from git history — without depending on tags.
Built on gtb — an opinionated, batteries-included framework for Go CLI tools and services.
Documentation: https://skillup.phpboyscout.uk · Latest release: v0.1.0
Status: released, pre-1.0.
v0.1.0is cut, published to the Go module proxy, and gating merge requests on phpboyscout/claude-code-plugins. The history is stable — install from the module proxy, not a clone.Being pre-1.0, the CLI surface may still change between minor versions. Pin the version your pipeline installs.
The original investigation, what it found and the recommendation are in the spike report on the wiki.
The problem
A plugin marketplace monorepo holds many plugins ("segments"), each with its own
plugin.json carrying its own version, each moving independently.
That version field is the distribution mechanism, not a label. Claude Code
caches at ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ and
resolves no git ref — consumers track the default branch. From the Claude Code
docs: "users only receive updates when you change this field."
So a version that does not move does not delay a change. It withholds it, silently and indefinitely. There is no error and nothing in CI to notice.
This is not hypothetical. On its first run against a real marketplace, skillup found a segment that had shipped nothing since it was created — two commits of new files that had reached nobody.
Why not an existing tool
| Tool | Why not |
|---|---|
| releaser-pleaser | No monorepo support (upstream issue #12, open since 2024) |
| release-please | Has the monorepo model and a JSON updater, but is GitHub-only |
commit-and-tag-version |
Bumps many files to one version, not N independent ones |
| cocogitto | Fits — but derives its baseline from the last git tag |
claude plugin tag |
Tags and validates correctly, but computes no versions |
Cocogitto plus claude plugin tag composes, and was proven to work. It has one
structural flaw:
A tag baseline makes tags load-bearing state. Somebody must cut one promptly
after every merge, forever. Delay it by a single merge and two different states
of main ship under the same version. Run it on a repository with no tags and
it proposes 0.1.0 for everything.
What skillup does differently
It takes each segment's baseline from the git history of that segment's own
plugin.json — the commit at which its version last changed.
Tags stop being input and become decoration. The consequences:
- Correct on a repository that has never been tagged. No seeding, no bootstrap.
- A missed or delayed tag costs visibility, never correctness.
- Tags can be back-filled, because the commit where each version was set is recoverable from history.
Commands
skillup plan --path . # what would change; changes nothing
skillup apply --path . # writes plugin.json only — no commit, no tag, no push
skillup check --path . # CI gate; non-zero when a segment is behind
skillup tag --path . # <segment>--v<version> tags, at the commit that set them
skillup version # which build produced this answer
$ skillup plan --path .
alpha 0.4.0 -> 0.5.0 (minor, 3 commit(s))
bravo 0.22.0 -> 0.22.1 (patch, 1 commit(s))
charlie 1.2.0 -> 2.0.0 (major, 1 commit(s))
The workflow it is built for
apply writes manifests and nothing else — deliberately. The caller commits the
result alongside the change that earned it, so the repository is never in a
state where a segment's content and its version disagree.
In CI that means running on the merge request, not after merge.
Rules it enforces
- Scoping is by changed path, not by the commit's scope label. A commit
messaged
feat(forge):that touches noforgefile bumps nothing. - It never lowers a version. Conventional commits under-describe some
changes — removing a skill breaks a consumer but usually lands as
refactor:— and reaching 1.0 is a promise, not an arithmetic result. Automation raises the floor; it does not overrule an author. - Below 1.0, a breaking change is a minor bump.
- Writes preserve the file byte-for-byte apart from the version. No JSON round-trip: re-serialising escapes non-ASCII and reflows inline arrays, turning a one-character change into an eighteen-line diff.
- Idempotent. It computes from the last committed version, so running twice before committing does not bump twice.
Consistency
check also reports where the catalogue and the segments on disk disagree: a
segment nobody lists (so nobody can install it), a marketplace.json version
contradicting plugin.json — which matters because the catalogue wins for
resolution — and a catalogue version that is not semver at all.
A catalogue entry pointing at a missing directory is not one of these: it
fails the run outright, before the consistency pass. See the
check reference.
In production
skillup gates every merge request on phpboyscout/claude-code-plugins, the marketplace it was written for:
version-check:
stage: version
image: registry.gitlab.com/phpboyscout/images/dev-tools:v0.3.3
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
variables:
GIT_DEPTH: "0" # skillup reads whole history; shallow is silently wrong
before_script:
- go install gitlab.com/phpboyscout/skillup/cmd/skillup@v0.1.0
script:
- skillup check --path .
The job only reads. CI cannot write the bump for you on either forge — a job-token push triggers no pipeline, so the merge request would block forever on a head commit nothing can validate. The GitLab and GitHub how-tos work through it.
Prerequisites
- Go 1.26.5 or newer — required to build and install.
The following tools are only needed for development (they are not required
to install or run the binary). Each is invoked by a just recipe:
just— the task runner used by this repo.golangci-lint— linting (just lint).pre-commit— repo hooks (just check).zensical— docs site (just docs-serve).goreleaser— local snapshot builds (just snapshot).
Install
go install gitlab.com/phpboyscout/skillup/cmd/skillup@v0.1.0
Pin the version rather than tracking @latest. This tool decides whether a
change ships; it should not change underneath a repository without a commit
saying so. Renovate tracks the pin like any other Go dependency.
The main package lives at cmd/skillup/, so the install path ends in
/cmd/skillup — installing the module root would fail because it has no
main package.
Every release also ships pre-built binaries for Linux, macOS and Windows on
amd64 and arm64, plus a checksums.txt, attached to the
GitLab release. Grab one
when there is no Go toolchain to hand.
The full instructions — including building from a clone — are in Install skillup.
Build & run
This repository ships a justfile with the common tasks. Build the
binary with the default recipe:
just # tidy + generate + build → bin/skillup
just build # the same, explicitly
Then run it:
./bin/skillup --help
To install the built binary into $GOPATH/bin:
just install
Develop
Clone & verify
just test # unit tests with coverage
just test-race # tests under the race detector
just lint # golangci-lint
just check # pre-commit hooks across the tree
just ci # the full local CI suite (tidy, generate, test, test-race, lint)
Run just ci before opening a pull/merge request — it mirrors what CI runs.
Project layout
| Path | What lives there |
|---|---|
cmd/skillup/ |
The main entry point (main.go). |
pkg/segment/ |
The whole of the logic — discovery, git history, version arithmetic, manifest writing, tagging, consistency. |
pkg/cmd/{plan,apply,check,tag}/ |
Thin command layers over pkg/segment — flags in, rendering out. |
pkg/cmd/root/ |
The root command — wiring and feature flags. Generated. |
internal/version/ |
Build-time version metadata (injected via ldflags). |
docs/ |
The documentation site source (served by zensical). |
.gtb/manifest.yaml |
The generator manifest (see below). |
pkg/segment is the part worth reading. Each pkg/cmd/<command>/main.go calls
into it and formats the answer; none of them decide anything.
File in pkg/segment |
What it answers |
|---|---|
plan.go |
Which segments exist, and what state each one is in |
git.go |
Where a segment's baseline commit is, and what touched it since |
version.go |
Semver arithmetic and the bump rules |
manifest.go |
Reading and byte-preserving writing of plugin.json |
commit.go |
Committing and pushing, when asked |
consistency.go |
Where the catalogue and the segments on disk disagree |
The manifest & regeneration model
skillup is scaffolded and kept in sync by GTB's generator. The
.gtb/manifest.yaml file is the source of truth for the command tree and
project settings. Two flows operate on it:
gtb generate command <name>— scaffold a new command, recording it in the manifest.gtb regenerate— re-render the project from the manifest and the current GTB templates.
Generated files are hash-tracked in the manifest. If you hand-edit a
generated file, the next regenerate detects the change as a conflict and
prompts before overwriting it — so your edits are never silently lost. This
README is one of those files: it is yours to edit, and regeneration will ask
before replacing it.
Do not hand-edit generated files expecting silent re-generation. Put custom logic in your own packages, or accept the conflict prompt on the next regenerate.
If a generated file is one you deliberately own — a justfile you have
extended, a Dockerfile, a real README.md — mark it hands-off in
.gtb/ignore so regenerate stops re-rendering it and stops prompting:
gtb ignore add justfile # or edit .gtb/ignore by hand
gtb ignore check justfile # confirm it is now ignored, and by which rule
The scaffold ships a commented .gtb/ignore explaining the syntax. See the
Configure Generator Ignore Rules
how-to.
Configuration — there isn't any
skillup takes everything it needs from arguments and the repository in front of it. There is no config file and nothing to initialise.
The framework's commands are therefore marked auxiliary in
pkg/cmd/root/cmd.go, which skips the config bootstrap entirely. One consequence
worth knowing if you extend it: on that path Props.Config is nil, so reach
for props.ViewOrNil(p) rather than p.GetConfigView(), which dereferences it.
Built-ins, deliberately disabled
GTB ships several commands by default. update, init, mcp, docs, doctor
and changelog are all disabled here, taking the surface from thirteen
commands to seven.
skillup reads git history, compares it to manifests and writes a version. It serves no MCP surface, ships no docs site, has nothing to initialise, and self-updates through whatever installed it. For a tool that runs in CI, surface area is something to justify rather than inherit.
version stays — it is not gated, and "which build produced this answer" is a
fair question to ask of a release tool.
Change the feature set with gtb enable <feature> / gtb disable <feature> —
this updates .gtb/manifest.yaml and re-renders the root command, so the change
survives gtb regenerate project. Do not hand-edit pkg/cmd/root/cmd.go;
it is generated and will be overwritten.
Documentation
The site is published at https://skillup.phpboyscout.uk, deployed from
main by the zensical-pages CI job. The source lives in docs/, structured
along Diátaxis lines:
| Your first run | Install it, point it at a marketplace you create, watch it catch a change that would have reached nobody. |
| Install skillup | Module proxy, release binaries, or a clone. |
| Set up the gate in GitLab · in GitHub | Make a merge request fail when a version is behind. |
| Command reference | Every flag, exit code and guarantee. |
| Why not tags? | The one design decision that distinguishes it from the alternatives. |
Serve it locally with:
just docs-serve
Releasing
Releases are driven by Conventional Commits
through the pipeline in .gitlab-ci.yml, assembled from phpboyscout/cicd
components. The chain:
- A merge to
maintriggers releaser-pleaser, which opens (or updates) a Release MR carrying the computed version and the generated changelog. - Merging that MR cuts the
vX.Y.Ztag. - The tag pipeline fans out to GoReleaser (
.goreleaser.yaml— binaries for six OS/arch pairs, attached to the GitLab release) and the zensical-pages docs deploy.
CHANGELOG.md is generated by releaser-pleaser and should not be hand-edited.
Merge requests are the only quality gate — pushing to main runs nothing but
releaser-pleaser, whose own MR gets the full gate before it lands.
See also the GTB release guide: https://gtb.phpboyscout.uk/how-to/custom-release-source/
One-off setup that bites late.
GITLAB_TOKENis a protected group variable, so it reaches a tag pipeline only ifv*is a protected tag. This project's first release failed at goreleaser's first step for exactly that reason, weeks after the project was created and everything looked healthy. The pattern is matched rather than snapshotted, so protectingv*afterwards fixes the existing tag too — retry the job rather than re-cutting.
Contributing
- Follow Conventional Commits for every commit — the changelog and version bumps are computed from them.
- Run
just cibefore opening a pull/merge request. - Do not hand-edit generated files (those tracked in
.gtb/manifest.yaml); the nextgtb regeneratewill flag them as conflicts. If you mean to own a file permanently, add it to.gtb/ignore(or rungtb ignore add <path>).
Licence
MIT — see LICENSE.
Go deeper
GTB documentation:
- Framework docs — https://gtb.phpboyscout.uk/
- Generating commands — https://gtb.phpboyscout.uk/cli/command/
- Regeneration & the manifest — https://gtb.phpboyscout.uk/concepts/regeneration/
- Configuration — https://gtb.phpboyscout.uk/concepts/config/
- Testing — https://gtb.phpboyscout.uk/how-to/testing/
- Repository (source & issues) — https://gitlab.com/phpboyscout/go-tool-base
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
skillup
command
Activates OS keychain support by blank-importing the go-keyring-backed Backend.
|
Activates OS keychain support by blank-importing the go-keyring-backed Backend. |
|
internal
|
|
|
pkg
|
|
|
segment
Package segment computes, writes and tags the versions of the segments in a Claude Code plugin marketplace monorepo.
|
Package segment computes, writes and tags the versions of the segments in a Claude Code plugin marketplace monorepo. |