# 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==`, `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](agent.md). ## 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--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 \| 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 (`/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]=="` by hand, or *Help ▸ Check for updates…* in the app on every platform ([Upgrade a bench](../usage/upgrade.md), #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`, `/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]=="` 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 install`s 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 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]=="` 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/` 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](../usage/uninstall.md)). 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](cross_platform.md). ## 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 `/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.