· Oladokun Oladapo · Building
Shipping a new version should be one command
Releasing a new version of a desktop app is a lot of small steps. I wanted it to be one command instead. Here's how the whole thing fits together.

Releasing a new version of a desktop app is more work than it sounds. To ship one update you have to pick a new version number, build the app for macOS, Windows and Linux, sign each build so the computer trusts it, get Apple to check the Mac build, wrap each one in an installer, put those installers somewhere people can download them, point the website's download button at the new files, write up what changed, publish that, and let everyone already using the app know a newer version exists.
That's a lot of steps, and doing them by hand has two problems.
The first is that it's easy to get one wrong. Miss the signing step and the app won't open. Forget to update the download link and new people get an old version. Leave out the file check and you can't safely update anyone automatically. None of these are hard on their own. There are just a lot of them, and you only have to slip once.
The second is simpler: if shipping an update is a chore, it happens less. When a release takes an hour of careful clicking, you release less often, which is the opposite of what you want.
What I wanted was for releasing to be as easy as pressing a button. One command, and everything after it happens on its own. That's what this is.
The one command. It all starts with a single script:
./scripts/cut-release.sh patch # or minor, major, or an exact version like 1.2.0That's the button. It works out the next version number, records it, and creates a git tag (a labelled point in the project's history) for the release, then pushes that tag up to GitHub. The tag is the trigger. Once it's pushed, nothing else happens on my computer:
sed -i.bak -E "s/^APP_VERSION=.*/APP_VERSION=$NEW/" gradle.properties && rm -f gradle.properties.bak
git commit -m "Release $TAG"
git tag -a "$TAG" -m "Release $TAG"
git push origin main
git push origin "$TAG"From here on, a service takes over.
What the tag sets off. Pushing that tag wakes up GitHub Actions, which is a way to run scripts automatically when something happens in your repository. Here it's watching for a release tag:
on:
push:
tags: ['v*']
workflow_dispatch:When it sees one, it builds the app for each operating system at the same time, then a final step gathers everything and does the actual release: build for Mac, Windows and Linux, sign each one, put them online, tell existing apps an update is ready, and update the changelog and website.
Building the installers. For each system, it packages the app into a normal double-click installer using a tool called jpackage:
./gradlew :composeApp:packageReleaseDmg -PAPP_VERSION="$VERSION" # macOS DMG
# packageReleaseMsi on Windows, packageReleaseDeb on LinuxSigning (proving the app really came from me, so the operating system trusts it) also happens here. That part is fiddly enough that it got its own post (the macOS signing post), so here I'll just say the installers come out already signed, and on Mac already checked by Apple.
Where the files go. Each finished installer goes to two places, and the reason for two is the interesting part.
One copy goes to GitHub Releases, a permanent archived version with notes. The other goes to a file host (Cloudflare R2, behind dl.leovidly.com) that serves the public downloads. That second copy is uploaded twice:
# 1. Immutable, versioned path, never overwritten. This is what the updater downloads.
aws s3 cp "$DMG" "s3://$BUCKET/v$VERSION/Leovidly-$VERSION.dmg" --endpoint-url "$ENDPOINT"
# 2. Moving channel pointer, always the newest. This is what the website button links to.
aws s3 cp "$DMG" "s3://$BUCKET/$CHANNEL/Leovidly.dmg" --endpoint-url "$ENDPOINT"One upload goes to a fixed, version-numbered address that never changes, so a file someone is downloading today stays exactly where it was. The other goes to a latest address that always points at the newest build, so the download button on the site never has to be changed by hand. It ends up looking like this:
dl.leovidly.com/v1.2.3/Leovidly-1.2.3.dmg (fixed, what the updater downloads)
dl.leovidly.com/latest/Leovidly.dmg (always-newest, the download button)
dl.leovidly.com/latest/app-update.json (the update file, below)Telling the app there's an update. For the app to update itself, it needs to know the newest version and where to find it. That's a small file, app-update.json, written next to the downloads on every release:
{
"assets": {
"macos-arm64": {
"version": "1.2.3",
"url": "https://dl.leovidly.com/v1.2.3/Leovidly-1.2.3.dmg",
"sha256": "e3b0c44298fc1c149afbf4c8996fb924..."
},
"windows-x64": {
"version": "1.2.0",
"url": "https://dl.leovidly.com/v1.2.0/Leovidly-1.2.0.msi",
"sha256": "9f86d081884c7d659a2feaa0c55ad015..."
}
}
}One entry per system, each with the version, the download link, and a short fingerprint of the file (a checksum, so the app can tell it downloaded the right thing). Notice Mac is on 1.2.3 and Windows on 1.2.0 here. Each system has its own version, so a Mac-only release updates Mac and leaves Windows alone.
The app updating itself. The app checks that file about once a day. It looks up its own system, compares versions, and if there's a newer one, offers it:
val assetKey = UpdatePlatform.currentAssetKey() // e.g. "macos-arm64"
val asset = feed.assets[assetKey] ?: return UpToDate
if (!SemVer.isNewerThan(asset.version, currentVersion)) return UpToDate
// otherwise, tell the user an update is readyIf you say yes, it downloads the installer and checks that fingerprint as it goes, and refuses to run anything that doesn't match:
val actualSha = digest.digest().toHex()
if (expectedSha.isNotBlank() && actualSha != expectedSha) {
Files.deleteIfExists(target)
throw Exception("Installer checksum mismatch")
}That check is why the fixed, version-numbered address matters: the app knows exactly which file it expects. Then it opens the installer for you to run. It isn't fully automatic, but on your side it's a prompt and a couple of clicks.
The changelog and website, too. The last thing a release does is update the words around it. It adds the new version to a changelog file, uploads that, and nudges the website to rebuild so the change shows up:
aws s3 cp changelog.json "s3://$BUCKET/changelog.json" --endpoint-url "$ENDPOINT"
curl -fsS -X POST "$NETLIFY_BUILD_HOOK" # rebuild leovidly.comSo the changelog page and the “what's new” inside the app both update from the same release. Nothing gets written twice.
So that's the whole thing. One command, and a few minutes later there's a new version built for every system, signed, online, and quietly offering itself to everyone already running the app, with the changelog and site caught up too. That was the goal: make releasing a new version about as hard as pressing a button.


