Skip to content

feat(install): what an installer must decide, in one testable place - #457

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/d6-packaged-install-path
Aug 20, 2026
Merged

feat(install): what an installer must decide, in one testable place#457
eaitbrahim merged 1 commit into
mainfrom
feat/d6-packaged-install-path

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Refs #439 (D6), #438 (D5). Milestone: Desktop distribution (#18).

Implements the install-location and existing-install requirements added to #438, and the "either way" half of #439.

The decision, and its consequence

#439 recommends option A: the desktop product has no self-update. Bundle-aware self-update buys convenience and costs an update channel that must itself be secured — and for a tool that moves real money, a user deliberately downloading a signed installer is arguably the better trust posture.

That has a consequence worth taking seriously: the installer is the update path. So "what should happen when this build meets the one already on disk" is a question something has to answer correctly every single time. An Inno Setup script and a .pkg postinstall are both places where that answer cannot be tested, so it lives in neither.

The rule that is not obvious

"Versions differ, so update" is right in one direction only.

keel/data/db.py:499 migrates with if current < target and ships no down-migrations. A database already at schema N, opened by a build that expects N−2, does not fail loudly — migrate finds nothing to apply and returns, and the old code then runs against tables and columns it was never written against.

Silence is the entire hazard. So:

installed → incoming decision proceeds silently?
nothing FRESH yes
0.10.0 → 0.11.0 UPGRADE yes
0.10.0 → 0.10.0 REINSTALL no — confirm
0.10.0 → 0.9.0 DOWNGRADE no — confirm + warn
0.10.0 → nightly UNCOMPARABLE no — confirm + warn

An uncomparable pair carries the downgrade warning too, because an uncomparable pair might be a downgrade. The warning names the recovery and not just the risk: restore a database backup taken before the upgrade, rather than running the older build anyway.

"Only a fresh install or a genuine upgrade proceeds silently" is itself a test, so a fourth silent path cannot be added without deleting an assertion.

The trap this avoids

The program directory and the deployment directory are different places, and conflating them is how an upgrade destroys an operator's work — the program directory is replaced wholesale, so a config that lived there would not survive one.

