Skip to content

fix(version): a frozen release must not ask git whether it is dirty - #459

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/d5-desktop-build-job
Aug 20, 2026
Merged

fix(version): a frozen release must not ask git whether it is dirty#459
eaitbrahim merged 1 commit into
mainfrom
feat/d5-desktop-build-job

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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:

$ cd keel/        ; ./keel --version    →  keel 0.10.0+aaaaaaaaaaaa (DIRTY) [release]
$ cd /tmp/other   ; ./keel --version    →  keel 0.10.0+aaaaaaaaaaaa (DIRTY) [release]

_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.

For a venv deployment this 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.

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:

  • a test pins that a venv release is still marked dirty when git disagrees — the fix must not weaken the case the check was written for;
  • an unstamped bundle is unknown, never checkout (it is not one), which 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: 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-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. 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 proceed without asking
exit 2 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" → 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

$ keel --version          # from the keel repo AND from an unrelated git repo
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 keel 0.11.0 with the OLDER 0.10.0."
                                                         + the forward-only migrations warning

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

What is left in D5

The release-workflow matrix job (macos-14, macos-15-intel, windows-latest) and the Inno Setup / .dmg packaging 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

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>
@eaitbrahim
eaitbrahim merged commit e6980f9 into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/d5-desktop-build-job branch August 20, 2026 20:19
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