From 2d7982bbb911166089e27f2b8c52d10500780a67 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 28 Jul 2026 11:09:53 +0200 Subject: [PATCH 1/2] ci: add coordinated release pipeline --- .github/workflows/android-release.yml | 141 +++++++ .github/workflows/apple-release.yml | 84 +--- .github/workflows/linux-packages.yml | 75 +--- .github/workflows/release-checks.yml | 53 +++ .github/workflows/release.yml | 369 +++++++++++++++++ .github/workflows/windows-store.yml | 4 +- Makefile | 8 +- androidApp/build.gradle.kts | 40 +- packaging/android/README.md | 54 +++ packaging/android/build-release.sh | 171 ++++++++ packaging/android/publish_play.py | 410 +++++++++++++++++++ packaging/android/tests/test_publish_play.py | 100 +++++ packaging/linux/README.md | 13 +- packaging/release/README.md | 41 ++ packaging/release/assemble-release.sh | 169 ++++++++ packaging/release/test-assemble-release.sh | 102 +++++ packaging/windows/README.md | 9 +- 17 files changed, 1678 insertions(+), 165 deletions(-) create mode 100644 .github/workflows/android-release.yml create mode 100644 .github/workflows/release-checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 packaging/android/README.md create mode 100755 packaging/android/build-release.sh create mode 100755 packaging/android/publish_play.py create mode 100644 packaging/android/tests/test_publish_play.py create mode 100644 packaging/release/README.md create mode 100755 packaging/release/assemble-release.sh create mode 100755 packaging/release/test-assemble-release.sh diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000..5b3497f --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,141 @@ +name: Android release package + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: android-release-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + build: + name: Build signed Android APK and AAB + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: "21.0.11+10.0.LTS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + gradle-home-cache-strict-match: true + + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: "1.91.0" + targets: aarch64-linux-android,x86_64-linux-android + + - name: Cache Cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: android-release-cargo-1.91.0-${{ hashFiles('Cargo.lock') }} + restore-keys: | + android-release-cargo-1.91.0- + + - name: Set up Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + with: + packages: "platform-tools platforms;android-36 build-tools;36.0.0" + + - name: Set up Android NDK + id: setup-ndk + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1 + with: + ndk-version: r27c + link-to-sdk: true + add-to-path: false + + - name: Export Android NDK location + run: | + echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV" + + - name: Resolve canonical version + id: version + run: | + packaging/version/resolve-version.sh verify >/dev/null + echo "app=$(packaging/version/resolve-version.sh product)" >> "$GITHUB_OUTPUT" + echo "code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT" + + - name: Validate signing configuration + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }} + UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} + run: | + for name in \ + KEYSTORE_BASE64 \ + KEYSTORE_PASSWORD \ + KEY_ALIAS \ + KEY_PASSWORD \ + UPLOAD_CERT_SHA256; do + if [ -z "${!name:-}" ]; then + echo "Missing Android release signing configuration: $name" >&2 + exit 1 + fi + done + + - name: Decode upload keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} + run: | + keystore="$RUNNER_TEMP/vnidrop-upload.jks" + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore" + chmod 600 "$keystore" + test -s "$keystore" + echo "VNIDROP_ANDROID_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV" + + - name: Build and verify signed release + env: + VNIDROP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} + VNIDROP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }} + VNIDROP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }} + VNIDROP_ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} + run: packaging/android/build-release.sh + + - name: Remove upload keystore + if: always() + run: rm -f "$RUNNER_TEMP/vnidrop-upload.jks" + + - name: Upload Android artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnidrop-${{ steps.version.outputs.app }}-android-release + path: build/release/android/ + if-no-files-found: error + retention-days: 90 + compression-level: 0 + + - name: Summarize Android package + run: | + echo "### Android release package" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: ${{ steps.version.outputs.app }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Version code: ${{ steps.version.outputs.code }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Signing: upload certificate verified" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/apple-release.yml b/.github/workflows/apple-release.yml index 9115d83..e4de19e 100644 --- a/.github/workflows/apple-release.yml +++ b/.github/workflows/apple-release.yml @@ -1,20 +1,19 @@ name: Apple release (macOS DMG) -# Builds, signs, notarizes, and publishes the direct-download macOS build: +# Builds, signs, notarizes, and uploads the direct-download macOS build: # - a Developer ID–signed, notarized VniDrop-.dmg, -# - a Sparkle appcast.xml (both attached to the GitHub Release), and -# - an updated Homebrew cask pushed to the sudosylabs/homebrew-vnidrop tap. +# - a Sparkle appcast.xml. +# +# The central release workflow publishes these artifacts and updates Homebrew. # # 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 the canonical vMAJOR.MINOR.PATCH tag (must point at a commit on -# master), or run manually using the version committed in version.properties. +# Called by the central tag-release workflow, or run manually to validate the +# signed/notarized direct-download artifact. on: - push: - tags: - - "v*.*.*" + workflow_call: workflow_dispatch: permissions: @@ -33,8 +32,6 @@ jobs: name: Build & notarize DMG runs-on: macos-latest timeout-minutes: 90 - permissions: - contents: write outputs: version: ${{ steps.version.outputs.app }} steps: @@ -82,7 +79,7 @@ jobs: run: brew install xcodegen swiftlint create-dmg - name: Install Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - name: Download Sparkle tools # generate_appcast + sign_update ship in the Sparkle release tarball. @@ -151,68 +148,3 @@ jobs: apple/dist/appcast.xml if-no-files-found: error retention-days: 14 - - - name: Publish GitHub Release - if: github.event_name == 'push' && github.ref_type == 'tag' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - set -euo pipefail - tag="$GITHUB_REF_NAME" - version="${tag#v}" - if gh release view "$tag" >/dev/null 2>&1; then - echo "Release $tag already exists; refusing to replace assets" >&2 - exit 1 - fi - gh release create "$tag" \ - "apple/dist/VniDrop-${version}.dmg" \ - "apple/dist/appcast.xml" \ - --verify-tag \ - --title "VniDrop $version" \ - --generate-notes - - update-cask: - name: Update Homebrew cask - needs: build - if: github.event_name == 'push' && github.ref_type == 'tag' - runs-on: ubuntu-22.04 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Download DMG artifact - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - name: vnidrop-${{ needs.build.outputs.version }}-macos-dmg - path: dist - - - name: Render cask - env: - VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)" - sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \ - -e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \ - packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb - echo "Rendered cask:"; cat /tmp/vnidrop.rb - - - name: Push to tap - env: - TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - git clone "https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" tap - mkdir -p tap/Casks - cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb - cd tap - git config user.name "vnidrop-release-bot" - git config user.email "release-bot@users.noreply.github.com" - git add Casks/vnidrop.rb - git commit -m "vnidrop ${VERSION}" || { echo "no cask changes"; exit 0; } - git push diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index bc58379..9e206a1 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -22,9 +22,7 @@ on: - "Makefile" - "config.mk" - "make/**" - push: - tags: - - "v*.*.*" + workflow_call: workflow_dispatch: permissions: @@ -212,74 +210,3 @@ jobs: echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY" echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY" echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY" - - publish-release: - name: Publish GitHub Release assets - if: github.event_name == 'push' && github.ref_type == 'tag' - needs: - - build-deb - - build-rpm - runs-on: ubuntu-22.04 - timeout-minutes: 15 - permissions: - contents: write - - steps: - - name: Checkout release history - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Verify tag is on master - run: | - if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then - echo "Release tags must point to a commit on master" >&2 - exit 1 - fi - - - name: Download Linux artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 - with: - pattern: vnidrop-*-linux-*-x64 - path: build/release/linux - merge-multiple: true - - - name: Verify artifacts and checksums - run: | - cd build/release/linux - shopt -s nullglob - deb_packages=(*.deb) - rpm_packages=(*.rpm) - checksum_files=(*.sha256) - if (( ${#deb_packages[@]} != 1 || ${#rpm_packages[@]} != 1 || ${#checksum_files[@]} != 2 )); then - echo "Expected one DEB, one RPM, and two checksum sidecars" >&2 - exit 1 - fi - version=${GITHUB_REF_NAME#v} - if [[ ${deb_packages[0]} != "vnidrop_${version}-1_amd64.deb" || ${rpm_packages[0]} != "vnidrop-${version}-1.x86_64.rpm" ]]; then - echo "Downloaded package names do not match tag $GITHUB_REF_NAME" >&2 - exit 1 - fi - sha256sum --check "${checksum_files[@]}" - sha256sum "${deb_packages[@]}" "${rpm_packages[@]}" > SHA256SUMS - rm -- "${checksum_files[@]}" - - - name: Publish GitHub Release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - tag=${GITHUB_REF_NAME} - version=${tag#v} - if gh release view "$tag" >/dev/null 2>&1; then - echo "GitHub Release $tag already exists; refusing to replace its assets" >&2 - exit 1 - fi - gh release create "$tag" \ - build/release/linux/*.deb \ - build/release/linux/*.rpm \ - build/release/linux/SHA256SUMS \ - --verify-tag \ - --title "VniDrop $version" \ - --generate-notes diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 0000000..606b5bf --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,53 @@ +name: Release pipeline checks + +on: + pull_request: + paths: + - ".github/workflows/android-release.yml" + - ".github/workflows/apple-release.yml" + - ".github/workflows/linux-packages.yml" + - ".github/workflows/release-checks.yml" + - ".github/workflows/release.yml" + - ".github/workflows/windows-store.yml" + - "packaging/android/**" + - "packaging/release/**" + - "packaging/version/**" + - "version.properties" + - "Makefile" + push: + branches: + - master + paths: + - ".github/workflows/android-release.yml" + - ".github/workflows/apple-release.yml" + - ".github/workflows/linux-packages.yml" + - ".github/workflows/release-checks.yml" + - ".github/workflows/release.yml" + - ".github/workflows/windows-store.yml" + - "packaging/android/**" + - "packaging/release/**" + - "packaging/version/**" + - "version.properties" + - "Makefile" + +permissions: + contents: read + +concurrency: + group: release-checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + scripts: + name: Validate release scripts + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run release checks + run: make check-release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..332e624 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,369 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: vnidrop-release + cancel-in-progress: false + +jobs: + preflight: + name: Verify release tag + if: ${{ vars.RELEASE_PIPELINE_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + version: ${{ steps.version.outputs.app }} + android_code: ${{ steps.version.outputs.android_code }} + + steps: + - name: Checkout release history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify canonical beta tag on current master + id: version + run: | + set -euo pipefail + packaging/version/resolve-version.sh verify >/dev/null + version="$(packaging/version/resolve-version.sh product)" + channel="$(packaging/version/resolve-version.sh channel)" + master_sha="$(git rev-parse origin/master)" + if [ "$GITHUB_SHA" != "$master_sha" ]; then + echo "Release tags must point at the current master commit" >&2 + exit 1 + fi + if [ "$channel" != "beta" ]; then + echo "Only beta closed-testing releases are enabled" >&2 + exit 1 + fi + echo "app=$version" >> "$GITHUB_OUTPUT" + echo "android_code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT" + + - name: Refuse an existing GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "GitHub Release $GITHUB_REF_NAME already exists" >&2 + exit 1 + fi + + linux: + name: Linux packages + needs: preflight + uses: ./.github/workflows/linux-packages.yml + + windows: + name: Windows Store package + needs: preflight + uses: ./.github/workflows/windows-store.yml + + macos: + name: Signed and notarized macOS package + needs: preflight + uses: ./.github/workflows/apple-release.yml + secrets: inherit + + android: + name: Signed Android package + needs: preflight + uses: ./.github/workflows/android-release.yml + secrets: inherit + + play-closed-testing: + name: Stage Play closed-testing draft + needs: + - preflight + - linux + - windows + - macos + - android + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: play-closed-testing + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download signed Android artifacts + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-android-release + path: build/release/android + + - name: Validate closed-testing configuration + env: + WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + PLAY_SERVICE_ACCOUNT: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }} + PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }} + PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }} + PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }} + run: | + for name in \ + WORKLOAD_IDENTITY_PROVIDER \ + PLAY_SERVICE_ACCOUNT \ + PLAY_PACKAGE_NAME \ + PLAY_CLOSED_TRACK \ + PLAY_APP_SIGNING_CERT_SHA256; do + if [ -z "${!name:-}" ]; then + echo "Missing Play closed-testing configuration: $name" >&2 + exit 1 + fi + done + case "${PLAY_CLOSED_TRACK,,}" in + production|*:production) + echo "Production Play tracks are forbidden" >&2 + exit 1 + ;; + esac + if [ "$PLAY_PACKAGE_NAME" != "com.vnidrop.app" ]; then + echo "Unexpected Play package name: $PLAY_PACKAGE_NAME" >&2 + exit 1 + fi + + - name: Authenticate to Google with GitHub OIDC + id: google-auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/androidpublisher + + - name: Stage AAB and download Play-signed APK + env: + GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }} + PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }} + PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }} + PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }} + VERSION: ${{ needs.preflight.outputs.version }} + VERSION_CODE: ${{ needs.preflight.outputs.android_code }} + run: | + set -euo pipefail + shopt -s nullglob + bundles=(build/release/android/*.aab) + if [ "${#bundles[@]}" -ne 1 ]; then + echo "Expected exactly one signed AAB" >&2 + exit 1 + fi + mkdir -p build/release/play + python3 packaging/android/publish_play.py \ + --bundle "${bundles[0]}" \ + --package-name "$PLAY_PACKAGE_NAME" \ + --track "$PLAY_CLOSED_TRACK" \ + --version-code "$VERSION_CODE" \ + --release-name "$VERSION" \ + --expected-app-certificate "$PLAY_APP_SIGNING_CERT_SHA256" \ + --apk-output "build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \ + --metadata-output build/release/play/play-release.json + + - name: Set up Android SDK verification tools + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + with: + packages: "platform-tools build-tools;36.0.0" + + - name: Verify Play-signed universal APK + env: + EXPECTED_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }} + VERSION: ${{ needs.preflight.outputs.version }} + VERSION_CODE: ${{ needs.preflight.outputs.android_code }} + run: | + set -euo pipefail + apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" + apksigner_path="$( + find "$ANDROID_SDK_ROOT/build-tools" -type f -name apksigner -perm -111 | + sort -r | + head -1 + )" + apkanalyzer_path="$( + find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 | + sort -r | + head -1 + )" + if [ -z "$apksigner_path" ] || [ -z "$apkanalyzer_path" ]; then + echo "Android SDK verification tools were not found" >&2 + exit 1 + fi + "$apksigner_path" verify --verbose --print-certs "$apk" \ + > build/release/play/apksigner-report.txt + actual="$( + awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' \ + build/release/play/apksigner-report.txt | + tr -d '[:space:]:' | + tr '[:upper:]' '[:lower:]' + )" + expected="$( + printf '%s' "$EXPECTED_CERT_SHA256" | + tr -d '[:space:]:' | + tr '[:upper:]' '[:lower:]' + )" + if [ -z "$actual" ] || [ "$actual" != "$expected" ]; then + echo "Play APK signing certificate mismatch" >&2 + exit 1 + fi + if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then + echo "Play APK package name mismatch" >&2 + exit 1 + fi + if [ "$("$apkanalyzer_path" manifest version-name "$apk")" != "$VERSION" ]; then + echo "Play APK version name mismatch" >&2 + exit 1 + fi + if [ "$("$apkanalyzer_path" manifest version-code "$apk")" != "$VERSION_CODE" ]; then + echo "Play APK version code mismatch" >&2 + exit 1 + fi + rm build/release/play/apksigner-report.txt + ( + cd build/release/play + sha256sum \ + "VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \ + play-release.json \ + > SHA256SUMS + ) + + - name: Upload Play-signed APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-android-play + path: build/release/play/ + if-no-files-found: error + retention-days: 90 + compression-level: 0 + + publish-github: + name: Publish coordinated GitHub Release + needs: + - preflight + - linux + - windows + - macos + - play-closed-testing + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write + id-token: write + attestations: write + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download Debian package + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-linux-deb-x64 + path: build/release/downloads/deb + + - name: Download RPM package + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-linux-rpm-x64 + path: build/release/downloads/rpm + + - name: Download macOS package + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg + path: build/release/downloads/macos + + - name: Download Windows Store package + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64 + path: build/release/downloads/windows + + - name: Download Play-signed APK + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-android-play + path: build/release/downloads/play + + - name: Verify and assemble public release assets + run: packaging/release/assemble-release.sh + + - name: Attest release provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: build/release/final/* + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + build/release/final/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "VniDrop ${{ needs.preflight.outputs.version }}" \ + --generate-notes + + update-homebrew: + name: Update Homebrew cask + needs: + - preflight + - macos + - publish-github + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download macOS package + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg + path: dist + + - name: Render Homebrew cask + env: + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)" + sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \ + -e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \ + packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb + + - name: Push cask to tap + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + git clone \ + "https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" \ + tap + mkdir -p tap/Casks + cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb + cd tap + git config user.name "vnidrop-release-bot" + git config user.email "release-bot@users.noreply.github.com" + git add Casks/vnidrop.rb + git commit -m "vnidrop ${VERSION}" || { + echo "Homebrew cask already matches ${VERSION}" + exit 0 + } + git push diff --git a/.github/workflows/windows-store.yml b/.github/workflows/windows-store.yml index 1e3aa37..8fee7d8 100644 --- a/.github/workflows/windows-store.yml +++ b/.github/workflows/windows-store.yml @@ -19,9 +19,7 @@ on: - "gradle/**" - "gradlew" - "gradlew.bat" - push: - tags: - - "v*.*.*" + workflow_call: workflow_dispatch: permissions: diff --git a/Makefile b/Makefile index 5410501..e33de0e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ 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-version check-localization localization localization-migrate +.PHONY: check-version check-release 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. @@ -66,6 +66,12 @@ check-version: ## Validate the canonical version and its platform mappings. cd $(ROOT) && packaging/version/resolve-version.sh verify cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) +check-release: ## Validate coordinated release scripts and workflow YAML. + cd $(ROOT) && bash -n packaging/android/build-release.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh + cd $(ROOT) && packaging/release/test-assemble-release.sh + cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v + cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml + check-rust: ## Run Rust formatting, lint, tests, and documentation checks. cd $(ROOT) && $(CARGO) fmt --all -- --check cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index b4977e1..e888f74 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() { archive.getEntry(path)?.size?.takeIf { it > 0L } == null } check(missing.isEmpty()) { - "Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}" + "APK has missing or empty VniDrop libraries: ${missing.joinToString()}" } } } @@ -39,6 +39,19 @@ plugins { val appVersion = rootProject.extra["vnidrop.productVersion"] as String val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int +val releaseKeystorePath = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PATH").orNull +val releaseKeystorePassword = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PASSWORD").orNull +val releaseKeyAlias = providers.environmentVariable("VNIDROP_ANDROID_KEY_ALIAS").orNull +val releaseKeyPassword = providers.environmentVariable("VNIDROP_ANDROID_KEY_PASSWORD").orNull +val releaseSigningValues = listOf( + releaseKeystorePath, + releaseKeystorePassword, + releaseKeyAlias, + releaseKeyPassword, +) +require(releaseSigningValues.all { it == null } || releaseSigningValues.all { it != null }) { + "Android release signing requires the keystore path, keystore password, key alias, and key password together" +} kotlin { compilerOptions { @@ -58,6 +71,20 @@ android { namespace = "com.vnidrop.app" compileSdk = libs.versions.android.compileSdk.get().toInt() + signingConfigs { + if (releaseKeystorePath != null) { + create("release") { + val keystoreFile = rootProject.file(releaseKeystorePath) + .also { require(it.isFile) { "Android release keystore was not found" } } + .also { require(it.canRead()) { "Android release keystore is not readable" } } + storeFile = keystoreFile + storePassword = releaseKeystorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + defaultConfig { applicationId = "com.vnidrop.app" minSdk = libs.versions.android.minSdk.get().toInt() @@ -79,6 +106,7 @@ android { buildTypes { getByName("release") { isMinifyEnabled = false + signingConfig = signingConfigs.findByName("release") } } compileOptions { @@ -90,6 +118,10 @@ android { jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug")) jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug")) } + getByName("release") { + jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/release")) + jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/release")) + } } } @@ -100,6 +132,12 @@ tasks.configureEach { ":shared:copyAndroidAndroidX64Debug", ) } + if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") { + dependsOn( + ":shared:copyAndroidAndroidArm64Release", + ":shared:copyAndroidAndroidX64Release", + ) + } } val verifyDebugVnidropLibraries = tasks.register("verifyDebugVnidropLibraries") { diff --git a/packaging/android/README.md b/packaging/android/README.md new file mode 100644 index 0000000..1dbbc73 --- /dev/null +++ b/packaging/android/README.md @@ -0,0 +1,54 @@ +# Android release pipeline + +Android releases use two independent credentials: + +- the upload keystore signs the APK and AAB; +- a short-lived Google access token publishes the AAB through the Play + Developer API. + +The GitHub release workflow expects these encrypted secrets: + +- `ANDROID_UPLOAD_KEYSTORE_BASE64` +- `ANDROID_UPLOAD_KEYSTORE_PASSWORD` +- `ANDROID_UPLOAD_KEY_ALIAS` +- `ANDROID_UPLOAD_KEY_PASSWORD` + +It also expects this repository variable: + +- `ANDROID_UPLOAD_CERT_SHA256` + +The protected `play-closed-testing` GitHub Environment supplies: + +- `PLAY_APP_SIGNING_CERT_SHA256` +- `GCP_WORKLOAD_IDENTITY_PROVIDER` +- `GCP_PLAY_SERVICE_ACCOUNT` +- `PLAY_PACKAGE_NAME` (`com.vnidrop.app`) +- `PLAY_CLOSED_TRACK` (the existing closed-test track identifier) + +The upload and app-signing certificate fingerprints are public identifiers from +Play Console's App signing page. Do not store a private key in a repository +variable. + +`packaging/android/build-release.sh` creates an upload-signed AAB and APK, +verifies their canonical version and upload certificate, and writes checksums. +The release workflow uploads only the AAB to Play. It then downloads the +universal APK generated and signed by Play for the public GitHub Release. + +Play publishing is deliberately restricted to `draft` releases on +`PLAY_CLOSED_TRACK`. Production promotion is not part of this pipeline. + +## One-time setup + +1. In Play Console, link a Google Cloud project and grant the deployment + service account permission to manage releases for VniDrop. +2. In Google Cloud, enable the Google Play Android Developer API and configure + a Workload Identity Federation provider that trusts this repository's + GitHub Actions identity. Permit the service account to receive federated + tokens from that provider. +3. Create the `play-closed-testing` GitHub Environment. Add the five variables + listed above and restrict deployment branches/tags to the release policy. +4. Add the four upload-keystore secrets and + `ANDROID_UPLOAD_CERT_SHA256` in the repository settings. + +No Google service-account JSON key is stored in GitHub. The workflow exchanges +GitHub's OIDC identity for a short-lived Google access token. diff --git a/packaging/android/build-release.sh b/packaging/android/build-release.sh new file mode 100755 index 0000000..51ab908 --- /dev/null +++ b/packaging/android/build-release.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +resolver="$repo_root/packaging/version/resolve-version.sh" +output_dir="$repo_root/build/release/android" +required_apk_libraries=( + "lib/arm64-v8a/libvnidrop.so" + "lib/x86_64/libvnidrop.so" +) +required_aab_libraries=( + "base/lib/arm64-v8a/libvnidrop.so" + "base/lib/x86_64/libvnidrop.so" +) + +require_environment() { + local name=$1 + [[ -n ${!name:-} ]] || { + printf 'Missing required environment variable: %s\n' "$name" >&2 + exit 1 + } +} + +normalize_fingerprint() { + printf '%s' "$1" | tr -d '[:space:]:' | tr '[:upper:]' '[:lower:]' +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +verify_archive_entries() { + local archive=$1 + shift + local entry + local size + for entry in "$@"; do + size="$( + unzip -l "$archive" "$entry" | + awk -v expected="$entry" '$4 == expected {print $1; exit}' + )" + [[ -n $size && $size -gt 0 ]] || { + printf 'Missing or empty Android native library %s in %s\n' \ + "$entry" "$archive" >&2 + exit 1 + } + done +} + +find_apksigner() { + if command -v apksigner >/dev/null 2>&1; then + command -v apksigner + return + fi + local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}} + [[ -n $sdk_root ]] || return 1 + find "$sdk_root/build-tools" -type f -name apksigner -perm -111 2>/dev/null | + sort -r | + head -1 +} + +for name in \ + VNIDROP_ANDROID_KEYSTORE_PATH \ + VNIDROP_ANDROID_KEYSTORE_PASSWORD \ + VNIDROP_ANDROID_KEY_ALIAS \ + VNIDROP_ANDROID_KEY_PASSWORD \ + VNIDROP_ANDROID_UPLOAD_CERT_SHA256; do + require_environment "$name" +done + +[[ -r $VNIDROP_ANDROID_KEYSTORE_PATH ]] || { + printf 'Android upload keystore is not readable: %s\n' "$VNIDROP_ANDROID_KEYSTORE_PATH" >&2 + exit 1 +} + +version="$("$resolver" product)" +version_code="$("$resolver" android-code)" +"$resolver" verify >/dev/null + +cd "$repo_root" +./gradlew \ + :androidApp:check \ + :androidApp:assembleRelease \ + :androidApp:bundleRelease \ + -Pvnidrop.diagnostics.included=false \ + --no-daemon \ + --no-configuration-cache \ + --stacktrace + +source_apk="$repo_root/androidApp/build/outputs/apk/release/androidApp-release.apk" +source_aab="$repo_root/androidApp/build/outputs/bundle/release/androidApp-release.aab" +metadata="$repo_root/androidApp/build/intermediates/merged_manifests/release/processReleaseManifest/output-metadata.json" +[[ -s $source_apk && -s $source_aab && -s $metadata ]] || { + printf 'Android release outputs are missing or empty\n' >&2 + exit 1 +} + +actual_version="$(jq -r '.elements[0].versionName' "$metadata")" +actual_version_code="$(jq -r '.elements[0].versionCode' "$metadata")" +[[ $actual_version == "$version" && $actual_version_code == "$version_code" ]] || { + printf 'Android artifact version mismatch: expected %s (%s), got %s (%s)\n' \ + "$version" "$version_code" "$actual_version" "$actual_version_code" >&2 + exit 1 +} + +jarsigner_report="$(jarsigner -verify "$source_aab" 2>&1)" || { + printf 'AAB signature verification failed:\n%s\n' "$jarsigner_report" >&2 + exit 1 +} +grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || { + printf 'jarsigner did not confirm the AAB signature\n' >&2 + exit 1 +} +verify_archive_entries "$source_apk" "${required_apk_libraries[@]}" +verify_archive_entries "$source_aab" "${required_aab_libraries[@]}" +apksigner_path="$(find_apksigner)" || { + printf 'apksigner was not found in PATH or the Android SDK\n' >&2 + exit 1 +} +signature_report="$("$apksigner_path" verify --verbose --print-certs "$source_apk")" +actual_fingerprint="$( + printf '%s\n' "$signature_report" | + awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' +)" +[[ -n $actual_fingerprint ]] || { + printf 'Could not read the APK signing certificate fingerprint\n' >&2 + exit 1 +} +actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")" +expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")" +[[ $actual_fingerprint == "$expected_fingerprint" ]] || { + printf 'APK signing certificate mismatch: expected %s, got %s\n' \ + "$expected_fingerprint" "$actual_fingerprint" >&2 + exit 1 +} +aab_fingerprint="$( + keytool -printcert -jarfile "$source_aab" | + awk -F': ' '/SHA256:/ {print $2; exit}' +)" +aab_fingerprint="$(normalize_fingerprint "$aab_fingerprint")" +[[ $aab_fingerprint == "$expected_fingerprint" ]] || { + printf 'AAB signing certificate mismatch: expected %s, got %s\n' \ + "$expected_fingerprint" "$aab_fingerprint" >&2 + exit 1 +} + +mkdir -p "$output_dir" +rm -f \ + "$output_dir"/VniDrop-*-upload-signed.apk \ + "$output_dir"/VniDrop-*.aab \ + "$output_dir"/SHA256SUMS +apk_name="VniDrop-${version}-${version_code}-upload-signed.apk" +aab_name="VniDrop-${version}-${version_code}.aab" +cp "$source_apk" "$output_dir/$apk_name" +cp "$source_aab" "$output_dir/$aab_name" + +{ + printf '%s %s\n' "$(sha256_file "$output_dir/$apk_name")" "$apk_name" + printf '%s %s\n' "$(sha256_file "$output_dir/$aab_name")" "$aab_name" +} > "$output_dir/SHA256SUMS" + +printf 'Created signed Android release artifacts:\n' +printf ' %s\n' "$output_dir/$aab_name" +printf ' %s\n' "$output_dir/$apk_name" +printf ' upload certificate SHA-256: %s\n' "$actual_fingerprint" diff --git a/packaging/android/publish_play.py b/packaging/android/publish_play.py new file mode 100755 index 0000000..2d74117 --- /dev/null +++ b/packaging/android/publish_play.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +API_ROOT = "https://androidpublisher.googleapis.com/androidpublisher/v3" +UPLOAD_ROOT = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3" +RETRYABLE_STATUS = {429, 500, 502, 503, 504} + + +class PlayApiError(RuntimeError): + def __init__(self, status: int | None, message: str) -> None: + super().__init__(message) + self.status = status + + +class PlayClient: + def __init__(self, token: str) -> None: + if not token: + raise ValueError("Google Play access token is required") + self.token = token + + def request( + self, + method: str, + url: str, + *, + body: bytes | None = None, + content_type: str | None = None, + timeout: int = 180, + attempts: int = 5, + ) -> bytes: + headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + } + if content_type is not None: + headers["Content-Type"] = content_type + + for attempt in range(1, attempts + 1): + request = urllib.request.Request( + url, + data=body, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + except urllib.error.HTTPError as error: + error_body = error.read().decode("utf-8", errors="replace") + if error.code not in RETRYABLE_STATUS or attempt == attempts: + raise PlayApiError( + error.code, + f"Google Play API returned HTTP {error.code}: {error_body}", + ) from error + except urllib.error.URLError as error: + if attempt == attempts: + raise PlayApiError( + None, + f"Google Play API request failed: {error.reason}", + ) from error + time.sleep(2 ** (attempt - 1)) + raise AssertionError("request retry loop exited unexpectedly") + + def request_json( + self, + method: str, + url: str, + *, + value: Any | None = None, + timeout: int = 180, + ) -> dict[str, Any]: + body = None + content_type = None + if value is not None: + body = json.dumps(value, separators=(",", ":")).encode() + content_type = "application/json" + response = self.request( + method, + url, + body=body, + content_type=content_type, + timeout=timeout, + ) + return json.loads(response) if response else {} + + +def normalize_fingerprint(value: str) -> str: + return "".join(character for character in value.lower() if character.isalnum()) + + +def validate_closed_track(track: str) -> None: + normalized = track.strip().casefold() + if not normalized: + raise ValueError("Play track is required") + if normalized == "production" or normalized.endswith(":production"): + raise ValueError( + "production tracks are forbidden by this closed-testing pipeline" + ) + + +def find_universal_apk( + response: dict[str, Any], + expected_fingerprint: str, +) -> tuple[str, str] | None: + expected = normalize_fingerprint(expected_fingerprint) + for signing_key in response.get("generatedApks", []): + fingerprint = normalize_fingerprint( + str(signing_key.get("certificateSha256Hash", "")) + ) + if fingerprint != expected: + continue + universal = signing_key.get("generatedUniversalApk") or {} + download_id = universal.get("downloadId") + if download_id: + return fingerprint, str(download_id) + return None + + +def build_track_payload( + track: dict[str, Any], + version_code: int, + release_name: str, +) -> dict[str, Any]: + releases = list(track.get("releases") or []) + expected_code = str(version_code) + if any( + expected_code in [str(code) for code in release.get("versionCodes", [])] + for release in releases + ): + raise ValueError( + f"version code {version_code} is already present in track " + f"{track.get('track', '')}" + ) + releases.append( + { + "name": release_name, + "versionCodes": [expected_code], + "status": "draft", + } + ) + return {"track": track["track"], "releases": releases} + + +def find_track_release( + track: dict[str, Any], + version_code: int, +) -> dict[str, Any] | None: + expected_code = str(version_code) + return next( + ( + release + for release in track.get("releases") or [] + if expected_code + in [str(code) for code in release.get("versionCodes", [])] + ), + None, + ) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def generated_apks_url(package_name: str, version_code: int) -> str: + package = urllib.parse.quote(package_name, safe="") + return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}" + + +def get_generated_apks( + client: PlayClient, + package_name: str, + version_code: int, +) -> dict[str, Any] | None: + try: + return client.request_json( + "GET", + generated_apks_url(package_name, version_code), + ) + except PlayApiError as error: + if error.status == 404: + return None + raise + + +def download_universal_apk( + client: PlayClient, + package_name: str, + version_code: int, + expected_fingerprint: str, + output: Path, + *, + attempts: int, + interval_seconds: int, +) -> str: + for attempt in range(1, attempts + 1): + response = get_generated_apks(client, package_name, version_code) + if response is not None: + selected = find_universal_apk(response, expected_fingerprint) + if selected is not None: + fingerprint, download_id = selected + package = urllib.parse.quote(package_name, safe="") + download = urllib.parse.quote(download_id, safe="") + url = ( + f"{API_ROOT}/applications/{package}/generatedApks/" + f"{version_code}/downloads/{download}:download" + ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(client.request("GET", url)) + if output.stat().st_size == 0: + raise RuntimeError("Google Play returned an empty universal APK") + return fingerprint + if attempt < attempts: + time.sleep(interval_seconds) + raise RuntimeError( + "Google Play did not provide a universal APK signed with the expected " + f"certificate after {attempts} attempts" + ) + + +def publish_bundle(args: argparse.Namespace) -> dict[str, Any]: + validate_closed_track(args.track) + if args.version_code < 1: + raise ValueError("version code must be positive") + if not args.bundle.is_file() or args.bundle.stat().st_size == 0: + raise ValueError(f"AAB is missing or empty: {args.bundle}") + + client = PlayClient(args.access_token) + existing = get_generated_apks(client, args.package_name, args.version_code) + if existing is not None: + selected = find_universal_apk(existing, args.expected_app_certificate) + if selected is None: + raise RuntimeError( + "version code already exists in Play, but no universal APK matches " + "the expected app-signing certificate" + ) + package = urllib.parse.quote(args.package_name, safe="") + edit = client.request_json( + "POST", + f"{API_ROOT}/applications/{package}/edits", + value={}, + ) + edit_id = str(edit["id"]) + edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}" + try: + track_id = urllib.parse.quote(args.track, safe="") + track = client.request_json( + "GET", + f"{edit_base}/tracks/{track_id}", + ) + release = find_track_release(track, args.version_code) + if release is None or release.get("status") != "draft": + raise RuntimeError( + "version code already exists in Play but is not a draft on " + f"the configured track {args.track}" + ) + finally: + try: + client.request("DELETE", edit_base, attempts=1) + except PlayApiError: + pass + source = "existing" + else: + package = urllib.parse.quote(args.package_name, safe="") + edit = client.request_json( + "POST", + f"{API_ROOT}/applications/{package}/edits", + value={}, + ) + edit_id = str(edit["id"]) + committed = False + edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}" + try: + upload_url = ( + f"{UPLOAD_ROOT}/applications/{package}/edits/{edit_id}/bundles" + "?uploadType=media" + ) + try: + uploaded = json.loads( + client.request( + "POST", + upload_url, + body=args.bundle.read_bytes(), + content_type="application/octet-stream", + attempts=1, + ) + ) + except PlayApiError: + bundles = client.request_json("GET", f"{edit_base}/bundles") + matches = [ + bundle + for bundle in bundles.get("bundles", []) + if int(bundle.get("versionCode", 0)) == args.version_code + ] + if len(matches) != 1: + raise + uploaded = matches[0] + + uploaded_code = int(uploaded["versionCode"]) + if uploaded_code != args.version_code: + raise RuntimeError( + f"Play accepted version code {uploaded_code}, expected " + f"{args.version_code}" + ) + + track_id = urllib.parse.quote(args.track, safe="") + track_url = f"{edit_base}/tracks/{track_id}" + track = client.request_json("GET", track_url) + payload = build_track_payload(track, args.version_code, args.release_name) + client.request_json("PUT", track_url, value=payload) + commit_url = ( + f"{edit_base}:commit" + "?changesInReviewBehavior=ERROR_IF_IN_REVIEW" + ) + client.request("POST", commit_url, body=b"") + committed = True + source = "uploaded" + finally: + if not committed: + try: + client.request("DELETE", edit_base, attempts=1) + except PlayApiError: + pass + + fingerprint = download_universal_apk( + client, + args.package_name, + args.version_code, + args.expected_app_certificate, + args.apk_output, + attempts=args.poll_attempts, + interval_seconds=args.poll_interval, + ) + return { + "packageName": args.package_name, + "track": args.track, + "releaseStatus": "draft", + "releaseName": args.release_name, + "versionCode": args.version_code, + "bundleSha256": sha256_file(args.bundle), + "universalApk": args.apk_output.name, + "universalApkSha256": sha256_file(args.apk_output), + "appSigningCertificateSha256": fingerprint, + "source": source, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Stage a signed AAB on a closed Play track and download Play's " + "app-signed universal APK" + ) + ) + parser.add_argument( + "--access-token", + default=os.environ.get("GOOGLE_PLAY_ACCESS_TOKEN"), + ) + parser.add_argument("--bundle", type=Path, required=True) + parser.add_argument("--package-name", required=True) + parser.add_argument("--track", required=True) + parser.add_argument("--version-code", type=int, required=True) + parser.add_argument("--release-name", required=True) + parser.add_argument("--expected-app-certificate", required=True) + parser.add_argument("--apk-output", type=Path, required=True) + parser.add_argument("--metadata-output", type=Path, required=True) + parser.add_argument("--poll-attempts", type=int, default=18) + parser.add_argument("--poll-interval", type=int, default=10) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + metadata = publish_bundle(args) + except (KeyError, ValueError, RuntimeError, PlayApiError) as error: + print(f"Play closed-testing publication failed: {error}", file=sys.stderr) + return 1 + args.metadata_output.parent.mkdir(parents=True, exist_ok=True) + args.metadata_output.write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + f"Staged {args.release_name} ({args.version_code}) as a draft on " + f"{args.track}" + ) + print(f"Downloaded Play-signed APK: {args.apk_output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/android/tests/test_publish_play.py b/packaging/android/tests/test_publish_play.py new file mode 100644 index 0000000..a2bc3c2 --- /dev/null +++ b/packaging/android/tests/test_publish_play.py @@ -0,0 +1,100 @@ +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "publish_play.py" +SPEC = importlib.util.spec_from_file_location("publish_play", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +publish_play = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(publish_play) + + +class PublishPlayTests(unittest.TestCase): + def test_rejects_phone_and_form_factor_production_tracks(self): + for track in ("production", "wear:production", " Production "): + with self.subTest(track=track): + with self.assertRaisesRegex(ValueError, "production"): + publish_play.validate_closed_track(track) + + def test_accepts_custom_closed_track(self): + publish_play.validate_closed_track("closed-beta") + + def test_normalizes_certificate_fingerprint(self): + self.assertEqual( + publish_play.normalize_fingerprint("AA:bb 01"), + "aabb01", + ) + + def test_selects_universal_apk_for_expected_signing_key(self): + response = { + "generatedApks": [ + { + "certificateSha256Hash": "11:22", + "generatedUniversalApk": {"downloadId": "wrong"}, + }, + { + "certificateSha256Hash": "AA:BB", + "generatedUniversalApk": {"downloadId": "correct"}, + }, + ] + } + self.assertEqual( + publish_play.find_universal_apk(response, "aa:bb"), + ("aabb", "correct"), + ) + + def test_track_update_preserves_existing_releases_and_adds_draft(self): + track = { + "track": "closed-beta", + "releases": [ + { + "name": "0.1.0", + "versionCodes": ["1"], + "status": "completed", + } + ], + } + updated = publish_play.build_track_payload(track, 2, "0.2.0") + self.assertEqual(updated["releases"][0], track["releases"][0]) + self.assertEqual( + updated["releases"][1], + { + "name": "0.2.0", + "versionCodes": ["2"], + "status": "draft", + }, + ) + + def test_track_update_rejects_duplicate_version_code(self): + track = { + "track": "closed-beta", + "releases": [{"versionCodes": ["2"], "status": "draft"}], + } + with self.assertRaisesRegex(ValueError, "already present"): + publish_play.build_track_payload(track, 2, "0.2.0") + + def test_finds_existing_release_by_version_code(self): + expected = {"versionCodes": ["2"], "status": "draft"} + track = { + "track": "closed-beta", + "releases": [ + {"versionCodes": ["1"], "status": "completed"}, + expected, + ], + } + self.assertIs( + publish_play.find_track_release(track, 2), + expected, + ) + + def test_returns_none_when_version_is_not_on_track(self): + track = { + "track": "closed-beta", + "releases": [{"versionCodes": ["1"], "status": "completed"}], + } + self.assertIsNone(publish_play.find_track_release(track, 2)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 01c788c..a3865f1 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -13,9 +13,10 @@ repository should add repository metadata signing and its own update channel. ## GitHub Actions -The Linux packages workflow runs for relevant pull requests, release tags -matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is -built and validated on its matching distribution family: +The Linux packages workflow runs for relevant pull requests and manual +dispatches. The coordinated release workflow also calls it for a canonical +`vMAJOR.MINOR.PATCH` tag. Each native package is built and validated on its +matching distribution family: - `.deb` on Ubuntu 22.04 for a conservative glibc baseline - `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies @@ -23,9 +24,9 @@ built and validated on its matching distribution family: The shared JVM suite runs inside the Debian build job. Package construction and payload validation happen in both build jobs, so there is no separate test runner. Pull requests build and verify both packages but do not retain -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`. +artifacts. Manual and coordinated-release runs retain build artifacts. The +central release workflow creates the single GitHub Release only after every +platform build and Play closed-testing stage succeeds. The legacy `v1.0.0` tag predates canonical versioning and does not define the current product version. New release tags must match `version.properties`. diff --git a/packaging/release/README.md b/packaging/release/README.md new file mode 100644 index 0000000..d86a4ae --- /dev/null +++ b/packaging/release/README.md @@ -0,0 +1,41 @@ +# Coordinated releases + +Only `.github/workflows/release.yml` responds to version tags. It verifies that +the tag matches `version.properties` and points at the current `master`, then +calls the native platform workflows in parallel. + +The tag workflow runs only when the repository variable +`RELEASE_PIPELINE_ENABLED` is exactly `true`. Leave it unset or set it to +`false` to disable all coordinated releases, including Play uploads, without +disabling release validation on pull requests. + +Platform workflows upload private workflow artifacts. After every native build +passes, the release pipeline: + +1. stages the signed AAB as a draft on the configured Play closed-test track; +2. downloads the universal APK signed by Play; +3. verifies and assembles the public artifacts; +4. generates checksums and GitHub build-provenance attestations; +5. creates exactly one GitHub Release; +6. updates the Homebrew cask. + +Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast, +Play-signed universal APK, checksum file, and release manifest. + +The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain +private workflow artifacts. Partner Center submission stays manual until the +first Microsoft Store release is certified. The Play release remains a draft +on a closed-testing track; this pipeline cannot publish to production. + +To release, first update and merge `version.properties`, including monotonic +Android and Apple build numbers. Then create and push the matching tag: + +```bash +git tag -s v0.2.0 -m "VniDrop 0.2.0" +git push origin v0.2.0 +``` + +The tag must point at the current `origin/master` commit. A failed run creates +no GitHub Release; a rerun safely reuses an already-staged Play draft only when +the version, configured track, draft status, and app-signing certificate all +match. diff --git a/packaging/release/assemble-release.sh b/packaging/release/assemble-release.sh new file mode 100755 index 0000000..dd282d4 --- /dev/null +++ b/packaging/release/assemble-release.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +resolver="$repo_root/packaging/version/resolve-version.sh" +input_dir="${VNIDROP_RELEASE_INPUT_DIR:-$repo_root/build/release/downloads}" +output_dir="${VNIDROP_RELEASE_OUTPUT_DIR:-$repo_root/build/release/final}" +source_commit="${GITHUB_SHA:-local}" +source_tag="${GITHUB_REF_NAME:-v$("$resolver" product)}" + +find_single() { + local directory=$1 + local pattern=$2 + local label=$3 + local matches=() + local match + while IFS= read -r match; do + matches+=("$match") + done < <(find "$directory" -type f -name "$pattern" -print) + [[ ${#matches[@]} == 1 ]] || { + printf 'Expected exactly one %s under %s, found %s\n' \ + "$label" "$directory" "${#matches[@]}" >&2 + exit 1 + } + printf '%s' "${matches[0]}" +} + +file_size() { + if stat -c '%s' "$1" >/dev/null 2>&1; then + stat -c '%s' "$1" + else + stat -f '%z' "$1" + fi +} + +verify_checksum_file() { + local checksum_file=$1 + ( + cd "$(dirname "$checksum_file")" + sha256sum --check "$(basename "$checksum_file")" + ) +} + +version="$("$resolver" product)" +android_code="$("$resolver" android-code)" +apple_build="$("$resolver" apple-build)" +windows_package="$("$resolver" windows-package)" +"$resolver" verify >/dev/null +[[ $source_tag == "v$version" ]] || { + printf 'Release tag %s does not match canonical version v%s\n' \ + "$source_tag" "$version" >&2 + exit 1 +} + +deb="$(find_single "$input_dir/deb" '*.deb' 'Debian package')" +rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')" +dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')" +appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')" +play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')" +play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')" +msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')" +msixupload="$(find_single "$input_dir/windows" '*.msixupload' 'Windows MSIX upload')" +windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Windows build metadata')" + +[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]] +[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]] +[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]] +[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]] +[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]] +[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]] + +deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')" +rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')" +windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')" +play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')" +verify_checksum_file "$deb_checksum" +verify_checksum_file "$rpm_checksum" +verify_checksum_file "$windows_checksums" +verify_checksum_file "$play_checksums" + +[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]] +[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]] +[[ $(jq -r '.versionCode' "$play_metadata") == "$android_code" ]] +play_track="$(jq -r '.track' "$play_metadata")" +normalized_play_track="$(printf '%s' "$play_track" | tr '[:upper:]' '[:lower:]')" +[[ $normalized_play_track != production && $normalized_play_track != *:production ]] +[[ $(jq -r '.appVersion' "$windows_metadata") == "$version" ]] +[[ $(jq -r '.packageVersion' "$windows_metadata") == "$windows_package" ]] +grep -F "VniDrop-${version}.dmg" "$appcast" >/dev/null + +mkdir -p "$output_dir" +[[ -z $(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit) ]] || { + printf 'Release output directory must be empty: %s\n' "$output_dir" >&2 + exit 1 +} +cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/" + +payloads=( + "$output_dir/$(basename "$deb")" + "$output_dir/$(basename "$rpm")" + "$output_dir/$(basename "$dmg")" + "$output_dir/$(basename "$appcast")" + "$output_dir/$(basename "$play_apk")" +) +files_json="$( + for file in "${payloads[@]}"; do + jq -n \ + --arg name "$(basename "$file")" \ + --arg sha256 "$(sha256sum "$file" | awk '{print $1}')" \ + --argjson bytes "$(file_size "$file")" \ + '{name: $name, sha256: $sha256, bytes: $bytes}' + done | jq -s . +)" + +jq -n \ + --arg productVersion "$version" \ + --arg releaseChannel "$("$resolver" channel)" \ + --arg tag "$source_tag" \ + --arg commit "$source_commit" \ + --arg androidVersionCode "$android_code" \ + --arg appleBuildNumber "$apple_build" \ + --arg windowsPackageVersion "$windows_package" \ + --arg windowsMsixUpload "$(basename "$msixupload")" \ + --arg windowsMsixUploadSha256 "$(sha256sum "$msixupload" | awk '{print $1}')" \ + --arg playTrack "$play_track" \ + --arg playBundleSha256 "$(jq -r '.bundleSha256' "$play_metadata")" \ + --arg playCertificateSha256 "$(jq -r '.appSigningCertificateSha256' "$play_metadata")" \ + --argjson files "$files_json" \ + '{ + productVersion: $productVersion, + releaseChannel: $releaseChannel, + tag: $tag, + sourceCommit: $commit, + platformVersions: { + androidVersionCode: ($androidVersionCode | tonumber), + appleBuildNumber: $appleBuildNumber, + windowsPackageVersion: $windowsPackageVersion + }, + play: { + track: $playTrack, + status: "draft", + bundleSha256: $playBundleSha256, + appSigningCertificateSha256: $playCertificateSha256 + }, + windowsStore: { + publicReleaseAsset: false, + msixUpload: $windowsMsixUpload, + sha256: $windowsMsixUploadSha256 + }, + files: $files + }' > "$output_dir/release-manifest.json" + +( + cd "$output_dir" + sha256sum \ + "$(basename "$deb")" \ + "$(basename "$rpm")" \ + "$(basename "$dmg")" \ + "$(basename "$appcast")" \ + "$(basename "$play_apk")" \ + release-manifest.json \ + > SHA256SUMS +) + +printf 'Assembled public release assets in %s\n' "$output_dir" +printf 'Windows Store submission retained as workflow artifact: %s\n' \ + "$(basename "$msixupload")" diff --git a/packaging/release/test-assemble-release.sh b/packaging/release/test-assemble-release.sh new file mode 100755 index 0000000..b434297 --- /dev/null +++ b/packaging/release/test-assemble-release.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-release-test.XXXXXX")" +trap 'rm -rf "$fixture_root"' EXIT + +input_dir="$fixture_root/input" +output_dir="$fixture_root/output" +mkdir -p \ + "$input_dir/deb" \ + "$input_dir/rpm" \ + "$input_dir/macos" \ + "$input_dir/play" \ + "$input_dir/windows" + +version="$("$repo_root/packaging/version/resolve-version.sh" product)" +android_code="$("$repo_root/packaging/version/resolve-version.sh" android-code)" +windows_package="$("$repo_root/packaging/version/resolve-version.sh" windows-package)" + +printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb" +printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm" +printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg" +printf 'VniDrop-%s.dmg\n' "$version" > "$input_dir/macos/appcast.xml" +printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk" +printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix" +printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload" + +jq -n \ + --arg releaseName "$version" \ + --argjson versionCode "$android_code" \ + '{ + releaseStatus: "draft", + releaseName: $releaseName, + versionCode: $versionCode, + track: "closed-beta", + bundleSha256: "bundle-sha", + appSigningCertificateSha256: "certificate-sha" + }' > "$input_dir/play/play-release.json" + +jq -n \ + --arg appVersion "$version" \ + --arg packageVersion "$windows_package" \ + '{appVersion: $appVersion, packageVersion: $packageVersion}' \ + > "$input_dir/windows/VniDrop_${version}_x64.build-info.json" + +( + cd "$input_dir/deb" + sha256sum "vnidrop_${version}-1_amd64.deb" \ + > "vnidrop_${version}-1_amd64.deb.sha256" +) +( + cd "$input_dir/rpm" + sha256sum "vnidrop-${version}-1.x86_64.rpm" \ + > "vnidrop-${version}-1.x86_64.rpm.sha256" +) +( + cd "$input_dir/play" + sha256sum \ + "VniDrop-${version}-${android_code}-play-universal.apk" \ + play-release.json \ + > SHA256SUMS +) +( + cd "$input_dir/windows" + sha256sum \ + "VniDrop_${version}_x64.msix" \ + "VniDrop_${version}_x64.msixupload" \ + "VniDrop_${version}_x64.build-info.json" \ + > SHA256SUMS +) + +GITHUB_REF_NAME="v$version" \ + GITHUB_SHA=fixture-commit \ + VNIDROP_RELEASE_INPUT_DIR="$input_dir" \ + VNIDROP_RELEASE_OUTPUT_DIR="$output_dir" \ + "$script_dir/assemble-release.sh" >/dev/null + +expected_public_files=( + "SHA256SUMS" + "VniDrop-${version}-${android_code}-play-universal.apk" + "VniDrop-${version}.dmg" + "appcast.xml" + "release-manifest.json" + "vnidrop-${version}-1.x86_64.rpm" + "vnidrop_${version}-1_amd64.deb" +) +actual_public_files=() +while IFS= read -r file; do + actual_public_files+=("$(basename "$file")") +done < <(find "$output_dir" -maxdepth 1 -type f -print | sort) +[[ ${actual_public_files[*]} == "${expected_public_files[*]}" ]] + +[[ $(jq -r '.productVersion' "$output_dir/release-manifest.json") == "$version" ]] +[[ $(jq -r '.play.status' "$output_dir/release-manifest.json") == draft ]] +[[ $(jq -r '.windowsStore.publicReleaseAsset' "$output_dir/release-manifest.json") == false ]] +( + cd "$output_dir" + sha256sum --check SHA256SUMS >/dev/null +) diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 972163a..c3b5c7a 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -32,9 +32,9 @@ package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch ## GitHub Actions The Windows Store package workflow runs automatically for relevant pull -requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch. -Pull requests build and validate without retaining an artifact. Tags and manual -runs retain: +requests and by manual dispatch. The coordinated release workflow also calls +it for a canonical `vMAJOR.MINOR.PATCH` tag. Pull requests build and validate +without retaining an artifact. Manual and coordinated-release runs retain: - VniDrop_VERSION_x64.msix - VniDrop_VERSION_x64.msixupload @@ -56,7 +56,8 @@ MakeAppx unpacks the finished package. Microsoft's current GitHub Actions publishing flow is for updates to an already-live free product. For the first release: -1. Run this workflow from a release tag or by manual dispatch. +1. Push a canonical release tag to run the coordinated workflow, or run the + Windows package workflow manually. 2. Download the retained artifact. 3. Test that exact build on an interactive Windows VM. Local installation needs an ephemeral development signature trusted only by that VM; this is not a From c6655da7db297fb9994cc2ea59b3518982b08516 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 28 Jul 2026 11:27:45 +0200 Subject: [PATCH 2/2] refactor(version): derive Apple build numbers --- .github/workflows/apple-release.yml | 1 + .gitignore | 1 + Makefile | 7 ++- apple/README.md | 6 +++ apple/Signing.xcconfig | 3 -- apple/project.yml | 17 ++++--- apple/scripts/build-dmg.sh | 29 +++++++++++- build.gradle.kts | 6 --- packaging/release/README.md | 5 +- packaging/release/assemble-release.sh | 14 ++++-- packaging/release/test-assemble-release.sh | 14 ++++++ packaging/version/README.md | 32 +++++++++---- packaging/version/generate-apple-xcconfig.sh | 48 +++++++++++++++++++ packaging/version/resolve-version.ps1 | 38 +++++++++++---- packaging/version/resolve-version.sh | 49 +++++++++++++++++--- packaging/version/test-version.sh | 32 +++++++++---- version.properties | 1 - 17 files changed, 248 insertions(+), 55 deletions(-) create mode 100755 packaging/version/generate-apple-xcconfig.sh diff --git a/.github/workflows/apple-release.yml b/.github/workflows/apple-release.yml index e4de19e..da32c60 100644 --- a/.github/workflows/apple-release.yml +++ b/.github/workflows/apple-release.yml @@ -145,6 +145,7 @@ jobs: name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg path: | apple/dist/VniDrop-*.dmg + apple/dist/VniDrop-*.build-info.json apple/dist/appcast.xml if-no-files-found: error retention-days: 14 diff --git a/.gitignore b/.gitignore index 5b43b44..048c9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ config.override.mk output/ .screenshots apple/RELEASE-MACOS.md +apple/Generated/*.xcconfig diff --git a/Makefile b/Makefile index e33de0e..8928169 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk .PHONY: format test check check-rust audit-rust test-rust test-rust-all .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: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple .PHONY: check-version check-release check-localization localization localization-migrate .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics @@ -124,7 +124,10 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings. @test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; } cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE) -apple-project: apple-core localization ## Generate the native Apple Xcode project. +apple-version-config: ## Generate derived Store and Direct Apple build settings. + cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all + +apple-project: apple-core localization apple-version-config ## Generate the native Apple Xcode project. cd $(ROOT)/apple && $(XCODEGEN) generate open-apple-project: apple-project ## Generate and open the native Apple Xcode project. diff --git a/apple/README.md b/apple/README.md index d7b8b0c..008ece4 100644 --- a/apple/README.md +++ b/apple/README.md @@ -40,6 +40,12 @@ make build-apple-ios # unsigned iOS simulator app make check-apple # iOS simulator tests ``` +`make apple-project` also generates ignored Store and Direct version xcconfig +files. Their `CURRENT_PROJECT_VERSION` values come from the central version +resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project +before creating another App Store archive so it receives a fresh build number; +direct DMG builds refresh their own value automatically. + ### macOS shipping channels The macOS app ships through two targets that build identical sources: diff --git a/apple/Signing.xcconfig b/apple/Signing.xcconfig index 396ad11..da695ba 100644 --- a/apple/Signing.xcconfig +++ b/apple/Signing.xcconfig @@ -1,6 +1,3 @@ -// 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/project.yml b/apple/project.yml index cfa2d8c..e883004 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1,9 +1,11 @@ # XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS). # Regenerate the project with: xcodegen generate (run from apple/) -# Requires two generated inputs first (both gitignored), before xcodegen: +# Requires three generated inputs first (all gitignored), before xcodegen: # - Rust core: apple/scripts/build-core.sh debug # - Localization: (cd localization && bun run src/cli.ts generate) # -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift +# - Versions: packaging/version/generate-apple-xcconfig.sh all +# -> Generated/StoreVersion.xcconfig, Generated/DirectVersion.xcconfig name: VniDrop options: bundleIdPrefix: com.vnidrop @@ -50,10 +52,6 @@ packages: targetTemplates: AppBase: type: application - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig - Release-Direct: Signing.xcconfig sources: - path: VniDrop excludes: @@ -66,7 +64,6 @@ targetTemplates: PRODUCT_NAME: VniDrop PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app 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 @@ -114,6 +111,10 @@ targets: VniDrop: templates: [AppBase] supportedDestinations: [iOS, macOS] + configFiles: + Debug: Generated/StoreVersion.xcconfig + Release: Generated/StoreVersion.xcconfig + Release-Direct: Generated/StoreVersion.xcconfig # Direct-download macOS target: Developer ID signed, notarized, ships in a .dmg # and self-updates via Sparkle. DIRECT_DISTRIBUTION gates all Sparkle code so the @@ -121,6 +122,10 @@ targets: VniDropDirect: templates: [AppBase] supportedDestinations: [macOS] + configFiles: + Debug: Generated/DirectVersion.xcconfig + Release: Generated/DirectVersion.xcconfig + Release-Direct: Generated/DirectVersion.xcconfig settings: base: SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) DIRECT_DISTRIBUTION" diff --git a/apple/scripts/build-dmg.sh b/apple/scripts/build-dmg.sh index 8ee8af5..bb0f6a5 100755 --- a/apple/scripts/build-dmg.sh +++ b/apple/scripts/build-dmg.sh @@ -29,10 +29,13 @@ SCHEME="VniDropDirect" CONFIG="Release-Direct" APP_NAME="VniDrop" VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh" +VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh" VERSION="$("$VERSION_RESOLVER" product)" -BUILD_NUMBER="$("$VERSION_RESOLVER" apple-build)" +export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}" +BUILD_NUMBER="$("$VERSION_RESOLVER" apple-direct-build)" "$VERSION_RESOLVER" verify >/dev/null +BUILD_METADATA="$DIST_DIR/$APP_NAME-$VERSION.build-info.json" # --- Resolve signing identity ------------------------------------------------ if [ -z "${DEVELOPER_ID_APP:-}" ]; then @@ -57,6 +60,7 @@ echo " team: ${DEVELOPMENT_TEAM:-}" echo "==> Building Rust core (release)" CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release echo "==> Regenerating Xcode project" +"$VERSION_CONFIG_GENERATOR" all ( cd "$APPLE_DIR" && xcodegen generate >/dev/null ) rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR" @@ -88,6 +92,18 @@ xcodebuild -exportArchive \ -exportOptionsPlist "$EXPORT_OPTS" APP="$EXPORT_DIR/$APP_NAME.app" [ -d "$APP" ] || { echo "error: export failed" >&2; exit 1; } +ACTUAL_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$APP/Contents/Info.plist")" +ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \ + "$APP/Contents/Info.plist")" +[ "$ACTUAL_VERSION" = "$VERSION" ] || { + echo "error: exported app version $ACTUAL_VERSION does not match $VERSION" >&2 + exit 1 +} +[ "$ACTUAL_BUILD" = "$BUILD_NUMBER" ] || { + echo "error: exported app build $ACTUAL_BUILD does not match $BUILD_NUMBER" >&2 + exit 1 +} # --- Build the DMG ----------------------------------------------------------- DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg" @@ -133,7 +149,18 @@ else fi SIZE="$(stat -f%z "$DMG")" +jq -n \ + --arg productVersion "$VERSION" \ + --arg directBuildNumber "$BUILD_NUMBER" \ + --arg artifact "$(basename "$DMG")" \ + '{ + productVersion: $productVersion, + directBuildNumber: $directBuildNumber, + distribution: "direct", + artifact: $artifact + }' > "$BUILD_METADATA" echo "==> Done." echo " dmg: $DMG" echo " version: $VERSION" +echo " build: $BUILD_NUMBER" echo " size: $SIZE bytes" diff --git a/build.gradle.kts b/build.gradle.kts index db5d558..ca0887e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,10 +54,6 @@ val androidVersionCode = canonicalInteger( 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"), @@ -73,7 +69,6 @@ val windowsPackageVersion = extra["vnidrop.productVersion"] = productVersion extra["vnidrop.releaseChannel"] = releaseChannel extra["vnidrop.androidVersionCode"] = androidVersionCode -extra["vnidrop.appleBuildNumber"] = appleBuildNumber extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion tasks.register("verifyVersion") { @@ -83,6 +78,5 @@ tasks.register("verifyVersion") { inputs.property("productVersion", productVersion) inputs.property("releaseChannel", releaseChannel) inputs.property("androidVersionCode", androidVersionCode) - inputs.property("appleBuildNumber", appleBuildNumber) inputs.property("windowsPackageVersion", windowsPackageVersion) } diff --git a/packaging/release/README.md b/packaging/release/README.md index d86a4ae..a999e03 100644 --- a/packaging/release/README.md +++ b/packaging/release/README.md @@ -27,8 +27,9 @@ private workflow artifacts. Partner Center submission stays manual until the first Microsoft Store release is certified. The Play release remains a draft on a closed-testing track; this pipeline cannot publish to production. -To release, first update and merge `version.properties`, including monotonic -Android and Apple build numbers. Then create and push the matching tag: +To release, first update and merge `version.properties`, including a monotonic +Android version code. Apple Store and Direct build numbers are derived +independently at build time. Then create and push the matching tag: ```bash git tag -s v0.2.0 -m "VniDrop 0.2.0" diff --git a/packaging/release/assemble-release.sh b/packaging/release/assemble-release.sh index dd282d4..398025e 100755 --- a/packaging/release/assemble-release.sh +++ b/packaging/release/assemble-release.sh @@ -45,7 +45,6 @@ verify_checksum_file() { version="$("$resolver" product)" android_code="$("$resolver" android-code)" -apple_build="$("$resolver" apple-build)" windows_package="$("$resolver" windows-package)" "$resolver" verify >/dev/null [[ $source_tag == "v$version" ]] || { @@ -58,6 +57,7 @@ deb="$(find_single "$input_dir/deb" '*.deb' 'Debian package')" rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')" dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')" appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')" +apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')" play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')" play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')" msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')" @@ -70,6 +70,14 @@ windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Window [[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]] [[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]] [[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]] +[[ $(jq -r '.productVersion' "$apple_metadata") == "$version" ]] +[[ $(jq -r '.distribution' "$apple_metadata") == direct ]] +[[ $(jq -r '.artifact' "$apple_metadata") == "$(basename "$dmg")" ]] +apple_direct_build="$(jq -r '.directBuildNumber' "$apple_metadata")" +[[ $apple_direct_build =~ ^[1-9][0-9]*(\.[0-9]+){0,2}$ ]] || { + printf 'Invalid direct Apple build number: %s\n' "$apple_direct_build" >&2 + exit 1 +} deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')" rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')" @@ -120,7 +128,7 @@ jq -n \ --arg tag "$source_tag" \ --arg commit "$source_commit" \ --arg androidVersionCode "$android_code" \ - --arg appleBuildNumber "$apple_build" \ + --arg appleDirectBuildNumber "$apple_direct_build" \ --arg windowsPackageVersion "$windows_package" \ --arg windowsMsixUpload "$(basename "$msixupload")" \ --arg windowsMsixUploadSha256 "$(sha256sum "$msixupload" | awk '{print $1}')" \ @@ -135,7 +143,7 @@ jq -n \ sourceCommit: $commit, platformVersions: { androidVersionCode: ($androidVersionCode | tonumber), - appleBuildNumber: $appleBuildNumber, + appleDirectBuildNumber: $appleDirectBuildNumber, windowsPackageVersion: $windowsPackageVersion }, play: { diff --git a/packaging/release/test-assemble-release.sh b/packaging/release/test-assemble-release.sh index b434297..578191a 100755 --- a/packaging/release/test-assemble-release.sh +++ b/packaging/release/test-assemble-release.sh @@ -19,6 +19,7 @@ mkdir -p \ version="$("$repo_root/packaging/version/resolve-version.sh" product)" android_code="$("$repo_root/packaging/version/resolve-version.sh" android-code)" windows_package="$("$repo_root/packaging/version/resolve-version.sh" windows-package)" +apple_direct_build=20260728.1432.17 printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb" printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm" @@ -28,6 +29,17 @@ printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-univer printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix" printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload" +jq -n \ + --arg productVersion "$version" \ + --arg directBuildNumber "$apple_direct_build" \ + --arg artifact "VniDrop-${version}.dmg" \ + '{ + productVersion: $productVersion, + directBuildNumber: $directBuildNumber, + distribution: "direct", + artifact: $artifact + }' > "$input_dir/macos/VniDrop-${version}.build-info.json" + jq -n \ --arg releaseName "$version" \ --argjson versionCode "$android_code" \ @@ -94,6 +106,8 @@ done < <(find "$output_dir" -maxdepth 1 -type f -print | sort) [[ ${actual_public_files[*]} == "${expected_public_files[*]}" ]] [[ $(jq -r '.productVersion' "$output_dir/release-manifest.json") == "$version" ]] +[[ $(jq -r '.platformVersions.appleDirectBuildNumber' \ + "$output_dir/release-manifest.json") == "$apple_direct_build" ]] [[ $(jq -r '.play.status' "$output_dir/release-manifest.json") == draft ]] [[ $(jq -r '.windowsStore.publicReleaseAsset' "$output_dir/release-manifest.json") == false ]] ( diff --git a/packaging/version/README.md b/packaging/version/README.md index 14af240..27ec40a 100644 --- a/packaging/version/README.md +++ b/packaging/version/README.md @@ -1,11 +1,12 @@ # 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. +the VniDrop product version and persistent store counters. Platform projects +and release workflows use the version resolver 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. +PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files. 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 @@ -15,8 +16,9 @@ the patch component (`0.2.1`). Release channels belong in | 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 | +| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` | +| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` | +| Linux | `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 | @@ -30,9 +32,23 @@ the Store. Its version is: 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. +Every Android upload must increment `ANDROID_VERSION_CODE`. Apple build numbers +are derived at build time by `apple-store-build` and `apple-direct-build`; they +are kept as separate resolver outputs so App Store and Sparkle releases do not +consume each other's cadence. Every changed Windows Store package must +increment the product version because the Store-reserved fourth component +cannot carry a rebuild number. + +Apple projects read generated build settings rather than `version.properties` +directly: + +```bash +packaging/version/generate-apple-xcconfig.sh all +``` + +The generated files under `apple/Generated/` are intentionally ignored. +`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for +tests; distribution builds normally use the current UTC time. Before releasing: diff --git a/packaging/version/generate-apple-xcconfig.sh b/packaging/version/generate-apple-xcconfig.sh new file mode 100755 index 0000000..c1bd45c --- /dev/null +++ b/packaging/version/generate-apple-xcconfig.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +resolver="$script_dir/resolve-version.sh" +output_dir="${VNIDROP_APPLE_XCCONFIG_DIR:-$repo_root/apple/Generated}" +mode="${1:-all}" + +case "$mode" in + store|direct|all) ;; + *) + printf 'Usage: %s {store|direct|all}\n' "$0" >&2 + exit 1 + ;; +esac + +export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}" +product_version="$("$resolver" product)" +mkdir -p "$output_dir" + +write_config() { + local filename=$1 + local build_field=$2 + local destination="$output_dir/$filename" + local temporary + local build_number + + build_number="$("$resolver" "$build_field")" + temporary="$(mktemp "$output_dir/.${filename}.XXXXXX")" + printf '%s\n' \ + '// Generated by packaging/version/generate-apple-xcconfig.sh.' \ + '// Regenerate this file instead of editing it.' \ + '#include "../Signing.xcconfig"' \ + '' \ + "PRODUCT_VERSION = $product_version" \ + "CURRENT_PROJECT_VERSION = $build_number" \ + > "$temporary" + mv "$temporary" "$destination" +} + +if [[ $mode == store || $mode == all ]]; then + write_config StoreVersion.xcconfig apple-store-build +fi +if [[ $mode == direct || $mode == all ]]; then + write_config DirectVersion.xcconfig apple-direct-build +fi diff --git a/packaging/version/resolve-version.ps1 b/packaging/version/resolve-version.ps1 index 64be60c..02d4467 100644 --- a/packaging/version/resolve-version.ps1 +++ b/packaging/version/resolve-version.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet("Product", "Channel", "AndroidCode", "AppleBuild", "WindowsPackage", "Json", "Verify")] + [ValidateSet("Product", "Channel", "AndroidCode", "AppleStoreBuild", "AppleDirectBuild", "WindowsPackage", "Json", "Verify")] [string] $Field = "Verify", [switch] $VerifyTag, @@ -48,8 +48,31 @@ function Convert-CanonicalInteger { $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" +$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC +if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) { + $buildTimeUtc = [DateTime]::UtcNow.ToString( + "yyyyMMddHHmmss", + [Globalization.CultureInfo]::InvariantCulture + ) +} +if ($buildTimeUtc -notmatch "^[0-9]{14}$") { + throw "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS" +} +$parsedBuildTime = [DateTime]::MinValue +if (-not [DateTime]::TryParseExact( + $buildTimeUtc, + "yyyyMMddHHmmss", + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal, + [ref] $parsedBuildTime + )) { + throw "VNIDROP_BUILD_TIME_UTC is not a valid UTC timestamp" +} +$appleBuildNumber = $parsedBuildTime.ToString( + "yyyyMMdd.HHmm.ss", + [Globalization.CultureInfo]::InvariantCulture +) 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" @@ -62,9 +85,6 @@ 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) { @@ -80,7 +100,8 @@ $versionInfo = [ordered] @{ productVersion = $productVersion releaseChannel = $releaseChannel androidVersionCode = $androidVersionCode - appleBuildNumber = $appleBuildNumber + appleStoreBuildNumber = $appleBuildNumber + appleDirectBuildNumber = $appleBuildNumber windowsPackageVersion = $windowsPackageVersion } @@ -88,10 +109,11 @@ switch ($Field) { "Product" { $productVersion } "Channel" { $releaseChannel } "AndroidCode" { $androidVersionCode } - "AppleBuild" { $appleBuildNumber } + "AppleStoreBuild" { $appleBuildNumber } + "AppleDirectBuild" { $appleBuildNumber } "WindowsPackage" { $windowsPackageVersion } "Json" { $versionInfo | ConvertTo-Json -Compress } "Verify" { - "VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple $appleBuildNumber, MSIX $windowsPackageVersion" + "VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple Store $appleBuildNumber, Apple Direct $appleBuildNumber, MSIX $windowsPackageVersion" } } diff --git a/packaging/version/resolve-version.sh b/packaging/version/resolve-version.sh index 1d7b191..589381f 100755 --- a/packaging/version/resolve-version.sh +++ b/packaging/version/resolve-version.sh @@ -35,8 +35,8 @@ validate_canonical_integer() { 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)" +build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}" [[ $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" @@ -47,9 +47,41 @@ 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 +[[ $build_time_utc =~ ^[0-9]{14}$ ]] || + fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS" + +build_month="${build_time_utc:4:2}" +build_day="${build_time_utc:6:2}" +build_hour="${build_time_utc:8:2}" +build_minute="${build_time_utc:10:2}" +build_second="${build_time_utc:12:2}" +build_year="${build_time_utc:0:4}" +(( 10#$build_year >= 1 )) || + fail "VNIDROP_BUILD_TIME_UTC contains an invalid year" +(( 10#$build_month >= 1 && 10#$build_month <= 12 )) || + fail "VNIDROP_BUILD_TIME_UTC contains an invalid month" + +case $((10#$build_month)) in + 2) + max_build_day=28 + if (( 10#$build_year % 400 == 0 || + (10#$build_year % 4 == 0 && 10#$build_year % 100 != 0) )); then + max_build_day=29 + fi + ;; + 4|6|9|11) + max_build_day=30 + ;; + *) + max_build_day=31 + ;; +esac +(( 10#$build_day >= 1 && 10#$build_day <= max_build_day )) || + fail "VNIDROP_BUILD_TIME_UTC contains an invalid day" +(( 10#$build_hour <= 23 && 10#$build_minute <= 59 && 10#$build_second <= 59 )) || + fail "VNIDROP_BUILD_TIME_UTC contains an invalid time" +apple_build_number="${build_time_utc:0:8}.${build_time_utc:8:4}.${build_time_utc:12:2}" windows_major=$((10#$product_major + 10#$windows_version_epoch)) (( windows_major <= 65535 )) || @@ -74,7 +106,10 @@ case "${1:-verify}" in android-code) printf '%s\n' "$android_version_code" ;; - apple-build) + apple-store-build) + printf '%s\n' "$apple_build_number" + ;; + apple-direct-build) printf '%s\n' "$apple_build_number" ;; windows-package) @@ -82,14 +117,14 @@ case "${1:-verify}" in ;; verify) verify_tag - printf 'VniDrop %s (%s), Android %s, Apple %s, MSIX %s\n' \ + printf 'VniDrop %s (%s), Android %s, Apple Store %s, Apple Direct %s, MSIX %s\n' \ "$product_version" "$release_channel" "$android_version_code" \ - "$apple_build_number" "$windows_package_version" + "$apple_build_number" "$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]}" + fail "Usage: $0 {product|channel|android-code|apple-store-build|apple-direct-build|windows-package|verify|verify-tag [tag]}" ;; esac diff --git a/packaging/version/test-version.sh b/packaging/version/test-version.sh index 8eacbb3..e883473 100755 --- a/packaging/version/test-version.sh +++ b/packaging/version/test-version.sh @@ -12,8 +12,7 @@ write_version() { "PRODUCT_VERSION=$1" \ "RELEASE_CHANNEL=$2" \ "ANDROID_VERSION_CODE=$3" \ - "APPLE_BUILD_NUMBER=$4" \ - "WINDOWS_VERSION_EPOCH=$5" \ + "WINDOWS_VERSION_EPOCH=$4" \ > "$scratch/version.properties" } @@ -28,24 +27,41 @@ expect_failure() { fi } -write_version 0.2.0 beta 2 2 1 +export VNIDROP_BUILD_TIME_UTC=20260728143217 + +write_version 0.2.0 beta 2 1 [[ $(resolve product) == 0.2.0 ]] [[ $(resolve android-code) == 2 ]] -[[ $(resolve apple-build) == 2 ]] +[[ $(resolve apple-store-build) == 20260728.1432.17 ]] +[[ $(resolve apple-direct-build) == 20260728.1432.17 ]] [[ $(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 +write_version 1.0.0 stable 42 1 [[ $(resolve windows-package) == 2.0.0.0 ]] -write_version 01.0.0 beta 2 2 1 +write_version 01.0.0 beta 2 1 expect_failure resolve verify -write_version 0.2.0 beta 0 2 1 +write_version 0.2.0 beta 0 1 expect_failure resolve verify -write_version 65535.0.0 stable 2 2 1 +write_version 65535.0.0 stable 2 1 expect_failure resolve verify +VNIDROP_BUILD_TIME_UTC=20260728146000 expect_failure resolve verify +VNIDROP_BUILD_TIME_UTC=2026-07-28 expect_failure resolve verify +VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify + +write_version 0.2.0 beta 2 1 +config_dir="$scratch/xcconfig" +VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \ + "$script_dir/generate-apple-xcconfig.sh" all +grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null +grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \ + "$config_dir/StoreVersion.xcconfig" >/dev/null +grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \ + "$config_dir/DirectVersion.xcconfig" >/dev/null + printf 'Version resolver tests passed.\n' diff --git a/version.properties b/version.properties index ee10ff4..57070b6 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,4 @@ PRODUCT_VERSION=0.2.0 RELEASE_CHANNEL=beta ANDROID_VERSION_CODE=2 -APPLE_BUILD_NUMBER=2 WINDOWS_VERSION_EPOCH=1