Skip to content

feat(packaging): a double-clickable macOS app, and a release job that cannot ship it unsigned - #463

Merged
eaitbrahim merged 3 commits into
mainfrom
feat/d5-macos-bundle
Aug 20, 2026
Merged

feat(packaging): a double-clickable macOS app, and a release job that cannot ship it unsigned#463
eaitbrahim merged 3 commits into
mainfrom
feat/d5-macos-bundle

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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

The macOS half — built and run, not described.

The app launches keel serve, not the CLI

packaging/macos_app.sh wraps a frozen bundle into a .app and 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 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 removed; keel_core.paths resolves state to the OS app-data directory now, and a cd here would override that with a guess. It only ensures the state directory exists and tees the server's output to serve.log beside 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:

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;
  • the default is build — the safe value is the one you get by not thinking about it;
  • signing is absent, not stubbed. 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 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.py covers the claims that would be expensive to discover were false:

  • publishing unsigned is impossible by accident, and no second route to a release asset exists;
  • the job builds the tag it released, not whatever main drifted to;
  • stamping precedes freezing (the stamp is a source file the freeze embeds);
  • 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. Mutation-checked by adding a codesign call and watching it fail.

Verification

4030 passed, 3 skipped (12 new). ruff check clean repo-wide, mypy clean over keel + packages. The .app and .dmg were built and the app was launched and driven end-to-end on this machine.

Still outstanding in D5

  • Certificates. Apple Developer ID at $99/yr; Windows OV cert or Azure Trusted Signing at ~$10/mo. Everything else is ready to have signing wired in behind a protected environment.
  • The Inno Setup script, which wants a Windows runner to be tested on.

🤖 Generated with Claude Code

eaitbrahim and others added 3 commits August 20, 2026 16:25
… 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>
@eaitbrahim
eaitbrahim merged commit 5606dd0 into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/d5-macos-bundle branch August 20, 2026 20:49
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