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 name above, normally han). It
has no skills or agents of its own; it exists to install the children via dependencies. 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
future han-* 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 is prev#. For a child
it is the version recorded in that child's plugin.json at prev; if the child did not exist at prev, 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 example han--v5.0.0 and han-core--v3.0.0. Every plugin gets
one, and the parent's is what the GitHub release attaches to.
prev is 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 of prev after its last v. That rule is correct under both namings, which matters because
prev can be either shape:
v4.6.0 -> prev# is 4.6.0
han--v5.0.0 -> prev# is 5.0.0
Do not read prev# as "the number without the leading v". That older rule returns the whole tag string for a
per-plugin tag, and prev# is the parent's baseline (see Step 3a), so a wrong value here silently corrupts the entire
version plan rather than failing.
Each plugin's source directory comes from the source field in marketplace.json (for example ./han-core), so its
plugin.json is {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 $ARGUMENTS for 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 tree from 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 branch is not the default branch, do not stop — note in the Step 7
summary that the release is being cut from current branch and the tag will point at that branch's HEAD. The
operator chose autonomous; surface the fact, do not block.
Step 2: Determine previous version, commit range, and PR list
Resolve prev from the two tag probes, in this order:
latest parent tag when 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 old vX.Y.Z naming. 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 name is not han, the latest parent tag probe 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.
prev feeds two different things: the commit range below, and the parent's baseline version in Step 3a via
prev#. Parse prev# with the after-the-last-v rule in the vocabulary block, not by stripping a leading v.
Commit range. With a previous tag: ${prev}..HEAD. First release: the full history (HEAD with no range base).
Nothing to release check. Run git log {range} --oneline. If it is empty, there are no commits since prev.
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 -un
For each number N, run gh pr view N --json number,title,author,url,mergedAt,state. Keep only entries where
state is MERGED. Sort the survivors by mergedAt ascending (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_list is empty (local-only or squash history with no PR refs), record the notable commit
subjects from git log {range} --oneline instead, and use the commits form documented in both reference files.
Collect closed issues and their attribution. For each merged PR N in $pr_list, find the issues that PR closed
and credit everyone involved. This relates each closed issue to the fix that resolved it.
Find the closed issues for the PR. Take the issue numbers from
gh pr view N --json closingIssuesReferences --jq '[.closingIssuesReferences[]?.number]' (the GitHub-tracked
closing links). As a fallback for older PRs that linked via text, also scan the PR body and commit messages for
GitHub closing keywords: gh pr view N --json body,commits --jq '[.body, (.commits[].messageBody)] | join("\n")'
and extract #<num> that follow close, closes, closed, fix, fixes, fixed, resolve, resolves, or
resolved (case-insensitive). Union the two sets, dedupe.
Confirm each is a closed issue. For each candidate number I, run
gh issue view I --json number,title,author,state,comments (suppress stderr; redirect 2>/dev/null). Skip the
number if the command fails (it is a PR number, not an issue, or does not exist).
Gather attribution per issue. Record:
- opener —
.author.login, unless .author.is_bot is true.
- issue contributors (people who contributed meaningfully) — the people who left a substantive comment on
the issue. A reaction is not a comment (a 👍 or other emoji reaction never appears in
.comments[]), so
reaction-only participants are already excluded. Drive-by comments do not count either. Pull each comment with
its author and body
(gh issue view I --json comments --jq '.comments[] | select(.author.is_bot|not) | {login: .author.login, body: .body}',
stderr suppressed), and treat a comment as a drive-by when its trimmed body is emoji-only, or is a brief
acknowledgment or status ping (for example +1, same, me too, bump, following, thanks,
any update(s)?), or is shorter than roughly 15 words and adds no detail. A person qualifies only when at least
one of their comments is substantive (not a drive-by). Remove the opener and the PR workers so each person is
credited once. May be empty.
- PR workers — for the closing PR
N, the union of the PR author, the review authors, and the commit authors:
gh pr view N --json author,reviews,commits --jq '[.author.login] + [.reviews[]?.author.login] + [.commits[].authors[].login] | unique'.
Drop bot accounts (is_bot where available, plus the web-flow, github-actions, and dependabot logins).
Build $issue_list. One entry per closed issue: its number, title, opener, contributors, the closing PR
number(s), and the merged PR workers. If the same issue is closed by more than one PR in the range, record every
closing PR and merge their worker sets. Build the changelog bullets and release-body lines per
references/changelog-rules.md and
references/release-notes-format.md. If no closed issues are found,
$issue_list is 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.json version is its established
baseline. Record the introduction in the changelog, but do not increment. This is the general rule for every future
han-* extension, not a one-time exception for the current children.
3a. Classify each plugin
For each plugin, read current from {source}/.claude-plugin/plugin.json and compute baseline:
- Child, did not exist at
prev (git cat-file -e {prev}:{source}/.claude-plugin/plugin.json fails, or this is the
first release): new plugin. baseline = current, target = current, no bump, mark it new. Skip the rest of
the classification for this plugin.
- Child, existed at
prev: baseline = git show {prev}:{source}/.claude-plugin/plugin.json | jq -r .version.
- Parent:
baseline = prev# (the parent's version is what the tag tracks, regardless of any directory move). On the
first release baseline is empty and the parent is treated like a new plugin set to its current value.
Determine whether the plugin changed in {range}:
- Child: changed when
git diff --name-only {prev}..HEAD -- {source}/ is non-empty.
- Parent: always treated as changed (it always bumps). Its change level is computed in 3b from the whole release,
not just
{parent source}/.
3b. Compute each changed plugin's bump level and target
For a changed child, classify the highest-priority change inside {source}/:
- major — a skill directory under
{source}/skills/ was removed or renamed (renaming breaks /skill-name), an
agent under {source}/agents/ was removed or renamed, or a commit indicates a breaking behavior change (! in the
type, BREAKING CHANGE, a review skill that now auto-posts, and so on). Inspect
git diff --name-status {range} -- {source}/ for D/R on SKILL.md or agent paths, and scan commit subjects
scoped to that plugin.
- minor — a new skill, a new agent, a new
references/ file, or a new optional capability was added inside
{source}/, with no major change present. Inspect the same diff for added SKILL.md / agent files.
- patch — only typo, permission, edge-case, or context-injection fixes inside
{source}/.
For the parent, the bump level is the maximum across the whole release:
- a child was removed from the suite → major (breaking for anyone who installed the meta-plugin).
- any changed child's level is major → major.
- a new child plugin was introduced, or any changed child's level is minor → at least minor (a new or
expanded capability reaches suite installers).
- otherwise (only child patches, or only repo-level/
{parent source}/ doc and config fixes) → patch.
Take the highest of those. Repo-root changes that do not live inside any plugin directory (for example docs/,
README.md, CONTRIBUTING.md) are suite-level: they count toward the parent's level (normally patch) and never bump a
child.
Compute proposed from each plugin's baseline: major → (x+1).0.0, minor → x.(y+1).0, patch → x.y.(z+1).
3c. Decide each plugin's target (ahead path vs. compute path)
For each changed plugin, compare current to baseline:
highest=$(printf '%s\n%s\n' "{baseline}" "{current}" | sort -V | tail -n1)
current strictly ahead of baseline (highest == current and current != baseline): the version was
already bumped during development. target = current. No confirmation for this plugin. Still compute the expected
proposed, and if current is a lower level of bump than the changes warrant, add one non-blocking advisory line
to the Step 7 summary.
current equal to or behind baseline: the one-bump-per-branch bump has not been applied for this plugin.
target = proposed; this plugin needs confirmation.
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"). State prev, and for every plugin a line of the form
{name}: {baseline} → {proposed} ({level}; {evidence}), marking new plugins as new at {current} (no bump), ahead
plugins as already at {current}, and unchanged children as unchanged 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.json version against its marketplace.json entry
selected by name:
jq -r .version {source}/.claude-plugin/plugin.json
jq -r --arg n '{name}' '.plugins[] | select(.name == $n) | .version' .claude-plugin/marketplace.json
Any 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 --porcelain must 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 Step 3c, and any plugin
the operator edited at 3d), set both files so they read target:
- Set
{source}/.claude-plugin/plugin.json version to that plugin's target (Edit).
- Sync that plugin's
marketplace.json entry: set the version of the plugins[] element whose name equals 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. 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 in CHANGELOG.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 ## v heading, in
this order: ### Issues closed in this release (only when $issue_list is non-empty), then
### Pull requests in this release (or the commits form from the fallback). Build the issue bullets from
$issue_list and the PR bullets from $pr_list (Step 2), and close the final subsection with the Full 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-purpose agent 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 its name,
baseline → target, level, and new/changed status.
- The commit log
git log {range} --oneline and git 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
stat git diff {range} --stat -- docs/ README.md CONTRIBUTING.md CHANGELOG.md .claude-plugin/ labeled as the
evidence for the ### han parent 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_context from Step 1 (may be empty).
- The two newest existing
## v{X.Y.Z} sections from CHANGELOG.md verbatim, 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 @mention as 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 Notes title, above the previous newest entry
(Edit/Write). Then append the generated bookkeeping subsections to it exactly as in the augment case.
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's baseline → target and
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, or new 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.md states a "Current version:" that does not equal the parent target, note it as a follow-up the operator
may want to make (do not edit CLAUDE.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 if draft_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.json that Step 4 changed. Commit with chore(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.
Compute each plugin's tag name as {name}--v{target}, for every plugin in plugins, then split them into two
sets using the Step 3 version plan:
- Being tagged this release — every plugin whose
target differs from its baseline (the parent always, plus
each bumped child), and every new plugin from Step 3a, whose tag has never existed.
- Carried forward — every unchanged child, whose
target equals its baseline. Its tag was created by the
release that shipped that version and points at that older release commit. This run does not re-tag it.
A release commonly bumps a handful of plugins and carries the rest forward, so the carried-forward set is routine
rather than exceptional. Treating it as an error blocks every release after the first.
Classify every tag against the remote in one call:
${CLAUDE_SKILL_DIR}/scripts/remote-tag-state.sh {release commit} {tag name}...
It prints {tag}\t{state}\t{sha} per tag. Exit 2 means the remote could not be read: stop, because a run that cannot
see the remote must not guess. The script reports what the remote holds and nothing more; this step decides what each
state means, because one of the four means different things for the two sets:
| State |
What Step 10 does with it |
absent |
Create and push it. |
remote-at-commit |
Skip it. Already published at this commit. |
remote-at-other-commit |
Depends on which set the plugin is in (see 3). |
local-only |
Push it. This is never a skip. |
Read each remote-at-other-commit against the set its plugin is in. A tag pointing somewhere other than
{release commit} is the normal resting state for a carried-forward plugin and an unrecoverable collision for one
being tagged now, so the two cannot share a rule.
- Carried forward: run
git merge-base --is-ancestor {sha} {release commit}. When it succeeds, the tag points
into this release's own history, which is what an already-shipped version looks like. Record it as already on
GitHub and skip it; Step 10 never touches it. When the check fails, the tag sits on a commit this release does
not descend from, so treat it as the stop below.
- Being tagged this release: stop, always. No ancestor check applies, because the version this run is about to
publish is already taken.
On a stop, stop before a single tag is created. Name the tag, the commit it points at, and {release commit}.
Say plainly that pushing is not the recovery: the push is rejected identically every time, GitHub does not allow
a published release's tag to be moved or deleted, and this skill never forces one. The way out is a different version
number, which is free right now and costs a burned version once the walk starts.
Ask for approval with AskUserQuestion (header: "Tag plugins"). Show:
- The tags to create and push, named individually.
- Separately, the tags already on GitHub that this run leaves untouched, including every carried-forward plugin
resolved at 3. Say that none of them is moved or deleted.
{release commit} and the branch it is on, with the Step 1.3 note if it is not the default branch.
- When
draft_release is true, that the draft flag holds back the release page only. Every tag is still created and
pushed, and none of them can be moved afterwards.
Options: create and push the tags (recommended, first); abort.
This gate always fires. Unlike Step 3d it has no skip condition, because the tags are permanent whether or not a
version needed computing.
Treat an empty or unreadable answer as an abort. Do not read it as approval. AskUserQuestion has a known
failure mode that returns empty answers silently, which is why it is absent from allowed-tools (see the note under
the frontmatter). This is the one gate in this skill where that failure would be unrecoverable.
On abort: stop. Nothing is tagged and nothing is published. The release commit is already made and pushed, so say
so, name it, and say that re-running later picks up from there: the changelog section already exists, so the run will
augment it rather than regenerate it.
Re-read git rev-parse HEAD after approval. If it no longer equals {release commit}, stop. The tags are
created against the current checkout, so a commit made while the gate was waiting would tag something that was never
pushed.
Step 10: Tag every plugin
Walk plugins, taking the entry whose name equals parent plugin name first, then the rest in listed order. The
parent goes first so the tag the GitHub release attaches to is the one most likely to exist if the walk stops partway.
Order by name, not by position in the file.
For each plugin, act on the outcome Step 9 recorded for it:
remote-at-commit — skip. Record it as already on GitHub.
Carried forward, resolved at Step 9.3 — skip. Record it as already on GitHub. Its tag belongs to the release that
shipped that version, and this run leaves it exactly where it is.
local-only — git push origin refs/tags/{tag name}. Record it as newly pushed.
absent — create and push it in one call:
command claude plugin tag {source} --push
The command builtin is required, not stylistic: a bare claude runs whatever function or alias the operator's shell
defines, which commonly blocks waiting for terminal input. The command derives the tag name from the plugin's manifest
and marketplace entry and re-checks that the two agree, so a disagreement that reached this point surfaces here.
Never pass --force. It skips both the dirty-tree and tag-exists checks, and this skill does not move tags.
Reading a failure. The command exits 1 for both a benign refusal and a real failure, so the exit status alone does
not tell them apart. Match the message:
already exists locally — the tag is on this machine but was not on the remote at Step 9, so a push is what is
needed. Push it as in the local-only case above. Do not re-run the tagging command; it will refuse identically.
- Any other non-zero exit stops the run. That includes a push rejection, an uncommitted change under a plugin
folder, and a version disagreement that Step 3.5 should have caught. A stop here is unexpected rather than routine.
On a stop mid-walk, do not publish. Report the full tag state: which tags are on GitHub, which exist only on this
machine, and for each of the latter the literal recovery command git push origin refs/tags/{tag name}. Re-running
/han-release does not retry a failed push, so never offer that as the recovery.
Step 11: Publish the GitHub release
Re-run the classification from Step 9 over every plugin's tag name, holding each result to the rule for its
set. Every plugin being tagged this release must now read remote-at-commit. Every carried-forward plugin
must read either remote-at-commit or the same remote-at-other-commit sha that passed the ancestor check at Step
9.3. No plugin in either set may read absent or local-only.
If any result fails its rule, stop and report as in Step 10. Publishing with a tag missing from GitHub is the
failure this gate exists to prevent. Demanding that every tag sit on {release commit} is not that check: it would
stop every release that carries an unchanged plugin forward, which is nearly all of them.
Publish, per references/release-notes-format.md, using
/tmp/han-release-notes-v{parent target}.md (the filename keeps the plain version; it is a local path, not a ref):
No release exists for the tag (gh release view {parent plugin name}--v{parent target} fails):
gh release create {parent plugin name}--v{parent target} --verify-tag --title "v{parent target}" --notes-file /tmp/han-release-notes-v{parent target}.md
plus --latest, plus --draft only when draft_release is true (never --latest together with --draft).
--verify-tag is required. Without it gh silently creates a missing tag at the default branch's head, which
would mint a permanent tag at a commit nobody released. The --title keeps the plain v{parent target} so the
releases page reads continuously across the naming change.
A release already exists: do not create a second one.
gh release edit {parent plugin name}--v{parent target} --notes-file /tmp/han-release-notes-v{parent target}.md.
Add --draft=false only when the operator asked to publish an existing draft (and draft_release is not set).
Report it as updated, not created.
Step 12: Report
Report concisely:
- The full version plan (parent and each child, and how the parent version was decided).
{release commit}, and whether it is on the default branch.
- One line per plugin naming its final tag state: newly pushed, already on GitHub (whether skipped at
remote-at-commit or carried forward from an earlier release), or present only on this machine. Those three are
different, and "created" plus "skipped" cannot express the third, which is the state a partly-failed release leaves
behind.
- Any tag still local-only, with its literal
git push origin refs/tags/{tag name} recovery line.
- The files committed and the commit; the release URL (or draft URL); whether the CHANGELOG section was augmented or
generated.
- Any advisories from Step 7 (under-bump,
CLAUDE.md version drift, non-default release branch).
If the operator aborted at Step 7 or Step 9, report exactly what was changed locally, what was pushed, and what was
not.
1---2name: han-release3description: Cut a Han release: update CHANGELOG.md with the changes since the last release, bump every plugin that changed, tag every plugin as {plugin-name}--v{version} so a version-constrained dependency can resolve, and publish a GitHub release whose notes attribute every merged pull request to its author, credit every closed issue to the person who opened it, the people who contributed to it, and the people who worked on the fix, and link back to the full changelog for that version. Han ships as a parent meta-plugin (`han`) plus child plugins (`han-core`, `han-github`, `han-reporting`, and any future `han-*` extension); the skill versions each plugin independently and tags each one independently, with the release attaching to the parent's tag. Use when releasing, cutting a release, shipping a new Han version, publishing release notes, or tagging a version. Reads each plugin's target version from its plugin.json; when a plugin has not been bumped past the latest tag yet, it proposes a semantic-versioning bump and conf4---56<!--7`AskUserQuestion` is deliberately absent from `allowed-tools`, and must stay absent. Listing it makes Claude Code's8permission evaluator auto-approve the tool through its always-allow path and return empty answers without ever9rendering the prompt, so every gate in this skill would silently pass. See10han-plugin-builder/skills/guidance/references/skill-building-guidance/allowed-tools-AskUserQuestion.md. The tool still11works unlisted; it prompts once for permission.12-->1314## Pre-requisites1516- gh CLI: !`which gh 2>/dev/null || echo "not installed"`17- jq: !`which jq 2>/dev/null || echo "not installed"`18- claude CLI: !`which claude 2>/dev/null || echo "not installed"`19- git repo: !`git rev-parse --is-inside-work-tree 2>/dev/null || echo NO`2021**If `gh`, `jq`, or `claude` reads `not installed`, or this is not a git repo:** tell the operator which prerequisite is22missing and that it must be installed/configured before `/han-release` can run, then **immediately stop**. The skill23cannot proceed without all four.2425The `claude` CLI is what creates the per-plugin tags in Step 10. Every invocation of it in this skill goes through the26shell's `command` builtin (`command claude ...`), never a bare `claude`, because an operator's shell commonly wraps27`claude` in a function or alias that blocks waiting for terminal input. The `which` probe above resolves the same way a28bare call would, so it reports the wrapper's presence rather than the executable's; treat a non-empty result as "the29tool is reachable" and let Step 10's first invocation be what proves it runs.3031## Project Context3233- repo: !`gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git config --get remote.origin.url`34- current branch: !`git branch --show-current 2>/dev/null || echo unknown`35- default branch: !`git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo unknown`36- working tree: !`git status --porcelain 2>/dev/null || echo NO`37- parent plugin name: !`jq -r .name .claude-plugin/marketplace.json 2>/dev/null`38- plugins (name source version):39 !`jq -r '.plugins[] | "\(.name)\t\(.source)\t\(.version)"' .claude-plugin/marketplace.json 2>/dev/null`40- latest parent tag: !`git fetch --tags --quiet >/dev/null 2>&1; git tag -l 'han--v*' --sort=-v:refname | head -n1`41- latest suite tag: !`git tag -l 'v*.*.*' --sort=-v:refname | head -n1`42- changelog head: !`grep -m1 '^## v' CHANGELOG.md 2>/dev/null`4344The two tag probes are separate on purpose. A single pattern covering both namings returns the **wrong** tag: version45sorting compares the whole refname, so `v4.6.0` sorts ahead of `han--v5.0.0` and the old suite tag wins on every release46after the transition. The fetch runs on the first probe only; both read the same refreshed tag list.4748`latest parent tag` carries the literal `han--v*` pattern, because a context-injection command is a fixed string and49cannot interpolate `parent plugin name`. If `parent plugin name` is not `han`, redo the lookup in Step 2 with the actual50name before using either value. Skipping that turns a renamed parent into an empty probe, which Step 2 would read as a51first release and silently expand the changelog to the whole repository history.5253### Vocabulary used throughout this skill5455- **parent** — the meta-plugin whose name equals the marketplace `name` (`parent plugin name` above, normally `han`). It56 has no skills or agents of its own; it exists to install the children via `dependencies`. The parent's own per-plugin57 tag is the one the GitHub release attaches to, so the release tag is `{parent plugin name}--v{parent target}`.58- **children** — every other entry in `marketplace.json.plugins[]` (`han-core`, `han-github`, `han-reporting`, and any59 future `han-*` plugin). Each child has its own version line, bumped independently of the others.60- **baseline** of a plugin — its version at `prev` (the latest release tag). For the parent this is `prev#`. For a child61 it is the version recorded in that child's `plugin.json` at `prev`; if the child did not exist at `prev`, it is a62 **new plugin** (see Step 3).63- **current** of a plugin — the version in its working-tree `plugin.json`.64- **target** of a plugin — the version being released for it. The release tag is65 `{parent plugin name}--v{parent target}`.66- **tag name** of a plugin — `{name}--v{target}`, for example `han--v5.0.0` and `han-core--v3.0.0`. Every plugin gets67 one, and the parent's is what the GitHub release attaches to.68- `prev` is the previous release's tag, resolved in Step 2 from the two tag probes. On the first release it is empty.69- `prev#` is **the part of `prev` after its last `v`**. That rule is correct under both namings, which matters because70 `prev` can be either shape:7172 ```73 v4.6.0 -> prev# is 4.6.074 han--v5.0.0 -> prev# is 5.0.075 ```7677 Do not read `prev#` as "the number without the leading `v`". That older rule returns the whole tag string for a78 per-plugin tag, and `prev#` is the parent's baseline (see Step 3a), so a wrong value here silently corrupts the entire79 version plan rather than failing.8081- Each plugin's source directory comes from the `source` field in `marketplace.json` (for example `./han-core`), so its82 `plugin.json` is `{source}/.claude-plugin/plugin.json`. Use `{source}` verbatim in every git command: the83 `./`-prefixed form works both after a `{ref}:` colon (`git show {prev}:{source}/...`) and as a pathspec84 (`git diff ... -- {source}/`). Do not strip the leading `./`.8586## Step 1: Parse the invocation and check release safety87881. **Parse `$ARGUMENTS`** for two independent flags, then treat the remaining free text as optional release context that89 informs the changelog narrative:90 - `pause_before_publish` — true if the argument contains "pause", "review", or "confirm before publish"91 (case-insensitive). Default **false**.92 - `draft_release` — true if the argument contains "draft". Default **false**.93 - The leftover text (anything that is not those flag phrases) is `$release_context`, passed into the narrative94 dispatch in Step 5. May be empty.95962. **Working tree must be clean.** If `working tree` from Project Context is non-empty, there are uncommitted or97 untracked changes. Stop and tell the operator to commit or stash them first. Releasing an unknown working state is98 unsafe and a pushed tag is hard to reverse. This is a hard stop, not a pause gate.991003. **Branch note (non-blocking).** If `current branch` is not the `default branch`, do not stop — note in the Step 7101 summary that the release is being cut from `current branch` and the tag will point at that branch's `HEAD`. The102 operator chose autonomous; surface the fact, do not block.103104## Step 2: Determine previous version, commit range, and PR list1051061. **Resolve `prev` from the two tag probes, in this order:**107 1. `latest parent tag` when it is non-empty. This is the normal case from the second per-plugin release onward.108 2. Otherwise `latest suite tag`. This is the transition release, the one release where the previous tag still carries109 the old `vX.Y.Z` naming. Once a parent tag exists on the remote this arm is unreachable and can be deleted.110 3. Otherwise empty: this is the **first release**. There is no previous tag, the commit range is the full history,111 all compare links are omitted, and every plugin is treated as new (Step 3).112113 If `parent plugin name` is not `han`, the `latest parent tag` probe used the wrong literal. Re-run the lookup114 yourself before applying the precedence above:115 `git tag -l '{parent plugin name}--v*' --sort=-v:refname | head -n1`.116117 `prev` feeds two different things: the commit range below, **and** the parent's baseline version in Step 3a via118 `prev#`. Parse `prev#` with the after-the-last-`v` rule in the vocabulary block, not by stripping a leading `v`.1191202. **Commit range.** With a previous tag: `${prev}..HEAD`. First release: the full history (`HEAD` with no range base).1211223. **Nothing to release check.** Run `git log {range} --oneline`. If it is empty, there are no commits since `prev`.123 Stop and tell the operator there is nothing to release.1241254. **Collect merged PRs in the range.** Extract PR numbers from both squash subjects and merge commits:126127 ```128 git log {range} --pretty=%s%x00%b | grep -oE '#[0-9]+' | tr -d '#' | sort -un129 ```130131 For each number `N`, run `gh pr view N --json number,title,author,url,mergedAt,state`. Keep only entries where132 `state` is `MERGED`. Sort the survivors by `mergedAt` ascending (newest merge last). This is `$pr_list`. The PR list133 is repo-wide and appears once per release; it is not split per plugin. Build the PR lines and the changelog bullets134 per [references/release-notes-format.md](./references/release-notes-format.md) and135 [references/changelog-rules.md](./references/changelog-rules.md).1361375. **No-PR fallback.** If `$pr_list` is empty (local-only or squash history with no PR refs), record the notable commit138 subjects from `git log {range} --oneline` instead, and use the commits form documented in both reference files.1391406. **Collect closed issues and their attribution.** For each merged PR `N` in `$pr_list`, find the issues that PR closed141 and credit everyone involved. This relates each closed issue to the fix that resolved it.142143 - **Find the closed issues for the PR.** Take the issue numbers from144 `gh pr view N --json closingIssuesReferences --jq '[.closingIssuesReferences[]?.number]'` (the GitHub-tracked145 closing links). As a fallback for older PRs that linked via text, also scan the PR body and commit messages for146 GitHub closing keywords: `gh pr view N --json body,commits --jq '[.body, (.commits[].messageBody)] | join("\n")'`147 and extract `#<num>` that follow `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, or148 `resolved` (case-insensitive). Union the two sets, dedupe.149150 - **Confirm each is a closed issue.** For each candidate number `I`, run151 `gh issue view I --json number,title,author,state,comments` (suppress stderr; redirect `2>/dev/null`). Skip the152 number if the command fails (it is a PR number, not an issue, or does not exist).153154 - **Gather attribution per issue.** Record:155 - **opener** — `.author.login`, unless `.author.is_bot` is true.156 - **issue contributors** (people who contributed meaningfully) — the people who left a **substantive** comment on157 the issue. A reaction is not a comment (a 👍 or other emoji reaction never appears in `.comments[]`), so158 reaction-only participants are already excluded. Drive-by comments do not count either. Pull each comment with159 its author and body160 (`gh issue view I --json comments --jq '.comments[] | select(.author.is_bot|not) | {login: .author.login, body: .body}'`,161 stderr suppressed), and treat a comment as a drive-by when its trimmed body is emoji-only, or is a brief162 acknowledgment or status ping (for example `+1`, `same`, `me too`, `bump`, `following`, `thanks`,163 `any update(s)?`), or is shorter than roughly 15 words and adds no detail. A person qualifies only when at least164 one of their comments is substantive (not a drive-by). Remove the opener and the PR workers so each person is165 credited once. May be empty.166 - **PR workers** — for the closing PR `N`, the union of the PR author, the review authors, and the commit authors:167 `gh pr view N --json author,reviews,commits --jq '[.author.login] + [.reviews[]?.author.login] + [.commits[].authors[].login] | unique'`.168 Drop bot accounts (`is_bot` where available, plus the `web-flow`, `github-actions`, and `dependabot` logins).169170 - **Build `$issue_list`.** One entry per closed issue: its number, title, opener, contributors, the closing PR171 number(s), and the merged PR workers. If the same issue is closed by more than one PR in the range, record every172 closing PR and merge their worker sets. Build the changelog bullets and release-body lines per173 [references/changelog-rules.md](./references/changelog-rules.md) and174 [references/release-notes-format.md](./references/release-notes-format.md). If no closed issues are found,175 `$issue_list` is empty and the issues subsection/section is omitted everywhere.176177## Step 3: Build the per-plugin version plan178179Enumerate the plugins from `plugins` in Project Context (one parent, plus each child). For **every** plugin, determine180`baseline`, whether it changed in `{range}`, and its `target`. Classify changes against181[`docs/semantic-versioning.md`](../../../docs/semantic-versioning.md). The governing rules:182183- **The parent always bumps on every release.** Even when only one child changed, the parent gets a version bump,184 because every release is a release of the suite.185- **A child bumps only when its own directory changed in `{range}`.** A child with no changes keeps its version.186- **A brand-new plugin is not bumped by the release that introduces it.** Its `plugin.json` version is its established187 baseline. Record the introduction in the changelog, but do not increment. This is the general rule for every future188 `han-*` extension, not a one-time exception for the current children.189190### 3a. Classify each plugin191192For each plugin, read `current` from `{source}/.claude-plugin/plugin.json` and compute `baseline`:193194- **Child, did not exist at `prev`** (`git cat-file -e {prev}:{source}/.claude-plugin/plugin.json` fails, or this is the195 first release): **new plugin**. `baseline = current`, `target = current`, **no bump**, mark it `new`. Skip the rest of196 the classification for this plugin.197- **Child, existed at `prev`**: `baseline = git show {prev}:{source}/.claude-plugin/plugin.json | jq -r .version`.198- **Parent**: `baseline = prev#` (the parent's version is what the tag tracks, regardless of any directory move). On the199 first release `baseline` is empty and the parent is treated like a new plugin set to its `current` value.200201Determine whether the plugin **changed** in `{range}`:202203- **Child**: changed when `git diff --name-only {prev}..HEAD -- {source}/` is non-empty.204- **Parent**: always treated as changed (it always bumps). Its change _level_ is computed in 3b from the whole release,205 not just `{parent source}/`.206207### 3b. Compute each changed plugin's bump level and target208209For a **changed child**, classify the highest-priority change inside `{source}/`:210211- **major** — a skill directory under `{source}/skills/` was removed or renamed (renaming breaks `/skill-name`), an212 agent under `{source}/agents/` was removed or renamed, or a commit indicates a breaking behavior change (`!` in the213 type, `BREAKING CHANGE`, a review skill that now auto-posts, and so on). Inspect214 `git diff --name-status {range} -- {source}/` for `D`/`R` on `SKILL.md` or agent paths, and scan commit subjects215 scoped to that plugin.216- **minor** — a new skill, a new agent, a new `references/` file, or a new optional capability was added inside217 `{source}/`, with no major change present. Inspect the same diff for added `SKILL.md` / agent files.218- **patch** — only typo, permission, edge-case, or context-injection fixes inside `{source}/`.219220For the **parent**, the bump level is the maximum across the whole release:221222- a child was **removed** from the suite → **major** (breaking for anyone who installed the meta-plugin).223- any changed child's level is **major** → **major**.224- a **new** child plugin was introduced, or any changed child's level is **minor** → at least **minor** (a new or225 expanded capability reaches suite installers).226- otherwise (only child patches, or only repo-level/`{parent source}/` doc and config fixes) → **patch**.227228Take the highest of those. Repo-root changes that do not live inside any plugin directory (for example `docs/`,229`README.md`, `CONTRIBUTING.md`) are suite-level: they count toward the parent's level (normally patch) and never bump a230child.231232Compute `proposed` from each plugin's `baseline`: major → `(x+1).0.0`, minor → `x.(y+1).0`, patch → `x.y.(z+1)`.233234### 3c. Decide each plugin's target (ahead path vs. compute path)235236For each changed plugin, compare `current` to `baseline`:237238```239highest=$(printf '%s\n%s\n' "{baseline}" "{current}" | sort -V | tail -n1)240```241242- **`current` strictly ahead of `baseline`** (`highest` == `current` and `current` != `baseline`): the version was243 already bumped during development. **`target = current`. No confirmation for this plugin.** Still compute the expected244 `proposed`, and if `current` is a _lower_ level of bump than the changes warrant, add one non-blocking advisory line245 to the Step 7 summary.246- **`current` equal to or behind `baseline`**: the one-bump-per-branch bump has not been applied for this plugin.247 `target = proposed`; this plugin **needs confirmation**.248249### 3d. Confirm the plan (conditional gate)250251`target = parent target` drives the tag `{parent plugin name}--v{parent target}`.252253This gate is conditional and often does not fire. It is **not** the gate that approves the tags; that one is Step 9 and254it always fires.255256- **If no plugin needs confirmation** (every changed plugin was already ahead, plus the new plugins): the plan is fully257 determined. Do not prompt. Record it for the Step 7 summary and continue.258- **If one or more plugins need confirmation**: present the whole plan in **one** `AskUserQuestion`259 (`header: "Release versions"`). State `prev`, and for every plugin a line of the form260 `{name}: {baseline} → {proposed} ({level}; {evidence})`, marking new plugins as `new at {current} (no bump)`, ahead261 plugins as `already at {current}`, and unchanged children as `unchanged at {current}`. Name the specific skills/agents262 and commits that drove each level. Options: accept the proposed plan (recommended, first); adjust the parent level;263 adjust a child level; enter explicit versions. Apply the operator's answer to the affected plugins. Record the final264 plan as the version decision in the Step 7 summary.265266## Step 3.5: Validate every plugin before anything is written267268Run this **before** Step 4 writes anything. The working tree is still clean here, and that is the point: a failure at269this step leaves the repository exactly as the run found it, with nothing written and nothing pushed.270271Both checks cover **every** plugin in `plugins`, not only the ones being bumped. Version drift in an untouched plugin is272the likeliest way this fails, because Step 4 only writes plugins whose `target` differs from `current` and so never273corrects a plugin nobody changed.2742751. **Version agreement.** For each plugin, compare its `plugin.json` version against its `marketplace.json` entry276 selected by name:277278 ```279 jq -r .version {source}/.claude-plugin/plugin.json280 jq -r --arg n '{name}' '.plugins[] | select(.name == $n) | .version' .claude-plugin/marketplace.json281 ```282283 Any disagreement **stops the run**. Name the plugin and both versions. Do not attempt a repair: the correct value284 depends on which file is wrong, and that is the operator's call.2852862. **Tree cleanliness.** `git status --porcelain` must be empty. Step 1.2 already required this, so a non-empty result287 here means something wrote a file since the run started. Stop and say so.288289Do **not** implement either check by invoking `command claude plugin tag --dry-run`. That command refuses on a dirty290plugin folder, and after Step 4 every bumped plugin's folder is dirty by construction, so a dry-run sweep placed after291the writes refuses exactly the plugins the release just bumped and cannot tell that refusal apart from real drift. The292two reads above answer the same question and work in either state.293294## Step 4: Apply the versions295296For **every** plugin whose `target` differs from its `current` (the compute-path plugins from Step 3c, and any plugin297the operator edited at 3d), set both files so they read `target`:2982991. Set `{source}/.claude-plugin/plugin.json` `version` to that plugin's `target` (Edit).3002. Sync that plugin's `marketplace.json` entry: set the `version` of the `plugins[]` element whose `name` equals the301 plugin name (Edit). Select by name, not by index.302303Skip any plugin whose `target == current` (ahead-path or new plugins — their files are already correct). When the entire304plan is ahead-path/new (no version differs from `current`), this step is a no-op; note it and continue.305306**Re-run the version-agreement check from Step 3.5 over every plugin this step wrote.** Each plugin's two files are307edited separately, so this step is the one place in the run that can create a half-applied bump. Skip the cleanliness308check here: the tree is dirty by design now. A disagreement stops the run, and nothing has been committed yet, so the309recovery is discarding the working-tree edits.310311## Step 5: Update CHANGELOG.md312313Follow [references/changelog-rules.md](./references/changelog-rules.md) exactly. From `v3.0.0` onward, each release314section is a parent `## v{parent target}` heading with one `### {plugin} v{version}` sub-heading per plugin that changed315(the parent always appears; new and changed children appear; unchanged children are omitted), plus the release-level316bookkeeping subsections. Every `@mention` in the changelog (narrative, PR bullets, issue bullets) is a markdown link to317the person's GitHub profile: `[@{login}](https://github.com/{login})`, never flat text.3183191. **Does `## v{parent target}` already exist in `CHANGELOG.md`?** Search for the literal heading.3203212. **It exists — augment.** Leave every existing line of the narrative untouched. Put the generated bookkeeping322 subsections as the last `###` subsections of the `## v{parent target}` section, before the next `## v` heading, in323 this order: `### Issues closed in this release` (only when `$issue_list` is non-empty), then324 `### Pull requests in this release` (or the commits form from the fallback). Build the issue bullets from325 `$issue_list` and the PR bullets from `$pr_list` (Step 2), and close the final subsection with the `Full changelog:`326 line using the blob link from [references/release-notes-format.md](./references/release-notes-format.md). Use Edit.327328 **If a generated bookkeeping subsection is already present under this heading, replace it in place rather than329 appending a second copy.** A re-run reaching this branch is ordinary: the Step 9 tag gate sits after the release330 commit, so declining it and running again later brings the run back to its own changelog section.3313323. **It does not exist — generate, then append.** Dispatch **one** `general-purpose` agent to write the narrative333 `## v{parent target}` section. The skill already holds this context — paste the actual values into the prompt, do not334 tell the agent to go read them:335336 - The version plan from Step 3: parent `baseline → target`, and for each changed/new child its `name`,337 `baseline → target`, level, and new/changed status.338 - The commit log `git log {range} --oneline` and `git diff {range} --stat`, plus, per changed plugin,339 `git diff {range} --stat -- {source}/` so the agent can attribute each change to its plugin, plus a suite-level340 stat `git diff {range} --stat -- docs/ README.md CONTRIBUTING.md CHANGELOG.md .claude-plugin/` labeled as the341 evidence for the `### han` parent section (repo-root changes outside any plugin directory).342 - `$pr_list` (PR numbers, titles, authors).343 - `$issue_list` (Step 2): each closed issue's number, title, opener, contributors, closing PR(s), and the relevant344 fix, so the narrative can credit the issue opener where it describes that fix.345 - `$release_context` from Step 1 (may be empty).346 - The two newest existing `## v{X.Y.Z}` sections from `CHANGELOG.md` verbatim, as the register model.347 - The "Register and voice" and "Per-plugin structure" constraints from348 [references/changelog-rules.md](./references/changelog-rules.md), pasted in full.349350 Prompt the agent to: produce only the markdown for the `## v{parent target}` section — a one-paragraph summary that351 names the parent's new version and lists each changed/new child with its version, then one `### {plugin} v{version}`352 sub-heading per changed or new plugin (parent first), each describing only that plugin's changes (using `####` for353 topic subsections when needed), then a release-level `### Deferred (YAGNI)` subsection only when work was354 deliberately cut; when a change closes a tracked issue, name the fix and credit the issue opener inline as a profile355 link `[@{login}](https://github.com/{login})`; render every `@mention` as a `[@{login}](https://github.com/{login})`356 profile link, never flat text; match the register of the two pasted sections; obey every hard voice constraint;357 attribute every change to the plugin whose directory it touched; never invent changes not present in the commits,358 diff, PR list, or issue list; return only the section markdown with no preamble. If the agent returns anything else,359 discard it and re-issue with an explicit "return only the section markdown" reminder.360361 Insert the returned section directly under the `# Han Release Notes` title, above the previous newest entry362 (Edit/Write). Then append the generated bookkeeping subsections to it exactly as in the augment case.363364## Step 6: Assemble the release notes body365366Build the GitHub release body per [references/release-notes-format.md](./references/release-notes-format.md): the367release's **summary paragraph first** (the one-paragraph overview from the `## v{parent target}` narrative, with no368heading above it), then `## What's Changed` and the PR lines (`* {title} by @{login} in {url}`, newest merge last), then369an `## Issues closed` section built from `$issue_list` (omitted when empty), then every `### {plugin} v{version}`370sub-heading of the narrative **excluding** the `## v{parent target}` heading itself and the generated PR/commits/issues371bookkeeping subsections (the summary paragraph already leads the body, so it is not repeated here), then the372`**Full changelog:**` blob link and the `**Full Changelog:**` compare link (compare line omitted on a first release).373Compute the blob anchor by lowercasing `v{parent target}` and deleting every character that is not `a-z`, `0-9`, or `-`374(`v3.0.0` → `v300`). Write the assembled body to `/tmp/han-release-notes-v{parent target}.md` with the Write tool. Do375not assemble it with shell `echo`/`printf`.376377## Step 7: Show the prepared release378379Print to the operator, regardless of mode:380381- The full version plan: the release tag `{parent plugin name}--v{parent target}`, the parent's `baseline → target` and382 how it was decided (ahead-of-tag → used as-is, or computed-and-confirmed at Step 3), and one line per child383 (`bumped baseline → target`, `unchanged at version`, or `new at version`).384- The branch the tags will point at, plus the non-default-branch note from Step 1.3 if it applies.385- Any non-blocking advisory from Step 3 (under-bump warning on any plugin) and the post-release advisory check: if386 `CLAUDE.md` states a "Current version:" that does not equal the parent `target`, note it as a follow-up the operator387 may want to make (do not edit `CLAUDE.md`; it is out of scope).388- The exact CHANGELOG diff for the `## v{parent target}` section.389- The full assembled release notes body from Step 6.390- The publish mode: published `--latest`, or draft (only if `draft_release`).391392**If `pause_before_publish` is true:** use `AskUserQuestion` (`header: "Publish release"`) — options: publish now393(proceed to Step 8), abort (stop, having changed only local files). Do not push or publish until approved.394395**If `pause_before_publish` is false (default):** continue to Step 8 without pausing.396397This gate is opt-in and covers the whole release. It is separate from the Step 9 tag gate, which always fires and398covers the tags only. When both fire they arrive close together; that is intended, and the distinct `header` values are399what tell them apart.400401## Step 8: Commit and push the release commit402403The operator's request to tag and publish authorizes the commit and push required to do it.4044051. **Commit the release prep.** Stage `CHANGELOG.md`, `.claude-plugin/marketplace.json`, and every406 `{source}/.claude-plugin/plugin.json` that Step 4 changed. Commit with `chore(release): v{parent target}`. The commit407 subject keeps the plain version; it is a message, not a ref. If nothing is staged (augment produced no diff and no408 version changed — unlikely), skip the commit and note it.4094102. **Record `{release commit}`.** `git rev-parse HEAD`. Every tag must point here, and Step 9 re-checks it.4114123. **Push the commit, before any tag.** `git push origin HEAD`. If this fails, **stop**: no tag has been created, so413 nothing irreversible has happened. Pushing a tag first would transfer the commit to GitHub without putting it on any414 branch, and a tag can never be moved afterwards.415416## Step 9: Approve the tags (mandatory gate)417418The commit is on GitHub and no tag exists yet. Everything past this point is irreversible.4194201. **Compute each plugin's `tag name`** as `{name}--v{target}`, for every plugin in `plugins`, then split them into two421 sets using the Step 3 version plan:422 - **Being tagged this release** — every plugin whose `target` differs from its `baseline` (the parent always, plus423 each bumped child), and every **new** plugin from Step 3a, whose tag has never existed.424 - **Carried forward** — every unchanged child, whose `target` equals its `baseline`. Its tag was created by the425 release that shipped that version and points at that older release commit. This run does not re-tag it.426427 A release commonly bumps a handful of plugins and carries the rest forward, so the carried-forward set is routine428 rather than exceptional. Treating it as an error blocks every release after the first.4294302. **Classify every tag against the remote** in one call:431432 ```433 ${CLAUDE_SKILL_DIR}/scripts/remote-tag-state.sh {release commit} {tag name}...434 ```435436 It prints `{tag}\t{state}\t{sha}` per tag. Exit 2 means the remote could not be read: stop, because a run that cannot437 see the remote must not guess. The script reports what the remote holds and nothing more; this step decides what each438 state means, because one of the four means different things for the two sets:439440 | State | What Step 10 does with it |441 | ------------------------ | ---------------------------------------------- |442 | `absent` | Create and push it. |443 | `remote-at-commit` | Skip it. Already published at this commit. |444 | `remote-at-other-commit` | Depends on which set the plugin is in (see 3). |445 | `local-only` | Push it. This is never a skip. |4464473. **Read each `remote-at-other-commit` against the set its plugin is in.** A tag pointing somewhere other than448 `{release commit}` is the normal resting state for a carried-forward plugin and an unrecoverable collision for one449 being tagged now, so the two cannot share a rule.450 - **Carried forward:** run `git merge-base --is-ancestor {sha} {release commit}`. When it succeeds, the tag points451 into this release's own history, which is what an already-shipped version looks like. Record it as **already on452 GitHub** and skip it; Step 10 never touches it. When the check fails, the tag sits on a commit this release does453 not descend from, so treat it as the stop below.454 - **Being tagged this release:** stop, always. No ancestor check applies, because the version this run is about to455 publish is already taken.456457 **On a stop**, stop before a single tag is created. Name the tag, the commit it points at, and `{release commit}`.458 Say plainly that pushing is **not** the recovery: the push is rejected identically every time, GitHub does not allow459 a published release's tag to be moved or deleted, and this skill never forces one. The way out is a different version460 number, which is free right now and costs a burned version once the walk starts.4614624. **Ask for approval** with `AskUserQuestion` (`header: "Tag plugins"`). Show:463 - The tags to create and push, named individually.464 - Separately, the tags already on GitHub that this run leaves untouched, including every carried-forward plugin465 resolved at 3. Say that none of them is moved or deleted.466 - `{release commit}` and the branch it is on, with the Step 1.3 note if it is not the default branch.467 - When `draft_release` is true, that the draft flag holds back the release page only. Every tag is still created and468 pushed, and none of them can be moved afterwards.469470 Options: create and push the tags (recommended, first); abort.4714725. **This gate always fires.** Unlike Step 3d it has no skip condition, because the tags are permanent whether or not a473 version needed computing.4744756. **Treat an empty or unreadable answer as an abort.** Do not read it as approval. `AskUserQuestion` has a known476 failure mode that returns empty answers silently, which is why it is absent from `allowed-tools` (see the note under477 the frontmatter). This is the one gate in this skill where that failure would be unrecoverable.4784797. **On abort:** stop. Nothing is tagged and nothing is published. The release commit is already made and pushed, so say480 so, name it, and say that re-running later picks up from there: the changelog section already exists, so the run will481 augment it rather than regenerate it.4824838. **Re-read `git rev-parse HEAD` after approval.** If it no longer equals `{release commit}`, **stop**. The tags are484 created against the current checkout, so a commit made while the gate was waiting would tag something that was never485 pushed.486487## Step 10: Tag every plugin488489Walk `plugins`, taking the entry whose name equals `parent plugin name` **first**, then the rest in listed order. The490parent goes first so the tag the GitHub release attaches to is the one most likely to exist if the walk stops partway.491Order by name, not by position in the file.492493For each plugin, act on the outcome Step 9 recorded for it:494495- **`remote-at-commit`** — skip. Record it as already on GitHub.496- **Carried forward, resolved at Step 9.3** — skip. Record it as already on GitHub. Its tag belongs to the release that497 shipped that version, and this run leaves it exactly where it is.498- **`local-only`** — `git push origin refs/tags/{tag name}`. Record it as newly pushed.499- **`absent`** — create and push it in one call:500501 ```502 command claude plugin tag {source} --push503 ```504505 The `command` builtin is required, not stylistic: a bare `claude` runs whatever function or alias the operator's shell506 defines, which commonly blocks waiting for terminal input. The command derives the tag name from the plugin's manifest507 and marketplace entry and re-checks that the two agree, so a disagreement that reached this point surfaces here.508509**Never pass `--force`.** It skips both the dirty-tree and tag-exists checks, and this skill does not move tags.510511**Reading a failure.** The command exits 1 for both a benign refusal and a real failure, so the exit status alone does512not tell them apart. Match the message:513514- `already exists locally` — the tag is on this machine but was not on the remote at Step 9, so a push is what is515 needed. Push it as in the `local-only` case above. Do not re-run the tagging command; it will refuse identically.516- **Any other non-zero exit stops the run.** That includes a push rejection, an uncommitted change under a plugin517 folder, and a version disagreement that Step 3.5 should have caught. A stop here is unexpected rather than routine.518519**On a stop mid-walk**, do not publish. Report the full tag state: which tags are on GitHub, which exist only on this520machine, and for each of the latter the literal recovery command `git push origin refs/tags/{tag name}`. Re-running521`/han-release` does **not** retry a failed push, so never offer that as the recovery.522523## Step 11: Publish the GitHub release5245251. **Re-run the classification** from Step 9 over every plugin's `tag name`, holding each result to the rule for its526 set. Every plugin **being tagged this release** must now read `remote-at-commit`. Every **carried-forward** plugin527 must read either `remote-at-commit` or the same `remote-at-other-commit` sha that passed the ancestor check at Step528 9.3. No plugin in either set may read `absent` or `local-only`.529530 If any result fails its rule, **stop** and report as in Step 10. Publishing with a tag missing from GitHub is the531 failure this gate exists to prevent. Demanding that every tag sit on `{release commit}` is not that check: it would532 stop every release that carries an unchanged plugin forward, which is nearly all of them.5335342. **Publish**, per [references/release-notes-format.md](./references/release-notes-format.md), using535 `/tmp/han-release-notes-v{parent target}.md` (the filename keeps the plain version; it is a local path, not a ref):536537 - **No release exists for the tag** (`gh release view {parent plugin name}--v{parent target}` fails):538 `gh release create {parent plugin name}--v{parent target} --verify-tag --title "v{parent target}" --notes-file /tmp/han-release-notes-v{parent target}.md`539 plus `--latest`, plus `--draft` only when `draft_release` is true (never `--latest` together with `--draft`).540541 `--verify-tag` is required. Without it `gh` silently creates a missing tag at the default branch's head, which542 would mint a permanent tag at a commit nobody released. The `--title` keeps the plain `v{parent target}` so the543 releases page reads continuously across the naming change.544545 - **A release already exists:** do not create a second one.546 `gh release edit {parent plugin name}--v{parent target} --notes-file /tmp/han-release-notes-v{parent target}.md`.547 Add `--draft=false` only when the operator asked to publish an existing draft (and `draft_release` is not set).548 Report it as updated, not created.549550## Step 12: Report551552Report concisely:553554- The full version plan (parent and each child, and how the parent version was decided).555- `{release commit}`, and whether it is on the default branch.556- **One line per plugin naming its final tag state**: newly pushed, already on GitHub (whether skipped at557 `remote-at-commit` or carried forward from an earlier release), or present only on this machine. Those three are558 different, and "created" plus "skipped" cannot express the third, which is the state a partly-failed release leaves559 behind.560- Any tag still local-only, with its literal `git push origin refs/tags/{tag name}` recovery line.561- The files committed and the commit; the release URL (or draft URL); whether the CHANGELOG section was augmented or562 generated.563- Any advisories from Step 7 (under-bump, `CLAUDE.md` version drift, non-default release branch).564565If the operator aborted at Step 7 or Step 9, report exactly what was changed locally, what was pushed, and what was566not.