Skip to content

Publishing guide

How racinglines reaches PyPI and the public repo. Read The rules first. They are the owner's (18 Oct 2026), and every release, script and workflow below exists to keep them.

The rules

  1. Two repos, one direction. racinglines/racinglines is private: all development, full history, Jev, the TUI, other sports, data, ops. racinglines/racinglines-public is public: the released code only, one commit per release, no development history. Code moves private → public only, and only through the export.
  2. The export is the only door. A file reaches racinglines-public or a PyPI package only if oss/manifest.toml lists it. scripts/oss/export.py copies just those tracked files and fails, with nothing pushed or built, when a file matches a banned pattern (real sportsbook names, personal paths, the bucket), is over the size cap (data never ships), or would leave an import broken. Anything not on the allowlist stays private, including new files by default. To ship something, add it to the manifest in a reviewed PR. Never copy files to the public repo by hand.
  3. Every PyPI version is one public commit and tag. vX on PyPI = tag vX on racinglines-public = one commit whose message names the private source commit. Nothing is uploaded that isn't in the public repo.
  4. The private repo never uploads (from 1.0.1). It tags and exports; racinglines-public's own workflow builds and publishes, so PyPI's provenance (the attestation) names a public commit anyone can read.
  5. The real upload waits on the owner. The pypi job waits for the owner's approval in GitHub. A PyPI version can be yanked but never reused or replaced, so a bad release is fixed with the next patch version.
  6. What the package is. A snapshot of how the fantasy run started: F1 trading, the UCI downhill sim, the MK8DX (kart) demo and the web app with its live viewer (pip install racinglines[web]). No Jev, no TUI, no other sports. Fantasy bug fixes and tweaks ship as 1.x. A private or internal package is a later decision, not now.
  7. Test from TestPyPI the safe way. Install only racinglines from TestPyPI (--no-deps) and every dependency from PyPI (see Each release).

Where things stand (19 Oct 2026)

What State
PyPI racinglines 1.0.1 (19 Oct), the first release through the flow below: built and uploaded by racinglines-public, so its attestation names racinglines/racinglines-public (tag v1.0.1, private source fab9c6b). 1.0.0 (18 Oct) was uploaded by the private repo
racinglines-public One commit per release: v1.0.0 (pushed by hand), v1.0.1rc2, v1.0.1rc3, v1.0.1 (pushed by the private release job)
Next Switch-over step 5: remove the private repo's publishers and environments

How a release flows (from 1.0.1)

Step Where What happens
1. Tag private racinglines/racinglines The owner tags a commit on staging or main vX (rc first) from the Mac
2. Export private, .github/workflows/release.yml (from deploy/ci/release.yml) Allowlist export of the tag at version X, then a build and smoke test of a copy (RELEASE=1: the plain MIT LICENSE, the version must match). Any failure stops here
3. Push private → public The export replaces racinglines-public's tree as one commit (racinglines X (PyPI X; export of the private source at <sha>)) with tag vX, using the write deploy key. It refuses if the tag already exists
4. Publish racinglines-public, .github/workflows/release.yml (from oss/overlay/) Builds the tagged tree as it is, smoke-tests it, uploads to TestPyPI, then to PyPI after the owner approves pypi. rc/a/b tags stop after TestPyPI

Outside contributions to racinglines-public are ported into private by hand. The next export replaces the public tree, so a change that wasn't ported disappears. The public repo's CONTRIBUTING.md (oss/overlay/) says so.

What each release ships

Version Install What works
1.0.1 the same 1.0.0 plus: published from racinglines-public, GitHub links and the from-source steps point there, CHANGELOG.md
1.0.0 pip install racinglines, pip install "racinglines[web]" Tiers core, mtb_dh, web of the manifest: the F1 engine and CLI, the downhill sim, the kart demo, the database schema and migrations, the web app (Markets, Trade, History, Lab, the live viewer), F$ fantasy trading. web --demo creates the demo accounts; / is the sign-in page. The README's "Everything" steps are the test

The scope is the manifest's, not this page's: python scripts/oss/export.py --list prints the file list, and the export prints the lazy imports of left-out modules (commands that stop with an ImportError in the package).

How an installed package finds its files

racinglines/paths.py: in a checkout (any folder with pyproject.toml next to the package, including pip install -e . on the VM and the Mac) nothing changes. In a wheel, the committed configs sports/, markets/, exchanges/ and migrations/ are read from the copies bundled at racinglines/_bundled/ (the file list is in setup.py), and data/ defaults to ~/.racinglines/data (RACINGLINES_DATA still overrides it).

The pieces

File What it does
oss/manifest.toml The allowlist: tiers of include/exclude globs, the banned patterns and the size cap
scripts/oss/export.py Copies a ref's tracked files that the manifest allows, lays oss/overlay/ over them, sets the version; fails on the scrub, the size cap or a broken import
oss/overlay/ What only the public tree has: the plain MIT LICENSE, the README.md (the PyPI page), the CHANGELOG.md, the CONTRIBUTING.md, .github/workflows/release.yml (the public publish workflow)
deploy/ci/release.yml The private workflow: tag → export → smoke → push to racinglines-public. The owner copies it to .github/workflows/release.yml
scripts/release/package_smoke.sh Builds the sdist and wheel, twine check --strict, checks the bundled configs are in and no tests/data/docs are, installs the wheel in a fresh venv and runs the CLI from an empty folder. RELEASE=1 also requires the plain MIT LICENSE
setup.py The package's file list (packages, templates, the bundled configs)

