diff --git a/.github/workflows/publish-store.yml b/.github/workflows/publish-store.yml
index cd654fc64c..db39b4848b 100644
--- a/.github/workflows/publish-store.yml
+++ b/.github/workflows/publish-store.yml
@@ -1,16 +1,146 @@
-
-name: Publish
-
+name: Publish to Google Play
+run-name: "Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}"
on:
workflow_dispatch:
+ inputs:
+ product:
+ description: "Which app is being released."
+ type: choice
+ options:
+ - Password Manager
+ - Authenticator
+ version-name:
+ description: "Version name to promote to production ex 2025.1.1"
+ type: string
+ version-code:
+ description: "Build number to promote to production."
+ required: true
+ type: string
+ rollout-percentage:
+ description: "Percentage of users who will receive this version update."
+ required: true
+ type: choice
+ options:
+ - 10%
+ - 30%
+ - 50%
+ - 100%
+ default: 10%
+ release-notes:
+ description: "Change notes to be included with this release."
+ type: string
+ default: "Bug fixes."
+ required: true
+ track-from:
+ description: "Track to promote from."
+ type: choice
+ options:
+ - internal
+ - Fastlane Automation Source
+ required: true
+ default: "internal"
+ track-target:
+ description: "Track to promote to."
+ type: choice
+ options:
+ - production
+ - Fastlane Automation Target
+ required: true
+env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-permissions: {}
+permissions:
+ contents: read
+ packages: read
jobs:
- publish:
+ promote:
runs-on: ubuntu-24.04
name: Promote build to Production in Play Store
steps:
- - name: TEST STEP
- run: exit 0
+ - name: Log inputs to job summary
+ run: |
+ echo "Job Inputs
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo '```json' >> $GITHUB_STEP_SUMMARY
+ echo '${{ toJson(inputs) }}' >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo " " >> $GITHUB_STEP_SUMMARY
+
+ - name: Check out repo
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Configure Ruby
+ uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
+ with:
+ bundler-cache: true
+
+ - name: Install Fastlane
+ run: |
+ gem install bundler:2.2.27
+ bundle config path vendor/bundle
+ bundle install --jobs 4 --retry 3
+
+ - name: Log in to Azure
+ uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
+ with:
+ creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
+
+ - name: Retrieve secrets
+ env:
+ ACCOUNT_NAME: bitwardenci
+ CONTAINER_NAME: mobile
+ run: |
+ mkdir -p ${{ github.workspace }}/secrets
+ mkdir -p ${{ github.workspace }}/app/src/standardRelease
+
+ az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
+ --name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
+
+ az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
+ --name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
+
+ - name: Format Release Notes
+ run: |
+ FORMATTED_MESSAGE="$(echo "${{ inputs.release-notes }}" | sed 's/ /\n/g')"
+ echo "RELEASE_NOTES<> $GITHUB_ENV
+ echo "$FORMATTED_MESSAGE" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ - name: Promote Play Store version to production
+ env:
+ PLAY_KEYSTORE_PASSWORD: ${{ secrets.PLAY_BETA_KEYSTORE_PASSWORD }}
+ PLAY_KEY_PASSWORD: ${{ secrets.PLAY_BETA_KEY_PASSWORD }}
+ VERSION_CODE_INPUT: ${{ inputs.version-code }}
+ VERSION_NAME: ${{inputs.version-name}}
+ ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }}
+ PRODUCT: ${{ inputs.product }}
+ TRACK_FROM: ${{ inputs.track-from }}
+ TRACK_TARGET: ${{ inputs.track-target }}
+ run: |
+ if [ "$PRODUCT" = "Password Manager" ]; then
+ PACKAGE_NAME="com.x8bit.bitwarden"
+ elif [ "$PRODUCT" = "Authenticator" ]; then
+ PACKAGE_NAME="com.bitwarden.authenticator"
+ else
+ echo "Unsupported product: $PRODUCT"
+ exit 1
+ fi
+
+ VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',')
+
+ decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc)
+
+ bundle exec fastlane updateReleaseNotes \
+ releaseNotes:"$RELEASE_NOTES" \
+ versionCode:"$VERSION_CODE"
+
+ bundle exec fastlane promoteToProduction \
+ versionCode:"$VERSION_CODE" \
+ versionName:"$VERSION_NAME" \
+ rolloutPercentage:"$decimal" \
+ packageName:"$PACKAGE_NAME" \
+ releaseNotes:"$RELEASE_NOTES" \
+ track:"$TRACK_FROM" \
+ trackPromoteTo:"$TRACK_TARGET"
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 454b806547..803c0be24e 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -13,6 +13,9 @@
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
+require_relative 'patches/supply_custom_promote_config'
+require_relative 'patches/supply_custom_promote'
+
default_platform(:android)
platform :android do
@@ -418,4 +421,74 @@ platform :android do
mapping: "authenticator/build/outputs/mapping/release/mapping.txt",
)
end
+
+ desc "Retrieve build from Github releases"
+ lane :retrieveBuildFromGithub do |options|
+
+ version_codes = Actions.lane_context[SharedValues::GOOGLE_PLAY_TRACK_VERSION_CODES]
+ UI.message("Version codes in beta track: #{version_codes}")
+ end
+
+ desc "Update release notes for all locales."
+ lane :updateReleaseNotes do |options|
+ changelog = options[:releaseNotes]
+ version_code = options[:versionCode]
+
+ auth_locales = ["en-US"]
+ pw_manager_locales = ["ca", "cs-CZ", "da-DK", "de-DE", "en-US", "es-ES", "et", "fr-FR", "hr", "hu-HU", "id", "it-IT", "iw-IL", "ja-JP", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ro", "ru-RU", "sk", "sv-SE", "tr-TR", "uk", "vi", "zh-CN", "zh-TW"]
+ if options[:packageName] == "com.bitwarden.authenticator"
+ locales = auth_locales
+ else
+ locales = pw_manager_locales
+ end
+ locales.each do |locale|
+ dir = "metadata/android/#{locale}/changelogs"
+ FileUtils.mkdir_p(dir)
+ File.write("#{dir}/#{version_code}.txt", changelog)
+ end
+ end
+
+ desc "Promote to production."
+ lane :promoteToProduction do |options|
+ release_options = {
+ package_name: options[:packageName],
+ version_code: options[:versionCode].to_i,
+ version_name: options[:versionName],
+ track: options[:track],
+ track_promote_to: options[:trackPromoteTo],
+ skip_release_verification: true,
+ skip_upload_apk: true,
+ skip_upload_aab: true,
+ }
+
+ if options[:releaseNotes].nil? or options[:releaseNotes].to_s.empty?
+ release_options[:skip_upload_metadata] = true
+ else
+ release_options[:skip_upload_metadata] = false
+ end
+
+ if options[:rolloutPercentage].to_f < 1
+ release_options[:track_promote_release_status] = "inProgress"
+ release_options[:rollout] = options[:rolloutPercentage]
+ else
+ release_options[:release_status] = "completed"
+ end
+
+ begin
+ UI.message("🚀 Starting release to #{options[:trackPromoteTo]}...")
+
+ supply(release_options)
+
+ rescue => error
+ message = error.to_s
+
+ if message.include?("You cannot rollout this release because it does not allow any existing users to upgrade to the newly added APKs")
+ UI.error("❌ App Store Error: Cannot rollout release because no existing users can upgrade to the new build.")
+ UI.error("This might mean the version is older than what is currently available on the track, pick a newer build.")
+ else
+ UI.error("❌ Unexpected error during release: #{message}")
+ end
+ raise
+ end
+ end
end
diff --git a/fastlane/patches/supply_custom_promote.rb b/fastlane/patches/supply_custom_promote.rb
new file mode 100644
index 0000000000..e684dbc56a
--- /dev/null
+++ b/fastlane/patches/supply_custom_promote.rb
@@ -0,0 +1,97 @@
+# Patch Description:
+# Fixes issue where Fastlane 'Supply' doesn't recognize previous builds
+# when promoting to another track.
+#
+# Source: https://github.com/artsy/eigen/pull/10262
+# Author: Brian Beckerle (@brainbicycle)
+#
+
+module Supply
+ class Uploader
+ alias_method :original_promote_track, :promote_track
+
+ def promote_track
+ if Supply.config[:skip_release_verification]
+ custom_promote_track
+ else
+ original_promote_track
+ end
+ end
+
+ def custom_promote_track
+ UI.message("Using custom promotion logic")
+ track_from = client.tracks(Supply.config[:track]).first
+ unless track_from
+ UI.user_error!("Cannot promote from track '#{Supply.config[:track]}' - track doesn't exist")
+ end
+
+ releases = track_from.releases
+ UI.message("Track contents: #{track_from.to_json}")
+
+ version_code = Supply.config[:version_code].to_s
+ if !Supply.config[:skip_release_verification]
+ if version_code != ""
+ releases = releases.select do |release|
+ release.version_codes.include?(version_code)
+ end
+ else
+ releases = releases.select do |release|
+ release.status == Supply.config[:release_status]
+ end
+ end
+
+ if releases.size == 0
+ if version_code != ""
+ UI.user_error!("Cannot find release with version code '#{version_code}' to promote in track '#{Supply.config[:track]}'")
+ else
+ UI.user_error!("Track '#{Supply.config[:track]}' doesn't have any releases")
+ end
+ elsif releases.size > 1
+ UI.user_error!("Track '#{Supply.config[:track]}' has more than one release - use :version_code to filter the release to promote")
+ end
+ else
+ UI.message("Skipping release verification as per configuration.")
+ if version_code == ""
+ UI.user_error!("Must provide a version code when release verification is skipped.")
+ end
+ if Supply.config[:version_name].nil?
+ UI.user_error!("To force promote a :version_code, it is mandatory to enter the :version_name")
+ end
+ release = AndroidPublisher::TrackRelease.new(
+ name: Supply.config[:version_name],
+ version_codes: [version_code],
+ status: Supply.config[:track_promote_release_status] || Supply::ReleaseStatus::COMPLETED
+ )
+ end
+
+ release = releases.first unless Supply.config[:skip_release_verification]
+ track_to = client.tracks(Supply.config[:track_promote_to]).first || AndroidPublisher::Track.new(
+ track: Supply.config[:track_promote_to],
+ releases: []
+ )
+
+ rollout = (Supply.config[:rollout] || 0).to_f
+ if rollout > 0 && rollout < 1
+ release.status = Supply::ReleaseStatus::IN_PROGRESS
+ release.user_fraction = rollout
+ else
+ release.status = Supply.config[:track_promote_release_status]
+ release.user_fraction = nil
+ end
+
+ if track_to
+ # Its okay to set releases to an array containing the newest release
+ # Google Play will keep previous releases there this release is a partial rollout
+ track_to.releases = [release]
+ else
+ track_to = AndroidPublisher::Track.new(
+ track: Supply.config[:track_promote_to],
+ releases: [release]
+ )
+ end
+
+ client.update_track(Supply.config[:track_promote_to], track_to)
+ UI.message("confirmed that update_track was reached: #{Supply.config[:track_promote_to]} #{release}")
+ end
+ end
+end
diff --git a/fastlane/patches/supply_custom_promote_config.rb b/fastlane/patches/supply_custom_promote_config.rb
new file mode 100644
index 0000000000..dc50163451
--- /dev/null
+++ b/fastlane/patches/supply_custom_promote_config.rb
@@ -0,0 +1,38 @@
+# Patch Description:
+# Fixes issue where Fastlane 'Supply' doesn't recognize previous builds
+# when promoting to another track.
+#
+# Source: https://github.com/artsy/eigen/pull/10262
+# Author: Brian Beckerle (@brainbicycle)
+#
+
+module Supply
+ class Options
+ class << self
+ alias_method :original_available_options, :available_options
+
+ def available_options
+ original_options = original_available_options
+ custom_options = [
+ FastlaneCore::ConfigItem.new(
+ key: :skip_release_verification,
+ env_name: "SUPPLY_SKIP_RELEASE_VERIFICATION",
+ description: "If set to true, skips checking if the version code exists in the track before promoting",
+ type: Boolean,
+ default_value: false,
+ optional: true
+ )
+ ]
+
+ # Only add custom options if they aren't already present
+ custom_options.each do |custom_option|
+ unless original_options.any? { |option| option.key == custom_option.key }
+ original_options << custom_option
+ end
+ end
+
+ original_options
+ end
+ end
+ end
+end