This document describes how ADT Studio is versioned and released, the branching
model behind it, and exactly what the release pipeline
(.github/workflows/release.yml) does.
TL;DR
- Feature branches merge into
develop→ produces beta releases (vX.Y.Z-beta.N), published often for QA.- Once a beta is validated,
developmerges intomain→ produces a stable release (vX.Y.Z).- You never push a tag by hand. You trigger a release with a
RELEASE:commit (or the manual button); the pipeline creates the tag, builds everything, and publishes the GitHub release.
feature/* ──merge──▶ develop ──merge──▶ main
│ │
▼ ▼
beta release stable release
vX.Y.Z-beta.N vX.Y.Z
(frequent, for QA) (validated, public)
| Branch | Release track | Tag format | Audience | Cadence |
|---|---|---|---|---|
develop |
beta (prerelease) | vX.Y.Z-beta.N |
QA / internal testers | Frequent |
main |
stable | vX.Y.Z |
End users | On approval |
The contract is enforced by the pipeline (see Branch contract):
- A release on
developmust be avX.Y.Z-beta.Ntag — a stable tag is rejected. - A release on
mainmust be avX.Y.Ztag — a prerelease tag is rejected.
This guarantees a beta never leaks to the stable channel and vice versa.
- Developers open PRs from
feature/*branches intodevelop. - When enough has landed, a maintainer cuts a beta from
develop(see Triggering a release). QA installs/updates and tests it. - More betas (
-beta.2,-beta.3, …) are cut as fixes land — this is meant to be frequent and cheap. - Once a beta line is approved,
developis merged intomainand a stable release is cut frommain.
There are two ways to start a release. Both run the exact same pipeline.
Push a commit to develop or main whose subject line starts with RELEASE:
followed by the tag:
# On develop — beta
git commit --allow-empty -m "RELEASE: v0.8.0-beta.1"
git push origin develop
# On main — stable
git commit --allow-empty -m "RELEASE: v0.8.0"
git push origin mainNotes:
- The
RELEASE:prefix is matched case-insensitively by the tag extractor. - The
vprefix is optional in the message — it is added automatically. - Only the head commit of the push is inspected.
From the GitHub UI: Actions → Release → Run workflow, pick the branch, and
enter the tag (e.g. v0.8.0-beta.1 on develop, v0.8.0 on main).
The workflow runs four jobs. Builds happen first; the tag and the public release are created only after every build succeeds, so a failed build never leaves a dangling tag or a half-published release.
┌─────────────┐
│ prepare │ validate tag, classify beta/stable,
│ │ bump version, stage metadata
└──────┬──────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ desktop │ │ docker │ (run in parallel)
│ (3 OS matrix)│ │ build & push │
└──────┬──────┘ └──────┬──────┘
└──────────┬──────────┘
▼
┌─────────────┐
│ finalize │ commit metadata, push tag,
│ │ create GitHub release
└─────────────┘
Gated by if: runs only on a manual dispatch or when the head commit subject
starts with RELEASE:. It then:
- Extracts the tag from the commit subject or the dispatch input, prepends
vif missing, and validates its character set (also blocks injection). - Rejects a tag that already exists on the remote — bump the version instead.
- Classifies the tag:
vX.Y.Z-beta[.N]→prerelease = truevX.Y.Z→prerelease = false- anything else → error (the only accepted formats are stable and beta).
- Enforces the branch contract:
developonly accepts beta tags.mainonly accepts stable tags.
- Computes the Docker tag list: always
ghcr.io/unicef/adt-studio:<tag>; stable releases additionally move:latest. - Bumps
apps/desktop/package.jsonto the release version (used by electron-builder for installer naming and the auto-update channel). - Updates the issue-template version dropdowns and stages the changed files
as a
release-metadataartifact (committed later byfinalize).
Matrix build across macOS, Windows, and Linux. For each OS it builds the workspace, builds the Electron app, packages installers, and signs them:
| OS | Output | Signing |
|---|---|---|
| macOS | .dmg, .zip |
Apple Developer ID + notarization |
| Windows | .exe (NSIS) |
Azure Trusted Signing via jsign |
| Linux | .AppImage, .deb |
— |
Installers and the auto-update channel manifests (latest*.yml for stable,
beta*.yml for beta) are uploaded as per-OS artifacts.
Builds the combined single-image (app target) and pushes it to GHCR with the
tags computed in prepare:
- Stable →
ghcr.io/unicef/adt-studio:vX.Y.Zand:latest - Beta →
ghcr.io/unicef/adt-studio:vX.Y.Z-beta.Nonly (never:latest)
A beta image therefore never overwrites the :latest tag that production
docker run / docker compose users pull.
Runs only after desktop and docker both succeed. It:
- Restores the staged metadata and downloads all installer artifacts.
- Commits the metadata bump (
chore(release): <tag>) and pushes it to the release branch. (This push usesGITHUB_TOKEN, which by design does not re-trigger the workflow — no release loop.) - Creates and pushes the git tag.
- Generates a standalone
docker-compose.ymlfrom the release template. - Creates the GitHub release with auto-generated notes, attaching the
installers, the
docker-compose.yml, and the Windows launcher script. Beta releases are marked as pre-release (--prerelease), so they never become GitHub's "Latest release".
Beta and stable are distinct products and can be installed at the same time
without overwriting each other. The identity is keyed off the -beta in the
version (see apps/desktop/electron-builder.js):
| Stable | Beta | |
|---|---|---|
appId |
com.nees.adt-studio |
com.nees.adt-studio.beta |
productName |
ADT-Studio |
ADT-Studio-Beta |
| Install dir / shortcut / uninstall entry | separate | separate |
userData (books, config, cache) |
separate | separate |
Because the install location, Start Menu shortcut, Windows uninstall key, and
the Electron userData directory all derive from appId / productName, the
two builds never collide. The main process sets a matching AppUserModelId per
channel (src/main/index.ts) so taskbar
grouping and notifications stay separate too.
The desktop app follows the release channel that matches the build it is running, and each track is closed — a build never crosses over to the other channel:
| Installed build | Update channel | Sees |
|---|---|---|
Stable (vX.Y.Z) |
latest |
Stable releases only |
Beta (vX.Y.Z-beta.N) |
beta |
Beta releases only |
This is configured in
apps/desktop/src/main/services/auto-updater.ts:
the updater enables prereleases and selects the beta channel only when the
running version is itself a beta; stable installs use the latest channel and
ignore prereleases.
Beta stays beta. A beta install must never auto-update onto a stable build — doing so would silently graduate a beta into a stable install (and, with separate identities, would orphan its data). Two safeguards enforce this:
- Manifest level —
generateUpdatesFilesForAllChannelsis off, so a stable release only writeslatest.ymland never overwritesbeta.yml. Beta installs readingbeta.ymltherefore only ever see newer betas.- Runtime level — the updater rejects any non-beta version offered to a beta build, as defense-in-depth.
To move a tester from beta to stable, install the stable build separately (they coexist) and uninstall the beta if desired.
The Docker distribution has no equivalent channel crossover: users pin an
explicit image tag, and only :latest moves with stable releases.
Each release produces:
- Desktop installers — Windows
.exe, macOS.dmg/.zip, Linux.AppImage/.deb, plus the*.ymlauto-update manifests. - Docker image — pushed to
ghcr.io/unicef/adt-studio(see tag rules above). - GitHub release — installers + a ready-to-use
docker-compose.yml+windows-setup-and-run.bat, with auto-generated changelog notes. Marked pre-release for betas.
# Cut a beta from develop (QA build)
git switch develop
git commit --allow-empty -m "RELEASE: v0.8.0-beta.1"
git push origin develop
# Promote to stable: merge develop → main, then cut a stable release
git switch main
git merge --no-ff develop
git commit --allow-empty -m "RELEASE: v0.8.0"
git push origin main| Rule | Stable (main) |
Beta (develop) |
|---|---|---|
| Tag format | vX.Y.Z |
vX.Y.Z-beta.N |
| GitHub release | normal | pre-release |
Docker :latest |
moved | untouched |
| Desktop channel | latest |
beta |
| Frequency | on approval | frequent |