Ship desktop app releases to your customers — powered by Self-Host Pro.
Upload your desktop app builds (Tauri, Electron, native) to Self-Host Pro straight from CI, then publish and promote a release channel — in one step of your pipeline.
The action is a thin, auditable wrapper (bash + curl + jq) over the Self-Host Pro CI release
API:
| Command | What it does | Use it for |
|---|---|---|
github-release |
Mirrors an on: release event — classifies assets, uploads, finalizes |
The one-step path. Start here. |
upload (default) |
Uploads one artifact (file + os + arch) |
one platform per matrix job |
finalize |
Publishes the release + promotes a channel | run once, after a matrix |
📁 Runnable, copy-paste versions of every workflow below live in
examples/(github-release.yml,matrix-build.yml,parallel-builds.yml).
Cut a release in the GitHub UI (or with gh release create) — attach your binaries, write notes,
mark it Latest or Pre-release. This workflow mirrors all of it to Self-Host Pro automatically:
name: Publish to Self-Host Pro
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: serversideup/github-action-selfhostpro-release@v1
with:
command: github-release
team: ${{ vars.SHP_TEAM }}
product: ${{ vars.SHP_PRODUCT }}
email: ${{ secrets.SHP_EMAIL }}
token: ${{ secrets.SHP_TOKEN }}That's the whole workflow. The action reads the release, infers os/arch from each asset's
filename (matching the dashboard's own detection), attaches any .sig signatures, uploads every
binary, and finalizes — carrying over your notes and mapping Latest → stable,
Pre-release → beta. See github-release mode for the details and the
asset_map escape hatch.
When you build inside the same pipeline, a matrix uploads one platform per job to a draft
release, then a single finalize job publishes it and promotes it to a channel:
name: Release
on:
push:
tags: ["v*"]
jobs:
build:
strategy:
matrix:
include:
- { runner: macos-14, os: macos, arch: arm64, ext: dmg }
- { runner: macos-13, os: macos, arch: x64, ext: dmg }
- { runner: windows-2022, os: windows, arch: x64, ext: msi }
- { runner: ubuntu-22.04, os: linux, arch: x64, ext: AppImage }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ... build your app, producing dist/MyApp.<ext> (and dist/MyApp.<ext>.sig for Tauri) ...
- uses: serversideup/github-action-selfhostpro-release@v1
with:
team: ${{ vars.SHP_TEAM }}
product: ${{ vars.SHP_PRODUCT }}
email: ${{ secrets.SHP_EMAIL }}
token: ${{ secrets.SHP_TOKEN }}
file: dist/MyApp.${{ matrix.ext }}
os: ${{ matrix.os }}
arch: ${{ matrix.arch }}
# format is auto-derived from the file extension; set it only to override
finalize:
needs: build
runs-on: ubuntu-22.04
steps:
- uses: serversideup/github-action-selfhostpro-release@v1
with:
command: finalize
team: ${{ vars.SHP_TEAM }}
product: ${{ vars.SHP_PRODUCT }}
email: ${{ secrets.SHP_EMAIL }}
token: ${{ secrets.SHP_TOKEN }}
publish: true
channel: stableBecause release creation is race-safe server-side, every matrix job converges on one draft release for the version — no duplicate rows, no ordering requirements.
A matrix isn't required. If your pipeline already has separate, independently-parallel build jobs
(different runners, different toolchains — a big fan-out CI), each one just adds an upload step for
the artifact it produces. They all target the same version, so they converge on a single release
no matter what order they finish in. A final release job, gated on all of them with needs:,
publishes once:
name: Release
on:
push:
tags: ["v*"] # all jobs derive the same version from the tag
jobs:
build-macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
# ... build → dist/MyApp-arm64.dmg (+ .sig) ...
- uses: serversideup/github-action-selfhostpro-release@v1
with: { team: "${{ vars.SHP_TEAM }}", product: "${{ vars.SHP_PRODUCT }}",
email: "${{ secrets.SHP_EMAIL }}", token: "${{ secrets.SHP_TOKEN }}",
file: dist/MyApp-arm64.dmg, os: macos, arch: arm64 }
build-windows:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
# ... build → dist/MyApp-x64.msi ...
- uses: serversideup/github-action-selfhostpro-release@v1
with: { team: "${{ vars.SHP_TEAM }}", product: "${{ vars.SHP_PRODUCT }}",
email: "${{ secrets.SHP_EMAIL }}", token: "${{ secrets.SHP_TOKEN }}",
file: dist/MyApp-x64.msi, os: windows, arch: x64 }
build-linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# ... build → dist/MyApp-x64.AppImage ...
- uses: serversideup/github-action-selfhostpro-release@v1
with: { team: "${{ vars.SHP_TEAM }}", product: "${{ vars.SHP_PRODUCT }}",
email: "${{ secrets.SHP_EMAIL }}", token: "${{ secrets.SHP_TOKEN }}",
file: dist/MyApp-x64.AppImage, os: linux, arch: x64 }
release:
needs: [build-macos, build-windows, build-linux] # runs only after every upload
runs-on: ubuntu-22.04
steps:
- uses: serversideup/github-action-selfhostpro-release@v1
with:
command: finalize
team: ${{ vars.SHP_TEAM }}
product: ${{ vars.SHP_PRODUCT }}
email: ${{ secrets.SHP_EMAIL }}
token: ${{ secrets.SHP_TOKEN }}
publish: true
channel: stableWhy this works — and what to watch for:
- No coordination between builds. Concurrent uploads to the same version converge on one draft
release (
UNIQUE(product_id, version)server-side). Order doesn't matter; there's no "create the release first" step. finalizewaits forneeds:. The release isn't published until every listed build job succeeds. If one platform fails, nothing is published.- Every job must agree on the version. Deriving it from the pushed tag (above) guarantees that.
If you're not triggering on a tag, set
version:explicitly on every job (e.g. from a shared job output) so they all hit the same release. - Re-running one failed build re-uploads only that platform's artifact (it replaces the
os+archslot); the others are untouched. Re-runreleaseafterward to publish. - Folding into an existing CI (like a big
on: pushpipeline): add theuploadstep to the build jobs you already have, and gate the publish so you don't cut a release on every commit — either trigger a separate workflowon: push: tags, or addif: startsWith(github.ref, 'refs/tags/')to thereleasejob.
Prefer the GitHub-UI release flow instead?
github-releasemode collapses all of this into a single step.
matrix jobs → upload one artifact each (command: upload) → POST …/releases/{version}/artifacts
final job → publish + promote once (command: finalize) → POST …/releases/{version}/finalize
- Versions are stored bare. A pushed
v2.1.0tag becomes version2.1.0(the leadingv/Vis stripped both by the action and server-side). - Re-uploading replaces. Pushing the same
os+archagain to a draft replaces that artifact. - Opinionated, safe flow. The
uploadcommand intentionally does not publish or promote, so a parallel matrix can't publish a release mid-build. Publishing/promoting goes throughfinalize, which runsneeds: build.
| Input | Required | Default | Notes |
|---|---|---|---|
command |
— | upload |
upload or finalize |
base_url |
— | https://app.selfhostpro.com |
Your instance |
team |
✅ | Team slug (the URL slug, not the display name) | |
product |
✅ | Product slug | |
version |
— | derived from tag | Falls back to the pushed git tag (${GITHUB_REF_NAME} with a leading v stripped) |
email |
✅ | Account email (HTTP Basic username) — use a secret | |
token |
✅ | Team access token (HTTP Basic password) — use a secret | |
file |
upload only |
Path to the binary | |
os |
upload only |
macos | windows | linux |
|
arch |
upload only |
arm64 | x64 | x86 | universal (universal is macOS-only) |
|
format |
— | auto | Auto-derived from the file extension server-side. Set only to override. |
signature |
— | Update signature string. If empty, the action reads <file>.sig when present. |
|
notes |
— | Release notes (applied on the first upload of a version, or via finalize) |
|
publish |
— | true |
Used by finalize |
channel |
— | Channel slug to promote to (e.g. stable) — used by finalize |
|
max_retries |
— | 3 |
Attempts on 429 / 5xx / transport errors |
dry_run |
— | false |
Print the composed request without sending |
github_token |
— | ${{ github.token }} |
Token to download release assets (github-release) |
asset_map |
— | Per-file filename=os/arch (or filename=skip) overrides (github-release) |
|
latest_channel |
— | stable |
Channel a Latest release is promoted to (github-release) |
prerelease_channel |
— | beta |
Channel a Pre-release is promoted to (github-release) |
osandarchare case-folded for you (macOS→macos) and validated before the request, so a typo fails fast with a clear message instead of a server422.
| Output | Description |
|---|---|
version |
Version acted upon (bare, e.g. 2.1.0) |
release_status |
draft | published |
artifact_id |
Uploaded artifact id (upload command) |
The action also appends a summary table (version, command, os/arch, status, size, checksum) to the job's Step Summary.
Uploads a single artifact to the version's draft release. Required: file, os, arch. The
release is created on first upload and reused by every later upload for that version.
- A
<file>.signext to the binary is attached automatically as the update signature (handy for Tauri). Override with thesignatureinput. formatis derived from the file extension (.dmg→dmg,.AppImage→appimage, …); set it only when your filename doesn't carry the right extension.
Publishes the release (requires at least one uploaded artifact) and, if channel is given,
promotes it. Run it once with needs: build after the matrix. publish defaults to true.
command: github-release turns a published GitHub Release into a Self-Host Pro release in one
step. It runs on on: release and:
- Reads the tag → version (
v3.2.0→3.2.0), the body → release notes, and the Latest/Pre-release flag → channel. - Classifies every attached asset into
os/archfrom its filename (see below). - Attaches a matching
<asset>.sig/<asset>.ascsignature when present. - Downloads and uploads each binary, then finalizes — publishing and promoting to the channel.
No API changes, no per-asset wiring. For private repos, the default github_token is enough to
download the assets.
| GitHub release | Self-Host Pro channel | Override input |
|---|---|---|
| Latest (not a pre-release) | stable |
latest_channel |
| Pre-release | beta |
prerelease_channel |
Set an override to "" to publish without promoting to a channel.
Inference mirrors the Self-Host Pro dashboard exactly, so an asset classified here lands the same as one uploaded through the UI.
OS comes from the file extension:
| Extension | OS |
|---|---|
.dmg, .pkg |
macos |
.exe, .msi |
windows |
.deb, .rpm, .AppImage |
linux |
| anything else | defaults to macos (with a warning — use asset_map) |
Arch comes from filename tokens (first match wins, case-insensitive):
| Token in filename | Arch |
|---|---|
aarch64, arm64, apple-silicon, -arm |
arm64 |
universal |
universal (macOS only) |
x86_64, x64, amd64, intel |
x64 |
i386, ia32, win32, x86, -386 |
x86 |
| none of the above | arm64 (macOS) · x64 (windows / linux) |
Arch is then clamped to what the OS allows (universal only on macOS; x86 not on macOS), so the
result is always a valid pair.
Sidecars are handled for you: .sig / .asc files are attached to their binary, and
checksums.txt, *.sha256, latest.json, and similar are skipped.
When a filename doesn't carry a recognizable platform — or you want to skip an asset — map it
explicitly. One entry per line, exact filename match, # for comments:
with:
command: github-release
# ...
asset_map: |
MyApp-portable.bin = linux/x64
MyApp.zip = macos/universal
sbom.spdx.json = skipBoth endpoints use HTTP Basic auth with the same credentials you use for docker login:
- username = your Self-Host Pro account email
- password = a team access token
Always pass these as secrets (email/token). The action masks the token in logs.
The action does not sign anything — it just attaches the signature you provide (the signature
input, or a <file>.sig it discovers on disk). Generate signatures with your toolchain (e.g. the
Tauri updater) and point the action at them. See the Self-Host Pro and Tauri docs for the signing
setup.
The CI upload streams through your Self-Host Pro instance (PHP). The server's
application-level ceiling defaults to 1 GB (configurable via RELEASES_MAX_UPLOAD_KB), but in
production it is also bounded by PHP upload_max_filesize / post_max_size and your reverse-proxy
body-size limits — raise all three together if you ship large binaries. Typical Tauri builds fit
comfortably.
This action publishes a moving v1 major tag plus immutable vX.Y.Z tags.
- Convenience:
serversideup/github-action-selfhostpro-release@v1 - Maximum supply-chain safety (recommended for production): pin to a commit SHA
serversideup/github-action-selfhostpro-release@<sha>
- Self-Host Pro: https://selfhostpro.com
- Sibling action (Docker Swarm deploy): https://github.com/serversideup/github-action-docker-swarm-deploy
- Changelog: CHANGELOG.md
- Security policy: SECURITY.md
MIT © Server Side Up