Prompt file imported from phenobarbital/querysource (
.claude/commands/release.md). Fill in{{arguments}}before use. Copyright stays with the author.
/release — QuerySource Release via gh
Arguments: {{arguments}}
Drive a release of querysource: bump the version on dev, open (and, on
confirmation, merge) the dev → main PR, then — only after an explicit yes —
run gh release create on main. Creating the release fires
.github/workflows/release.yml, which builds the wheels (cibuildwheel +
maturin, Python 3.10–3.13) and uploads to PyPI.
Usage
/release patch # 4.5.15 -> 4.5.16
/release minor # 4.5.15 -> 4.6.0
/release major # 4.5.15 -> 5.0.0
/release 4.5.16-1 # explicit version (hotfix-style suffix, as 4.5.11-1)
/release patch --dry-run # show the plan, touch nothing
/release patch --no-release # bump + push + PR, stop before gh release
/release patch --draft # create the GitHub Release as a draft (no publish)
/release --publish-only 4.5.16 # version already on main: only create the release
/release patch --notes-from-changes # notes from CHANGES.rst "Unreleased" instead of --generate-notes
How versioning works here
- Single source of truth:
__version__inquerysource/version.py(pyproject.tomlhasdynamic = ["version"]→version = {attr = "querysource.__version__"}).querysource/_version.pyis generated by setuptools_scm and untracked — never edit or commit it. - Tags are the bare version (
4.5.15, novprefix). They are created bygh release createitself, pointing atmain. - Branch flow: the bump commit lands on
dev, reachesmainthrough adev → mainPR (merge commit), and the release targetsmain. - Release notes:
--generate-notes(the "What's Changed" / "Full Changelog" format used by every previous release). - PyPI never allows re-uploading a version. A broken release is fixed with a new version, never by deleting and recreating the same one.
Step 1 — Preflight (read-only)
- Parse
{{arguments}}: exactly one ofpatch,minor,major, an explicitX.Y.Z[-N], or--publish-only X.Y.Z. If missing or ambiguous, ask — never default topatch. - Tooling and remote state:
Ifgh auth status git fetch origin --tags --pruneghis not authenticated, stop and tell the user to run! gh auth login. - Location — never release from a feature branch or a worktree:
Abort if HEAD is notgit rev-parse --abbrev-ref HEAD && git rev-parse --show-topleveldev(ormainfor--publish-only) or the toplevel is under.claude/worktrees/. - Clean tracked tree (untracked files are ignored — they never enter the
release commit):
If non-empty, abort: "Commit or stash first — the release commit carries onlygit status --porcelain --untracked-files=noquerysource/version.py(and CHANGES.rst if requested)." - Branch sync:
git rev-list --left-right --count dev...origin/dev # must be "0 0" (or ahead-only) git log --oneline --no-merges origin/dev..origin/main # commits on main missing from devdevbehindorigin/dev→ abort, ask the user togit pull --ff-only.mainhas non-merge commits thatdevlacks → warn: the PR will not be a clean fast path; suggest refreshingdevfrommainfirst (as PR #595 did) and ask whether to continue.
- Current versions:
Computegrep -E "^__version__" querysource/version.py git show origin/main:querysource/version.py | grep -E "^__version__" gh release list -L 3NEXTfrom the dev version and the bump part. Then refuse if it already exists anywhere:
Also abort ifgit rev-parse -q --verify "refs/tags/$NEXT" && echo "TAG EXISTS" gh release view "$NEXT" >/dev/null 2>&1 && echo "RELEASE EXISTS"NEXTis not greater than the latest release tag. - Live SDD workers may
merge/resetshared branches under you:
If one is live, warn and ask before continuing.ps -eo pid,etime,args | grep -i '[s]dd-worker'
Step 2 — Plan
Show the plan and what landed since the last release:
LAST=$(gh release list -L 1 --json tagName -q '.[0].tagName')
git log --oneline --no-merges "$LAST"..origin/dev
Release plan
Current (dev): 4.5.15 Latest release: 4.5.15
Next: 4.5.16
1. Commit "chore(release): bump version to 4.5.16" on dev, push
2. PR dev -> main ("Release 4.5.16"), merge on confirmation
3. gh release create 4.5.16 --target main --generate-notes
4. release.yml builds wheels and publishes to PyPI (~10 min)
If --dry-run was passed, stop here.
Step 3 — Bump on dev
- Edit
querysource/version.py: set__version__ = '<NEXT>'(keep single quotes — match the file). Touch nothing else. - Only if
--notes-from-changeswas passed: inCHANGES.rst, retitle theUnreleased/==========heading to<NEXT> (YYYY-MM-DD)with a matching=underline and open a fresh emptyUnreleasedsection above it. Show the diff and get approval before continuing. - Commit and push — stage explicit paths only, never
git add .:git add querysource/version.py # + CHANGES.rst if step 2 ran git diff --cached --name-only # must list only those files git commit -m "chore(release): bump version to <NEXT>" git push origin dev
Step 4 — Land on main
- Reuse an open
dev → mainPR if there is one, otherwise create it:gh pr list --base main --head dev --state open --json number,url gh pr create --base main --head dev --title "Release <NEXT>" \ --body "Release <NEXT>. Merging publishes nothing by itself; the GitHub Release does." - Show the PR URL and the checks:
gh pr checks <number>. - Ask for confirmation to merge. On yes:
If the user prefers to merge on GitHub, stop here and tell them to rungh pr merge <number> --merge git fetch origin git show origin/main:querysource/version.py | grep -E "^__version__" # must equal NEXT/release --publish-only <NEXT>afterwards.
If --no-release was passed, stop here and print that follow-up command.
Step 5 — GitHub Release (irreversible — confirm)
--publish-only X.Y.Z enters here directly, after Step 1 checks 1–4 and 6
(with NEXT = X.Y.Z, and requiring origin/main's version to equal it).
- State plainly: "This creates tag
<NEXT>onmainand triggersrelease.yml, which uploads querysource<NEXT>to PyPI. PyPI does not allow re-uploading this version." - Ask for explicit confirmation. Never proceed on an implied yes.
- Create the release:
gh release create "<NEXT>" --target main --title "<NEXT>" --generate-notes --latest--draftin{{arguments}}→ add--draft(no workflow run until it is published from GitHub).--notes-from-changes→ write the new CHANGES.rst section to a scratch file and use--notes-file <file>instead of--generate-notes.
- Watch the publish run:
Run the watch in the background (it takes ~10 minutes) and report when it finishes. On failure, showsleep 5 RUN=$(gh run list --workflow release.yml -L 1 --json databaseId -q '.[0].databaseId') gh run watch "$RUN" --exit-statusgh run view "$RUN" --log-failed | tail -60.
Step 6 — Report
Release 4.5.16
Version bump: querysource/version.py 4.5.15 -> 4.5.16 (<sha> on dev)
PR: #<n> dev -> main (merged / open)
GH Release: https://github.com/phenobarbital/querysource/releases/tag/4.5.16 (created / draft / skipped)
PyPI: release.yml run <id> — success / failed / running
https://pypi.org/project/querysource/4.5.16/
Error handling
| Situation | Action |
|---|---|
No bump type / version in {{arguments}} |
Ask. Never default to patch. |
gh not authenticated |
Stop; user runs ! gh auth login. |
| Tracked changes in the tree | Abort — the release commit carries version files only. |
| On a feature branch or inside a worktree | Abort. Releases start from dev. |
Tag or release <NEXT> already exists |
Abort — investigate the previous release; never overwrite. |
origin/main version ≠ <NEXT> at Step 5 |
Abort — the bump did not reach main (PR unmerged?). |
| PR has conflicts | Stop; refresh dev from main via a PR, then re-run. |
release.yml fails before upload |
Fix on dev and re-run the workflow from the same release (gh run rerun <id>) — the version was not published yet. |
release.yml fails after a partial upload |
Cut a new patch version; PyPI never accepts the same version twice. |
Live sdd-worker detected |
Warn and ask before pushing or merging. |
Never
git push --forcetodevormain, or delete/move an existing tag.gh release deleteof a published version to "retry".- Edit or commit
querysource/_version.py. - Create the release before
maincontains the version bump.
