feat(release): unify cross-platform versioning

This commit is contained in:
2026-07-28 08:22:58 +02:00
parent fc1d27bf45
commit 407a0d2d60
35 changed files with 500 additions and 220 deletions

View File

@@ -27,9 +27,8 @@ artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag
whose commit is on `master` creates the matching GitHub Release with the `.deb`,
`.rpm`, and a combined `SHA256SUMS`.
The existing `v1.0.0` tag predates this workflow and will not run it
retroactively. Use the next version tag after this configuration reaches
`master`.
The legacy `v1.0.0` tag predates canonical versioning and does not define the
current product version. New release tags must match `version.properties`.
## Install a downloaded package
@@ -61,11 +60,12 @@ Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and
`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
From the repository root on the matching Linux family, run one of:
Set the release in `version.properties`. From the repository root on the
matching Linux family, run one of:
```bash
make package-deb VERSION=1.0.0
make package-rpm VERSION=1.0.0
make package-deb
make package-rpm
```
The Make targets collect the Compose output under `build/release/linux/`, then

View File

@@ -2,38 +2,14 @@
set -euo pipefail
version=${1:-1.0.0}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolver="$script_dir/../version/resolve-version.sh"
version="$("$resolver" product)"
"$resolver" verify >/dev/null
if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
exit 1
fi
version=${GITHUB_REF_NAME#v}
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must use MAJOR.MINOR.PATCH" >&2
if [[ -n ${1:-} && $1 != "$version" ]]; then
printf 'Version overrides are not supported; version.properties declares %s\n' "$version" >&2
exit 1
fi
IFS=. read -r major minor patch <<< "$version"
parts=("$major" "$minor" "$patch")
for index in "${!parts[@]}"; do
part=${parts[$index]}
if [[ $part != "0" && $part == 0* ]]; then
echo "Version components must be canonical integers without leading zeroes" >&2
exit 1
fi
if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then
echo "Version components must be between 0 and 65535" >&2
exit 1
fi
if (( index == 0 && 10#$part == 0 )); then
echo "The major version must be non-zero" >&2
exit 1
fi
done
printf '%s\n' "$version"

View File

@@ -0,0 +1,44 @@
# Application versioning
`version.properties` at the repository root is the single source of truth for
the VniDrop application version. Platform projects and release workflows read
that file rather than accepting independent version overrides.
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
PowerShell, Gradle, Rust, and Xcode.
The product uses numeric semantic versions. While the app is in beta, feature
releases increment the minor component (`0.2.0`, `0.3.0`) and fixes increment
the patch component (`0.2.1`). Release channels belong in
`RELEASE_CHANNEL`; they are not appended to store version fields.
| Platform | Product version | Platform build/package version |
| --- | --- | --- |
| Android | `PRODUCT_VERSION` | `ANDROID_VERSION_CODE` |
| Apple | `PRODUCT_VERSION` | `APPLE_BUILD_NUMBER` |
| Linux and direct macOS | `PRODUCT_VERSION` | Native package revision |
| Rust handshake | `PRODUCT_VERSION` | Rust crate version remains independent |
| Microsoft Store | `PRODUCT_VERSION` in the app | Derived MSIX dot-quad |
MSIX requires a non-zero first component and reserves the fourth component for
the Store. Its version is:
```text
(product major + WINDOWS_VERSION_EPOCH).product minor.product patch.0
```
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0`
maps to `2.0.0.0`. Do not change the epoch after publishing.
Every Android or Apple upload must increment its platform build number. Every
changed Windows Store package must increment the product version because the
Store-reserved fourth component cannot carry a rebuild number.
Before releasing:
```bash
make check-version
```
Release tags must exactly match `vPRODUCT_VERSION`. Manual workflow dispatches
also build the committed version and do not accept free-form version inputs.

View File

