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" apkanalyzer_path="$( find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 | sort -r | head -1 )" if [ -z "$apkanalyzer_path" ]; then echo "apkanalyzer was not found" >&2 exit 1 fi packaging/android/verify-apk-signature.sh \ "$apk" \ "$EXPECTED_CERT_SHA256" \ >/dev/null 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 ( 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-microsoft-store: name: Submit Microsoft Store update needs: - preflight - linux - windows - macos - play-closed-testing runs-on: windows-2025 timeout-minutes: 30 environment: microsoft-store permissions: contents: read steps: - 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/windows - name: Validate Microsoft Store configuration id: store-package shell: pwsh env: AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} SELLER_ID: ${{ secrets.SELLER_ID }} MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} run: | $configuration = @{ AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET SELLER_ID = $env:SELLER_ID MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID } foreach ($entry in $configuration.GetEnumerator()) { if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") { throw "Missing Microsoft Store configuration: $($entry.Key)" } } if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") { throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID" } $packages = @( Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse ) if ($packages.Count -ne 1) { throw "Expected exactly one msixupload package, found $($packages.Count)" } "path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT - name: Set up Microsoft Store Developer CLI uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1 with: version: v0.3.9 - name: Authenticate and verify Store access shell: pwsh env: AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} SELLER_ID: ${{ secrets.SELLER_ID }} MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} run: | msstore settings --enableTelemetry false if ($LASTEXITCODE -ne 0) { throw "Failed to disable Microsoft Store CLI telemetry" } msstore reconfigure ` --tenantId "$env:AZURE_AD_TENANT_ID" ` --sellerId "$env:SELLER_ID" ` --clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" ` --clientSecret "$env:AZURE_AD_APPLICATION_SECRET" if ($LASTEXITCODE -ne 0) { throw "Microsoft Store authentication failed" } msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID" if ($LASTEXITCODE -ne 0) { throw "The Microsoft Store application is not accessible" } - name: Publish package to Microsoft Store shell: pwsh env: MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} STORE_PACKAGE: ${{ steps.store-package.outputs.path }} run: | msstore publish "$env:STORE_PACKAGE" ` --appId "$env:MICROSOFT_STORE_PRODUCT_ID" if ($LASTEXITCODE -ne 0) { throw "Microsoft Store package publication failed" } - name: Summarize Store submission shell: pwsh env: VERSION: ${{ needs.preflight.outputs.version }} MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }} run: | "### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY "- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY "- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY "- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY publish-github: name: Publish coordinated GitHub Release needs: - preflight - linux - windows - macos - play-closed-testing - publish-microsoft-store 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