Releasing
In this guide you cut a release of Humanize: you run the release workflow with the version, merge the pull request it opens, and check that PyPI has the release, with an attestation saying where it was built. It is for the maintainers of humanfia/humanize.
Cut a release when main holds something people installing hmz should have. A release is never replaced: one that turns out broken is yanked, and the next one fixes it.
Before you start
- Write access to humanfia/humanize, and
ghsigned in to it. - A checkout set up as in Contributing, for the system tests.
- Optionally, coding agent CLIs signed in on your machine, for the system tests.
How it works
main is where changes land, and never what is released. Every change reaches it as a pull request, squashed into one commit titled with the pull request's title, through the merge queue once ci-ok is green on it as main will be. title holds the title to Conventional Commits, and ci-ok builds the package.
Every release comes from a release/X.Y branch, pre-releases included: 0.2.0-alpha.1, 0.2.0-rc.1, 0.2.0 and 0.2.1 all come from release/0.2. The branch is held to the same rules as main. Three workflows do it:
release.yml, run by hand onmainwith the version, checks it, findsrelease/X.Y, and has release-please open the release pull request against it. For a newX.Yit first cutsrelease/X.Yfrommain. The pull request bumpsversioninpyproject.toml,uv.lockand the macOS app'stauri.conf.json, and adds the release's section toCHANGELOG.md, written from the pull request titles since the last release of that line, or, for a new line, since the line before it was cut. A pre-release counts as a last release only to the pre-releases after it:0.2.0says all that changed since0.1.x, not just since0.2.0-rc.1.ci.ymlruns on the release pull request, approved byrelease.yml: on a pull request a workflow opened, it waits for approval, and itsci-okis needed to merge it. It runs again on every push to arelease/*branch, the merge included, on what the push changed.publish.yml, on a push to arelease/*branch that merged its release pull request, tags the merge commitvX.Y.Z, builds a wheel and an sdist, runstwine check --strict(which fails if PyPI could not renderREADME.md), publishes both to PyPI, and only then publishes the GitHub release, so it is never out before its package. Beside them, on macOS, it builds Humanize Desktop as one disk image for Apple silicon and Intel,humanize-X.Y.Z-macos-universal.dmg, attests it, and attaches it to the release, with the app's update beside it: see The app's updates. On Windows it builds the Windows app, whose hmz runs in WSL, as a per-user installer,humanize-X.Y.Z-windows-x64-setup.exe, attests it, and attaches it as well. Those are all the release offers: the wheel and the sdist are PyPI's.
main itself never says a released version: its pyproject.toml says 0.0.0, and so does tauri.conf.json.
The disk image is signed ad hoc, which is why its guide says how to open it the first time. Once the repository holds an Apple Developer ID in the secrets APPLE_CERTIFICATE (the .p12, in base64), APPLE_CERTIFICATE_PASSWORD and APPLE_SIGNING_IDENTITY, publish.yml signs it with that instead, and with APPLE_ID, APPLE_PASSWORD (an app-specific password) and APPLE_TEAM_ID it notarizes it as well. Nothing else changes.
The app's updates
An installed app updates itself from the latest.json of the newest release that carries one, found through GitHub's list of releases: never a pre-release's, unless the app is a pre-release itself. For that, publish.yml builds the app's update as well, humanize-X.Y.Z-macos-universal.app.tar.gz, signs it with the updater's key, attests it, and writes latest.json, which names the version, links the release as its notes, and offers the update with its signature to both darwin-aarch64 and darwin-x86_64. Both are attached to the release with the disk image. On Windows the installer is the update: it is signed with the same key, as humanize-X.Y.Z-windows-x64-setup.exe.sig, attached beside it, and offered in latest.json to windows-x86_64.
The key is held in the secrets TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD; its public half is plugins.updater.pubkey in tauri.conf.json, and an app takes no update not signed with it. Where the secrets are not set, the release is made without the update, with a warning, and installed apps stay as they are.
Back up the updater's key
The key is ~/.tauri/humanize.key on the Mac it was made on, with its password in ~/.tauri/humanize.key.password beside it. Keep both in the maintainers' password manager. Losing the key means no installed app can be updated again: a new key's updates are refused by every app already out, whose users must download the disk image by hand once more.
The version is checked before anything is opened:
| You name | It comes from | It must |
|---|---|---|
X.Y.0, or its -alpha.N, -beta.N, -rc.N | release/X.Y, cut from main if it does not exist | come after every release of X.Y; a new X.Y must come after every line there is |
X.Y.Z with Z above 0, or its pre-releases | release/X.Y, which must exist | come after every release of X.Y, with X.Y.0 released |
So a line goes alpha, beta, rc, release, then patches, and never back. On PyPI each version is in PEP 440: 0.2.0-beta.1 is 0.2.0b1. An alpha, beta or rc is a pre-release on GitHub, and the latest release is the newest that is not one, so a patch to an older line never takes it.
Which version to name, since the last release:
| The line has | Bump |
|---|---|
a breaking change: a ! in a pull request's title | minor while the version is 0.x, major after |
| a feature | minor |
| only fixes | patch, from the existing release/X.Y |
Nobody holds a PyPI token: PyPI lets publish.yml, run in the repository's pypi environment, publish hmz, which is called trusted publishing. Every file it publishes carries an attestation naming the workflow run that built it.
Step 1: Run the system tests the release needs
CI never runs tests/system, which drives real agents on real tasks. Running it is optional, and costs minutes and tokens a test, so run the files that cover what changed since the last release, on the commit you are about to release. A release that changes how a backend is driven runs that backend's test_harness_* file; one that changes a built-in flow runs its test_flows_* file:
uv run pytest tests/system/test_flows_ralph.pyRelease only when none of them fails: fix it on main first. A test that skips names what this machine lacks. Where a test goes has the rest.
Step 2: Open the release
gh workflow run release.yml -f version=0.2.0-beta.1Or Actions → release → Run workflow on GitHub, from main. A minute later the pull request chore(release/0.2): release 0.2.0-beta.1 is open against release/0.2. Read its CHANGELOG.md: it is what the release will say.
Running it again with the same version brings the pull request up to date with its branch.
Step 3: Merge it
Approve the pull request and merge it once ci-ok is green.
Check it worked
The run takes a few minutes. Follow it with:
gh run watch "$(gh run list --workflow publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"Once its five jobs are green, the release is on GitHub Releases. Then install it from PyPI:
uvx hmz@0.2.0b1 --versionhmz 0.2.0b1Then check that each file's attestation names this repository:
uvx pypi-attestations verify pypi --repository https://github.com/humanfia/humanize \
pypi:hmz-0.2.0b1-py3-none-any.whlOK: hmz-0.2.0b1-py3-none-any.whlDo the same for pypi:hmz-0.2.0b1.tar.gz. PyPI shows the same on the release's Download files page: each file names publish.yml on humanfia/humanize as its publisher.
The disk image is attested by GitHub instead, and so is the app's update:
gh release download v0.2.0-beta.1 --repo humanfia/humanize --pattern '*.dmg'
gh attestation verify humanize-0.2.0-beta.1-macos-universal.dmg --repo humanfia/humanizeOnce a release that is not a pre-release is out, the installed apps are offered it:
curl -sL https://github.com/humanfia/humanize/releases/latest/download/latest.json | jq .version"0.2.0"Variations
A change for a line already cut. A fix that has to reach 0.2, or a feature for a 0.2 still in beta, merges to main first, then comes over in a pull request against release/0.2:
git switch -c fix/backport-the-bug origin/release/0.2
git cherry-pick <the commit on main>
gh pr create --base release/0.2 --title "fix: …"Then open the next version of that line, 0.2.1 or 0.2.0-beta.2, as in Step 2.
Changing your mind. Close the release pull request and delete its branch: nothing was released. A release/X.Y cut for it stays, and the next version of that line comes from it.
A check of the package before a release. CI's static job builds it on every pull request. The same on your machine, into dist/, which git ignores:
uv build --no-sources
uvx --from dist/hmz-*.whl hmz --versionYanking a release
When a release is broken, yank it on PyPI: Manage project, the version, Options, Yank, with a reason that names the version to use instead. A yanked version stays installable for anyone who asks for it by number, and nothing else picks it. Then say so at the top of the GitHub release's notes, fix it, and release the next patch.
Do not delete a release from PyPI, and do not move its tag. PyPI never takes the same file name twice, so a deleted version cannot be published again either, and anyone who pinned it can no longer install it.
Pitfalls
| The run fails with | Because | Do |
|---|---|---|
… is not X.Y.Z or X.Y.Z-(alpha|beta|rc).N, … is released already, … does not come after …, … which is not released, … has nowhere to come from or … would start a line before … in release | the version named. Nothing was opened | run it again with one that is |
GitHub Actions is not permitted to create or approve pull requests in release | the repository's setting | an admin allows it in Settings → Actions → General, then run it again |
the release pull request waits for ci-ok forever | GitHub held a ci run for github-actions[bot], a first-time contributor until its first pull request merges | Approve workflows to run on the pull request |
a failed twine check | PyPI would not render README.md. Nothing was published, but the tag and a draft release exist | gh release delete vX.Y.Z --yes, keeping the tag; fix it on main, bring it to the line, and open the next version |
invalid-publisher in publish to PyPI | PyPI's trusted publisher does not describe this workflow | on PyPI, the publisher must name owner humanfia, repository humanize, workflow publish.yml and environment pypi; then gh run rerun <run-id> --failed |
File already exists | PyPI has this version already | open the next version: PyPI never takes a version's files twice |
a failed publish the release | PyPI has the release, and the GitHub release is still a draft | gh run rerun <run-id> --failed |
a failed desktop or desktop-windows | the disk image or the installer did not build, or did not sign or notarize. PyPI may have the release; the GitHub release is still a draft | fix what its log says, then gh run rerun <run-id> --failed; a fix that needs a commit goes to main, then the next version |
No TAURI_SIGNING_PRIVATE_KEY in desktop or desktop-windows | the updater's secrets are not set. The release has no update, and installed apps are not offered it | set both secrets, then release the next version |
For admins: guarding the pypi environment
In Settings → Environments → pypi, add yourself and another maintainer as required reviewers, and limit deployments to release/* branches. Each upload then waits for a second person to approve it on the run's page.
Next steps
| To | Read |
|---|---|
| run a system test, or add one | Where a test goes |
| set up a checkout and run the checks | Contributing |
| see every step the release runs | release.yml, publish.yml and release-please-config.json |