Packaging and releases
One repository, two distributions on PyPI, one tag.
Distribution |
Contains |
Built by |
Installed with |
|---|---|---|---|
|
|
the project’s own |
|
|
|
|
|
Tagging vX.Y.Z on main publishes both through PyPI trusted publishing
(OIDC — GitLab namespace plesty/core, project plesty-bench,
.gitlab-ci.yml), one publisher per distribution told apart by the job’s
GitLab environment (pypi-bench, pypi-server), and creates the GitLab
Release. Why the packages are split at all: The agent and the
client.
The light installers
The installers carry no application code. They install uv if it is
missing and then download the application and its dependencies from PyPI at
the pinned version; then they make the application visible:
Platform |
Installer |
What it adds |
|---|---|---|
Windows |
|
Start-menu entries (PLESTY Bench, Uninstall), optional desktop shortcut, uninstaller in “Apps & features” |
macOS |
|
|
Linux |
the same |
a |
plesty-server / plesty-server-gui land on uv’s bin dir (~/.local/bin);
the console remembers the last fleet it used (<home>/last-fleet) so a
double-click reopens it.
Nothing is frozen into a per-OS bundle, which is why an upgrade is one command
against the install that is already there and not a second download of the
installer — uv tool install --force "plesty-server[gui]==<version>" by
hand, or Help ▸ Check for updates… in the app on every platform
(Upgrade a bench, #29). uv tool upgrade would keep the
exact version the installer pinned.
The app cannot replace the files of its own running environment — Windows
refuses outright — so Update hands the install to a helper outside it
(services.updates): assets/update.ps1 with pip into the Windows
installer’s environment, assets/update.sh with uv tool install --force
elsewhere. The app closes first, asking if servers or jobs run, and the helper
opens the Updating PLESTY Bench window (assets/update_window.py, Tk, so it
needs nothing the update replaces), which follows update.log and, once the
exit code appears, says how it went and offers to start the app. The helper
writes each log line until it is written and the whole log before the exit
code, since the window reading it, or a virus scanner, may hold it for a
moment. A failed install leaves the installed version in place; Help ▸
About shows the last update from its record. The log and record are kept
outside the environment being replaced: %LOCALAPPDATA%\PLESTY Bench on
Windows, ~/.local/state/plesty-bench elsewhere.
When the console starts it asks PyPI, at most once a day and off the GUI
thread, whether a newer release exists (startup_update,
<home>/update-check.json), and names it in the status bar (#39). No network
says nothing; a source checkout is not checked.
Where the application lands, and why Windows differs
macOS and Linux run uv tool install "plesty-server[gui]==<version>" and the
application lives in uv’s tool environment. Windows does not: it builds a
plain virtual environment under %LOCALAPPDATA%\PLESTY Bench\app from a uv
Python and pip installs into that, then copies the two commands to
~\.local\bin.
The reason is one measured failure. uv writes each console-script launcher to
a temp file and reopens it to patch its PE resources, and an on-access virus
scanner blocks the reopen — os error -2147024786, always naming the
pyside6-essentials wheel. plesty-server[gui] builds about 25 launchers, 23
of them PySide6 tools nothing here calls, so a per-file failure rate near 1%
is a per-install failure rate near 20%. A bench under a policy-managed scanner
failed three installs in a row, and the exclusion that would fix it is an IT
action no user of ours can take. uv 0.12 cannot be told to skip
launchers it does not need; --with-executables-from picks what is linked
onto PATH, not what is built. pip writes a launcher as a single write of stub
shebang + zip payload to its final path, reopening nothing, so the failure cannot occur. The cost is a slower install and a Windows-only update and uninstall path.
The pinned version
Both installers install the release they were built for, never latest and never a development version — a bench is exactly the release it downloaded, and an explicit upgrade is the only thing that moves it forward.
The pin must be a version that exists on PyPI, so it comes from the tag, not
from the working tree: installer:windows derives it from $CI_COMMIT_TAG,
and plesty-server.iss refuses to compile when it carries .dev or a +
local segment (what versioningit yields off a tag — 0.2.3.dev7+abc1234,
0.2.2+modified — none of which are published). release:assets stamps the
same version into install.sh in place of @PLESTY_VERSION@; run from a
checkout the placeholder survives and the install is unpinned.
PLESTY_SERVER_VERSION overrides it, and release:server-wheel fails if the
version it publishes is not the tag.
Building the Windows installer
packaging/windows/build-installer.ps1 has two modes, and the difference is
what the wizard installs:
powershell -ExecutionPolicy Bypass -File .\packaging\windows\build-installer.ps1 -Mode Test
powershell -ExecutionPolicy Bypass -File .\packaging\windows\build-installer.ps1 -Mode Release -Version 0.2.5
The -ExecutionPolicy Bypass is not optional on a default box: an unsigned
.ps1 will not run otherwise. And every .ps1 here is ASCII only —
Windows PowerShell 5.1 reads a file without a BOM as cp1252, where the third
byte of an em-dash decodes to a smart quote, which PowerShell accepts as a
string delimiter. One em-dash in a comment silently opens a string and the file
stops parsing.
Release is what ships: the wizard installs
"plesty-server[gui]==<version>" from PyPI, so the version must be a real
release — the Inno script refuses a .dev or +local version outright.
Test is how a branch is put through the real wizard: the version is the
working tree’s (0.3.0.dev3+0ca7c6c), both distributions are built from the
tree, and the wheels are carried inside the installer, so what gets
installed is the code in front of you. The output is named …-TEST-… and every
wizard page says so. The wheels are copied into {app}\wheels, which
[InstallDelete] empties first: [Files] only adds, and without it every
test install left its wheels beside the last one’s (#47). A test build off a
maintenance line is numbered after that line’s newest release
(packaging/versioning.py), since its releases are tagged on main and
git describe would otherwise find only the line’s first tag.
That distinction is not cosmetic. The wizard is a process context of its own —
a process descended from setup.exe cannot traverse a directory junction, so
uv behaves differently under it than under a shell — and a defect of that
kind can only be reproduced by running the real installer. Without a test mode
the only way to exercise the wizard was to ship a release first.
install.sh has the same two modes on macOS and Linux: --mode=test builds
both wheels from the checkout and installs those, --mode=release (the
default) installs from PyPI — plus --launcher-only for a build that is
already installed some other way.
An installer/<name> branch builds a release installer in CI, pinned to
the last tag (or INSTALLER_VERSION) — no wheels from the branch are carried.
It proves a change to the Inno script end to end before tagging; it does not
test the branch’s code. For that, build -Mode Test locally.
Signing
Unsigned installers are refused by name: Windows SmartScreen says “Windows protected your PC — Unknown publisher”, macOS Gatekeeper “unidentified developer”. Both need a paid identity; the build is wired for it but ships unsigned until one exists.
What removes the warning |
Wired |
|
|---|---|---|
Windows |
An Authenticode certificate. |
yes — set the variable |
macOS |
A Developer ID Application certificate (Apple Developer Program) plus notarization with |
no |
install.sh does clear com.apple.quarantine from the launcher it writes and
ad-hoc signs it, so a locally built bundle opens by double-click — the bundle
is created on the machine at the user’s request, and the flag it inherits from
the downloaded script is what Gatekeeper would otherwise object to. That is not
a substitute for Developer ID on anything downloaded as an app.
Uninstalling: one form, not a chain of questions
Removing the application and wiping the bench are different acts, and the
uninstaller used to be able to do only the first — uv tool uninstall plesty-server, home left alone, no way to say otherwise. Decommissioning a
machine then meant deleting a directory by hand.
The Windows uninstaller asks once, on a check list with a row per item (what the rows are: Uninstall a bench). One form rather than a chain of yes/no boxes, because four questions in a row is worse than the problem it solves: nothing is on screen at once, there is no way to see what the whole answer adds up to, and no way back to a box already dismissed. Leaving every box unticked is the “keep the data” answer, and it is the default.
The rows come from the application: the uninstaller runs plesty-server purge --list --tsv and builds the list from the file it writes, a heading per part
with its items under it. It passes back the ticked items’ keys in a file
(-PartsFile), because fleet and device names would not survive a command
line. When the application cannot be asked — it no longer starts — the
uninstaller falls back to a fixed form with a box per part, where Logs
carries state and tools with it: both rebuild themselves.
Cancel skips uninstall.ps1, so the application environment and the bench
home stay. It cannot undo what Inno has already done by the time the form is
shown: its own files, the Start-menu entries and the Apps & features entry
are gone, and setup.exe must be run again to restore them.
The parts and their descriptions belong to the application, not to the wizard:
uninstall.ps1 passes the answer to plesty-server purge rather than deleting
directories itself, and removes the application environment and the two
launchers in ~\.local\bin last, because purge runs from them — then
any older install left in uv’s tool environment.
A layout known to a PowerShell script as well as to the application would be a
second copy of that knowledge, going quietly out of date the next time the home
grew a directory.
--yes is required for anything destructive, and an unknown part name is
refused before anything is removed rather than after — a typo must not silently
keep data somebody asked to be rid of. A file that will not delete is reported
and the rest still goes, because an uninstall that stops at the first locked
file leaves more behind than one that carries on and says what it could not
take.
The CI jobs on a tag
Job |
Does |
|---|---|
|
publishes |
|
|
|
|
|
stamps the tag into |
tests:windows is the other job that matters here: the whole suite on a
saas-windows-medium-amd64 runner, which is what the pragma: no cover markers
in host.py are a claim about. See Cross-platform
development.
What the bench still needs
uv (the installers install it), and on macOS and Linux git for modules
installed from a repository — a Windows bench fetches a portable one into
<home>/tools/git for itself. Python is not needed — uv manages interpreters for the tool
environment and for every device environment.
Re-executing itself — plesty/server/reexec.py
Jobs and the detached launcher are plesty-server processes too: from a tool
environment or a checkout that is python -m plesty.server … /
python -m plesty.server.services.launch with the environment’s interpreter.
reexec is the one module that would also know a frozen bundle, should one
ever be built again.