# Hv Release

> Cut a release — walk the project's per-project release checklist (`.hv/RELEASE.md`) as a preflight gate, bump version (major/minor/patch), generate categorized release notes from commits since the last tag, prepend a section to CHANGELOG.md, create an annotated git tag, push, publish a release on GitHub or GitLab if origin is set, and offer to close any upstream issues still open for shipped items. Use on "release", "cut a release", "tag a release", "ship X.Y.Z".

- Skill: `l4ci/hv-release` (Agent Skill)
- Install (CLI): `npx skillmds@latest add l4ci/hv-release`
- Raw SKILL.md: https://api.skillmd.com/api/skills/l4ci/hv-release/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: l4ci (https://skillmd.com/u/l4ci)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/l4ci/hv-release

---


**Print the banner below verbatim before any other action — skip if dispatched as a subagent.** See `references/banner-preamble.md`.

```
════════════════════════════════════════════════════════════════════════
  🏷️  hv-release  ·  bump version, tag, notes, CHANGELOG, publish
  triggers: "release", "cut release", "ship X.Y.Z"  ·  pairs: hv-ship
════════════════════════════════════════════════════════════════════════
```

# hv-release — Cut a Release

## Configuration

Read from `.hv/config.json` (all keys optional — defaults apply if absent):

| Key | Default | Notes |
|---|---|---|
| `release.versionFile` | (auto-detect) | Explicit path override; skips auto-detect search |
| `release.changelogPath` | `CHANGELOG.md` | Project-root relative |
| `release.checklistPath` | `.hv/RELEASE.md` | Per-project release checklist walked in Step 1.5; absent = offer scaffold |
| `release.tagPrefix` | `v` | Set to `""` for unprefixed tags |
| `release.draft` | `false` | Pass `--draft` to `gh`/`glab` |
| `release.requireCleanTree` | `true` | Set `false` to allow dirty releases (testing only) |
| `release.confirmLargePushCommits` | `10` | Threshold (commits) above which auto/loop autonomy still confirms before pushing unpushed HEAD |

## Step 1 — Preflight & Guard

```bash
.hv/bin/hv-preflight
```

See `docs/reference/preflight.md` for exit-code handling.

Then verify:

1. **Clean tree** — run `git status --porcelain`. If output is non-empty and `release.requireCleanTree` is `true` (default), stop: *"Working tree is dirty. Commit or stash changes first, or set `release.requireCleanTree: false`."* Show `git status -s` in the error.
2. **On main/trunk** — `git rev-parse --abbrev-ref HEAD`. If not `main`, `master`, or `trunk`, stop with a one-liner.
3. **HEAD pushed** — `git rev-parse HEAD` vs `git rev-parse @{u}`. If they differ, branch on `autonomy.level` from `.hv/config.json`:

   - `"auto"` or `"loop"` — count unpushed commits via `git rev-list @{u}..HEAD --count`.
     - If the count is < `release.confirmLargePushCommits` (default 10), silently run `git push origin <current-branch>` and continue. A release intends to ship the local commits; an unpushed HEAD is part of the release, not a pre-flight error.
     - If the count is ≥ `release.confirmLargePushCommits`, interject ONE `AskUserQuestion` regardless of autonomy:
       - **Header:** `"Large push"`
       - **Question:** *"<N> unpushed commits about to be pushed as part of this release. Continue?"*
       - **Options:**
         1. `"Push and continue (Recommended)"` — runs `git push origin <current-branch>`, then proceed
         2. `"Abort"` — stop without writing anything
       - Plain-text fallback: *"Push <N> commits and continue, or abort?"*
   - `"off"` (default) — use `AskUserQuestion`:
     - **Header:** `"Unpushed"`
     - **Question:** *"HEAD has unpushed commits. Push them as part of this release?"*
     - **Options:**
       1. `"Push and continue (Recommended)"` — runs `git push origin <current-branch>`, then proceed
       2. `"Abort"` — stop without writing anything
     - Plain-text fallback: *"Push and continue, or abort?"*

**Initialize task list.** Follow the canonical pattern in `references/task-list-init.md` — load `TaskCreate(…)` via `ToolSearch select:TaskCreate,TaskUpdate` if needed, then create one task per phase below.

Phases:

1. *Preflight & guard* — clean tree, on main, HEAD pushed (Step 1)
2. *Project checklist* — walk `.hv/RELEASE.md` items as gates (Step 1.5)
3. *Bump version* — version source detected and incremented (Steps 2–4)
4. *Generate notes* — categorized release notes drafted from commits (Steps 5–7)
5. *Tag & push* — annotated tag created, branch + tag pushed (Steps 8–12)
6. *Publish* — `gh`/`glab` release published if origin matches (Step 13)
7. *Close upstream issues* — manual gate to close any GH/GL issues still open for shipped items (Step 13.4)
8. *Post-release nudges* — summary + autonomy-aware chaining (Step 14+)

## Step 1.5 — Project Checklist

Per-project release steps that aren't (and shouldn't be) hardcoded into the skill — sibling version files, lockfiles, docs version refs, infra rollouts, anything project-specific. Lives in `release.checklistPath` (default `.hv/RELEASE.md`). Tracked by default — shared with the team like any other source file.

Skip the whole step in `--dry-run` mode (print the parsed items and `DRY RUN — checklist walk skipped.` instead).

**File absent.** Branch on `autonomy.level` from `.hv/config.json`:

- `"off"` (default) — `AskUserQuestion`:
  - **Header:** `"Checklist"`
  - **Question:** *"No `<release.checklistPath>` found. Scaffold a starter checklist now, or continue without?"*
  - **Options** (single-select):
    1. `Scaffold starter (Recommended)` — write the template below, open it for the user to edit, then re-read and walk it
    2. `Continue without` — note it in the Step 14 summary as *"no project checklist"*; continue to Step 2
    3. `Abort`
  - Plain-text fallback: *"Scaffold checklist, continue without, or abort?"*
- `"auto"` or `"loop"` — skip silently. No checklist means no gate; do not interrupt unattended runs to scaffold one.

**Starter template** (write verbatim on Scaffold):

```markdown
# Release Checklist

Each `- [ ]` line is a gate `/hv-release` walks before bumping the version. Edit freely — nothing here is hardcoded. Items marked `- [x]` are ignored. Append `(manual)` to any item that must interject even in `autonomy.level: auto`/`loop`.

- [ ] Sibling version-bearing files are in sync (e.g., `.claude-plugin/marketplace.json`, lockfiles, docs version refs)
- [ ] CI is green on the release branch
- [ ] Migration notes for users on the prior version are written

(Add project-specific items below.)
```

**File present.** Parse every line matching `^\s*-\s+\[\s*\]\s+(.+)$` as a gate (in file order). Lines matching `- [x]` are skipped. If zero gates, print *"Checklist has no open items — continuing."* and proceed.

For each gate, branch on `autonomy.level`:

- `"off"` — always interject:
  - **Header:** `"Checklist"`
  - **Question:** *"Checklist item: \<text\>. Done?"*
  - **Options** (single-select):
    1. `Yes, continue (Recommended)` — proceed to next item
    2. `Fix it now and continue` — pause for the user; re-ask the same item afterward
    3. `Skip this item` — note in the Step 14 summary as `skipped: <text>`
    4. `Abort release` — stop with *"Release aborted at checklist item: \<text\>. Nothing written."*
  - Plain-text fallback: *"Done, fix now, skip, or abort?"*
- `"auto"` or `"loop"` — auto-acknowledge items whose text does **not** end with `(manual)`; interject (using the off-mode prompt above) for items that do. This lets users mark sensitive items (`Push staging migration (manual)`) as always-confirmed even in unattended runs.

