From private repo to public downloads: building Space Zero’s desktop release pipeline

September 8, 2026

Space Zero built locally. That wasn’t the same as having something I could hand to someone else.

It’s an Electron desktop app for software builders, bringing projects, agents, code, and the surrounding workflow into one workspace. Getting it running on my own machine was one problem. Producing a download that someone else could install without access to my development environment was another.

For macOS, that meant signing, notarization, and Gatekeeper verification. For future updates, it meant stable metadata URLs. And because the source repository is private, I needed a way to make downloads public without making the source public.

The release pipeline became the next piece of product work.

The goal sounded straightforward: push a tag, let CI build the app, publish the downloads. The path to getting there involved enough beta tags to become a debugging journal.

The release process I wanted

I wanted the human part to be small:

  1. Bump the version.
  2. Push a matching beta tag.
  3. Let CI validate, build, verify, and publish.

Both the root package.json and the desktop app’s package.json must match the tag. After committing the version bump, the release commands look like this, with N replaced by the next unused beta number:

pnpm release:validate-tag v0.1.0-beta.N
git tag v0.1.0-beta.N
git push origin v0.1.0-beta.N

Everything after that should be repeatable from a clean checkout.

I also wanted packaging and publishing to be separate decisions. Successfully producing an artifact shouldn’t automatically make it public while I’m still developing the release process.

Publication therefore requires an explicit repository variable:

SPACEZERO_MACOS_RELEASE_PUBLISH_ENABLED=true

Without it, the workflow can exercise packaging and verification without uploading public downloads.

The other boundary was credentials. Dependency installation, typechecking, linting, tests, and ordinary builds run without release secrets. Apple credentials are supplied to the signing and notarization step. R2 credentials are supplied to the upload step.

That gave me four distinct categories to reason about: build inputs, signing credentials, notarization credentials, and publication credentials.

macOS: building the app was only the beginning

The first macOS release path produces separate Apple Silicon and Intel builds:

  • arm64 on an Apple Silicon GitHub-hosted runner.
  • x64 on an Intel GitHub-hosted runner.

Each produces a DMG and a ZIP.

The DMG is the familiar installation path. The ZIP is also important because it is the payload format used by the macOS update machinery we’re preparing for.

Apple’s trust process has several separate parts.

Signing uses a Developer ID Application certificate to identify the developer and protect the integrity of the signed application.

Notarization submits the signed software to Apple’s notarization service.

Stapling attaches the notarization ticket to supported artifacts so it can travel with the download.

For signing, I configured a password-protected certificate and private key through GitHub Actions secrets. For notarization, I used an App Store Connect API key rather than an Apple ID and app-specific password.

The workflow writes the notarization key into an owner-only temporary file and removes it when the step exits.

Electron Builder signs and notarizes the application with automatic publishing disabled. A separate script then submits the signed DMG for notarization and staples it.

The ZIP itself isn’t stapled; the application inside it is what gets checked.

Finally, CI verifies the results. It extracts the ZIP, mounts the DMG, and checks the applications inside them. A packaging command exiting successfully wasn’t enough evidence that the download was ready.

The beta tags became a debugging journal

The earliest beta tags already existed from previous work, so this pass started at beta.5.

From there, each new tag exposed another assumption.

Clean runners don’t know what your laptop knows

One of the first failures happened before signing.

The clean GitHub runner exposed typecheck and build-order assumptions that my existing checkout hadn’t exposed. Local build state can make a process appear more self-contained than it really is.

This was exactly why I wanted CI to own releases. If producing an artifact depends on something left over from yesterday’s development session, the release process isn’t reproducible yet.

beta.6 addressed the clean-runner typecheck problem.

Architecture flags and filenames needed explicit rules

Next came argument handling.

Passing architecture flags through package scripts into Electron Builder didn’t behave the way the workflow expected. I added a small wrapper that selects one architecture and invokes Electron Builder with explicit arguments, including:

--mac --arm64 --publish never

The Intel job uses --x64 instead.

Architecture selection also needed to live in one place. The target configuration stopped declaring both architectures while the CI job was trying to request only one.

Then there were filenames.

The product name is Space Zero, but release filenames became deliberately predictable:

Space-Zero-0.1.0-beta.16-arm64.dmg
Space-Zero-0.1.0-beta.16-arm64.zip

The naming fixes spread across beta.8 and beta.9, because changing the producer wasn’t enough. The staging commands and verifier also needed to agree.

A filename becomes part of the release interface once metadata, upload scripts, and download links depend on it.

Stapling changed the file I had just hashed

The most interesting failure was an integrity check doing exactly what I had asked it to do.

Electron Builder generated updater metadata containing file sizes and SHA-512 hashes. After that, the workflow notarized and stapled the DMG.

Stapling changed the DMG’s bytes.

The verifier then compared the finished DMG against metadata describing the pre-stapled file and rejected it.

The sequence was effectively:

Generate DMG
→ Generate metadata
→ Staple DMG
→ Compare changed DMG against old metadata
→ Fail

The fix in beta.10 was to distinguish the intended updater payload from the installation image.

For macOS, the verifier enforces metadata size and SHA-512 checks on ZIP updater entries. It still requires every referenced file to exist, and the DMG still goes through its notarization and Gatekeeper checks. A separate SHA-256 checksum file is generated from the final artifacts before publication.

That distinction is specific to this ZIP-based update path. If the DMG became an updater payload, its metadata would need to describe the final, stapled bytes.

The broader lesson was simple: know which step produces the final bytes before deciding when to hash them.

