# Hermes Hookdeck Release

> Guides maintainers through releasing the hermes-hookdeck plugin to PyPI. Publishing a GitHub release is what triggers the publish, and the tag is the version — nothing in the repo is bumped. Validates the proposed version against SemVer from the actual change set, including the rule that a change to the bundled skill or plugin.yaml is a shipped change. Use when cutting a release, publishing to PyPI, drafting release notes, choosing vMAJOR.MINOR.PATCH, `gh release create`, or following the release checklist.

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

---


# hermes-hookdeck — release workflow

## Canonical documentation

Follow **[README.md](../../README.md) § Releasing** for the human steps
(GitHub UI). This skill adds **how the automation works**, the **gates that
must pass first**, and a **research loop** for drafting notes.

## The tag is the version

Nothing in the repo declares a version. setuptools-scm derives it from the git
tag at build time and bakes it into `hookdeck/_version.py` inside the wheel, so
`hookdeck.__version__` and the PyPI version are the same string by
construction and cannot drift.

The practical consequence: **you choose the number when you create the
release**, not in a commit beforehand. There is nothing to bump, and no
pre-release step that can be forgotten.

**PyPI is append-only.** A version number that has been published can never be
reused or replaced, even after a yank. That is why the number is worth getting
right before you publish — it is the one part of this that cannot be undone.

Between releases a source checkout reports a dev version derived from the last
tag (`0.1.2.dev4+g1a2b3c4`). That is expected, not a bug: it says "four commits
past v0.1.1".

## Agent checklist (end-to-end)

Follow **in order**. Items marked **gate** are blocking unless the maintainer
explicitly overrides.

- [ ] **Everything intended for the release is merged to `main`.** Nothing is
      released from a branch.
- [ ] **`PREV_TAG` / `NEW_TAG` confirmed** — `git tag --sort=-v:refname | head -1`
      for the current release; propose `NEW_TAG` and agree it.
- [ ] **Change set reviewed:** `git log PREV_TAG..origin/main` — read the full
      messages, not just subjects. Group for **user-facing** notes.
- [ ] **gate — SemVer:** `NEW_TAG` matches the **minimum** bump for the delta
      (see **SemVer** below). Stop and realign if under-bumped. Nothing else
      validates this — the tag *is* the version, so a wrong tag is a wrong
      release.
- [ ] **gate — CI green on `main`:** the tip of `main` has passing checks.
- [ ] **Release notes drafted** — see **Drafting release notes** and
      [references/release-notes-template.md](references/release-notes-template.md).
- [ ] **gate — maintainer approval** of tag name and notes. No surprise
      releases; publishing is irreversible on PyPI.
- [ ] **Publish** with `gh release create` (below), targeting `main`.
- [ ] **Confirm** the `Release` workflow succeeded and the version is on PyPI.

## What triggers a release

**Publishing a GitHub release** — [.github/workflows/release.yml](../../.github/workflows/release.yml)
runs on `release: [published]`. There is no separate approval step: publishing
the release *is* the decision to ship.

Pushing a bare tag does **nothing**. The tag is created by the release.

## What the workflow does

1. **build** — checks out the release's tag with full history (setuptools-scm
   needs the tag present to resolve the version), runs `ruff` and the full test
   suite, builds the wheel and sdist, and runs `twine check`.
2. **publish** — uploads to PyPI via Trusted Publishing (OIDC, no stored
   token), in the `pypi` environment.
3. **attach-artifacts** — attaches the wheel and sdist to the GitHub release
   you created, so it carries the artifacts that actually went to PyPI.

A failure in **build** means nothing was published; fix `main` and publish a
new release. A failure in **publish** can be re-run from the Actions tab
(`gh run rerun <id> --failed`) — do not create a second release for the same
version.

Because the version comes from the tag, a mistyped tag produces a real release
at the wrong number rather than an error. Check the tag before publishing; that
is the check the workflow can no longer do for you.

## SemVer: validate the proposed version

Check any proposed tag against what actually changed since `PREV_TAG`.

**The contract this package offers** is its configuration, its environment
variables, its CLI, its agent tools, and the Hermes surfaces it registers.

| Change since `PREV_TAG` | Bump | Examples |
|---|---|---|
| **Breaking** — an existing install stops working, or behaves differently without the operator changing anything | **MAJOR** | Renamed or removed config key or `HOOKDECK_EG_*` variable; a default that changes delivery behaviour; a removed `hermes hookdeck` subcommand; a renamed or removed `hookdeck_*` agent tool; a ledger schema change that an older row cannot satisfy |
| **New capability**, backward compatible | **MINOR** | New config option with a safe default; new subcommand or agent tool; a new route feature; support for a Hermes version that was not supported before |
| **Fixes and corrections**, no new capability, nothing breaks | **PATCH** | Bug fixes; corrections to the bundled skill or `plugin.yaml`; dependency bumps with no behaviour change; packaging metadata |

**A change under `hookdeck/` ships even when it is only prose.** The bundled
skill (`hookdeck/skills/`), `plugin.yaml` and the dashboard bundle are inside
the wheel, so correcting them is a **patch release**, not a docs-only change.

