fix(version): a frozen release must not ask git whether it is dirty - #459
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #438 (D5), #439 (D6). Milestone: Desktop distribution (#18).
The bug, demonstrated before it was fixed
A stamped release build reports
(DIRTY)when run from inside any git repository:_gitrunssubprocess.run(["git", ...])with nocwd, so it inherits the process working directory, reads whateverHEADis there, finds it disagrees with the stamp, and marks the build dirty.For a venv deployment this rarely bites —
~/keelis 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:
A warning that fires on correct builds is a warning people learn to ignore, and this is the one that must never be ignored.
The fix, and what it deliberately does not weaken
A frozen bundle trusts its stamp and does not consult git at all — not merely "reaches the right answer", but never spawns the subprocess. On a desktop app that is also a visible pause per
--version, and on a machine with no git it is 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. So:
unknown, nevercheckout(it is not one), which keepsplan_update'ssource != "release"refusal correct.is_packaged()moves tokeel/version.pyandkeel/install.pyre-exports it.keel.versionis 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: what makes the installer implementable
#457 landed the decision logic, and #438 requires the installed version be read from metadata, never by executing the installed binary. A frozen bundle has no
.dist-infofor an installer script to parse — so the installer writeskeel-install.iniinto the program directory.INI rather than JSON because Inno Setup reads INI natively and would otherwise need a JSON parser written in Pascal. A hand-rolled parser deciding whether to overwrite someone's install is not a trade worth making.
keel install-plan --target DIRis the machine interface over it, likekeel versions: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" → confirmation, never a silent overwrite. An installer that crashed while deciding whether to overwrite would be worse than one that asks.
Verified against a real stamped bundle
4018 passed, 3 skipped (18 new).
ruff checkclean repo-wide,mypyclean overkeel+packages.What is left in D5
The release-workflow matrix job (
macos-14,macos-15-intel,windows-latest) and the Inno Setup /.dmgpackaging scripts. Those are the parts I cannot verify locally at all, so they are deliberately not in this PR — everything here was checked against a real frozen binary first.Signing remains gated on certificates: Apple Developer ID at $99/yr, plus a Windows OV cert or Azure Trusted Signing at ~$10/mo. My recommendation for the build job is that it builds always but attaches to a release only when signing succeeded — an unsigned binary that then asks for exchange API keys is the malware silhouette §1 of the PRD warns about, and the pipeline should make shipping it impossible rather than merely unlikely.
🤖 Generated with Claude Code