From bc9aa5738016f9af5ed9bef2c0549521fe73aea1 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 16:25:34 -0600 Subject: [PATCH] feat(packaging): the unsigned macOS .dmg built in CI, and the docs for steps 3 and 4 (P11a step 6, macOS half) cargo-packager's dmg format (it makes the .app first) from deploy/packaging/packager-macos.toml, an Info.plist merged into the generated one, build-macos.sh in portable shell (BSD sed) and a package-macos recipe; the release workflow gets a macos job on macos-latest that uploads the .dmg as a run artifact and attaches it on a tag. Unsigned and not notarized: that is step 5 (the owner's Developer ID, Q-4). The config was parsed under cargo-packager 0.11.8's strict schema on Linux; the build itself needs the CI run. No new dependency. docs/packaging.md (macOS rows, finding 7 resolved, the staged update), architecture.md and testing.md describe steps 3 and 4. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/release.yml | 50 ++++++++++++++++++++- deploy/packaging/Info.plist | 12 +++++ deploy/packaging/build-macos.sh | 33 ++++++++++++++ deploy/packaging/packager-macos.toml | 36 +++++++++++++++ docs/architecture.md | 22 ++++++++-- docs/packaging.md | 66 +++++++++++++++++++++------- docs/testing.md | 19 ++++++++ justfile | 5 +++ 8 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 deploy/packaging/Info.plist create mode 100755 deploy/packaging/build-macos.sh create mode 100644 deploy/packaging/packager-macos.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 231444f..0094a6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,11 @@ # program linked on a newer one does not start on an older one), attach # them to the GitHub release, and add them to the apt and dnf repositories # on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of -# the unit (the release key, Q-4) signs the manifest and the indexes, and -# step 6 adds the macOS and Windows jobs. The release holds no key today. +# the unit (the release key, Q-4) signs the manifest and the indexes. The +# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization: +# step 5); the Windows job is unit P11c. The release holds no key today. +# To see the artifacts without a tag: Actions > release > Run workflow, +# pick the branch; each job uploads its files as a run artifact. name: release on: @@ -75,3 +78,46 @@ jobs: git add -A git commit -m "wanderbooks $v" git push origin HEAD:gh-pages + - name: Keep them with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: linux-packages + path: target/packages/${{ steps.version.outputs.version }}/ + + macos: + # UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and + # notarizes it. Apple silicon (macos-latest); an Intel build is not + # made yet. + runs-on: macos-latest + env: + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + with: + install_args: "rust just cargo:cargo-packager" + - name: The tag is the program's version + id: version + run: | + own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) + tag="${GITHUB_REF_NAME#v}" + if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then + echo "tag $GITHUB_REF_NAME is not version $own" >&2 + exit 1 + fi + echo "version=$own" >> "$GITHUB_OUTPUT" + - name: Build the unsigned .dmg + run: just package-macos ${{ steps.version.outputs.version }} + - name: Attach it to the release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: | + v=${{ steps.version.outputs.version }} + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v" + gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg + - name: Keep it with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-unsigned + path: target/packages/${{ steps.version.outputs.version }}/*.dmg diff --git a/deploy/packaging/Info.plist b/deploy/packaging/Info.plist new file mode 100644 index 0000000..9b274fd --- /dev/null +++ b/deploy/packaging/Info.plist @@ -0,0 +1,12 @@ + + + + + + LSMultipleInstancesProhibited + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/deploy/packaging/build-macos.sh b/deploy/packaging/build-macos.sh new file mode 100755 index 0000000..f9e809f --- /dev/null +++ b/deploy/packaging/build-macos.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The macOS bundle (P11a step 6), UNSIGNED, from the release program +# already built at target/release/wanderbooks (`cargo build --release -p +# ledger-cli`). Run on a Mac from the repository root: +# +# build-macos.sh [VERSION] +# +# Puts Wanderbooks_VERSION_.dmg into target/packages/VERSION/. It +# is not signed or notarized (step 5): Gatekeeper refuses it on a Mac +# that downloaded it, and the owner opens it with right-click > Open. +# Portable shell: macOS has BSD sed and no GNU mktemp options. +set -euo pipefail +own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) +version=${1:-$own} +out="target/packages/$version" +program=${WB_PROGRAM:-target/release/wanderbooks} +test -x "$program" || { echo "build the release program first: cargo build --release -p ledger-cli" >&2; exit 1; } +rm -rf "$out"; mkdir -p "$out" +stage="target/packages/.stage-$version" +rm -rf "$stage"; mkdir -p "$stage" +cp "$program" "$stage/wanderbooks" +config="$stage/packager.toml" +# cargo-packager reads paths relative to the config file, which is in the +# stage folder here, so they are made absolute. +sed -e "s/^version = \".*\"/version = \"$version\"/" \ + -e "s#^out-dir = .*#out-dir = \"$PWD/$out\"#" \ + -e "s#\"target/release\"#\"$PWD/$stage\"#" \ + -e "s#\"deploy/packaging/#\"$PWD/deploy/packaging/#" \ + -e "s#\"LICENSE-MIT\"#\"$PWD/LICENSE-MIT\"#" \ + deploy/packaging/packager-macos.toml >"$config" +cargo packager --config "$config" --formats dmg --release +rm -rf "$stage" +ls -l "$out" diff --git a/deploy/packaging/packager-macos.toml b/deploy/packaging/packager-macos.toml new file mode 100644 index 0000000..64ef6c0 --- /dev/null +++ b/deploy/packaging/packager-macos.toml @@ -0,0 +1,36 @@ +# cargo-packager's description of the macOS bundle (P11a step 6; D-24, +# D-25): the .app and its .dmg, UNSIGNED. `deploy/packaging/build-macos.sh` +# runs it on a Mac (CI: the `macos` job of .github/workflows/release.yml). +# Developer ID signing and notarization are step 5 (the owner's Apple +# certificate, Q-4): they add `signing-identity` under [macos] and the +# certificate and notarization credentials as CI secrets. +# Paths are relative to this file's copy that build-macos.sh writes. +name = "wanderbooks" +product-name = "Wanderbooks" +version = "0.1.0" +identifier = "ai.wanderbooks.app" +description = "Double-entry bookkeeping your agent keeps and you can read." +homepage = "https://wanderbooks.ai" +authors = ["Stephen Waits"] +license-file = "LICENSE-MIT" +category = "Finance" +binaries-dir = "target/release" +out-dir = "target/packages" +# A png is made into the .icns (cargo-packager does it); the 512 px +# placeholder is the owner's to replace. +icons = ["deploy/packaging/icons/512x512.png"] +formats = ["dmg"] +binaries = [{ path = "wanderbooks", main = true }] + +[macos] +# The oldest macOS Rust's tier-1 aarch64-apple-darwin and x86-64 targets +# run on; eframe's wgpu/Metal path needs 10.15 at least. +minimum-system-version = "11.0" +# Keys merged into the generated Info.plist: one viewer at a time, and the +# discrete GPU is not woken for a ledger. +info-plist-path = "deploy/packaging/Info.plist" + +[dmg] +window-size = { width = 540, height = 380 } +app-position = { x = 140, y = 180 } +application-folder-position = { x = 400, y = 180 } diff --git a/docs/architecture.md b/docs/architecture.md index 27e82bc..9e0572c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -355,9 +355,21 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by without the agent noticing. The format gate stays the server's (3.9): `rollback` refuses while a book declares a format or feature the kept version does not know; -- an AppImage is the exception to the restart: the host runs from the old - image's mount, whose path does not change, so it keeps running the old - version until the books are next closed and opened (docs/packaging.md). +- an AppImage runs the program from a mount that goes away with the image, + so the host learns the image's own path from `APPIMAGE` (the one variable + `start.rs` adds to its scrubbed environment, only when it names an + absolute file; the host reads it about itself and never tells an agent), + runs and restarts from that file, and tells a replaced program by its + inode, size and last write, like any other; +- updates are also **staged** (step 4): while the books are open the host + looks at the feed at most once a day (`update_check` knob; the look is + noted in `updates/checked`), and only downloads and verifies a newer + release into `/updates/staged/`. It never swaps. The staged program + is applied by an opener just before it starts a host (no host runs, so no + call is cut off), after a second check against the key, or by + `wanderbooks update`, the owner's say-so, which reuses the staged copy. A + local release is named in `/updates/feed` (a `file://` address, then + the key file), the same rule as `--trusted-key`. `deploy/packaging/` holds the Linux packaging: `packager.toml` (cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), @@ -365,7 +377,9 @@ the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD` (the AUR package, which repacks the release's tarball), `build-linux.sh`, `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a throwaway-keyed feed on this machine) and `test-linux-packages.sh`. -`.github/workflows/release.yml` builds them on a version tag. +`.github/workflows/release.yml` builds them on a version tag. The macOS +bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an +unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5). ## The result envelope and the guards (`ledger-mcp`, P5) diff --git a/docs/packaging.md b/docs/packaging.md index 3ec5b02..07063da 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -16,8 +16,8 @@ not a tool, so there is nothing for a recipe to call. | `.rpm` | `cargo-generate-rpm` (table in `crates/ledger-cli/Cargo.toml`) | same paths, marker `rpm`; 36 MB | dnf repository (`repo-indexes.sh dnf`) | unsigned until step 5 (`--signing-key` takes the PGP key then) | | AUR `PKGBUILD` | `cargo packager` makes the tarball (`pacman` format) and a PKGBUILD for it; ours is `deploy/packaging/aur/PKGBUILD`, which repacks the release's tarball (package `wanderbooks-bin`) | same paths, marker `aur`; built with `makepkg` | pacman (the AUR keeps old PKGBUILDs) | n/a (pacman trusts the AUR's checksums) | | AppImage | `cargo packager` (`appimage`); 44 MB | one file the owner puts anywhere and runs; needs no admin password | `wanderbooks update` (`$APPIMAGE` is the file replaced) against the manifest; minisign | minisign by the release key (step 5) | -| `.dmg` | `cargo packager` (`dmg`) | (trial) needs a Mac | `wanderbooks update` | Developer ID and notarization (step 5) | -| NSIS `.exe` | `cargo packager` (`nsis`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | +| `.dmg` | `cargo packager` (`dmg`), `deploy/packaging/build-macos.sh`; **built in CI, unsigned; the trial on a Mac is the owner's** | `Wanderbooks.app` dragged to Applications; no admin password (the trial will say) | `wanderbooks update` (it replaces the program inside the `.app`: see the note below) | **unsigned**; Developer ID and notarization are step 5 | +| NSIS `.exe` | `cargo packager` (`nsis`) | **not built**: native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | `just package-linux` builds the first four (`deploy/packaging/build-linux.sh`, about a minute after the release build, itself 8 to 12 minutes on a @@ -64,21 +64,18 @@ What the trial found, each of which is built around now: manifest's `signature` is that `.sig` as written, and `update` reads the public key in either form. Checked end to end by hand (below), and by `a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`. -7. **The AppImage updates itself but the books it holds do not restart - onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run - from a shell (`Wanderbooks.AppImage update`, or the one that was running) - proves the swap and the rollback: after `update`, the AppImage on disk - is the new file, the old one is kept, and `rollback` restores it. But the - host that is open runs from the old image's mount +7. **The AppImage updates itself, and (since step 4) the books it holds + restart onto the new image.** `update` replaces `$APPIMAGE`. The host + that is open used to run from the old image's mount (`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so - `host.rs` never sees a replaced file and the open books keep the old - version until they are closed and opened again (`wanderbooks close`). - Fixing it means the host learning the image's path (its environment is - scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`, - or the path would come from the mount table) and starting its successor - from it: a decision about what the host may trust, left to the EM (the - unit's report names it). Package installs and a hand-placed executable - restart as designed (`bridge_survives_update`). + it never saw a replaced file. The host now reads `APPIMAGE` (it is in + the allow-list of `start.rs`'s scrubbed environment, only when it names + an absolute file; a path the host reads about itself and never tells an + agent), runs and restarts from that file, and tells a replaced program + by its inode, size and last write. Tested without FUSE by + `a_replaced_appimage_restarts_the_books` (the programs run from one + copy while `APPIMAGE` names another, as a mount does); not run against + a real image mount in this wave. 8. **Onboarding with no terminal does not exist yet.** The unit's Done-when (1) says an AppImage "opens, onboards and posts an entry with no terminal". The AppImage opens (a bare launch is the viewer), but the viewer @@ -118,6 +115,43 @@ package manager. Not tested: Ubuntu, openSUSE, a package-manager *downgrade* (the repositories keep every release by construction), and the `.rpm` and `.deb` under their real signatures (step 5). +## The macOS bundle (step 6, built in CI, unsigned) + +`deploy/packaging/packager-macos.toml` (cargo-packager: the `dmg` format, +which makes the `.app` first; the icon is the 512 px PNG, made into an +`.icns` by the packager; `minimum-system-version` 11.0), `Info.plist` +(merged into the generated one: one viewer at a time, no discrete GPU), +`build-macos.sh` (the same shape as the Linux script, in portable shell +because macOS has BSD `sed`) and `just package-macos`. The `macos` job of +`.github/workflows/release.yml` runs on `macos-latest` (Apple silicon: the +manifest key is `macos-aarch64`; an Intel build is not made), uploads the +`.dmg` as a run artifact (named `macos-dmg-unsigned`) and, on a tag, +attaches it to the release. **It is not signed or notarized** (step 5: the +owner's Developer ID and Q-4), so Gatekeeper refuses it on a Mac that +downloaded it; the owner opens it with right-click, Open. What was checked +here, on Linux: the config parses under cargo-packager 0.11.8's strict +schema (unknown keys are errors) and the script's `sed` is the portable +subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs +the CI run (Actions, release, Run workflow, this branch) and then a Mac. +Open items for the trial on a Mac: whether `wanderbooks update` replacing +the program inside a signed `.app` breaks its signature (it will: step 5 +must decide, probably by updating a whole `.app`); the translocated-app +check (`translocated_app_refuses_to_connect`) is not built. + +## Updates staged by the host (step 4) + +The host looks at the feed at most once a day while the books are open +(`update_check`), and downloads and verifies a newer release into +`/updates/staged/` with a `meta.json` (version, sha256, signature, +notes). It never swaps. The next opener that starts a host applies it +first; `wanderbooks update` applies it too and reuses the staged copy; +`update --check` says "already downloaded". A local release is named by +`/updates/feed` (a `file://` address, then the key file). Without a +compiled-in release key and without that file, the look does nothing but +log why. Not built: the in-app "restart now to update" choice and the +viewer's notice, which need an admin-socket intent and a place in the +viewer (see the unit's As-built block). + ## What `update` and `rollback` need from a release `manifest.json` at `releases/latest/download/` (D-26): diff --git a/docs/testing.md b/docs/testing.md index db1c715..7eb8716 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3711,6 +3711,25 @@ it (the same program with a tail, so its hash differs). All in file now on disk (not a deleted one). The host's own restart laws stay `open_while_held.rs`'s (`a_restart_is_not_a_close`). +Step 3's gap and step 4 (same file): + +- `a_replaced_appimage_restarts_the_books`: the programs run from one copy + (a mount's) while `APPIMAGE` names another (the image); `update` + replaces only the image; the host restarts onto it (its `/proc/PID/exe` + is the image), the mount's copy is untouched, the session writes on. +- `the_host_stages_a_daily_update_and_the_next_start_applies_it`: with a + local feed named in `updates/feed`, the host stages 9.0.0 by itself; + the program, the host and the session are unchanged meanwhile; after + `close`, the next opener applies it (the old program is kept, the staged + folder gone) and the books are as they were. +- `a_daily_update_that_is_not_ours_is_not_staged`: a signature by another + key, then a wrong sha256, each looked at and neither staged. +- `update_installs_the_copy_the_host_staged_without_downloading_again`: the + release's file is deleted; `update` installs from the staged copy. +- `a_staged_update_that_was_tampered_with_is_dropped_not_applied`: a staged + file edited together with its recorded checksum fails the signature; the + next start drops it and runs the old program. + Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`, `a_package_marker_beside_the_program_makes_it_package_managed`, `a_manifest_address_is_resolved_beside_the_manifest`, diff --git a/justfile b/justfile index a6d248e..462aa66 100644 --- a/justfile +++ b/justfile @@ -273,6 +273,11 @@ package-linux version="" formats="deb,appimage,pacman": cargo build --release -p ledger-cli deploy/packaging/build-linux.sh {{version}} {{formats}} +# Build the unsigned macOS .dmg (on a Mac; CI does it on a tag or dispatch) +package-macos version="": + cargo build --release -p ledger-cli + deploy/packaging/build-macos.sh {{version}} + # A release folder on this machine signed with a throwaway key; then # `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub` # Make a local release feed to try `wanderbooks update` against