---
name: release
version: "1.0"
description: >
  Cut a new gflow-cli release — bump version, update CHANGELOG, tag, push, and back-merge.
---

# `/gflow:release` — Cut a new release

Follow this sequence verbatim. Every step matters.

> **Branch-protection note:** `main` blocks direct pushes. The release commit travels
> via a `chore/release-vX.Y.Z` branch PR. The signed tag is pushed independently
> (tag pushes bypass branch protection and trigger the CI release workflow immediately).
>
> **Source-branch note (read first):** `develop` is the integration branch — it carries
> ALL unreleased work and `main` usually lags it. The release branch is cut from
> **`develop`**, NOT `main`. The PR `chore/release-vX.Y.Z → main` then brings the full
> integration history onto `main`. Do not expect the work to already be on `main`.

## Inputs

Ask the user (if not already provided):

1. **Version** — the new version (e.g. `0.4.0`, `0.4.0a3`, `1.0.0rc1`). Use PEP 440 prerelease suffixes (`aN`, `bN`, `rcN`). If they don't know, run `/gflow:changelog` first and propose the next bump (PATCH for fixes only, MINOR for new features, MAJOR for breaks).
2. **Pre-release?** — prerelease versions stay marked as GitHub prereleases. Only the user can say when a release line is ready for the stable tag.

---

## Sequence

**1. Review what's queued.**

Run `/gflow:changelog` — confirm the `[Unreleased]` block is non-empty and accurate before proceeding.

**2. Verify (or triage) a clean working tree.**

```bash
git status --short
```

If empty, continue. If not, **triage before aborting** — do not blindly stop:

- **Auto-injected boilerplate** (e.g. a context-mode routing block appended to
  `CLAUDE.md` by an MCP plugin's SessionStart hook): this is plugin-injected, not
  project content — `git restore` it. Confirm with the user if unsure.
- **Build/temp artifacts** (e.g. a stray `tmp*.tar.gz` sdist at repo root): delete them.
- **Genuine uncommitted work:** STOP and tell the user to commit or stash on the
  appropriate branch (never commit straight to `develop`).

The tree must be clean before you create the release branch.

**3. Verify `develop` is the release source and up-to-date.**

The release is cut from `develop`, NOT `main` (see Source-branch note above).
Confirm direction explicitly — a backwards divergence means a prior back-merge was skipped.

```bash
git fetch origin
git rev-parse --abbrev-ref HEAD                      # expect "develop"
git rev-list --count HEAD..origin/develop            # local behind origin — expect 0
git rev-list --count origin/main..origin/develop     # develop AHEAD of main — expect > 0 (the work to release)
git rev-list --count origin/develop..origin/main     # main AHEAD of develop — expect 0
```

If not on `develop`: `git checkout develop && git pull origin develop`.
If local is behind origin: `git pull origin develop`.
**If `main` is AHEAD of `develop` (last count > 0): STOP.** A prior release skipped its
`main → develop` back-merge — recover first (see the `release-back-merge-gap-recovery`
memory) or the release branch will hit conflicts on `pyproject.toml` / `__init__.py` / `CHANGELOG.md`.

**4. Run quality gates.**

Run `/gflow:check` — all gates must pass. Abort if any fail.

**4b. Live-verify the release's user-facing features (REQUIRED gate).**

For every new/changed user-facing feature in this release, exercise it against
live Flow (credit-free wherever possible — image gen, entity attach, upscale, and
scene/timeline ops cost no Veo credits) and write the evidence to
`docs/LIVE_VERIFICATION_v<NEW_VERSION>.md` using the 5-layer ledger (file count +
magic bytes + dimensions/shape + structlog invariants + a user-confirmable
artifact). Add it to the "what was live-verified" entry in `docs/INDEX.md`. This
doc shipped for every release v0.7.0→v0.13.0, then lapsed for v0.14.0–v0.15.1 —
which is why it is now an explicit gate. If a feature genuinely cannot be verified
this cycle, record that and the reason in the doc; never silently omit it. Stage
the doc into the release-prep commit (step 11).

**5. Create a release branch off `develop` — in its own worktree, after telling every
other session.**

```bash
git worktree add -b chore/release-v<NEW_VERSION> .claude/worktrees/release-v<NEW_VERSION> origin/develop
cd .claude/worktrees/release-v<NEW_VERSION>
```

Before cutting, run `ListAgents` (or the equivalent in your harness) and message every
other session working on this repo: *"Cutting v<NEW_VERSION> from develop@<sha>; do not
push to develop until the back-merge lands."* Then cut in a **dedicated worktree** (the
repo convention is `.claude/worktrees/<slug>`), never in the shared checkout. The cut is
from `origin/develop`, which step 3 just verified local `develop` is not behind — a local
`develop` that goes stale later no longer matters (unpushed local commits on `develop`
are deliberately NOT in the release; push them first if they should be). Steps 6–13 run inside this worktree;
step 14 returns to the main checkout and removes it.

