storebound/docs/desktop-release.md
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

22 KiB

Releasing storebound desktop

How the desktop app (apps/desktop) is built, signed, checked and published. For what the app does, see desktop.md.

Distribution

  • The desktop app is its own download. A signed app from storebound.dev/desktop/:
    • macOS: a DMG with a universal app (Apple silicon and Intel), macOS 11 or later;
    • Windows: an NSIS installer that installs for the current user and brings the WebView2 bootstrapper;
    • Linux: an AppImage and a .deb, built on Ubuntu 22.04.
  • It updates itself with Tauri's updater: storebound.dev/desktop/latest.json, every bundle signed with the updater key. The app checks the signature before it replaces anything and installs only after the owner clicks Install in a native dialog (Settings > Updates).
  • It brings its own CLI. The app bundles the storebound CLI as a sidecar, so it never depends on an npm install. That CLI is the npm package of the same version.
  • The npm package stays the CLI, the MCP server and the skills. It contains no Tauri binaries.
  • Bridge: storebound desktop installs, updates and opens the app of the CLI's own version (docs/desktop.md, "One product with the CLI"). It reads storebound.dev/desktop/dl/<version>/latest.json (written by the publish script next to the version's files) and falls back to the channel's latest.json. --download only saves the checked installer to ~/Downloads.

One version, one release