**A change outside `hookdeck/` usually does not ship.** `docs/`, `README.md`,
`AGENTS.md`, `tests/`, `skills/` at the repo root and `.github/` are not
packaged. A release containing only those has nothing for a user to install —
say so rather than cutting one.

Verify which of the two you are in rather than assuming:

```bash
git diff --stat PREV_TAG..origin/main -- hookdeck/
```

Empty output means nothing shipped.

**Agent behaviour:** state the **minimum** bump the change set requires, then
compare it to what was proposed. If they conflict, **do not treat the proposal
as authoritative** — explain the mismatch and recommend the correct version. If
it is genuinely ambiguous whether something breaks an existing install, **ask**.

## Publish with GitHub CLI (`gh`)

Create the release with `gh` rather than pushing a tag — a bare tag does not
trigger anything.

1. **Write the notes to a temp file** and register cleanup, so a failure does
   not leave it behind:

   ```bash
   NOTES_FILE="$(mktemp "${TMPDIR:-/tmp}/hermes-hookdeck-release-notes.XXXXXX.md")"
   trap 'rm -f "$NOTES_FILE"' EXIT
   ```

2. **Write** the final markdown body to `"$NOTES_FILE"`.

3. **Create the release**, targeting `main`:

   ```bash
   gh release create "vM.m.p" \
     --repo hookdeck/hermes-hookdeck \
     --target main \
     --title "vM.m.p" \
     --notes-file "$NOTES_FILE"
   ```

   Add `--prerelease` for `rc`, `a` or `b` versions, so the repository's
   "latest release" does not point at a release candidate.

4. **Watch it**: `gh run watch` — or `gh run list --workflow=release.yml --limit 1`.

**Requirements:** `gh` authenticated. Nothing needs to be bumped first — the
tag you pass here becomes the version. Do not put secrets in the notes file.

### Verify afterwards

```bash
pip download --no-deps -d /tmp/verify hermes-hookdeck==M.m.p
```

For a release that changed anything under `hookdeck/`, confirm the change is
really in the artifact rather than only in the repo — install it into a clean
venv and check the file, not the working tree.

## Drafting release notes

Use [references/release-notes-template.md](references/release-notes-template.md)
as the skeleton. **Include only headings with real content** — omit a section
rather than writing "None".

Write for someone who runs a gateway, not for someone who reads this repo. The
useful question is "what changes for me, and do I have to do anything?" — not
"which files moved".

- **Lead with anything that requires action.** Config changes, renamed
  variables, anything that alters delivery behaviour. If nothing does, say the
  upgrade is a drop-in.
- **A fix is only worth a bullet if the reader could have hit it.** Say what
  went wrong from the outside ("the bundled skill never loaded"), not what the
  code did.
- Always end with the compare link:
  `https://github.com/hookdeck/hermes-hookdeck/compare/<prev_tag>...<new_tag>`

**Contributors:** do not add a generic thanks block every release. Include one
only for a first-time contributor or an exceptionally large contribution.

## Research loop

1. **Tags:** `git tag --sort=-v:refname | head -5`. Confirm `PREV_TAG`.
2. **Commits:** `git log PREV_TAG..origin/main` — full messages. This repo
   writes the reasoning into commit bodies; that is the changelog source.
3. **Shipped or not:** `git diff --stat PREV_TAG..origin/main -- hookdeck/`.
4. **Group** by user impact, not by file.
5. **SemVer check** against the table above.
6. **CI:**

   ```bash
   gh api "repos/hookdeck/hermes-hookdeck/commits/$(git rev-parse origin/main)/check-runs" \
     --jq '.check_runs[] | "\(.name): \(.status)/\(.conclusion // "-")"'
   ```

   Every run must be `completed/success`. Do not release otherwise.

   Ask for **check-runs**, not `/status`. The latter reports the legacy commit
   Status API, which this repo does not use — with no statuses recorded it
   answers `pending` forever, so it reads as a red gate on a green `main`.

## Safety and governance

- **PyPI is append-only.** A published version cannot be reused, replaced, or
  truly deleted. Yanking hides it from resolvers; it does not free the number.
- **Do not release a red `main`.** The workflow runs the suite itself and will
  fail the build, but finding out during a release is the wrong time.
- **Do not under-bump.** Resolve a SemVer disagreement with the maintainer
  before publishing, not after.
- **The tag is unchecked.** Nothing compares it to the change set, so a typo
  (`v0.2.0` for a patch) ships at that number and burns it permanently.
- **Green tests are not proof of integration.** The suite runs against a stub
  this repo also owns. Both defects found after 200 green tests came from a
  real `hermes gateway run`. For a release touching the delivery path, say
  which level you actually exercised — see [AGENTS.md](../../AGENTS.md).

## Related files

| Topic | Location |
|---|---|
| Human release steps | [README.md § Releasing](../../README.md) |
| CI entrypoint | [.github/workflows/release.yml](../../.github/workflows/release.yml) |
| Version derivation | [pyproject.toml](../../pyproject.toml) `[tool.setuptools_scm]` |
| What is packaged | [pyproject.toml](../../pyproject.toml) `[tool.setuptools.package-data]` |
| Repo conventions | [AGENTS.md](../../AGENTS.md) |