> **Why.** On 2026-09-05 the v0.68.0 release branch was cut in the shared checkout and
> then switched out from under the release runner by another session's `git checkout`,
> costing a recovery; separately, an unrelated PR landed on `develop` between the cut and
> the tag, so the signed tag had to be deleted and re-signed on a merged head (nothing
> had been pushed, so no public tag moved — step 12 now checks for this). A worktree
> makes the branch immune to sibling checkouts; the announcement makes the `develop`
> race visible instead of discovered at tag time.

This branch now contains all of `develop` (⊇ `main`) plus your release prep. All
release prep commits live here; the PR into `main` (step 14) carries the full
integration history forward.

**6. Bump the shared release version — SEVEN sites, and no single gate sees them all.**

This is the canonical list. It is the only one that spans all three gates; the previous
version of this step named four sites, `check_repo_hygiene.py`'s docstring named three,
and its own function checked five. A release engineer then met each omission as a gate
failure mid-release (#839). **If you add a version site, add it here.**

| # | Site | Caught by |
|---|---|---|
| 1 | `pyproject.toml` `[project].version` | `check_repo_hygiene._check_version_agreement` |
| 2 | `src/gflow_cli/__init__.py` `__version__` | same |
| 3 | `.codex-plugin/plugin.json` `"version"` | same |
| 4 | `uv.lock` (the `gflow-cli` package block) | same — re-resolved by `uv lock` |
| 5 | `server.json` `.version` **and** `.packages[*].version` (twice) | same |
| 6 | `plugins/gflow/.claude-plugin/plugin.json` `"version"` | `tests/test_plugin_manifests.py::test_plugin_version_tracks_pyproject` |
| 7 | `docker/Dockerfile` `ARG GFLOW_VERSION` | `tests/test_dockerfile_version_pin.py` |

`server.json` is the one that bites quietly: it is the MCP Registry's copy of our
metadata, and the registry publish carries whatever the ref says. Forget it and the
listing points at a superseded version with nothing downstream noticing.

**Do not bulk-replace the old version across the repo.** `docker/README.md` quotes
measured results (`gflow_cli : <version>`, `✅ measured`); rewriting those turns a
record of what was tested into a false claim. Bump declaration sites only — the seven
above — and leave measurement records alone unless the measurement was re-run.

Sites 1–3 and 6:

```toml
[project]
version = "<NEW_VERSION>"
```

```json
{
  "version": "<NEW_VERSION>"
}
```

**7. Bump package version** in `src/gflow_cli/__init__.py`:

```python
__version__ = "<NEW_VERSION>"
```

**8. Update version assertion tests** if present:

```bash
rg -n "__version__|<OLD_VERSION>|version assertion" tests src pyproject.toml .codex-plugin/plugin.json plugins/gflow/.claude-plugin/plugin.json
```

**9. Migrate CHANGELOG.**

- Move all entries under `## [Unreleased]` to a new `## [<NEW_VERSION>] — YYYY-MM-DD` section.
- Leave `## [Unreleased]` empty.
- Update the link footer. Match the repo's existing convention — every prior entry
  uses the `compare/vPREV...vNEW` form, so use that for the new version too (NOT the
  `releases/tag/` form), or `/gflow:doc-review` will flag the inconsistency:
  ```
  [Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v<NEW_VERSION>...HEAD
  [<NEW_VERSION>]: https://github.com/ffroliva/gflow-cli/compare/v<PREV_VERSION>...v<NEW_VERSION>
  ```

**9b. Update `docs/PROJECT_STATUS.md` — this is an ACTION, not a review finding.**

Rewrite the `## Current release` section to describe the release being cut, and add a
milestone-history row for its headline change. Demote the previous release into a
`<details><summary>vPREV — …</summary>` block rather than deleting it.

The file's own header says "Updated on every signed tag" — a promise that went unkept for
five consecutive releases, and again in v0.64.0, where the section still announced v0.63.0 as
current at tag time. It was caught only because a human council happened to read the file.
`scripts/ci/check_release_artifacts.py` now enforces it (violation 6): the version being cut
must appear in that section specifically, not merely somewhere in the file — every past
release is still named further down, so a whole-file search would pass on a fully stale
header. Run it before committing:

```bash
uv run python scripts/ci/check_release_artifacts.py
```

Doing this at step 9b rather than discovering it at step 10 is the point: doc-review is a
*detector*, and a gate that only detects still costs a round trip every release.

**10. Run the documentation review gate.**

Run `/gflow:doc-review` — audit all version refs, INDEX completeness, evidence files, **the published `website/docs/` mirror (PII gate + content-drift check, §4b)**, **code↔docs parity via git log (§4c)**, skill files, CHANGELOG footer, and memory files. Fix every **FAIL** before continuing. Fold all discovered fixes into the release prep commit — **including any `website/docs/` re-sync** (the mirror is anonymized and hand-synced; a canonical doc change this release must be mirrored, and `CHANGELOG.md` must never appear under `website/docs/`).

Also **consolidate shipped planning artifacts** here: extract any durable patterns
into auto-memory, then remove the now-shipped `docs/superpowers/` plan / spec /
verification files (keep only in-flight work). `check_repo_hygiene.py` enforces the
root-doc allowlist, so a stray review doc or session marker left at the repo root
will fail the gate.

**11. Commit the release prep.**

```bash
# All seven version sites from step 6 — a bumped-but-unstaged site fails the gate
# on the release branch, after the tag is already in your fingers.
git add pyproject.toml .codex-plugin/plugin.json plugins/gflow/.claude-plugin/plugin.json
git add src/gflow_cli/__init__.py uv.lock server.json docker/Dockerfile CHANGELOG.md
git add docs/PROJECT_STATUS.md                 # step 9b — enforced by check_release_artifacts
git add docs/ website/docs/ skills/ .claude/commands/gflow/   # include any doc-review + mirror fixes
# doc-review version-currency fixes often also touch ROOT docs — stage them too:
git add README.md PLAN.md KNOWN_ISSUES.md AGENTS.md llms.txt 2>/dev/null || true
git status --short                            # review EVERYTHING staged before committing
git commit -m "chore(release): v<NEW_VERSION>"
```

- **`uv.lock` changes** on every version bump (the editable package version is
  pinned in the lockfile) — it is easy to forget and must ship in this commit.
- **`.codex-plugin/plugin.json` tracks the package version** so marketplace installs
  receive a new cache path for every release.
- **`server.json` carries the version TWICE** — top level and inside the PyPI package
  entry. `_check_version_agreement` checks both; a half-bump fails the gate.
- The release-prep commit must NOT carry a `Co-Authored-By` trailer (see reminders).

**12. Tag the release commit.** Use `-s` for a signed annotated tag so GitHub shows **"Verified"** AND `.github/workflows/release.yml` passes the signed-tag gate (unsigned or lightweight tags are rejected by CI).

First confirm `develop` has not moved since step 5 — anything merged there in the
meantime is not in this branch and would ship in the *next* release while its
CHANGELOG entry sits under a heading that no longer exists:

```bash
git fetch origin
git rev-list --count HEAD..origin/develop   # expect 0
```

If it is non-zero: `git merge origin/develop`, move the newcomers' `[Unreleased]`
entries under `## [<NEW_VERSION>]`, re-run steps 4, **4b** and 10 — 4b because the
newcomers are user-facing features that just entered this release and each needs its
`LIVE_VERIFICATION_v<NEW_VERSION>.md` row (v0.68.0 shipped #672 this way and the ledger
had no row until a reviewer supplied one) — amend or add to the step 11 commit, and only
then tag. **Exception:** if the only newcomers are `docs(sponsors): refresh hall of fame`
commits from `.github/workflows/sponsors.yml` (a daily bot that cannot see a release
freeze), `git merge origin/develop` and move on — they carry no `[Unreleased]` entry, no
4b row and nothing to re-verify. If a tag was already created locally, `git tag -d v<NEW_VERSION>` and re-sign
it on the merged head — this is safe only while the tag is unpushed (see the **NEVER
force-push a release tag** reminder below). `develop` can still move between this check
and the step 13 push; that cannot corrupt the tag, it only means a late commit ships in
the next release — re-run the `rev-list` immediately before pushing if you care.

```bash
git tag -s v<NEW_VERSION> -m "v<NEW_VERSION>"
```

Signing requirements:
- **SSH signing (preferred):** `git config --global gpg.format ssh` + `user.signingkey` pointing at your public key.
- **GPG:** any registered GPG key works.
- Run `git config --global user.signingkey` to confirm a key is configured.

Confirm the tag actually carries a signature (this is what CI checks):

```bash
git cat-file -p v<NEW_VERSION> | grep -c "BEGIN SSH SIGNATURE"   # expect 1 (or "BEGIN PGP SIGNATURE" for GPG)
```

> **Benign local-verify error:** `git tag -v v<NEW_VERSION>` may fail with
> `gpg.ssh.allowedSignersFile needs to be configured`. This is a *local
> verification-config* gap only — the tag IS validly signed and CI still passes
> (CI greps for the signature header, above). To make local verify work once and
> for all, create an allowed-signers file (`<your-email> ssh-rsa AAAA...`) and run
> `git config --global gpg.ssh.allowedSignersFile <path>`. See the `release-signing`
> memory for the exact recipe. Do NOT treat this error as a signing failure.

**13. Push the tag first** (bypasses branch protection; triggers the CI release workflow immediately):

> **⚠ POINT OF NO RETURN — confirm with the user before this push.** Pushing the
> tag immediately triggers `.github/workflows/release.yml` → **PyPI publish +
> public GitHub Release**. A pushed release tag must NOT be force-replaced (ship a
> PATCH instead). Get an explicit go-ahead, then push.

```bash
git push origin v<NEW_VERSION>
```

CI will start building the release. Watch <https://github.com/ffroliva/gflow-cli/actions>.
Wait for the `Release` run to report **completed / success** and confirm the GitHub
Release published before continuing (`gh release view v<NEW_VERSION>`).

**14. Push the release branch and open the PR.**

```bash
git push -u origin chore/release-v<NEW_VERSION>
gh pr create --base main --head chore/release-v<NEW_VERSION> \
  --title "chore(release): v<NEW_VERSION>"
```

Wait for PR CI to go green (`gh pr checks <N> --watch`). **The `SonarCloud analysis`
check must be green (gate passed) — not just the test matrix.** If it is red or you
want the verdict, run `/gflow:sonar <N>` and drive it to zero before merging.

Before merging, leave the release worktree and remove it — `--delete-branch` deletes the
local branch too, and git refuses to delete a branch a worktree has checked out
(`error: cannot delete branch … used by worktree`). The branch is pushed, so nothing is
lost:

```bash
cd <main checkout>                                   # e.g. C:/development/github/gflow-cli
git worktree remove --force .claude/worktrees/release-v<NEW_VERSION>
git worktree prune
```

Then merge with a **merge commit** — never squash:

```bash
gh pr merge <N> --merge --delete-branch
```

On Windows the removal can fail on the worktree's `.venv` file lock. `git worktree prune`
does **not** help — it only forgets worktrees whose directory is already gone, so the
branch stays held and `--delete-branch` still fails. In that case merge without it and
delete the remote ref explicitly; remove the directory and the local branch later
(CLAUDE.md § Worktrees):

```bash
gh pr merge <N> --merge
git push origin --delete chore/release-v<NEW_VERSION>
```

> **NEVER `--squash` this PR.** Because the branch was cut from `develop`, the PR
> carries the entire batch of unreleased integration commits. A squash collapses
> them into one opaque commit on `main` and destroys that history. `--merge`
> preserves it. (The release workflow already ran from the tag push in step 13 —
> this PR is to bring the bump commit + integration history onto `main`.)

**15. Back-merge `main` into `develop`.**

After the release PR is merged, bring the bump commit back to `develop` so branches stay aligned.
This runs in the **main checkout** (step 14 already returned you there) — `develop` is
checked out there, and git refuses `git checkout develop` from any other worktree
(`fatal: 'develop' is already used by worktree at …`):

```bash
cd <main checkout>
git checkout develop
git pull origin develop
git fetch origin main
git merge origin/main --no-ff -m "chore: back-merge main (v<NEW_VERSION>) into develop"
git push origin develop
```

If there are conflicts (rare — only if `develop` has commits that touched the same lines as the bump), resolve them, keeping `develop`'s unreleased work and `main`'s version bump.

**16. Report.**

Tell the user:
- Tag push triggered `.github/workflows/release.yml`.
- Watch <https://github.com/ffroliva/gflow-cli/actions> for the release workflow.
- On success: PyPI publish + GitHub Release with auto-generated notes.
- On failure (most common: PyPI Trusted Publishing not yet configured): point to <https://pypi.org/manage/account/publishing/>.
- `develop` is now synced with `main` (back-merge done in step 15).
- The release worktree is gone (step 14); if Windows held its `.venv`, name the directory
  that still needs a manual delete.
- Message the sessions you announced to in step 5: the back-merge has landed, `develop` is
  open again.
- Next development cycle starts on `develop` — open `## [Unreleased]` in CHANGELOG is ready.

### Pipeline Continuation (Next Step Handoff)

Upon completing a Release:
1. Proactively announce: **"Release v<NEW_VERSION> shipped to PyPI & GitHub Releases! Back-merge to develop complete. Next step: Phase 1 Triage (`/gflow:issue-assessment <N>`) for the next development cycle."**

---

## Critical reminders

- **ALWAYS** cut the release branch from `develop`, not `main` — `develop` carries the work.
- **NEVER** `--squash` the release PR into `main` — it destroys the integration history the branch carries. Use `--merge`.
- **NEVER** skip the `main → develop` back-merge (step 15) — skipping it guarantees conflicts at the next release.
- **NEVER** add `Co-Authored-By: Claude` (or any AI co-author) to the release commit.
- **NEVER** force-push a release tag once it's on GitHub. Ship a PATCH fix instead.
- **NEVER** `--no-verify` past hooks. Fix the underlying issue.
- **NEVER** push directly to `main` — branch protection will reject it. Always use a PR.
- A `git tag -v` `allowedSignersFile` error is **benign** (verify-only) — the tag is still signed and CI passes. Don't treat it as a failure.
- **CONFIRM with the user before the step 13 tag push** — it's the irreversible PyPI + public Release trigger.
- If quality gates fail at step 4, **STOP**. Surface the failures to the user.
- If doc-review fails at step 10, **STOP**. Fix before committing.

---

## See also

- [RELEASE.md](../../RELEASE.md) — full release protocol, prerelease policy, and checklist
- [README § Releases](../../README.md#releases) — release policy and cadence
- [PLAN § Phase 5](../../PLAN.md#phase-5--public-alpha-release-on-pypi) — first-release exit criteria
