
The Release Ceremony: How Vaulthalla Desktop Ships
Vaulthalla Desktop is out for macOS. How one tag becomes a Developer ID signed, notarized and stapled DMG, a signed Sparkle update and a Homebrew cask, with no human hand in the release.
Vaulthalla Desktop is out: a native Mac app for every node you run, Developer ID signed, notarized by Apple and updated in place. Here's the ceremony that ships it, and why no human hand touches a release.
Vaulthalla Desktop is the native cockpit for your Vaulthalla nodes. Pair your Mac with each server once, then manage every node, browse your vaults in Finder, back vault keys up to 1Password, and give AI agents a safe MCP surface. Swift 6 and SwiftUI, macOS 27 and later, Apple Silicon.
1brew install --cask valkyrianlabs/tap/vaulthalla-desktop
Running a vault on this Mac too? Starting with server 1.19.0, one cask installs both:
1brew install --cask valkyrianlabs/tap/vaulthalla
Or download Vaulthalla-0.2.0.dmg from the release. It opens without a single Gatekeeper prompt, because before that DMG existed it went through a ceremony.
Why a ceremony
A Mac app that holds device keys to your vaults has to be the app we built, signed by us, and nobody else's. Apple's chain for that is long: a Developer ID certificate, provisioning profiles for every target with restricted entitlements, a hardened runtime, notarization, a stapled ticket. Then the update feed has to be signed with a key of its own, and the Homebrew cask has to point at the exact bytes that were published.
Every one of those steps fails quietly when it's done by hand. A helper left with an ad hoc signature. A debug entitlement that slipped into a release build. A cask that names a DMG nobody uploaded yet. So none of it is done by hand. A tag starts the ceremony, the ceremony checks its own work at every step, and anything it can't prove stops the release.
Before the first build: claim your names
The ceremony starts with paperwork. Vaulthalla Desktop is signed by Valkyrian Labs LLC (team ZG2G6V7GLC) in the Apple Developer Program, never by a personal account.
We learned that the expensive way. Bundle ids are unique across every Apple team, and during the early File Provider experiments, Xcode's automatic signing with a free personal team quietly registered sh.vaulthalla.desktop. That claim is permanent: the company team can never have that id. Desktop became sh.vaulthalla.app, with the Finder extension at sh.vaulthalla.app.fileprovider and the agent bridge at sh.vaulthalla.app.mcp, and the repository now says in three places never to sign with a personal team.
The ceremony, step by step
Pushing a tag vX.Y.Z runs .github/workflows/release.yml on a macOS runner with Xcode 27. One script, tools/release/build.sh, does the work, and every step proves the one before it.
The tag must match the code
The tag has to equal the
VERSIONfile, or the run stops. The build number is the commit count onmain, so it only ever grows.A keychain that lives for one job
The Developer ID certificate goes into a fresh keychain with a random password, created for this run. Three provisioning profiles (the app, the Finder extension and the agent bridge) are installed, along with notarization credentials and the Sparkle update key in a
0600file. A final step that runs even on failure deletes the keychain and the key.One signing file for every target
The archive is built with
DeveloperID.xcconfigpassed as-xcconfig, which outranks every target and project setting. Each target picks its own profile by bundle id, so adding a target can't silently fall back to the wrong one.Re-sign what Xcode didn't
Xcode signs
Sparkle.frameworkon copy but leaves its helpers (the installer and downloader XPC services,Autoupdate,Updater.app) with Sparkle's ad hoc signatures, which notarization rejects. The script signs them inside out with the hardened runtime and a secure timestamp, then reseals the framework and the app.Prove every signature
codesign --verify --deep --stricton the whole app, then a check of each piece: the app, the Finder extension, both helpers and every Sparkle component must be signed by exactlyDeveloper ID Application: Valkyrian Labs LLC, and none may carryget-task-allow, the entitlement that lets a debugger attach.Notarize the app, then the DMG
The app is notarized and stapled on its own first, so a copy dragged out of the DMG still opens offline. Then the DMG is built, signed, notarized and stapled too, and Gatekeeper has to accept it (
spctl --assess) before the run continues. A rejected notarization prints Apple's log and fails the release.Sign the update, then check the feed
Sparkle's
generate_appcastsigns the DMG with an EdDSA key and writesappcast.xml. The script then reads the feed back: it must offer exactly one item, at exactly this release's URL, with this DMG's length, and the signature must verify withsign_update --verify.Render and audit the cask
The Homebrew cask is rendered from a template with this DMG's version and SHA-256, then checked with
brew styleandbrew audit --strictin a throwaway tap on the runner.Publish, in order
A second job checks the DMG against its
.sha256again and creates the GitHub release with the DMG, its checksum and the appcast. Only after the release exists does it commit the cask tovalkyrianlabs/homebrew-tap, so the cask can never point at a DMG that isn't published yet.
From tag to published release takes about eight minutes.
Rehearsals publish nothing
Any pull request that touches the release tooling runs the whole ceremony, real signing and real notarization included, and stops before publishing. The pipeline, the agent bridge, Sparkle and both cask changes each rehearsed before they merged, and the ones that needed it stayed drafts until their rehearsal passed. Pull requests from forks never see the signing secrets at all.
That's why the first real tag was boring. The pipeline had already signed and notarized the app three times in rehearsal.
One feed, no update server
Desktop updates itself with Sparkle. Its feed URL is fixed in the app:
1https://github.com/valkyrianlabs/vaulthalla-desktop/releases/latest/download/appcast.xml
releases/latest always resolves to the newest release, and every release carries an appcast that lists only itself. So there's no feed server to run, nothing to edit after a release, and no way for the feed and the download to disagree: they're assets of the same release. The one rule that comes with it is that a release is never marked prerelease, since releases/latest skips those.
Each update is signed with an EdDSA key whose public half is built into the app, separate from Apple's signature. To be offered as an update, a DMG needs both.
Homebrew stays out of Sparkle's way: the cask says auto_updates true and leaves upgrades to the app. Debug builds never check for updates, so a development build can't replace itself with a release.
Signing the parts nobody sees
The app bundle carries more than the app, and each piece had its own trap:
- The agent bridge.
vaulthalla-mcpgives Claude Code or Codex the tools of every paired node, so it needs the Secure Enclave device key, which lives in a keychain group. That's a restricted entitlement only an embedded provisioning profile can authorize, and a bare command-line tool can't embed one: macOS kills it on launch. The bridge became a hidden background app bundle with its own profile, andContents/Helpers/vaulthalla-mcpstays a symlink to it, so every existing agent config kept working. - The 1Password helper. It's written in Go, and Go's linker names each build with a fresh hash, which would give it a different code identity every release. It's signed with the fixed identifier
sh.vaulthalla.app.1passwordinstead, so 1Password sees the same helper from one version to the next.
The server checks our work too
Starting with 1.19.0, the server's macOS package can carry Desktop as an optional component, and the server's own release pipeline fetches the latest Desktop DMG to put in it. It doesn't take our word for it. It refuses the DMG unless:
- it matches its published
.sha256; - Gatekeeper accepts it as notarized Developer ID software;
- the app inside is
sh.vaulthalla.app, signed by teamZG2G6V7GLC, with a valid stapled ticket.
A server release fails rather than publish without Desktop. The component is version-checked too, so installing the server never downgrades a newer Desktop that Sparkle already put on your Mac.
That gives valkyrianlabs/tap three casks:
| Cask | Installs | Needs |
|---|---|---|
vaulthalla | Server and Desktop | macOS 27 |
vaulthalla-desktop | Desktop only, for remote vaults | macOS 27 |
vaulthalla-server | Server only, for headless Macs | macOS 14 |
vaulthalla and vaulthalla-desktop both install /Applications/Vaulthalla.app, so each declares a conflict with the other.
Two releases in one afternoon
All of this happened on October 30, times in UTC:
| Time | What happened |
|---|---|
| 16:57 | The release pipeline opens as a pull request and starts rehearsing |
| 17:42 | It merges after its rehearsal passes |
| 18:49 | Tag v0.1.0, after the agent bridge and Sparkle land |
| 18:58 | Vaulthalla Desktop 0.1.0 is published: signed, notarized, stapled |
| 18:58 | The cask step is refused with an HTTP 403 |
| 19:14 | The fix merges; 0.1.0's cask is published from its release artifact |
| 22:09 | Tag v0.2.0: protocol negotiation and capability-gated features |
| 22:17 | 0.2.0 is out, offered to every 0.1.0 install through Sparkle |
The one failure came last and was harmless by design. The cask step ran after the release, as it always does, so the DMG and appcast were already published and correct. The tap token belongs to the Valkyrian Labs organization and couldn't write to the old vaulthalla tap. Casks now publish to valkyrianlabs/tap, and the 0.1.0 cask was published from that run's own artifact.
Where Desktop fits
The web console stays the browser surface for operators and the page you send share recipients to. Desktop holds no business logic: every action it offers is an operation the daemon authorizes, as the account the Mac was paired by. Destructive agent commands still wait for your approval, and vault keys are never reachable over MCP.
That design has a consequence: Desktop, the server and the web console now ship on their own schedules, and a 0.2.0 app will meet servers older and newer than itself. How it stays correct across that skew is the subject of the next post, on the handshake.
- macOS 27 or later, Apple Silicon only. The server itself still runs on macOS 14.
- Version 0.x. Desktop is young: pair it with a node you can afford to re-pair.
Your hardware. Your keys. Your cloud. Now with a cockpit.