The CLI and the desktop app are one product with one version number: version in the root package.json. 0.8.0 is the first release of both under one number. (The desktop had its own desktopVersion, 0.1.0; its release candidate in ~/Desktop/storebound-dist/desktop-0.1.0 was never published, so no installed app or link depends on it.)

  • bun tools/desktop-version.ts checks the copies: the agent plugin manifests (.claude-plugin/plugin.json, marketplace.json, .codex-plugin/plugin.json, gemini-extension.json), apps/desktop/package.json, tauri.conf.json, Cargo.toml and the app's entry in Cargo.lock. CI runs this check; src-tauri/tests/d5.rs checks the app's copies from the Rust side.
  • bun tools/desktop-version.ts --set 0.8.0 writes a new version into package.json and all of them.
  • Release order (one commit, one version): build and check the desktop artifacts (mac, add), publish them (it refuses a folder whose version is not package.json's), then npm publish of the same version (CONTRIBUTING.md). The app goes first, so npx storebound desktop of the new CLI finds its own version.

Keys and private files (outside every repository)

~/.storebound-keys/ (mode 700) on the release Mac holds:

file what made by
updater.key minisign private key of the updater (no password; mode 600) bunx tauri signer generate -w ~/.storebound-keys/updater.key -p "" --ci (done once, D5)
updater.key.pub its public key; the same text is plugins.updater.pubkey in tauri.conf.json and PUBLIC_KEY in cli/lib/desktop-dist.ts the same command
forbidden-terms.txt names the privacy scan must never find: one per line (people, accounts, emails, machine names) the owner
notary.json optional: {"keyId", "issuerId", "keyPath"} of the App Store Connect API key for notarytool (or the ASC_* / APPLE_API_* variables) the owner

Back up updater.key (password manager or an offline copy). If it is lost, installed apps can never update again: they only accept bundles signed with this key, so everyone would have to reinstall by hand. Never commit it, never copy it to a runner. The Forgejo workflow builds unsigned installers, and the Mac signs them.

Privacy gate

bun tools/privacy-scan.ts <files or folders> runs on every artifact before it can ship.

  • Terms it looks for:
    • the terms file;
    • this machine's account name, home folder, full name, host and computer names, and git's global identity;
    • the private-name rule of tools/scrub.ts;
    • any /Users/<name>, /home/<name> or C:\Users\<name> that is not a generic account.
  • How it reads files: as bytes, in UTF-8 and UTF-16LE.
  • Archives it opens: DMG (mounted read-only, never shown in Finder), .tar.gz, .deb, .zip, AppImage (unsquashfs or 7-Zip) and NSIS (7-Zip).
  • Debug files: .dSYM, .pdb and source maps fail the scan.
  • Exit codes: 1 when it finds something, 2 when it cannot open an archive. A term is never printed in full.

Path-free builds. tools/desktop-release.ts builds with --remap-path-prefix and -ffile-prefix-map, which turn home into ~, the checkout into /src, the cargo and rustup homes into /cargo and /rustup, and CARGO_TARGET_DIR into /target. On Windows it also uses /PDBALTPATH and /d1trimfile. The target folder must be outside the home folder. Release profile: strip = true, LTO, panic = "abort".

macOS (on the owner's Mac)

bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.1.0     # from a clean commit

Runs in the owner's logged-in macOS session. It refuses SSH and background sessions: over SSH codesign cannot use the login keychain (errSecInternalComponent).

What it does, in order:

  1. Checks the session, the version, a clean checkout, the updater key and that the rustup targets are installed.
  2. Picks the signing identity:
    • Developer ID Application found (security find-identity -v -p codesigning): hardened runtime with src-tauri/entitlements.plist (JIT for the Bun sidecar), notarization and stapling. Tauri notarizes the app, the script notarizes the DMG.
    • None found: an ad-hoc signed build whose files end in -unsigned.
  3. Builds the sidecars for arm64 and x64, merges them into one universal file with lipo, then runs tauri build --target universal-apple-darwin --bundles app --no-sign with the path-free environment. Then the architecture guard on that unsigned app: every Mach-O file in the bundle (Contents/MacOS, Frameworks, Resources: main binary, sidecar, helpers, dylibs) must contain arm64 (lipo -archs), nothing may be Intel-only, and Info.plist must not set LSRequiresNativeExecution false or put x86_64 first in LSArchitecturePriority. Users must never see macOS's "App Update Required ... will not open in macOS 28" (Rosetta) notice. Only then does tauri bundle sign, notarize and staple the same binaries. The guard runs again on the signed app, the app in the DMG and the one in the updater bundle, and its lines go into evidence.json.
  4. Makes the updater bundle (.app.tar.gz, root-owned entries, no macOS metadata) and the DMG. The DMG is made with hdiutil only (no Finder scripting, nothing comes to the front).
  5. Checks the exact artifacts:
    • codesign --verify --deep --strict;
    • the Gatekeeper expectation (signed: spctl accepts it as Notarized Developer ID and stapler validate passes; unsigned: ad-hoc, and spctl rejects it);
    • lipo -archs on every executable in the bundle (each must carry arm64 and x86_64). The x86_64 slice is never run on the build Mac: that starts Rosetta, and macOS then warns the owner that the app "includes a component that will not open" in the next macOS;
    • the self-test (arm64, native), run from the mounted DMG and from the unpacked updater bundle. storebound-desktop --self-test starts the bundled sidecar, sends session.hello, prints one JSON line and exits, before Tauri starts, so no window opens;
    • the privacy scan of the app, the DMG and the updater bundle.
  6. Writes minisign .sig files with the updater key and checks them right away against tauri.conf.json's key.
  7. Writes latest.json, SHA256SUMS and evidence-macos-<version>.json. The evidence file (tool versions, checks, hashes) stays local.

Signing identity and notarization key

  1. Developer ID certificate. A Developer ID Application certificate of your Apple developer team in the login keychain (the Account Holder creates it at developer.apple.com > Certificates > +, with a CSR from Keychain Access). It is installed on the release Mac since October 2026. A machine without it still builds, but only the ad-hoc -unsigned preview.

  2. Notarization key. An App Store Connect API key, found in this order:

    • APPLE_API_KEY, APPLE_API_ISSUER and APPLE_API_KEY_PATH;
    • storebound's own ASC_KEY_ID, ASC_ISSUER_ID and ASC_P8 (a path or the PEM; by default ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8, where Xcode keeps it);
    • ~/.storebound-keys/notary.json.

    The key ids are never written into the repository.

  3. Build. Run the command above in Terminal on the Mac.

Gates on the exact artifacts. A signed release stops unless all of these pass:

  • every executable in Contents/MacOS (the app and the CLI sidecar, signed inside out by Tauri), and the bundle, is signed with Developer ID, with the hardened runtime and a secure timestamp;
  • no executable carries the App Sandbox entitlement (Mac App Store) or get-task-allow;
  • spctl -a -vvv -t exec <app> says Notarized Developer ID, and xcrun stapler validate <app> passes for the built app, the app inside the mounted DMG and the app inside the updater bundle;
  • spctl -a -vvv -t open --context context:primary-signature <dmg> accepts the DMG, and xcrun stapler validate <dmg> passes.

Opening an unsigned preview (what the downloads page says)

macOS: copy storebound to Applications and open it. When macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).

Windows and Linux (Forgejo Actions)

.forgejo/workflows/desktop.yml runs on a desktop-v* tag or by hand (workflow_dispatch; tick "windows" for the Windows installer too: a tag push builds Linux only).

  • Linux (runs-on: ubuntu-22.04, a container): AppImage and .deb built with the path-free environment, then:

    • self-tests of the extracted AppImage and the unpacked .deb;
    • both started for real under Xvfb (no xdg-utils or desktop-file-utils in the container, like a minimal system): the job fails unless host.log says sidecar started (the window, WebKitGTK and the UI ran) with no error line in host.log or webview.log;
    • the privacy scan and SHA256SUMS;
    • an artifact storebound-desktop-linux.

    WEBKIT_DISABLE_DMABUF_RENDERER=1: the app sets it itself at start on Linux unless the user set it (some GPU / VM setups leave the window blank with DMABUF), and the workflow sets it too.

  • Windows (runs-on: windows): an NSIS installer (installMode: currentUser, WebView2 embedBootstrapper, unsigned), then:

    • a silent per-user install (/S), the self-test of the installed exe, a silent uninstall;
    • the privacy scan;
    • an artifact storebound-desktop-windows.

Then on the Mac:

bun tools/desktop-release.ts add --out ~/.storebound-releases/desktop/0.1.0 \
  --commit <the run's full commit hash> --run <the run's URL> \
  storebound_0.1.0_amd64.AppImage storebound_0.1.0_amd64.deb storebound_0.1.0_x64-setup.exe

It scans and signs each file, renames the unsigned Windows installer to -unsigned, and updates latest.json (windows-x86_64, linux-x86_64) and SHA256SUMS. It writes evidence-linux-<version>.json and evidence-windows-<version>.json (run, commit, sizes, hashes, the scan; they stay local like the macOS one) and refuses a --commit other than the one in the folder's macOS evidence: every artifact of a version comes from one commit. Before add, check the downloads against the run: the Linux artifact carries SHA256SUMS; for the Windows installer compare with Get-FileHash of C:\storebound-target\release\bundle\nsis\ on the runner. For the scan, install squashfs and sevenzip from Homebrew.

Runner setup

Forgejo runs in Docker on the VDS. .forgejo/workflows/ci.yml (bun run ci, Rust tests and clippy included) runs on every push and pull request to main; desktop.yml as above. Neither publishes anything (Jenkins still publishes site/). Workflow artifacts of this public repository can be downloaded by anyone for 14 days.

  1. Actions in Forgejo (done). FORGEJO__actions__ENABLED=true and FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org in the compose file; the Actions unit is on for CRT/storebound only.

  2. Linux runner on the VDS (done). /srv/forgejo-runner/: Forgejo runner 13 (vds-linux, registered to CRT/storebound only, label ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04, capacity 2, job timeout 2 h) next to its own Docker daemon (docker:29-dind, TLS only, 16 CPUs / 24 GB). Jobs run in throwaway containers of that daemon: no Docker socket, no host volumes, nothing of Forgejo's data. The runner never sees a signing key. actions/cache uses the runner's cache server. prune.sh (cron, daily) removes stopped containers, dangling images and cache entries unused for 14 days, and caps images at 40 GB and the cache at 20 GB.

    • Re-register (a new token from the repository's Actions runner settings): forgejo-runner register --no-interactive --instance https://git.crt.fyi --token <token> --name vds-linux in the runner image with /srv/forgejo-runner/data as /data, then docker compose up -d.
    • Linux quirks the workflow handles: NO_STRIP=1 (strip removes the bun sidecar's payload) and the sidecar's rpath set before bundling (linuxdeploy's own patchelf corrupts the bun executable; with the rpath already set it leaves the file alone). The self-tests start the sidecar from the AppImage and the .deb, so a broken sidecar fails the job.
  3. Windows runner on the VDS (done). Windows Server 2022 Standard (evaluation) in a KVM virtual machine, in Docker (dockurr/windows:6.05), /srv/windows-runner/. Forgejo runner 13.2.0 (vds-windows, label windows:host, capacity 1, job timeout 3 h), registered at the CRT user level: every CRT repository can use it (storebound, Demo App, ...).

    • Resources: 8 vCPUs, 12 GB RAM, a 150 GB disk (storage/data.img, sparse). restart: unless-stopped.
    • Network: nothing public. The web viewer (8006), RDP (3389) and the VM's OpenSSH (22, as host port 2223) are bound to 127.0.0.1 on the VDS; use an SSH tunnel (ssh -L 8006:127.0.0.1:8006 root@<vds>, then http://127.0.0.1:8006). The VM reaches the internet and git.crt.fyi through the container's NAT. No host folders but its own storage and oem/ (copied into the VM once, at install).
    • Accounts: builder (local admin, auto sign-in; password in /srv/windows-runner/.env, mode 600) and runner (no admin rights, random password known only to Windows) for the service. OpenSSH takes only the key in /srv/windows-runner/ssh/ (./vmssh, ./vmscp on the VDS).
    • Files: compose.yml; oem/install.bat + oem/setup.ps1 (first boot: OpenSSH, Windows Update policy, time zone UTC+8); provision.ps1 (build tools, idempotent, pinned downloads; log in provision.log); runner-setup.ps1 (account, config.yml, registration with a token on stdin, WinSW service); build/runner/ (forgejo-runner built from the v13.2.0 tag with GOOS=windows: Forgejo ships no Windows binary).
    • Toolchain (machine-wide, on the PATH the service sees): VS 2022 Build Tools 17.14 (MSVC x64/x86 and ARM64, Windows 11 SDK 10.0.26100), rustup in C:\rust (stable-x86_64-pc-windows-msvc, writable by runner, so jobs can rustup target add), Bun 1.4.0 (bun, bunx) in C:\bun, Git for Windows 2.56 (Git Bash's bash on the PATH for shell: bash; core.autocrlf false, core.longpaths true), PowerShell 7.6 (shell: pwsh), Node 24 (JavaScript actions), 7-Zip 26.04, WebView2 runtime. Tauri fetches NSIS itself.
    • Service: forgejo-runner (WinSW 2.12, delayed automatic start, restarts on failure) runs C:\forgejo-runner\forgejo-runner.exe daemon as runner; jobs work in C:\actions (a daily task removes job folders older than a day). Defender stays on, with exclusions for C:\actions, C:\storebound-target, C:\rust and C:\bun.
    • Windows Update: installs daily at 03:00 (UTC+8) and never restarts on its own while builder is signed in (it always is); active hours 07:00 to 23:00. A restart for updates is manual: ./vmssh "Restart-Computer -Force" when no job runs.
    • Licensing: the evaluation edition runs 180 days, then shuts down every hour. Extend it with slmgr /rearm (6 rearms left at install, 180 days each; the VM was installed in October 2026) or rebuild the VM (delete storage/, docker compose up -d, run provision.ps1 and runner-setup.ps1 again). The evaluation license is for evaluation and testing; for a permanent build machine, buy a Windows Server (or Windows 11 Pro) license and enter the key.
    • Re-register (a new token from CRT's user settings > Actions > Runners, or GET /api/v1/user/actions/runners/registration-token): on the VDS, delete C:\forgejo-runner\.runner in the VM and pipe the token into ./vmssh "powershell -File C:\provision\runner-setup.ps1".
    • Shared runner: any workflow of any CRT repository with runs-on: windows runs here, as runner, with internet access. Keep workflows of fork pull requests off it (as ci.yml does for Linux).

    The workflow's Windows job runs only when "windows" is ticked (from the API, "inputs": {"windows": "on"}).

  4. Windows code signing (later). Buy a code-signing certificate, then sign the installer before add and pass --code-signed.

Hosting on storebound.dev

URL served from how it gets there
/#download (the downloads, a section of the home page; /desktop/ redirects there) /var/www/storebound.dev/index.html, /var/www/storebound.dev/desktop/index.html site/index.html, site/desktop/index.html, with the site (Jenkins + /usr/local/sbin/storebound-publish)
/desktop/latest.json, /desktop/beta/latest.json /var/www/storebound-desktop/ storebound-desktop-publish
/desktop/dl// /var/www/storebound-desktop/dl/ storebound-desktop-publish

Why the binaries live outside the site's document root. The site's publish script rsyncs site/ with --delete-after, so anything not in git would vanish on the next site deploy. Binaries are never committed. Apache maps the three paths with Include /etc/apache2/storebound-desktop.conf in the :443 vhost (tools/vds/apache-storebound-desktop.conf):

  • latest.json is sent no-cache;
  • version folders are sent immutable;
  • no directory listings.

/usr/local/sbin/storebound-desktop-publish <version> [stable|beta] (tools/vds/storebound-desktop-publish, root:root 755) takes /var/lib/storebound-desktop/incoming/<version>/ (root only, mode 700) and checks it:

  • only plain files with plain names;
  • SHA256SUMS matches;
  • latest.json is that version, and every URL in it names an uploaded file under /desktop/dl//.

Then it publishes:

  • a new version folder appears at once (temp folder, then rename);
  • a later add to the same version only adds files, and a published file is never replaced by other bytes;
  • latest.json goes in last (temp file, then rename), so apps see a version only when all its files are there.

It runs nothing from the upload.

No sudoers entry is needed today. The owner's Mac uploads and runs the script as root over SSH. If a CI job should publish one day, give its user exactly this script, as the existing Jenkins entries do: <user> ALL=(root) NOPASSWD: /usr/local/sbin/storebound-desktop-publish.

Both files are installed on the VDS (D5). The Apache vhost was backed up to /root/storebound.dev.conf.bak-* first. To reinstall after a change:

scp tools/vds/storebound-desktop-publish root@<server>:/usr/local/sbin/storebound-desktop-publish
scp tools/vds/apache-storebound-desktop.conf root@<server>:/etc/apache2/storebound-desktop.conf
ssh root@<server> 'chmod 755 /usr/local/sbin/storebound-desktop-publish && apache2ctl configtest && systemctl reload apache2'

Publish

Publishing is the owner's call. Every installed app on the channel offers the update as soon as latest.json is live.

export STOREBOUND_PUBLISH_HOST=root@<server>      # the VDS (not the Cloudflare name)
bun tools/desktop-release.ts verify  --out ~/.storebound-releases/desktop/0.1.0
bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0              # dry run: the plan
bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0 --yes        # upload + publish
  1. Checks again: sums, signatures against tauri.conf.json's key, latest.json and the privacy scan.
  2. Uploads: rsync into the incoming folder (evidence stays local).
  3. Publishes: runs the publish script.
  4. Confirms: reads the live latest.json back.

--channel beta publishes to /desktop/beta/latest.json, which apps on the Beta channel read.

Rollback. Tauri updates only to a higher version, so a bad release is replaced by a fixed higher version. Its files stay where they are, because links and checksums must not change.

Going public: checklist

  1. A signed, notarized macOS build from the release commit (the Developer ID certificate is on the Mac). The gates above must pass.
  2. Forgejo Actions enabled, the Linux runner registered, a desktop-v0.1.0 tag built, add of the AppImage and deb.
  3. Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists.
  4. publish --yes to stable. Then open storebound.dev/#download on each OS and run storebound desktop --download.
  5. An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install.
  6. Update CHANGELOG (the home page's download section and README already point at storebound.dev/#download).