Packaging and releases

One repository, two distributions on PyPI, one tag.

Distribution

Contains

Built by

Installed with

plesty-bench

plesty/bench — the client

the project’s own pyproject.toml (hub-release of plesty-standard-ci)

uv tool install plesty-bench (developer machine)

plesty-server

plesty/server — the bench application (CLI, console, agent); depends on plesty-bench==<same version>, plesty-lib[report]; extra gui = plesty-lib[gui] and pyside6 pinned to one version

packaging/server_dist.py in release:server-wheel

uv tool install "plesty-server[gui]" (bench)

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

plesty-server-<tag>-windows-setup.exe — Inno Setup (packaging/windows/plesty-server.iss), built by packaging/windows/build-installer.ps1, ~2 MB, per-user, no admin

Start-menu entries (PLESTY Bench, Uninstall), optional desktop shortcut, uninstaller in “Apps & features”

macOS

curl -LsSf <install.sh> | sh (packaging/install.sh; --launcher-only makes just the entry, for a build installed some other way)

~/Applications/PLESTY Bench.app — a launcher with the mark as icon, so it appears in Launchpad

Linux

the same install.sh

a .desktop entry in the application menu

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. installer:windows passes Inno’s SignTool when WINDOWS_SIGN_COMMAND (protected CI variable, a signtool command line with Inno’s $f placeholder) is set, and signs the uninstaller too. OV certificates still accumulate SmartScreen reputation over the first downloads; EV is trusted immediately.

yes — set the variable

macOS

A Developer ID Application certificate (Apple Developer Program) plus notarization with notarytool and a stapled ticket. This needs a real signed bundle shipped from CI rather than the launcher install.sh writes on the machine.

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

hub-release (template)

publishes plesty-bench

release:server-wheel

server_dist.py → publishes plesty-server (same version, same trusted publisher)

installer:windows

saas-windows-medium-amd64: .ico from the mark (Pillow), Inno Setup via chocolatey → setup.exe

release:assets

stamps the tag into install.sh, uploads it and setup.exe to the generic package registry (permanent) and creates the Release: installer, install script, both PyPI links

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.