Claude Skill

han-release

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 t

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download testdouble-han-.claude_skills_han-release-abba73a.zip · 26 KB
Part of testdouble/han — 47 skills

Install

skills CLI npx skills add https://github.com/testdouble/han/tree/main/.claude/skills/han-release
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install testdouble-han@llmmart
Git git clone https://github.com/testdouble/han.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole testdouble/han collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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 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 ./.

Step 1: Parse the invocation and check release safety

  1. 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.
  2. 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.

  3. 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

  1. Resolve prev from the two tag probes, in this order:

    1. latest parent tag when it is non-empty. This is the normal case from the second per-plugin release onward.
    2. 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.
    3. 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 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.

  2. Commit range. With a previous tag: ${prev}..HEAD. First release: the full history (HEAD with no range base).

  3. 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.

  4. 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.

  5. 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.

  6. 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.

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.

Classify each plugin, compute its bump level, and decide its target by following references/version-plan-rules.md. That file holds the baseline lookup for a parent, an existing child, and a new child (3a); the major, minor, and patch classification for a changed child and for the parent (3b); and the ahead-path versus compute-path decision that determines which plugins need confirmation below (3c).

3d. Confirm the plan (conditional gate)

target = parent target drives the tag {parent plugin name}--v{parent target}.

This gate is conditional and often does not fire. It is not the gate that approves the tags; that one is Step 9 and it always fires.

  • If no plugin needs confirmation (every changed plugin was already ahead, plus the new plugins): the plan is fully determined. Do not prompt. Record it for the Step 7 summary and continue.
  • If one or more plugins need confirmation: present the whole plan in one AskUserQuestion (header: "Release versions"). 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.

  1. 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.

  2. 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 3c in references/version-plan-rules.md, and any plugin the operator edited at 3d), set both files so they read target:

  1. Set {source}/.claude-plugin/plugin.json version to that plugin's target (Edit).
  2. 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, 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.

  1. Does ## v{parent target} already exist in CHANGELOG.md? Search for the literal heading.

  2. 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.

  3. 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.

  1. 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.

  2. Record {release commit}. git rev-parse HEAD. Every tag must point here, and Step 9 re-checks it.

  3. 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.

  1. 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 classified at 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.

  2. 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.
  3. 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.

  4. 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.

  5. This gate always fires. Unlike Step 3d it has no skip condition, because the tags are permanent whether or not a version needed computing.

  6. 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.

  7. 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.

  8. 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

  1. 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.

  2. 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.