Switch-over (owner, once)

Before 1.0.1. Everything is in the browser or on the Mac; no token or password goes in a terminal.

  1. A write deploy key for racinglines-public. Deploy keys need an admin of racinglines-public (the racinglines account; lalligagger is a collaborator, and gh repo deploy-key add as lalligagger answers 404). On the Mac: ssh-keygen -t ed25519 -N "" -C racinglines-release -f ~/tmp/rl-release-key && pbcopy < ~/tmp/rl-release-key.pub (the public half only). In the browser as racinglines: racinglines-public → Settings → Deploy keys → Add, paste, tick Allow write access. Then the private half goes straight from the file into a repository Actions secret of the private repo (not an environment secret, not on racinglines-public): gh secret set PUBLIC_DEPLOY_KEY --repo racinglines/racinglines < ~/tmp/rl-release-key && rm ~/tmp/rl-release-key*. If the release log shows DEPLOY_KEY: empty and Load key ...: error in libcrypto, the secret is missing; if it shows Permission denied (publickey) alone, the public half isn't on racinglines-public. Redo this step either way.
  2. Environments in racinglines-public (Settings → Environments): testpypi with a deployment rule of Ref type Tag, pattern v* (the dropdown defaults to Branch, which doesn't match tags); pypi with Required reviewers = lalligagger and the same Tag rule. Untick "Allow administrators to bypass".
  3. Trusted publishers for racinglines-public. On pypi.org: the project racinglines → Manage → Publishing → add a GitHub publisher: owner racinglines, repository racinglines-public, workflow release.yml, environment pypi. The same on test.pypi.org with environment testpypi.
  4. The private workflow, as the owner's own PR (cloud sessions can't push workflows): cp deploy/ci/release.yml .github/workflows/release.yml on a branch from staging, commit, PR, merge.
  5. After 1.0.1 is on PyPI: remove the racinglines/racinglines publishers on pypi.org and test.pypi.org, and its testpypi/pypi environments, so the private repo can't upload at all.

Each release

The changelog first. Add a ## X (date) entry for the version to oss/overlay/CHANGELOG.md (what a user of the package sees, newest first) in the PR that goes out with it. The export of vX or vXrcN fails without it, and the file ships as racinglines-public's CHANGELOG.md (linked from PyPI's sidebar as Changelog).

Dry run first (TestPyPI only). Tag the commit with an rc version from the Mac (cloud git can't push tags; outside conda, so git is /usr/bin/git):

# LOCAL (Mac)
cd ~/py_dev/racinglines && conda deactivate; /usr/bin/git fetch origin staging && /usr/bin/git log --oneline -1 origin/staging
/usr/bin/git tag v1.0.1rc1 origin/staging && /usr/bin/git push origin v1.0.1rc1

The private run "release" exports, smokes and pushes; then racinglines-public's run "release" builds and uploads to TestPyPI. Install it the way a user would: only racinglines from TestPyPI (--no-deps), every dependency from PyPI. Never let pip resolve dependencies against TestPyPI: anyone can upload there, and squatted names win on version number (1.0.0rc1's test pulled a broken "FASTAPI 1.0" that fails to build).

# LOCAL (Mac): outside conda, so pip and racinglines are the venv's
rm -rf ~/tmp/rl-try && python3.14 -m venv ~/tmp/rl-try && source ~/tmp/rl-try/bin/activate
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --no-deps "racinglines==1.0.1rc1"
pip install "racinglines[web]==1.0.1rc1"
hash -r && which racinglines && cd /tmp && racinglines --version && racinglines check --offline --no-db --sport f1

which racinglines must end in rl-try/bin/racinglines, and the version must be the rc's. Then run the README's "Everything" steps against a throwaway container on a spare port (docker run --name rl-rc-db ... -p 5434:5432 ... with DATABASE_URL=...@localhost:5434/racinglines and RACINGLINES_DATA exported), so the test touches none of your own containers or data. Never remove the racinglines-db container or its volume for a test.

The real one, on the owner's go: tag the same commit vX (git tag v1.0.1 v1.0.1rc1, push it). After TestPyPI, the public run waits on pypi: Review deployments → tick pypi → Approve and deploy. Then check https://pypi.org/project/racinglines/ and pip install racinglines==X with no index flags.

If something goes wrong

  • The export fails (a banned name, an oversized file, a broken import, no changelog entry): nothing was pushed. Fix it in private, tag a new rc.
  • The smoke fails: nothing pushed or uploaded. Same fix.
  • The push says the tag already exists: that version is released; tag the next one.
  • The public run fails before PyPI: the public commit and tag exist but PyPI doesn't have the version. Fix in private and release the next patch version, never re-tag.
  • "Tag vX is not allowed to deploy to pypi": the environment's rule is a Branch rule or missing; make it Ref type Tag, v*, then "Re-run failed jobs".
  • A bad build reached PyPI: yank it on pypi.org (Manage → Releases), never delete, and ship the next patch version.