Verdaccio Pull Request Workflow
verdaccio/verdaccio
Takes a change through a verdaccio pull request: branch, local checks, changeset, title and body, labels, CI and review rounds, and ports to other release lines.
Cut 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…
$ npx skills add testdouble/han --skill han-release -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install testdouble/han han-release --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/han-release .claude/skills/han-release && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .claude/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/testdouble/han/tree/main/.claude/skills/han-releaseType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add testdouble/han --skill han-release -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install testdouble/han han-release --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/han-release .agents/skills/han-release && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .agents/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add testdouble/han --skill han-release -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install testdouble/han han-release --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/han-release .cursor/skills/han-release && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .cursor/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/testdouble/han.git --path .claude/skills/han-release--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add testdouble/han --skill han-release -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install testdouble/han han-release --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/han-release .gemini/skills/han-release && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .gemini/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install testdouble/han han-releaseInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add testdouble/han --skill han-release -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/han-release .github/skills/han-release && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .github/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add testdouble/han --skill han-release -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install testdouble/han han-release --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/testdouble/han.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/han-release .opencode/skills/han-release && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "han-release" agent skill from https://github.com/testdouble/han/tree/main/.claude/skills/han-release into .opencode/skills/han-release/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "han-release", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
han-releaseCut 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…
Han Release is an agent skill from testdouble/han. Cut 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…
Its SKILL.md is about 8.6k tokens, which your agent loads only when the skill is triggered. The skill folder holds 8 other files, including scripts and reference files (for example `references/attribution-rules.md`, `references/changelog-rules.md` and `references/release-notes-format.md`).
It sits in Development, covering Pull requests and Changelog and release notes. It works with Git and GitHub. The repository describes itself as: Han: AI skills and agents for "Solo" product engineers and small teams. The licence is MIT.
12 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit abba73a. It shows what the files ask for, not the result of running them.
Pre-approves these tools, so the agent can use them without asking each time:
ReadEditWriteGlobGrepAgentBash(git *)Bash(gh *)Bash(jq *)Bash(which *)…and 4 more on the same allowed-tools line.
From allowed-tools in the SKILL.md frontmatter.
Ships 2 files in scripts/ (Shell), which the agent can run.
Shell commands in SKILL.md call:
gitghjqclaudeFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
github.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Han Release loads about 8.6k tokens when it runs, and up to ~15k if it reads all its reference files. Until then it costs about 201 tokens; SKILL.md has 4,754 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); the scripts in this folder are not scanned.
The full file from testdouble/han at commit abba73a, republished under its MIT licence (© testdouble). 4,754 words, ~8,639 tokens.
.claude/skills/han-release/SKILL.md (or your agent's skills folder). This skill also uses 6 other files; get the full folder from GitHub.<!--
`AskUserQuestion` is deliberately absent from `allowed-tools`, and must stay absent. Listing it makes Claude Code's
permission evaluator auto-approve the tool through its always-allow path and return empty answers without ever
rendering the prompt, so every gate in this skill would silently pass. See
han-plugin-builder/skills/guidance/references/skill-building-guidance/allowed-tools-AskUserQuestion.md. The tool still
works unlisted; it prompts once for permission.
-->
which gh 2>/dev/null || echo "not installed"which jq 2>/dev/null || echo "not installed"which claude 2>/dev/null || echo "not installed"git rev-parse --is-inside-work-tree 2>/dev/null || echo NOIf 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.
gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git config --get remote.origin.urlgit branch --show-current 2>/dev/null || echo unknowngit symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo unknowngit status --porcelain 2>/dev/null || echo NOjq -r .name .claude-plugin/marketplace.json 2>/dev/nulljq -r '.plugins[] | "\(.name)\t\(.source)\t\(.version)"' .claude-plugin/marketplace.json 2>/dev/nullgit fetch --tags --quiet >/dev/null 2>&1; git tag -l 'han--v*' --sort=-v:refname | head -n1git tag -l 'v*.*.*' --sort=-v:refname | head -n1grep -m1 '^## v' CHANGELOG.md 2>/dev/nullThe 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.
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.0Do 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 3a in references/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 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 ./.
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.$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.
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.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.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 at 3a in references/version-plan-rules.md 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 -unFor 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, 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_list entry. If no closed issues are found, $issue_list is
empty and the issues subsection/section is omitted everywhere.
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:
{range}. A child with no changes keeps its version.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.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).
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.
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.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.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 --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.
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:
{source}/.claude-plugin/plugin.json version to that plugin's target (Edit).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.
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 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:
baseline → target, and for each changed/new child its name,
baseline → target, level, and new/changed status.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).## v{X.Y.Z} sections from CHANGELOG.md verbatim, as the register model.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.
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.
Print to the operator, regardless of mode:
{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).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).## v{parent target} section.--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.
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.
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:
target differs from its baseline (the parent always, plus
each bumped child), and every new plugin classified at 3a, whose tag has never existed.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.
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.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:
{release commit} and the branch it is on, with the Step 1.3 note if it is not the default branch.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.
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} --pushThe 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.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.
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.
Report concisely:
{release commit}, and whether it is on the default branch.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.git push origin refs/tags/{tag name} recovery line.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.
© testdouble, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 6 other files (scripts, references) in .claude/skills/han-release of testdouble/han.
Open the folder on GitHubat commit abba73a
Han Release next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Han Release this skilltestdouble/han | 279 | — | ~8.6k | Automated safety check: Pass | MIT | |
| Verdaccio Pull Request Workflowverdaccio/verdaccio | 18k | — | ~1.9k | Automated safety check: Pass | MIT | |
| ZCF Release AutomationUfoMiao/zcf | 6.1k | — | ~3.4k | Automated safety check: Pass | MIT | |
| WooCommerce Draft PR Creatorwoocommerce/woocommerce | 11k | — | ~1.1k | Automated safety check: Pass | Custom licence | |
| Prisma Release PRprisma/orm | 48k | — | ~4k | Automated safety check: Pass | Apache-2.0 | |
| Nylas Nodejs Releasenylas/nylas-nodejs | 181 | — | ~1.6k | Automated safety check: Warn | MIT |
verdaccio/verdaccio
Takes a change through a verdaccio pull request: branch, local checks, changeset, title and body, labels, CI and review rounds, and ports to other release lines.
UfoMiao/zcf
Automates a version release with changesets: analyzes code changes, writes a bilingual CHANGELOG, bumps the version and commits through a release branch and pull request.
woocommerce/woocommerce
Opens a concise draft pull request for the current branch, working out change type, base branch, title and body from the commits and diff.
prisma/orm
Helps a Prisma 8 maintainer cut the next release by bumping the version across every workspace package, opening the release PR and preparing the docs PR.
nylas/nylas-nodejs
Prepares nylas-nodejs SDK releases on a versioned release branch with CHANGELOG updates, version bump, git tag, and PR body.
ZaxbyHub/opencode-swarm
Codex adapter for opencode-swarm that governs commits, pushes, draft PRs, PR body updates and CI closeout, deferring to the repo's canonical commit-pr protocol.
testdouble/han
Convert a stakeholder summary markdown file into a single self-contained HTML executive report — bottom line and decision asks up front, supporting detail later — styled with a Test Double-derived…
testdouble/han
Update Han plugin documentation so every skill, agent, guidance doc, index, and cross-reference is current and accurate.
testdouble/han
Authoritative guidance for building Claude Code skills, agents, and plugins, plus init and update steps that install and refresh the plugin-building skills in the current repository.
testdouble/han
Builds a feature implementation plan from an existing feature specification (or equivalent context) through a facilitated team conversation.
testdouble/han
Restructure existing code without changing its behavior, through a test-gated refactoring loop: a named target, a green suite over that target before any edit, a planned sequence of small named…
testdouble/han
Generate a PR description from the current branch's changes against a GitHub PR, using the gh CLI.
Categories
Cut 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…. Han Release is an agent skill from testdouble/han.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.
Han Release fits situations like: cutting a release; shipping a new Han version; publishing release notes; tagging a version.
Run `npx skills add testdouble/han --skill han-release -a claude-code`. Or copy the skill folder (.claude/skills/han-release in testdouble/han) into .claude/skills/han-release in your project. Claude Code loads it when a task matches its description.
Run `npx skills add testdouble/han --skill han-release -a codex`. Or copy the skill folder (.claude/skills/han-release in testdouble/han) into .agents/skills/han-release in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add testdouble/han --skill han-release -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/han-release, .gemini/skills/han-release, .github/skills/han-release and .opencode/skills/han-release in your project.
Going by SKILL.md and its folder, Han Release needs a shell for the scripts in its folder and the command-line tools its instructions call (git, gh, jq and claude). Our summary lists: A Bash shell. Its frontmatter pre-approves these tools: Read, Edit, Write, Glob, Grep, Agent, Bash(git *), Bash(gh *), Bash(jq *), Bash(which *), Bash(grep *), Bash(sed *), Bash(head *), Bash(command claude *).
SKILL.md names 1 domain. In commands or code: github.com; the agent is likely to contact it when it follows the instructions. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. The check reads SKILL.md only: the scripts in the folder are not scanned, so read them before running anything.
Han Release is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 8.6k tokens (SKILL.md is roughly 35k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full. Its references folder adds about 6.2k tokens, read only when the agent opens those files.
Skills that share tags, products or a category with Han Release: Verdaccio Pull Request Workflow (verdaccio/verdaccio, 18k stars), ZCF Release Automation (UfoMiao/zcf, 6.1k stars), WooCommerce Draft PR Creator (woocommerce/woocommerce, 11k stars) and Prisma Release PR (prisma/orm, 48k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
testdouble (a GitHub organization) maintains it in testdouble/han, which has 279 GitHub stars. The repository holds 54 skills in this directory. The repository was last updated on October 1, 2026.
Source: testdouble/han on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.