Vaulthalla odin and zeus convene in Vaulthalla
All posts

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.

Cooper Larson8 min read

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.

  1. The tag must match the code

    The tag has to equal the VERSION file, or the run stops. The build number is the commit count on main, so it only ever grows.

  2. 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 0600 file. A final step that runs even on failure deletes the keychain and the key.

  3. One signing file for every target

    The archive is built with DeveloperID.xcconfig passed 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.

  4. Re-sign what Xcode didn't

    Xcode signs Sparkle.framework on 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.

  5. Prove every signature

    codesign --verify --deep --strict on the whole app, then a check of each piece: the app, the Finder extension, both helpers and every Sparkle component must be signed by exactly Developer ID Application: Valkyrian Labs LLC, and none may carry get-task-allow, the entitlement that lets a debugger attach.

  6. 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.

  7. Sign the update, then check the feed

    Sparkle's generate_appcast signs the DMG with an EdDSA key and writes appcast.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 with sign_update --verify.

  8. 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 style and brew audit --strict in a throwaway tap on the runner.

  9. Publish, in order

    A second job checks the DMG against its .sha256 again and creates the GitHub release with the DMG, its checksum and the appcast. Only after the release exists does it commit the cask to valkyrianlabs/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-mcp gives 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, and Contents/Helpers/vaulthalla-mcp stays 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.1password instead, 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 team ZG2G6V7GLC, 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:

CaskInstallsNeeds
vaulthallaServer and DesktopmacOS 27
vaulthalla-desktopDesktop only, for remote vaultsmacOS 27
vaulthalla-serverServer only, for headless MacsmacOS 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:

TimeWhat happened
16:57The release pipeline opens as a pull request and starts rehearsing
17:42It merges after its rehearsal passes
18:49Tag v0.1.0, after the agent bridge and Sparkle land
18:58Vaulthalla Desktop 0.1.0 is published: signed, notarized, stapled
18:58The cask step is refused with an HTTP 403
19:14The fix merges; 0.1.0's cask is published from its release artifact
22:09Tag v0.2.0: protocol negotiation and capability-gated features
22:170.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.

Know the limits
  • 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.