--- 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.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 .claude/worktrees/release-v origin/develop cd .claude/worktrees/release-v ``` Before cutting, run `ListAgents` (or the equivalent in your harness) and message every other session working on this repo: *"Cutting v from develop@; do not push to develop until the back-merge lands."* Then cut in a **dedicated worktree** (the repo convention is `.claude/worktrees/`), 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 : `, `✅ 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 = "" ``` ```json { "version": "" } ``` **7. Bump package version** in `src/gflow_cli/__init__.py`: ```python __version__ = "" ``` **8. Update version assertion tests** if present: ```bash rg -n "__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 `## [] — 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...HEAD []: https://github.com/ffroliva/gflow-cli/compare/v...v ``` **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 `
vPREV — …` 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" ``` - **`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 `## []`, 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.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` 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 -m "v" ``` 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 | grep -c "BEGIN SSH SIGNATURE" # expect 1 (or "BEGIN PGP SIGNATURE" for GPG) ``` > **Benign local-verify error:** `git tag -v v` 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 (` ssh-rsa AAAA...`) and run > `git config --global gpg.ssh.allowedSignersFile `. 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 ``` CI will start building the release. Watch . Wait for the `Release` run to report **completed / success** and confirm the GitHub Release published before continuing (`gh release view v`). **14. Push the release branch and open the PR.** ```bash git push -u origin chore/release-v gh pr create --base main --head chore/release-v \ --title "chore(release): v" ``` Wait for PR CI to go green (`gh pr checks --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 ` 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
# e.g. C:/development/github/gflow-cli git worktree remove --force .claude/worktrees/release-v git worktree prune ``` Then merge with a **merge commit** — never squash: ```bash gh pr merge --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 --merge git push origin --delete chore/release-v ``` > **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
git checkout develop git pull origin develop git fetch origin main git merge origin/main --no-ff -m "chore: back-merge main (v) 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 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 . - `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 shipped to PyPI & GitHub Releases! Back-merge to develop complete. Next step: Phase 1 Triage (`/gflow:issue-assessment `) 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