About releasekit
releasekit is a release-preparation skill for repositories using Conventional Commits. It derives a semantic version and changelog, then creates a tag and GitHub release after preview and confirmation; it does not publish packages to registries.
Key features
- 01Derives the next semantic version
- 02Generates Keep a Changelog entries
- 03Previews changes and requires confirmation
- 04Creates a version tag and GitHub release
Use cases
- 01Prepare a release from merged commits
- 02Review the next version and changelog
- 03Create a tag that triggers a CI publishing workflow
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ mimukit/ skills --skill releasekitAfter installing, type/releasekitin Claude Code to confirm it loaded.
Details
From the project READMEreleasekit
A repo that writes Conventional Commits has already answered the two questions a release asks: what changed, and how much did it break. releasekit reads that answer back out. It resolves the commit range since the last release, derives the next semver version and a Keep a Changelog section from the commits in it, and cuts the release: a CHANGELOG.md entry, a version bump in the ecosystem's manifest, an annotated tag, and a GitHub release.
Everything it does is previewed before anything mutates, because a version number is spent permanently. A tag that ships broken code cannot be reused, only superseded.
releasekit stops at the tag. It does not run npm publish, twine upload, or cargo publish. The tag it creates is precisely the trigger a publish workflow already listens for, and credentials plus irreversibility are two good reasons to leave that where it is.
When this fires
The user wants to turn merged work into a released version: "cut a release", "release this", "tag a version", "what would the next version be", "generate the changelog", "ship v2", "/releasekit".
Three boundaries matter:
- It does not write the commits it reads. Authoring Conventional Commits belongs to the commit step; releasekit only ever parses what is already in the log.
- It is not a publisher. The registry upload is CI's job, triggered by the tag.
- It is interactive only. The confirmation below is a real gate with no exemption, so releasekit never runs inside an unattended pipeline. A run with nobody to answer stops at the preview rather than assuming a yes.
Procedure
1. Preflight
Every check runs before anything mutates, and each failure names itself rather than falling through to a later one. Preflight is done when every check below has passed or has stopped the run with its named refusal.
Environment. Confirm git, a remote, and a usable gh. Without gh, releasekit still writes the changelog and cuts the tag, so say once that the GitHub release and the CI check are both skipped, and why. Do not fail wholesale for a missing enrichment.
Base ref. Never assume main; repos default to develop, trunk, and master in the wild. When gitkit is installed, ask it, because it owns this answer. Otherwise resolve it directly:
gh repo view --json defaultBranchRef --jq .defaultBranchRef.name
falling back to git symbolic-ref --short refs/remotes/origin/HEAD, and asking the user rather than guessing past that.
Repo state. Refuse, and say which one failed, when:
- the working tree is dirty, because a release must describe committed code;
- HEAD is not the base ref, because releasing a feature branch tags code nobody merged;
- the branch is behind its upstream, because the changelog would omit commits that are already public.
Competing release tooling. Look for .changeset/, .releaserc*, release-please-config.json, or a semantic-release dependency. Any of them owns versioning end to end, and a second writer corrupts its state. Stop before mutating anything, name the tool, and name its command instead. This is a refusal, not a warning.
Workspaces. Look for workspaces in package.json, a pnpm-workspace.yaml, or [workspace] in Cargo.toml. A marker alone is not a monorepo, because plenty of repos have a packages/ directory and one published artifact. Refuse only when a marker is present and more than one manifest carries a version, and list every versioned manifest you found. A workspace where only the root is versioned has one version and releases normally.
Branch protection. Probe the base branch once:
gh api "repos/{owner}/{repo}/branches/$BASE/protection"
A 404 means unprotected and selects the direct path. Any other answer means protected and selects the release-PR path. Getting this wrong is not cosmetic: the direct path pushes a commit straight to the base, and a protected repo rejects it after the changelog has already been written.
Phase. Work out which phase this run is from the repo, never from state you keep. Check in this order, and take the first that matches:
- Resume. An earlier run stopped part way. The evidence is the newest
chore(release): vX.Y.Zcommit on HEAD's line, plus one of these: tagvX.Y.Zexists only locally (git ls-remote --tags origin vX.Y.Zis empty), the tag is on the remote butgh release view vX.Y.Zfinds no release, or, on the direct path, the commit is not on the upstream and has no tag. Resume that version and never derive a new one, because a second derivation spends a second number on the same code. Pick up at the first step that did not land, and take the release body from that version's section inCHANGELOG.md. - Finish, release-PR path only. A merged
chore(release): vX.Y.Zcommit on the base with no tag pointing at it. - Prepare on the release-PR path, or a fresh release on the direct path. Anything else.
Resume and finish both read the version from the commit subject and skip Derive the version and Render the changelog. The check is done when the preview can name one phase and, for resume, the first step still to run.
2. Derive the version
Resolve the last release. Take the highest stable semver tag merged into HEAD, not the highest version in the repo:
tags=$(git tag --merged HEAD \
| grep -E '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
| awk -F. '{ m = $1; sub(/^v/, "", m); if (m + 0 < 1000) print }')
nv=$(printf '%s\n' "$tags" | grep -c '^v')
nb=$(printf '%s\n' "$tags" | grep -c '^[0-9]')
if [ "$nb" -gt "$nv" ]; then style='^[0-9]'; else style='^v'; fi
last=$(printf '%s\n' "$tags" | grep "$style" | sort -V | tail -n 1)
An empty $last means no previous release. Each filter has one job:
--merged HEADkeeps the lineage. A repo runningv1.xalongsidev2.xreads its own line, because av2tag is not merged into thev1maintenance branch.- The anchored regex accepts exactly
MAJOR.MINOR.PATCHwith an optionalvand no leading zeros. It rejects a prerelease (v1.3.0-rc.1), a build or backup suffix (v1.4.0.bak), a zero-padded calver date (2026.08.01), and a name such asnightly-2026-08-01. Agit describe --matchglob cannot do this, because a trailing*accepts any suffix. - The
awkfilter drops a tag whose major is 1000 or more. That shape is a calver date such as2026.8.1, which the regex alone accepts. List every dropped tag in the preview so a real semver major that high is visible. - The prefix style follows the majority of the surviving tags, and a tie goes to
v. A repo that tagsv1.2.3ignores a stray bare2.0.0, and a bare-tagged repo ignores a strayvtag. List the tags of the other style in the preview as skipped. sort -Vorders by version, sov1.10.0sorts afterv1.9.0. On a host whosesortlacks-V, sort on the three numeric fields instead.
Prereleases are skipped here, so the commits a v1.3.0-rc.1 shipped still appear in the v1.3.0 changelog. Cut them at the resolver, so nothing downstream has to know prereleases exist.
Read the range <lasttag>..HEAD, or the whole history when no tag exists. Parse each commit's subject and body, because a BREAKING CHANGE: footer lives in the body, so bodies cannot be skipped.
Bump from what the range contains, taking the highest that applies:
| in the range | bump on 1.x and above | bump on 0.x |
|---|---|---|
a ! marker or a BREAKING CHANGE: footer | major | minor |
any feat | minor | patch |
any fix, hotfix, perf, or revert | patch | patch |
only docs, chore, style, test, build, ci, refactor | none | none |
The 0.x column is not a typo, and it is the rule most likely to surprise someone. Bumping major on a pre-1.0 repo takes 0.3.0 to 1.0.0, which declares the API stable off the back of a single commit footer. This follows the release-please and Cargo convention instead. State the rule and its arithmetic in words in the preview, not just the resulting number.
A first release asks. With no tag in the repo, prompt once with 0.1.0 prefilled. This happens exactly once per repo and cannot be corrected afterwards, and 1.0.0 is a public declaration of API stability that must never be guessed on the user's behalf. Skip the prompt when a manifest already carries a version, because that version is the user's answer.
The tag prefix is inherited, not chosen. v1.2.0 gives v, 1.2.0 gives none. Default to v on a first release.
Two refusals come out of this step, both with a named override so the refusal is discoverable rather than a dead end:
- Nothing user-visible. The range holds no commit that forces a bump. Say so concretely ("nothing user-visible since v1.2.0") and name
--allow-emptyfor a user who has a reason. A patch bump for every CI tweak makes the version meaningless; a prompt every time trains people to dismiss it. - Nothing parses. No commit in the range is a Conventional Commit, so any bump would be a guess. Refuse the same way.
Commits that do not parse otherwise proceed. Collect them for an Other section and count them in the preview. Squash-merge repos build subjects from PR titles, and any repo predating the convention has a mixed log, so refusing until every commit parses would make releasekit unusable on both. Nothing is silently dropped; the floor above is what stops the bump becoming fiction.
This step is done when the preview can state the last release (or none), every tag the resolver skipped, the next version, and the rule that produced it.
3. Render the changelog
Group the parsed commits into Keep a Changelog sections by type:
| commit type | section |
|---|---|
any type with ! or a BREAKING CHANGE: footer | Breaking changes |
feat | Added |
fix, hotfix | Fixed |
perf, revert | Changed |
docs, refactor, style, test, build, ci, chore | none; count them by type in the preview |
| unparsed | Other |
A hotfix commit is a fix that shipped on an urgent path, so it reads as a fix to the user. The omitted types change nothing a user of the release can see, and the preview count shows they were read, not lost. On an --allow-empty release, list the omitted commits under Other, so the section is never empty. Render a section only when it has an entry.
- Breaking changes come first, always in their own section, carrying the migration note from the footer. A reader scanning a release wants the thing that will break them above the thing that delights them.
- Keep the scope, drop the type. Under an Added heading,
auth: add token refresh retryreads better thanfeat(auth): add token refresh retry, because the heading already saidfeat. - Link each entry to its commit, and to its PR when the merge commit names one.
- Account for every commit. The step is done when each commit in the range sits in exactly one section or in the omitted count.
- Render exactly once. The
CHANGELOG.mdsection and the GitHub release body are the same string. Two renders are two texts that can disagree, and the disagreement always surfaces after the release is public.
4. Preview and confirm
The preview is mandatory and always renders. releasekit preview ends here having mutated nothing; a bare releasekit waits for a confirmation here and mutates nothing until it gets one.
Lead with the path, then the facts:
- first line. The path (direct or release-PR) and the phase (resume, finish, prepare, or fresh). A misdetected phase is the one failure that costs a duplicate release, so it has to be visible before it is actionable.
- the computed version, and the rule that produced it stated in words;
- the rendered changelog in full;
- the manifest file and its old → new version, or that no manifest was found;
- the tag name;
- the CI check rollup result, and the SHA it belongs to;
- the count of unparsed commits;
- every command about to run.
5. Cut the release
Verify CI first, on either path, and bind it to a SHA. Read the check rollup for the exact commit the CI result has to cover, and refuse to tag a failing one, because a tag on red code spends a version number on something nobody can use. --allow-red overrides it for a known-flaky required check. When gh is unusable there is no rollup, so say the check was skipped; never let a missing signal read as green. A pending rollup is not green either, so stop and report it.
gh api "repos/{owner}/{repo}/commits/$SHA/check-runs" \
--jq '.check_runs[] | [.name, .status, .conclusion] | @tsv'
gh api "repos/{owner}/{repo}/commits/$SHA/status" --jq .state
- Finish, and a resume whose release commit is on the upstream.
$SHAis the release commit that gets the tag. - Direct path, and a resume whose release commit is not on the upstream. CI cannot have run on the release commit, so
$SHAis its parent: HEAD before the release commit. The release commit only adds the changelog and the manifest bump to it. Re-check thatorigin/<base>still equals$SHAjust before the push, and stop if it moved.
The direct path
Unprotected base. In this order, because each step depends on the last landing:
- Bump the detected manifest (
package.json,Cargo.toml,pyproject.toml, a gemspec). No manifest found is fine and is not an error. - Prepend the rendered section to
CHANGELOG.md, creating the file with a header when it does not exist. - Commit both together as
chore(release): v<version>. One commit, because the bump and the changelog describe the same event. - Create an annotated tag whose message is the release title.
- Push the commit and the tag.
- Create the GitHub release with
gh release create, using the rendered body.
The release-PR path
Protected base. Two invocations, and the phase resolved in Preflight decides which one runs.
Prepare. Do the manifest bump, the changelog, and the commit on a release-v<version> branch. Push it and open a PR titled chore(release): v<version>, whose body is the rendered changelog. The title is load-bearing: a squash merge turns the PR title into the commit subject, and the finish phase finds the release by that subject. Stop there. The user reviews and merges it.
Finish. The merged, untagged chore(release): commit is the trigger. Verify the rollup on that commit's SHA, tag that commit, push the tag, and create the GitHub release. The changelog is already in the repo from the merge, so nothing is rewritten and nothing is committed.
This step is done when every command the preview listed has run, or when the run has stopped at a failed step. If any step on either path fails, stop at that step and report exactly what landed and what did not. The next run detects the gap as resume and continues from there. Never roll back a pushed tag; see the refusals in Notes.
6. Hand off
Write this section in the procedural register: one instruction per sentence, active voice, present tense, no metaphor.
The hand-off differs by path, so report the one that ran.
Direct path, release-PR finish, and resume. This is terminal.
- What changed. The version, the files bumped, and the commit created.
- Where it landed. The tag name, the release URL, and the branch pushed.
- Next. There is no next kit. Say that pushing the tag may have started a publish workflow, and that releasekit does not own or watch it. Name
gh release editfor a typo in the notes.
Release-PR path, prepare. This is not terminal.
- What changed. The release branch, the commit on it, and the PR opened. Say plainly that no tag exists yet.
- Where it landed. The branch name and the PR URL.
- Next. Review and merge the PR. Use mergekit when it is installed, otherwise
gh pr merge. Then run releasekit again to tag the merged commit.
Notes
- Four refusals, and each one is a refusal rather than a caution. releasekit stands down when another release tool owns the repo. It refuses a workspace with several versioned packages. It refuses to tag a red base. It never moves or deletes a published tag, because re-tagging breaks every consumer that already resolved it. A bad release is fixed by cutting the next one.
- Fixing a released typo is out of scope. The notes on a published release are editable by nature, but adding an edit path would make a third entry point out of a skill with one procedure and two exits. Run
gh release editinstead. - Prereleases are read, never written. releasekit passes over an existing
-rc.Ntag so a repo already using them can still cut a stable release. It does not create one. - One version per repo. Per-package versioning in a monorepo is a different skill with a different data model, and guessing at it silently is worse than refusing.
- It never runs unattended. The confirmation in Preview and confirm is a consent gate with no exemption. An orchestrator that reaches it with nobody to answer must stop and escalate, not proceed.
- Follow the repo over these defaults. A repo with its own changelog format, a
CHANGELOGat a different path, or a documented release procedure gets that, and say you followed it. Skillis declared for one call only, which is askinggitkitfor the base ref. releasekit invokes no other skill; naming mergekit in the hand-off is routing, not launching.- No filesystem or shell (a browser-based agent, say)? Then you cannot read the log or cut anything. Instead print the derived version, the rendered changelog as a codeblock, and the exact
git tagandgh release createcommands for the user to run.