Merge pull request #34 from sudosylabs/feat/unified-versioning

feat(release): unify cross-platform versioning
This commit is contained in:
Hammed Abass
2026-07-28 08:45:02 +02:00
committed by GitHub
35 changed files with 500 additions and 220 deletions

View File

@@ -8,20 +8,14 @@ name: Apple release (macOS DMG)
# The App Store / TestFlight build is NOT produced here — that goes through Xcode # The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution. # Organizer / App Store Connect. This workflow only covers direct distribution.
# #
# Trigger: push a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or # Trigger: push the canonical vMAJOR.MINOR.PATCH tag (must point at a commit on
# run manually with an explicit version (produces artifacts, no Release). # master), or run manually using the version committed in version.properties.
on: on:
push: push:
tags: tags:
- "v*.*.*" - "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "0.1.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -58,17 +52,11 @@ jobs:
exit 1 exit 1
fi fi
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '' }}
run: | run: |
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then packaging/version/resolve-version.sh verify >/dev/null
version="${GITHUB_REF_NAME#v}" version="$(packaging/version/resolve-version.sh product)"
else
version="$REQUESTED_VERSION"
fi
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Select Xcode - name: Select Xcode
@@ -147,12 +135,12 @@ jobs:
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV" echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Build, sign & notarize DMG - name: Build, sign & notarize DMG
run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}" run: apple/scripts/build-dmg.sh
- name: Generate appcast - name: Generate appcast
env: env:
RELEASE_REPO: ${{ github.repository }} RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}" run: apple/scripts/generate-appcast.sh
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -4,6 +4,8 @@ on:
pull_request: pull_request:
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"
@@ -18,6 +20,8 @@ on:
- master - master
paths: paths:
- "apple/**" - "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "crates/uniffi-bindgen/**" - "crates/uniffi-bindgen/**"
- "Cargo.toml" - "Cargo.toml"

View File

@@ -5,6 +5,8 @@ on:
paths: paths:
- ".github/workflows/linux-packages.yml" - ".github/workflows/linux-packages.yml"
- "packaging/linux/**" - "packaging/linux/**"
- "packaging/version/**"
- "version.properties"
- "assets/linux/**" - "assets/linux/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -24,12 +26,6 @@ on:
tags: tags:
- "v*.*.*" - "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -88,16 +84,14 @@ jobs:
restore-keys: | restore-keys: |
linux-deb-x64-cargo-1.91.0- linux-deb-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package - name: Test and build Debian package
run: make package-deb VERSION=${{ steps.version.outputs.app }} run: make package-deb
- name: Upload Debian artifact - name: Upload Debian artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -193,16 +187,14 @@ jobs:
restore-keys: | restore-keys: |
linux-rpm-x64-cargo-1.91.0- linux-rpm-x64-cargo-1.91.0-
- name: Resolve version - name: Resolve canonical version
id: version id: version
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT" echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package - name: Build RPM package
run: make package-rpm VERSION=${{ steps.version.outputs.app }} run: make package-rpm
- name: Upload RPM artifact - name: Upload RPM artifact
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'

View File

@@ -6,6 +6,8 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"
@@ -19,6 +21,8 @@ on:
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile" - "Makefile"
- "config.mk" - "config.mk"
- "make/**" - "make/**"

View File

@@ -4,6 +4,8 @@ on:
pull_request: pull_request:
paths: paths:
- "shared/**" - "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
@@ -24,6 +26,8 @@ on:
- master - master
paths: paths:
- "shared/**" - "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**" - "crates/vnidrop/**"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"

View File

@@ -5,6 +5,8 @@ on:
paths: paths:
- ".github/workflows/windows-store.yml" - ".github/workflows/windows-store.yml"
- "packaging/windows/**" - "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**" - "assets/windows/**"
- "desktopApp/**" - "desktopApp/**"
- "shared/**" - "shared/**"
@@ -21,12 +23,6 @@ on:
tags: tags:
- "v*.*.*" - "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions: permissions:
contents: read contents: read
@@ -77,37 +73,13 @@ jobs:
restore-keys: | restore-keys: |
windows-x64-cargo-1.91.0- windows-x64-cargo-1.91.0-
- name: Resolve Store version - name: Resolve canonical version
id: version id: version
shell: pwsh shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: | run: |
$version = $env:REQUESTED_VERSION $version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
if ($env:GITHUB_REF_TYPE -eq "tag") { "app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") { "package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
throw "Store release tags must use vMAJOR.MINOR.PATCH"
}
$version = $env:GITHUB_REF_NAME.Substring(1)
}
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Version must use MAJOR.MINOR.PATCH"
}
$parts = $version.Split(".")
for ($index = 0; $index -lt $parts.Count; $index++) {
$part = $parts[$index]
$number = 0
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
throw "Version components must be canonical integers"
}
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
throw "Version components must be between 0 and 65535, with a non-zero major"
}
}
"app=$version" >> $env:GITHUB_OUTPUT
"package=$version.0" >> $env:GITHUB_OUTPUT
- name: Test and build release app image - name: Test and build release app image
shell: pwsh shell: pwsh
@@ -115,7 +87,6 @@ jobs:
$arguments = @( $arguments = @(
":shared:jvmTest" ":shared:jvmTest"
":desktopApp:createReleaseDistributable" ":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release" "-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false" "-Pvnidrop.diagnostics.included=false"
"--no-daemon" "--no-daemon"
@@ -131,7 +102,6 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$arguments = @{ $arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop" AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows" OutputDirectory = ".\build\release\windows"
} }

View File

@@ -13,13 +13,13 @@ include $(ROOT)/make/release.mk
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple .PHONY: apple-core apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
.PHONY: check-localization localization localization-migrate .PHONY: check-version check-localization localization localization-migrate
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
help: ## Show available commands and common configuration variables. help: ## Show available commands and common configuration variables.
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}' @grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
@printf '\nCommon variables:\n' @printf '\nCommon variables:\n'
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))' @printf ' %-28s %s\n' 'version.properties' 'Canonical application version ($(VERSION))'
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework' @printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))' @printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override' @printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
@@ -59,7 +59,12 @@ format: ## Format Rust sources.
test: test-rust test-shared ## Run the main Rust and shared JVM test suites. test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification. check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && packaging/version/test-version.sh
cd $(ROOT) && packaging/version/resolve-version.sh verify
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-rust: ## Run Rust formatting, lint, tests, and documentation checks. check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check cd $(ROOT) && $(CARGO) fmt --all -- --check
@@ -126,7 +131,7 @@ build-apple-macos-direct: apple-project ## Build the direct-download macOS targe
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env). build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION) cd $(ROOT) && apple/scripts/build-dmg.sh
open-apple: build-apple-macos ## Build and launch the native macOS app. open-apple: build-apple-macos ## Build and launch the native macOS app.
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; } @test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }

View File

@@ -37,6 +37,9 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
} }
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int
kotlin { kotlin {
compilerOptions { compilerOptions {
jvmTarget = JvmTarget.JVM_11 jvmTarget = JvmTarget.JVM_11
@@ -59,8 +62,8 @@ android {
applicationId = "com.vnidrop.app" applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = androidVersionCode
versionName = "1.0" versionName = appVersion
} }
packaging { packaging {
resources { resources {

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@@ -38,7 +40,7 @@
<data android:mimeType="application/vnd.vnidrop.transfer"/> <data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter> </intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. --> <!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter> <intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>

View File

@@ -52,7 +52,7 @@ The macOS app ships through two targets that build identical sources:
```bash ```bash
make build-apple-macos-direct # unsigned compile-check of the direct target make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg make build-apple-dmg # signed (+ notarized) .dmg
``` ```
Full signing, notarization, appcast, and cask flow: see Full signing, notarization, appcast, and cask flow: see

View File

@@ -1,3 +1,6 @@
// Product and build versions are shared by every platform.
#include "../version.properties"
// VniDrop development and CI builds are intentionally unsigned. // VniDrop development and CI builds are intentionally unsigned.
CODE_SIGNING_ALLOWED = NO CODE_SIGNING_ALLOWED = NO
CODE_SIGNING_REQUIRED = NO CODE_SIGNING_REQUIRED = NO

View File

@@ -6,7 +6,7 @@ import UIKit
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let env = PlatformEnvironment( let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)", name: "\(device.systemName) \(device.systemVersion)",
appVersion: version, appVersion: version,

View File

@@ -5,7 +5,7 @@ import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`. /// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor @MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let host = Host.current().localizedName ?? "Mac" let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment( let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString, name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,

View File

@@ -65,11 +65,8 @@ targetTemplates:
base: base:
PRODUCT_NAME: VniDrop PRODUCT_NAME: VniDrop
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0" MARKETING_VERSION: "$(PRODUCT_VERSION)"
# Placeholder only — the real CFBundleVersion is stamped at build time as a CURRENT_PROJECT_VERSION: "$(APPLE_BUILD_NUMBER)"
# UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
# build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist INFOPLIST_FILE: VniDrop/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
@@ -111,23 +108,6 @@ targetTemplates:
echo "error: SwiftLint not installed — run 'brew install swiftlint'" echo "error: SwiftLint not installed — run 'brew install swiftlint'"
exit 1 exit 1
fi fi
postBuildScripts:
# Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
# Info.plist before code signing. Runs for every build (Xcode GUI archive and
# CLI alike), so both the App Store and direct-download channels get a
# monotonic, meaningful build id. CI/reproducible builds can pin it via the
# VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
- name: Stamp build number (UTC timestamp)
basedOnDependencyAnalysis: false
script: |
build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
if [ -f "$plist" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
echo "Stamped CFBundleVersion = $build"
else
echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
fi
targets: targets:
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater. # App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.

View File

@@ -7,8 +7,7 @@
# This is the direct-distribution counterpart to the App Store archive flow; it # This is the direct-distribution counterpart to the App Store archive flow; it
# never touches the App Store `VniDrop` target. The Rust crate is not modified. # never touches the App Store `VniDrop` target. The Rust crate is not modified.
# #
# Usage: apple/scripts/build-dmg.sh [version] # Usage: apple/scripts/build-dmg.sh
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
# #
# Environment: # Environment:
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)". # DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
@@ -29,29 +28,11 @@ PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
SCHEME="VniDropDirect" SCHEME="VniDropDirect"
CONFIG="Release-Direct" CONFIG="Release-Direct"
APP_NAME="VniDrop" APP_NAME="VniDrop"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
# --- Resolve version (arg > git tag > project MARKETING_VERSION) ------------- VERSION="$("$VERSION_RESOLVER" product)"
resolve_version() { BUILD_NUMBER="$("$VERSION_RESOLVER" apple-build)"
local v="${1:-}" "$VERSION_RESOLVER" verify >/dev/null
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
v="${GITHUB_REF_NAME#v}"
fi
if [ -z "$v" ]; then
v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)"
fi
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2
exit 1
fi
printf '%s' "$v"
}
VERSION="$(resolve_version "${1:-}")"
# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's
# "Stamp build number" build phase. Pin it here (one value for the whole archive)
# so the app, DMG, and appcast all agree; Sparkle compares it to order updates.
BUILD_NUMBER="$(date -u +%y%m%d.%H%M)"
export VNIDROP_BUILD="$BUILD_NUMBER"
# --- Resolve signing identity ------------------------------------------------ # --- Resolve signing identity ------------------------------------------------
if [ -z "${DEVELOPER_ID_APP:-}" ]; then if [ -z "${DEVELOPER_ID_APP:-}" ]; then

View File

@@ -9,7 +9,7 @@
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's # keychain). The resulting appcast.xml is uploaded as a release asset; the app's
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest. # SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
# #
# Usage: apple/scripts/generate-appcast.sh [version] # Usage: apple/scripts/generate-appcast.sh
# #
# Environment: # Environment:
# DIST_DIR Folder holding the DMG(s). Default: apple/dist # DIST_DIR Folder holding the DMG(s). Default: apple/dist
@@ -24,12 +24,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple" APPLE_DIR="$REPO_ROOT/apple"
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}" DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}" RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
VERSION="${1:-}" VERSION="$("$VERSION_RESOLVER" product)"
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then "$VERSION_RESOLVER" verify >/dev/null
VERSION="${GITHUB_REF_NAME#v}"
fi
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
# Enclosure URLs resolve to the specific release's assets. # Enclosure URLs resolve to the specific release's assets.
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION" DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader // in each subproject's classloader
@@ -13,3 +15,74 @@ plugins {
alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
} }
val versionFile = layout.projectDirectory.file("version.properties")
val versionProperties = Properties().apply {
versionFile.asFile.inputStream().use(::load)
}
fun requiredVersionProperty(name: String): String =
versionProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("Missing $name in ${versionFile.asFile}")
fun canonicalInteger(name: String, value: String, range: LongRange): Long {
require(value.matches(Regex("0|[1-9][0-9]*"))) {
"$name must be a canonical non-negative integer"
}
val number = value.toLongOrNull()
require(number != null && number in range) {
"$name must be between ${range.first} and ${range.last}"
}
return number
}
val productVersion = requiredVersionProperty("PRODUCT_VERSION")
val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
.matchEntire(productVersion)
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
require(productVersionParts[0] <= 65534 && productVersionParts.drop(1).all { it <= 65535 }) {
"PRODUCT_VERSION components exceed the supported store ranges"
}
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
"RELEASE_CHANNEL contains unsupported characters"
}
val androidVersionCode = canonicalInteger(
"ANDROID_VERSION_CODE",
requiredVersionProperty("ANDROID_VERSION_CODE"),
1L..2_100_000_000L,
).toInt()
val appleBuildNumber = requiredVersionProperty("APPLE_BUILD_NUMBER")
require(appleBuildNumber.matches(Regex("[1-9][0-9]*(\\.[0-9]+){0,2}"))) {
"APPLE_BUILD_NUMBER must contain one to three period-separated non-negative integers and start above zero"
}
val windowsVersionEpoch = canonicalInteger(
"WINDOWS_VERSION_EPOCH",
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
1L..65535L,
)
val windowsMajor = productVersionParts[0] + windowsVersionEpoch
require(windowsMajor <= 65535) {
"Derived Windows package major exceeds 65535"
}
val windowsPackageVersion =
"$windowsMajor.${productVersionParts[1]}.${productVersionParts[2]}.0"
extra["vnidrop.productVersion"] = productVersion
extra["vnidrop.releaseChannel"] = releaseChannel
extra["vnidrop.androidVersionCode"] = androidVersionCode
extra["vnidrop.appleBuildNumber"] = appleBuildNumber
extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion
tasks.register("verifyVersion") {
group = "verification"
description = "Validates the canonical cross-platform application version."
inputs.file(versionFile)
inputs.property("productVersion", productVersion)
inputs.property("releaseChannel", releaseChannel)
inputs.property("androidVersionCode", androidVersionCode)
inputs.property("appleBuildNumber", appleBuildNumber)
inputs.property("windowsPackageVersion", windowsPackageVersion)
}

View File

@@ -1,5 +1,5 @@
# Default command configuration. Override locally in the ignored # Default command configuration. Override local tool paths in the ignored
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0). # config.override.mk or on the command line.
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
HOST_OS := windows HOST_OS := windows
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
OPEN ?= open OPEN ?= open
POWERSHELL ?= pwsh POWERSHELL ?= pwsh
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties) override VERSION := $(shell $(ROOT)/packaging/version/resolve-version.sh product)
APPLE_PROFILE ?= debug APPLE_PROFILE ?= debug
APPLE_CONFIGURATION ?= Debug APPLE_CONFIGURATION ?= Debug
APPLE_DESTINATION ?= APPLE_DESTINATION ?=

31
crates/vnidrop/build.rs Normal file
View File

@@ -0,0 +1,31 @@
use std::{env, fs, path::PathBuf};
fn main() {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let version_file = manifest_dir.join("../../version.properties");
println!("cargo:rerun-if-changed={}", version_file.display());
let contents = fs::read_to_string(&version_file)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", version_file.display()));
let versions: Vec<_> = contents
.lines()
.filter_map(|line| line.strip_prefix("PRODUCT_VERSION="))
.collect();
assert_eq!(
versions.len(),
1,
"{} must contain exactly one PRODUCT_VERSION",
version_file.display()
);
let version = versions[0];
let components: Vec<_> = version.split('.').collect();
assert!(
components.len() == 3
&& components.iter().all(|component| {
component.parse::<u16>().is_ok()
&& (component == &"0" || !component.starts_with('0'))
}),
"PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
);
println!("cargo:rustc-env=VNIDROP_PRODUCT_VERSION={version}");
}

View File

@@ -106,7 +106,7 @@ impl HandshakeClient {
transfer_name: metadata.transfer_name.clone(), transfer_name: metadata.transfer_name.clone(),
receiver_name: receiver_name.map(ToOwned::to_owned), receiver_name: receiver_name.map(ToOwned::to_owned),
receiver_device_name: None, receiver_device_name: None,
app_version: env!("CARGO_PKG_VERSION").to_string(), app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(),
}) })
.await .await
} }

View File

@@ -6,19 +6,7 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
} }
val appVersion = providers.gradleProperty("vnidrop.version").get() val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val appVersionParts = appVersion.split(".")
require(
appVersionParts.size == 3 &&
appVersionParts.mapIndexed { index, part ->
val number = part.toIntOrNull()
number != null &&
number.toString() == part &&
number in (if (index == 0) 1 else 0)..65535
}.all { it },
) {
"vnidrop.version must be MAJOR.MINOR.PATCH with numeric components from 0 to 65535 and a non-zero major"
}
dependencies { dependencies {
implementation(projects.shared) implementation(projects.shared)

View File

@@ -6,10 +6,6 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
# Product version used by desktop packaging. Release workflows override this
# from the vMAJOR.MINOR.PATCH tag.
vnidrop.version=1.0.0
#Android #Android
android.builtInKotlin=false android.builtInKotlin=false
android.newDsl=false android.newDsl=false

View File

@@ -1,11 +1,10 @@
.PHONY: package-deb package-rpm package-msix .PHONY: package-deb package-rpm package-msix
package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z). package-deb: ## Build and verify a Debian x64 package.
@test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; } @test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; }
@cd $(ROOT); \ @cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \ $(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS); \ $(GRADLE_RELEASE_FLAGS); \
@@ -19,12 +18,11 @@ package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z).
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \ ( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name" printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z). package-rpm: ## Build and verify an RPM x64 package.
@test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; } @test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; }
@cd $(ROOT); \ @cd $(ROOT); \
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ version="$$(packaging/linux/resolve-version.sh)"; \
$(GRADLE) :desktopApp:packageReleaseRpm \ $(GRADLE) :desktopApp:packageReleaseRpm \
-Pvnidrop.version="$$version" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS); \ $(GRADLE_RELEASE_FLAGS); \
@@ -38,14 +36,12 @@ package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z).
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \ ( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name" printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
package-msix: ## Build and verify an unsigned Windows Store MSIX (VERSION=x.y.z). package-msix: ## Build and verify an unsigned Windows Store MSIX.
@test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; } @test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; }
cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \ cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \
-Pvnidrop.version="$(VERSION)" \
-Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.desktop.rustVariant=release \
-Pvnidrop.diagnostics.included=false \ -Pvnidrop.diagnostics.included=false \
$(GRADLE_RELEASE_FLAGS) $(GRADLE_RELEASE_FLAGS)
cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \ cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \
-Version "$(VERSION)" \
-AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \ -AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \
-OutputDirectory build/release/windows -OutputDirectory build/release/windows

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`, whose commit is on `master` creates the matching GitHub Release with the `.deb`,
`.rpm`, and a combined `SHA256SUMS`. `.rpm`, and a combined `SHA256SUMS`.
The existing `v1.0.0` tag predates this workflow and will not run it The legacy `v1.0.0` tag predates canonical versioning and does not define the
retroactively. Use the next version tag after this configuration reaches current product version. New release tags must match `version.properties`.
`master`.
## Install a downloaded package ## 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 `fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
Ubuntu prevents `jpackage` from discovering normal RPM dependencies. 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 ```bash
make package-deb VERSION=1.0.0 make package-deb
make package-rpm VERSION=1.0.0 make package-rpm
``` ```
The Make targets collect the Compose output under `build/release/linux/`, then The Make targets collect the Compose output under `build/release/linux/`, then

View File

@@ -2,38 +2,14 @@
set -euo pipefail 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 [[ -n ${1:-} && $1 != "$version" ]]; then
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf 'Version overrides are not supported; version.properties declares %s\n' "$version" >&2
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
exit 1 exit 1
fi fi
version=${GITHUB_REF_NAME#v}
fi
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must use MAJOR.MINOR.PATCH" >&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" 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`. name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004 The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the (build 19041) or later. The product version comes from `version.properties`.
Store, so app version 1.2.3 becomes package version 1.2.3.0. 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 ## GitHub Actions
@@ -87,9 +89,9 @@ The Store ID is a non-secret variable.
From the repository root: From the repository root:
~~~powershell ~~~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 The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to

View File

@@ -1,8 +1,5 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string] $AppImage, [string] $AppImage,
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows" throw "MSIX packaging must run on Windows"
} }
$versionParts = $Version.Split(".") $versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH" $versionInfoJson = & $versionResolver -Field Json -VerifyTag
for ($index = 0; $index -lt $versionParts.Count; $index++) { $versionInfo = $versionInfoJson | ConvertFrom-Json
$part = $versionParts[$index] $Version = [string] $versionInfo.productVersion
$number = 0 $packageVersion = [string] $versionInfo.windowsPackageVersion
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"
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path $appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage" Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"

View File

@@ -37,7 +37,7 @@ plugins {
alias(libs.plugins.kotlinAtomicfu) alias(libs.plugins.kotlinAtomicfu)
} }
val appVersion = providers.gradleProperty("vnidrop.version").get() val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant") val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
.map { value -> .map { value ->
when (value.trim().lowercase()) { when (value.trim().lowercase()) {

View File

@@ -52,7 +52,7 @@ private class AndroidDeviceInfoProvider(
private fun Context.appVersion(): String = runCatching { private fun Context.appVersion(): String = runCatching {
packageManager.getPackageInfo(packageName, 0).versionName packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0" }.getOrNull()?.takeIf(String::isNotBlank) ?: "unknown"
private fun Context.activeNetworkSummary(): String? = runCatching { private fun Context.activeNetworkSummary(): String? = runCatching {
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager

View File

@@ -14,7 +14,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
AppDependencies( AppDependencies(
environment = PlatformEnvironment( environment = PlatformEnvironment(
name = "Java ${System.getProperty("java.version")}", name = "Java ${System.getProperty("java.version")}",
appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0", appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "unknown",
defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop", defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
uiPlatform = uiPlatformForJvm(System.getProperty("os.name")), uiPlatform = uiPlatformForJvm(System.getProperty("os.name")),

5
version.properties Normal file
View File

@@ -0,0 +1,5 @@
PRODUCT_VERSION=0.2.0
RELEASE_CHANNEL=beta
ANDROID_VERSION_CODE=2
APPLE_BUILD_NUMBER=2
WINDOWS_VERSION_EPOCH=1