feat(packaging): a double-clickable macOS app, and a release job that cannot ship it unsigned - #463
Merged
Merged
Conversation
… cannot ship it unsigned The macOS half of #438, built and run rather than described. `packaging/macos_app.sh` wraps a frozen bundle into a `.app` and a 20MB compressed `.dmg`. The one design decision in it: **the app launches `keel serve`, not the CLI.** An app launched from Finder has no controlling terminal at all -- the packaging research rates that the single biggest technical risk in this milestone, above code signing -- so a bundle whose entry point were the CLI would open, find no tty, refuse every gated action and exit with nothing on screen. The console binary ships inside the same `.app`, so a terminal user still gets the full CLI from `keel.app/Contents/Resources/keel/keel`. One artifact, both audiences, no second build. The launcher deliberately does NOT set a working directory. Finder launches with cwd `/`, which was the blocker D1 (#434) removed; `keel_core.paths` now resolves state to the OS app-data directory, and a `cd` here would override that with a guess. It only ensures the state directory exists and tees the server's output into `serve.log` beside it, so "why did it not start" is answerable from the folder the operator already knows about. VERIFIED THE WAY FINDER WOULD DO IT -- cwd `/`, stdin closed, no controlling terminal: 200 / 200 /setup 200 /gates 200 /venues 303 POST /setup/config 303 POST /setup/database 303 POST /setup/rules state created: config.yaml, keel.db, serve.log That is the milestone's whole promise, demonstrated: a working paper deployment built from a browser, on a machine with nothing on it, with no command typed and no terminal involved. THE RELEASE JOB, AND WHY IT DEFAULTS TO NOT PUBLISHING. An unsigned binary that then asks for exchange API keys is the shape of malware distribution -- the desktop PRD opens on a live example found while surveying this space. For a project whose proposition is auditability, shipping that silhouette without OS-level verification would be worse than shipping no installer. So `desktop: publish` FAILS while signing is unconfigured, rather than quietly attaching an unsigned build to a release, and the default is `build`: the safe value is the one you get by not thinking about it. Signing is left ABSENT rather than stubbed, because a step that silently does nothing when a secret is missing is precisely how an unsigned artifact comes to look signed. The smoke step is the part that earns its keep. Three of the four ways a bundle breaks are silent -- it starts cleanly and has no venues, or no version identity, or no templates (#458) -- so every assertion there corresponds to one of them, including that the dev-only fake venue never reaches a shipped artifact. Windows ships a zip for now. Inno Setup is the intended installer and carries the install-path and version-decision UX #438 specifies, but it cannot be tested anywhere in this repository today; an installer nobody has run is a worse artifact than an archive everyone understands. `tests/test_desktop_packaging.py` pins what cannot be run here: that publishing unsigned is impossible by accident, that no second route to a release asset exists, that the job builds the tag it released rather than whatever main drifted to, that stamping precedes freezing, and that each of the four silent failures is still checked. The signing assertion excludes comment lines -- the script explains in prose which commands signing will need, and a test that forbade the words would forbid documenting them -- and it was mutation-checked by adding a `codesign` call. 4030 passed, 3 skipped (12 new). ruff clean repo-wide; mypy clean over keel + packages. Refs #438, #18. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stead of a certificate
Apple signing is dropped as a budget decision, and the honest version of that decision is worth
writing down rather than leaving as an empty step.
There is no free path to a notarised macOS build. Notarisation requires a Developer ID
certificate, which requires the $99/yr Developer Program; a free Apple account signs only for
local development, and a self-signed certificate buys nothing at all, because Gatekeeper trusts
Apple-issued Developer IDs and nothing else. So an unsigned `.dmg` off the internet carries a
quarantine flag and macOS refuses the first open.
That cost is documented rather than hidden. The release notes now say plainly that the builds are
not code-signed, why, and what the user has to do once: System Settings -> Privacy & Security ->
Open Anyway, and More info -> Run anyway for SmartScreen. A download that trips Gatekeeper with no
explanation is indistinguishable from a broken one.
WHAT REPLACES OS-LEVEL TRUST, AT ZERO COST. Every artifact now carries a GitHub build attestation
binding it to this repository, this workflow and this commit, verifiable with
gh attestation verify <file> --repo CodeGateSoftware/keel
plus a SHA256SUMS file attached to the same release. That does not move Gatekeeper -- nothing free
does -- but it answers "did this binary come from that source, built by that pipeline", which is
the question a certificate answers too, and arguably the one an auditable project should care
about most. A user who is told to click past a security warning with no way to check what they
have is being taught a bad habit while holding exchange API keys; this gives them the check.
The publish option is spelled `publish-unsigned`. `publish` would have been a value you could
select without thinking; this one cannot be chosen without reading the word, the default remains
`build` (which attaches nothing to a release), and a test pins both. Attestation is produced
BEFORE anything is attached, so a failed attestation cannot leave a published artifact behind
that nobody can verify -- also pinned.
The previous "refuse to publish while unsigned" guard is gone deliberately, replaced by the
naming and the provenance rather than silently deleted.
4032 passed, 3 skipped. ruff clean repo-wide; mypy clean over keel + packages. The `.app` and
`.dmg` were rebuilt and the app driven end-to-end after the change.
Refs #438, #18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… warn them The signing decision has been recorded for us (#438) and in the release notes. Neither reaches the person actually staring at "keel cannot be opened because the developer cannot be verified". Three places now do, in the order someone hits them: **Inside the .dmg, beside the app.** `READ ME FIRST.txt` is where a Mac user looks the moment the app refuses to open; a page in a repository is no use to them at that point. The disk image is built from a staging directory rather than the .app alone, so the note is actually shipped instead of merely written. **`docs/desktop-install.md`**, linked from the README's documentation map and from the release notes, in plain language for someone who has never opened a terminal. **The release notes**, which now name the reason and link the full page rather than repeating it. WHAT THEY SAY, AND WHY IN THOSE WORDS. The reason is a budget, and saying so plainly is better than "not signed at this time". A user told a build is unsigned with no reason assumes carelessness; a user told the certificate costs $99/yr and the project cannot commit to it has been given a fact they can weigh. It is also simply true. It answers the obvious "why not just make your own certificate" -- because macOS trusts only certificates Apple issued, so a self-signed one does nothing at all -- and the equally obvious "is my download broken", because "damaged" is what macOS sometimes says and it is not what happened. Leaving that uncorrected is how a working download gets deleted. And it does not simply ask someone to click past a security warning on a program they may give exchange API keys to. It gives them the check first: `gh attestation verify` and SHA256SUMS, which answer the same question a certificate answers, and a plain instruction not to open the file if either fails. The pip path is named too, for anyone who would rather avoid the whole thing. Every claim above is pinned by test, including that the note travels inside the image. Verified by building the .dmg and mounting it: `keel.app` and `READ ME FIRST.txt`, as intended. 4038 passed, 3 skipped (6 new). ruff clean repo-wide. Refs #438, #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). Milestone: Desktop distribution (#18).
The macOS half — built and run, not described.
The app launches
keel serve, not the CLIpackaging/macos_app.shwraps a frozen bundle into a.appand a 20 MB compressed.dmg. The one design decision in it is that entry point.An app launched from Finder has no controlling terminal at all — the packaging research rates this the single biggest technical risk in the milestone, above code signing. A bundle whose entry point were the CLI would open, find no tty, refuse every gated action, and exit with nothing on screen.
The console binary ships inside the same
.app, so a terminal user still gets the full CLI fromkeel.app/Contents/Resources/keel/keel. One artifact, both audiences, no second build.The launcher deliberately does not set a working directory. Finder launches with cwd
/, which was the blocker D1 removed;keel_core.pathsresolves state to the OS app-data directory now, and acdhere would override that with a guess. It only ensures the state directory exists and tees the server's output toserve.logbeside it, so "why did it not start" is answerable from the folder the operator already knows.Verified the way Finder would do it
cwd
/, stdin closed, no controlling terminal:That is the milestone's whole promise, demonstrated — a working paper deployment built from a browser, on a machine with nothing on it, with no command typed and no terminal involved.
The release job, and why it defaults to not publishing
An unsigned binary that then asks for exchange API keys is the shape of malware distribution — the desktop PRD opens on a live example found while surveying this space. For a project whose proposition is auditability, shipping that silhouette without OS-level verification would be worse than shipping no installer.
So:
desktop: publishfails while signing is unconfigured, rather than quietly attaching an unsigned build to a release;build— the safe value is the one you get by not thinking about it;The smoke step is the part that earns its keep. Three of the four ways a bundle breaks are silent — it starts cleanly and has no venues, or no version identity, or no templates (#458) — so every assertion corresponds to one of them, including that the dev-only fake venue never reaches a shipped artifact.
Windows ships a zip, for now
Inno Setup is the intended installer and carries the install-path and version-decision UX #438 specifies. It cannot be tested anywhere in this repository today, and an installer nobody has run is a worse artifact than an archive everyone understands.
keel install-plan(#459) is already the interface it will call, so the logic is landed and tested ahead of the script that uses it.What is pinned, since the job cannot run here
tests/test_desktop_packaging.pycovers the claims that would be expensive to discover were false:maindrifted to;The signing assertion excludes comment lines — the script explains in prose which commands signing will need, and a test that forbade the words would forbid documenting them. Mutation-checked by adding a
codesigncall and watching it fail.Verification
4030 passed, 3 skipped (12 new).
ruff checkclean repo-wide,mypyclean overkeel+packages. The.appand.dmgwere built and the app was launched and driven end-to-end on this machine.Still outstanding in D5
🤖 Generated with Claude Code