The canonical release sequence and the publish-script contract.
- Confirm versions match in
pyproject.toml::project.versionandCargo.toml::workspace.package.version. The publish guard rejects a mismatch. - Land a clean main:
./ci.sh allpasses locally, all CI gates green on the PR, branch merged. - Locally on a clean checkout of the release tag, run:
./ci.sh all uv run python ci/build_wheel.pybuild_wheel.pybuilds the CLI via cargo into the pinnedCARGO_TARGET_DIR, drives maturin to produce the wheel + sdist (PyO3 extension only), then post-processes the wheel to inject the cargo-builttemplate-cli[.exe]attemplate_python_rust_cmd-<ver>.data/scripts/with a fresh RECORD row.verify_artifacts()asserts both the PyO3 extension module and the rawtemplate-cliwheel script are present. There is no_bin/staging step under the package source tree — see #7. - Verify wheel and sdist by hand:
uv run --with twine twine check dist/*. - Set
_ENABLED = Trueinci/publish.py(deliberately not a CLI flag — the file edit is the audit trail). - Publish:
Which runs
./publishtwine upload --skip-existingagainst the artifacts indist/. The--skip-existingflag is load-bearing: it lets a reattempted release skip files that already landed without failing the whole upload. - Tag and push:
git tag vX.Y.Z && git push origin vX.Y.Z. - Reset
_ENABLED = Falseinci/publish.pyand commit so the guard stays on for the next maintainer.
The publish script:
- exits with code 1 while
_ENABLED = Falseand tells the operator to enable it manually. - fails on a dirty worktree — uses
git status --shortas the predicate. - builds release artifacts by invoking
ci/build_wheel.py(the named entry point that opts INTO the full maturin context). - runs
twine checkbefore upload so a malformed wheel is caught locally. - uploads only missing artifacts when rerunning via
twine upload --skip-existing. - supports an explicit
--repository-urlfor staging uploads to TestPyPI.
A release is a human-in-the-loop decision: version bump, changelog, deprecation policy. Wrapping it in a script that won't run unless the operator flipped a constant by hand is a deliberate friction step — the file edit is the "I really mean it" signal. CI doesn't release; operators release.
If the release flow ever becomes a workflow (release-auto.yml),
move the guard into a manual-approval gate, not into a CLI flag. The
edit-in-source-file pattern only works at human cadence.
ci/build_wheel.py runs on each native runner in the CI matrix; per
platform you get one wheel and (on Linux) one sdist. Upload all of
them at once at the end — twine upload dist/* handles the mix
correctly because each wheel's tag identifies its target platform.
Before tagging, run the two action gates to confirm the composite action contract still holds:
./ci.sh action_yaml
./ci.sh action_surface
These are fast (<5 s) and catch the typo class of regressions where
action.yml's shell snippets drift from the binary's real
subcommand surface.