Releasing
This page is the operator's runbook for the branch model, the CI gate that protects it, and the tag-driven release flow that publishes taxi-seed to PyPI. If you are looking for how to build and test locally as a contributor, see Contributing instead — this page is about promoting code from a merged PR to a published package.
Branch model
devis the default branch and the integration branch. Feature branches are cut fromdev, and pull requests targetdev— notmain.mainis the stable, released branch. Nothing lands onmaindirectly; it only moves forward by mergingdevinto it oncedevis in a state worth shipping.- The docs site deploys from
main(see thedocsjob in.github/workflows/ci.yml, gated onpushtorefs/heads/main), somainis also "what's live on the docs site" at any given moment. - Releases are tagged on
main, after the promotion merge — never ondevand never on a feature branch. The version published to PyPI is whatevermainlooked like when the tag was pushed.
The promotion path in order: feature branch → PR into dev → dev passes CI → PR/merge dev into main → tag main → release workflow runs.
The CI gate
.github/workflows/ci.yml defines four jobs — test, docs, integration, and branch-guard. A pull request runs test, integration, and branch-guard; docs only runs (and deploys) on a push to main, so it does not gate PRs.
test— unit tests across the Python version matrix (3.12, 3.13).docs— builds and deploys the documentation site; gated onpushtorefs/heads/main, not on pull requests.integration— end-to-end tests against a real SQL Server container (loader integration + pipeline e2e), on Python 3.13.branch-guard— fails a pull request that targetsmainfrom any head branch other thandev. It runs on every PR and passes trivially for PRs intodev, so it costs a few seconds and only bites when the branch model above is being bypassed.
Merging into dev or main is blocked until integration passes — it is configured as a required status check (see the one-time setup below for the exact command that wires this up) main additionally requires branch-guard.
branch-guard is the only thing enforcing the branch model
GitHub branch protection has no setting that restricts which head branch may open a pull request against a given base — protection gates checks and pushes, not PR topology. Without branch-guard as a required check on main, a feature branch can open and merge a PR straight into main while passing every other gate. That is exactly how #9 bypassed dev.
The required check is named integration
Branch protection matches on the job name, not on what the job does. If the integration job in .github/workflows/ci.yml is ever renamed, the branch-protection rule silently stops matching anything — GitHub will show the merge button as available even though no equivalent check ran. Renaming that job and updating branch protection have to happen in the same PR.
Releasing (test → prod)
The published version is derived entirely from the git tag via hatch-vcs — there is no version string to bump in pyproject.toml. Pushing a tag is the release action.
.github/workflows/release.yml triggers on any tag matching v* and classifies it as prerelease or final by shape:
| Tag shape | Example | Classified as | Destination |
|---|---|---|---|
vX.Y.Z |
v0.2.0 |
final | PyPI + GitHub Release |
anything else matching v* |
v0.2.0rc1, v0.2.0a1, v0.2.0b1, v0.2.0.dev1 |
prerelease | TestPyPI + GitHub prerelease |
Both paths run the same build job first (checkout with full history/tags for hatch-vcs, uv sync --extra test, pytest -q as a guard, uv build), then fan out to testpypi or pypi based on the classification, then create the GitHub release/prerelease from the built artifacts. Publishing uses OIDC Trusted Publishing (id-token: write) — there are no PyPI tokens stored anywhere in the repo or its secrets.
Cut a prerelease (TestPyPI)
From an up-to-date main:
This runs the build job, then the testpypi job (environment testpypi, no approval required), then creates a GitHub prerelease. Watch it in the Actions tab or with:
Verify the published artifact installs before promoting further. TestPyPI is not a mirror of PyPI. It happens to carry unrelated uploads under the names duckdb and pyyaml (newest stable 1.3.2.post1 and 3.11 respectively — neither satisfies this project's floors), and nothing at all under httpx. So the runtime dependencies have to resolve against real PyPI:
Cut a final release (PyPI)
Once the prerelease has been verified, tag the same commit (or a later one on main) with the final version:
This runs build, then the pypi job — which waits for approval in the pypi GitHub Environment (the required reviewer configured during one-time setup) before it publishes — then creates the GitHub Release. Approve the deployment from the Actions run page (or gh run watch, which surfaces the pending-approval state) when you're ready for it to go live.
Once it lands, confirm the release from a consumer's position — the console scripts are the part most likely to be wrong, since they change whenever a tool is added or removed:
The user-facing install instructions are on the Installation page; update it in the same PR whenever a release adds, removes, or renames a CLI.
Safety / gotchas
- The required reviewer on the
pypiEnvironment is the only gate protecting production PyPI. If that environment is created without a required reviewer (or not created at all), a finalvX.Y.Ztag publishes to real PyPI unattended — there is no other approval step in the path. Don't skip step 3 of the one-time setup below. release.ymltriggers on anyv*tag pushed to any branch, not justmain. Only create release tags onmain, per the branch model above — an accidentalv1.0.0tag pushed on a feature branch or ondevwould build from that commit and publish it just the same.
One-time setup checklist
These are manual, out-of-band steps — they only need to happen once per repo, not per release.
-
Create and default
dev.Then in GitHub: Settings → General → Default branch → switch it to
dev. New PRs andgit clonewill targetdevfrom then on. -
Register PyPI + TestPyPI Trusted Publishers. Both PyPI and TestPyPI support registering a pending publisher before the project has ever been uploaded — this is what lets the very first
pypi/testpypijob authenticate via OIDC with nothing to configure on the GitHub side beyond the environment name. On each of pypi.org and test.pypi.org, add a pending publisher with:Field Value PyPI project name taxi-seedOwner andrekammanRepository name taxi-seedWorkflow name release.ymlEnvironment name pypi(on pypi.org) /testpypi(on test.pypi.org) -
Create the GitHub Environments. Settings → Environments:
testpypi— no protection rules. The prerelease flow should be fast and unattended.pypi— add yourself (or the release approver) as a required reviewer. This is the actual production approval gate: thepypijob inrelease.ymlwill not run until someone approves the deployment.
-
Turn on branch protection for both
devandmain. Both require theintegrationcheck —devbecause feature-branch PRs land there, andmainbecausedev→mainpromotion PRs need the same gate to keepmainreleasable.mainadditionally requiresbranch-guard, which is what actually enforces the branch model:Note: GitHub only lists a check name as selectable in the Settings UI after it has run at least once on the repo (e.g. via one open PR) — but the API above accepts the
integrationname regardless of whether the UI has seen it yet, so this can be run before or after that first PR.
Optional — custom docs domain
The docs site can be pointed at taxi-seed.com instead of the default andrekamman.github.io/taxi-seed/ URL. This is not required for anything above and can be done at any time:
-
Add a
docs/CNAMEfile to the repo containing exactly:mkdocs gh-deploycopies everything underdocs/into the published site, so this file will land at the root ofgh-pagesautomatically on the next docs deploy. 2. In GitHub: Settings → Pages, set the custom domain totaxi-seed.comand enable "Enforce HTTPS" once the certificate has provisioned. 3. Add the DNS records at your registrar: either anALIAS/ANAME(or the four GitHub PagesArecords) for the apex domain, or aCNAMErecord if serving from a subdomain — pointing atandrekamman.github.io.