han-release
SkillDev toolsCut a Han release: update CHANGELOG.md with the changes since the last release, bump and tag every plugin that changed as {plugin-name}--v{version} so a version-constrained dependency can resolve, and publish a GitHub release crediting every merged pull request and closed issue to the people behind it. Use when releasing, cutting a release, shipping a new Han version, publishing release notes, or tagging a version. Always stops for approval before creating any tag, because a pushed tag is never moved. Requires the gh CLI, jq, the claude CLI, and a clean git checkout. This is a repository-maintenance skill for the Han repo itself, not a general review or PR skill — use code-review for local review, post-code-review-to-pr to post a PR review, and update-pr-description for PR bodies.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the han-release skill
What this skill tells your AI
The instructions your AI receives, as published by testdouble/han in .claude/skills/han-release/SKILL.md and read by ahel’s review.
Pre-requisites
- gh CLI: !
which gh 2>/dev/null || echo "not installed" - jq: !
which jq 2>/dev/null || echo "not installed" - claude CLI: !
which claude 2>/dev/null || echo "not installed" - git repo: !
git rev-parse --is-inside-work-tree 2>/dev/null || echo NO
If gh, jq, or claude reads not installed, or this is not a git repo: tell the operator which prerequisite is
missing and that it must be installed/configured before /han-release can run, then immediately stop. The skill
cannot proceed without all four.
The claude CLI is what creates the per-plugin tags in Step 10. Every invocation of it in this skill goes through the
shell's command builtin (command claude ...), never a bare claude, because an operator's shell commonly wraps
claude in a function or alias that blocks waiting for terminal input. The which probe above resolves the same way a
bare call would, so it reports the wrapper's presence rather than the executable's; treat a non-empty result as "the
tool is reachable" and let Step 10's first invocation be what proves it runs.
Project Context
- repo: !
gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git config --get remote.origin.url - current branch: !
git branch --show-current 2>/dev/null || echo unknown - default branch: !
git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo unknown - working tree: !
git status --porcelain 2>/dev/null || echo NO - parent plugin name: !
jq -r .name .claude-plugin/marketplace.json 2>/dev/null - plugins (name source version):
!
jq -r '.plugins[] | "\(.name)\t\(.source)\t\(.version)"' .claude-plugin/marketplace.json 2>/dev/null - latest parent tag: !
git fetch --tags --quiet >/dev/null 2>&1; git tag -l 'han--v*' --sort=-v:refname | head -n1 - latest suite tag: !
git tag -l 'v*.*.*' --sort=-v:refname | head -n1 - changelog head: !
grep -m1 '^## v' CHANGELOG.md 2>/dev/null
The two tag probes are separate on purpose. A single pattern covering both namings returns the wrong tag: version
sorting compares the whole refname, so v4.6.0 sorts ahead of han--v5.0.0 and the old suite tag wins on every release
after the transition. The fetch runs on the first probe only; both read the same refreshed tag list.
latest parent tag carries the literal han--v* pattern, because a context-injection command is a fixed string and
cannot interpolate parent plugin name. If parent plugin name is not han, redo the lookup in Step 2 with the actual
name before using either value. Skipping that turns a renamed parent into an empty probe, which Step 2 would read as a
first release and silently expand the changelog to the whole repository history.
Vocabulary used throughout this skill
-
parent — the meta-plugin whose name equals the marketplace
name(parent plugin nameabove, normallyhan). It has no skills or agents of its own; it exists to install the children viadependencies. The parent's own per-plugin tag is the one the GitHub release attaches to, so the release tag is{parent plugin name}--v{parent target}. -
children — every other entry in
marketplace.json.plugins[](han-core,han-github,han-reporting, and any futurehan-*plugin). Each child has its own version line, bumped independently of the others. -
baseline of a plugin — its version at
prev(the latest release tag). For the parent this isprev#. For a child it is the version recorded in that child'splugin.jsonatprev; if the child did not exist atprev, it is a new plugin (see Step 3). -
current of a plugin — the version in its working-tree
plugin.json. -
target of a plugin — the version being released for it. The release tag is
{parent plugin name}--v{parent target}. -
tag name of a plugin —
{name}--v{target}, for examplehan--v5.0.0andhan-core--v3.0.0. Every plugin gets one, and the parent's is what the GitHub release attaches to. -
previs the previous release's tag, resolved in Step 2 from the two tag probes. On the first release it is empty. -
prev#is the part ofprevafter its lastv. That rule is correct under both namings, which matters becauseprevcan be either shape:v4.6.0 -> prev# is 4.6.0 han--v5.0.0 -> prev# is 5.0.0Do not read
prev#as "the number without the leadingv". That older rule returns the whole tag string for a per-plugin tag, andprev#is the parent's baseline (see 3a inreferences/version-plan-rules.md), so a wrong value here silently corrupts the entire version plan rather than failing. -
Each plugin's source directory comes from the
sourcefield inmarketplace.json(for example./han-core), so itsplugin.jsonis{source}/.claude-plugin/plugin.json. Use{source}verbatim in every git command: the./-prefixed form works both after a{ref}:colon (git show {prev}:{source}/...) and as a pathspec (git diff ... -- {source}/). Do not strip the leading./.
Step 1: Parse the invocation and check release safety
-
Parse
$ARGUMENTSfor two independent flags, then treat the remaining free text as optional release context that informs the changelog narrative:pause_before_publish— true if the argument contains "pause", "review", or "confirm before publish" (case-insensitive). Default false.draft_release— true if the argument contains "draft". Default false.- The leftover text (anything that is not those flag phrases) is
$release_context, passed into the narrative dispatch in Step 5. May be empty.
-
Working tree must be clean. If
working treefrom Project Context is non-empty, there are uncommitted or untracked changes. Stop and tell the operator to commit or stash them first. Releasing an unknown working state is unsafe and a pushed tag is hard to reverse. This is a hard stop, not a pause gate. -
Branch note (non-blocking). If
current branchis not thedefault branch, do not stop — note in the Step 7 summary that the release is being cut fromcurrent branchand the tag will point at that branch'sHEAD. The operator chose autonomous; surface the fact, do not block.
Step 2: Determine previous version, commit range, and PR list
-
Resolve
prevfrom the two tag probes, in this order:latest parent tagwhen it is non-empty. This is the normal case from the second per-plugin release onward.- Otherwise
latest suite tag. This is the transition release, the one release where the previous tag still carries the oldvX.Y.Znaming. Once a parent tag exists on the remote this arm is unreachable and can be deleted. - Otherwise empty: this is the first release. There is no previous tag, the commit range is the full history, all compare links are omitted, and every plugin is treated as new (Step 3).
If
parent plugin nameis nothan, thelatest parent tagprobe used the wrong literal. Re-run the lookup yourself before applying the precedence above:git tag -l '{parent plugin name}--v*' --sort=-v:refname | head -n1.prevfeeds two different things: the commit range below, and the parent's baseline version at 3a inreferences/version-plan-rules.mdviaprev#. Parseprev#with the after-the-last-vrule in the vocabulary block, not by stripping a leadingv. -
Commit range. With a previous tag:
${prev}..HEAD. First release: the full history (HEADwith no range base). -
Nothing to release check. Run
git log {range} --oneline. If it is empty, there are no commits sinceprev. Stop and tell the operator there is nothing to release. -
Collect merged PRs in the range. Extract PR numbers from both squash subjects and merge commits:
git log {range} --pretty=%s%x00%b | grep -oE '#[0-9]+' | tr -d '#' | sort -unFor each number
N, rungh pr view N --json number,title,author,url,mergedAt,state. Keep only entries wherestateisMERGED. Sort the survivors bymergedAtascending (newest merge last). This is$pr_list. The PR list is repo-wide and appears once per release; it is not split per plugin. Build the PR lines and the changelog bullets per references/release-notes-format.md and references/changelog-rules.md. -
No-PR fallback. If
$pr_listis empty (local-only or squash history with no PR refs), record the notable commit subjects fromgit log {range} --onelineinstead, and use the commits form documented in both reference files. -
Collect closed issues and their attribution. For each merged PR
Nin$pr_list, find the issues that PR closed and credit everyone involved, following references/attribution-rules.md. That file holds the closing-issue lookup, the substantive-comment test that decides who counts as a contributor, the bot-account exclusions, and the shape of each$issue_listentry. If no closed issues are found,$issue_listis empty and the issues subsection/section is omitted everywhere.
Step 3: Build the per-plugin version plan
Enumerate the plugins from plugins in Project Context (one parent, plus each child). For every plugin, determine
baseline, whether it changed in {range}, and its target. Classify changes against
docs/semantic-versioning.md. The governing rules:
- The parent always bumps on every release. Even when only one child changed, the parent gets a version bump, because every release is a release of the suite.
- A child bumps only when its own directory changed in
{range}. A child with no changes keeps its version. - A brand-new plugin is not bumped by the release that introduces it. Its
plugin.jsonversion is its established baseline. Record the introduction in the changelog, but do not increment. This is the general rule for every futurehan-*extension, not a one-time exception for the current children.
Classify each plugin, compute its bump level, and decide its target by following references/version-plan-rules.md. That file holds the baseline lookup for a parent, an existing child, and a new child (3a); the major, minor, and patch classification for a changed child and for the parent (3b); and the ahead-path versus compute-path decision that determines which plugins need confirmation below (3c).
3d. Confirm the plan (conditional gate)
target = parent target drives the tag {parent plugin name}--v{parent target}.
This gate is conditional and often does not fire. It is not the gate that approves the tags; that one is Step 9 and it always fires.
- If no plugin needs confirmation (every changed plugin was already ahead, plus the new plugins): the plan is fully determined. Do not prompt. Record it for the Step 7 summary and continue.
- If one or more plugins need confirmation: present the whole plan in one
AskUserQuestion(header: "Release versions"). Stateprev, and for every plugin a line of the form{name}: {baseline} → {proposed} ({level}; {evidence}), marking new plugins asnew at {current} (no bump), ahead plugins asalready at {current}, and unchanged children asunchanged at {current}. Name the specific skills/agents and commits that drove each level. Options: accept the proposed plan (recommended, first); adjust the parent level; adjust a child level; enter explicit versions. Apply the operator's answer to the affected plugins. Record the final plan as the version decision in the Step 7 summary.
Step 3.5: Validate every plugin before anything is written
Run this before Step 4 writes anything. The working tree is still clean here, and that is the point: a failure at this step leaves the repository exactly as the run found it, with nothing written and nothing pushed.
Both checks cover every plugin in plugins, not only the ones being bumped. Version drift in an untouched plugin is
the likeliest way this fails, because Step 4 only writes plugins whose target differs from current and so never
corrects a plugin nobody changed.
-
Version agreement. For each plugin, compare its
plugin.jsonversion against itsmarketplace.jsonentry selected by name:jq -r .version {source}/.claude-plugin/plugin.json jq -r --arg n '{name}' '.plugins[] | select(.name == $n) | .version' .claude-plugin/marketplace.jsonAny disagreement stops the run. Name the plugin and both versions. Do not attempt a repair: the correct value depends on which file is wrong, and that is the operator's call.
-
Tree cleanliness.
git status --porcelainmust be empty. Step 1.2 already required this, so a non-empty result here means something wrote a file since the run started. Stop and say so.
Do not implement either check by invoking command claude plugin tag --dry-run. That command refuses on a dirty
plugin folder, and after Step 4 every bumped plugin's folder is dirty by construction, so a dry-run sweep placed after
the writes refuses exactly the plugins the release just bumped and cannot tell that refusal apart from real drift. The
two reads above answer the same question and work in either state.
Step 4: Apply the versions
For every plugin whose target differs from its current (the compute-path plugins from 3c in references/version-plan-rules.md, and any plugin
the operator edited at 3d), set both files so they read target:
- Set
{source}/.claude-plugin/plugin.jsonversionto that plugin'starget(Edit). - Sync that plugin's
marketplace.jsonentry: set theversionof theplugins[]element whosenameequals the plugin name (Edit). Select by name, not by index.
Skip any plugin whose target == current (ahead-path or new plugins — their files are already correct). When the entire
plan is ahead-path/new (no version differs from current), this step is a no-op; note it and continue.
Re-run the version-agreement check from Step 3.5 over every plugin this step wrote. Each plugin's two files are edited separately, so this step is the one place in the run that can create a half-applied bump. Skip the cleanliness check here: the tree is dirty by design now. A disagreement stops the run, and nothing has been committed yet, so the recovery is discarding the working-tree edits.
Step 5: Update CHANGELOG.md
Follow references/changelog-rules.md exactly, writing the narrative in the voice at
writing-voice.md. From v3.0.0 onward, each release
section is a parent ## v{parent target} heading with one ### {plugin} v{version} sub-heading per plugin that changed
(the parent always appears; new and changed children appear; unchanged children are omitted), plus the release-level
bookkeeping subsections. Every @mention in the changelog (narrative, PR bullets, issue bullets) is a markdown link to
the person's GitHub profile: [@{login}](https://github.com/{login}), never flat text.
-
Does
## v{parent target}already exist inCHANGELOG.md? Search for the literal heading. -
It exists — augment. Leave every existing line of the narrative untouched. Put the generated bookkeeping subsections as the last
###subsections of the## v{parent target}section, before the next## vheading, in this order:### Issues closed in this release(only when$issue_listis non-empty), then### Pull requests in this release(or the commits form from the fallback). Build the issue bullets from$issue_listand the PR bullets from$pr_list(Step 2), and close the final subsection with theFull changelog:line using the blob link from references/release-notes-format.md. Use Edit.If a generated bookkeeping subsection is already present under this heading, replace it in place rather than appending a second copy. A re-run reaching this branch is ordinary: the Step 9 tag gate sits after the release commit, so declining it and running again later brings the run back to its own changelog section.
-
It does not exist — generate, then append. Dispatch one
general-purposeagent to write the narrative## v{parent target}section. The skill already holds this context — paste the actual values into the prompt, do not tell the agent to go read them:- The version plan from Step 3: parent
baseline → target, and for each changed/new child itsname,baseline → target, level, and new/changed status. - The commit log
git log {range} --onelineandgit diff {range} --stat, plus, per changed plugin,git diff {range} --stat -- {source}/so the agent can attribute each change to its plugin, plus a suite-level statgit diff {range} --stat -- docs/ README.md CONTRIBUTING.md CHANGELOG.md .claude-plugin/labeled as the evidence for the### hanparent section (repo-root changes outside any plugin directory). $pr_list(PR numbers, titles, authors).$issue_list(Step 2): each closed issue's number, title, opener, contributors, closing PR(s), and the relevant fix, so the narrative can credit the issue opener where it describes that fix.$release_contextfrom Step 1 (may be empty).- The two newest existing
## v{X.Y.Z}sections fromCHANGELOG.mdverbatim, as the register model. - The "Register and voice" and "Per-plugin structure" constraints from references/changelog-rules.md, pasted in full.
Prompt the agent to: produce only the markdown for the
## v{parent target}section — a one-paragraph summary that names the parent's new version and lists each changed/new child with its version, then one### {plugin} v{version}sub-heading per changed or new plugin (parent first), each describing only that plugin's changes (using####for topic subsections when needed), then a release-level### Deferred (YAGNI)subsection only when work was deliberately cut; when a change closes a tracked issue, name the fix and credit the issue opener inline as a profile link[@{login}](https://github.com/{login}); render every@mentionas a[@{login}](https://github.com/{login})profile link, never flat text; match the register of the two pasted sections; obey every hard voice constraint; attribute every change to the plugin whose directory it touched; never invent changes not present in the commits, diff, PR list, or issue list; return only the section markdown with no preamble. If the agent returns anything else, discard it and re-issue with an explicit "return only the section markdown" reminder.Insert the returned section directly under the
# Han Release Notestitle, above the previous newest entry (Edit/Write). Then append the generated bookkeeping subsections to it exactly as in the augment case. - The version plan from Step 3: parent
Step 6: Assemble the release notes body
Build the GitHub release body per references/release-notes-format.md: the
release's summary paragraph first (the one-paragraph overview from the ## v{parent target} narrative, with no
heading above it), then ## What's Changed and the PR lines (* {title} by @{login} in {url}, newest merge last), then
an ## Issues closed section built from $issue_list (omitted when empty), then every ### {plugin} v{version}
sub-heading of the narrative excluding the ## v{parent target} heading itself and the generated PR/commits/issues
bookkeeping subsections (the summary paragraph already leads the body, so it is not repeated here), then the
**Full changelog:** blob link and the **Full Changelog:** compare link (compare line omitted on a first release).
Compute the blob anchor by lowercasing v{parent target} and deleting every character that is not a-z, 0-9, or -
(v3.0.0 → v300). Write the assembled body to /tmp/han-release-notes-v{parent target}.md with the Write tool. Do
not assemble it with shell echo/printf.
Step 7: Show the prepared release
Print to the operator, regardless of mode:
- The full version plan: the release tag
{parent plugin name}--v{parent target}, the parent'sbaseline → targetand how it was decided (ahead-of-tag → used as-is, or computed-and-confirmed at Step 3), and one line per child (bumped baseline → target,unchanged at version, ornew at version). - The branch the tags will point at, plus the non-default-branch note from Step 1.3 if it applies.
- Any non-blocking advisory from Step 3 (under-bump warning on any plugin) and the post-release advisory check: if
CLAUDE.mdstates a "Current version:" that does not equal the parenttarget, note it as a follow-up the operator may want to make (do not editCLAUDE.md; it is out of scope). - The exact CHANGELOG diff for the
## v{parent target}section. - The full assembled release notes body from Step 6.
- The publish mode: published
--latest, or draft (only ifdraft_release).
If pause_before_publish is true: use AskUserQuestion (header: "Publish release") — options: publish now
(proceed to Step 8), abort (stop, having changed only local files). Do not push or publish until approved.
If pause_before_publish is false (default): continue to Step 8 without pausing.
This gate is opt-in and covers the whole release. It is separate from the Step 9 tag gate, which always fires and
covers the tags only. When both fire they arrive close together; that is intended, and the distinct header values are
what tell them apart.
Step 8: Commit and push the release commit
The operator's request to tag and publish authorizes the commit and push required to do it.
-
Commit the release prep. Stage
CHANGELOG.md,.claude-plugin/marketplace.json, and every{source}/.claude-plugin/plugin.jsonthat Step 4 changed. Commit withchore(release): v{parent target}. The commit subject keeps the plain version; it is a message, not a ref. If nothing is staged (augment produced no diff and no version changed — unlikely), skip the commit and note it. -
Record
{release commit}.git rev-parse HEAD. Every tag must point here, and Step 9 re-checks it. -
Push the commit, before any tag.
git push origin HEAD. If this fails, stop: no tag has been created, so nothing irreversible has happened. Pushing a tag first would transfer the commit to GitHub without putting it on any branch, and a tag can never be moved afterwards.
Step 9: Approve the tags (mandatory gate)
The commit is on GitHub and no tag exists yet. Everything past this point is irreversible.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 263
- Forks
- 23
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
han-release- Source
- github.com/testdouble/han