From 407a0d2d6081dc6fca166e78325ba5a206d67f0a Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 28 Jul 2026 08:22:58 +0200 Subject: [PATCH] feat(release): unify cross-platform versioning --- .github/workflows/apple-release.yml | 26 ++--- .github/workflows/apple.yml | 4 + .github/workflows/linux-packages.yml | 24 ++--- .github/workflows/rust-core.yml | 4 + .github/workflows/shared-kmp.yml | 4 + .github/workflows/windows-store.yml | 42 ++------ Makefile | 13 ++- androidApp/build.gradle.kts | 7 +- androidApp/src/main/AndroidManifest.xml | 6 +- apple/README.md | 2 +- apple/Signing.xcconfig | 3 + .../Platform/AppDependencies+iOS.swift | 2 +- .../Platform/AppDependencies+macOS.swift | 2 +- apple/project.yml | 24 +---- apple/scripts/build-dmg.sh | 29 +----- apple/scripts/generate-appcast.sh | 10 +- build.gradle.kts | 73 ++++++++++++++ config.mk | 6 +- crates/vnidrop/build.rs | 31 ++++++ crates/vnidrop/src/handshake.rs | 2 +- desktopApp/build.gradle.kts | 14 +-- gradle.properties | 4 - make/release.mk | 14 +-- packaging/linux/README.md | 12 +-- packaging/linux/resolve-version.sh | 36 ++----- packaging/version/README.md | 44 +++++++++ packaging/version/resolve-version.ps1 | 97 +++++++++++++++++++ packaging/version/resolve-version.sh | 95 ++++++++++++++++++ packaging/version/test-version.sh | 51 ++++++++++ packaging/windows/README.md | 10 +- packaging/windows/build-msix.ps1 | 18 +--- shared/build.gradle.kts | 2 +- .../com/vnidrop/app/Platform.android.kt | 2 +- .../kotlin/com/vnidrop/app/Platform.jvm.kt | 2 +- version.properties | 5 + 35 files changed, 500 insertions(+), 220 deletions(-) create mode 100644 crates/vnidrop/build.rs create mode 100644 packaging/version/README.md create mode 100644 packaging/version/resolve-version.ps1 create mode 100755 packaging/version/resolve-version.sh create mode 100755 packaging/version/test-version.sh create mode 100644 version.properties diff --git a/.github/workflows/apple-release.yml b/.github/workflows/apple-release.yml index 9812b69..9115d83 100644 --- a/.github/workflows/apple-release.yml +++ b/.github/workflows/apple-release.yml @@ -8,20 +8,14 @@ name: Apple release (macOS DMG) # The App Store / TestFlight build is NOT produced here — that goes through Xcode # 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 -# run manually with an explicit version (produces artifacts, no Release). +# Trigger: push the canonical vMAJOR.MINOR.PATCH tag (must point at a commit on +# master), or run manually using the version committed in version.properties. on: push: tags: - "v*.*.*" workflow_dispatch: - inputs: - version: - description: Release version in MAJOR.MINOR.PATCH form - required: true - default: "0.1.0" - type: string permissions: contents: read @@ -58,17 +52,11 @@ jobs: exit 1 fi - - name: Resolve version + - name: Resolve canonical version id: version - env: - REQUESTED_VERSION: ${{ inputs.version || '' }} run: | - if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then - version="${GITHUB_REF_NAME#v}" - else - version="$REQUESTED_VERSION" - fi - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; } + packaging/version/resolve-version.sh verify >/dev/null + version="$(packaging/version/resolve-version.sh product)" echo "app=$version" >> "$GITHUB_OUTPUT" - name: Select Xcode @@ -147,12 +135,12 @@ jobs: echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV" - name: Build, sign & notarize DMG - run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}" + run: apple/scripts/build-dmg.sh - name: Generate appcast env: RELEASE_REPO: ${{ github.repository }} - run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}" + run: apple/scripts/generate-appcast.sh - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 64a810b..d81a063 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "apple/**" + - "version.properties" + - "packaging/version/**" - "crates/vnidrop/**" - "crates/uniffi-bindgen/**" - "Cargo.toml" @@ -18,6 +20,8 @@ on: - master paths: - "apple/**" + - "version.properties" + - "packaging/version/**" - "crates/vnidrop/**" - "crates/uniffi-bindgen/**" - "Cargo.toml" diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index ec30fbc..bc58379 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -5,6 +5,8 @@ on: paths: - ".github/workflows/linux-packages.yml" - "packaging/linux/**" + - "packaging/version/**" + - "version.properties" - "assets/linux/**" - "desktopApp/**" - "shared/**" @@ -24,12 +26,6 @@ on: tags: - "v*.*.*" workflow_dispatch: - inputs: - version: - description: Release version in MAJOR.MINOR.PATCH form - required: true - default: "1.0.0" - type: string permissions: contents: read @@ -88,16 +84,14 @@ jobs: restore-keys: | linux-deb-x64-cargo-1.91.0- - - name: Resolve version + - name: Resolve canonical version id: version - env: - REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }} run: | - version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") + version=$(packaging/linux/resolve-version.sh) echo "app=$version" >> "$GITHUB_OUTPUT" - name: Test and build Debian package - run: make package-deb VERSION=${{ steps.version.outputs.app }} + run: make package-deb - name: Upload Debian artifact if: github.event_name != 'pull_request' @@ -193,16 +187,14 @@ jobs: restore-keys: | linux-rpm-x64-cargo-1.91.0- - - name: Resolve version + - name: Resolve canonical version id: version - env: - REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }} run: | - version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION") + version=$(packaging/linux/resolve-version.sh) echo "app=$version" >> "$GITHUB_OUTPUT" - name: Build RPM package - run: make package-rpm VERSION=${{ steps.version.outputs.app }} + run: make package-rpm - name: Upload RPM artifact if: github.event_name != 'pull_request' diff --git a/.github/workflows/rust-core.yml b/.github/workflows/rust-core.yml index 7eb59bc..90dda17 100644 --- a/.github/workflows/rust-core.yml +++ b/.github/workflows/rust-core.yml @@ -6,6 +6,8 @@ on: - "Cargo.toml" - "Cargo.lock" - "crates/vnidrop/**" + - "version.properties" + - "packaging/version/**" - "Makefile" - "config.mk" - "make/**" @@ -19,6 +21,8 @@ on: - "Cargo.toml" - "Cargo.lock" - "crates/vnidrop/**" + - "version.properties" + - "packaging/version/**" - "Makefile" - "config.mk" - "make/**" diff --git a/.github/workflows/shared-kmp.yml b/.github/workflows/shared-kmp.yml index 3f94afb..2544487 100644 --- a/.github/workflows/shared-kmp.yml +++ b/.github/workflows/shared-kmp.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "shared/**" + - "version.properties" + - "packaging/version/**" - "crates/vnidrop/**" - "Cargo.toml" - "Cargo.lock" @@ -24,6 +26,8 @@ on: - master paths: - "shared/**" + - "version.properties" + - "packaging/version/**" - "crates/vnidrop/**" - "Cargo.toml" - "Cargo.lock" diff --git a/.github/workflows/windows-store.yml b/.github/workflows/windows-store.yml index 4192e79..1e3aa37 100644 --- a/.github/workflows/windows-store.yml +++ b/.github/workflows/windows-store.yml @@ -5,6 +5,8 @@ on: paths: - ".github/workflows/windows-store.yml" - "packaging/windows/**" + - "packaging/version/**" + - "version.properties" - "assets/windows/**" - "desktopApp/**" - "shared/**" @@ -21,12 +23,6 @@ on: tags: - "v*.*.*" workflow_dispatch: - inputs: - version: - description: Release version in MAJOR.MINOR.PATCH form - required: true - default: "1.0.0" - type: string permissions: contents: read @@ -77,37 +73,13 @@ jobs: restore-keys: | windows-x64-cargo-1.91.0- - - name: Resolve Store version + - name: Resolve canonical version id: version shell: pwsh - env: - REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }} run: | - $version = $env:REQUESTED_VERSION - if ($env:GITHUB_REF_TYPE -eq "tag") { - if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") { - 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 + $version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json + "app=$($version.productVersion)" >> $env:GITHUB_OUTPUT + "package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT - name: Test and build release app image shell: pwsh @@ -115,7 +87,6 @@ jobs: $arguments = @( ":shared:jvmTest" ":desktopApp:createReleaseDistributable" - "-Pvnidrop.version=${{ steps.version.outputs.app }}" "-Pvnidrop.desktop.rustVariant=release" "-Pvnidrop.diagnostics.included=false" "--no-daemon" @@ -131,7 +102,6 @@ jobs: shell: pwsh run: | $arguments = @{ - Version = "${{ steps.version.outputs.app }}" AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop" OutputDirectory = ".\build\release\windows" } diff --git a/Makefile b/Makefile index 4010dc8..5410501 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,13 @@ include $(ROOT)/make/release.mk .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: 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 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}' @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_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))' @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. -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. 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 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. @test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; } diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f1e992c..b4977e1 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -37,6 +37,9 @@ plugins { alias(libs.plugins.composeCompiler) } +val appVersion = rootProject.extra["vnidrop.productVersion"] as String +val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int + kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_11 @@ -59,8 +62,8 @@ android { applicationId = "com.vnidrop.app" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" + versionCode = androidVersionCode + versionName = appVersion } packaging { resources { diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 40dee41..a655451 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ - + @@ -38,7 +40,7 @@ - + diff --git a/apple/README.md b/apple/README.md index e99f093..d7b8b0c 100644 --- a/apple/README.md +++ b/apple/README.md @@ -52,7 +52,7 @@ The macOS app ships through two targets that build identical sources: ```bash 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 diff --git a/apple/Signing.xcconfig b/apple/Signing.xcconfig index da695ba..396ad11 100644 --- a/apple/Signing.xcconfig +++ b/apple/Signing.xcconfig @@ -1,3 +1,6 @@ +// Product and build versions are shared by every platform. +#include "../version.properties" + // VniDrop development and CI builds are intentionally unsigned. CODE_SIGNING_ALLOWED = NO CODE_SIGNING_REQUIRED = NO diff --git a/apple/VniDrop/Platform/AppDependencies+iOS.swift b/apple/VniDrop/Platform/AppDependencies+iOS.swift index e4d5bd2..318c490 100644 --- a/apple/VniDrop/Platform/AppDependencies+iOS.swift +++ b/apple/VniDrop/Platform/AppDependencies+iOS.swift @@ -6,7 +6,7 @@ import UIKit @MainActor func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies { 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( name: "\(device.systemName) \(device.systemVersion)", appVersion: version, diff --git a/apple/VniDrop/Platform/AppDependencies+macOS.swift b/apple/VniDrop/Platform/AppDependencies+macOS.swift index d75aa46..ed7c14f 100644 --- a/apple/VniDrop/Platform/AppDependencies+macOS.swift +++ b/apple/VniDrop/Platform/AppDependencies+macOS.swift @@ -5,7 +5,7 @@ import AppKit /// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`. @MainActor 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 env = PlatformEnvironment( name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString, diff --git a/apple/project.yml b/apple/project.yml index faf3d7c..cfa2d8c 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -65,11 +65,8 @@ targetTemplates: base: PRODUCT_NAME: VniDrop PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app - MARKETING_VERSION: "0.1.0" - # Placeholder only — the real CFBundleVersion is stamped at build time as a - # 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" + MARKETING_VERSION: "$(PRODUCT_VERSION)" + CURRENT_PROJECT_VERSION: "$(APPLE_BUILD_NUMBER)" GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: VniDrop/Resources/Info.plist CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements @@ -111,23 +108,6 @@ targetTemplates: echo "error: SwiftLint not installed — run 'brew install swiftlint'" exit 1 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: # App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater. diff --git a/apple/scripts/build-dmg.sh b/apple/scripts/build-dmg.sh index 6504a7f..8ee8af5 100755 --- a/apple/scripts/build-dmg.sh +++ b/apple/scripts/build-dmg.sh @@ -7,8 +7,7 @@ # 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. # -# Usage: apple/scripts/build-dmg.sh [version] -# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag. +# Usage: apple/scripts/build-dmg.sh # # Environment: # DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)". @@ -29,29 +28,11 @@ PROJECT="$APPLE_DIR/VniDrop.xcodeproj" SCHEME="VniDropDirect" CONFIG="Release-Direct" APP_NAME="VniDrop" +VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh" -# --- Resolve version (arg > git tag > project MARKETING_VERSION) ------------- -resolve_version() { - local v="${1:-}" - 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" +VERSION="$("$VERSION_RESOLVER" product)" +BUILD_NUMBER="$("$VERSION_RESOLVER" apple-build)" +"$VERSION_RESOLVER" verify >/dev/null # --- Resolve signing identity ------------------------------------------------ if [ -z "${DEVELOPER_ID_APP:-}" ]; then diff --git a/apple/scripts/generate-appcast.sh b/apple/scripts/generate-appcast.sh index 762bd43..1b1e213 100755 --- a/apple/scripts/generate-appcast.sh +++ b/apple/scripts/generate-appcast.sh @@ -9,7 +9,7 @@ # 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. # -# Usage: apple/scripts/generate-appcast.sh [version] +# Usage: apple/scripts/generate-appcast.sh # # Environment: # 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" DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}" RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}" +VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh" -VERSION="${1:-}" -if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then - VERSION="${GITHUB_REF_NAME#v}" -fi -[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; } +VERSION="$("$VERSION_RESOLVER" product)" +"$VERSION_RESOLVER" verify >/dev/null # Enclosure URLs resolve to the specific release's assets. DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION" diff --git a/build.gradle.kts b/build.gradle.kts index 4eb9026..db5d558 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader @@ -13,3 +15,74 @@ plugins { alias(libs.plugins.kotlinJvm) 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) +} diff --git a/config.mk b/config.mk index 1d14e66..abf1238 100644 --- a/config.mk +++ b/config.mk @@ -1,5 +1,5 @@ -# Default command configuration. Override locally in the ignored -# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0). +# Default command configuration. Override local tool paths in the ignored +# config.override.mk or on the command line. ifeq ($(OS),Windows_NT) HOST_OS := windows @@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen OPEN ?= open 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_CONFIGURATION ?= Debug APPLE_DESTINATION ?= diff --git a/crates/vnidrop/build.rs b/crates/vnidrop/build.rs new file mode 100644 index 0000000..839344a --- /dev/null +++ b/crates/vnidrop/build.rs @@ -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::().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}"); +} diff --git a/crates/vnidrop/src/handshake.rs b/crates/vnidrop/src/handshake.rs index 3cc7638..d48f4a3 100644 --- a/crates/vnidrop/src/handshake.rs +++ b/crates/vnidrop/src/handshake.rs @@ -106,7 +106,7 @@ impl HandshakeClient { transfer_name: metadata.transfer_name.clone(), receiver_name: receiver_name.map(ToOwned::to_owned), receiver_device_name: None, - app_version: env!("CARGO_PKG_VERSION").to_string(), + app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(), }) .await } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 7423fd8..f2db6e5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,19 +6,7 @@ plugins { alias(libs.plugins.composeCompiler) } -val appVersion = providers.gradleProperty("vnidrop.version").get() -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" -} +val appVersion = rootProject.extra["vnidrop.productVersion"] as String dependencies { implementation(projects.shared) diff --git a/gradle.properties b/gradle.properties index 6d5c097..035c8de 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,10 +6,6 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=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.builtInKotlin=false android.newDsl=false diff --git a/make/release.mk b/make/release.mk index 27680a6..b1fe40a 100644 --- a/make/release.mk +++ b/make/release.mk @@ -1,11 +1,10 @@ .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; } @cd $(ROOT); \ - version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ + version="$$(packaging/linux/resolve-version.sh)"; \ $(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \ - -Pvnidrop.version="$$version" \ -Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.diagnostics.included=false \ $(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" ); \ 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; } @cd $(ROOT); \ - version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \ + version="$$(packaging/linux/resolve-version.sh)"; \ $(GRADLE) :desktopApp:packageReleaseRpm \ - -Pvnidrop.version="$$version" \ -Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.diagnostics.included=false \ $(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" ); \ 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; } cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \ - -Pvnidrop.version="$(VERSION)" \ -Pvnidrop.desktop.rustVariant=release \ -Pvnidrop.diagnostics.included=false \ $(GRADLE_RELEASE_FLAGS) cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \ - -Version "$(VERSION)" \ -AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \ -OutputDirectory build/release/windows diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 4cf8244..01c788c 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -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 diff --git a/packaging/linux/resolve-version.sh b/packaging/linux/resolve-version.sh index da3488c..a3867e3 100755 --- a/packaging/linux/resolve-version.sh +++ b/packaging/linux/resolve-version.sh @@ -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" diff --git a/packaging/version/README.md b/packaging/version/README.md new file mode 100644 index 0000000..14af240 --- /dev/null +++ b/packaging/version/README.md @@ -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. diff --git a/packaging/version/resolve-version.ps1 b/packaging/version/resolve-version.ps1 new file mode 100644 index 0000000..64be60c --- /dev/null +++ b/packaging/version/resolve-version.ps1 @@ -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" + } +} diff --git a/packaging/version/resolve-version.sh b/packaging/version/resolve-version.sh new file mode 100755 index 0000000..1d7b191 --- /dev/null +++ b/packaging/version/resolve-version.sh @@ -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:-}" + 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 diff --git a/packaging/version/test-version.sh b/packaging/version/test-version.sh new file mode 100755 index 0000000..8eacbb3 --- /dev/null +++ b/packaging/version/test-version.sh @@ -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' diff --git a/packaging/windows/README.md b/packaging/windows/README.md index a7e87b0..972163a 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -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 diff --git a/packaging/windows/build-msix.ps1 b/packaging/windows/build-msix.ps1 index dfe7027..1819d23 100644 --- a/packaging/windows/build-msix.ps1 +++ b/packaging/windows/build-msix.ps1 @@ -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" diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ea17415..f6baf59 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,7 +37,7 @@ plugins { 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") .map { value -> when (value.trim().lowercase()) { diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index cd5c21c..fda519c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -52,7 +52,7 @@ private class AndroidDeviceInfoProvider( private fun Context.appVersion(): String = runCatching { packageManager.getPackageInfo(packageName, 0).versionName -}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0" +}.getOrNull()?.takeIf(String::isNotBlank) ?: "unknown" private fun Context.activeNetworkSummary(): String? = runCatching { val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index 7f71fd9..0aae810 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -14,7 +14,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController AppDependencies( environment = PlatformEnvironment( 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", defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", uiPlatform = uiPlatformForJvm(System.getProperty("os.name")), diff --git a/version.properties b/version.properties new file mode 100644 index 0000000..ee10ff4 --- /dev/null +++ b/version.properties @@ -0,0 +1,5 @@ +PRODUCT_VERSION=0.2.0 +RELEASE_CHANNEL=beta +ANDROID_VERSION_CODE=2 +APPLE_BUILD_NUMBER=2 +WINDOWS_VERSION_EPOCH=1