After all gates pass, continue to Step 2.

## Step 2 — Detect Version Source

```bash
.hv/bin/hv-release-detect-version
```

Parse JSON output `{file, version, kind}`:

- `file` — absolute path to the version-bearing file
- `version` — current semver string (e.g., `1.10.0`)
- `kind` — `plugin-json` | `package-json` | `pyproject` | `cargo` | `plain`

If exit 1, surface the stderr message verbatim and stop. If output indicates multiple candidates, use `release.versionFile` to disambiguate — document that the user should set it in `.hv/config.json`.

## Step 3 — Determine Bump Type

Accept an arg if the user supplied one: `major`, `minor`, `patch`, or an explicit `X.Y.Z` string. Also accept `--dry-run` flag — run all steps but skip all writes, commits, tags, and pushes; print what would happen.

If no arg, first generate the commit-range preview for a recommendation:

```bash
.hv/bin/hv-release-changelog-from-commits <prev-tag>..HEAD
```

Scan output for bucket headings:
- `## Breaking` present → recommend `major`
- `## New` present (no Breaking) → recommend `minor`
- else → recommend `patch`

Then use `AskUserQuestion`:
- **Header:** `"Bump type"`
- **Question:** *"Current version: `<current>`. What bump type?"*
- **Options** (single-select, mark recommended):
  1. `patch — <current> → <X.Y.Z+1>` (mark Recommended if applicable)
  2. `minor — <current> → <X.Y+1.0>`
  3. `major — <current> → <X+1.0.0>`
  4. `Explicit version` — prompt for the exact string via Other
  5. `Abort`

Plain-text fallback: *"Bump type? (major / minor / patch / X.Y.Z / abort)"*

If the user picks explicit, validate: must be valid semver and strictly greater than current. If invalid, stop with an error.

## Step 4 — Compute New Version

Use the helper's `--dry-run` mode so version computation has one source of truth — Step 8 will repeat the call without `--dry-run` to actually write the file.

```bash
new_version=$(.hv/bin/hv-release-bump-version --dry-run <file> <kind> <bump>)
```

`<file>` and `<kind>` come from Step 2; `<bump>` is `patch`, `minor`, `major`, or the explicit semver from Step 3. The helper validates the bump (must be valid semver and strictly greater than `current` for the explicit path) and prints the new version. If validation fails, the helper exits 1 with a message — surface it and stop.

Store `new_version` for use in Steps 6, 7, 8, 9, 10, 11, 12, 13, 14.

If BREAKING CHANGE commits were detected (Step 3 scan) but the user chose `patch` or `minor`, interject with `AskUserQuestion` before continuing:
- **Header:** `"Escalate"`
- **Question:** *"Commits contain `BREAKING CHANGE:` footers but bump type is `<chosen>`. Escalate to major?"*
- **Options:** `Escalate to major (Recommended)` / `Keep <chosen>` / `Abort`
- Plain-text fallback: *"Escalate to major, keep <chosen>, or abort?"* — default to Recommended (Escalate to major) on ambiguity, naming it explicitly. See `references/ask-user-question-fallback.md`.

## Step 5 — Detect Previous Tag

```bash
git describe --tags --abbrev=0 2>/dev/null || true
```

If empty (no tags exist), range = full history; set `prev_tag = ""`. Note this in the Step 14 summary. If a tag exists, set `prev_tag = <value>` and `range = <prev_tag>..HEAD`.

## Step 6 — Generate Release Notes

```bash
.hv/bin/hv-release-changelog-from-commits <range>
```

Captures categorized Markdown (buckets in helper-emit order: Breaking, New, Fixed, Performance, Changed, Documentation, Other). Merge commits are filtered by the helper.

