Skip to content
LibxaFrame
Sign In
Technical 9 August 2026 6 min read

Shipping a private app from a public release repository

A self-updating app fetches its manifest and installer from the user’s machine, unauthenticated. Point that at a private repository and the token ships inside every copy. The two-repository split, and the races that took two releases to find.

Libxa Desktop's source is private. Its installers are public. That is two repositories, and the split is not organisational tidiness: it is forced by how self-updating works.

Why not one repository

An app that updates itself has to fetch two things from a machine it does not control: a manifest saying what the newest version is, and the installer itself. Both requests come from the user's computer, unauthenticated.

Point the updater at a private repository and it needs a token. That token ships inside every copy of the application, which means it is available to anyone who unzips one. A read token for a private source repository, handed to everybody who installs your app, is the same as not having a private repository.

So:

Repository Visibility Contents
libxa-desktop private source, CI, the release workflow
libxa-desktop-releases public GitHub Releases only: no source

The public repository holds nothing but compiled artifacts. It leaks nothing the installer does not already contain, because it is the installer.

What a release contains

Three files, and all three matter:

  • libxa-desktop-<version>-x64.exe: the installer
  • …exe.blockmap: lets the updater download only the changed blocks of the next version instead of the whole 100 MB
  • latest.yml: the version and SHA-512 the updater reads

A release missing latest.yml is invisible to every installed copy. It fails silently: no error, no update, nobody notices for weeks.

Two things that bit us

The manifest and the installer must match. electron-builder writes latest.yml with the artifact URL normalised: spaces become hyphens, while the file on disk keeps the space from productName. The updater then requests a filename that does not exist and every update 404s. Naming artifacts from ${name} instead of ${productName} removes the discrepancy at the source rather than relying on both sides normalising identically.

Publishing directly is a race. A published release is visible the moment it exists, and electron-builder uploads assets one at a time. There is a window where latest.yml names an installer that is still uploading, and an updater that hits it downloads a truncated file.

Worse, its uploader runs one task per artifact and each task creates its own draft release when it does not find a matching one. That raced: two drafts per tag, the installer in one and the blockmap in the other. Two releases shipped missing their blockmap before we noticed.

The fix was to take electron-builder out of the publishing path entirely. It builds; the workflow creates one draft, uploads every artifact to it, checks they all arrived, and only then flips it live.

Crossing the boundary in CI

GITHUB_TOKEN is scoped to the repository running the workflow. Publishing from the private repository into the public one crosses that boundary, so it needs a fine-grained PAT with Contents: Read and write on the releases repository alone.

That is the one piece that cannot be automated: there is no API for creating a personal access token. The workflow checks for it up front and fails in forty seconds with the fix in the error message, rather than after a ten-minute Windows build:

::error::RELEASE_TOKEN is not set. Publishing to
libxa-framework/libxa-desktop-releases needs a fine-grained PAT with
Contents: Read and write on that repository.

A build that fails at the last step, ten minutes in, on a missing secret, with a stack trace from inside an uploader, is a bad afternoon. Failing early with the answer is worth the fifteen lines.

Verifying a release

Before publishing, a script checks that latest.yml describes the installer sitting next to it:

node scripts/verify-release.mjs

If the two came from different builds, every update fails checksum verification: on the user's machine, silently, long after anyone is reading the release log. Catching it costs a second.

Keep reading