README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
20 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.
- The npm package stays the CLI, the MCP server and the skills. It contains no Tauri binaries.
- Bridge:
storebound desktop.- Installed: it opens the app. In an app folder it sends
storebound://open?path=<folder>, and the app selects that folder, or adds it after a native dialog. - Not installed: it prints the download for this OS and CPU.
--downloadsaves the installer to ~/Downloads after checking its size, SHA-256 and minisign signature. It never installs anything.
- Installed: it opens the app. In an app folder it sends
One version
desktopVersion in the root package.json is the desktop app's version. The npm package's own version belongs
to the CLI and moves on its own.
bun tools/desktop-version.tschecks the copies:apps/desktop/package.json,tauri.conf.json,Cargo.tomland the app's entry inCargo.lock. CI runs this check.bun tools/desktop-version.ts --set 0.1.1writes a new version into all of them.src-tauri/tests/d5.rschecks the same thing again from the Rust side.
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>orC:\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:
- Checks the session, the version, a clean checkout, the updater key and that the rustup targets are installed.
- Picks the signing identity:
- Developer ID Application found (
security find-identity -v -p codesigning): hardened runtime withsrc-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.
- Developer ID Application found (
- Builds the sidecars for arm64 and x64, merges them into one universal file with
lipo, then runstauri build --target universal-apple-darwin --bundles app --no-signwith 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 setLSRequiresNativeExecutionfalse or put x86_64 first inLSArchitecturePriority. Users must never see macOS's "App Update Required ... will not open in macOS 28" (Rosetta) notice. Only then doestauri bundlesign, 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. - Makes the updater bundle (
.app.tar.gz, root-owned entries, no macOS metadata) and the DMG. The DMG is made withhdiutilonly (no Finder scripting, nothing comes to the front). - Checks the exact artifacts:
codesign --verify --deep --strict;- the Gatekeeper expectation (signed:
spctlaccepts it as Notarized Developer ID andstapler validatepasses; unsigned: ad-hoc, andspctlrejects it); lipo -archson 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-teststarts the bundled sidecar, sendssession.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.
- Writes minisign
.sigfiles with the updater key and checks them right away against tauri.conf.json's key. - Writes
latest.json,SHA256SUMSandevidence-macos-<version>.json. The evidence file (tool versions, checks, hashes) stays local.
Signing identity and notarization key
-
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
-unsignedpreview. -
Notarization key. An App Store Connect API key, found in this order:
APPLE_API_KEY,APPLE_API_ISSUERandAPPLE_API_KEY_PATH;- storebound's own
ASC_KEY_ID,ASC_ISSUER_IDandASC_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.
-
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, andxcrun 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, andxcrun 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, WebView2embedBootstrapper, 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.
- a silent per-user install (
Then on the Mac:
bun tools/desktop-release.ts add --out ~/.storebound-releases/desktop/0.1.0 \
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. 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.
-
Actions in Forgejo (done).
FORGEJO__actions__ENABLED=trueandFORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.orgin the compose file; the Actions unit is on for CRT/storebound only. -
Linux runner on the VDS (done).
/srv/forgejo-runner/: Forgejo runner 13 (vds-linux, registered to CRT/storebound only, labelubuntu-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/cacheuses 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-linuxin the runner image with/srv/forgejo-runner/dataas/data, thendocker 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.
- Re-register (a new token from the repository's Actions runner settings):
-
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, labelwindows: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 andoem/(copied into the VM once, at install). - Accounts:
builder(local admin, auto sign-in; password in/srv/windows-runner/.env, mode 600) andrunner(no admin rights, random password known only to Windows) for the service. OpenSSH takes only the key in/srv/windows-runner/ssh/(./vmssh,./vmscpon 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 inprovision.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 withGOOS=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 byrunner, so jobs canrustup target add), Bun 1.4.0 (bun,bunx) inC:\bun, Git for Windows 2.56 (Git Bash'sbashon the PATH forshell: 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) runsC:\forgejo-runner\forgejo-runner.exe daemonasrunner; jobs work inC:\actions(a daily task removes job folders older than a day). Defender stays on, with exclusions forC:\actions,C:\storebound-target,C:\rustandC:\bun. - Windows Update: installs daily at 03:00 (UTC+8) and never restarts on its own while
builderis 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 (deletestorage/,docker compose up -d, runprovision.ps1andrunner-setup.ps1again). 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, deleteC:\forgejo-runner\.runnerin 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: windowsruns here, asrunner, 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"}). - Resources: 8 vCPUs, 12 GB RAM, a 150 GB disk (
-
Windows code signing (later). Buy a code-signing certificate, then sign the installer before
addand 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
addto 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
- Checks again: sums, signatures against tauri.conf.json's key, latest.json and the privacy scan.
- Uploads: rsync into the incoming folder (evidence stays local).
- Publishes: runs the publish script.
- 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
- A signed, notarized macOS build from the release commit (the Developer ID certificate is on the Mac). The gates above must pass.
- Forgejo Actions enabled, the Linux runner registered, a
desktop-v0.1.0tag built,addof the AppImage and deb. - Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists.
publish --yesto stable. Then open storebound.dev/#download on each OS and runstorebound desktop --download.- An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install.
- Update CHANGELOG (the home page's download section and README already point at storebound.dev/#download).