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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 16:25:34 -06:00
1 parent 6e7deb15c7
commit bc9aa57380
8 files changed
+221 -22

No files matched your search

+48 -2
View File
@@ -3,8 +3,11 @@
# program linked on a newer one does not start on an older one), attach # 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 # 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 # 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 # the unit (the release key, Q-4) signs the manifest and the indexes. The
# step 6 adds the macOS and Windows jobs. The release holds no key today. # 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 name: release
on: on:
@@ -75,3 +78,46 @@ jobs:
git add -A git add -A
git commit -m "wanderbooks $v" git commit -m "wanderbooks $v"
git push origin HEAD:gh-pages 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
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Merged by cargo-packager into the Info.plist it generates for the
.app (P11a step 6): the keys the generated one lacks. -->
<plist version="1.0">
<dict>
<key>LSMultipleInstancesProhibited</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
+33
View File
@@ -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_<arch>.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"
+36
View File
@@ -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 }
+18 -4
View File
@@ -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): 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 `rollback` refuses while a book declares a format or feature the kept
version does not know; version does not know;
- an AppImage is the exception to the restart: the host runs from the old - an AppImage runs the program from a mount that goes away with the image,
image's mount, whose path does not change, so it keeps running the old so the host learns the image's own path from `APPIMAGE` (the one variable
version until the books are next closed and opened (docs/packaging.md). `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 `<root>/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 `<root>/updates/feed` (a `file://` address, then
the key file), the same rule as `--trusted-key`.
`deploy/packaging/` holds the Linux packaging: `packager.toml` `deploy/packaging/` holds the Linux packaging: `packager.toml`
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), (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`, package, which repacks the release's tarball), `build-linux.sh`,
`repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a
throwaway-keyed feed on this machine) and `test-linux-packages.sh`. 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) ## The result envelope and the guards (`ledger-mcp`, P5)
+50 -16
View File
@@ -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) | | `.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) | | 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) | | 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) | | `.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`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (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`, `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 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 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 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`. `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 7. **The AppImage updates itself, and (since step 4) the books it holds
onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run restart onto the new image.** `update` replaces `$APPIMAGE`. The host
from a shell (`Wanderbooks.AppImage update`, or the one that was running) that is open used to run from the old image's mount
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
(`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so (`/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 it never saw a replaced file. The host now reads `APPIMAGE` (it is in
version until they are closed and opened again (`wanderbooks close`). the allow-list of `start.rs`'s scrubbed environment, only when it names
Fixing it means the host learning the image's path (its environment is an absolute file; a path the host reads about itself and never tells an
scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`, agent), runs and restarts from that file, and tells a replaced program
or the path would come from the mount table) and starting its successor by its inode, size and last write. Tested without FUSE by
from it: a decision about what the host may trust, left to the EM (the `a_replaced_appimage_restarts_the_books` (the programs run from one
unit's report names it). Package installs and a hand-placed executable copy while `APPIMAGE` names another, as a mount does); not run against
restart as designed (`bridge_survives_update`). a real image mount in this wave.
8. **Onboarding with no terminal does not exist yet.** The unit's Done-when 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 (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 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 *downgrade* (the repositories keep every release by construction), and the
`.rpm` and `.deb` under their real signatures (step 5). `.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
`<root>/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
`<root>/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 ## What `update` and `rollback` need from a release
`manifest.json` at `releases/latest/download/` (D-26): `manifest.json` at `releases/latest/download/` (D-26):
+19
View File
@@ -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 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`). `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`, Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`,
`a_package_marker_beside_the_program_makes_it_package_managed`, `a_package_marker_beside_the_program_makes_it_package_managed`,
`a_manifest_address_is_resolved_beside_the_manifest`, `a_manifest_address_is_resolved_beside_the_manifest`,
+5
View File
@@ -273,6 +273,11 @@ package-linux version="" formats="deb,appimage,pacman":
cargo build --release -p ledger-cli cargo build --release -p ledger-cli
deploy/packaging/build-linux.sh {{version}} {{formats}} 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 # A release folder on this machine signed with a throwaway key; then
# `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub` # `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub`
# Make a local release feed to try `wanderbooks update` against # Make a local release feed to try `wanderbooks update` against