**Compact dense buckets.** When a bucket has 3+ entries that clearly belong to the same feature or concern (e.g., 7 `feat:` commits all touching one new skill), replace the raw list with a single model-written summary line capturing the theme, optionally followed by 1–2 bullets naming the highest-impact pieces (a breaking change, a flag flip, a new public surface). Buckets with fewer than 3 entries stay as-is — the noise floor is low and the model adds little value. The helper's job is the raw categorization; *editorial collapse is yours*.

Append stats line — run:

```bash
git diff --shortstat <prev_tag>..HEAD   # omit <prev_tag>.. when no previous tag
```

Format as `## Stats\n<N commits, M files changed, +X −Y lines>`.

Build compare URL (only when `prev_tag` is non-empty):

```bash
.hv/bin/hv-release-detect-host
```

- `github` or `github-enterprise` → `https://<host>/<owner>/<repo>/compare/<prev_tag>...v<new_version>`
- `gitlab` or `gitlab-self-hosted` → `https://<host>/<owner>/<repo>/-/compare/<prev_tag>...v<new_version>`
- `none` → omit compare URL

Append `**Full changelog:** <compare-url>` to the notes (or omit if no prev tag or no host).

Prepend a model-written one-line summary scoped to the top 2-3 themes from the buckets. This summary also becomes the release title suffix in Step 13.

**Run the self-audit before Step 7 displays the draft.** Compact bucket summaries, the prepended one-line summary, and any other model-written prose in the notes are user-facing artifacts that ship to GitHub/GitLab and live in CHANGELOG.md indefinitely. Apply the rule sheet and self-audit pass in `references/humanizing-prose.md` to the assembled notes silently — the user sees the post-audit draft, not the pre-audit one.

## Step 7 — Review Notes

Display the full notes draft to the user, then use `AskUserQuestion`:

- **Header:** `"Notes"`
- **Question:** *"Release `v<new_version>` — notes look good?"*
- **Options** (single-select):
  1. `Looks good (Recommended)`
  2. `Edit` — accept replacement text via Other (free-text replaces the draft verbatim)
  3. `Abort release`

Plain-text fallback: *"Proceed, edit, or abort?"*

If the user picks **Edit**, accept the replacement text and store it. Re-display the edited notes before continuing (no second prompt — one edit pass only).

If the user picks **Abort**, stop with one line: *"Release aborted. Nothing written."*

Write the (possibly edited) notes to a temp file:

```bash
NOTES_FILE=$(mktemp /tmp/hv-release-notes.XXXXXX.md)
```

## Step 8 — Update Version File

```bash
written=$(.hv/bin/hv-release-bump-version <file> <kind> <bump>)
```

Same arguments as Step 4's `--dry-run` call; this writes the file and prints the new version. Assert `written == new_version`; if they diverge, stop with an error (the file may be partially modified — surface the discrepancy and let the user investigate).

Skip in the skill's `--dry-run` mode (different from the helper's flag); print what would be written instead.

## Step 9 — Update CHANGELOG.md

```bash
.hv/bin/hv-release-update-changelog <new_version> "$NOTES_FILE" [--path <release.changelogPath>]
```

If exit 1 (version section already exists), surface the error and stop — do not proceed to commit.

Skip in `--dry-run` mode; print what would be prepended instead.

## Step 10 — Commit

```bash
git add <version-file> <release.changelogPath>
git commit -m "chore: release v<new_version>"
```

Skip in `--dry-run` mode.

## Step 11 — Tag

Check whether `user.signingkey` is set:

```bash
git config --get user.signingkey 2>/dev/null
```

If set, use `git tag -s`; otherwise `git tag -a`:

```bash
git tag [-a|-s] v<new_version> -F "$NOTES_FILE"
```

Skip in `--dry-run` mode; print the tag command that would run.

## Step 12 — Push

> **Manual gate — filing a public artifact.** Pushing the tag creates externally-visible state. This step is **always manual** — never auto-invoked, regardless of `autonomy.level`. Step 7 (Review Notes) already gathered explicit user approval of the release notes; the push only fires after that approval. See `references/manual-gates.md`.

