Cutting a cwcli release (sharp edges)
Keep the root-cause "why" so a later change does not silently re-break the release flow.
Cutting a release
Current practice (the UV_PUBLISH_TOKEN flow), verified against .github/workflows/release.yml:
- A version bump touches exactly four files:
versioninpyproject.toml,__version__insrc/caffeinated_whale_cli/__init__.py, the project's own entry inuv.lock(regenerate viauv lock, do not hand-edit), and a newCHANGELOG.mdsection. - Both
build.ymlandrelease.ymlhard-fail whenpyproject.tomland__init__.pydisagree, so the two version strings must always be bumped together. CHANGELOG.mdis manually maintained in Keep a Changelog format: a## [x.y.z] - YYYY-MM-DDsection with### Added/### Changed/### Fixedsubsections, entries shaped like- **`cmd` command** - descriptionwith indented sub-bullets, user-facing changes only (no CI/typing internals), no issue numbers.- Version choice follows semver as this repo practices it: minor for new flags or behavior changes (0.32.0, 0.33.0, 0.34.0), patch for a narrow compatibility fix (0.31.1).
- The bump lands as a normal PR to
developwith achore: bump version to x.y.zcommit. - Publishing to PyPI is done by
release.yml, not from a dev machine. Merge the bump PR into the defaultdevelopbranch, then push avX.Y.Ztag pointing at the merge commit; the tag trigger fires regardless of branch. The workflow verifies the tag matches the package version, publishes to PyPI, and creates a GitHub release with the built artifacts. - The release card's copy is written by hand, in the repo, BEFORE the tag is pushed -
.github/release-notes/v<version>.md. It is Chris's cross-project release-notes template (## [<version>](<diff url>) (<date>), then> Description, then whichever of Upgrade Steps / Breaking Changes / New Features / Bug Fixes / Performance Improvements / Other Changes actually apply - never an empty section), not a cwcli-specific "What's Changed" shape;.github/release-notes/README.mdowns the full format and rules. This replaced the older cwcli-specific card format (a### What's Changed/### What's Newheading, hand-written advertising copy, a<!-- flagship -->marker, and a generated Installation/CHANGELOG-link footer) as of the v2.1.0 release, on the standing rule that every project's release notes use the same template going forward - see the global agent instructions' "Release notes" section.tests/test_release_body.py's history (TestTheFlagshipMarker, the oldTestTheCardSatisfiesTheStandingRules) documents what the old format guarded; those tests were rewritten for the new one rather than kept as dead weight..github/scripts/release-body.shcombines that note with a generated header (version, diff link, release date) and validates the section headings and the[ACTION REQUIRED]flag on Upgrade Steps entries; the workflow composes the result BEFORE the build and publish, so an invalid note stops the release before a version is consumed on PyPI. The split is load-bearing: a template cannot write the actual copy, and a generated commit list is not a release card - which is whygenerate_release_notesis off and the workflow's old hardcoded body is gone. That old hardcoded body once pointed the CHANGELOG link atblob/master/, a branch this repo does not have; the owner corrected it on the published 1.0.0 note but not in the generator, so every later release regenerated the dead link. Fix the generator, not the output. Render before tagging with.github/scripts/release-body.sh <version> karotkriss/caffeinated-whale-cli. - This repo never mentions the unreleased Console/
servecommand, or any GUI, in a release note - seeAGENTS.md's "Console" entry. It ships unreachable, and advertising an unreachable command would be worse than saying nothing. - A tag push triggers
release.yml's container job, which is the first (and so far only) job in this repo to rungitdirectly inside arun:step (release-body.sh'sgit tag --list/git rev-parsecalls, used to find the previous release tag).actions/checkoutonly registers the workspace as a safe directory around its own git calls and again in its post-job cleanup - never for laterrun:steps - so a container job whose checked-out files are owned by a different UID than the onerun:steps execute as hits git's "detected dubious ownership" refusal (exit 128) the first time any step tries to run git itself. The fix is a dedicated "Configure git safe directory" step (git config --global --add safe.directory "$GITHUB_WORKSPACE") placed immediately after checkout and before any git-invoking step; if a future change adds another git call to this job, or tobuild.yml/lint.yml/test.yml's container jobs, it needs the same registration first. A tag that already exists on the remote when a fix like this lands cannot simply be re-pushed (the ref exists; nothing re-triggers) and re-running the OLD failed workflow run replays the exact unfixed YAML (GitHub pins a run's workflow definition to the commit that triggered it) - so recovering a tag that failed for an environment reason, with nothing actually published under it yet (verify against PyPI and the GitHub releases API, not the workflow's exit code), means deleting and recreating the tag against the commit that carries the fix. uv buildruns with--no-create-gitignore: uv otherwise writes a.gitignoreintodist/, which the release upload glob swept up and GitHub published as an asset nameddefault.gitignore(it renames leading-dot uploads). Every release from v0.35.0 to v2.0.0 carries that stray asset. Do not "fix" this by narrowing the upload glob - keepdist/holding only distributables.- The publish step runs
uv publish --check-url https://pypi.org/simple/inside the job'sghcr.io/astral-sh/uvcontainer. It authenticates with theUV_PUBLISH_TOKENsecret, a PyPI API token, since commit1fbc555- so it uses the token and does not attempt trusted publishing, even though theid-token: writepermission and thepypienvironment:block that OIDC needed are both still present.--check-urlis NOT twine'sskip-existing: it skips ONLY a byte-identical re-upload (content-hash dedup, for retry/parallel-upload safety within the SAME build), and a same-name but different-content upload ERRORS withLocal file and index file do not match(exit 2). Because Python builds are non-reproducible, re-tagging an already-published version rebuilds a byte-different artifact and FAILS rather than skipping - so each release must be a genuinely NEW version; do not expect re-tagging an existing version to skip gracefully. This replaced the oldpypa/gh-action-pypi-publish@release/v1step, which is a Docker container action and cannot run from inside a jobcontainer:(GitHub tries to bootstrap it viacreate-docker-action.py, which is absent in the nested-container filesystem, and dies with[Errno 2] No such file or directory, exit 2 - the v0.33.0 tag never reached PyPI for this reason). - One-time prerequisite (captain-only, not automatable in CI): the
UV_PUBLISH_TOKENrepository secret must hold a valid PyPI API token for this project; publishing fails with an auth error otherwise and CI cannot create it. If the flow is ever moved back to OIDC, the prerequisite becomes a PyPI trusted publisher registered for ownerkarotkriss, repocaffeinated-whale-cli, workflowrelease.yml, environmentpypi. bump-homebrew-tapfans a release out to thekarotkriss/homebrew-cwclitap (release.yml's second job) - a SEPARATE job,needs: build-and-publishpluscontinue-on-error: true, so a tap-bump failure (missing token, tap CI hiccup, PyPI propagation lag, rate limit) can never fail the PyPI publish or the GitHub release:needs:alone means it never even runs if the publish job failed, andcontinue-on-error: truekeeps a failure inside it from reddening the overall workflow run. It resolves the released version's exact sdisturlandsha256from PyPI's own JSON API (https://pypi.org/pypi/caffeinated-whale-cli/<version>/json,.urls[] | select(.packagetype=="sdist")) with a short retry loop for the brief post-publish propagation gap - the file's hashed storage path can't be predicted, so this is the one call that yields both values PyPI itself recorded. It then handsdownload-url/download-sha256tomislav/bump-homebrew-formula-action(pinned by commit SHA, tag v4.2), authenticated via theHOMEBREW_TAP_TOKENsecret (contents: writeon the tap, added by the captain outside this repo) asCOMMITTER_TOKEN.create-pullrequest: trueis forced explicitly: the action's own default is to push directly to the tap's base branch whenever the token has push rights, whichHOMEBREW_TAP_TOKENdoes - forcing PR mode is what lets the tap's ownbrew test-botCI validate the bump before it lands. The action was chosen overdawidd6/action-homebrew-bump-formulabecause that one wrapsbrew bump-formula-praround atag/revisiongit-source model (GitHub release tarballs); this formula'surlis a PyPI sdist, whichmislav/...takes as a direct, explicit input. Verified locally (not from a real release) that the action's field-replacement regex touches only the FIRST top-levelurl/sha256pair inFormula/cwcli.rb, leaving the many per-dependencyresourceblocks (each with their ownurl/sha256, generated separately e.g. viabrew update-python-resources) untouched. Known gap, tap-side, not fixable from this repo:Formula/cwcli.rb'stest doblock hardcodesassert_match "2.4.0", shell_output("#{bin}/cwcli --version")instead of interpolating the formula's ownversion; the bump action does not touchtest doblocks (confirmed by running its replacement logic against the real formula), so every future bump PR will carry a stale version literal there, and the tap'sbrew test-bot --only-formulaejob may fail on that one assertion even though theurl/sha256bump itself is correct. Fix is tap-side (e.g.assert_match version.to_s, ...), out of this repo's blast radius.- The tap PR self-merges once the tap's own CI is green - no human ever clicks merge (captain ruling 2026-08-08, since the PyPI publish above is already fully automatic on a tag). A second step, "Enable auto-merge on the tap PR", runs right after the bump action, authenticated with the same
HOMEBREW_TAP_TOKENasGH_TOKEN. The bump action never exposes the PR it opened as a step output (confirmed against its source:edit-github-blob.tsreturns the URL from a function, but the action entrypoint nevercore.setOutputs it), but it always creates a fresh branch namedupdate-<formula-file-basename>-<unix-timestamp>(hereupdate-cwcli.rb-<ts>) and opens exactly one PR from it - sogh pr list --repo karotkriss/homebrew-cwcli --state openfiltered to that head-branch prefix and sorted bycreatedAt, taking the last, always finds the PR this run just opened.gh pr merge <n> --auto --squashthen arms GitHub's native auto-merge: GitHub itself merges once every required status check on the tap'smainpasses, and leaves the PR open, untouched, if a check fails or is still running - this step never merges anything itself and never blind-pushes to the tap'smain. Native auto-merge only actually gates on the checks if the tap repo has both "Allow auto-merge" enabled (repo setting) and branch protection onmainrequiring the test-bot checks - without a required check, GitHub has nothing to wait on and a queued auto-merge can go through immediately. Both are tap-repo administration, outside this repo's blast radius (delegated to firstmate), and are now applied and verified live:gh api repos/karotkriss/homebrew-cwclireportsallow_auto_merge: true, and branch protection onmainrequires exactly the three test-bot contexts (strict: false, no review gate). The mechanism is therefore fully armed end to end. The exact required-check context names, taken from a real merged tap PR's check runs (not guessed from.github/workflows/tests.ymlin the tap) are:test-bot (macos-15-intel),test-bot (macos-26),test-bot (ubuntu-latest, ghcr.io/homebrew/brew:main, --privileged). The job'scontinue-on-error: truestill covers the auto-merge step, so a transient failure there can never redden the release run, and a tap check that fails simply leaves the bump PR open and unmerged.