Pre-requisites
- gh CLI: !
which gh || echo MISSING - jq: !
which jq || echo MISSING - git repo: !
git rev-parse --is-inside-work-tree 2>/dev/null || echo NO
If gh or jq is MISSING, 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 three.
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 - default branch: !
git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo unknown - working tree: !
git status --porcelain - 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 release tag: !
git fetch --tags --quiet >/dev/null 2>&1; git tag -l 'v*.*.*' --sort=-v:refname | head -n1 - changelog head: !
grep -m1 '^## v' CHANGELOG.md 2>/dev/null
Vocabulary used throughout this skill
- parent — the meta-plugin whose name equals the marketplace
name(parent plugin nameabove, normallyhan). It has no skills or agents of its own; it exists to install the children viadependencies. The git tag tracks the parent's version, so the release tag isv{parent target}. - children — every other entry in
marketplace.json.plugins[](han.core,han.github,han.reporting, and any futurehan.*plugin). Each child has its own version line, bumped independently of the others. - baseline of a plugin — its version at
prev(the latest release tag). For the parent this isprev#. For a child it is the version recorded in that child'splugin.jsonatprev; if the child did not exist atprev, it is a new plugin (see Step 3). - current of a plugin — the version in its working-tree
plugin.json. - target of a plugin — the version being released for it. The release tag is
v{parent target}. previs thelatest release tag(for examplev2.7.0; the number without the leadingvisprev#). On the first releaseprevis empty.- Each plugin's source directory comes from the
sourcefield inmarketplace.json(for example./han.core), so itsplugin.jsonis{source}/.claude-plugin/plugin.json. Use{source}verbatim in every git command: the./-prefixed form works both after a{ref}:colon (git show {prev}:{source}/...) and as a pathspec (git diff ... -- {source}/). Do not strip the leading./.
Step 1: Parse the invocation and check release safety
-
Parse
$ARGUMENTSfor two independent flags, then treat the remaining free text as optional release context that informs the changelog narrative:pause_before_publish— true if the argument contains "pause", "review", or "confirm before publish" (case-insensitive). Default false.draft_release— true if the argument contains "draft". Default false.- The leftover text (anything that is not those flag phrases) is
$release_context, passed into the narrative dispatch in Step 5. May be empty.
-
Working tree must be clean. If
working treefrom Project Context is non-empty, there are uncommitted or untracked changes. Stop and tell the operator to commit or stash them first. Releasing an unknown working state is unsafe and a pushed tag is hard to reverse. This is a hard stop, not a pause gate. -
Branch note (non-blocking). If
current branchis not thedefault branch, do not stop — note in the Step 7 summary that the release is being cut fromcurrent branchand the tag will point at that branch'sHEAD. The operator chose autonomous; surface the fact, do not block.
Step 2: Determine previous version, commit range, and PR list
-
previslatest release tag. If it is 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). -
Commit range. With a previous tag:
${prev}..HEAD. First release: the full history (HEADwith no range base). -
Nothing to release check. Run
git log {range} --oneline. If it is empty, there are no commits sinceprev. Stop and tell the operator there is nothing to release. -
Collect merged PRs in the range. Extract PR numbers from both squash subjects and merge commits:
git log {range} --pretty=%s%x00%b | grep -oE '#[0-9]+' | tr -d '#' | sort -unFor each number
N, rungh pr view N --json number,title,author,url,mergedAt,state. Keep only entries wherestateisMERGED. Sort the survivors bymergedAtascending (newest merge last). This is$pr_list. The PR list is repo-wide and appears once per release; it is not split per plugin. Build the PR lines and the changelog bullets per references/release-notes-format.md and references/changelog-rules.md. -
No-PR fallback. If
$pr_listis empty (local-only or squash history with no PR refs), record the notable commit subjects fromgit log {range} --onelineinstead, and use the commits form documented in both reference files. -
Collect closed issues and their attribution. For each merged PR
Nin$pr_list, find the issues that PR closed and credit everyone involved. 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 followclose,closes,closed,fix,fixes,fixed,resolve,resolves, orresolved(case-insensitive). Union the two sets, dedupe. -
Confirm each is a closed issue. For each candidate number
I, rungh issue view I --json number,title,author,state,comments(suppress stderr; redirect2>/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_botis 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_botwhere available, plus theweb-flow,github-actions, anddependabotlogins).
- opener —
-
Build
$issue_list. One entry per closed issue: its number, title, opener, contributors, the closing PR number(s),
-