```bash
git push origin <current-branch> v<new_version>
```

Single call — pushes both the commit and the tag atomically. If this fails (e.g., origin not set), stop and print the tag SHA for manual recovery.

Skip in `--dry-run` mode.

## Step 13 — Create Remote Release

> **Manual gate — filing a public artifact.** Publishing the release on GitHub/GitLab creates externally-visible state. This step is **always manual** — never auto-invoked, regardless of `autonomy.level`. The user already approved the release notes in Step 7; this step is downstream of that approval. See `references/manual-gates.md`.

```bash
.hv/bin/hv-release-detect-host
```

Branch on the helper's `host` output. Host-specific command blocks live in `references/release-hosts.md` — substitute `<new_version>`, `$NOTES_FILE`, and the summary verbatim.

| `host` | Section in `references/release-hosts.md` |
|--------|------------------------------------------|
| `github`, `github-enterprise` | `## github or github-enterprise` |
| `gitlab`, `gitlab-self-hosted` | `## gitlab or gitlab-self-hosted` |
| `none`                        | `## none`                         |

Add new host values to both the helper and the reference together.

Skip the whole step in `--dry-run` mode; print the `gh`/`glab` command that would run.

## Step 13.4 — Close Upstream Issues

Mirrors `/hv-ship` Step 6c, scoped to the whole release range rather than a single ship cycle. Closes upstream issues that landed in this release but stayed open because the work was pushed directly to main (skipping `/hv-ship` entirely) or because `/hv-ship` chose "leave open" at the time.

Skip the whole step in `--dry-run` mode (nothing was actually tagged or pushed).

**1. List candidates.**

```bash
.hv/bin/hv-issues-imported --open-only
```