@@ -0,0 +1,97 @@
[CmdletBinding()]
param(
[ValidateSet("Product", "Channel", "AndroidCode", "AppleBuild", "WindowsPackage", "Json", "Verify")]
[string] $Field = "Verify",
[switch] $VerifyTag,
[string] $VersionFile
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($VersionFile)) {
$VersionFile = Join-Path $PSScriptRoot "..\..\version.properties"
}
$VersionFile = (Resolve-Path -LiteralPath $VersionFile).Path
function Read-VersionProperty {
param([string] $Name)
$prefix = "$Name="
$matches = @(Get-Content -LiteralPath $VersionFile | Where-Object { $_.StartsWith($prefix) })
if ($matches.Count -ne 1) {
throw "Expected exactly one $Name entry in $VersionFile"
}
return $matches[0].Substring($prefix.Length)
}
function Convert-CanonicalInteger {
param(
[string] $Name,
[string] $Value,
[long] $Minimum,
[long] $Maximum
)
if ($Value -notmatch "^(0|[1-9][0-9]*)$") {
throw "$Name must be a canonical non-negative integer"
}
$number = 0L
if (-not [long]::TryParse($Value, [ref] $number) -or $number -lt $Minimum -or $number -gt $Maximum) {
throw "$Name must be between $Minimum and $Maximum"
}
return $number
}
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
$androidVersionCodeText = Read-VersionProperty "ANDROID_VERSION_CODE"
$appleBuildNumber = Read-VersionProperty "APPLE_BUILD_NUMBER"
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
if ($productVersion -notmatch "^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") {
throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
}
$productParts = $productVersion.Split(".")
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 65534
$null = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 65535
$null = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 65535
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
throw "RELEASE_CHANNEL contains unsupported characters"
}
$androidVersionCode = Convert-CanonicalInteger "ANDROID_VERSION_CODE" $androidVersionCodeText 1 2100000000
if ($appleBuildNumber -notmatch "^[1-9][0-9]*(\.[0-9]+){0,2}$") {
throw "APPLE_BUILD_NUMBER must contain one to three period-separated non-negative integers and start above zero"
}
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
$windowsMajor = $productMajor + $windowsVersionEpoch
if ($windowsMajor -gt 65535) {
throw "Derived Windows package major exceeds 65535"
}
$windowsPackageVersion = "$windowsMajor.$($productParts[1]).$($productParts[2]).0"
if ($VerifyTag -and $env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME -ne "v$productVersion") {
throw "Release tag must be v$productVersion, got $($env:GITHUB_REF_NAME)"
}
$versionInfo = [ordered] @{
productVersion = $productVersion
releaseChannel = $releaseChannel
androidVersionCode = $androidVersionCode
appleBuildNumber = $appleBuildNumber
windowsPackageVersion = $windowsPackageVersion
}
switch ($Field) {
"Product" { $productVersion }
"Channel" { $releaseChannel }
"AndroidCode" { $androidVersionCode }
"AppleBuild" { $appleBuildNumber }
"WindowsPackage" { $windowsPackageVersion }
"Json" { $versionInfo | ConvertTo-Json -Compress }
"Verify" {
"VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple $appleBuildNumber, MSIX $windowsPackageVersion"
}
}

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
fail() {
printf '%s\n' "$*" >&2
exit 1
}
read_property() {
local key=$1
local matches
matches="$(sed -n "s/^${key}=//p" "$version_file")"
[[ -n "$matches" ]] || fail "Missing $key in $version_file"
[[ $(printf '%s\n' "$matches" | wc -l | tr -d ' ') == 1 ]] ||
fail "Duplicate $key in $version_file"
printf '%s' "$matches"
}
validate_canonical_integer() {
local name=$1
local value=$2
local minimum=$3
local maximum=$4
[[ $value =~ ^(0|[1-9][0-9]*)$ ]] ||
fail "$name must be a canonical non-negative integer"
(( 10#$value >= minimum && 10#$value <= maximum )) ||
fail "$name must be between $minimum and $maximum"
}
product_version="$(read_property PRODUCT_VERSION)"
release_channel="$(read_property RELEASE_CHANNEL)"
android_version_code="$(read_property ANDROID_VERSION_CODE)"
apple_build_number="$(read_property APPLE_BUILD_NUMBER)"
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
[[ $product_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 65534
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 65535
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 65535
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
validate_canonical_integer "ANDROID_VERSION_CODE" "$android_version_code" 1 2100000000
[[ $apple_build_number =~ ^[1-9][0-9]*(\.[0-9]+){0,2}$ ]] ||
fail "APPLE_BUILD_NUMBER must contain one to three period-separated non-negative integers and start above zero"
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
windows_major=$((10#$product_major + 10#$windows_version_epoch))
(( windows_major <= 65535 )) ||
fail "Derived Windows package major exceeds 65535"
windows_package_version="$windows_major.$product_minor.$product_patch.0"
verify_tag() {
local tag=${1:-${GITHUB_REF_NAME:-}}
if [[ ${GITHUB_REF_TYPE:-} == tag || -n ${1:-} ]]; then
[[ $tag == "v$product_version" ]] ||
fail "Release tag must be v$product_version, got ${tag:-<empty>}"
fi
}
case "${1:-verify}" in
product)
printf '%s\n' "$product_version"
;;
channel)
printf '%s\n' "$release_channel"
;;
android-code)
printf '%s\n' "$android_version_code"
;;
apple-build)
printf '%s\n' "$apple_build_number"
;;
windows-package)
printf '%s\n' "$windows_package_version"
;;
verify)
verify_tag
printf 'VniDrop %s (%s), Android %s, Apple %s, MSIX %s\n' \
"$product_version" "$release_channel" "$android_version_code" \
"$apple_build_number" "$windows_package_version"
;;
verify-tag)
verify_tag "${2:-}"
;;
*)
fail "Usage: $0 {product|channel|android-code|apple-build|windows-package|verify|verify-tag [tag]}"
;;
esac

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolver="$script_dir/resolve-version.sh"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
write_version() {
printf '%s\n' \
"PRODUCT_VERSION=$1" \
"RELEASE_CHANNEL=$2" \
"ANDROID_VERSION_CODE=$3" \
"APPLE_BUILD_NUMBER=$4" \
"WINDOWS_VERSION_EPOCH=$5" \
> "$scratch/version.properties"
}
resolve() {
VNIDROP_VERSION_FILE="$scratch/version.properties" "$resolver" "$@"
}
expect_failure() {
if "$@" >/dev/null 2>&1; then
printf 'Expected command to fail: %s\n' "$*" >&2
exit 1
fi
}
write_version 0.2.0 beta 2 2 1
[[ $(resolve product) == 0.2.0 ]]
[[ $(resolve android-code) == 2 ]]
[[ $(resolve apple-build) == 2 ]]
[[ $(resolve windows-package) == 1.2.0.0 ]]
resolve verify-tag v0.2.0
expect_failure resolve verify-tag v1.0.0
write_version 1.0.0 stable 42 42 1
[[ $(resolve windows-package) == 2.0.0.0 ]]
write_version 01.0.0 beta 2 2 1
expect_failure resolve verify
write_version 0.2.0 beta 0 2 1
expect_failure resolve verify
write_version 65535.0.0 stable 2 2 1
expect_failure resolve verify
printf 'Version resolver tests passed.\n'

View File

@@ -24,8 +24,10 @@ after the first release. The manifest display name uses the exact reserved Store
name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the
Store, so app version 1.2.3 becomes package version 1.2.3.0.
(build 19041) or later. The product version comes from `version.properties`.
Because MSIX requires a non-zero major and reserves the fourth component, the
package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch
`1`, product version `0.2.0` becomes package version `1.2.0.0`.
## GitHub Actions
@@ -87,9 +89,9 @@ The Store ID is a non-secret variable.
From the repository root:
~~~powershell
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
.\packaging\windows\build-msix.ps1 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
~~~
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to

View File

@@ -1,8 +1,5 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)]
[string] $AppImage,
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows"
}
$versionParts = $Version.Split(".")
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
for ($index = 0; $index -lt $versionParts.Count; $index++) {
$part = $versionParts[$index]
$number = 0
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
}
$packageVersion = "$Version.0"
$versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
$versionInfoJson = & $versionResolver -Field Json -VerifyTag
$versionInfo = $versionInfoJson | ConvertFrom-Json
$Version = [string] $versionInfo.productVersion
$packageVersion = [string] $versionInfo.windowsPackageVersion
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"