Release management

How to release a desktop app with GitHub Actions

By the Omnihorn team · · 7 min read

To release a desktop app with GitHub Actions, build it on a Windows runner, collect the output folder, take the version from a git tag, and upload the folder to your update service with a key stored as a repository secret. Map branches to channels so main reaches production while feature branches go to private test channels.

Why release a desktop app from CI?

A release built on your laptop depends on your laptop: its SDK versions, its stray environment variables, whatever is in its temp folder. A release built in CI comes from a clean machine, from a known commit, with a log anyone on the team can read. If something goes wrong, you can tell exactly which code shipped and rebuild it.

CI also removes the manual steps people forget under pressure: bumping the version, copying the right folder, uploading to the right place. Once the workflow exists, git push or git tag is the release.

Which runner should build a Windows desktop app?

Build Windows apps on a GitHub-hosted Windows runner, windows-latest, unless your toolchain needs something specific. It comes with common build tools preinstalled. windows-latest moves to a newer Windows image from time to time, so if a toolchain update breaks your build, pin a versioned image such as windows-2022.

  • Pin your toolchain. Install exact versions of your SDK in the workflow (the setup actions for Node, .NET, Flutter and others take a version) instead of relying on whatever the image ships.
  • Commit lock files and install from them, so a dependency update cannot slip into a release unnoticed.
  • Cache dependencies to keep builds fast, but never cache the build output itself between releases.

What should the build job produce?

Produce a folder that runs on a clean PC: the executable plus every DLL and data file it loads. Most desktop frameworks write such a folder already, for example dist/win-unpacked for electron-builder or the publish folder of dotnet publish. That folder is the unit you version, upload and update.

Check it once on a machine that has never seen your development tools. A missing runtime DLL is the most common reason a CI build installs and then fails to start. Leave out debug symbols you do not need, test data and anything secret. If you sign your own executables with a code-signing certificate, do it in this job, before upload, so the signed files are what users get.

How do you set the version from a git tag?

Trigger the release on tags that look like v1.4.0, strip the v, and pass the rest to your build and upload steps. The tag becomes the single source of truth for the version, it is visible in the repository history, and a release can always be traced back to one commit.

Read the version from the tag
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - id: version
        shell: bash
        run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
      - name: Build
        run: echo "Build version ${{ steps.version.outputs.value }} into ./dist"

For builds from branches, where there is no tag, add a build counter to the last release version rather than inventing a new one. Users then see 1.4.0 plus a build number, and you never publish two different builds under the same version.

How should branches map to release channels?

Map long-lived branches to long-lived channels and short-lived branches to short-lived private channels. main feeds production (or a beta channel first, if you soak releases), release branches feed their own channel, and each feature branch gets a private channel that testers can follow and that disappears when the branch is done.

Git refChannelWho installs from it
mainproductionEvery user
release/*A release or beta channelTesters and early adopters
feature/*One private channel per branchThe few people testing that change
Tag v*Whichever channel you nameDepends on the channel

Whatever you choose, keep a human gate somewhere before production. That can be a manual approval on a GitHub environment, a soak on a beta channel, or simply a person assigning the tested build to production.

How do you keep release secrets safe?

Store the upload key as a repository secret, pass it only to the step that needs it, and never print it. GitHub does not pass repository secrets to workflows triggered by pull requests from forks, and Dependabot runs get only Dependabot secrets, so a release step must skip cleanly when the key is empty instead of failing.

  • Always filter `on: push` by branch. Without a branches: filter, every push from every branch and bot can start a release.
  • Do not release from `pull_request_target` with code from the pull request. That event runs with your secrets, so checking out a stranger’s code there hands them your key.
  • Give the job minimal permissions, for example permissions: contents: read if it does not need to write to the repository.
  • Rotate by overlap. Create a new key, update the secret, then revoke the old key.
  • Pin third-party actions to a version tag or a commit you have reviewed.

Set up the release workflow step by step

  1. Create an upload key

    In your update service, create a key that can publish builds for this one app, and copy it once.

  2. Store it as a secret

    In GitHub, open Settings, Secrets and variables, Actions, add a repository secret named OMNIHORN_API_KEY and paste the key.

  3. Add the workflow file

    Create .github/workflows/release.yml from the template below, with a branches: filter that lists only the branches you release from.

  4. Replace the build step

    Put your real build command in place of the placeholder and make sure the output ends up in ./dist.

  5. Push and read the summary

    Push to a matching branch, open the run and confirm the version, the branch and which channel received the build.

  6. Install from the channel

    On a test PC, install from the channel the build went to and confirm the new version arrives.

Uploading to Omnihorn from GitHub Actions

With Omnihorn, each app has its own upload keys, created on the app’s CI/CD tab. A key can publish builds to that one app and cannot read other apps or billing. The upload step sends the folder; files that were already uploaded before are not sent again. Omnihorn then scans and verifies the build and signs its manifest.

.github/workflows/release.yml
name: "Release my app"

on:
  push:
    branches: [main, 'release/**']

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      # Build your app here so that its files end up in ./dist
      - name: Build
        run: echo "Replace this step with your build command"

      - uses: Ivorn-Technologies/upload-build@v1
        with:
          api-key: ${{ secrets.OMNIHORN_API_KEY }}
          path: ./dist
          # Optional: base version from a tag, and an existing channel to assign
          # version: ${{ steps.version.outputs.value }}
          # channel: production
  • Versions. Pass version with a base version such as 1.4.0; Omnihorn appends +build.N. Without it, the latest build’s base version is reused.
  • Branches to channels. A channel can follow a branch, either an exact name like main or a pattern like release/*. A push from a branch that no channel follows creates a private channel for it, unless you set create-branch-channel: false.
  • Release notes. The release-notes input becomes the "What’s new" text the installer shows before an update.
  • Forks. When the key is empty, as in a pull request from a fork, the step skips the upload with a notice and succeeds.

Once a build is uploaded, installed copies on the matching channel pick it up on their next update check. The full input list, GitLab instructions and troubleshooting are in the CI/CD guide; dashboard uploads are covered in Upload builds.

›Can GitHub Actions build a Windows desktop app?

Yes. Use a Windows runner such as windows-latest, install your toolchain at a pinned version, and run the same build command you use locally. The job produces the folder you ship.

›Should I release on every push to main or only on tags?

Both work. Pushing every main build to a test or beta channel and using tags for production releases is a common split: testers get everything, users get versions you chose.

›Why does my release workflow fail on Dependabot pull requests?

Dependabot runs do not receive your repository secrets, so the upload key is empty. Filter on: push by branch and make the upload step skip when the key is missing.

›Is it safe to run release workflows on pull requests from forks?

Only if they cannot reach your secrets. Normal pull_request runs from forks get no secrets. Do not check out untrusted pull request code in a pull_request_target workflow that has your keys.