Skip to content

Instantly share code, notes, and snippets.

@simplenotezy
Last active June 10, 2024 22:15
Show Gist options
  • Save simplenotezy/f52b470293edafa919584d911cc5e6b9 to your computer and use it in GitHub Desktop.
Save simplenotezy/f52b470293edafa919584d911cc5e6b9 to your computer and use it in GitHub Desktop.
Bootstraps a flutter project with Fastlane and Github Action to deploy on release
// ignore_for_file: avoid_print
import 'dart:io';
void main() {
// Read the PRODUCT_BUNDLE_IDENTIFIER from ios/Runner.xcodeproj/project.pbxproj
// or fallback to com.example.app
String? appBundleId = 'com.example.app';
File iosProjectFile = File('ios/Runner.xcodeproj/project.pbxproj');
if (iosProjectFile.existsSync()) {
String content = iosProjectFile.readAsStringSync();
RegExp regExp = RegExp(r'PRODUCT_BUNDLE_IDENTIFIER = (.+);');
Match? match = regExp.firstMatch(content);
if (match != null) {
appBundleId = match.group(1);
}
}
// read applicationId = "com.hejtech.flutter_fastlane_tutorial" from android/app/build.gradle
// or fallback to com.example.app
String? appPackage = 'com.example.app';
File androidBuildFile = File('android/app/build.gradle');
if (androidBuildFile.existsSync()) {
String content = androidBuildFile.readAsStringSync();
RegExp regExp = RegExp(r'applicationId = "(.+)"');
Match? match = regExp.firstMatch(content);
if (match != null) {
appPackage = match.group(1);
}
}
Map<String, String> files = {
'android/fastlane/.env': '''
# This file should be added to your .gitignore file
# It contains sensitive information that should not be versioned
# https://docs.fastlane.tools/best-practices/keys/#where-do-i-store-my-keys
FIREBASE_APP_ID=
APP_PACKAGE_NAME=$appPackage
# Optional: Override the path to your Firebase service account JSON file (defaults to root of your project)
# GOOGLE_SERVICE_ACCOUNT_JSON_PATH=
''',
'android/fastlane/Appfile': '''
package_name(ENV["APP_PACKAGE_NAME"]) # e.g. com.krausefx.app
''',
'android/fastlane/Fastfile': '''
import "../../Fastfile"
default_platform(:android)
platform :android do
# Build flutter Android app
lane :build do |options|
verify_env(envs: [
"APP_PACKAGE_NAME"
])
# Verify 'firebase_app_distribution_service_account.json' file exists
unless File.exist?(google_service_account_json_path)
UI.user_error!("google_service_account.json file not found. Please add it to the root of the flutter project. See https://docs.fastlane.tools/actions/supply/")
end
# Verify version number is correct
if !is_ci && (!options[:version_number])
version_number = get_version_from_pubspec()
continue = UI.confirm("Deploying version #{version_number} (from pubspec.yaml) to Play Store. Continue?")
unless continue
UI.user_error!("Aborted")
end
end
build_flutter_app(
type: options[:type] || "appbundle",
no_codesign: options[:no_codesign],
config_only: options[:config_only],
build_number: options[:build_number],
version_number: options[:version_number],
store: "playstore"
)
end
# Release to Play Store using Fastlane Supply (https://docs.fastlane.tools/actions/supply/)
desc "Release to Play Store"
lane :release_play_store do |options|
begin
build(
no_codesign: options[:no_codesign],
config_only: options[:config_only],
build_number: options[:build_number],
version_number: options[:version_number]
)
supply(
track: 'internal',
# Uncomment this if getting error "Only releases with status draft may be created on draft app."
# release_status: 'draft',
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key: google_service_account_json_path,
skip_upload_apk: true, # Upload the aab instead of apk
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
# Release to Play Store using Firebase App Distribution (https://docs.fastlane.tools/actions/firebase_app_distribution/)
desc "Release to Play Store using Firebase App Distribution"
lane :release_play_store_using_firebase do |options|
begin
build(
type: 'apk',
no_codesign: options[:no_codesign],
config_only: options[:config_only],
build_number: options[:build_number],
version_number: options[:version_number]
)
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID"],
android_artifact_path: "#{root_path}/build/app/outputs/flutter-apk/app-release.apk",
service_credentials_file: google_service_account_json_path,
# Use the following to enable debug mode
debug: true
)
end
end
end''',
'android/fastlane/Pluginfile': '''
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-firebase_app_distribution'
gem 'fastlane-plugin-get_new_build_number'
''',
'android/Gemfile': '''
source "https://rubygems.org"
gem "fastlane"
# takes version from flutter (pubspec.yaml)
gem "fastlane-plugin-flutter_version", git: "https://github.com/tianhaoz95/fastlane-plugin-flutter-version"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
''',
/// IOS
'ios/fastlane/.env': '''
ASC_KEY_ID=""
ASC_ISSUER_ID=""
ASC_KEY_P8_BASE64=""
MATCH_PASSWORD=""
MATCH_GIT_BASIC_AUTHORIZATION=""
APP_BUNDLE_ID=$appBundleId
FIREBASE_APP_ID=
# Optional: Override the path to your Firebase service account JSON file (defaults to root of your project)
# GOOGLE_SERVICE_ACCOUNT_JSON_PATH=
''',
'ios/fastlane/Appfile': '''
app_identifier(ENV["DEVELOPER_APP_IDENTIFIER"])
apple_id(ENV["FASTLANE_APPLE_ID"])
itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"])
team_id(ENV["DEVELOPER_PORTAL_TEAM_ID"])
''',
'ios/fastlane/Fastfile': '''
import "../../Fastfile"
default_platform(:ios)
platform :ios do
# Authenticate with Apple Store
private_lane :authenticate_apple_store do
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_P8_BASE64"],
is_key_content_base64: true,
in_house: false
)
end
# Build iOS app
lane :build_ipa do |options|
authenticate_apple_store
build_flutter_app(
type: "ipa",
no_codesign: options[:no_codesign] || false,
config_only: options[:config_only] || false,
build_number: options[:build_number] || get_build_number('appstore'),
version_number: options[:version_number] || get_version_from_pubspec(),
store: "appstore"
)
end
desc "Release a new build to Apple Store"
lane :release_app_store do |options|
verify_env(envs: [
"ASC_KEY_ID",
"ASC_ISSUER_ID",
"ASC_KEY_P8_BASE64",
"APP_BUNDLE_ID",
"MATCH_PASSWORD",
"MATCH_GIT_BASIC_AUTHORIZATION",
])
authenticate_apple_store
build_number = options.fetch(:build_number, get_build_number('appstore'))
version_number = options.fetch(:version_number, get_version_from_pubspec())
# Verify version number is correct
if !is_ci && (!options[:version_number])
continue = UI.confirm("Deploying version #{version_number} (from pubspec.yaml) to App Store. Continue?")
unless continue
UI.user_error!("Aborted")
end
end
# Sync certificates and profiles using match
UI.message("Syncing certificates and profiles")
if is_ci
UI.message("CI detected. Setting up CI environment")
setup_ci
end
sync_code_signing(
type: "appstore",
readonly: is_ci,
)
build_ipa(
build_number: build_number,
version_number: version_number
)
build_app(
skip_build_archive: true,
archive_path: "../build/ios/archive/Runner.xcarchive",
)
# If GoogleService-Info.plist exists and Pods/FirebaseCrashlytics exists
# Upload symbols to Firebase Crashlytics
if File.file?("../ios/Runner/GoogleService-Info.plist") && File.directory?("../ios/Pods/FirebaseCrashlytics")
upload_symbols_to_crashlytics(
gsp_path: "../ios/Runner/GoogleService-Info.plist"
)
end
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
# This is a work in progress, requiring ad-hoc export method
# desc "Release to Play Store using Firebase App Distribution"
# lane :release_to_firebase do |options|
# begin
# build_ipa
# firebase_app_distribution(
# app: ENV["FIREBASE_APP_ID"],
# service_credentials_file: google_service_account_json_path,
# ipa_path: "../build/ios/Runner.ipa"
# )
# end
# end
end
''',
'ios/fastlane/Matchfile': '''
git_url("https://github.com/your/app-certificates-and-profiles.git")
storage_mode("git")
type("appstore") # The default type, can be: appstore, adhoc, enterprise or development
app_identifier([ENV["APP_BUNDLE_ID"]])
''',
'ios/fastlane/Pluginfile': '''
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-firebase_app_distribution'
gem 'fastlane-plugin-get_new_build_number'
''',
'ios/Gemfile': '''
source "https://rubygems.org"
gem "fastlane"
# takes version from flutter (pubspec.yaml)
gem "fastlane-plugin-flutter_version", git: "https://github.com/tianhaoz95/fastlane-plugin-flutter-version"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
''',
/// Generics
'Fastfile': '''
opt_out_usage
# Have an easy way to get the root of the project
def root_path
Dir.pwd.sub(/.*\\Kfastlane/, '').sub(/.*\\Kandroid/, '').sub(/.*\\Kios/, '').sub(/.*\\K\\/\\//, '')
end
# Have an easy way to run flutter tasks on the root of the project
lane :sh_on_root do |options|
command = options[:command]
sh("cd #{root_path} && #{command}")
end
# Tasks to be reused on each platform flow
lane :fetch_dependencies do
sh_on_root(command: "flutter pub get --suppress-analytics")
end
# Tasks to be reused on each platform flow
lane :build_autogenerated_code do
sh_on_root(command: "flutter pub run build_runner build --delete-conflicting-outputs")
end
# Tasks to be reused on each platform flow
lane :lint do
sh_on_root(command: "flutter format --suppress-analytics --set-exit-if-changed -n lib/main.dart lib/src/ test/")
end
lane :build_flutter_app do |options|
pubspec_version_number = get_version_from_pubspec()
type = options[:type]
build_number = options[:build_number] || get_build_number(options[:store])
version_number = options[:version_number] || pubspec_version_number
no_codesign = options[:no_codesign] || false
config_only = options[:config_only] || false
commit = last_git_commit
command = "flutter build #{type} --release --no-pub --suppress-analytics"
command += " --build-number=#{build_number}" if build_number.to_s != ""
command += " --build-name=#{version_number}" if version_number.to_s != ""
command += " --no-codesign" if no_codesign
command += " --config-only" if config_only
UI.message("Building #{type} - version: #{version_number} - build: #{build_number} - commit: #{commit[:abbreviated_commit_hash]}")
fetch_dependencies
# Check if build_runner exists in pubspec.yaml
# If it does, run the build_runner command
if File.exist?("#{root_path}/pubspec.yaml")
pubspec_content = File.read("#{root_path}/pubspec.yaml")
if pubspec_content.include?("build_runner:")
build_autogenerated_code
end
end
sh_on_root(command: command)
end
# Tasks to be reused on each platform flow
lane :test do |options|
sh_on_root(command: "flutter test --no-pub --coverage --suppress-analytics")
end
# Private lane to verify all environment variables are set
private_lane :verify_env do |options|
# array of ENVS to check
envs = options.fetch(:envs, [])
envs.each do |env|
if ENV[env].nil? || ENV[env].empty?
UI.user_error!("ENV \\"#{env}\\" is not set. Please set it in your environment variables (e.g. ios/fastlane/.env)")
end
end
end
# A helper method to get the path to the firebase service account json file
def google_service_account_json_path
root_path + '/' + (ENV["GOOGLE_SERVICE_ACCOUNT_JSON_PATH"] || 'google_service_account.json')
end
# Build number is a unique identifier for each build that is uploaded to the app store.
# This method will get the latest build number from the app store and increment it by 1.
# Ensure authenticate_apple_store is called before this method
def get_build_number(store)
return get_new_build_number(
bundle_identifier: store == "appstore" ? ENV["APP_BUNDLE_ID"] : nil,
package_name: store == "playstore" ? ENV["APP_PACKAGE_NAME"] : nil,
google_play_json_key_path: google_service_account_json_path
).to_s
end
def get_version_from_pubspec
require 'yaml'
# Define the correct path to pubspec.yaml relative to the Fastlane directory
pubspec_path = File.expand_path("#{root_path}/pubspec.yaml")
# Check if the file exists to avoid errors
unless File.exist?(pubspec_path)
UI.error("pubspec.yaml file not found at path: #{pubspec_path}")
return nil
end
# Parse the pubspec.yaml file
pubspec_content = File.read(pubspec_path)
# Use regex to find the version number line and extract both version number and build number
version_line = pubspec_content.match(/version:\\s*(\\d+\\.\\d+\\.\\d+)\\+(\\d+)/)
if version_line
version_number = version_line[1]
else
UI.error("Version number not found in pubspec.yaml")
return nil
end
return version_number
end
''',
'google_service_account.json': '''
Follow this guide to create a service account JSON file: https://docs.fastlane.tools/actions/supply/#setup
''',
'android/key.properties': '''
storePassword=<the password you used when generating the key>
keyPassword=<the password you used when generating the key>
keyAlias=release
storeFile=../key.jks
''',
'''.github/workflows/deploy-with-fastlane.yaml''': r'''
name: Publish iOS and Android release
on:
release:
types: [published]
env:
FLUTTER_CHANNEL: "stable"
RUBY_VERSION: "3.2.2"
jobs:
build_ios:
name: Build iOS
# You can upgrade to more powerful machines if you need to
# See https://docs.github.com/en/actions/using-github-hosted-runners/about-larger-runners/about-larger-runners#about-macos-larger-runners
runs-on: macos-latest
# Depending on how long your build takes, you might want to increase the timeou
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
bundler-cache: true
working-directory: 'ios'
- name: Run Flutter tasks
uses: subosito/flutter-action@v2.16.0
with:
# Remember to specify flutter version in pubspec.yaml under environment
# https://github.com/subosito/flutter-action?tab=readme-ov-file#use-version-from-pubspecyaml
flutter-version-file: 'pubspec.yaml'
channel: ${{ env.FLUTTER_CHANNEL }}
cache: true
- uses: maierj/fastlane-action@v3.1.0
with:
lane: 'release_app_store'
subdirectory: ios
options: '{ "version_number": "${{ github.ref_name }}" }'
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_BUNDLE_ID: ${{ secrets.APP_BUNDLE_ID }}
notify_ios:
name: Send Slack Notification about iOS build
needs: [build_ios]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Send Slack Notification about iOS build
uses: rtCamp/action-slack-notify@v2
if: ${{ !cancelled() && (success() || failure()) && env.SLACK_LOGS_WEBHOOK_PRESENT == 'true' }}
env:
SLACK_LOGS_WEBHOOK_PRESENT: ${{ secrets.SLACK_LOGS_WEBHOOK && 'true' || 'false' }}
SLACK_WEBHOOK: ${{ secrets.SLACK_LOGS_WEBHOOK }}
SLACK_CHANNEL: logs
SLACK_USERNAME: "${{ github.repository_owner }}"
SLACK_ICON: "https://github.com/${{ github.repository_owner }}.png?size=250"
SLACK_COLOR: "${{ contains(needs.*.result, 'success') && 'good' || 'danger' }}"
SLACK_TITLE: "${{ contains(needs.*.result, 'success') && 'Successfully released' || 'Error during release of' }} ${{ github.ref_name }} for iOS to TestFlight"
SLACK_FOOTER: "DevOps"
SLACK_MESSAGE: "${{ contains(needs.*.result, 'success') && 'Released:' || 'Release failed:' }} ${{github.event.head_commit.message}}"
build_android:
name: Build Android
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
bundler-cache: true
working-directory: 'android'
- name: Run Flutter tasks
uses: subosito/flutter-action@v2.16.0
with:
flutter-version-file: 'pubspec.yaml'
channel: ${{ env.FLUTTER_CHANNEL }}
cache: true
- name: Create google_service_account.json
run: |
echo "${{ secrets.FIREBASE_SERVICE_ACCOUNT_BASE64 }}" | base64 --decode > google_service_account.json
- name: Create key.jks
run: |
echo "${{ secrets.ANDROID_KEYSTORE_FILE_BASE64 }}" | base64 --decode > android/key.jks
- name: Create key.properties
run: |
cat <<EOF > android/key.properties
storePassword=${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
keyAlias=release
storeFile=../key.jks
EOF
env:
ANDROID_KEY_STORE_PASSWORD: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
- uses: maierj/fastlane-action@v3.1.0
with:
lane: 'release_play_store'
subdirectory: android
options: '{ "version_number": "${{ github.ref_name }}" }'
env:
APP_PACKAGE_NAME: ${{ secrets.APP_PACKAGE_NAME }}
notify_android:
name: Send Slack Notification about Android build
needs: [build_android]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Send Slack Notification about Android build
uses: rtCamp/action-slack-notify@v2
if: ${{ !cancelled() && (success() || failure()) && env.SLACK_LOGS_WEBHOOK_PRESENT == 'true' }}
env:
SLACK_LOGS_WEBHOOK_PRESENT: ${{ secrets.SLACK_LOGS_WEBHOOK && 'true' || 'false' }}
SLACK_WEBHOOK: ${{ secrets.SLACK_LOGS_WEBHOOK }}
SLACK_CHANNEL: logs
SLACK_USERNAME: "${{ github.repository_owner }}"
SLACK_ICON: "https://github.com/${{ github.repository_owner }}.png?size=250"
SLACK_COLOR: "${{ contains(needs.*.result, 'success') && 'good' || 'danger' }}"
SLACK_TITLE: "${{ contains(needs.*.result, 'success') && 'Successfully released' || 'Error during release of' }} ${{ github.ref_name }} for Android to Play Store"
SLACK_FOOTER: "DevOps"
SLACK_MESSAGE: "${{ contains(needs.*.result, 'success') && 'Released:' || 'Release failed:' }} ${{github.event.head_commit.message}}"
''',
};
// verify if the files already exist
List<String> existingFiles = [];
files.forEach((path, content) {
if (File.fromUri(Uri.file(path)).existsSync()) {
existingFiles.add(path);
}
});
bool override = false;
// ask if he wish to override the file
if (existingFiles.isNotEmpty) {
print('The following files already exist:');
existingFiles.forEach((file) {
print(file);
});
stdout.write('Do you wish to override the files? (y/n): ');
String? answer = stdin.readLineSync();
override = answer?.toLowerCase() == 'y';
}
files.forEach((path, content) {
// if it exists and the user doesn't want to override, skip
if (File.fromUri(Uri.file(path)).existsSync() && !override) {
return;
}
File file = File(path);
file.createSync(recursive: true);
file.writeAsStringSync(content);
print('File $path created successfully.');
});
// Check if .gitignore contains '*.jks*, key.properties, google_service_account.json'
// and if not, append them
File gitignore = File('.gitignore');
if (gitignore.existsSync()) {
String content = gitignore.readAsStringSync();
if (!content.contains('.env')) {
gitignore.writeAsStringSync('.env\n', mode: FileMode.append);
}
if (!content.contains('*.jks')) {
gitignore.writeAsStringSync('*.jks\n', mode: FileMode.append);
}
if (!content.contains('key.properties')) {
gitignore.writeAsStringSync('key.properties\n', mode: FileMode.append);
}
if (!content.contains('google_service_account.json')) {
gitignore.writeAsStringSync('google_service_account.json\n',
mode: FileMode.append);
}
if (!content.contains('**/fastlane/report.xml')) {
gitignore.writeAsStringSync('**/fastlane/report.xml\n',
mode: FileMode.append);
}
if (!content.contains('**/fastlane/Preview.html')) {
gitignore.writeAsStringSync('**/fastlane/Preview.html\n',
mode: FileMode.append);
}
if (!content.contains('**/fastlane/screenshots')) {
gitignore.writeAsStringSync('**/fastlane/screenshots\n',
mode: FileMode.append);
}
if (!content.contains('**/fastlane/test_output')) {
gitignore.writeAsStringSync('**/fastlane/test_output\n',
mode: FileMode.append);
}
}
// Modify `android/app/build.gradle` and ensure it contains the following,
// ```gradle
// def keystoreProperties = new Properties()
// def keystorePropertiesFile = rootProject.file('key.properties')
// if (keystorePropertiesFile.exists()) {
// keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
// }
// ```
File androidBuildGradle = File('android/app/build.gradle');
if (androidBuildGradle.existsSync()) {
// Ask if user wants to automatically add the required configurations
print(
'Do you want to automatically add the required configurations to android/app/build.gradle? (y/n): ');
String? answer = stdin.readLineSync();
if (answer?.toLowerCase() == 'y') {
// Read the content of the file
String content = androidBuildGradle.readAsStringSync();
if (!content.contains('def keystoreProperties = new Properties()')) {
print('Adding keystoreProperties to android/app/build.gradle');
// add it above the `android {` block
content = content.replaceFirst('android {', '''
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {''');
}
// If it does not contain signingConfigs {
// then add it above the buildTypes block
if (!content.contains('signingConfigs {')) {
print('Adding signingConfigs to android/app/build.gradle');
content = content.replaceFirst('buildTypes {', '''
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {''');
}
// Set signingConfig signingConfigs.release
print('Ensuring signingConfig is set to signingConfigs.release');
content = content.replaceFirst(
RegExp(r'buildTypes \{[\s\S]*?release \{[\s\S]*?\}[\s\S]*?\}'), '''
buildTypes {
release {
signingConfig signingConfigs.release
}
}''');
// Write the content back to the file
androidBuildGradle.writeAsStringSync(content);
}
}
print('Fastlane setup completed successfully.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment