storebound/docs/desktop-release.md

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 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): see "The release in one command" below. The app goes first (publish refuses a folder whose version is not package.json's), then npm publish of the same version (CONTRIBUTING.md), so npx storebound desktop of 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 full bun 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

  1. Preflight: a clean checkout equal to origin/main whose candidate is green or still running, the updater key, the publish host (STOREBOUND_PUBLISH_HOST or ~/.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-acceptance fills the cache ahead of time).
  2. Bump: the version in package.json and every copy, the CHANGELOG's ## Unreleased cut into ## <version> · <date> (its first paragraph is the release summary).
  3. Fast gate, commit, push: bun run ci:quick, the commit as CRT, push to main, push the tag.
  4. 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.
  5. add, matrix, verify: updater signatures of the Linux / Windows files, the 9-run acceptance matrix (strict) to the release, verify.
  6. 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.
  7. /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).
  8. Prints npm publish and npm 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.5 and 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>, then bun 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 (or upload-forgejo, then publish).
  • npm publish of 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> 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.
  • Signed macOS artifacts are scanned without their code signatures (tools/mac-signature-scan.ts, used by desktop-release.ts for 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-signature on 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 UDIF koly trailer 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:

  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)

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, 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 (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.

  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).
  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, /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 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. --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

  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 and Windows runners registered, a dry run of release.yml green, then the v tag, add of the AppImage, deb and setup.exe from the release.
  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).