#110

chore(deploy): manage the toolchain with mise

Openjasonappahwants to merge intofrom

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 both post_office units

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.0

Precompiled, ~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.0

pyenv, 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.10

Fixed 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.3

The 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-reload now runs before the service restarts. The units are symlinks into the repo, so a git pull can change what a unit does — reloading after the restarts meant a unit change only took effect on the following deploy. This is what makes the ExecStart change here land on the first deploy rather than the second.
  • init.sh is 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 cd to the repo root, so they no longer depend on the caller's working directory.
  • CI uses jdx/mise-action@v4 and reads the same .python-version as 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 check clean
  • 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 exec works invoked by absolute path with a minimal PATH (/usr/bin:/bin) — the systemd case
  • shellcheck -x clean on all three scripts
  • caveat worth knowing: an in-project .venv shadows mise's interpreter for mise exec -- python and mise which python. That's why the script uses mise where. Production doesn't use in-project virtualenvs, but anyone setting PIPENV_VENV_IN_PROJECT locally 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):

beforeafter
Python3.11.113.11.11 — unchanged
Django5.1.155.1.15
virtualenvsame pathnot recreated
loginHTTP 200HTTP 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