mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Compare commits
7 Commits
feat/relea
...
6d908d8dc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d908d8dc3 | ||
| c6655da7db | |||
| 2d7982bbb9 | |||
|
|
52d4102308 | ||
| 407a0d2d60 | |||
|
|
fc1d27bf45 | ||
| 31ba3f40b2 |
141
.github/workflows/android-release.yml
vendored
Normal file
141
.github/workflows/android-release.yml
vendored
Normal file
@@ -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"
|
||||
107
.github/workflows/apple-release.yml
vendored
107
.github/workflows/apple-release.yml
vendored
@@ -1,27 +1,20 @@
|
||||
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-<version>.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 a tag vMAJOR.MINOR.PATCH (must point at a commit on master), or
|
||||
# run manually with an explicit version (produces artifacts, no Release).
|
||||
# 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:
|
||||
inputs:
|
||||
version:
|
||||
description: Release version in MAJOR.MINOR.PATCH form
|
||||
required: true
|
||||
default: "0.1.0"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -39,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:
|
||||
@@ -58,17 +49,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '' }}
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
version="$REQUESTED_VERSION"
|
||||
fi
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "bad version '$version'" >&2; exit 1; }
|
||||
packaging/version/resolve-version.sh verify >/dev/null
|
||||
version="$(packaging/version/resolve-version.sh product)"
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select Xcode
|
||||
@@ -94,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.
|
||||
@@ -147,12 +132,12 @@ jobs:
|
||||
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build, sign & notarize DMG
|
||||
run: apple/scripts/build-dmg.sh "${{ steps.version.outputs.app }}"
|
||||
run: apple/scripts/build-dmg.sh
|
||||
|
||||
- name: Generate appcast
|
||||
env:
|
||||
RELEASE_REPO: ${{ github.repository }}
|
||||
run: apple/scripts/generate-appcast.sh "${{ steps.version.outputs.app }}"
|
||||
run: apple/scripts/generate-appcast.sh
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -160,71 +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
|
||||
|
||||
- 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
|
||||
|
||||
4
.github/workflows/apple.yml
vendored
4
.github/workflows/apple.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
@@ -18,6 +20,8 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- "apple/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "crates/uniffi-bindgen/**"
|
||||
- "Cargo.toml"
|
||||
|
||||
99
.github/workflows/linux-packages.yml
vendored
99
.github/workflows/linux-packages.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/linux-packages.yml"
|
||||
- "packaging/linux/**"
|
||||
- "packaging/version/**"
|
||||
- "version.properties"
|
||||
- "assets/linux/**"
|
||||
- "desktopApp/**"
|
||||
- "shared/**"
|
||||
@@ -20,16 +22,8 @@ on:
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Release version in MAJOR.MINOR.PATCH form
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -88,16 +82,14 @@ jobs:
|
||||
restore-keys: |
|
||||
linux-deb-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||
version=$(packaging/linux/resolve-version.sh)
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test and build Debian package
|
||||
run: make package-deb VERSION=${{ steps.version.outputs.app }}
|
||||
run: make package-deb
|
||||
|
||||
- name: Upload Debian artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -193,16 +185,14 @@ jobs:
|
||||
restore-keys: |
|
||||
linux-rpm-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
version=$(packaging/linux/resolve-version.sh "$REQUESTED_VERSION")
|
||||
version=$(packaging/linux/resolve-version.sh)
|
||||
echo "app=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RPM package
|
||||
run: make package-rpm VERSION=${{ steps.version.outputs.app }}
|
||||
run: make package-rpm
|
||||
|
||||
- name: Upload RPM artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -220,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
|
||||
|
||||
53
.github/workflows/release-checks.yml
vendored
Normal file
53
.github/workflows/release-checks.yml
vendored
Normal file
@@ -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
|
||||
369
.github/workflows/release.yml
vendored
Normal file
369
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
4
.github/workflows/rust-core.yml
vendored
4
.github/workflows/rust-core.yml
vendored
@@ -6,6 +6,8 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
@@ -19,6 +21,8 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/vnidrop/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "Makefile"
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
|
||||
4
.github/workflows/shared-kmp.yml
vendored
4
.github/workflows/shared-kmp.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "shared/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
@@ -24,6 +26,8 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- "shared/**"
|
||||
- "version.properties"
|
||||
- "packaging/version/**"
|
||||
- "crates/vnidrop/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
|
||||
46
.github/workflows/windows-store.yml
vendored
46
.github/workflows/windows-store.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/windows-store.yml"
|
||||
- "packaging/windows/**"
|
||||
- "packaging/version/**"
|
||||
- "version.properties"
|
||||
- "assets/windows/**"
|
||||
- "desktopApp/**"
|
||||
- "shared/**"
|
||||
@@ -17,16 +19,8 @@ on:
|
||||
- "gradle/**"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Release version in MAJOR.MINOR.PATCH form
|
||||
required: true
|
||||
default: "1.0.0"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -77,37 +71,13 @@ jobs:
|
||||
restore-keys: |
|
||||
windows-x64-cargo-1.91.0-
|
||||
|
||||
- name: Resolve Store version
|
||||
- name: Resolve canonical version
|
||||
id: version
|
||||
shell: pwsh
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
|
||||
run: |
|
||||
$version = $env:REQUESTED_VERSION
|
||||
if ($env:GITHUB_REF_TYPE -eq "tag") {
|
||||
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
|
||||
throw "Store release tags must use vMAJOR.MINOR.PATCH"
|
||||
}
|
||||
$version = $env:GITHUB_REF_NAME.Substring(1)
|
||||
}
|
||||
|
||||
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
|
||||
throw "Version must use MAJOR.MINOR.PATCH"
|
||||
}
|
||||
$parts = $version.Split(".")
|
||||
for ($index = 0; $index -lt $parts.Count; $index++) {
|
||||
$part = $parts[$index]
|
||||
$number = 0
|
||||
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
|
||||
throw "Version components must be canonical integers"
|
||||
}
|
||||
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
|
||||
throw "Version components must be between 0 and 65535, with a non-zero major"
|
||||
}
|
||||
}
|
||||
|
||||
"app=$version" >> $env:GITHUB_OUTPUT
|
||||
"package=$version.0" >> $env:GITHUB_OUTPUT
|
||||
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
|
||||
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
|
||||
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Test and build release app image
|
||||
shell: pwsh
|
||||
@@ -115,7 +85,6 @@ jobs:
|
||||
$arguments = @(
|
||||
":shared:jvmTest"
|
||||
":desktopApp:createReleaseDistributable"
|
||||
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
|
||||
"-Pvnidrop.desktop.rustVariant=release"
|
||||
"-Pvnidrop.diagnostics.included=false"
|
||||
"--no-daemon"
|
||||
@@ -131,7 +100,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$arguments = @{
|
||||
Version = "${{ steps.version.outputs.app }}"
|
||||
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
|
||||
OutputDirectory = ".\build\release\windows"
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ config.override.mk
|
||||
output/
|
||||
.screenshots
|
||||
apple/RELEASE-MACOS.md
|
||||
apple/Generated/*.xcconfig
|
||||
|
||||
26
Makefile
26
Makefile
@@ -12,14 +12,14 @@ 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: check-localization localization localization-migrate
|
||||
.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
|
||||
|
||||
help: ## Show available commands and common configuration variables.
|
||||
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
|
||||
@printf '\nCommon variables:\n'
|
||||
@printf ' %-28s %s\n' 'VERSION=x.y.z' 'Package version (default: $(VERSION))'
|
||||
@printf ' %-28s %s\n' 'version.properties' 'Canonical application version ($(VERSION))'
|
||||
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
|
||||
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
|
||||
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
|
||||
@@ -59,7 +59,18 @@ format: ## Format Rust sources.
|
||||
|
||||
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
|
||||
|
||||
check: check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
|
||||
check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
|
||||
|
||||
check-version: ## Validate the canonical version and its platform mappings.
|
||||
cd $(ROOT) && packaging/version/test-version.sh
|
||||
cd $(ROOT) && packaging/version/resolve-version.sh verify
|
||||
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
|
||||
|
||||
check-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
|
||||
@@ -113,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.
|
||||
@@ -126,7 +140,7 @@ build-apple-macos-direct: apple-project ## Build the direct-download macOS targe
|
||||
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
|
||||
|
||||
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
|
||||
cd $(ROOT) && apple/scripts/build-dmg.sh $(VERSION)
|
||||
cd $(ROOT) && apple/scripts/build-dmg.sh
|
||||
|
||||
open-apple: build-apple-macos ## Build and launch the native macOS app.
|
||||
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
|
||||
|
||||
@@ -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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,22 @@ plugins {
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
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 {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
@@ -55,12 +71,26 @@ 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()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = androidVersionCode
|
||||
versionName = appVersion
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -76,6 +106,7 @@ android {
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -87,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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +132,12 @@ tasks.configureEach {
|
||||
":shared:copyAndroidAndroidX64Debug",
|
||||
)
|
||||
}
|
||||
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
|
||||
dependsOn(
|
||||
":shared:copyAndroidAndroidArm64Release",
|
||||
":shared:copyAndroidAndroidX64Release",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
@@ -38,7 +40,7 @@
|
||||
<data android:mimeType="application/vnd.vnidrop.transfer"/>
|
||||
</intent-filter>
|
||||
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
|
||||
<intent-filter>
|
||||
<intent-filter tools:ignore="AppLinkUrlError">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
|
||||
@@ -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:
|
||||
@@ -52,7 +58,7 @@ The macOS app ships through two targets that build identical sources:
|
||||
|
||||
```bash
|
||||
make build-apple-macos-direct # unsigned compile-check of the direct target
|
||||
make build-apple-dmg VERSION=x.y.z # signed (+ notarized) .dmg
|
||||
make build-apple-dmg # signed (+ notarized) .dmg
|
||||
```
|
||||
|
||||
Full signing, notarization, appcast, and cask flow: see
|
||||
|
||||
@@ -6,7 +6,7 @@ import UIKit
|
||||
@MainActor
|
||||
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||
let device = UIDevice.current
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
let env = PlatformEnvironment(
|
||||
name: "\(device.systemName) \(device.systemVersion)",
|
||||
appVersion: version,
|
||||
|
||||
@@ -5,7 +5,7 @@ import AppKit
|
||||
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
|
||||
@MainActor
|
||||
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
let host = Host.current().localizedName ?? "Mac"
|
||||
let env = PlatformEnvironment(
|
||||
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
|
||||
@@ -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:
|
||||
@@ -65,11 +63,7 @@ targetTemplates:
|
||||
base:
|
||||
PRODUCT_NAME: VniDrop
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
|
||||
MARKETING_VERSION: "0.1.0"
|
||||
# Placeholder only — the real CFBundleVersion is stamped at build time as a
|
||||
# UTC YYMMDD.HHMM timestamp by the "Stamp build number" phase below, so every
|
||||
# build is monotonic and self-describing (shown as "MARKETING_VERSION (build)").
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "$(PRODUCT_VERSION)"
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: VniDrop/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
@@ -111,29 +105,16 @@ targetTemplates:
|
||||
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||
exit 1
|
||||
fi
|
||||
postBuildScripts:
|
||||
# Stamp CFBundleVersion as a UTC YYMMDD.HHMM timestamp into the built
|
||||
# Info.plist before code signing. Runs for every build (Xcode GUI archive and
|
||||
# CLI alike), so both the App Store and direct-download channels get a
|
||||
# monotonic, meaningful build id. CI/reproducible builds can pin it via the
|
||||
# VNIDROP_BUILD env var. MARKETING_VERSION stays the human X.Y.Z version.
|
||||
- name: Stamp build number (UTC timestamp)
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
build="${VNIDROP_BUILD:-$(date -u +%y%m%d.%H%M)}"
|
||||
plist="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
|
||||
if [ -f "$plist" ]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build" "$plist"
|
||||
echo "Stamped CFBundleVersion = $build"
|
||||
else
|
||||
echo "warning: Info.plist not found at $plist; CFBundleVersion not stamped"
|
||||
fi
|
||||
|
||||
targets:
|
||||
# App Store / TestFlight target. iOS + macOS, sandboxed, no self-updater.
|
||||
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
|
||||
@@ -141,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"
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
# This is the direct-distribution counterpart to the App Store archive flow; it
|
||||
# never touches the App Store `VniDrop` target. The Rust crate is not modified.
|
||||
#
|
||||
# Usage: apple/scripts/build-dmg.sh [version]
|
||||
# version MAJOR.MINOR.PATCH; defaults to MARKETING_VERSION / the git tag.
|
||||
# Usage: apple/scripts/build-dmg.sh
|
||||
#
|
||||
# Environment:
|
||||
# DEVELOPER_ID_APP Codesign identity, e.g. "Developer ID Application: … (TEAMID)".
|
||||
@@ -29,29 +28,14 @@ PROJECT="$APPLE_DIR/VniDrop.xcodeproj"
|
||||
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"
|
||||
|
||||
# --- Resolve version (arg > git tag > project MARKETING_VERSION) -------------
|
||||
resolve_version() {
|
||||
local v="${1:-}"
|
||||
if [ -z "$v" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
if [ -z "$v" ]; then
|
||||
v="$(sed -nE 's/.*MARKETING_VERSION: "([0-9.]+)".*/\1/p' "$APPLE_DIR/project.yml" | head -1)"
|
||||
fi
|
||||
if [[ ! "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "version must be MAJOR.MINOR.PATCH (got '$v')" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$v"
|
||||
}
|
||||
VERSION="$(resolve_version "${1:-}")"
|
||||
|
||||
# CFBundleVersion is a UTC YYMMDD.HHMM timestamp stamped by the target's
|
||||
# "Stamp build number" build phase. Pin it here (one value for the whole archive)
|
||||
# so the app, DMG, and appcast all agree; Sparkle compares it to order updates.
|
||||
BUILD_NUMBER="$(date -u +%y%m%d.%H%M)"
|
||||
export VNIDROP_BUILD="$BUILD_NUMBER"
|
||||
VERSION="$("$VERSION_RESOLVER" product)"
|
||||
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
|
||||
@@ -76,6 +60,7 @@ echo " team: ${DEVELOPMENT_TEAM:-<unknown>}"
|
||||
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"
|
||||
@@ -107,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"
|
||||
@@ -152,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"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# keychain). The resulting appcast.xml is uploaded as a release asset; the app's
|
||||
# SUFeedURL (/releases/latest/download/appcast.xml) always resolves to the newest.
|
||||
#
|
||||
# Usage: apple/scripts/generate-appcast.sh [version]
|
||||
# Usage: apple/scripts/generate-appcast.sh
|
||||
#
|
||||
# Environment:
|
||||
# DIST_DIR Folder holding the DMG(s). Default: apple/dist
|
||||
@@ -24,12 +24,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
APPLE_DIR="$REPO_ROOT/apple"
|
||||
DIST_DIR="${DIST_DIR:-$APPLE_DIR/dist}"
|
||||
RELEASE_REPO="${RELEASE_REPO:-sudosylabs/vnidrop}"
|
||||
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [ -z "$VERSION" ] && [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
[ -n "$VERSION" ] || { echo "error: version required (arg or tag)" >&2; exit 1; }
|
||||
VERSION="$("$VERSION_RESOLVER" product)"
|
||||
"$VERSION_RESOLVER" verify >/dev/null
|
||||
|
||||
# Enclosure URLs resolve to the specific release's assets.
|
||||
DOWNLOAD_PREFIX="https://github.com/$RELEASE_REPO/releases/download/v$VERSION"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
// this is necessary to avoid the plugins to be loaded multiple times
|
||||
// in each subproject's classloader
|
||||
@@ -13,3 +15,68 @@ plugins {
|
||||
alias(libs.plugins.kotlinJvm) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
}
|
||||
|
||||
val versionFile = layout.projectDirectory.file("version.properties")
|
||||
val versionProperties = Properties().apply {
|
||||
versionFile.asFile.inputStream().use(::load)
|
||||
}
|
||||
|
||||
fun requiredVersionProperty(name: String): String =
|
||||
versionProperties.getProperty(name)?.takeIf { it.isNotBlank() }
|
||||
?: error("Missing $name in ${versionFile.asFile}")
|
||||
|
||||
fun canonicalInteger(name: String, value: String, range: LongRange): Long {
|
||||
require(value.matches(Regex("0|[1-9][0-9]*"))) {
|
||||
"$name must be a canonical non-negative integer"
|
||||
}
|
||||
val number = value.toLongOrNull()
|
||||
require(number != null && number in range) {
|
||||
"$name must be between ${range.first} and ${range.last}"
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
val productVersion = requiredVersionProperty("PRODUCT_VERSION")
|
||||
val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)")
|
||||
.matchEntire(productVersion)
|
||||
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
|
||||
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
|
||||
require(productVersionParts[0] <= 65534 && productVersionParts.drop(1).all { it <= 65535 }) {
|
||||
"PRODUCT_VERSION components exceed the supported store ranges"
|
||||
}
|
||||
|
||||
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
|
||||
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
|
||||
"RELEASE_CHANNEL contains unsupported characters"
|
||||
}
|
||||
val androidVersionCode = canonicalInteger(
|
||||
"ANDROID_VERSION_CODE",
|
||||
requiredVersionProperty("ANDROID_VERSION_CODE"),
|
||||
1L..2_100_000_000L,
|
||||
).toInt()
|
||||
val windowsVersionEpoch = canonicalInteger(
|
||||
"WINDOWS_VERSION_EPOCH",
|
||||
requiredVersionProperty("WINDOWS_VERSION_EPOCH"),
|
||||
1L..65535L,
|
||||
)
|
||||
val windowsMajor = productVersionParts[0] + windowsVersionEpoch
|
||||
require(windowsMajor <= 65535) {
|
||||
"Derived Windows package major exceeds 65535"
|
||||
}
|
||||
val windowsPackageVersion =
|
||||
"$windowsMajor.${productVersionParts[1]}.${productVersionParts[2]}.0"
|
||||
|
||||
extra["vnidrop.productVersion"] = productVersion
|
||||
extra["vnidrop.releaseChannel"] = releaseChannel
|
||||
extra["vnidrop.androidVersionCode"] = androidVersionCode
|
||||
extra["vnidrop.windowsPackageVersion"] = windowsPackageVersion
|
||||
|
||||
tasks.register("verifyVersion") {
|
||||
group = "verification"
|
||||
description = "Validates the canonical cross-platform application version."
|
||||
inputs.file(versionFile)
|
||||
inputs.property("productVersion", productVersion)
|
||||
inputs.property("releaseChannel", releaseChannel)
|
||||
inputs.property("androidVersionCode", androidVersionCode)
|
||||
inputs.property("windowsPackageVersion", windowsPackageVersion)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Default command configuration. Override locally in the ignored
|
||||
# config.override.mk or on the command line (for example: make package-deb VERSION=1.2.0).
|
||||
# Default command configuration. Override local tool paths in the ignored
|
||||
# config.override.mk or on the command line.
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
HOST_OS := windows
|
||||
@@ -24,7 +24,7 @@ XCODEGEN ?= xcodegen
|
||||
OPEN ?= open
|
||||
POWERSHELL ?= pwsh
|
||||
|
||||
VERSION ?= $(shell sed -n 's/^vnidrop.version=//p' $(ROOT)/gradle.properties)
|
||||
override VERSION := $(shell $(ROOT)/packaging/version/resolve-version.sh product)
|
||||
APPLE_PROFILE ?= debug
|
||||
APPLE_CONFIGURATION ?= Debug
|
||||
APPLE_DESTINATION ?=
|
||||
|
||||
31
crates/vnidrop/build.rs
Normal file
31
crates/vnidrop/build.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let version_file = manifest_dir.join("../../version.properties");
|
||||
println!("cargo:rerun-if-changed={}", version_file.display());
|
||||
|
||||
let contents = fs::read_to_string(&version_file)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", version_file.display()));
|
||||
let versions: Vec<_> = contents
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("PRODUCT_VERSION="))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
versions.len(),
|
||||
1,
|
||||
"{} must contain exactly one PRODUCT_VERSION",
|
||||
version_file.display()
|
||||
);
|
||||
let version = versions[0];
|
||||
let components: Vec<_> = version.split('.').collect();
|
||||
assert!(
|
||||
components.len() == 3
|
||||
&& components.iter().all(|component| {
|
||||
component.parse::<u16>().is_ok()
|
||||
&& (component == &"0" || !component.starts_with('0'))
|
||||
}),
|
||||
"PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
|
||||
);
|
||||
println!("cargo:rustc-env=VNIDROP_PRODUCT_VERSION={version}");
|
||||
}
|
||||
@@ -106,7 +106,7 @@ impl HandshakeClient {
|
||||
transfer_name: metadata.transfer_name.clone(),
|
||||
receiver_name: receiver_name.map(ToOwned::to_owned),
|
||||
receiver_device_name: None,
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
app_version: env!("VNIDROP_PRODUCT_VERSION").to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -6,19 +6,7 @@ plugins {
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
val appVersion = providers.gradleProperty("vnidrop.version").get()
|
||||
val appVersionParts = appVersion.split(".")
|
||||
require(
|
||||
appVersionParts.size == 3 &&
|
||||
appVersionParts.mapIndexed { index, part ->
|
||||
val number = part.toIntOrNull()
|
||||
number != null &&
|
||||
number.toString() == part &&
|
||||
number in (if (index == 0) 1 else 0)..65535
|
||||
}.all { it },
|
||||
) {
|
||||
"vnidrop.version must be MAJOR.MINOR.PATCH with numeric components from 0 to 65535 and a non-zero major"
|
||||
}
|
||||
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
|
||||
|
||||
dependencies {
|
||||
implementation(projects.shared)
|
||||
|
||||
@@ -6,10 +6,6 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
|
||||
# Product version used by desktop packaging. Release workflows override this
|
||||
# from the vMAJOR.MINOR.PATCH tag.
|
||||
vnidrop.version=1.0.0
|
||||
|
||||
#Android
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
.PHONY: package-deb package-rpm package-msix
|
||||
|
||||
package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z).
|
||||
package-deb: ## Build and verify a Debian x64 package.
|
||||
@test "$(HOST_OS)" = linux || { printf 'Debian packaging requires Linux.\n' >&2; exit 1; }
|
||||
@cd $(ROOT); \
|
||||
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
|
||||
version="$$(packaging/linux/resolve-version.sh)"; \
|
||||
$(GRADLE) :shared:jvmTest :desktopApp:packageReleaseDeb \
|
||||
-Pvnidrop.version="$$version" \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
$(GRADLE_RELEASE_FLAGS); \
|
||||
@@ -19,12 +18,11 @@ package-deb: ## Build and verify a Debian x64 package (VERSION=x.y.z).
|
||||
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
|
||||
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
|
||||
|
||||
package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z).
|
||||
package-rpm: ## Build and verify an RPM x64 package.
|
||||
@test "$(HOST_OS)" = linux || { printf 'RPM packaging requires Linux.\n' >&2; exit 1; }
|
||||
@cd $(ROOT); \
|
||||
version="$$(packaging/linux/resolve-version.sh "$(VERSION)")"; \
|
||||
version="$$(packaging/linux/resolve-version.sh)"; \
|
||||
$(GRADLE) :desktopApp:packageReleaseRpm \
|
||||
-Pvnidrop.version="$$version" \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
$(GRADLE_RELEASE_FLAGS); \
|
||||
@@ -38,14 +36,12 @@ package-rpm: ## Build and verify an RPM x64 package (VERSION=x.y.z).
|
||||
( cd "$$output_directory" && sha256sum "$$output_name" > "$$output_name.sha256" ); \
|
||||
printf 'Package: %s/%s\n' "$$output_directory" "$$output_name"
|
||||
|
||||
package-msix: ## Build and verify an unsigned Windows Store MSIX (VERSION=x.y.z).
|
||||
package-msix: ## Build and verify an unsigned Windows Store MSIX.
|
||||
@test "$(HOST_OS)" = windows || { printf 'MSIX packaging requires Windows.\n' >&2; exit 1; }
|
||||
cd $(ROOT) && $(GRADLE) :shared:jvmTest :desktopApp:createReleaseDistributable \
|
||||
-Pvnidrop.version="$(VERSION)" \
|
||||
-Pvnidrop.desktop.rustVariant=release \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
$(GRADLE_RELEASE_FLAGS)
|
||||
cd $(ROOT) && $(POWERSHELL) -NoProfile -File packaging/windows/build-msix.ps1 \
|
||||
-Version "$(VERSION)" \
|
||||
-AppImage desktopApp/build/compose/binaries/main-release/app/VniDrop \
|
||||
-OutputDirectory build/release/windows
|
||||
|
||||
54
packaging/android/README.md
Normal file
54
packaging/android/README.md
Normal file
@@ -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.
|
||||
171
packaging/android/build-release.sh
Executable file
171
packaging/android/build-release.sh
Executable file
@@ -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"
|
||||
410
packaging/android/publish_play.py
Executable file
410
packaging/android/publish_play.py
Executable file
@@ -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', '<unknown>')}"
|
||||
)
|
||||
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())
|
||||
100
packaging/android/tests/test_publish_play.py
Normal file
100
packaging/android/tests/test_publish_play.py
Normal file
@@ -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()
|
||||
@@ -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,13 +24,12 @@ 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 existing `v1.0.0` tag predates this workflow and will not run it
|
||||
retroactively. Use the next version tag after this configuration reaches
|
||||
`master`.
|
||||
The legacy `v1.0.0` tag predates canonical versioning and does not define the
|
||||
current product version. New release tags must match `version.properties`.
|
||||
|
||||
## Install a downloaded package
|
||||
|
||||
@@ -61,11 +61,12 @@ Use JDK 21 and Rust 1.91. Build DEB packages on Debian/Ubuntu with `dpkg` and
|
||||
`fakeroot`; build RPM packages on Fedora with `rpm-build`. Building an RPM on
|
||||
Ubuntu prevents `jpackage` from discovering normal RPM dependencies.
|
||||
|
||||
From the repository root on the matching Linux family, run one of:
|
||||
Set the release in `version.properties`. From the repository root on the
|
||||
matching Linux family, run one of:
|
||||
|
||||
```bash
|
||||
make package-deb VERSION=1.0.0
|
||||
make package-rpm VERSION=1.0.0
|
||||
make package-deb
|
||||
make package-rpm
|
||||
```
|
||||
|
||||
The Make targets collect the Compose output under `build/release/linux/`, then
|
||||
|
||||
@@ -2,38 +2,14 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
version=${1:-1.0.0}
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
resolver="$script_dir/../version/resolve-version.sh"
|
||||
version="$("$resolver" product)"
|
||||
"$resolver" verify >/dev/null
|
||||
|
||||
if [[ ${GITHUB_REF_TYPE:-} == "tag" ]]; then
|
||||
if [[ ! ${GITHUB_REF_NAME:-} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Linux release tags must use vMAJOR.MINOR.PATCH" >&2
|
||||
if [[ -n ${1:-} && $1 != "$version" ]]; then
|
||||
printf 'Version overrides are not supported; version.properties declares %s\n' "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
version=${GITHUB_REF_NAME#v}
|
||||
fi
|
||||
|
||||
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Version must use MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=. read -r major minor patch <<< "$version"
|
||||
parts=("$major" "$minor" "$patch")
|
||||
|
||||
for index in "${!parts[@]}"; do
|
||||
part=${parts[$index]}
|
||||
if [[ $part != "0" && $part == 0* ]]; then
|
||||
echo "Version components must be canonical integers without leading zeroes" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#part} > 5 )) || (( 10#$part > 65535 )); then
|
||||
echo "Version components must be between 0 and 65535" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( index == 0 && 10#$part == 0 )); then
|
||||
echo "The major version must be non-zero" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$version"
|
||||
|
||||
42
packaging/release/README.md
Normal file
42
packaging/release/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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 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"
|
||||
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.
|
||||
177
packaging/release/assemble-release.sh
Executable file
177
packaging/release/assemble-release.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/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)"
|
||||
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')"
|
||||
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')"
|
||||
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" ]]
|
||||
[[ $(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')"
|
||||
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 appleDirectBuildNumber "$apple_direct_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),
|
||||
appleDirectBuildNumber: $appleDirectBuildNumber,
|
||||
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")"
|
||||
116
packaging/release/test-assemble-release.sh
Executable file
116
packaging/release/test-assemble-release.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/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)"
|
||||
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"
|
||||
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
|
||||
printf '<url>VniDrop-%s.dmg</url>\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 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" \
|
||||
'{
|
||||
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 '.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 ]]
|
||||
(
|
||||
cd "$output_dir"
|
||||
sha256sum --check SHA256SUMS >/dev/null
|
||||
)
|
||||
60
packaging/version/README.md
Normal file
60
packaging/version/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Application versioning
|
||||
|
||||
`version.properties` at the repository root is the single source of truth for
|
||||
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, 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
|
||||
the patch component (`0.2.1`). Release channels belong in
|
||||
`RELEASE_CHANNEL`; they are not appended to store version fields.
|
||||
|
||||
| Platform | Product version | Platform build/package version |
|
||||
| --- | --- | --- |
|
||||
| Android | `PRODUCT_VERSION` | `ANDROID_VERSION_CODE` |
|
||||
| Apple 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 |
|
||||
|
||||
MSIX requires a non-zero first component and reserves the fourth component for
|
||||
the Store. Its version is:
|
||||
|
||||
```text
|
||||
(product major + WINDOWS_VERSION_EPOCH).product minor.product patch.0
|
||||
```
|
||||
|
||||
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0`
|
||||
maps to `2.0.0.0`. Do not change the epoch after publishing.
|
||||
|
||||
Every Android 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:
|
||||
|
||||
```bash
|
||||
make check-version
|
||||
```
|
||||
|
||||
Release tags must exactly match `vPRODUCT_VERSION`. Manual workflow dispatches
|
||||
also build the committed version and do not accept free-form version inputs.
|
||||
48
packaging/version/generate-apple-xcconfig.sh
Executable file
48
packaging/version/generate-apple-xcconfig.sh
Executable file
@@ -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
|
||||
119
packaging/version/resolve-version.ps1
Normal file
119
packaging/version/resolve-version.ps1
Normal file
@@ -0,0 +1,119 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Product", "Channel", "AndroidCode", "AppleStoreBuild", "AppleDirectBuild", "WindowsPackage", "Json", "Verify")]
|
||||
[string] $Field = "Verify",
|
||||
|
||||
[switch] $VerifyTag,
|
||||
|
||||
[string] $VersionFile
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($VersionFile)) {
|
||||
$VersionFile = Join-Path $PSScriptRoot "..\..\version.properties"
|
||||
}
|
||||
$VersionFile = (Resolve-Path -LiteralPath $VersionFile).Path
|
||||
|
||||
function Read-VersionProperty {
|
||||
param([string] $Name)
|
||||
|
||||
$prefix = "$Name="
|
||||
$matches = @(Get-Content -LiteralPath $VersionFile | Where-Object { $_.StartsWith($prefix) })
|
||||
if ($matches.Count -ne 1) {
|
||||
throw "Expected exactly one $Name entry in $VersionFile"
|
||||
}
|
||||
return $matches[0].Substring($prefix.Length)
|
||||
}
|
||||
|
||||
function Convert-CanonicalInteger {
|
||||
param(
|
||||
[string] $Name,
|
||||
[string] $Value,
|
||||
[long] $Minimum,
|
||||
[long] $Maximum
|
||||
)
|
||||
|
||||
if ($Value -notmatch "^(0|[1-9][0-9]*)$") {
|
||||
throw "$Name must be a canonical non-negative integer"
|
||||
}
|
||||
$number = 0L
|
||||
if (-not [long]::TryParse($Value, [ref] $number) -or $number -lt $Minimum -or $number -gt $Maximum) {
|
||||
throw "$Name must be between $Minimum and $Maximum"
|
||||
}
|
||||
return $number
|
||||
}
|
||||
|
||||
$productVersion = Read-VersionProperty "PRODUCT_VERSION"
|
||||
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
|
||||
$androidVersionCodeText = Read-VersionProperty "ANDROID_VERSION_CODE"
|
||||
$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"
|
||||
}
|
||||
$productParts = $productVersion.Split(".")
|
||||
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 65534
|
||||
$null = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 65535
|
||||
$null = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 65535
|
||||
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
|
||||
throw "RELEASE_CHANNEL contains unsupported characters"
|
||||
}
|
||||
$androidVersionCode = Convert-CanonicalInteger "ANDROID_VERSION_CODE" $androidVersionCodeText 1 2100000000
|
||||
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
|
||||
$windowsMajor = $productMajor + $windowsVersionEpoch
|
||||
if ($windowsMajor -gt 65535) {
|
||||
throw "Derived Windows package major exceeds 65535"
|
||||
}
|
||||
$windowsPackageVersion = "$windowsMajor.$($productParts[1]).$($productParts[2]).0"
|
||||
|
||||
if ($VerifyTag -and $env:GITHUB_REF_TYPE -eq "tag" -and $env:GITHUB_REF_NAME -ne "v$productVersion") {
|
||||
throw "Release tag must be v$productVersion, got $($env:GITHUB_REF_NAME)"
|
||||
}
|
||||
|
||||
$versionInfo = [ordered] @{
|
||||
productVersion = $productVersion
|
||||
releaseChannel = $releaseChannel
|
||||
androidVersionCode = $androidVersionCode
|
||||
appleStoreBuildNumber = $appleBuildNumber
|
||||
appleDirectBuildNumber = $appleBuildNumber
|
||||
windowsPackageVersion = $windowsPackageVersion
|
||||
}
|
||||
|
||||
switch ($Field) {
|
||||
"Product" { $productVersion }
|
||||
"Channel" { $releaseChannel }
|
||||
"AndroidCode" { $androidVersionCode }
|
||||
"AppleStoreBuild" { $appleBuildNumber }
|
||||
"AppleDirectBuild" { $appleBuildNumber }
|
||||
"WindowsPackage" { $windowsPackageVersion }
|
||||
"Json" { $versionInfo | ConvertTo-Json -Compress }
|
||||
"Verify" {
|
||||
"VniDrop $productVersion ($releaseChannel), Android $androidVersionCode, Apple Store $appleBuildNumber, Apple Direct $appleBuildNumber, MSIX $windowsPackageVersion"
|
||||
}
|
||||
}
|
||||
130
packaging/version/resolve-version.sh
Executable file
130
packaging/version/resolve-version.sh
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
read_property() {
|
||||
local key=$1
|
||||
local matches
|
||||
matches="$(sed -n "s/^${key}=//p" "$version_file")"
|
||||
[[ -n "$matches" ]] || fail "Missing $key in $version_file"
|
||||
[[ $(printf '%s\n' "$matches" | wc -l | tr -d ' ') == 1 ]] ||
|
||||
fail "Duplicate $key in $version_file"
|
||||
printf '%s' "$matches"
|
||||
}
|
||||
|
||||
validate_canonical_integer() {
|
||||
local name=$1
|
||||
local value=$2
|
||||
local minimum=$3
|
||||
local maximum=$4
|
||||
[[ $value =~ ^(0|[1-9][0-9]*)$ ]] ||
|
||||
fail "$name must be a canonical non-negative integer"
|
||||
(( 10#$value >= minimum && 10#$value <= maximum )) ||
|
||||
fail "$name must be between $minimum and $maximum"
|
||||
}
|
||||
|
||||
product_version="$(read_property PRODUCT_VERSION)"
|
||||
release_channel="$(read_property RELEASE_CHANNEL)"
|
||||
android_version_code="$(read_property ANDROID_VERSION_CODE)"
|
||||
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"
|
||||
IFS=. read -r product_major product_minor product_patch <<< "$product_version"
|
||||
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 65534
|
||||
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 65535
|
||||
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 65535
|
||||
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
|
||||
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
|
||||
validate_canonical_integer "ANDROID_VERSION_CODE" "$android_version_code" 1 2100000000
|
||||
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 )) ||
|
||||
fail "Derived Windows package major exceeds 65535"
|
||||
windows_package_version="$windows_major.$product_minor.$product_patch.0"
|
||||
|
||||
verify_tag() {
|
||||
local tag=${1:-${GITHUB_REF_NAME:-}}
|
||||
if [[ ${GITHUB_REF_TYPE:-} == tag || -n ${1:-} ]]; then
|
||||
[[ $tag == "v$product_version" ]] ||
|
||||
fail "Release tag must be v$product_version, got ${tag:-<empty>}"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-verify}" in
|
||||
product)
|
||||
printf '%s\n' "$product_version"
|
||||
;;
|
||||
channel)
|
||||
printf '%s\n' "$release_channel"
|
||||
;;
|
||||
android-code)
|
||||
printf '%s\n' "$android_version_code"
|
||||
;;
|
||||
apple-store-build)
|
||||
printf '%s\n' "$apple_build_number"
|
||||
;;
|
||||
apple-direct-build)
|
||||
printf '%s\n' "$apple_build_number"
|
||||
;;
|
||||
windows-package)
|
||||
printf '%s\n' "$windows_package_version"
|
||||
;;
|
||||
verify)
|
||||
verify_tag
|
||||
printf 'VniDrop %s (%s), Android %s, Apple Store %s, Apple Direct %s, MSIX %s\n' \
|
||||
"$product_version" "$release_channel" "$android_version_code" \
|
||||
"$apple_build_number" "$apple_build_number" "$windows_package_version"
|
||||
;;
|
||||
verify-tag)
|
||||
verify_tag "${2:-}"
|
||||
;;
|
||||
*)
|
||||
fail "Usage: $0 {product|channel|android-code|apple-store-build|apple-direct-build|windows-package|verify|verify-tag [tag]}"
|
||||
;;
|
||||
esac
|
||||
67
packaging/version/test-version.sh
Executable file
67
packaging/version/test-version.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
resolver="$script_dir/resolve-version.sh"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
write_version() {
|
||||
printf '%s\n' \
|
||||
"PRODUCT_VERSION=$1" \
|
||||
"RELEASE_CHANNEL=$2" \
|
||||
"ANDROID_VERSION_CODE=$3" \
|
||||
"WINDOWS_VERSION_EPOCH=$4" \
|
||||
> "$scratch/version.properties"
|
||||
}
|
||||
|
||||
resolve() {
|
||||
VNIDROP_VERSION_FILE="$scratch/version.properties" "$resolver" "$@"
|
||||
}
|
||||
|
||||
expect_failure() {
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
printf 'Expected command to fail: %s\n' "$*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
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-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 1
|
||||
[[ $(resolve windows-package) == 2.0.0.0 ]]
|
||||
|
||||
write_version 01.0.0 beta 2 1
|
||||
expect_failure resolve verify
|
||||
|
||||
write_version 0.2.0 beta 0 1
|
||||
expect_failure resolve verify
|
||||
|
||||
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'
|
||||
@@ -24,15 +24,17 @@ after the first release. The manifest display name uses the exact reserved Store
|
||||
name; the product's in-app branding and launcher remain `VniDrop`.
|
||||
|
||||
The initial package targets Windows Desktop x64, Windows 10 version 2004
|
||||
(build 19041) or later. The fourth MSIX version component is reserved by the
|
||||
Store, so app version 1.2.3 becomes package version 1.2.3.0.
|
||||
(build 19041) or later. The product version comes from `version.properties`.
|
||||
Because MSIX requires a non-zero major and reserves the fourth component, the
|
||||
package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch
|
||||
`1`, product version `0.2.0` becomes package version `1.2.0.0`.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
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
|
||||
@@ -54,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
|
||||
@@ -87,9 +90,9 @@ The Store ID is a non-secret variable.
|
||||
From the repository root:
|
||||
|
||||
~~~powershell
|
||||
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
|
||||
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
|
||||
|
||||
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
|
||||
.\packaging\windows\build-msix.ps1 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
|
||||
~~~
|
||||
|
||||
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Version,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $AppImage,
|
||||
|
||||
@@ -87,16 +84,11 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
|
||||
throw "MSIX packaging must run on Windows"
|
||||
}
|
||||
|
||||
$versionParts = $Version.Split(".")
|
||||
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
|
||||
for ($index = 0; $index -lt $versionParts.Count; $index++) {
|
||||
$part = $versionParts[$index]
|
||||
$number = 0
|
||||
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
|
||||
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
|
||||
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
|
||||
}
|
||||
$packageVersion = "$Version.0"
|
||||
$versionResolver = Join-Path $PSScriptRoot "..\version\resolve-version.ps1"
|
||||
$versionInfoJson = & $versionResolver -Field Json -VerifyTag
|
||||
$versionInfo = $versionInfoJson | ConvertFrom-Json
|
||||
$Version = [string] $versionInfo.productVersion
|
||||
$packageVersion = [string] $versionInfo.windowsPackageVersion
|
||||
|
||||
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
|
||||
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"
|
||||
|
||||
@@ -37,7 +37,7 @@ plugins {
|
||||
alias(libs.plugins.kotlinAtomicfu)
|
||||
}
|
||||
|
||||
val appVersion = providers.gradleProperty("vnidrop.version").get()
|
||||
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
|
||||
val desktopRustVariant = providers.gradleProperty("vnidrop.desktop.rustVariant")
|
||||
.map { value ->
|
||||
when (value.trim().lowercase()) {
|
||||
|
||||
@@ -52,7 +52,7 @@ private class AndroidDeviceInfoProvider(
|
||||
|
||||
private fun Context.appVersion(): String = runCatching {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
}.getOrNull()?.takeIf(String::isNotBlank) ?: "0.1.0"
|
||||
}.getOrNull()?.takeIf(String::isNotBlank) ?: "unknown"
|
||||
|
||||
private fun Context.activeNetworkSummary(): String? = runCatching {
|
||||
val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
@@ -14,7 +14,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
|
||||
AppDependencies(
|
||||
environment = PlatformEnvironment(
|
||||
name = "Java ${System.getProperty("java.version")}",
|
||||
appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0",
|
||||
appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "unknown",
|
||||
defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
|
||||
defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
|
||||
uiPlatform = uiPlatformForJvm(System.getProperty("os.name")),
|
||||
|
||||
@@ -63,10 +63,61 @@ import com.vnidrop.app.ui.platform.LocalUiPlatform
|
||||
import com.vnidrop.app.ui.shell.AppShell
|
||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_is_direct
|
||||
import vnidrop.shared.generated.resources.about_is_title
|
||||
import vnidrop.shared.generated.resources.about_isnt_title
|
||||
import vnidrop.shared.generated.resources.about_privacy_title
|
||||
import vnidrop.shared.generated.resources.about_tagline
|
||||
import vnidrop.shared.generated.resources.approval_endpoint_id
|
||||
import vnidrop.shared.generated.resources.button_approve
|
||||
import vnidrop.shared.generated.resources.button_choose_files
|
||||
import vnidrop.shared.generated.resources.button_close
|
||||
import vnidrop.shared.generated.resources.button_create_new_transfer
|
||||
import vnidrop.shared.generated.resources.button_download_invitation
|
||||
import vnidrop.shared.generated.resources.button_open_settings
|
||||
import vnidrop.shared.generated.resources.button_receive_files
|
||||
import vnidrop.shared.generated.resources.nav_receive
|
||||
import vnidrop.shared.generated.resources.nav_send
|
||||
import vnidrop.shared.generated.resources.notifications_description
|
||||
import vnidrop.shared.generated.resources.notifications_local_title
|
||||
import vnidrop.shared.generated.resources.notifications_title
|
||||
import vnidrop.shared.generated.resources.receive_choose_method_title
|
||||
import vnidrop.shared.generated.resources.receive_clear_history
|
||||
import vnidrop.shared.generated.resources.receive_clear_history_description
|
||||
import vnidrop.shared.generated.resources.receive_clear_history_title
|
||||
import vnidrop.shared.generated.resources.receive_delete_history_item
|
||||
import vnidrop.shared.generated.resources.receive_empty_title
|
||||
import vnidrop.shared.generated.resources.receive_method_file
|
||||
import vnidrop.shared.generated.resources.receive_new_subtitle
|
||||
import vnidrop.shared.generated.resources.relay_add_url
|
||||
import vnidrop.shared.generated.resources.relay_apply
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom
|
||||
import vnidrop.shared.generated.resources.relay_strict_warning
|
||||
import vnidrop.shared.generated.resources.send_access_anyone
|
||||
import vnidrop.shared.generated.resources.send_choose_file_title
|
||||
import vnidrop.shared.generated.resources.send_subtitle
|
||||
import vnidrop.shared.generated.resources.settings_network_title
|
||||
import vnidrop.shared.generated.resources.settings_subtitle
|
||||
import vnidrop.shared.generated.resources.snackbar_dismiss
|
||||
import vnidrop.shared.generated.resources.status_available
|
||||
import vnidrop.shared.generated.resources.storage_calculating
|
||||
import vnidrop.shared.generated.resources.storage_clear_transfer_cache
|
||||
import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers
|
||||
import vnidrop.shared.generated.resources.storage_delete_transfers_description
|
||||
import vnidrop.shared.generated.resources.storage_received_files
|
||||
import vnidrop.shared.generated.resources.storage_transfer_data
|
||||
import vnidrop.shared.generated.resources.transfer_qr_unavailable
|
||||
import vnidrop.shared.generated.resources.transfer_scan_qr
|
||||
import vnidrop.shared.generated.resources.transfer_share_title
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class FoundationComposeTest {
|
||||
@@ -82,7 +133,7 @@ class FoundationComposeTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
onNodeWithText("Approve").performClick()
|
||||
onNodeWithText(Res.string.button_approve.value).performClick()
|
||||
runOnIdle { assertEquals("request", accepted) }
|
||||
}
|
||||
|
||||
@@ -111,8 +162,8 @@ class FoundationComposeTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
onNodeWithText("Notifications").performClick()
|
||||
onNodeWithText("Get notified about transfer activity while VniDrop is in the background.").assertIsDisplayed()
|
||||
onNodeWithText(Res.string.notifications_title.value).performClick()
|
||||
onNodeWithText(Res.string.notifications_description.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,15 +213,12 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Network").performClick()
|
||||
onNodeWithText("Device ID: endpoint-for-allowlist").assertIsDisplayed()
|
||||
onNodeWithText("Strict custom").performClick()
|
||||
onNodeWithText(
|
||||
"Strict custom mode will not start unless at least one configured relay is reachable. " +
|
||||
"VniDrop never uses public relays or public discovery in this mode.",
|
||||
).assertIsDisplayed()
|
||||
onNodeWithText("Add relay server").assertIsDisplayed()
|
||||
onNodeWithText("Apply network settings").performClick()
|
||||
onNodeWithText(Res.string.settings_network_title.value).performClick()
|
||||
onNodeWithText(Res.string.approval_endpoint_id.value("endpoint-for-allowlist")).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.relay_mode_custom.value).performClick()
|
||||
onNodeWithText(Res.string.relay_strict_warning.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.relay_add_url.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.relay_apply.value).performClick()
|
||||
runOnIdle { assertTrue(applied) }
|
||||
}
|
||||
|
||||
@@ -203,20 +251,14 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Clear transfer cache").performClick()
|
||||
onNodeWithText(
|
||||
"Removes cached transfer content after briefly restarting VniDrop. " +
|
||||
"Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.",
|
||||
).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.storage_clear_transfer_cache.value).performClick()
|
||||
onNodeWithText(Res.string.storage_clear_transfer_cache_description.value).assertIsDisplayed()
|
||||
runOnIdle { assertFalse(cacheClearRequested) }
|
||||
onNodeWithTag("confirm-clear-transfer-cache").performClick()
|
||||
runOnIdle { assertTrue(cacheClearRequested) }
|
||||
|
||||
onNodeWithText("Delete all transfers").performClick()
|
||||
onNodeWithText(
|
||||
"This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " +
|
||||
"Ongoing transfers and received files are not deleted. This can’t be undone.",
|
||||
).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.storage_delete_transfers.value).performClick()
|
||||
onNodeWithText(Res.string.storage_delete_transfers_description.value).assertIsDisplayed()
|
||||
runOnIdle { assertFalse(deleteRequested) }
|
||||
|
||||
onNodeWithTag("confirm-delete-all-transfers").performClick()
|
||||
@@ -260,9 +302,9 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Received files").assertIsDisplayed()
|
||||
onNodeWithText("Transfer data").assertIsDisplayed()
|
||||
onAllNodesWithText("Calculating storage usage…").assertCountEquals(0)
|
||||
onNodeWithText(Res.string.storage_received_files.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.storage_transfer_data.value).assertIsDisplayed()
|
||||
onAllNodesWithText(Res.string.storage_calculating.value).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -292,14 +334,12 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Send files directly. Stay in control of who receives them.").assertIsDisplayed()
|
||||
onNodeWithText("What VniDrop is").assertIsDisplayed()
|
||||
onNodeWithText("What VniDrop isn’t").assertIsDisplayed()
|
||||
onAllNodesWithText("Privacy & security").assertCountEquals(1)
|
||||
onNodeWithText(Res.string.about_tagline.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.about_is_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.about_isnt_title.value).assertIsDisplayed()
|
||||
onAllNodesWithText(Res.string.about_privacy_title.value).assertCountEquals(1)
|
||||
onAllNodesWithText("Apache 2.0").assertCountEquals(1)
|
||||
val explanationBounds = onNodeWithText(
|
||||
"A direct device-to-device transfer — your files go straight to the receiver.",
|
||||
).getUnclippedBoundsInRoot()
|
||||
val explanationBounds = onNodeWithText(Res.string.about_is_direct.value).getUnclippedBoundsInRoot()
|
||||
assertTrue(explanationBounds.bottom - explanationBounds.top > 32.dp)
|
||||
}
|
||||
|
||||
@@ -328,7 +368,7 @@ class FoundationComposeTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
onNodeWithText("Allow notifications").performClick()
|
||||
onNodeWithText(Res.string.notifications_local_title.value).performClick()
|
||||
runOnIdle { assertEquals(true, enabled) }
|
||||
}
|
||||
|
||||
@@ -360,7 +400,7 @@ class FoundationComposeTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
onNodeWithText("Open Settings").performClick()
|
||||
onNodeWithText(Res.string.button_open_settings.value).performClick()
|
||||
runOnIdle { assertTrue(opened) }
|
||||
}
|
||||
|
||||
@@ -372,7 +412,7 @@ class FoundationComposeTest {
|
||||
VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) }
|
||||
}
|
||||
onNodeWithText("Saved successfully").assertIsDisplayed()
|
||||
onNodeWithContentDescription("Dismiss").assertIsDisplayed()
|
||||
onNodeWithContentDescription(Res.string.snackbar_dismiss.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -392,7 +432,7 @@ class FoundationComposeTest {
|
||||
|
||||
val messageBottom = onNodeWithText("Notifications are turned off for VniDrop. You can enable them in Settings.")
|
||||
.getUnclippedBoundsInRoot().bottom
|
||||
val closeBottom = onNodeWithContentDescription("Dismiss").getUnclippedBoundsInRoot().bottom
|
||||
val closeBottom = onNodeWithContentDescription(Res.string.snackbar_dismiss.value).getUnclippedBoundsInRoot().bottom
|
||||
val actionTop = onNodeWithText("Open Settings").getUnclippedBoundsInRoot().top
|
||||
assertTrue(messageBottom <= actionTop)
|
||||
assertTrue(closeBottom <= actionTop)
|
||||
@@ -421,7 +461,7 @@ class FoundationComposeTest {
|
||||
|
||||
val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom
|
||||
val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top
|
||||
val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top
|
||||
val navigationLabelTop = onNodeWithText(Res.string.nav_send.value).getUnclippedBoundsInRoot().top
|
||||
assertTrue(overlayBottom <= floatingActionTop)
|
||||
assertTrue(overlayBottom <= navigationLabelTop)
|
||||
}
|
||||
@@ -445,7 +485,7 @@ class FoundationComposeTest {
|
||||
}
|
||||
|
||||
onNodeWithText("VniDrop").assertIsDisplayed()
|
||||
onNodeWithText("Receive").performClick()
|
||||
onNodeWithText(Res.string.nav_receive.value).performClick()
|
||||
runOnIdle { assertEquals(AppDestination.Receive, selected) }
|
||||
}
|
||||
|
||||
@@ -563,9 +603,9 @@ class FoundationComposeTest {
|
||||
|
||||
onNodeWithTag("send-empty-icon").assertIsDisplayed()
|
||||
onNodeWithTag("receive-empty-icon").assertIsDisplayed()
|
||||
onAllNodesWithText("Transfers you’re sharing from this device.").assertCountEquals(0)
|
||||
onAllNodesWithText("Transfers you’ve received on this device.").assertCountEquals(0)
|
||||
onAllNodesWithText("Your name, where transfers are saved, appearance, and notifications.").assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.send_subtitle.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.receive_new_subtitle.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.settings_subtitle.value).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -592,9 +632,9 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("New transfer").performClick()
|
||||
onNodeWithText("Choose what to share").assertIsDisplayed()
|
||||
onNodeWithText("Choose files").assertIsDisplayed()
|
||||
onNodeWithText(Res.string.button_create_new_transfer.value).performClick()
|
||||
onNodeWithText(Res.string.send_choose_file_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.button_choose_files.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -627,7 +667,7 @@ class FoundationComposeTest {
|
||||
}
|
||||
|
||||
onNodeWithText("1.5 KB").assertIsDisplayed()
|
||||
onNodeWithText("Anyone with this transfer").performClick()
|
||||
onNodeWithText(Res.string.send_access_anyone.value).performClick()
|
||||
runOnIdle { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, selectedPolicy) }
|
||||
}
|
||||
|
||||
@@ -656,7 +696,7 @@ class FoundationComposeTest {
|
||||
}
|
||||
|
||||
val titleBounds = onNodeWithText("Photos").getUnclippedBoundsInRoot()
|
||||
val statusBounds = onNodeWithText("Available").getUnclippedBoundsInRoot()
|
||||
val statusBounds = onNodeWithText(Res.string.status_available.value).getUnclippedBoundsInRoot()
|
||||
assertTrue(statusBounds.left - titleBounds.right <= 12.dp)
|
||||
onNodeWithText("Photos").performClick()
|
||||
runOnIdle { assertEquals(9UL, selectedId) }
|
||||
@@ -679,16 +719,16 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithContentDescription("Share").assertIsDisplayed()
|
||||
onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0)
|
||||
onNodeWithContentDescription("Share").performClick()
|
||||
onNodeWithContentDescription(Res.string.transfer_share_title.value).assertIsDisplayed()
|
||||
onAllNodesWithText(Res.string.transfer_scan_qr.value).assertCountEquals(0)
|
||||
onNodeWithContentDescription(Res.string.transfer_share_title.value).performClick()
|
||||
runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) }
|
||||
waitUntil(timeoutMillis = 5_000) {
|
||||
onAllNodesWithText("Scan with VniDrop to receive this transfer").fetchSemanticsNodes().isNotEmpty()
|
||||
onAllNodesWithText(Res.string.transfer_scan_qr.value).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed()
|
||||
onNodeWithText("Save .vnd file").assertIsDisplayed()
|
||||
onNodeWithContentDescription("Close").assertIsDisplayed()
|
||||
onNodeWithText(Res.string.transfer_scan_qr.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.button_download_invitation.value).assertIsDisplayed()
|
||||
onNodeWithContentDescription(Res.string.button_close.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -710,12 +750,12 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onAllNodesWithText("Share").assertCountEquals(0)
|
||||
onAllNodesWithText("Save .vnd file").assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.transfer_share_title.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.button_download_invitation.value).assertCountEquals(0)
|
||||
|
||||
runOnIdle { transfer.value = transfer.value.copy(status = TransferStatus.Failed) }
|
||||
onAllNodesWithText("Share").assertCountEquals(0)
|
||||
onAllNodesWithText("Save .vnd file").assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.transfer_share_title.value).assertCountEquals(0)
|
||||
onAllNodesWithText(Res.string.button_download_invitation.value).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -737,8 +777,8 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("QR unavailable for this invitation. Use Share or Download instead.").assertIsDisplayed()
|
||||
onNodeWithText("Save .vnd file").assertIsDisplayed()
|
||||
onNodeWithText(Res.string.transfer_qr_unavailable.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.button_download_invitation.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -774,10 +814,10 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Nothing received yet").assertIsDisplayed()
|
||||
onNodeWithText("Start receiving").performClick()
|
||||
onNodeWithText("How would you like to connect?").assertIsDisplayed()
|
||||
onNodeWithText("Open a .vnd invitation").assertIsDisplayed()
|
||||
onNodeWithText(Res.string.receive_empty_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.button_receive_files.value).performClick()
|
||||
onNodeWithText(Res.string.receive_choose_method_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.receive_method_file.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -813,11 +853,11 @@ class FoundationComposeTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithContentDescription("Delete from receive history").assertIsDisplayed()
|
||||
onNodeWithText("Clear history").performClick()
|
||||
onNodeWithText("Clear receive history?").assertIsDisplayed()
|
||||
onNodeWithText("Downloaded files will remain on this device.", substring = true).assertIsDisplayed()
|
||||
onNodeWithContentDescription("Close").assertIsDisplayed()
|
||||
onNodeWithContentDescription(Res.string.receive_delete_history_item.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.receive_clear_history.value).performClick()
|
||||
onNodeWithText(Res.string.receive_clear_history_title.value).assertIsDisplayed()
|
||||
onNodeWithText(Res.string.receive_clear_history_description.value).assertIsDisplayed()
|
||||
onNodeWithContentDescription(Res.string.button_close.value).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -883,3 +923,9 @@ class FoundationComposeTest {
|
||||
updatedAt = 2L,
|
||||
)
|
||||
}
|
||||
|
||||
private val StringResource.value: String
|
||||
get() = runBlocking { getString(this@value) }
|
||||
|
||||
private fun StringResource.value(vararg formatArgs: Any): String =
|
||||
runBlocking { getString(this@value, *formatArgs) }
|
||||
|
||||
4
version.properties
Normal file
4
version.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
PRODUCT_VERSION=0.2.0
|
||||
RELEASE_CHANNEL=beta
|
||||
ANDROID_VERSION_CODE=2
|
||||
WINDOWS_VERSION_EPOCH=1
|
||||
Reference in New Issue
Block a user