Sparkle is the updater. Railcast is the backend it needs.
Sparkle handles the client side of auto-updates on macOS — checking for new versions, downloading, verifying, installing. It deliberately doesn't host anything: the appcast feed, the release files, the signing — that part is on you. In practice that turns into a Supabase Edge Function nobody wants to maintain, an appcast.xml hand-edited on a raw GitHub URL, or a full backend built to serve one XML file.
Railcast is that missing piece. Push a build, get back a signed, hosted feed. Nothing to run, nothing to keep alive.
- A hosted
appcast.xmlfor each app, served fast and cached at the edge - EdDSA signing happens on your machine: Railcast never sees your private key, so even a full compromise of the server can't produce an update your users' Sparkle will accept
- Release channels (stable / beta) out of the box
- A CLI that turns "build → signed, hosted release" into one command
- Already shipping with Sparkle? Bring your existing signing key (
railcast init --import-key): installed copies keep trusting your updates, no new app version needed - Pull a broken release without deleting anything (
railcast yank) - No lock-in:
railcast exporthands you every file, signature and a ready-made feed, andrailcast redirectmoves already-installed apps to your own host without a new release - A web dashboard for your apps, tokens and release history — the same operations as
railcast list/yank/cleanup/redirect/export, plus deleting your account
Solo and small-team macOS developers shipping a native app who want Sparkle's update experience without owning the infrastructure behind it. If you've ever thought "I just need somewhere to put this XML file," this is for you.
- Create an account — railcast.casablanque.com/register, email + password. Confirm the email that gets sent; that link also logs you in.
- Get a token — on the dashboard, under "Get a token", click Generate new token. It's shown once and copied to your clipboard automatically — save it somewhere, it can't be viewed again (you can always revoke it and generate a new one).
- Install the CLI:
It verifies the download's sha256 against the checksum published alongside every release binary before installing anything — a corrupted download or a tampered mirror gets rejected, not silently installed.
curl -fsSL railcast.casablanque.com/install.sh | sh - Set your token for the session (optional, but every command below assumes it):
export RAILCAST_TOKEN=<token from step 2>
From the directory where your build lives:
railcast init --app testappThis generates an Ed25519 signing key, registers a new app on the server, and saves two things in the current directory:
testapp.key— your private signing key (mode0600). Never share it, never commit it. Losing it means you can no longer publish updates for this app — there's no recovery..railcast.json— the app's real (server-generated) id and the path to the key.railcast publishreads this automatically from now on, so you don't need to pass--app/--keyagain as long as you runpublishfrom this same directory.
init also prints an SUFeedURL and SUPublicEDKey — add both to your app's Info.plist once, so Sparkle knows where to check for updates and which key to trust.
Then publish the first build:
railcast publish -f testapp-1.0.0.zip-v/--version and -b/--build are both optional for a .zip — Railcast reads
CFBundleShortVersionString and CFBundleVersion straight out of the .app's own Info.plist
inside the archive, so there's nothing to type or keep in sync by hand. Before touching the
network, publish prints exactly what it resolved and where each value came from, so you can
double check:
┌─ Publishing plan ──────────────────────────
│ file: testapp-1.0.0.zip
│ app: a1b2c3d4e5f6
│ channel: stable
│ version: 1.0.0 (detected from Info.plist)
│ build: 42 (detected from Info.plist)
│ sha256: 9f3a...
└────────────────────────────────────────────
Installed copies only trust the SUPublicEDKey they shipped with, so reuse your existing key instead of generating a new one:
# 1. Export the key Sparkle keeps in your Keychain (from Sparkle's bin/ folder):
./generate_keys -x sparkle_private_key
# 2. Register it with Railcast:
railcast init --app myapp --import-key sparkle_private_keyThe export is a 32-byte seed, and Railcast reads it directly — CI checks this against Sparkle's own generate_keys and sign_update (same key, same file, identical signature). The key is copied to myapp.key in Railcast's format; your original file is left alone. init prints SUPublicEDKey — it must equal the one already in your shipping app's Info.plist, otherwise it's the wrong key. Then ship one last release from your old feed that changes SUFeedURL to the Railcast URL; from then on, publish with Railcast.
From the same directory (so .railcast.json is picked up):
railcast publish -f testapp-2.0.0.zipBump CFBundleShortVersionString/CFBundleVersion in Xcode like you normally would before archiving — Railcast picks up whatever's actually in the zip, every time. There's no separate number to remember to bump on the Railcast side.
- The archive file must still have a name Railcast hasn't seen before for this app (see Gotchas below) — the filename itself has to change every release, bumping the version alone does not satisfy this. Baking the version into the filename (as above) is the simplest way to guarantee that.
- If you do pass
--version/--buildexplicitly, they override whatever's in the zip. An explicit--buildstill has to be strictly greater than the previous build on that channel — the server rejects anything else. - For a
.dmg/.pkg(or a.zipwith no.appinside, or with a non-numericCFBundleVersion), auto-detection isn't possible — pass--versionexplicitly, and see the Gotchas note on--buildbelow.
Optional flags for either a first publish or an update:
| Flag | Short | Purpose |
|---|---|---|
--build <n> |
-b |
Explicit build number, overriding what's detected from the archive (or Railcast's own auto-assign, if detection isn't possible). Must be greater than the channel's current latest — see Gotchas. |
--channel beta |
-c |
Publishes to a separate channel instead of stable. Build-number ordering is tracked per channel, independently. |
--notes "…" |
Markdown (or plain text) release notes, shown in Sparkle's update dialog. Notes that start with an HTML tag are sent as HTML instead. Markdown rendering needs Sparkle 2.9+ and macOS 12+; older clients show the raw text. | |
--notes-file path |
Same, read from a file — overrides --notes if both are given. |
|
--min-system-version <v> |
Lowest macOS version the build runs on, e.g. 13.0 (sparkle:minimumSystemVersion). Detected from LSMinimumSystemVersion in the .app's Info.plist for a .zip. |
|
--critical |
Marks the update as critical (sparkle:criticalUpdate) — Sparkle won't let the user postpone it. |
|
--phased-rollout <seconds> |
Staggers the rollout to installed clients (sparkle:phasedRolloutInterval). 0 (default) disables it. |
--file/-f, --version/-v, --app/-a, --key/-k, and --token/-t all have the same
short forms shown earlier. Run railcast publish --help any time for the full, current flag list.
railcast yank 1.4.2 # hide it from the feed, keep the file
railcast yank 1.4.2 --undo # bring it backNew update checks get the previous release again. Sparkle never downgrades, so anyone who already installed the bad build stays on it until you publish a fix with a higher build number. railcast list marks yanked releases; railcast cleanup never deletes them. The only live release on a channel can't be yanked.
Railcast is run by one person, so nothing about your releases is locked in here.
1. Export everything:
railcast export --app myapp --out ./export --files-url https://updates.myapp.com/filesDownloads every release file (checked against its recorded sha256), writes releases.json (signatures, hashes, notes) and a ready-made appcast.xml (plus appcast-beta.xml etc.) pointing at --files-url. Upload export/files/ to any static host and serve the XML there.
2. Redirect installed apps to it:
railcast redirect --to https://updates.myapp.com/appcast.xmlInstalled copies keep asking the feed URL they shipped with. After this, that URL answers with a redirect (302) to the new feed, so they follow you without a new release. Railcast fetches the target first and refuses it unless it looks like an appcast (--force overrides). Keep the new feed signed with the same key — installed copies still trust the SUPublicEDKey they shipped with. The beta channel follows too if the URL ends in /appcast.xml (it maps to appcast-beta.xml next to it). Undo with railcast redirect --clear; railcast redirect alone shows the current state. CI checks this with a real Sparkle client (the update check of Sparkle's own command-line updater follows the redirect, and the feed comes back after --clear), but try it with a throwaway app before relying on it for a real one.
- Upload filenames are permanent per app. Once
appid/filenamehas a published version attached, that exact filename can never be re-uploaded for that app — it's intentional (nothing should be able to silently swap the bytes behind an already-signed, already-published release). If you get"...zip" was already published for this app, the fix is to rename the archive, not to change--version/--build. Baking the version into the filename up front avoids ever hitting this. --build/--versionare detected from the archive, not tracked by Railcast. For a.zip, Railcast readsCFBundleShortVersionString/CFBundleVersionstraight from the.app's ownInfo.plistinside it — the same values already baked into what's running on someone's Mac, so there's no separate counter that can drift out of sync. This only works for.ziparchives with a.appinside and a numericCFBundleVersion; anything else (.dmg/.pkg, or a.zipwhere detection fails) falls back to a per-app, per-channel counter Railcast maintains itself (starting at1,stableandbetaindependent) — pass--versionexplicitly in that case, and see the next point for--build.- If you're relying on Railcast's own counter (the fallback above), it doesn't know about builds you shipped before adopting Railcast. Sparkle compares the appcast's build number against the installed app's own
CFBundleVersion— if that's already at, say,42from your own tooling, and Railcast's counter starts fresh at1, existing users would never see the update (1 < 42). This isn't a concern if.zipauto-detection is working (see above) — it always reflects the realCFBundleVersion, so it can't fall behind. It only matters for.dmg/.pkgor undetectable.zips: set a floor once, atinittime —railcast init --app myapp --initial-build 42— and the counter starts at43instead. Forgot, and the app already exists? Pass an explicit--buildhigher than your last real one for the next publish; the counter picks up from there afterwards. - The signing key never touches the server.
initgenerates it locally (or imports yours with--import-key) and only ever uploads the public half. If<app>.keyis lost, there is no way to publish further updates to that app under the sameSUPublicEDKey: installed copies would reject anything signed with a different key, so those users would have to download the new app by hand. Back the key up. publishchecks your key before uploading. It verifies the fresh signature and compares the key's public half with the one registered for the app; a mismatch (wrong--keyfile) stops the publish instead of producing a feed Sparkle would silently reject. The server itself does not verify signatures — it only checks sha256 and size.- The public feed is cached at the edge for up to 60 seconds. A release you just published (or yanked) is visible immediately in the data center you published from, and within a minute everywhere else. Browsers and Sparkle are told to revalidate every time (
Cache-Control: no-cache), so reloading the feed URL shows the current state. Beta feeds are never cached. - Limits on the hosted instance: 500 MiB per file, 5 GiB of published releases per account, 60 uploads per hour, 50 apps and 100 tokens per account. Uploads that never get registered as a release are deleted by a nightly sweep after 24 hours. Delete old releases (
railcast cleanup) to free space. --appatinittime is just a local label — it picks the default key filename and shows up in your terminal, but the id Railcast actually uses (in the feed URL, in--appforpublish) is a separate, server-generated id written into.railcast.json. You don't need it to be unique across all Railcast users.- Publishing from a different machine or directory (no local
.railcast.json/key) means passing--app <id>and--key <path>explicitly topublish— copy both from whereverinitoriginally ran. Don't runinitagain for an app you already have; that creates a brand-new app with a brand-new key, not a continuation of the old one. - Beta channel feeds are unlisted, not private.
railcast initprints a feed URL like.../appcast.xml?channel=beta&token=<beta_token>— anyone with that URL can read the beta feed, there's no per-user auth on it. Treat the URL itself as the secret; it's not shown again afterinit, but you can find the current one on the dashboard. - Tokens can be account-wide or scoped to one app, and to publish-or-read, set at creation time (dashboard: the "Get a token" form; CLI: not creatable from the CLI itself, only from the dashboard). A leaked token only ever exposes what it was actually scoped to — prefer a narrowly-scoped one for CI. Revoke it from the dashboard immediately if it leaks; publishing continues to work for anyone with a different valid token.
- Self-hosting: override the API base URL with
--base-urlor$RAILCAST_BASE_URLif you're not using the hosted instance.
In active development. macOS / Sparkle only.
Free and open source under AGPL-3.0 — self-host it, or use the hosted instance at railcast.casablanque.com. No account gating, no paid tier. Donations are welcome but never required — see the site for links.
The hosted instance is run by one person. That is why export and redirect exist: if it ever goes away, your releases and your installed apps can leave with you.
Terms and privacy notes for the hosted instance are short and in plain language. Complaint, question, suggestion — or just want to say something? Feel free to reach out: casablanque@proton.me
Questions or bugs: casablanque@proton.me