ship
Releasing software involves a precise sequence: increment version, sync that change, build distribution artifacts, upload to PyPI, tag the release in git, and create a GitHub release with notes. Miss any step and you have version mismatches, untagged releases, or PyPI packages that don’t match the git history.
pj ship automates the entire pipeline with safety gates: abort if there are uncommitted changes, verify each step succeeds before proceeding, support dry-run mode to preview without executing. The result: reliable releases that take 30 seconds instead of 15 minutes of careful manual steps.
Version Extraction
Parse the current version from settings.ini for display and tag creation. This is the source of truth for version numbers in nbdev projects.
get_version_from_settings
get_version_from_settings ()
Extract version from settings.ini
The version line in settings.ini looks like version = 0.0.3. We split on =, take the right side, and strip whitespace. Simple parsing, but robust enough—nbdev enforces this format.
Release Orchestration
The complete release pipeline with safety checks, dry-run support, and granular control flags.
ship
ship (args)
Ship a new release: bump version, sync, build, upload, tag, and create GitHub release
The Release Pipeline
Gate 0: Clean working directory
Shipping with uncommitted changes is dangerous—you might release code that doesn’t match what’s in git. We check git status --porcelain and abort if there are any uncommitted files. The --force flag bypasses this for testing, but it’s discouraged.
Step 1: Version bump
nbdev_bump_version increments the specified part of the version number (0=major, 1=minor, 2=patch) and updates settings.ini. The --part argument defaults to 2 (patch releases: 0.0.X), but you can bump minor (0.X.0) or major (X.0.0) as needed.
In dry-run mode, we manually calculate what the new version would be by splitting on ., incrementing the specified part, and rejoining. This gives accurate preview without modifying settings.ini.
Step 2: Sync version bump
The version change in settings.ini needs to be committed and pushed before we build. Otherwise, the PyPI package would have a different version than what’s in git—a recipe for confusion.
TODO: This is the second place we need git_sync(). Same three-line pattern as in sync(): add, commit, push. Once we extract the helper, both call sites become git_sync(message, verbose).
Step 3: Build and upload
nbdev_pypi handles the full build process: creates isolated environment, installs build dependencies, builds sdist and wheel, uploads both to PyPI. Those setuptools warnings about _MissingDynamic are harmless—nbdev’s dual settings.ini/pyproject.toml approach confuses setuptools slightly, but the build works.
The --skip-pypi flag is useful for testing the full workflow without actually uploading. You can verify tagging and GitHub release creation without touching PyPI.
Step 4: Git tagging
Tags mark specific commits as releases. We use annotated tags (-a) with a message, following the convention vX.Y.Z. The tag must be created after the version bump commit exists and after PyPI upload succeeds—if upload fails, we haven’t polluted git with a tag for a non-existent release.
Step 5: GitHub release
gh release create with --generate-notes auto-generates release notes from commits since the last tag. This gives you a basic changelog without maintaining a separate CHANGELOG.md file. For projects without GitHub issues tracking, this is good enough.
The --skip-gh-release flag lets you publish to PyPI and tag without creating the GitHub release, useful if you want to write custom release notes manually later.
Post-release links
After a successful release, we show direct links to the PyPI package page and GitHub release. The repo URL comes from gh repo view, which reads the remote from .git/config—works whether the repo is in a personal account or an organization.
The upgrade reminder
After shipping a new version, your global pj installation is now outdated. We remind you to run uv tool upgrade pj-sh to get the version you just released. Otherwise, you’ll be dogfooding an old version while the new one is live.
Design Decisions
Why bump version first? The version in settings.ini feeds into the build process. Bump, commit, push, then build ensures the package metadata reflects what’s in git.
Why tag after PyPI upload? If the upload fails (auth issues, rate limits, package name conflicts), you haven’t created a tag that points to a version that doesn’t exist on PyPI. The tag should represent “this commit is available as version X on PyPI”.
Why separate skip flags? Sometimes you want to test tagging without uploading (--skip-pypi). Sometimes you want to upload but write release notes manually later (--skip-gh-release). Granular control beats all-or-nothing.
Why dry-run mode? The first time you run pj ship, you want to see exactly what will happen without actually doing it. Dry-run shows the command sequence, calculates the new version, and exits without modifying anything. Build confidence before pulling the trigger.
Why –force? Testing the release pipeline often involves uncommitted work-in-progress code. --force bypasses the clean working directory check so you can test the full workflow without stashing. Use sparingly—never force a real release.