From d2b3458943e9118d5fcf31ac1fd2a4699696f52c Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 20 Aug 2026 16:25:21 -0400 Subject: [PATCH 1/3] feat(packaging): a double-clickable macOS app, and a release job that 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) --- .github/workflows/release.yml | 147 ++++++++++++++++++++++++++++ packaging/macos_app.sh | 81 ++++++++++++++++ tests/test_desktop_packaging.py | 164 ++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100755 packaging/macos_app.sh create mode 100644 tests/test_desktop_packaging.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2303891..aa275050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,12 @@ on: description: "Semver to release, without a leading v (e.g. 0.2.0). Must already match pyproject.toml." required: true type: string + desktop: + description: "Desktop artifacts. 'build' produces and smoke-tests them as workflow artifacts; 'publish' also attaches them to the release and REQUIRES signing to have succeeded; 'skip' does neither." + required: true + default: build + type: choice + options: [build, publish, skip] permissions: contents: write # create the tag and the release @@ -218,3 +224,144 @@ jobs: --notes-file /tmp/release-notes.md echo "published v${{ inputs.version }}" + # -- desktop artifacts ------------------------------------------------------------------------- + # + # Runs AFTER `release`, so the tag it checks out is the one that job created and published. + # + # WHY IT DEFAULTS TO `build` AND NOT `publish`. An unsigned binary that then asks for exchange + # API keys is the shape of malware distribution -- section 1 of 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 at all. So the pipeline is built so that publishing an unsigned artifact is not + # something you can do by forgetting a flag: `publish` FAILS when the signing secrets are + # absent, rather than quietly attaching an unsigned build to a release. + desktop: + needs: release + if: ${{ inputs.desktop != 'skip' }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + arch: arm64 + # Two DMGs rather than a universal2 build: `lipo`-merging a bundled-CPython-plus- + # native-extension tree is fragile in practice. Note this runner image is scheduled to + # sunset around Aug 2027. + - os: macos-15-intel + arch: x86_64 + - os: windows-latest + arch: x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: v${{ inputs.version }} + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install the workspace + run: uv sync --all-packages + + # The SAME stamp the release job writes. Without it a bundle reports `[checkout]`, and + # `keel/version.py` will not call an unstamped bundle a release -- correctly, which is why + # the smoke below can assert on it. + - name: Stamp build info + shell: bash + run: | + set -euo pipefail + COMMIT="$(git rev-parse --short=12 HEAD)" + cat > keel/_build_info.py <