These are my notes for cutting a berth release. A release is a v* git tag plus
the artifacts the tag-triggered workflow builds: a GitHub Release with the
wheel/sdist attached, and a daemon image on GHCR. I don't publish to PyPI. If I
ever do, I'll add it as an explicit step with trusted publishing.
The current version is 0.5.0. The worked examples below use that; bump them when you bump the version.
The version lives in three places, and all three have to match the tag:
pyproject.toml—version = "0.5.0"src/berth/__init__.py—__version__ = "0.5.0"uv.lock— the project's own entry. Runuv lockafter editing the first two so the lockfile picks up the new version.
Edit the first two by hand, then:
uv lockI also update ../CHANGELOG.md as part of cutting the
release: move the unreleased notes under a new ## 0.5.0 - <date> heading.
A note on dependency bumps, since they touch the lockfile: I don't bump Python
deps by hand here, and Dependabot doesn't do them either — it can't read
uv.lock. They're refreshed by .github/workflows/uv-upgrade.yml, which runs
uv lock --upgrade weekly, validates the result the way CI does, and opens one
PR with the whole upgraded set. Dependabot still handles GitHub Actions and the
npm deps under ui/. None of that touches the >= floors in pyproject.toml;
those are deliberate minimums I raise by hand.
Run this top to bottom from a clean tree before tagging:
uv lock
uv lock --check
uv run --frozen ruff check src tests
uv run --frozen mypy src
uv run --frozen pytest tests/unit tests/integration -q
uvx bandit -q -r src -x src/berth/ui/assets
uvx detect-secrets scan --all-files \
--exclude-files '(^\.venv/|^ui/node_modules/|^src/berth/ui/assets/|^\.mypy_cache/|^\.pytest_cache/|^\.ruff_cache/|^uv\.lock$|^ui/package-lock\.json$)'
cd ui
npm ci
npm audit
npm run build
cd ..
rm -rf dist
uv buildThe npm run build step regenerates the bundled UI under src/berth/ui. The
release workflow rebuilds it too and fails if what's committed is stale, so make
sure the rebuilt UI is committed before you tag.
Before tagging, run the black-box listener probe against the exact staging
deployment shape you plan to publish. The probe (scripts/security_probe.py)
only pokes HTTP-visible boundaries, so point it at a running daemon / reverse
proxy:
python scripts/security_probe.py \
--public-url https://api.example.com:11500 \
--cluster-url https://cluster.example.com:11501 \
--token "$BERTH_API_KEY"For a local daemon or generated/self-signed staging certs, add --insecure to
skip TLS verification:
python scripts/security_probe.py \
--public-url https://127.0.0.1:11500 \
--cluster-url https://127.0.0.1:11501 \
--insecureIt exits non-zero if any check fails. What it checks:
- Both listeners hide the generated FastAPI docs (
/openapi.json,/docs,/redocreturn 404) and serve the baseline browser security headers (CSP,permissions-policy,referrer-policy,x-content-type-options,x-frame-options) on/healthz. - Public listener:
/metrics,/admin/keys, and/v1/chat/completionsrequire auth (401), sensitive routes carrycache-control: no-storeandpragma: no-cache, the cluster CA isn't mounted (/admin/ca.pem→ 404), and agent registration isn't callable there. - Cluster listener: serves the CA for pinned enrollment, rejects an invalid
enrollment token with 403, and does NOT mount the public-only routes
(
/v1/chat/completions,/metrics,/admin/keys→ 404).
When --token is supplied it also runs the authenticated checks: a real
/v1/models call works (200), and the browser stream-ticket boundary holds — a
ticket minted for one stream path can't be used to mint or reach another, and
the non-stream path is rejected.
Then inspect the built wheel for the runtime data that has to ship inside it:
uv run --frozen python -m zipfile -l dist/berth-*.whl \
| rg 'berth/(store/migrations|backends/.*yaml|auth/tiers.yaml|ui/index.html)'git push origin main
git tag -a v0.5.0 -m "berth 0.5.0"
git push origin v0.5.0Pushing the v* tag runs .github/workflows/release.yml. It:
- Rebuilds the UI (
ui), then fails if the committedsrc/berth/uidiffers from the fresh build — so a stale bundle stops the release. - Runs
uv buildand creates a public GitHub Release with auto-generated notes, attaching the.whland.tar.gz. - Builds and pushes the daemon image to GHCR with build provenance (
mode=max) and an SBOM, tagged with the version andlatest:
ghcr.io/mapika/berth/daemon:v0.5.0
ghcr.io/mapika/berth/daemon:latest
It does not publish to PyPI.
uv tool install \
https://github.com/Mapika/berth/releases/download/v0.5.0/berth-0.5.0-py3-none-any.whlQuick check that the wheel installs and the CLI runs in a throwaway venv:
tmp=$(mktemp -d)
uv venv "$tmp/.venv"
"$tmp/.venv/bin/python" -m pip install dist/berth-0.5.0-py3-none-any.whl
"$tmp/.venv/bin/berth" --help