π Setting up CI CD Pipeline for Flutter with Fastlane & Bitbucket Pipelines - dhruvin207/flutter-common-utils GitHub Wiki
Automating builds and releases is a game-changer. In this post, Iβll walk through the full CI/CD setup for a Flutter app using Bitbucket Pipelines and Fastlane. Weβll cover everything from environment setup to deployment on Google Play (internal testing) and TestFlight. Bitbucket Pipelines is an integrated CI/CD service built into Bitbucket Cloudβ
, which lets us define automated build/deploy steps in a bitbucket-pipelines.yml
file. By combining this with Fastlane, we can achieve a smooth, end-to-end Flutter release workflow.
π Table of Contents
- Overview
- Prerequisites
- Pipeline Architecture
- Bitbucket Pipelines Configuration
- Fastlane Workflows
- Environment Variables
- Version Management
- Release Process
- Troubleshooting
π Overview
This project uses Bitbucket Pipelines integrated with Fastlane to automate the build and deployment process for both Android and iOS platforms. The pipeline supports:
- Automated version bumping
- Building development and production flavors
- Generating APK/AAB for Android
- Generating IPA for iOS
- Deploying to Google Play Store (Internal Testing)
- Deploying to TestFlight
- Creating release tags
π Prerequisites
Before diving in, make sure you have:
- ποΈ Bitbucket repository for your Flutter project.
- π¦ Flutter 3.x or higher (ensures compatibility with modern tools).
- π Ruby & Bundler (for Fastlane). (sudo gem install bundler)
- π€ Android SDK and build tools (for Android builds).
- π Xcode, CocoaPods, and an Apple Developer account (for iOS builds).
- π Signing credentials: Android keystore (base64-encoded) and iOS distribution cert/profiles (or App Store Connect API key).
With these in place, weβre ready to install Fastlane and configure our pipeline.
π» Fastlane Installation
Fastlane is a Ruby-based tool, so we install it like any Ruby gem (or via Homebrew on macOS). For example, as per Flutterβs docs, you can run:
gem install fastlane # or on macOS: brew install fastlane
Alternatively, you can use Bundler for reproducible installs: add gem 'fastlane'
to your Gemfile
(both at the project root or in android/
and ios/
if you have separate Gemfiles), then run bundle install. After installing Fastlane, initialize it in each platform folder. In your project:
cd android
fastlane init # creates android/fastlane/Fastfile and Appfile
cd ../ios
fastlane init # creates ios/fastlane/Fastfile and Appfile
During init, Fastlane will ask some questions about your appβs package/bundle identifiers. Make sure to set the Android package name and iOS bundle identifier correctly in the generated Appfiles
, and fill in your Apple ID/team ID when prompted. Once this is done, youβll have the basic Fastlane structure to define lanes (workflows) for building and releasing your app.
ποΈ Pipeline Architecture
The pipeline is structured as follows:
Project Root
βββ bitbucket-pipelines.yml # Main pipeline configuration
βββ fastlane/ # Common Fastlane configuration
β βββ Fastfile # Common Fastlane lanes
βββ android/ # Android-specific configuration
β βββ fastlane/
β β βββ Fastfile # Android-specific lanes
β βββ Gemfile # Ruby dependencies for Android
βββ ios/ # iOS-specific configuration
βββ fastlane/
β βββ Fastfile # iOS-specific lanes
βββ Gemfile # Ruby dependencies for iOS
π Bitbucket Pipelines Configuration
The bitbucket-pipelines.yml
file defines several pipeline workflows:
In bitbucket-pipelines.yml, we define a series of steps that correspond to our CI/CD flow. Key steps include Setup Environment, Version Bump, Build Android, Deploy Android, Build & Deploy iOS, and Create Release Tagβ. Hereβs a high-level summary:
- Setup Environment (
setup-environment
): Validate the branch (e.g. only run on Dev or Main), install Ruby and Bundler, and prepare any global requirements. - Bump Version (
bump-version
): Optionally increment the app version. This runs a Fastlane lane that updates pubspec.yaml and opens a PR with the new version. - Build Android (
build-android
): Run Flutter build (APK or AAB) for the specified flavor and build type (debug, release, etc.). We also generate symbol files if needed. - Deploy Android (
deploy-android
): Use Fastlaneβssupply
action to upload the built artifact to Google Playβs internal testing track. - Build & Deploy iOS (
build-deploy-ios
): On a macOS runner, install CocoaPods, sync certificates/profiles (using Fastlane match or API), build the IPA, and upload to TestFlight via Fastlane. - Create Release Tag (
create-release-tag
): Finally, run a Fastlane lane that tags the Git repo with the new version number (e.g. v1.2.3) to mark the release.
βοΈ Fastlane Workflows
Common Workflows
Version Management
The main Fastlane file (fastlane/Fastfile
) contains common lanes used across platforms:
default_platform(:android)
platform :android do
desc "Increment the version number in pubspec.yaml"
lane :bump_version do |options|
version_type = options[:type] || "patch" # Default to "patch" if no type is provided
# Calculate the path to pubspec.yaml (relative to the fastlane directory)
pubspec_path = "../pubspec.yaml"
# Check if the file exists
unless File.exist?(pubspec_path)
UI.error("pubspec.yaml not found at #{pubspec_path}. Ensure the path is correct.")
exit 1
end
# Read the pubspec.yaml file
pubspec_content = File.read(pubspec_path)
# Extract the current version and version code
version_match = pubspec_content.match(/version:\s*(\d+)\.(\d+)\.(\d+)\+(\d+)/)
unless version_match
UI.error("""
β Could not find valid version format in pubspec.yaml
Expected format: x.x.x+0 (e.g., 1.2.3+0)
Please ensure version follows semantic versioning pattern: MAJOR.MINOR.PATCH+BUILD
Example: version: 1.2.3+0
""")
exit 1
end
major, minor, patch, version_code = version_match.captures.map(&:to_i)
old_version = "#{major}.#{minor}.#{patch}+#{version_code}"
# Increment version based on the specified type
case version_type
when "major"
major += 1
minor = 0
patch = 0
version_code += 1
when "minor"
minor += 1
patch = 0
version_code += 1
when "patch"
patch += 1
version_code += 1
when "version_code"
version_code += 1
else
UI.error("Invalid version type: #{version_type}. Use 'major', 'minor', 'patch', or 'version_code'.")
exit 1
end
# Construct the new version string
new_version = "#{major}.#{minor}.#{patch}+#{version_code}"
new_display_version = "#{major}.#{minor}.#{patch}(#{version_code})"
# Get the base branch (current branch before creating new branch)
base_branch = sh("git rev-parse --abbrev-ref HEAD").strip
# Get the last version bump commit
last_version_commit = sh(
"git log --grep='Bumped Version' -n 1 --pretty=format:'%H'",
error_callback: lambda { |msg|
UI.important("No previous version bump found, will include all commits")
"HEAD^"
}
).strip
# Get all commits between last version and current HEAD
new_changes = sh(
"git log #{last_version_commit}..HEAD --pretty=format:'β’ %s%n'",
error_callback: lambda { |msg|
UI.error("Failed to get commit messages: #{msg}")
""
}
).strip
# Create a new branch for version bump
bump_branch = "bump_version/#{new_version}"
sh "git checkout -b #{bump_branch}"
# Update the pubspec.yaml file
new_pubspec_content = pubspec_content.gsub(
/version:\s*\d+\.\d+\.\d+\+\d+/,
"version: #{new_version}"
)
File.write(pubspec_path, new_pubspec_content)
FastlaneCore::PrintTable.print_values(
config: {
"Version Type" => version_type.upcase,
"Version" => "#{old_version} => #{new_version}"
},
title: "Version Update Summary",
)
update_env_variable("APP_VERSION", new_version)
# Add files to git staging
sh "git add #{pubspec_path}"
# Commit the changes
commit_message = "Bumped Version #{new_display_version}"
sh "git commit -m \"#{commit_message}\""
# Push the new branch
sh "git push -u origin #{bump_branch}"
UI.success("Bumped Version committed and pushed to branch: #{bump_branch}")
# Create pull request using Bitbucket API
begin
# You'll need to set these environment variables in your CI/CD settings
username = options[:bitbucket_username] || ""
workspace = options[:bitbucket_workspace] || ""
repository = options[:bitbucket_repository] || ""
app_password = options[:bitbucket_app_password] || ""
pr_reviewers = options[:bitbucket_pr_reviewers] || ""
validate_required_params({
bitbucket_username: username,
bitbucket_workspace: workspace,
bitbucket_repository: repository,
bitbucket_app_password: app_password,
bitbucket_pr_reviewers: pr_reviewers
})
# Modify the PR description to include commit messages
pr_description = "Automated version bump PR\n\nUpdated version from #{old_version} to #{new_version}\n\nChanges included in this version:\n\n#{new_changes}"
# Prepare PR creation payload
payload = {
title: commit_message,
description: pr_description,
source: {
branch: {
name: bump_branch
}
},
destination: {
branch: {
name: base_branch
}
},
reviewers: pr_reviewers.split(',').map { |uuid| { uuid: uuid.strip } }
}.to_json
# Create PR using curl
response = sh(
"curl -X POST -H \"Content-Type: application/json\" " \
"-u #{username}:#{app_password} " \
"https://api.bitbucket.org/2.0/repositories/#{workspace}/#{repository}/pullrequests " \
"-d '#{payload}'"
)
UI.success "β
Pull request created successfully for version #{new_display_version}"
rescue => ex
UI.error "Failed to create pull request: #{ex.message}"
exit 1
end
end
desc "Create a release tag in the repository"
lane :create_release_tag do |options|
# Define the path to the pubspec.yaml file
pubspec_file = File.expand_path('../pubspec.yaml', __dir__)
# Read and parse the pubspec.yaml file
unless File.exist?(pubspec_file)
UI.error "β pubspec.yaml file not found at: #{pubspec_file}"
return
end
pubspec_content = YAML.load_file(pubspec_file)
# Extract the app version from pubspec.yaml
app_version = pubspec_content["version"]
unless app_version
UI.error "β Could not find 'version' in pubspec.yaml"
return
end
formatted_version = format_version(app_version)
tag_name = "v#{formatted_version}" # Tag name, e.g., "v1.0.0(1)"
project_root = File.expand_path('../..', __dir__)
release_note_file = File.join(
project_root,
"release_note.txt"
)
begin
if File.exist?(release_note_file)
release_note = File.read(release_note_file).strip
UI.success "β
Release notes loaded successfully"
else
UI.important "βRelease note file not found at: #{release_note_file}"
release_note = "No release notes provided"
end
rescue StandardError => e
UI.error "Failed to read release notes: #{e.message}"
release_note = "No release notes provided"
end
current_commit = sh("git rev-parse HEAD").strip
# Check if the tag already exists
existing_tag = sh("git tag -l '#{tag_name}'").strip
if existing_tag == tag_name
UI.important "βTag '#{tag_name}' already exists. Replacing it."
# Delete the existing tag locally and remotely
sh("git tag -d '#{tag_name}'")
sh("git push origin :refs/tags/'#{tag_name}'")
UI.success "β
Existing tag '#{tag_name}' deleted."
end
# Create the tag using the release note as the tag message
add_git_tag(
tag: tag_name,
message: release_note,
commit: current_commit # Tags the specific commit
)
# Push the tag to the remote repository
push_git_tags
end
def update_env_variable(key, value)
env_file = File.join(FastlaneCore::FastlaneFolder.path, "export_env")
# Create file if doesn't exist
FileUtils.touch(env_file) unless File.exist?(env_file)
# Read existing content
lines = File.exist?(env_file) ? File.readlines(env_file) : []
# Convert to hash for easy manipulation
env_hash = {}
lines.each do |line|
if line.include?("=")
k, v = line.strip.split("=", 2)
env_hash[k] = v
end
end
# Update specific variable
env_hash[key] = value
# Write back to file
File.open(env_file, "w") do |f|
env_hash.each do |k, v|
f.puts "#{k}=#{v}"
end
end
UI.success "β
Updated #{key} in environment file"
end
def get_env_variable(key)
env_file = File.join(FastlaneCore::FastlaneFolder.path, "export_env")
unless File.exist?(env_file)
UI.important "β οΈ Environment file not found at: #{env_file}"
return nil
end
begin
# Read and parse file
File.readlines(env_file).each do |line|
if line.include?("=")
k, v = line.strip.split("=", 2)
return v if k == key
end
end
UI.important "β οΈ Key '#{key}' not found in environment file"
return nil
rescue StandardError => e
UI.error "Failed to read environment file: #{e.message}"
return nil
end
end
def format_version(version_string)
begin
version_part, build_part = version_string.split('+')
"#{version_part}(#{build_part})"
rescue StandardError => e
UI.error "Failed to format version: #{e.message}"
version_string
end
end
def validate_required_params(params)
# params is a hash where keys are parameter names and values are their actual values
missing_params = []
# Iterate through each parameter to check if it's empty
params.each do |key, value|
missing_params << key.to_s if value.to_s.strip.empty?
end
# Handle missing parameters
if !missing_params.empty?
UI.user_error! "β Missing required parameters: #{missing_params.join(', ')}"
else
UI.success "β
All required parameters present"
end
end
end
Android Specific Workflows
The Android Fastlane file (android/fastlane/Fastfile
) contains Android-specific lanes:
Build and Deploy
import "../../fastlane/Fastfile"
default_platform(:android)
platform :android do
desc "Build Dev APK Release"
lane :build_dev_release_apk do |options|
validate_and_build(options.merge(flavor: "dev",build_type: "release",extension: "apk"))
end
desc "Build Dev APK Debug"
lane :build_dev_debug_apk do |options|
validate_and_build(options.merge(flavor: "dev",build_type: "debug",extension: "apk"))
end
desc "Build Dev AAB Release"
lane :build_dev_release_aab do |options|
validate_and_build(options.merge(flavor: "dev",build_type: "release",extension: "aab"))
end
desc "Build Prod APK Release"
lane :build_prod_release_apk do |options|
validate_and_build(options.merge(flavor: "prod",build_type: "release",extension: "apk"))
end
desc "Build Prod APK Debug"
lane :build_prod_debug_apk do |options|
validate_and_build(options.merge(flavor: "prod",build_type: "debug",extension: "apk"))
end
desc "Build Prod AAB Release"
lane :build_prod_release_aab do |options|
validate_and_build(options.merge(flavor: "prod",build_type: "release",extension: "aab"))
end
lane :validate_and_build do |options|
build_type = options[:build_type] || "release"
if build_type == "release"
setup_keystore(options)
end
build(
flavor: options[:flavor],
build_type: build_type,
extension: options[:extension],
dart_defines: options[:dart_defines]
)
end
desc "π Setup Keystore"
private_lane :setup_keystore do |options|
# Retrieve options or set defaults
base64_keystore = options[:base64_keystore]
key_alias = options[:key_alias]
keystore_password = options[:keystore_password]
key_password = options[:key_password]
keystore_path = File.join(Dir.pwd, "keystore.jks")
validate_required_params({
base64_keystore: base64_keystore,
key_alias: key_alias,
keystore_password: keystore_password,
keystore_path: keystore_path
})
begin
decoded_content = Base64.strict_decode64(base64_keystore.to_s.strip)
File.open(keystore_path, "wb") do |file|
file.write(decoded_content)
end
if File.size(keystore_path) == 0
UI.user_error! "Keystore file is empty after decoding"
end
UI.success("β
Keystore file created: #{keystore_path} (#{File.size(keystore_path)} bytes)")
rescue StandardError => e
UI.error "Failed to decode base64: #{e.message}"
UI.user_error! "Base64 decode failed"
end
# Verify the keystore using `keytool`
verify_command = "keytool -list -keystore #{keystore_path} -alias #{key_alias} -storepass '#{keystore_password}' -keypass '#{key_password}'"
UI.message("π Verifying keystore configuration...")
begin
sh(verify_command)
UI.success("β
Keystore verification successful!")
rescue => e
UI.user_error!("Keystore verification failed,provide the valid configurations\n" \
"Error: #{e}\n\n" \
"Properties Status:\n" \
"β
Keystore File: #{File.exist?(keystore_path)}\n" \
"β
Alias Provided: #{!key_alias.nil?}\n" \
"β
Store Password: #{!keystore_password.nil?}\n" \
"β
Key Password: #{!key_password.nil?}")
end
# Path for the key.properties file
key_properties_path = File.join(Dir.pwd, "key.properties")
# Ensure the android directory exists
FileUtils.mkdir_p(File.dirname(key_properties_path))
# Generate the key.properties file with the specified format
File.open(key_properties_path, "w") do |file|
file.write <<-KEY_PROPERTIES
keystore_path=#{keystore_path}
key_alias=#{key_alias}
key_store_password=#{keystore_password}
key_password=#{key_password}
KEY_PROPERTIES
end
UI.success("Key properties file created at: #{key_properties_path}")
# Read and print the file content
file_content = File.read(key_properties_path)
UI.message("Key properties file content:\n#{file_content}")
end
desc "Build the App"
lane :build do |options|
flavor = options[:flavor]
build_type = options[:build_type] || "release"
extension = options[:extension] || "apk"
symbols_directory = options[:symbols_directory] || "build/app/outputs/symbols/#{flavor}/#{build_type}"
validate_required_params({
flavor: flavor
})
# Dart defines
dart_defines = options[:dart_defines] || []
dart_defines = dart_defines.split(",") if dart_defines.is_a?(String)
dart_define_args = dart_defines.map { |define| "--dart-define=#{define}" }.join(" ")
# Add obfuscation and debug symbol generation
obfuscation_args = build_type == "release" ? "--obfuscate --split-debug-info=#{symbols_directory}" : ""
build_command = extension == "aab" ? "aab" : "apk"
# Print build information in table format
UI.message "π± Build Configuration Details:"
FastlaneCore::PrintTable.print_values(
config: {
"Environment" => flavor == "prod" ? "Production" : "Development",
"Build Type" => build_type,
"Output Format" => extension.upcase,
},
title: "Build Parameters"
)
UI.message "π¨ Starting build process..."
# Execute flutter build command
sh "flutter build #{build_command} --flavor #{flavor} --#{build_type} #{obfuscation_args} #{dart_define_args}"
# Define output paths based on Flutter's build pattern
base_path = File.expand_path(File.join(
FastlaneCore::FastlaneFolder.path,
"..",
"..",
"build",
"app",
"outputs"
))
output_path = if extension == "apk"
File.expand_path(File.join(
base_path,
"apk",
flavor,
build_type,
"app-#{flavor}-#{build_type}.apk"
))
else
File.expand_path(File.join(
base_path,
"bundle",
"#{flavor}Release",
"app-#{flavor}-release.aab"
))
end
# Create and set permissions for output directory
output_dir = File.dirname(output_path)
UI.message "Setting up output directory: #{output_dir}"
begin
FileUtils.mkdir_p(output_dir)
# Grant read/write permissions to directory
FileUtils.chmod_R(0777, output_dir)
UI.success "β
Directory permissions set: #{output_dir}"
rescue StandardError => e
UI.error "Failed to set permissions: #{e.message}"
end
update_env_variable("BUILD_OUTPUT_PATH", output_path)
update_env_variable("FLAVOR", flavor)
package_name = ENV['ANDROID_PACKAGE_NAME']
update_env_variable("ANDROID_PACKAGE_NAME", package_name)
if build_type == "release"
create_symbol_zip(flavor: flavor)
end
# Success message with build details
UI.success "β
Build completed successfully!"
UI.success "π¦ Generated #{extension.upcase} for #{flavor.upcase} (#{build_type})"
UI.important "π Output location: #{output_path}"
end
desc "Create a symbol zip file"
lane :create_symbol_zip do |options|
flavor = options[:flavor]
validate_required_params({
flavor: flavor
})
project_root = File.expand_path('../..', __dir__)
# Find native libraries dynamically by searching in multiple possible locations
possible_paths = [
# AGP 7.0+ path
File.join(project_root, "build", "app", "intermediates", "merged_native_libs", "#{flavor}Release", "merge#{flavor}ReleaseNativeLibs", "out", "lib"),
# AGP 4.0+ path
File.join(project_root, "build", "app", "intermediates", "merged_native_libs", "#{flavor}Release", "out", "lib"),
# Alternative path
File.join(project_root, "build", "app", "intermediates", "stripped_native_libs", "#{flavor}Release", "out", "lib"),
# Another possible path
File.join(project_root, "build", "app", "intermediates", "cmake", "#{flavor}Release", "obj")
]
# Use the first path that exists and contains files
symbols_source = nil
possible_paths.each do |path|
if File.directory?(path) && !Dir.glob("#{path}/**/*.so").empty?
symbols_source = path
break
end
end
# If no path is found, throw an error
if symbols_source.nil?
UI.user_error!("β Could not find native libraries (.so files) in any of the expected locations")
else
UI.success("β
Found native libraries at: #{symbols_source}")
end
# Create symbols.zip in the build directory
symbols_zip = File.join(project_root, "build", "symbols.zip")
if File.directory?(symbols_source) && !Dir.glob("#{symbols_source}/**/*.so").empty?
UI.message("Creating symbols zip...")
if Gem.win_platform?
# Windows: Compress only the contents of the source folder
Dir.chdir(symbols_source) do
sh("powershell Compress-Archive -Path './*' -DestinationPath '#{symbols_zip}' -Force")
end
else
# macOS/Linux: Compress only the contents of the source folder
Dir.chdir(symbols_source) do
sh("zip -r '#{symbols_zip}' ./*")
end
end
update_env_variable("SYMBOL_ZIP_PATH", symbols_zip)
UI.success("β
Symbols zip created at: #{symbols_zip}")
else
UI.user_error!("β Native libraries directory not found or contains no .so files: #{symbols_source}")
end
end
desc "Upload APK to Internal Testing Track"
lane :upload_to_internal_testing do |options|
begin
output_path = options[:output_path]
symbols_zip = options[:symbols_zip]
json_key_data = options[:json_key_data]
package_name = options[:package_name]
validate_required_params({
output_path: output_path,
json_key_data: json_key_data,
package_name: package_name,
mapping: symbols_zip
})
# Step 1: Validate the JSON key file
validate_play_store_json_key(
json_key_data: json_key_data
)
UI.message("β
JSON key file validation successful!")
supply(
json_key_data: json_key_data,
package_name: package_name,
track: "internal",
aab: output_path,
mapping: symbols_zip
)
UI.success("β
App successfully uploaded to Internal Testing!")
rescue => e
UI.user_error! "β Error occurred: #{e.message}"
end
end
end
iOS Specific Workflows
The iOS Fastlane file (ios/fastlane/Fastfile
) contains iOS-specific lanes:
Building and Deployment
import "../../fastlane/Fastfile"
default_platform(:ios)
platform :ios do
desc "Full pipeline: install pods, sync certificates, build IPA, and deploy"
lane :build_deploy do |options|
# Execute each step in order.
install_pods
sync_certificates(options)
build_flutter_ipa(options)
deploy(options)
end
desc "Install CocoaPods dependencies"
lane :install_pods do
UI.message("π¦ Installing CocoaPods dependencies...")
cocoapods()
UI.success("β
Pods installed!")
end
desc "Sync certificates and provisioning profiles"
lane :sync_certificates do |options|
flavor = options[:flavor] || "dev"
git_repo_url = options[:git_repo_url] || ""
git_basic_authorization = options[:git_basic_authorization] || ""
apple_team_id = options[:apple_team_id] || ""
apple_id_username = options[:apple_id_username] || ""
apple_id_password = options[:apple_id_password] || ""
keychain_password = options[:keychain_password] || ""
key_id = options[:key_id] || ""
issuer_id = options[:issuer_id] || ""
key_content = options[:key_content] || ""
ENV['FASTLANE_USER'] = apple_id_username
ENV['FASTLANE_PASSWORD'] = apple_id_password
ENV['MATCH_KEYCHAIN_PASSWORD'] = keychain_password
app_identifier = if flavor == "prod"
"com.example.app"
else
"com.example.app.dev"
end
validate_required_params({
git_repo_url: git_repo_url,
git_basic_authorization: git_basic_authorization,
apple_team_id: apple_team_id,
apple_id_username: apple_id_username,
apple_id_password: apple_id_password,
keychain_password: keychain_password,
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content
})
# First, set up the App Store Connect API key
UI.message("Setting up App Store Connect API key...")
app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
duration: 500, # maximum 1200
in_house: false,
is_key_content_base64: true
)
# Use the same API key setup for match, but without custom hash creation
# This avoids potential issues with key formatting and curve name errors
UI.message("Running match to sync certificates and profiles...")
match(
readonly: false,
git_url: git_repo_url,
git_basic_authorization: git_basic_authorization,
git_branch: "main",
type: "appstore",
username: apple_id_username,
app_identifier: app_identifier,
team_id: apple_team_id,
storage_mode: "git"
)
UI.success("β
Certificates and profiles synced!")
end
desc "Build the IPA using Flutter"
lane :build_flutter_ipa do |options|
flavor = options[:flavor]
build_type = options[:build_type] || "release"
extension = options[:extension] || "ipa"
symbols_directory = options[:symbols_directory] || "build/app/outputs/symbols/#{flavor}/#{build_type}"
UI.message("ποΈ Building IPA with Flutter...")
# Dart defines
dart_defines = options[:dart_defines] || []
dart_defines = dart_defines.split(",") if dart_defines.is_a?(String)
dart_define_args = dart_defines.map { |define| "--dart-define=#{define}" }.join(" ")
# Add obfuscation and debug symbol generation
obfuscation_args = build_type == "release" ? "--obfuscate --split-debug-info=#{symbols_directory}" : ""
# Prepare entitlements file
prepare_entitlements(options)
# Print build information in table format
UI.message "π± Build Configuration Details:"
FastlaneCore::PrintTable.print_values(
config: {
"Environment" => flavor == "prod" ? "Production" : "Development",
"Build Type" => build_type,
"Output Format" => extension.upcase,
},
title: "Build Parameters"
)
UI.message "π¨ Starting build process..."
# Execute flutter build command
sh "flutter build ipa --flavor #{flavor} --#{build_type} #{obfuscation_args} #{dart_define_args}"
UI.success("β
IPA built successfully!")
end
desc "Deploy the IPA to TestFlight/Firebase (Deployment logic placeholder)"
lane :deploy do |options|
UI.message("π Deploying IPA to TestFlight...")
key_id = options[:key_id] || ""
issuer_id = options[:issuer_id] || ""
key_content = options[:key_content] || ""
# Define output paths based on Flutter's build pattern
base_path = File.expand_path(File.join(
FastlaneCore::FastlaneFolder.path,
"..",
"..",
"build",
"ios"
))
output_path = File.expand_path(File.join(
base_path,
"ipa",
"app.ipa"
))
# Add read and write permissions to the IPA file
UI.message("π Setting file permissions for IPA...")
if File.exist?(output_path)
FileUtils.chmod(0644, output_path) # Sets read/write for owner, read for others
UI.success("β
File permissions updated successfully!")
else
UI.error("β IPA file not found at #{output_path}")
end
# Set up App Store Connect API key for TestFlight upload
UI.message("Setting up App Store Connect API key for TestFlight...")
app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
duration: 500, # maximum 1200
in_house: false,
is_key_content_base64: true
)
# Upload to TestFlight
UI.message("Uploading IPA to TestFlight...")
pilot(
distribute_external: false,
skip_waiting_for_build_processing: true,
skip_submission: true,
ipa: output_path,
)
end
lane :prepare_entitlements do |options|
entitlements_content = options[:entitlements_content] || ""
# Define the path where the entitlements file should be created
base_path = File.expand_path(File.join(
FastlaneCore::FastlaneFolder.path,
"..",
"..",
"ios"
))
entitlements_path = File.expand_path(File.join(
base_path,
"Runner",
"Runner.entitlements"
))
# Check if the entitlements file already exists
if File.exist?(entitlements_path)
UI.message("β
Runner.entitlements already exists at #{entitlements_path}")
else
# Only check for content if we need to create the file
if entitlements_content.nil? || entitlements_content.empty?
UI.user_error!("entitlements_content is missing! Make sure it's set in GitHub Secrets or ENV variables.")
end
# Write the content to the file
File.write(entitlements_path, entitlements_content)
UI.message("β
Successfully created Runner.entitlements at #{entitlements_path}")
end
end
end
π Environment Variables
The pipeline uses various environment variables for configuration:
Common Variables
BUMP_VERSION
: Type of version bump (MAJOR, MINOR, PATCH, VERSION_CODE, NONE)BUILD_TYPE
: Output format (APK, AAB)BITBUCKET_BRANCH
: Current branch (Dev or Main)
Android Variables
ANDROID_KEYSTORE_ALIAS
: Keystore aliasANDROID_KEYSTORE_PASSWORD
: Keystore passwordANDROID_KEYSTORE_ALIAS_PASSWORD
: Key passwordANDROID_KEYSTORE_BASE64
: Base64-encoded keystore fileANDROID_PACKAGE_NAME
: Android package nameGCP_SERVICE_ACCOUNT_JSON_DATA
: Google Play service account JSON
iOS Variables
APPLE_ID_USER_NAME
: Apple ID usernameAPPLE_ID_PASSWORD
: App-specific password for Apple IDAPPLE_TEAM_ID
: Apple Developer Team IDKEYCHAIN_PASSWORD
: Keychain password for certificate storageAPPSTORE_CONNECT_KEY_ID
: App Store Connect API key IDAPPSTORE_CONNECT_ISSUER_ID
: App Store Connect API issuer IDAPPSTORE_CONNECT_KEY_CONTENT_BASE64
: Base64-encoded API key contentIOS_ENTITLEMENTS_CONTENT
: iOS entitlements file content
App Configuration Variables
APP_ID
: App ID for configurationAPP_SECRET
: App secret for configurationSERVICE_URL
: Service URL for the appMAP_API_KEY
: Google Maps API key string
π¦ Version Management
The pipeline supports automatic version bumping with the following options:
- MAJOR: Increments the major version (1.0.0 β 2.0.0)
- MINOR: Increments the minor version (1.0.0 β 1.1.0)
- PATCH: Increments the patch version (1.0.0 β 1.0.1)
- VERSION_CODE: Increments only the build number (1.0.0+1 β 1.0.0+2)
- NONE: Skips version increment
The version bump process:
- Creates a new branch
- Updates the version in pubspec.yaml
- Commits the change
- Creates a pull request with the changes
π·οΈ Release Process
The release process involves:
- Bumping the version
- Building the app for both platforms
- Deploying to internal testing/TestFlight
- Creating a release tag
π§ Troubleshooting
Common Issues
-
Keystore Verification Failed:
- Ensure the keystore file is correctly encoded in base64
- Verify the keystore password and alias are correct
-
Certificate Sync Failed:
- Check Apple ID credentials
- Verify the App Store Connect API key is valid
-
Build Failures:
- Check Flutter version compatibility
- Ensure all dependencies are correctly installed
-
Deployment Failures:
- Verify Google Play service account has correct permissions
- Check App Store Connect API key permissions
π Complete Configuration Files
Bitbucket Pipelines Configuration
Below is the complete bitbucket-pipelines.yml
file:
image: ghcr.io/cirruslabs/flutter:3.29.0
definitions:
variables:
build-variables: &build-pipeline-variables
- name: BUILD_TYPE
default: AAB
allowed-values:
- APK
- AAB
version-variables: &version-pipeline-variables
- name: BUMP_VERSION
default: PATCH
allowed-values:
- MAJOR
- MINOR
- PATCH
- VERSION_CODE
- NONE
steps:
- step: &setup-environment
name: Setup Environment
script:
- |
if [ "$BITBUCKET_BRANCH" != "Dev" ] && [ "$BITBUCKET_BRANCH" != "Main" ]; then
echo "β οΈ Pipeline Aborted: Invalid branch detected"
echo "Current branch: $BITBUCKET_BRANCH"
echo "Please use either 'dev' branch for development or 'main' branch for production"
exit 1
fi
# Install Ruby and Bundler
- apt-get update && apt-get install -y ruby-full
- gem install bundler
- step: &bump-version
name: Bump Version
script:
- flutter doctor
- flutter clean
- bundle config set path ~/.gem
- bundle install --retry=3
- bundle exec fastlane --version
- if [ "$BUMP_VERSION" = "NONE" ]; then
echo "Skipping version increment";
else
bundle exec fastlane bump_version type:$(echo "$BUMP_VERSION" | tr '[:upper:]' '[:lower:]') bitbucket_username:"$RENOVATE_USERNAME" bitbucket_workspace:"$BITBUCKET_WORKSPACE" bitbucket_repository:"$BITBUCKET_REPOSITORY" bitbucket_app_password:"$RENOVATE_PASSWORD" bitbucket_pr_reviewers:"$PR_REVIEWERS";
fi
- step: &build-android
name: Build Android Application
script:
- |
export FLAVOR=$(if [ "$BITBUCKET_DEPLOYMENT_ENVIRONMENT" = "production" ]; then
echo "prod"
else
echo "dev"
fi)
- echo "Building with flavor:${FLAVOR:-none}"
- export BUILD_TYPE_LOWER=$(echo "$BUILD_TYPE" | tr '[:upper:]' '[:lower:]')
- echo "Building with type:$BUILD_TYPE_LOWER"
- flutter --version
- flutter clean
- flutter pub get
- cd android
- bundle config set path ~/.gem
- bundle install --retry=3
- bundle exec fastlane --version
- bundle exec fastlane validate_and_build dart_defines:"APP_ID=$APP_ID,APP_SECRET=$APP_SECRET,SERVICE_URL=$SERVICE_URL,MAP_API_KEY=$MAP_API_KEY, key_alias:"$ANDROID_KEYSTORE_ALIAS" keystore_password:"$ANDROID_KEYSTORE_PASSWORD" key_password:"$ANDROID_KEYSTORE_ALIAS_PASSWORD" base64_keystore:"$ANDROID_KEYSTORE_BASE64" flavor:"$FLAVOR" extension:"$BUILD_TYPE_LOWER"
- cat fastlane/export_env
- export BUILD_OUTPUT_PATH=$(grep BUILD_OUTPUT_PATH fastlane/export_env | cut -d'=' -f2 | tr -d '"')
- echo "Build output path is:$BUILD_OUTPUT_PATH"
- cd ..
- pwd # Print the current working directory
- ls -R # List all files and folders recursively
- find . -name "*.aab"
- chmod -R +r build/app/outputs/ # Ensure all output files are readable
- chmod -R +r build/app/intermediates/merged_native_libs/
- chmod +r android/fastlane/export_env
artifacts:
- build/app/outputs/**/*.apk
- build/app/outputs/**/*.aab
- build/app/intermediates/merged_native_libs/**/*.zip
- android/fastlane/export_env
- step: &deploy-android
name: Deploy Android Application
script:
- cd android
- bundle config set path ~/.gem
- bundle install --retry=3
- bundle exec fastlane --version
- cat fastlane/export_env
- while IFS='=' read -r key value; do
key=$(echo "$key" | tr -d ' ');
value=$(echo "$value" | tr -d '"' | tr -d ' ');
export "$key=$value";
echo "$key is set to $value";
done < fastlane/export_env
- bundle exec fastlane upload_to_internal_testing flavor:"$FLAVOR" output_path:"$BUILD_OUTPUT_PATH" json_key_data:"$GCP_SERVICE_ACCOUNT_JSON_DATA" package_name:"$ANDROID_PACKAGE_NAME" symbols_zip:"$SYMBOLE_ZIP_PATH"
- cd ..
- step: &build-deploy-ios
name: Build And Deploy iOS App
script:
- |
export FLAVOR=$(if [ "$BITBUCKET_DEPLOYMENT_ENVIRONMENT" = "production" ]; then
echo "prod"
else
echo "dev"
fi)
- flutter clean
- flutter pub get
- cd ios
- bundle config set path ~/.gem
- bundle install --retry=3
- bundle exec fastlane --version
- bundle exec fastlane build_deploy dart_defines:"APP_ID=$APP_ID,APP_SECRET=$APP_SECRET,SERVICE_URL=$SERVICE_URL,MAP_API_KEY=$MAP_API_KEY, flavor:"$FLAVOR" git_repo_url:"$GIT_REPO_URL" git_basic_authorization:"$GIT_BASIC_AUTHENTICATION" apple_id_username:"$APPLE_ID_USER_NAME" apple_id_password:"$APPLE_ID_PASSWORD" apple_team_id:"$APPLE_TEAM_ID" keychain_password:"$KEYCHAIN_PASSWORD" key_id:"$APPSTORE_CONNECT_KEY_ID" issuer_id:"$APPSTORE_CONNECT_ISSUER_ID" key_content:"$APPSTORE_CONNECT_KEY_CONTENT_BASE64 entitlements_content:$IOS_ENTITLEMENTS_CONTENT"
- step: &create-release-tag
name: Create Release Tag
script:
- bundle config set path ~/.gem
- bundle install --retry=3
- bundle exec fastlane --version
- bundle exec fastlane create_release_tag
pipelines:
custom:
bump-version:
- variables: *version-pipeline-variables
- step: *setup-environment
- step: *bump-version
ios-dev-build:
- step:
<<: *setup-environment
runs-on:
- macos
- step:
<<: *build-deploy-ios
deployment: development
runs-on:
- macos
ios-prod-build:
- step:
<<: *setup-environment
runs-on:
- macos
- step:
<<: *build-deploy-ios
deployment: production
runs-on:
- macos
android-dev-build:
- variables: *build-pipeline-variables
- step:
<<: *setup-environment
runs-on:
- macos
- step:
<<: *build-android
deployment: development
runs-on:
- macos
android-prod-build:
- variables: *build-pipeline-variables
- step:
<<: *setup-environment
runs-on:
- macos
- step:
<<: *build-android
deployment: production
runs-on:
- macos
π With this CI/CD setup, every change to Dev or Main can trigger automated versioning, building, and deployment. Android builds land on Google Play internal testing, and iOS builds go to TestFlight, all without manual intervention. Fastlane and Bitbucket Pipelines together make for a powerful release process. I hope this guide helps you streamline your Flutter releases!.