From 3202b566142828a6fc668d0526873f75ea051bb0 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:53:17 -0400 Subject: [PATCH] [bre-1089] mac desktop publish automation using fastlane (#16091) * Use Fastlane to publish to Apple App Store * Publish MacOS build number as artifact * Download and source build number from artifact * Refactor Fastlane file to use already existing builds in TestFlight * fastfile changes, release workflow changes, gitignore addition * reorder steps to after dist dir is created * resolve pathing issue * upload step path fix * make comments more clear * enable phased rollout, add auto-submit checkbox * move logic from release to publish workflow * configure dry run properly for MAS * edit file for testing * workflow testing * verbose logging for debugging * update to look at releases * remove verbose flag for next test * add verbose logging back * disable precheck * hardcode app v for test * hardcode app v for testing * additional test * log build numbers * remove testing values, prep for draft PR * flip metadata bool for testing * comment out branch check * hardcode locales * add metadata and locales change * lane change * more logging for finding build * address logs feedback * edit_live false * testing * extra logging from apple api * testing * workaround for attaching build attempt * workaround patch update * simplify and retest skip metadata true * turn precheck true * remove autosubmit checkbox, add live edit true for testing release notes formatting * re-org dispatch, rename dir to release_notes, flip live edit to false * another formatting attempt * additional formatting changes * account for double space, add dash to beginning * different formatting approach * format test * simplified notes formatting test, double line after each period * proper formatting * rename file for rust linter * remove testing comments * remove default string from notes, logic to check for empty release notes in mas_publish, formatting * add validation logic after publishing --------- Co-authored-by: Micaiah Martin --- .github/workflows/build-desktop.yml | 18 +++ .github/workflows/publish-desktop.yml | 108 +++++++++++++++- .github/workflows/release-desktop.yml | 3 +- apps/desktop/.gitignore | 5 + apps/desktop/fastlane/fastfile | 174 ++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/fastlane/fastfile diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index e6c77b366b..8063306662 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1299,6 +1299,7 @@ jobs: $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json + Write-Output "### MacOS App Store build number: $env:BUILD_NUMBER" - name: Install Node dependencies @@ -1374,6 +1375,23 @@ jobs: CSC_FOR_PULL_REQUEST: true run: npm run pack:mac:mas + - name: Create MacOS App Store build number artifact + shell: pwsh + env: + BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} + run: | + $buildInfo = @{ + buildNumber = $env:BUILD_NUMBER + } + $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json + + - name: Upload MacOS App Store build number artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: macos-build-number.json + path: apps/desktop/dist/macos-build-number.json + if-no-files-found: error + - name: Upload .pkg artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index aafc4d25ed..9fe8909f8d 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -18,10 +18,15 @@ on: type: string default: latest electron_rollout_percentage: - description: 'Staged Rollout Percentage for Electron' - required: true + description: 'Staged Rollout Percentage for Electron (ignored if Electron publish disabled)' + required: false default: '10' type: string + electron_publish: + description: 'Publish to Electron (auto-updater)' + required: true + default: true + type: boolean snap_publish: description: 'Publish to Snap store' required: true @@ -32,6 +37,15 @@ on: required: true default: true type: boolean + mas_publish: + description: 'Publish to Mac App Store' + required: true + default: true + type: boolean + release_notes: + description: 'Release Notes' + required: false + type: string jobs: setup: @@ -71,7 +85,7 @@ jobs: echo "Release Version: ${{ inputs.version }}" echo "version=${{ inputs.version }}" - $TAG_NAME="desktop-v${{ inputs.version }}" + TAG_NAME="desktop-v${{ inputs.version }}" echo "Tag name: $TAG_NAME" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT @@ -109,6 +123,7 @@ jobs: name: Electron blob publish runs-on: ubuntu-22.04 needs: setup + if: inputs.electron_publish permissions: contents: read packages: read @@ -292,6 +307,92 @@ jobs: run: choco push --source=https://push.chocolatey.org/ working-directory: apps/desktop/dist + mas: + name: Deploy Mac App Store + runs-on: macos-15 + needs: setup + permissions: + contents: read + id-token: write + if: inputs.mas_publish + env: + _PKG_VERSION: ${{ needs.setup.outputs.release_version }} + _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate release notes for MAS + if: inputs.mas_publish && (inputs.release_notes == '' || inputs.release_notes == null) + run: | + echo "โŒ Release notes are required when publishing to Mac App Store" + echo "Please provide release notes using the 'Release Notes' input field" + exit 1 + + - name: Download MacOS App Store build number + working-directory: apps/desktop + run: wget https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/macos-build-number.json + + - name: Setup Ruby and Install Fastlane + uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0 + with: + ruby-version: '3.0' + bundler-cache: false + working-directory: apps/desktop + + - name: Install Fastlane + working-directory: apps/desktop + run: gem install fastlane + + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-clients + secrets: "APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Publish to App Store + env: + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} + APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} + working-directory: apps/desktop + run: | + BUILD_NUMBER=$(jq -r '.buildNumber' macos-build-number.json) + CHANGELOG="${{ inputs.release_notes }}" + IS_DRY_RUN="${{ inputs.publish_type == 'Dry Run' }}" + + if [ "$IS_DRY_RUN" = "true" ]; then + echo "๐Ÿงช DRY RUN MODE - Testing without actual App Store submission" + echo "๐Ÿ“ฆ Would publish build $BUILD_NUMBER to Mac App Store" + else + echo "๐Ÿš€ PRODUCTION MODE - Publishing to Mac App Store" + echo "๐Ÿ“ฆ Publishing build $BUILD_NUMBER to Mac App Store" + fi + + echo "๐Ÿ“ Release notes (${#CHANGELOG} chars): ${CHANGELOG:0:100}..." + + # Validate changelog length (App Store limit is 4000 chars) + if [ ${#CHANGELOG} -gt 4000 ]; then + echo "โŒ Release notes too long: ${#CHANGELOG} characters (max 4000)" + exit 1 + fi + + fastlane publish --verbose \ + app_version:"${{ env._PKG_VERSION }}" \ + build_number:$BUILD_NUMBER \ + changelog:"$CHANGELOG" \ + dry_run:$IS_DRY_RUN + update-deployment: name: Update Deployment Status runs-on: ubuntu-22.04 @@ -300,6 +401,7 @@ jobs: - electron-blob - snap - choco + - mas permissions: contents: read deployments: write diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5ce0da4cb4..bfd6115a1a 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -124,7 +124,8 @@ jobs: apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive, apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}.yml, apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-linux.yml, - apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml" + apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml, + apps/desktop/artifacts/macos-build-number.json" commit: ${{ github.sha }} tag: desktop-v${{ env.PKG_VERSION }} name: Desktop v${{ env.PKG_VERSION }} diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 444c9a8510..083040f7fc 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -3,3 +3,8 @@ dist-safari/ *.env PlugIns/safari.appex/ xcuserdata/ + +# Fastlane +fastlane/report.xml +fastlane/README.md +fastlane/release_notes/ diff --git a/apps/desktop/fastlane/fastfile b/apps/desktop/fastlane/fastfile new file mode 100644 index 0000000000..08c35dfa7b --- /dev/null +++ b/apps/desktop/fastlane/fastfile @@ -0,0 +1,174 @@ +default_platform(:mac) + +# Static configuration for the Mac desktop app +require 'json' +require 'base64' + +APP_CONFIG = { + app_identifier: "com.bitwarden.desktop", + release_notes_path: "fastlane/release_notes", + locales: ["ca", "zh-Hans", "zh-Hant", "da", "nl-NL", "en-US", "fi", "fr-FR", "de-DE", "id", "it", "ja", "ko", "no", "pt-PT", "pt-BR", "ru", "es-ES", "es-MX", "sv", "tr", "vi", "en-GB", "th"] +} + +platform :mac do + desc "Prepare release notes from changelog" + lane :prepare_release_notes do |options| + changelog = options[:changelog] || "Bug fixes and improvements" + + # Split on periods and format with bullet points + # Try different formatting approaches for App Store Connect + formatted_changelog = changelog + .split('.') + .map(&:strip) + .reject(&:empty?) + .map { |item| "โ€ข #{item}" } + .join("\n") + + UI.message("Original changelog: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}") + UI.message("Formatted changelog: #{formatted_changelog[0,100]}#{formatted_changelog.length > 100 ? '...' : ''}") + + # Create release notes directories and files for all locales + APP_CONFIG[:locales].each do |locale| + dir = "release_notes/#{locale}" + FileUtils.mkdir_p(dir) + File.write("#{dir}/release_notes.txt", formatted_changelog) + UI.message("Creating release notes for #{locale}") + end + + # Create release notes hash for deliver + notes = APP_CONFIG[:locales].each_with_object({}) do |locale, hash| + file_path = "release_notes/#{locale}/release_notes.txt" + if File.exist?(file_path) + hash[locale] = File.read(file_path) + else + UI.important("No release notes found for #{locale} at #{file_path}, skipping.") + end + end + + UI.success("โœ… Prepared release notes for #{APP_CONFIG[:locales].count} locales") + notes + end + + desc "Display configuration information" + lane :show_config do |options| + build_number = (options[:build_number] || ENV["BUILD_NUMBER"]).to_s.strip + app_version = (options[:app_version] || ENV["APP_VERSION"]).to_s.strip + + UI.message("๐Ÿ“ฆ App ID: #{APP_CONFIG[:app_identifier]}") + UI.message("๐Ÿท๏ธ Version: #{app_version.empty? ? '(not set)' : app_version}") + UI.message("๐Ÿ”ข Build Number: #{build_number}") + UI.message("๐ŸŒ Locales: #{APP_CONFIG[:locales].count}") + end + + desc "Publish desktop to the Mac App Store" + lane :publish do |options| + build_number = (options[:build_number] || ENV["BUILD_NUMBER"]).to_s.strip + app_version = (options[:app_version] || ENV["APP_VERSION"]).to_s.strip + changelog = options[:changelog] || "Bug fixes and improvements" + is_dry_run = options[:dry_run] == "true" || options[:dry_run] == true + + if is_dry_run + UI.header("๐Ÿงช DRY RUN: Testing Bitwarden Desktop App Store submission") + else + UI.header("๐Ÿš€ Publishing Bitwarden Desktop to Mac App Store") + end + + # Show configuration info + show_config(build_number: build_number, app_version: app_version) + + # Validate app_version + UI.user_error!("โŒ APP_VERSION is required") if app_version.nil? || app_version.empty? + # Validate build_number + UI.user_error!("โŒ BUILD_NUMBER is required") if build_number.nil? || build_number.empty? + + # Prepare release notes for all locales + notes = prepare_release_notes(changelog: changelog) + + if is_dry_run + UI.important("๐Ÿงช DRY RUN MODE - Skipping actual App Store Connect submission") + UI.message("โœ… Validation passed") + UI.message("โœ… Release notes prepared for #{APP_CONFIG[:locales].count} locales") + UI.message("โœ… Release notes: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}") + UI.success("๐ŸŽฏ DRY RUN COMPLETE - Everything looks ready for production!") + next # Use 'next' instead of 'return' in fastlane lanes + end + + # Set up App Store Connect API + app_store_connect_api_key( + key_id: "6TV9MKN3GP", + issuer_id: ENV["APP_STORE_CONNECT_TEAM_ISSUER"], + key_content: Base64.encode64(ENV["APP_STORE_CONNECT_AUTH_KEY"]), + is_key_content_base64: true + ) + + UI.message("๐Ÿ“ Using release notes for #{notes.keys.count} locales") + UI.message("๐ŸŽฏ Publishing version #{app_version} with build #{build_number}") + + # Upload to App Store Connect + deliver( + platform: "osx", + app_identifier: APP_CONFIG[:app_identifier], + app_version: app_version, + build_number: build_number, + metadata_path: "metadata", + skip_binary_upload: true, + skip_screenshots: true, + skip_metadata: false, # Enable metadata upload to include release notes + release_notes: notes, + edit_live: false, + submit_for_review: true, # if this is false, the build number does not attach to the draft + phased_release: true, # Enable 7-day phased rollout + precheck_include_in_app_purchases: false, + run_precheck_before_submit: false, + automatic_release: true, + force: true + ) + + # Verify submission in App Store Connect (skip in dry run mode) + unless is_dry_run + UI.message("โณ Waiting 60 seconds for App Store Connect to process submission...") + sleep(60) + + UI.message("๐Ÿ” Verifying submission in App Store Connect...") + + # Find the app + app = Spaceship::ConnectAPI::App.find(APP_CONFIG[:app_identifier]) + UI.user_error!("โŒ App not found in App Store Connect") if app.nil? + + # Find the version we just submitted + versions = app.get_app_store_versions + target_version = nil + + versions.each do |v| + if v.version_string == app_version + target_version = v + break + end + end + + UI.user_error!("โŒ Version #{app_version} not found in App Store Connect after submission") if target_version.nil? + + UI.success("โœ… Version #{app_version} found in App Store Connect") + UI.message("๐Ÿ“Š Current status: #{target_version.app_store_state}") + + # Validate build attachment + if target_version.build.nil? + UI.user_error!("โŒ No build attached to version #{app_version}") + elsif target_version.build.version != build_number + UI.user_error!("โŒ Wrong build attached: found #{target_version.build.version}, expected #{build_number}") + else + UI.success("โœ… Build #{build_number} correctly attached to version #{app_version}") + end + + # Check submission status + valid_states = ["WAITING_FOR_REVIEW", "IN_REVIEW"] + unless valid_states.include?(target_version.app_store_state) + UI.user_error!("โŒ Unexpected submission state: #{target_version.app_store_state}. Expected one of: #{valid_states.join(', ')}") + end + + UI.success("๐ŸŽ‰ Verification complete: Version #{app_version} with build #{build_number} successfully submitted!") + else + UI.success("๐Ÿงช DRY RUN: Skipping App Store Connect verification") + end + end +end