The Gatekeeper check also needed fixing

There was another macOS-specific detail waiting after that.

Assessing a DMG with spctl needed the primary-signature context:

spctl --assess \
  --type open \
  --context context:primary-signature \
  --verbose=4 \
  Space-Zero-0.1.0-beta.16-arm64.dmg

That became the beta.11 fix.

By then, the macOS path was green. But getting trusted artifacts out of CI still left another question: where should people download them?

GitHub Releases weren’t public enough

The initial publishing destination was GitHub Releases.

It was the obvious choice. The source was already on GitHub, the workflow ran in GitHub Actions, and Releases could hold the resulting files.

But the repository is private. Its release downloads are private too.

I needed public artifacts, not a public source repository.

A separate public repository containing only releases would have worked. Instead, I chose Cloudflare R2: object storage, an S3-compatible upload API, a custom download domain, and no direct egress bandwidth charges.

The public layout separates the current beta channel from versioned archives:

https://downloads.spacezero.dev/spacezero/macos/beta/
https://downloads.spacezero.dev/spacezero/macos/releases/<tag>/
 
https://downloads.spacezero.dev/spacezero/linux/beta/
https://downloads.spacezero.dev/spacezero/linux/releases/<tag>/

The beta paths give update clients stable locations to check. The versioned paths retain a particular release.

Each packaged app receives its platform-specific update base URL. Publishing metadata now establishes the distribution side of updates; it doesn’t mean the in-app update service is already implemented.

The cost model also suited an early desktop beta. These artifacts are measured in hundreds of megabytes per platform set. At R2’s current standard storage price of $0.015 per GB-month, a few gigabytes of retained builds are a pennies-scale storage cost before the free allowance.

Requests are billed separately. “No egress charges” doesn’t mean every operation is free, but it removes one concern when distributing large desktop downloads.

Moving hosts introduced its own failures

R2 authentication failed twice before I configured the correct Access Key ID and matching Secret Access Key.

Then the publication job exposed a different assumption: the downloaded GitHub artifacts weren’t laid out as the metadata merger expected.

Each macOS architecture uploaded its own artifact set. The publishing job needed both sets in one directory before merging their updater metadata.

I made that operation explicit:

  1. Download the per-architecture artifacts.
  2. Flatten their files into a staging directory.
  3. Merge the macOS metadata.
  4. Verify the combined artifact set again.
  5. Generate checksums and upload.

beta.13 added the flattening step. beta.14 fixed the verifier’s recognition of YAML list entries such as - url:.

That last bug was in my verification code, not Electron Builder. A release pipeline includes parsers and filesystem assumptions of its own, and those need testing too.

Linux was easier, but not free

Once macOS publication worked, I added Linux.

I kept the first slice deliberately narrow: Linux x64 AppImage only.

No .deb, .rpm, Snap, Flatpak, or Linux arm64 yet.

AppImage is a familiar distribution option for Electron desktop apps, and it let me start with one downloadable artifact rather than maintaining several packaging systems immediately. It still needs real-machine testing; one file doesn’t mean universal compatibility.

Linux didn’t need the Apple signing and notarization path. It did need its own:

  • Ubuntu packaging job.
  • Electron Builder target.
  • Update base URL.
  • Artifact and metadata verifier.
  • R2 publication prefix.

beta.15 introduced that path. beta.16 fixed two naming problems.

First, Electron Builder uses x86_64 in the AppImage filename even though the build architecture is selected as x64:

Space-Zero-0.1.0-beta.16-x86_64.AppImage

The staging commands and verifier had to use the actual output convention.

Second, the package-derived executable name wasn’t suitable for AppImage packaging. I made it explicit:

executableName: space-zero

The package name, product name, executable name, and artifact name serve different purposes. Letting one implicitly determine all the others had stopped being helpful.

What the final workflow looks like

By v0.1.0-beta.16, all six jobs completed successfully:

Validate, test, and build
├── Package macOS arm64 ─┐
├── Package macOS x64 ───┴── Publish R2 macOS release
└── Package Linux x64 AppImage ── Publish R2 Linux release

The macOS publish job waits for both architectures. Linux has its own publication path. Both upload jobs require the publication gate.

The workflow installs from the frozen lockfile, validates versions, runs the quality checks, and packages the artifacts.

Before public upload, it verifies the relevant artifact set again. That includes ZIP hashes and sizes on macOS, AppImage hashes and sizes on Linux, and the existence of referenced files. macOS trust checks happen earlier on the macOS runners.

The release machinery now lives in repository-owned scripts rather than a sequence of commands I have to remember on my laptop.

That’s the useful outcome: the next release starts with a version and a tag, not with reconstructing how the previous one worked.

What this unlocks next

This milestone is the distribution pipeline. It isn’t a claim that the entire packaged Space Zero runtime is finished.

At this point, the next work is to package private Node.js 22.23.1, include the Workspace Host resources, generate and verify an integrity manifest, and make the packaged desktop application launch its Local Host correctly. Until those resources are available, packaged Local Host startup remains fail-closed.

After that comes the in-app update service and UI using electron-updater. The stable hosting paths and metadata are groundwork for that feature.

Windows and additional Linux formats can follow when demand justifies the extra release surface.

What I have now is a repeatable route from a private repository to verified, publicly hosted desktop artifacts.

The work was mostly small fixes: an argument separator, a filename, a YAML entry, a credential pairing, a verification flag. Together, they were the difference between an app that built on my machine and a release process I could use again.