πŸš€ 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

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:

  1. Setup Environment (setup-environment): Validate the branch (e.g. only run on Dev or Main), install Ruby and Bundler, and prepare any global requirements.
  2. 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.
  3. 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.
  4. Deploy Android (deploy-android): Use Fastlane’s supply action to upload the built artifact to Google Play’s internal testing track.
  5. 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.
  6. 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 alias
  • ANDROID_KEYSTORE_PASSWORD: Keystore password
  • ANDROID_KEYSTORE_ALIAS_PASSWORD: Key password
  • ANDROID_KEYSTORE_BASE64: Base64-encoded keystore file
  • ANDROID_PACKAGE_NAME: Android package name
  • GCP_SERVICE_ACCOUNT_JSON_DATA: Google Play service account JSON

iOS Variables

  • APPLE_ID_USER_NAME: Apple ID username
  • APPLE_ID_PASSWORD: App-specific password for Apple ID
  • APPLE_TEAM_ID: Apple Developer Team ID
  • KEYCHAIN_PASSWORD: Keychain password for certificate storage
  • APPSTORE_CONNECT_KEY_ID: App Store Connect API key ID
  • APPSTORE_CONNECT_ISSUER_ID: App Store Connect API issuer ID
  • APPSTORE_CONNECT_KEY_CONTENT_BASE64: Base64-encoded API key content
  • IOS_ENTITLEMENTS_CONTENT: iOS entitlements file content

App Configuration Variables

  • APP_ID: App ID for configuration
  • APP_SECRET: App secret for configuration
  • SERVICE_URL: Service URL for the app
  • MAP_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:

  1. Creates a new branch
  2. Updates the version in pubspec.yaml
  3. Commits the change
  4. Creates a pull request with the changes

🏷️ Release Process

The release process involves:

  1. Bumping the version
  2. Building the app for both platforms
  3. Deploying to internal testing/TestFlight
  4. Creating a release tag

πŸ”§ Troubleshooting

Common Issues

  1. Keystore Verification Failed:

    • Ensure the keystore file is correctly encoded in base64
    • Verify the keystore password and alias are correct
  2. Certificate Sync Failed:

    • Check Apple ID credentials
    • Verify the App Store Connect API key is valid
  3. Build Failures:

    • Check Flutter version compatibility
    • Ensure all dependencies are correctly installed
  4. 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!.