Files (han)
  • references
    • attribution-rules.md 3.1 KB
      # Release Attribution Rules
      
      This file defines how a release run credits the people behind every closed issue in the range. It is loaded by
      `SKILL.md` Step 2.6, which finds the issues each merged PR closed and builds `$issue_list` from them.
      
      Work one merged PR at a time and relate 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
      [changelog-rules.md](./changelog-rules.md) and
      [release-notes-format.md](./release-notes-format.md). If no closed issues are found,
      `$issue_list` is empty and the issues subsection/section is omitted everywhere.
      
    • changelog-rules.md 10.5 KB
      # CHANGELOG.md rules
      
      ## Contents
      
      - Per-plugin structure (from v3.0.0 onward)
      - Augment vs. generate
      - Linked mentions
      - Generated bookkeeping subsections
      - Register and voice for a generated narrative section
      
      `CHANGELOG.md` lives at the repository root. The title line is `# Han Release Notes`. Each version is a top-level
      section that starts with `## v{parent target}` (newest first, directly under the title), where `parent target` is the
      version of the parent `han` plugin.
      
      The heading keeps the plain `v{parent target}` form and always has. The release's git tag is
      `{parent plugin name}--v{parent target}`, which is a different string on purpose: the heading is a label a person reads
      and the anchor is computed from it, so changing it would break every link into every past release section.
      
      ## Per-plugin structure (from v3.0.0 onward)
      
      Han ships as a parent meta-plugin (`han`) plus child plugins (`han-core`, `han-github`, `han-reporting`, and any future
      `han-*` extension). Each plugin carries its own version. The changelog mirrors that: every change is attributed to the
      plugin whose directory it touched, never grouped together across plugins.
      
      A release section is laid out as:
      
      ```
      ## v{parent target}
      
      {one-paragraph plain-language summary of the release. It names the parent's
       new version and lists every changed or new child with its version, for
       example: "han 3.1.0 ships a new GitHub skill (han-github 1.1.0) and a fix to
       the code-review roster (han-core 1.0.1)." han-reporting is unchanged.}
      
      ### han v{parent target}
      
      {the suite-level / parent narrative: the restructure itself, repo-root docs,
       marketplace and meta-plugin changes. Use #### topic subsections when there is
       more than one distinct thread.}
      
      ### han-core v{version}
      
      {only han-core's changes. #### topic subsections as needed.}
      
      ### han-github v{version}
      
      {only han-github's changes.}
      
      ### Deferred (YAGNI)
      
      {release-level; only when work was deliberately cut. Optional.}
      
      ### Issues closed in this release
      
      - {issue title} (#{issue number}) — opened by [@{opener}](https://github.com/{opener}); fixed in #{PR number} by [@{worker}](https://github.com/{worker}); thanks to [@{contributor}](https://github.com/{contributor})
      
      ### Pull requests in this release
      
      - {PR title} (#{number}) — [@{author login}](https://github.com/{author login})
      
      Full changelog: {blob link — see release-notes-format.md}
      ```
      
      Rules for the per-plugin sub-headings:
      
      - **One `### {plugin name} v{version}` sub-heading per plugin that changed.** The parent (`### han v{parent target}`)
        always appears, because the parent always bumps. A child appears only when it changed in this release, or when it is
        newly introduced.
      - **A newly introduced child** gets a sub-heading marked as new, for example `### han-reporting v1.0.0 (new)`,
        describing what the plugin is and what it ships at introduction. Its version is the established baseline and does not
        increment for the release that introduces it.
      - **An unchanged child is omitted entirely.** No empty sub-heading.
      - **Topic detail within a plugin uses `####`.** Reserve `###` for plugin sub-headings and the two release-level
        bookkeeping subsections (`### Deferred (YAGNI)`, `### Pull requests in this release`).
      - **Attribute by directory.** A change to a file under `han-github/` belongs in the `### han-github` section. Repo-root
        changes that live outside any plugin directory (`docs/`, `README.md`, `CONTRIBUTING.md`) are suite-level and belong in
        the `### han` section.
      
      Releases before `v3.0.0` predate the split and use the older flat layout (descriptive `###` topic subsections directly
      under `## v{X.Y.Z}`). Leave those historical sections exactly as they are.
      
      ## Augment vs. generate
      
      Decide by whether a `## v{parent target}` section already exists:
      
      - **Section exists — augment, do not rewrite.** The curated prose is the source of truth. Leave every existing line of
        the narrative untouched. Append the generated bookkeeping subsections (see "Generated bookkeeping subsections" below)
        as the **last** `###` subsections of that version's section, immediately before the next `## v` heading (or end of
        file).
      
        **A generated bookkeeping subsection that is already there is replaced, not appended beside.** If
        `### Pull requests in this release`, `### Commits in this release`, or `### Issues closed in this release` already
        exists under this version's heading, overwrite that subsection in place. Only the generated subsections are
        replaceable; the narrative never is.
      
        This matters because a re-run is ordinary rather than rare. The tag-approval gate sits after the release commit, so
        declining it and running again later is a normal path, and the second run meets its own changelog section. Without
        this rule that run appends a second copy of a subsection already present, and the release body assembled from the
        section inherits the duplicate.
      
      - **Section missing — generate it, then append the subsection.** Dispatch one agent to write the narrative section
        (summary paragraph + per-plugin `### {plugin} v{version}` sub-headings + a release-level `### Deferred (YAGNI)`
        subsection when applicable) in the register described below. Insert the new `## v{parent target}` section directly
        under the `# Han Release Notes` title, above the previous newest entry. Then append the generated bookkeeping
        subsection as its last `###` subsection.
      
      Never delete or reorder existing version sections. Never edit a version section other than `## v{parent target}`.
      
      ## Linked mentions
      
      Every `@mention` of a person anywhere in a changelog section — narrative prose, the issues subsection, the pull-requests
      subsection — is a markdown link to that person's GitHub profile, never flat text:
      
      ```
      [@{login}](https://github.com/{login})
      ```
      
      GitHub does not auto-link `@username` in a rendered `CHANGELOG.md` blob, so the explicit link is what makes the mention
      clickable and unambiguous. (The GitHub release body is different: GitHub auto-links bare `@username` there, so the body
      keeps the bare form. See [release-notes-format.md](./release-notes-format.md).)
      
      ## Generated bookkeeping subsections
      
      Append these subsections at the end of the `## v{parent target}` section, in this order: the issues subsection first
      (when there are closed issues), then the pull-requests subsection. The `Full changelog:` line closes the last
      subsection.
      
      ### Issues closed in this release
      
      Include this subsection only when one or more issues were closed by the release's PRs (`$issue_list` non-empty). Omit it
      entirely otherwise. One bullet per closed issue, relating the issue to the fix that resolved it and crediting everyone
      involved:
      
      ```
      - {issue title} (#{issue number}) — opened by [@{opener}](https://github.com/{opener}); fixed in #{PR number} by [@{worker}](https://github.com/{worker}), [@{worker}](https://github.com/{worker}); thanks to [@{contributor}](https://github.com/{contributor})
      ```
      
      - **opened by** — the person who opened the issue (one mention).
      - **fixed in** — the PR(s) that closed the issue, and the people who worked on that PR (author, reviewers, and
        commit/co-authors), each linked. When more than one PR closed the issue, list each `#{number}` with its workers.
      - **thanks to** — the people who contributed meaningfully to the issue: those who left a **substantive** comment.
        Reactions never count (a 👍 is not a comment) and neither do drive-by comments (a bare `+1`, `bump`, `thanks`,
        `any update?`, an emoji-only reply, or a short acknowledgment that adds no detail). The opener and the PR workers are
        removed so each person is credited once. Omit the whole `; thanks to ...` clause when that set is empty.
      
      Exclude bot accounts (`is_bot`, plus the `web-flow`, `github-actions`, and `dependabot` logins) from every list.
      
      ### Pull requests in this release
      
      One bullet per merged pull request included in this release, newest merge last, in the exact form:
      
      ```
      - {PR title} (#{number}) — [@{author login}](https://github.com/{author login})
      ```
      
      The PR list is repo-wide and appears once per release. It is not split per plugin; per-plugin attribution lives in the
      narrative sub-headings above it.
      
      When no merged pull requests are found between the previous release and `HEAD` (local-only commits, squash history
      without PR refs), use this heading and body instead:
      
      ```
      ### Commits in this release
      
      - {commit subject} ({short sha})
      ```
      
      This is the changelog form of the no-PR fallback. The GitHub release body uses a different, single-line form for the
      same case (see [release-notes-format.md](./release-notes-format.md)). The changelog enumerates each commit; the body
      summarizes.
      
      Close the last bookkeeping subsection with one final line:
      
      ```
      Full changelog: {compare-or-blob link — see release-notes-format.md}
      ```
      
      ## Register and voice for a generated narrative section
      
      Match the register of the existing `## v{X.Y.Z}` entries already in `CHANGELOG.md`: neutral, descriptive, technical
      present tense ("The `/gap-analysis` swarm flips from opt-in to opt-out..."). This is **not** the first-person blog
      voice; it is the clipped changelog register those entries already use. The two newest existing sections are pasted into
      the dispatch prompt as the register model — follow them.
      
      Hard constraints from
      [`han-communication/references/writing-voice.md`](../../../../han-communication/references/writing-voice.md), applied
      verbatim to generated changelog prose:
      
      - No em-dash (`—`) in changelog prose. Entry text is a sentence, not a label-gloss line, so use a colon, comma,
        parentheses, or two sentences. (The voice profile permits the em-dash as a label-gloss or appositive separator; neither
        position occurs in a changelog entry.)
      - `use`, never `leverage` or `utilize`.
      - No `just`, no `actually`.
      - None of: "It's worth noting", "Importantly", "delve", "foster", "synergy", "underscore", "pivotal", "showcase",
        "robust" (as a vague positive), "paradigm shift", "game changer", "Let's dive in", "deep dive".
      - Name skills, agents, files, and flags specifically (`/tdd`, `han-core/skills/code-review/SKILL.md`), never
        generically.
      - Reference internal paths with backticks. State what changed plainly; do not hedge with "arguably" or "one might say".
      - Every `@mention` of a person is a profile link `[@{login}](https://github.com/{login})`, never flat text (see "Linked
        mentions" above).
      
      The narrative describes what changed and why from the operator's point of view, not a file-by-file diff. Code structure
      can be summarized; behavior changes (new skills, renamed skills, changed defaults, changed dispatch) must be stated
      explicitly, under the plugin that owns them.
      
    • release-notes-format.md 6.7 KB
      # GitHub release notes format
      
      ## Contents
      
      - Body template
      - PR line format
      - Issues closed section
      - Full-changelog links
      - Publish vs. draft, and idempotency
      
      The release notes body is assembled deterministically: the release's summary paragraph leads (no heading), followed by a
      `## What's Changed` PR list, an `## Issues closed` section, the per-plugin `### {plugin} v{version}` narrative
      sub-headings, then the full-changelog links. The release is named for the parent `han` plugin's version, so the body
      title is `v{parent target}`. The **tag** it attaches to is the parent's per-plugin tag,
      `{parent plugin name}--v{parent target}`. Those two are deliberately different: the title is display text a person
      reads, and the tag is a ref that has to match the naming Claude Code resolves plugin versions from.
      
      ## Body template
      
      ```
      {the summary paragraph of the v{parent target} narrative: the one-paragraph
       release overview, with NO heading above it. This leads the body.}
      
      ## What's Changed
      
      {one line per merged PR, newest merge last}
      
      ## Issues closed
      
      {one line per issue closed by this release's PRs, omitted entirely when none}
      
      {every ### {plugin} v{version} sub-heading (with their #### topic subsections) of
       the ## v{parent target} section, EXCLUDING the generated "### Issues closed in
       this release", "### Pull requests in this release", and "### Commits in this
       release" subsections (those are changelog-only bookkeeping). Do NOT include the
       ## v{parent target} heading itself, and do NOT repeat the summary paragraph
       here: it already leads the body above.}
      
      **Full changelog:** {blob link}
      **Full Changelog:** {compare link}
      ```
      
      The body opens with the release's summary paragraph (no heading), then `## What's Changed` and `## Issues closed`, then
      the per-plugin `### {plugin} v{version}` sub-headings exactly as they appear under `## v{parent target}` in
      `CHANGELOG.md`. The summary paragraph appears once, at the top; the `## v{parent target}` heading is not carried into
      the body, and the summary is not repeated above the sub-headings. The release notes still show which child plugins
      changed and their new versions. Do not flatten or regroup the sub-headings.
      
      If there is no previous release (this is the first tag), omit the `**Full Changelog:**` compare line and keep only the
      `**Full changelog:**` blob link.
      
      If no merged PRs were found, replace the PR list with a single line:
      `* Direct commits since {prev tag}; see the full changelog below.` and still include the narrative and links. This is
      the release-body form of the no-PR fallback. The `CHANGELOG.md` no-PR fallback is different by design: it lists each
      commit under a `### Commits in this release` subsection (see [changelog-rules.md](./changelog-rules.md)). The body
      summarizes; the changelog enumerates.
      
      ## PR line format
      
      One bullet per merged pull request, sorted by merge time ascending (newest merge last):
      
      ```
      * {PR title} by @{author login} in {PR url}
      ```
      
      This is the same format GitHub's auto-generated notes use and the same format prior Han releases used. Authors are
      attributed by GitHub login with a leading `@`. The PR list is repo-wide and is not split per plugin.
      
      In the GitHub release body, mentions stay as bare `@login`: GitHub auto-links them to profiles and notifies the people.
      (In `CHANGELOG.md` the mentions are explicit markdown links, because a rendered blob does not auto-link. See
      [changelog-rules.md](./changelog-rules.md).)
      
      ## Issues closed section
      
      The `## Issues closed` section lists every issue closed by this release's PRs, relating each issue to the fix and
      crediting the people involved. Omit the section entirely when no issues were closed. One bullet per issue:
      
      ```
      * {issue title} (#{issue number}) — opened by @{opener}, fixed in #{PR number} by @{worker}, @{worker}; thanks to @{contributor}
      ```
      
      `{opener}` is the person who opened the issue, `{worker}` are the people who worked on the closing PR (author,
      reviewers, commit/co-authors), and `{contributor}` are the people who contributed meaningfully to the issue: those who
      left a substantive comment (reactions and drive-by comments like a bare `+1`, `bump`, `thanks`, or an emoji-only reply
      do not count), with the opener and PR workers removed and the `; thanks to ...` clause omitted when empty. Exclude bot
      accounts. Mentions stay bare `@login` here, the same as the PR list.
      
      ## Full-changelog links
      
      Both links name a git ref, so both carry the parent's **tag**, not the plain version.
      
      **Blob link** points at the `CHANGELOG.md` section for this exact version, pinned to the tag:
      
      ```
      https://github.com/{owner}/{repo}/blob/{parent plugin name}--v{parent target}/CHANGELOG.md#{anchor}
      ```
      
      Compute `{anchor}` from the changelog **heading** text, which stays `v{parent target}`: lowercase it, then delete every
      character that is not `a-z`, `0-9`, or `-`. Dots are deleted. Examples: `v3.0.0` → `v300`; `v3.1.0` → `v310`;
      `v2.10.1` → `v2101`. The anchor is computed from the heading and never from the tag, so every anchor in every
      already-published release stays valid.
      
      **Compare link** is GitHub's standard range link from the previous release tag to this one:
      
      ```
      https://github.com/{owner}/{repo}/compare/{prev}...{parent plugin name}--v{parent target}
      ```
      
      `{owner}/{repo}` comes from `gh repo view --json nameWithOwner`. `{prev}` is the previous release's tag as Step 2
      resolved it, so on the transition release the two sides carry different namings (`v4.6.0...han--v5.0.0`). That is
      correct: a compare link takes any two refs. Omit the compare link entirely when there is no previous tag.
      
      ## Publish vs. draft, and idempotency
      
      Every `gh release` call names the **tag**, `{parent plugin name}--v{parent target}`. Only `--title` carries the plain
      version.
      
      - **Publish (default):**
        `gh release create {parent plugin name}--v{parent target} --verify-tag --title "v{parent target}" --latest --notes-file {file}`.
      - **Draft (only when explicitly requested):** add `--draft`. Do not pass `--latest` with `--draft`. The draft flag holds
        back the release page only; the tags are already created and pushed by then, and none of them can be moved.
      - **Release already exists for the tag:** do not create a second one. Update it in place with
        `gh release edit {parent plugin name}--v{parent target} --notes-file {file}` (add `--draft=false` only if the operator
        asked to publish an existing draft). Report that the release was updated rather than created.
      
      **`--verify-tag` is not optional.** Without it, `gh release create` silently creates a missing tag from the latest state
      of the default branch, minting a permanent tag at a commit nobody released. GitHub does not allow a published release's
      tag to be modified or deleted afterwards.
      
      Write the assembled body to a temp file with the Write tool and pass it via `--notes-file`. Do not build the body with
      shell `echo`/`printf`.
      
    • version-plan-rules.md 3.8 KB
      # Per-Plugin Version Plan Rules
      
      This file defines how a release run establishes each plugin's baseline version, classifies the size of its change, and
      decides its target version. It is loaded by `SKILL.md` Step 3, which applies the governing rules there and then confirms
      the resulting plan at 3d.
      
      ## 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**.
      
  • scripts
    • remote-tag-state.bats 5.6 KB · in bundle
    • remote-tag-state.sh 4 KB
      #!/usr/bin/env bash
      #
      # Classify each of a release's expected tags against what the remote actually holds.
      #
      # The plugin tagging command never consults the remote, so a release that walks every
      # plugin has no way to tell three states apart on its own: a tag already published, a
      # tag that was created locally and never pushed, and a tag somebody else already took
      # at a different commit. Those three need different behavior, and one of them is
      # unrecoverable, so this reads the remote once and says which is which.
      #
      # Usage: remote-tag-state.sh <release-commit-sha> <tag>...
      #
      # THE PEEL IS THE POINT. `git ls-remote --tags` reports the *tag object* SHA for an
      # annotated tag, not the commit it points at, and `claude plugin tag` creates annotated
      # tags. Comparing the unpeeled value against a commit therefore reports "different
      # commit" for every tag ever created this way. Git publishes the commit alongside, in a
      # second `refs/tags/<name>^{}` line, so the peeled line wins wherever it exists.
      #
      # Output is line-oriented and tab-separated, one row per requested tag, in the order the
      # tags were given:
      #
      #   <tag>\t<state>\t<sha>
      #
      #   absent                  neither on the remote nor local; create and push it
      #   remote-at-commit        on the remote at the release commit; skip it
      #   remote-at-other-commit  on the remote at a different commit; the CALLER decides what
      #                           that means, because this script cannot see the version plan.
      #                           An unchanged plugin keeps its version and so keeps the tag an
      #                           earlier release gave it, which is the normal resting state; a
      #                           tag being created right now is a collision and unrecoverable.
      #   local-only              on this machine only; push it, never treat it as a skip
      #
      # The sha column carries the commit the tag resolves to, or `-` when the tag is absent.
      #
      # Exit: 0 every requested tag classified, 2 could not reach the remote. A tag in any
      # state is still a successful classification, so the caller reads the rows rather than
      # the exit status to decide what to do. Only an unreadable remote is an error here,
      # because a run that cannot see the remote must not guess.
      
      set -euo pipefail
      
      RELEASE_COMMIT="${1:?release commit sha required}"
      shift || true
      
      if [ "$#" -eq 0 ]; then
        echo "error: at least one tag name required" >&2
        exit 2
      fi
      
      REMOTE="${HAN_RELEASE_REMOTE:-origin}"
      
      # One network round trip for every tag, rather than one per tag. `--tags` restricts the
      # listing to refs/tags/, and the output carries both the tag-object line and the peeled
      # `^{}` line for an annotated tag.
      if ! remote_listing="$(git ls-remote --tags "$REMOTE" 2>/dev/null)"; then
        echo "error: could not read tags from remote '$REMOTE'" >&2
        exit 2
      fi
      
      # Resolve one tag name against the remote listing. Prefers the peeled line, so an
      # annotated tag reports the commit and a lightweight tag reports its only line.
      # Prints nothing when the remote does not carry the tag.
      remote_sha_for() {
        local tag="$1" peeled plain
        peeled="$(printf '%s\n' "$remote_listing" | awk -v ref="refs/tags/$tag^{}" '$2 == ref { print $1; exit }')"
        if [ -n "$peeled" ]; then
          printf '%s' "$peeled"
          return 0
        fi
        plain="$(printf '%s\n' "$remote_listing" | awk -v ref="refs/tags/$tag" '$2 == ref { print $1; exit }')"
        printf '%s' "$plain"
      }
      
      for tag in "$@"; do
        remote_sha="$(remote_sha_for "$tag")"
      
        if [ -n "$remote_sha" ]; then
          if [ "$remote_sha" = "$RELEASE_COMMIT" ]; then
            printf '%s\t%s\t%s\n' "$tag" "remote-at-commit" "$remote_sha"
          else
            printf '%s\t%s\t%s\n' "$tag" "remote-at-other-commit" "$remote_sha"
          fi
          continue
        fi
      
        # Not on the remote. `git rev-list -n1` peels a local annotated tag the same way the
        # `^{}` line does for a remote one, so both halves of the comparison mean the commit.
        if local_sha="$(git rev-list -n1 "$tag" 2>/dev/null)" && [ -n "$local_sha" ]; then
          printf '%s\t%s\t%s\n' "$tag" "local-only" "$local_sha"
        else
          printf '%s\t%s\t%s\n' "$tag" "absent" "-"
        fi
      done
      
  • SKILL.md 33.8 KB
    ---
    name: han-release
    description: >
      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 the gh CLI, jq, the claude CLI, and a clean git checkout. This is a
      repository-maintenance skill for the Han repo itself, not a general review or PR skill — use code-review for local
      review, post-code-review-to-pr to post a PR review, and update-pr-description for PR bodies.
    argument-hint: "[pause before publishing] [draft] [optional release context]"
    allowed-tools:
      Read, Edit, Write, Glob, Grep, Agent, Bash(git *), Bash(gh *), Bash(jq *), Bash(which *), Bash(grep *), Bash(sed *),
      Bash(head *), Bash(command claude *)
    ---
    
    <!--
    `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.
    -->
    
    ## 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 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 `./`.
    
    ## Step 1: Parse the invocation and check release safety
    
    1. **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.
    
    2. **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.
    
    3. **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
    
    1. **Resolve `prev` from the two tag probes, in this order:**
       1. `latest parent tag` when it is non-empty. This is the normal case from the second per-plugin release onward.
       2. 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.
       3. 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 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`.
    
    2. **Commit range.** With a previous tag: `${prev}..HEAD`. First release: the full history (`HEAD` with no range base).
    
    3. **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.
    
    4. **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](./references/release-notes-format.md) and
       [references/changelog-rules.md](./references/changelog-rules.md).
    
    5. **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.
    
    6. **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](./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.
    
    ## 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`](../../../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.
    
    Classify each plugin, compute its bump level, and decide its target by following
    [references/version-plan-rules.md](./references/version-plan-rules.md). That file holds the baseline lookup for a
    parent, an existing child, and a new child (3a); the major, minor, and patch classification for a changed child and for
    the parent (3b); and the ahead-path versus compute-path decision that determines which plugins need confirmation below
    (3c).
    
    ### 3d. Confirm the plan (conditional gate)
    
    `target = parent target` drives the tag `{parent plugin name}--v{parent target}`.
    
    This gate is conditional and often does not fire. It is **not** the gate that approves the tags; that one is Step 9 and
    it always fires.
    
    - **If no plugin needs confirmation** (every changed plugin was already ahead, plus the new plugins): the plan is fully
      determined. Do not prompt. Record it for the Step 7 summary and continue.
    - **If one or more plugins need confirmation**: present the whole plan in **one** `AskUserQuestion`
      (`header: "Release versions"`). 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.
    
    1. **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.
    
    2. **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 3c in `references/version-plan-rules.md`, and any plugin
    the operator edited at 3d), set both files so they read `target`:
    
    1. Set `{source}/.claude-plugin/plugin.json` `version` to that plugin's `target` (Edit).
    2. 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](./references/changelog-rules.md) exactly, writing the narrative in the voice at
    [writing-voice.md](../../../han-communication/references/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.
    
    1. **Does `## v{parent target}` already exist in `CHANGELOG.md`?** Search for the literal heading.
    
    2. **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](./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.
    
    3. **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](./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](./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.
    
    1. **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.
    
    2. **Record `{release commit}`.** `git rev-parse HEAD`. Every tag must point here, and Step 9 re-checks it.
    
    3. **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.
    
    1. **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 classified at 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.
    
    2. **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.                 |
    
    3. **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.
    
    4. **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.
    
    5. **This gate always fires.** Unlike Step 3d it has no skip condition, because the tags are permanent whether or not a
       version needed computing.
    
    6. **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.
    
    7. **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.
    
    8. **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
    
    1. **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.
    
    2. **Publish**, per [references/release-notes-format.md](./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.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related