Targets main and should merge before #107. The point of the ordering: once this is in, the Python upgrade is a .python-version edit and nothing else — no interpreter to install on the host, no virtualenv to recreate by hand. #107 is rebased on top of this and is now 4 files instead of 7.
No tracking issue for this; this PR is the record.
The problem
Our deploy scripts assumed a correctly-provisioned host rather than producing one:
- the right Python "just had to be there" — installed by hand, via pyenv, by whoever set the box up
- pipenv had to be installed globally via apt, purely so the systemd units could see the binary
- every unit file hardcoded
/root/.pyenv/shims/python—gunicorn,discord_bot, and bothpost_officeunits
So an interpreter bump isn't really a code change, it's a manual step on the production host, and the best a script could do was fail loudly afterwards.
What this does
mise install, driven by the deploy scripts, so a host only ever needs mise itself — and init.sh installs that if it's missing:
mise ✓ python@3.11.11 cpython-3.11.11+...-install_only_stripped.tar.gz
mise ✓ pypi:pipenv@2026.8.0Precompiled, ~5s from nothing. The systemd units now run /usr/local/bin/mise exec -- pipenv run … and never name an interpreter. pyenv is no longer used anywhere, and pipenv no longer needs to be installed globally — the README says both explicitly.
.python-version stays the source of truth
mise reads it. This is off by default, so mise.toml turns it on — project-locally, so nobody has to configure their own machine:
[settings]
idiomatic_version_file_enable_tools = ["python"]python 3.11.11 .../.python-version 3.11.11
pypi:pipenv 2026.8.0 .../mise.toml 2026.8.0pyenv, asdf and actions/setup-python all still read that file, so this doesn't force mise on anyone. One sharp edge, commented in the file: a python entry under [tools] would silently outrank .python-version, and mise use python@… writes one — so don't run it.
pipenv is pinned rather than latest; it's the tool that builds the production virtualenv, and a deploy shouldn't pick up a new one on its own.
Three real bugs found while verifying
All would have shipped silently, and all are the exact failure this PR exists to prevent.
1. The venv was built on the wrong Python. pipenv does not consult PATH when creating a virtualenv — with no [requires] python_version in the Pipfile (main has none) it uses the interpreter pipenv itself runs under, and mise installs pipenv into its own virtualenv on an unrelated Python:
.python-version says 3.11.11 -> virtualenv came out on 3.12.10Fixed by passing the interpreter explicitly. mise where gives the tool's install directory, which — unlike mise which or a PATH lookup — can't be shadowed by an activated virtualenv.
2. CI was testing on the wrong Python too. Same root cause, different place: CI doesn't go through run.sh, so pipenv install --dev built the virtualenv on the runner's system Python rather than the version .python-version names:
Using default python from .../pypi-pipenv/.../bin/python 3.12.3The workflow now passes the same --python. Verified after the fix: created virtual environment CPython3.11.11.
3. CI couldn't install pipenv at all. Bare pipenv in [tools] resolves to mise's vfox plugin, which builds a venv in a postinstall hook: fine on macOS, dies on ubuntu-latest with ensurepip … exit status 127. Now pinned to the pypi: backend.
Can mise manage the sockets and services?
No — and it's a hard no rather than a "too complex". mise's systemd support manages user units via systemctl --user, writes them to ~/.config/systemd/user/dev.mise.*, and has no socket unit support at all. We need system-level units running as root plus a gunicorn.socket. So the units stay hand-written; mise only supplies the interpreter they run under.
Also in here
daemon-reloadnow runs before the service restarts. The units are symlinks into the repo, so agit pullcan change what a unit does — reloading after the restarts meant a unit change only took effect on the following deploy. This is what makes theExecStartchange here land on the first deploy rather than the second.init.shis re-runnable (ln -sfn,rm -f,set -e, and it had no shebang) and checks for nginx/postgresql/sudo up front instead of failing part-way through.- both scripts
cdto the repo root, so they no longer depend on the caller's working directory. - CI uses
jdx/mise-action@v4and reads the same.python-versionas production, so there's no separate CI python-version to keep in sync — that's one of the things #107 no longer has to edit. - dropped nodejs from the README's prerequisites — there's no
package.json,.nvmrc, or any JS build in the tree.
Verification
Against main's real Pipfile.lock and Python 3.11.11, with mise state and the virtualenv isolated so my own dev env wasn't touched:
- from an empty toolchain: mise installed 3.11.11 + pipenv 2026.8.0, built the virtualenv on 3.11.11,
manage.py checkclean - exercised the recreate path in both directions (a 3.12 virtualenv against a 3.11 project and vice-versa) — detected, removed, rebuilt on the right version
mise execworks invoked by absolute path with a minimalPATH(/usr/bin:/bin) — the systemd caseshellcheck -xclean on all three scripts- caveat worth knowing: an in-project
.venvshadows mise's interpreter formise exec -- pythonandmise which python. That's why the script usesmise where. Production doesn't use in-project virtualenvs, but anyone settingPIPENV_VENV_IN_PROJECTlocally should know.
End-to-end on a Linux VM
The systemd/nginx half is now actually exercised, on a throwaway Ubuntu 24.04 VM rather than reasoned about.
Upgrade path, from a host built to match production (nginx + postgresql + pyenv-provided Python 3.11.11, virtualenv already on 3.11.11, gunicorn serving through the /root/.pyenv/shims/python units on main):
| before | after | |
|---|---|---|
| Python | 3.11.11 | 3.11.11 — unchanged |
| Django | 5.1.15 | 5.1.15 |
| virtualenv | same path | not recreated |
login | HTTP 200 | HTTP 200 |
./deploy/run.sh, exit 0 in 24s, no manual init.sh: mise installed itself to /usr/local/bin/mise, the units switched to mise exec, gunicorn/nginx/both timers came back active. This PR does not change the interpreter on a host already matching .python-version. (On a host that has drifted off it — I first built the VM on 3.11.15 by accident — it realigns to the declared 3.11.11, which is the intent.)
Greenfield, a second VM with only curl sudo git nginx postgresql — no python, no pipenv, no pyenv, no mise: init.sh exit 0 in 6.6s, run.sh exit 0 in 23s, all services active, /accounts/login/ and a static asset both 200, and the post_office_queue oneshot runs to success through mise.
Idempotency: a second run.sh is 5.9s, doesn't re-trigger the virtualenv recreate, and leaves the site up. init.sh re-runs exit 0.
discord_bot.service fails in the VM on LoginFailure: Improper token has been passed. — the deliberately fake token, not the toolchain. The journal line is prefixed mise[...], so the full mise exec → pipenv → python → discord.py chain ran.
The VM caught a bug that would have broken the production deploy
mise's pypi: backend shells out to uv or pipx, and a freshly provisioned server has neither:
mise ERROR Failed to install pypi:pipenv@2026.8.0:
pipx is required to install pypi:pipenv but was not found.My machine and the GitHub runner both already had uv, so neither surfaced it — CI was green while the real deploy would have failed at the first step. mise.toml now installs uv and declares it as a dependency of pipenv.
Deploying this
Nothing to do on the host. run.sh converges the host itself: it installs mise if absent, links it to /usr/local/bin/mise where the units look for it, and relinks every unit before daemon-reload. All ln -sfn + systemctl enable, no-ops once done.
init.sh keeps only what is genuinely first-time-only and shouldn't repeat on every deploy — the nginx site symlinks and removing nginx's default site.
pyenv can stay installed; nothing reads it anymore.
🤖 Generated with Claude Code