Releasing
A release is a tag. CI (.github/workflows/android.yml) builds every v* tag, signs the APKs and publishes them as a GitHub release; F-Droid rebuilds release tags and ships our APK when its own build matches byte for byte (DISTRIBUTION.md). Nothing is built on a push to main, and nobody but the author pushes.
Two channels
Section titled “Two channels”One app, one package, one signing key. The tag says which channel a build belongs to.
| Stable | Beta | |
|---|---|---|
| Tag | v5.1.0 |
v5.1.0-beta.1 … v5.1.0-beta.9 |
VERSION_NAME |
5.1.0 |
5.1.0-beta.1 |
| GitHub release | the latest release | a pre-release, never the latest; the title ends in “(beta)” |
| Release notes | the ## [5.1.0] section of CHANGELOG.md |
a fixed header (scripts/release/beta-notes.md), then ## [Unreleased] as it stands at the tag |
| In-app updater | offered to everyone | offered only with Receive beta versions on |
| F-Droid | rebuilt and published | never seen: its update check matches release tags only |
| Obtainium | followed by default | only with Include prereleases on |
A beta tag builds exactly what a release tag builds — the same jobs, the same pinned tools, the same APK names (TailSocks-v5.1.0-beta.1-arm64-v8a-release.apk). Only the publishing step differs.
What the updater offers (Settings → Appearance & language → Receive beta versions, off by default):
| Installed | Switch | Offered |
|---|---|---|
| stable | off | the next stable release |
| stable | on | the newest of stable and beta, by version |
| beta | off | nothing, until a stable release newer than that beta is out — then that release |
| beta | on | the next beta, or the final release |
Versions are ordered 5.1.0-beta.2 < 5.1.0-beta.10 < 5.1.0 < 5.1.1-beta.1 (core/AppVersion.kt); a pre-release older than what is installed is never offered. A copy a store installed has no updater at all (UpdateChannel), so the switch is not shown there.
On a beta build the About dialog calls the app “TailSocks beta”, the diagnostics report adds “beta” to its version line, and “What’s new” shows the changelog’s [Unreleased] section under the build’s name — again after each beta, in English or Russian.
Beta and experimental are different things
Section titled “Beta and experimental are different things”- A beta is a build: the next version before it is finished. Anything in it may break; only those who asked for betas get it.
- An experimental feature ships in a stable build, off by default or clearly marked, and its behaviour may still change. Its changelog line starts with “Experimental:” (remote management in 5.0.0 is one).
Neither replaces the other: a feature that is not ready for everyone stays experimental in the final release too, and a beta is not where unmarked half-finished features go.
Version codes
Section titled “Version codes”version.properties holds both lines, and the code follows the name:
- a release:
major×1000000 + minor×10000 + patch×100; - beta N of it, N from 1 to 9: that code
− 100 + 10×N.
Each APK then adds its ABI’s offset: 0 for the universal one, 1–4 for armeabi-v7a, arm64-v8a, x86, x86_64.
| Version | VERSION_CODE |
APKs |
|---|---|---|
| 5.0.0 | 5000000 | 5000000–5000004 |
| 5.0.1-beta.1 | 5000010 | 5000010–5000014 |
| 5.0.1 | 5000100 | 5000100–5000104 |
| 5.1.0-beta.1 | 5009910 | 5009910–5009914 |
| 5.1.0-beta.2 | 5009920 | 5009920–5009924 |
| 5.1.0 | 5010000 | 5010000–5010004 |
| 6.0.0-beta.1 | 5999910 | 5999910–5999914 |
| 6.0.0 | 6000000 | 6000000–6000004 |
Why it orders: a version and its betas use the codes from code − 90 to code + 4, and two versions are at least 100 apart. So every beta is an update over every earlier release and beta, a release is an update over its own betas, and no code published so far changes.
The limits:
- Nine betas per version. The tenth would have the release’s own code. If nine are not enough, the next one is beta 1 of the next patch — and that patch is then the version that gets released.
- Minor and patch up to 99, as before.
- A beta’s code looks like the version below. The betas of
5.1.0sit at the end of5.0.99’s hundred and those of6.0.0just under six million. Nothing collides — a release uses only the first five codes of its hundred, a5.0.99included — and nobody reads the code; it only has to order.
The rule is written once, in buildSrc/src/main/java/io/github/bropines/tailscaled/release/ReleaseVersion.java. Gradle refuses to configure a build whose code does not follow its name; CI runs the same file against the tag before it builds; ReleaseVersionTest holds it to the ordering above. To check a tag by hand, from the repository’s root:
java buildSrc/src/main/java/io/github/bropines/tailscaled/release/ReleaseVersion.java v5.1.0-beta.1Cutting a beta
Section titled “Cutting a beta”- Leave the changelog alone: the work stays under
## [Unreleased]inCHANGELOG.mdandCHANGELOG_RU.md. A beta gets no section of its own. - In
version.propertiessetVERSION_NAME=5.1.0-beta.1andVERSION_CODE=5009910. - Check: the command above prints
kind=beta, then./gradlew :app:testDebugUnitTest :app:assembleDebug. - Commit (
chore: release 5.1.0-beta.1), tagv5.1.0-beta.1, push the commit and the tag. - CI publishes a pre-release. On the releases page it must carry the Pre-release badge, and Latest must still be the last stable release — every installed copy without the switch asks GitHub for exactly that one.
The next beta is the same with 5.1.0-beta.2 and 5009920. [Unreleased] keeps growing, and each beta’s notes are the whole section, not the difference from the beta before.
The final release
Section titled “The final release”- Rename
## [Unreleased]to## [5.1.0] - YYYY-MM-DDin both changelogs. - Set
VERSION_NAME=5.1.0andVERSION_CODE=5010000. - Commit (
chore: release 5.1.0), tagv5.1.0, push. - CI publishes it as the latest release, with that section as its notes. Beta users are offered it like everyone else.
- Move the F-Droid recipe to the new version (below).
What must not change for F-Droid
Section titled “What must not change for F-Droid”F-Droid publishes our APK only while its rebuild of a release tag is identical to ours, and finds new versions by itself. That rests on:
- Release tags are
v<major>.<minor>.<patch>and nothing else. The recipe’sUpdateCheckMode: Tags ^v\d+\.\d+\.\d+$is what keeps betas out; with the$gone, a beta tag would be read as a release. version.propertieskeeps its two lines,VERSION_NAME=andVERSION_CODE=, read from the tagged commit, and a release’s code staysmajor×1000000 + minor×10000 + patch×100with the ABI offsets 1–4 (VercodeOperation). F-Droid takes the first match of each in the file, so no comment above them may spell an assignment out.- A release’s APKs are named
TailSocks-v<version>-<abi>-release.apkin a release taggedv<version>— the recipe’sbinary:address. - The build path of a release tag: the Go core built in
/home/vagrant/build/io.github.bropines.tailscaledwith the pinned Go, gomobile and NDK, the C libraries by Gradle, a clean build, one APK for every channel. A beta goes down the same path so that it tests the build the release will get; nothing may be switched on the tag’s kind before the publishing step. buildSrcstays plain Java with no dependencies — it is compiled before every build, F-Droid’s included, and must not need a repository.
The recipe itself lives in the fdroiddata fork; distribution/io.github.bropines.tailscaled.yml is its draft. A release changes versionName, versionCode and commit in each of the four entries and CurrentVersion / CurrentVersionCode; a beta changes nothing there.