The `--open-only` flag drops entries whose upstream issue is already closed (or whose state can't be resolved — missing CLI, deleted issue), so the gate only surfaces issues still actually open. If the resulting JSON array is empty, skip the rest of this step silently.

**2. Manual gate.**

> **Manual gate — closing public upstream issues.** Closing the issues posts a tracking comment and changes their state on the remote — externally-visible. This step is **always manual** — never auto-invoked, regardless of `autonomy.level`. The release already published; this step decides whether to close the upstream issues too. See `references/manual-gates.md`.

**3. Ask the user.**

Invoke `AskUserQuestion` (single-select, ≤4 options):

- Header: `"Close"`
- Question: *"Close N upstream issue(s) released in `v<new_version>`? (`<comma-separated list of #N>`)"*
- Options: `"Yes, close all"`, `"Pick subset"`, `"No, leave open"`

This gate is **always manual** — never auto-picked in loop mode. Stop the loop here and wait for the user's answer.

**4. On "Yes, close all":** dispatch parallel `hv-issues-close` calls (one per candidate, all in a single batch of tool calls), passing the release commit SHA from Step 10:

```bash
.hv/bin/hv-issues-close --issue <N> --commit <release-commit-sha> --item <ID> [--repo <name>]
```

Pass `--repo` only for entries whose `repo` field is non-null (umbrella mode).

**5. On "Pick subset":** invoke a second `AskUserQuestion` (multiSelect, ≤4 candidates per call; chunk if N>4):

- Header: `"Pick issues"`
- Question: *"Which issue(s) should be closed?"*
- Options: one entry per candidate formatted as `"#N (item <ID>)"`

Then dispatch parallel `hv-issues-close` calls for each selected entry as in step 4.

**6. On "No, leave open":** print:

```
Skipping upstream issue close — N issue(s) left open. Run `gh issue close <N>` / `glab issue close <N>` manually if desired.
```

## Step 13.5 — Docs After-Work (Nudge or Auto-Invoke)

Read `docs.afterWork` from `.hv/config.json` (default `false`). If it's `false`, skip this step entirely. Users opt in via `/hv-config` or by running `/hv-ship --docs` manually once.

When the flag is on, a release is a natural docs trigger — release notes and CHANGELOG entries are user-facing artifacts that often imply other docs (READMEs, getting-started guides, reference pages) need a refresh. Skip in `--dry-run` mode. Don't repeat in the same session.

When triggered, branch on `autonomy.level`:

- `"off"` — append one line to the Step 14 summary — *"Release shipped. Run `/hv-ship --docs` to review and update public docs (after-work mode)."*
- `"auto"` or `"loop"` — **dispatch `hv-ship --docs` via `Skill` immediately — no prompt, no confirmation, no "want me to" question.** Pass a brief naming the new version, the bump type, and a one-line summary of what shipped (from Step 6's release notes title).

If `<docs.path>/` doesn't exist or is empty, `/hv-ship`'s Docs Mode after-work flow self-skips (printing a one-line "not yet initialized" notice) — no extra check needed here.

## Step 14 — Summary

Print one compact block:

```
Released v<new_version>
  Tag:      v<new_version> (<tag-SHA>)
  Commit:   <commit-SHA>
  CHANGELOG: <release.changelogPath>
  Remote:   <release-URL or "skipped">
  Checklist: <N> done / <M> skipped (or "no project checklist" / "skipped in dry-run")
  [No previous tag — full history used as range.]   ← only when no prev tag
```

If any checklist items were skipped, append a `Skipped checklist items:` block listing each on its own line.

In `--dry-run` mode, prefix the block with `DRY RUN — no changes written.`

## Edge Cases

- **No previous tag** — full history range; add the one-liner to the Step 14 summary. Omit compare URL from notes.
- **Multiple version files** — currently first-match wins; set `release.versionFile` in `.hv/config.json` to pin the file explicitly.
- **Working tree dirty** — fail at Step 1 unless `release.requireCleanTree: false`; show `git status -s` in the error message.
- **BREAKING CHANGE with patch/minor bump** — escalate via `AskUserQuestion` in Step 4 before any writes.
- **`gh`/`glab` not installed but origin matches** — fail at Step 13 *after* tag push; print recovery: `gh release create v<X.Y.Z> --notes-file <path>` and the push-delete command to revert the tag if needed: `git push --delete origin v<X.Y.Z>`.
- **No origin** — Step 13 silently skipped; tag and CHANGELOG still committed locally.
- **CHANGELOG section already exists for this version** — helper exits 1 at Step 9; surface the error and stop.
- **Existing CHANGELOG.md without `# Changelog` header** — helper preserves existing content; inserts after H1 if present, else prepends.
- **Compare URL when no previous tag** — omit the `Full changelog:` line from the notes.
- **Checklist absent in `auto`/`loop`** — silently skip Step 1.5; do not interrupt unattended runs to scaffold. The Step 14 summary notes *"no project checklist"*.
- **Checklist item user-skipped** — proceed, but list the skipped text under `Skipped checklist items:` in the Step 14 summary so the release record is honest.

## Rules

- Never bump without an explicit user-confirmed bump type.
- Never push the tag without the user reviewing and approving release notes (Step 7).
- Never duplicate a CHANGELOG section — the helper enforces this; stop if it exits 1.
- Atomic writes only — all file mutations go through the helpers which use atomic write semantics.
- Surface failures from any helper immediately; do not continue past a non-zero exit.
- `--dry-run` skips all writes, commits, tags, and pushes — output shows what would happen.
- Never hardcode project-specific release steps into the skill — they live in `release.checklistPath` per project. Step 1.5 walks the file; the skill itself stays generic.

## References

- [`references/banner-preamble.md`](../references/banner-preamble.md) — Banner-print rule shared by every skill.
- [`references/manual-gates.md`](../references/manual-gates.md) — Steps that must always be manual regardless of autonomy.level (PR opening, upstream issues, runlog dispatch).
- [`references/release-hosts.md`](../references/release-hosts.md) — Release-host detection and routing (GitHub / GitLab / origin-less).

