Local-first release CLI tools: ship_bump, ship_pypi, ship_release_gh
Tiny, local-first release tools for modern Python projects.
fastship gives you the same workflow feel as the nbdev nbdev-bump-version, release-pypi, and release-gh commands — but for plain (non-notebook) Python projects.
In an nbdev project (one with [tool.nbdev] in pyproject.toml), ship-bump, ship-pypi, ship-changelog, and ship-gh automatically delegate to their nbdev equivalents, so you can use the same commands in every repo.
pip install fastship
Create a new project:
ship-new my-project
cd my-project
pip install -e .[dev]
This creates a complete project with pyproject.toml, __version__, LICENSE, README, and everything wired for fastship. Author metadata comes from Git’s user.name and user.email. New projects also include fastship’s Jekyll _config.yml and _layouts/default.html.
ship-bumpBump a version part (0=major, 1=minor, 2=patch). For Rust projects (a Cargo.toml next to pyproject.toml) it bumps [package].version in Cargo.toml and runs maturin develop; otherwise it rewrites __version__ in your package __init__.py:
ship-bump --part 2
ship-bump --part 1
ship-bump --part 0
Decrement instead:
ship-bump --part 2 --unbump
ship-pypiBuild + upload to PyPI:
ship-pypi
Upload to a named repository in ~/.pypirc (e.g. testpypi):
ship-pypi --repository testpypi
Quiet mode:
ship-pypi --quiet
Build and upload a wheel without creating an sdist, either directly or as part of a plain Python release:
ship-pypi --wheel_only
ship-release --wheel_only
For a permanently wheel-only project:
[tool.fastship]
wheel-only = true
ship-pagesEnable GitHub Pages from the repository’s main branch and set the repository homepage to the Pages URL:
ship-pages
Run this after creating the GitHub repository and making its first push. The repository is inferred from the local origin remote, and authentication uses the same token lookup as ship-gh.
Fastship can also handle the repeated local tooling for PyO3 projects that use maturin and need Rust CLI binaries bundled into wheel scripts.
Create a new PyO3 project:
ship-rs-new my-project
cd my-project
pip install -e .[dev]
maturin develop
pytest -q
Use Cargo.toml as the version source:
[project]
name = "my_project"
dynamic = ["version"]
Commands:
ship-rs-new my-project # create a new maturin/PyO3 project
ship-rs-init # configure an existing maturin/PyO3 project
ship-rs-build # maturin build --release -o dist
ship-bump # bump Cargo.toml version, then refresh the local editable install
ship-release # changelog, tag, publish via CI, then bump
ship-rs-init must be run from an existing maturin project with Cargo.toml. It sets [project].dynamic = ["version"], removes [project].version, and exposes __version__ from CARGO_PKG_VERSION when it finds the PyO3 module.
Generated CI runs the tests, then builds wheels with maturin-action across an OS matrix (manylinux: auto on Linux) and publishes to GitHub Releases and PyPI on v* tags. Any CLI tools are Python console scripts declared in [project.scripts]; there are no native Rust binaries to build.
Create Python CFFI bindings over a bundled Zig shared library:
ship-zig-new my-project
cd my-project
pip install -e .[dev]
python build_lib.py
pytest -q
Commands:
ship-zig-new my-project # create a CFFI/Zig project
ship-zig-build # build and check the current platform wheel
ship-bump # bump the Python package version
ship-release # changelog, tag, publish via CI, then bump
The generated workflow builds one Python-ABI-independent wheel for Linux x86_64, Linux ARM64, macOS Intel, and macOS Apple Silicon. Tagged builds create the GitHub release and publish to PyPI with trusted publishing.
ship-prCreate a PR from uncommitted or unpushed work, merge it immediately, and clean up:
ship-pr "Add new feature"
ship-pr "Fix bug" --label bug
ship-pr "Breaking change" --label breaking
This command:
enhancement)You must be on the default branch (usually main) with no unpulled changes.
ship-changelogGenerate or update CHANGELOG.md from closed GitHub issues since your last release:
ship-changelog
This is useful when you want to edit the changelog separately (e.g., in an editor or Claude Code) before releasing.
If you already have a CHANGELOG.md, it must include <!-- do not remove --> near the top so fastship knows where to insert the next release notes.
ship-ghThis is an interactive helper:
CHANGELOG.md from closed GitHub issues since your last GitHub release$EDITOR (defaults to nano) so you can edit the changeloggit commit -am release, git push__version__ship-gh
If you’ve reviewed a changelog created by ship-changelog, release it without regenerating or reopening it:
ship-gh --no_changelog --no_editor
The confirmation prompt remains. Add --yes only when confirmation has already happened elsewhere.
ship-gh looks for a token in this order:
FASTSHIP_TOKEN./token file in your repo rootGITHUB_TOKENThe token must have permission to create releases (typically repo scope for classic PATs, or appropriate fine-grained permissions).
ship-releaseFull release workflow:
ship-release # generate changelog, release, bump version, push
ship-release selects the project type automatically:
The final Released message is printed only after the full local publishing workflow succeeds. Tag-driven native releases print Release started because CI owns publication.
To generate and review the changelog separately, then run the rest of the workflow without another editor or prompt:
ship-changelog
# review CHANGELOG.md
ship-release --no_changelog --no_editor --yes
Changelog generation is idempotent: regenerating replaces the pending section for the current version rather than duplicating it. Regeneration also discards manual edits to that pending section, so do not regenerate after the final review.
ship-pypi does not bump your version for you — keep it explicit and boring.ship-gh requires that your project has a git origin remote pointing at GitHub (or use --repo OWNER/REPO).To add fastship to an existing project:
__init__.pyIn your package’s main __init__.py:
__version__ = "0.0.1"
pyproject.toml[project]
name = "my-project"
dynamic = ["version"]
[tool.setuptools.dynamic]
version = { attr = "my_project.__version__" }
Keep __version__ = "x.y.z" as a simple literal (don’t compute it). ship-bump will rewrite this line near the top of the file to keep builds happy.
Fastship infers your package name from [project].name (changing - to _). To override the release branch:
[tool.fastship]
branch = "main" # defaults to current git branch