31 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 desktopinstalls, updates and opens the app of the CLI's own version (docs/desktop.md, "One product with the CLI"). It readsstorebound.dev/desktop/dl/<version>/latest.json(written by the publish script next to the version's files) and falls back to the channel'slatest.json.--downloadonly 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.tschecks 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.tomland the app's entry inCargo.lock. CI runs this check;src-tauri/tests/d5.rschecks the app's copies from the Rust side.bun tools/desktop-version.ts --set 0.8.0writes a new version into package.json and all of them.- Release order (one commit, one version): see "The release in one command" below. The app goes first
(
publishrefuses a folder whose version is not package.json's), thennpm publishof the same version (CONTRIBUTING.md), sonpx storebound desktopof the new CLI finds its own version.
The release in one command
bun tools/release.ts 0.8.5 # checks and the plan
bun tools/release.ts 0.8.5 --yes # the release; then the owner's npm publish
Target: tag push to everything published (the Forgejo release with every installer, storebound.dev's latest.json, the app in /Applications) in under 10 minutes, with every gate still in place. It works because nothing is built or tested twice: every push to main is already a release candidate.
Release candidates (every push to main)
.forgejo/workflows/ci.yml runs on every push to main, in parallel:
ci: the fullbun run ci(Rust tests and clippy included);acceptance: the release acceptance matrix, Linux and Windows x Node 20 / Node 22 / Bun (docs/acceptance.md);linux: the AppImage and the deb, self-tests, both started under Xvfb, privacy scan (the release target folder is cached, so only changed crates compile);windows: the NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
When all are green, the record job scans the installers with the private terms (secret FORBIDDEN_TERMS), builds
the 6-run acceptance matrix, and stores everything as the draft release rc-<full sha> on git.crt.fyi:
installers, SHA256SUMS, the acceptance run files, storebound_<version>_acceptance.md and green.json (sha,
version, CLI key, run link, every file's SHA-256). green.json is uploaded last: a candidate without it is not
complete. The newest 12 candidates are kept (tools/release-candidate.ts prune); release pruning never touches them.
bun tools/release-candidate.ts status --sha <sha> says green (with the record), red (with the failed run's link),
pending or none.
Version-only commits. The release's bump commit changes only the version. ci.yml's first job, gate, proves it
file by file: every changed file must be a version copy of tools/desktop-version.ts whose new text is exactly the
parent's text with the new version written in by that same writer, or CHANGELOG.md / docs/acceptance*. Then the
parent's full CI and acceptance matrix count for the bump commit (the parent's run is waited for if it is still
running; a red parent fails the gate), and only the installers are built again, because their version is compiled in
(Cargo, tauri.conf.json, the bundled CLI, the file names). That is simpler and safer than making the installers
version-independent. The acceptance runs carry a CLI key (tools/cli-key.ts: a hash of everything the packed
CLI is made of, with the version copies normalized), and acceptance.ts report --strict accepts a run of another
version only when its key is the checkout's.
The tag only promotes
.forgejo/workflows/release.yml on a v* tag checks the tag against package.json, every copy and the CHANGELOG
section, then release-candidate.ts promote: it waits for the tagged commit's candidate (heartbeat; fails at once
when the commit is red or has no ci run, with the run's link; 40 min timeout), checks its version, and makes the
release "storebound " from the candidate's files (no rebuild, no retest). Stable versions then delete the
older release entries (tags stay). Versions with a "-" are prereleases: never pruning, never the stable release.
Run it by hand from Actions > release with a tag (dry by default: only the plan).
What tools/release.ts does on the Mac
- Preflight: a clean checkout equal to origin/main whose candidate is green or still running, the updater key,
the publish host (
STOREBOUND_PUBLISH_HOSTor~/.storebound-keys/publish-host). The macOS acceptance matrix (Bun, Node 20, Node 22 from fnm) starts in the background unless its results for this CLI key are cached in~/.storebound-releases/acceptance-cache/<key>/(a version bump keeps the key;bun tools/release.ts mac-acceptancefills the cache ahead of time). - Bump: the version in package.json and every copy, the CHANGELOG's
## Unreleasedcut into## <version> · <date>(its first paragraph is the release summary). - Fast gate, commit, push:
bun run ci:quick, the commit as CRT, push to main, push the tag. - In parallel:
desktop-release.ts mac(universal build, sign; Tauri notarizes the app while the boot check runs; the DMG is notarized while the app and the updater bundle are checked, scanned and signed), Forgejo's ci run of the bump commit (installers only) and release.yml's promotion. Meanwhile the candidate's Linux / Windows installers and acceptance runs are downloaded. - add, matrix, verify: updater signatures of the Linux / Windows files, the 9-run acceptance matrix (strict) to
the release,
verify. - publish with
--forgejo-tag: the VDS fetches the Linux / Windows files from the Forgejo release over its own network, rsync sends only the Mac's DMG and updater bundle, the VDS attaches those to the Forgejo release from its copy, then the publish script swaps latest.json. The owner's uplink (about 0.5 to 1 MB/s) carries each Mac file once instead of three times. - /Applications/storebound.app: the running app is asked to quit (an AppleEvent), then its exact PID gets
SIGTERM / SIGKILL if needed; the new app is copied from the DMG, verified (
codesign --verify --deep --strict) and reopened in the background (open -g). - Prints
npm publishandnpm deprecate storebound@"<version>" ...(the owner's step) and the time of every phase (also in~/.storebound-releases/timings/). Logs:~/.storebound-releases/logs/<version>/.
Every wait has a timeout and a heartbeat; child processes are killed by PID when it exits.
Notarization in parallel, where possible. Apple notarizes the app (inside tauri bundle) and then the DMG. The
DMG cannot go first or at the same time: it must contain the app with its stapled ticket (the gate checks
stapler validate on the app inside the mounted DMG). So each wait for Apple runs next to other work instead: the
boot check during the app's notarization; every app and updater-bundle check, their privacy scan and the updater
signature during the DMG's.
Rehearsal: bun tools/release.ts --rehearsal [<n>] --yes runs all of it for version 0.0.0-rehearsal.<n> on the
branch rehearsal/<n> (ci.yml runs there too): a prerelease on git.crt.fyi, the rehearsal channel on
storebound.dev (/desktop/rehearsal/latest.json, which no app reads; the publish script refuses rehearsal versions
on stable / beta and other versions on rehearsal), the app copied to a scratch folder (nothing quit or opened), never
npm. At the end it deletes the release and tag, the candidate, the branch, the version's files on storebound.dev and
the local folders, and prints the times.
By hand (what release.ts runs)
bun tools/desktop-version.ts --set 0.8.5and the CHANGELOG section,bun run ci:quick, commit, push, tag, push the tag (release.yml promotes the candidate).bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.8.5.bun tools/release-candidate.ts fetch --sha <sha> --dir <dir>, thenbun tools/desktop-release.ts add --out ... --commit <sha> --run <run> <AppImage> <deb> <setup.exe>.bun tools/desktop-release.ts publish --out ... --forgejo-tag v0.8.5 --yes(orupload-forgejo, thenpublish).npm publishof the same version: the owner's step (CONTRIBUTING.md).
Secrets (repo Settings > Actions > Secrets): RELEASE_TOKEN, a token of CRT with scope write:repository
(name storebound-release-workflow; releases, assets, candidates: ci.yml's gate and record jobs and release.yml use
it), FORBIDDEN_TERMS, the contents of forbidden-terms.txt (never printed: hits show x***), and
FORGEJO_ORIGIN, the server's IP: git.crt.fyi is behind Cloudflare, which refuses uploads over 100 MB (the AppImage,
the DMG) with a 502, so forgejo-release.ts uploads assets to the origin directly (curl --resolve, TLS still
checked for git.crt.fyi). On the Mac the same IP is in ~/.storebound-keys/forgejo-origin. Rotate the token: create
a new one under CRT's Settings > Applications with that scope, put it into the secret, delete the old one.
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.
- Signed macOS artifacts are scanned without their code signatures (
tools/mac-signature-scan.ts, used bydesktop-release.tsfor the .app, the DMG and the updater bundle). A Developer ID signature embeds the signing certificate, and its subject is the developer's legal name, which may be in the terms file. That name may appear inside Apple code signatures and nowhere else, so the scan reads copies:codesign --remove-signatureon every Mach-O file of the copied app (also the app copied out of the mounted DMG and the one unpacked from the updater bundle), and the DMG's own signature blob (the range the UDIFkolytrailer names) zeroed in memory. Every other byte is scanned as it ships; a hit outside a signature still fails the release (tests/mac-signature-scan.test.ts).
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)
The linux and windows jobs of .forgejo/workflows/ci.yml, on every push to main (the release candidate).
-
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 \
--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 (the release candidate: bun run ci, the acceptance
matrix, the installers) runs on every push to main and ci + acceptance on pull requests; release.yml promotes a
candidate on a tag (Jenkins still publishes site/, and publish storebound.dev/desktop). 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).
- 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, /desktop/rehearsal/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|rehearsal] (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. --channel rehearsal
(release rehearsals only) to /desktop/rehearsal/latest.json, which no app reads. --forgejo-tag v<version>: the
server fetches the files the Forgejo release already has and attaches the Mac's files to it (see above).
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 and Windows runners registered, a dry run of release.yml green, then the
vtag,addof the AppImage, deb and setup.exe from the release. - 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).