461 lines
33 KiB
Markdown
461 lines
33 KiB
Markdown
# Releasing storebound desktop
|
|
|
|
How the desktop app (`apps/desktop`) is built, signed, checked and published. For what the app does, see
|
|
[desktop.md](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
|
|
|
|
```sh
|
|
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.
|
|
|
|
The build jobs scan the installers with the private terms (secret `FORBIDDEN_TERMS`). When all are green, the `record` job 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 <version>" 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. The Linux / Windows installers are not downloaded to
|
|
the Mac (only with `--sign-local`).
|
|
5. **add-remote, verify:** the VDS signs the Linux / Windows files (see "Updater signatures on the VDS" below); the
|
|
acceptance matrix only when the release's `TESTS` is on; `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/desktop-release.ts add-remote --out ... --host root@<vds> --assets <json> --commit <sha> --run <run>`
|
|
(the json: `[{name, url, sha256, size}]` of the release's installers, the SHA-256 from the candidate's
|
|
green.json). Fallback: `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; the VDS signs the Linux / Windows
|
|
ones and the Mac its own.
|
|
|
|
### Updater signatures on the VDS (since 21 Oct 2026)
|
|
|
|
The Mac's line is too slow to download the ~120 MB AppImage, the deb and the exe just to sign them, so the
|
|
updater key has a second home on the VDS: `/root/.storebound-keys/updater.key` (dir 0700, file 0600, root; an
|
|
optional `updater.key.password` next to it, 0600, when the key has a password; today it has none).
|
|
|
|
- **Install or rotate:** `bun tools/desktop-release.ts vds-key` (host from `~/.storebound-keys/publish-host`, one ssh
|
|
connection). It sends the Mac's `updater.key` (and `updater.key.password`, if present) over ssh stdin, never
|
|
printed or on a command line, sets the modes, signs a test string on the VDS with the tauri signer and verifies it
|
|
here against tauri.conf.json's public key. Rotation: make the new key on the Mac (as in the table), update the
|
|
public key in tauri.conf.json and cli/lib/desktop-dist.ts, run `vds-key`.
|
|
- **In a release** (`tools/release.ts` step 5, `desktop-release.ts add-remote`, code in `tools/vds-sign.ts`): one ssh
|
|
script fetches the promoted release's installers into `/var/lib/storebound-desktop/incoming/<version>/`, checks
|
|
each SHA-256 against the candidate's green.json, signs with `bunx @tauri-apps/cli@<pinned> signer sign` (bun is on
|
|
the VDS; no minisign needed) and prints only `SIG <file> <blake2b-512> <size> <.sig>` lines. The Mac verifies every
|
|
signature over that digest with the public key (and that its trusted comment names the file) before it writes
|
|
anything, then writes the .sig files, latest.json, SHA256SUMS and `.remote.json` (the files that live only on the
|
|
VDS). `verify` and `publish` use `.remote.json`; publish re-checks those files' SHA-256 on the VDS and never
|
|
rsyncs them. The installers' privacy scan is the one of their build jobs.
|
|
- **Fallback:** `bun tools/release.ts <version> --resume --sign-local --yes` (or without `--resume`): the old path,
|
|
the installers downloaded to the Mac and signed there with `~/.storebound-keys/updater.key`.
|
|
|
|
## 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)
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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/<version>/<files> | /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/<version>/.
|
|
|
|
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:
|
|
|
|
```sh
|
|
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.
|
|
|
|
```sh
|
|
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).
|