default_deployment_dir delegates to keel_core.paths.app_data_dir (#434) rather than restating it. An installer that proposed a folder the runtime does not discover would produce a deployment that appears empty on first launch: config written, database written, and a dashboard reporting a healthy install with no history.

NEVER_TOUCHED names every piece of operator state and is tested against real filenames, not eyeballed — so a typo in keel*.db cannot silently protect nothing.

program deployment
macOS /Applications/keel.app, falling back to ~/Applications ~/Library/Application Support/keel
Windows %LOCALAPPDATA%\Programs\keel %LOCALAPPDATA%\keel

Per-user on both, because an elevation prompt on a first run is precisely the friction this milestone exists to remove. Windows needs no fallback — its default is already per-user, and inventing one would be a second path for no reason.

The message that reaches a desktop user

Every refusal keel update produces today is correct and useless to one: they talk about site-packages layouts and tell the reader to put uv on PATH. A packaged user has no venv and no uv, and never will.

is_packaged() now refuses first, naming the real update path — download the next signed release, and your config, database and credentials stay exactly as they are. Driven through the real plan_update against an otherwise-valid deployment layout, so the packaged refusal is what's being observed rather than one the layout would have produced anyway.

Both freezer markers are checked: PyInstaller sets sys.frozen for every build mode but sys._MEIPASS only for --onefile. A false negative is the exact outcome #439 exists to stop.

Verification

3988 passed, 3 skipped (22 new). ruff check clean repo-wide, mypy clean over keel + packages.

Version comparison has one home — plan_install reads keel.commands.update.version_key rather than parsing semver again, pinned by test. A second reader would disagree with the first on exactly the strings nobody tested.

What this does not do

Build anything. The PyInstaller spec, the Inno Setup script, the .app bundle and the signed release job are D5 (#438), and D5 needs certificates I cannot obtain — Apple Developer ID at $99/yr, and a Windows OV cert or Azure Trusted Signing. This is the logic those scripts will call, landed first so it arrives tested rather than embedded in a build script where it never can be.

🤖 Generated with Claude Code

#439's decision is option A: the desktop product has no self-update. Bundle-aware self-update
buys convenience and costs an update channel that must itself be secured, and for a tool that
moves real money a user deliberately downloading a signed installer is the better trust posture.

That decision has a consequence worth taking seriously: **the installer is the update path**, so
"what should happen when this build meets the one already on disk" is a question something has to
answer correctly every single time. An Inno Setup script and a `.pkg` postinstall are both places
where that answer cannot be tested, so it does not live in either. It lives in `keel/install.py`,
which the installer calls and `keel update` reads to explain itself.

THE RULE THAT IS NOT OBVIOUS. "Versions differ, so update" is right in one direction only.
`keel/data/db.py` migrates with `if current < target` and ships no down-migrations. A database
already at schema N, opened by a build that expects N-2, does NOT fail loudly: `migrate` finds
nothing to apply and returns, and the old code then runs against tables and columns it was never
written against. Silence is the entire hazard. So a downgrade is a confirmation carrying a
specific warning -- including the recovery, which is a database backup taken BEFORE the upgrade,
never running the older build anyway. An uncomparable pair carries the same warning, because an
uncomparable pair might BE a downgrade.

Only two outcomes proceed silently: a fresh install, and a genuine upgrade. Same-version and
downgrade both stop and ask. That closed statement is itself a test.

THE TRAP THIS AVOIDS. The program directory and the deployment directory are different places,
and conflating them is how an upgrade destroys an operator's work: the program directory is
replaced WHOLESALE, so a config that lived there would not survive one. `default_deployment_dir`
delegates to `keel_core.paths.app_data_dir` (#434) rather than restating it -- an installer that
proposed a folder the runtime does not discover would produce a deployment that appears EMPTY on
first launch: config written, database written, and a dashboard reporting a healthy install with
no history. `NEVER_TOUCHED` names every piece of operator state, and is tested against real
filenames rather than eyeballed, so a typo in `keel*.db` cannot silently protect nothing.

Defaults are per-user on both platforms, because an elevation prompt on a first run is precisely
the friction this milestone exists to remove. macOS offers `~/Applications` when `/Applications`
is not writable; Windows needs no fallback because its default is already per-user, and inventing
one would be a second path for no reason.

AND THE MESSAGE THAT REACHES A DESKTOP USER. Every refusal `keel update` produces today is
correct and useless to one: they talk about `site-packages` layouts and tell the reader to put
`uv` on PATH. A packaged user has no venv and no `uv` and never will. `is_packaged()` now refuses
FIRST, naming the actual update path -- download the next signed release, and your config,
database and credentials stay exactly as they are. Both freezer markers are checked, because
PyInstaller sets `sys.frozen` for every build mode but `sys._MEIPASS` only for `--onefile`, and a
false negative here is the exact outcome #439 exists to stop.

Version comparison has one home: `plan_install` reads `keel.commands.update.version_key` rather
than parsing semver again, pinned by test. A second reader would disagree with the first on
exactly the strings nobody tested.

3988 passed, 3 skipped (22 new). ruff clean repo-wide; mypy clean over keel + packages.

Refs #439, #438, #18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit eef5f16 into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/d6-packaged-install-path branch August 20, 2026 19:48
eaitbrahim added a commit that referenced this pull request Aug 20, 2026
…459)

A stamped release build reports `(DIRTY)` when run from inside ANY git repository. `_git` runs
`subprocess.run(["git", ...])` with no `cwd`, so it inherits the process working directory, reads
whatever HEAD is there, finds it disagrees with the stamp, and marks the build dirty. Demonstrated
before fixing: the same stamped build reported clean in its own checkout and DIRTY from an
unrelated repo two directories away.

For a venv deployment that rarely bites -- `~/keel` is not a git repo. For a packaged desktop app
it is the normal case, because a double-clicked binary runs wherever the user happens to be. And
the consequence is not cosmetic: a legitimate signed release prints

    warning: this build is NOT reproducible -- it does not correspond to a commit.
    Do not run it against live funds.

A warning that fires on correct builds is a warning people learn to ignore, and this is the one
that must never be ignored.

A frozen bundle now trusts its stamp and does not consult git at all -- not merely "reaches the
right answer", but never spawns the subprocess, which on a desktop app is also a visible pause per
`--version` and, on a machine with no git, an exception handler doing nothing useful. The
stale-stamp hazard the cross-check exists for cannot arise in a bundle: there is no working tree
to have edited. A test pins that a VENV release is still marked dirty when git disagrees, because
the fix must not weaken the case the check was written for. An unstamped bundle is `unknown`,
never `checkout` -- it is not one -- which also keeps `plan_update`'s `source != "release"`
refusal correct.

`is_packaged()` moves to `keel/version.py` and `keel/install.py` re-exports it. `keel.version` is
a leaf and "how was this built, and how is it running" is its subject; two detectors would
eventually disagree about the same process, which a test now forbids.

ALSO: the marker and the command that make the installer implementable.

#457 landed the decision logic and #438 requires the installed version be read from METADATA
rather than by executing the installed binary. A frozen bundle has no `.dist-info` for an
installer script to parse, so the installer writes `keel-install.ini` into the PROGRAM directory
-- INI rather than JSON because Inno Setup reads INI natively and would otherwise need a JSON
parser written in Pascal, and a hand-rolled parser deciding whether to overwrite someone's install
is not a trade worth making.

`keel install-plan --target DIR` is the machine interface over it, like `keel versions`: exit 0 to
proceed, exit 2 to stop and confirm, so a script that reads nothing but the status still fails
safe. Missing, unreadable, malformed and empty markers all read as "cannot establish what is
installed", which becomes a confirmation rather than a silent overwrite -- an installer that
crashed while deciding whether to overwrite would be worse than one that asks.

Verified against a real stamped PyInstaller bundle:

    $ keel --version              # from the keel repo AND from an unrelated one
    keel 0.10.0+52a035aa1111 [release]

    $ keel install-plan --target <empty>            -> exit 0, "Install keel 0.10.0."
    $ keel install-plan --target <holding 0.11.0>   -> exit 2, "Replace ... with the OLDER ...",
                                                       plus the forward-only migrations warning

4018 passed, 3 skipped (18 new). ruff clean repo-wide; mypy clean over keel + packages.

Refs #438, #439, #18.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant