Hi 👋
Beberapa waktu yang lalu, ketika ada waktu luang karena sudah selesai mengerjakan sprint, gue mencoba untuk mengerjakan salah satu Technical debt yang belum terlaksana di aplikasi Kitabisa versi iOS yaitu implement Continuous Integration dan Continuous Delivery .
Untuk mempermudah, fastlane dijadikan pilihan utama sebagai alat bantunya. Selain simple, fastlane juga punya action <code>Match</code> yang memudahkan code signing. Dan untuk machine nya sendiri, kitabisa sudah menggunakan CircleCI .
Sebelum memulai, perlu diketahui di aplikasi kitabisa ada 2 tahap development, yaitu staging dan production yang semua setting dan configurasinya ada di info.plist. Jadi, hanya menggunakan satu target tapi punya 2 scheme. Oleh karena itu dotenv
bisa dimanfaatkan untuk membedakan environment buildnya. Di post ini ga akan dibahas mendetail tentang setup fastlane ya, dan diasumsikan kalian sudah lumayan familiar dengan apa yang akan dibahas.
First Step
Awalnya simple aja, ikutin beberapa tutorial dari hasil search sana-sini, dan ini adalah script ruby
untuk dipanggil oleh fastlane untuk archive project iOS kitabisa.
def buildGym(method:, configuration:, provisioning:)
build_number = TZInfo::Timezone.get('Asia/Jakarta').now.strftime("%y%m%d.%H%M")
increment_build_number build_number: build_number
settings_to_override = {
:PROVISIONING_PROFILE_SPECIFIER => "match #{provisioning} #{ENV['BUNDLE_IDENTIFIER']}"
}
gym(
workspace: @WORKSPACE_PATH,
scheme: ENV['SCHEME'],
configuration: configuration,
export_method: method,
xcargs: settings_to_override,
)
putChangeLog
end
Variable settings_to_override
digunakan untuk me-override code-signing agar sesuai dengan yang diinginkan (e.g untuk upload ke fabric maka provisioning yang digunakan adalah provisioning adhoc).
Dan untuk fastlane lane-nya seperti berikut,
desc "build for staging/production"
lane :build do
buildGym(
method: ENV['METHOD'],
configuration: ENV['CONFIGURATION'],
provisioning: ENV['PROVISIONING']
)
end
Jadi ketika dipanggil hanya perlu pasing env-nya saja seperti berikut,
fastlane build --env staging
atau
fastlane build
karena env defaultnya adalah production.
Ok, coba running di local, success me-archive sehingga menghasilkan file .ipa
dan .dsym
.
Untuk setting workflow circleCI pada awalnya seperti berikut,
# .circleci/config.yml
version: 2
jobs:
buildStaging:
macos:
xcode: "10.2.1"
environment:
FASTLANE_LANE: build
shell: /bin/bash --login -o pipefail
steps:
- checkout
- restore_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
- run:
name: bundle installation
command: bundle check || bundle install --path vendor/bundle
- save_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
paths:
- vendor/bundle
- run:
name: Fastlane build staging
command: bundle exec fastlane $FASTLANE_LANE --env staging
- persist_to_workspace:
root: .
paths:
# needed to upload to fabric beta
- Pods/Crashlytics/
- output/
- fastlane/
- Gemfile
- Gemfile.lock
stagingDistribution:
macos:
xcode: "10.2.1"
environment:
FASTLANE_LANE: beta
FASTLANE_LANE_APPCENTER: appcenter
shell: /bin/bash --login -o pipefail
steps:
- attach_workspace:
at: .
- restore_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
- run:
name: bundle installation
command: bundle check || bundle install --path vendor/bundle
- save_cache:
key: 1-gems-{{ checksum "Gemfile.lock" }}
paths:
- vendor/bundle
- run:
name: Upload to fabric beta
command: bundle exec fastlane $FASTLANE_LANE --env staging
- store_artifacts:
path: output/
workflows:
version: 2
buildForBeta:
jobs:
- buildStaging
- stagingDistribution:
requires:
- buildStaging
Workflow-nya berjalan seperti ini, job yang pertama membuild & me-archive project sehingga menghasilkan produk akhir yaitu .ipa
dan .dsym
yang sudah di zip. Setelah job pertama selesai, dilanjutkan ke job berikutnya, yaitu mendistribusi file .ipa
dan .dsym
ke fabric beta. Karena binary untuk upload ke fabric beta termasuk dalam pods nya fabric beta, maka beberapa file perlu ‘dikirim’ ke job berikutnya, dengan perintah persist_to_workspace
dan pada job berikutnya file tersebut diterima dengan perintah attach_workspace
.
Nightmare
![nighmare][nightmare] [nightmare]: https://source.unsplash.com/P_GeWr5wNQc/1800x620
Sebenarnya semua berjalan dengan lancar, build success, beta distribution pun berjalan.
Sampai pada akhirnya, CircleCI mengirimkan baris response berikut.
Blocked due to plan-no-credits-available Please purchase a new credit block then push a new commit or call the API to run a new build.
Ya, kehabisan kredit di CircleCI.
Total build untuk project iOS sampai dengan berikut
Ya, ternyata rata-rata build time sekitar 15-17 menit, total untuk staging & production sekitar 30 menit sekali jalan.
Pods Caching
Hal yang dicurigai pertama adalah proses build nya pod. Ok gimana kalo pods nya di cache aja? toh jarang-jarang juga update pod.
Googling-googling sampai lah ke artikel Om Loïs Di Qual .
Konsepnya sih, instead of build workspacenya, build aja masing-masing projectnya (project cocoapods dan main). Khusus untuk project cococapods, setelah success cache folder build
kemudian di link ke main projectnya.
Untuk detailnya bisa baca link di atas. Hanya saja, ketika dicoba cocoapodpod framework yang sudah di buat selalu gagal di - link - ke dalam app Kitabisa.
Setelah beberapa kali coba-coba akhirnya ini patch yang bisa dipake di project Kitabisa.
desc "patch app"
lane :patch_app do
fastlane_require 'xcodeproj'
project = Xcodeproj::Project.open("../Kitabisa.xcodeproj")
target = project.targets.select { |target| target.name == "Kitabisa" }.first
phase = target.shell_script_build_phases.select { |phase| phase.name.include?('Embed Pods Frameworks') }.first
phase.shell_script = [
"BUILT_PRODUCTS_DIR=#{File.expand_path('../build/Release-iphoneos')}",
"#{phase.shell_script}"
].join("\n")
project.save()
end
Dan, di linknya mas Loïs Di Qual juga menggunakan s3 nya aws. Sementara gue memilih untuk menggunakan system cache-nya si CircleCI .
Jadi untuk build pods dan cachenya kira-kira begini,
def build_pod()
if File.directory?(File.expand_path('../build'))
puts "Folder exists"
else
xcodebuild(
:project => File.expand_path('../Pods/Pods.xcodeproj'),
:scheme => "Pods-Kitabisa",
:configuration => 'Release',
:destination => 'generic/platform=iOS',
:xcargs => 'BITCODE_GENERATION_MODE=bitcode',
)
end
end
desc "build pod project"
lane :build_pod do
build_pod
end
Sementara untuk build main projectnya, menjadi seperti berikut,
def build_app(method:, configuration:, provisioning:)
fastlane_require 'tzinfo'
get_match
build_number = TZInfo::Timezone.get('Asia/Jakarta').now.strftime("%y%m%d.%H%M")
increment_build_number build_number: build_number
cache_folder = File.expand_path('../build/Release-iphoneos')
settings_to_override = {
:PROVISIONING_PROFILE_SPECIFIER => "match #{provisioning} #{ENV['BUNDLE_IDENTIFIER']}",
:PODS_CONFIGURATION_BUILD_DIR => "#{cache_folder}",
:FRAMEWORK_SEARCH_PATHS => "$(inherited) #{cache_folder}"
}
gym(
project: "Kitabisa.xcodeproj",
scheme: ENV['SCHEME'],
configuration: configuration,
export_method: method,
xcargs: settings_to_override,
)
putChangeLog
end
def putChangeLog()
File.open('changes.txt', 'a') { |file| file.puts last_git_commit[:message]}
end
desc "build for staging/production"
lane :build do
build_app(
method: ENV['METHOD'],
configuration: ENV['CONFIGURATION'],
provisioning: ENV['PROVISIONING']
)
end
Sehingga basic flownya seperti berikut,
fastlane build_pod
fastlane patch_app
fastlane build --env '{environment}'
Ok test di CircleCI dan ini hasilnya.
Sebelum di cache
Setelah di cache
Hm, ternyata hanya turun sekitar 5 menit saja.
Optimize to the max
Setelah pods di cache, ternyata cuma turun 5 menit-an saja.
Setelah dicari-cari, yang paling lama adalah process xcrun seperti digambar berikut
Kalau dilihat, dari start process xcrun sampai selesai memakan waktu kira - kira 5-6 menit.
Sebenarnya process apa itu?
Setelah googling-googling (lagi), ternyata itu adalah salah satu process App Thining nya si apple yaitu embedded bitcode untuk keperluan LLVM.
Berhubung untuk staging hanya keperluan QA, jadi sepertinya process ini bisa diskip dan tetap di enable untuk production yang masuk ke appstore connect. Tapi untuk cocoapods gue akan tetap menanbahkan bitcode pada buildnya agar bisa dipakai di staging ataupun production, toh sudah di cache juga.
Untuk me-disable bitcode via gym, hanya perlu ditambahkan parameter uploadBitcode & complieBitcode pada export_options-nya.
Jadi perubahannya seperti berikut.
def build_app(method:, configuration:, provisioning:)
fastlane_require 'tzinfo'
get_match
build_number = TZInfo::Timezone.get('Asia/Jakarta').now.strftime("%y%m%d.%H%M")
increment_build_number build_number: build_number
cache_folder = File.expand_path('../build/Release-iphoneos')
settings_to_override = {
:PROVISIONING_PROFILE_SPECIFIER => "match #{provisioning} #{ENV['BUNDLE_IDENTIFIER']}",
:PODS_CONFIGURATION_BUILD_DIR => "#{cache_folder}",
:FRAMEWORK_SEARCH_PATHS => "$(inherited) #{cache_folder}"
}
gym(
project: "Kitabisa.xcodeproj",
scheme: ENV['SCHEME'],
configuration: configuration,
export_method: method,
xcargs: settings_to_override,
export_options: {
uploadBitcode: false,
uploadSymbols: true,
compileBitcode: false
}
)
putChangeLog
end
def putChangeLog()
File.open('changes.txt', 'a') { |file| file.puts last_git_commit[:message]}
end
desc "build for staging/production"
lane :build do
build_app(
method: ENV['METHOD'],
configuration: ENV['CONFIGURATION'],
provisioning: ENV['PROVISIONING']
)
end
Terakhir, untuk workflow CircleCI dirubah menjadi seperti ini,
# .circleci/config.yml
aliases:
- &restore_cache
key: v2-gems-{{ checksum "Gemfile.lock" }}
- &run_cache
name: bundle installation
command: bundle check || bundle install --path vendor/bundle
- &save_cache
key: v2-gems-{{ checksum "Gemfile.lock" }}
paths:
- vendor/bundle
- &restore_pod
key: v2-pods-{{ checksum "Podfile.lock" }}
- &run_pod
name: pod cache
command: bundle exec fastlane build_pod
- &save_pod
key: v2-pods-{{ checksum "Podfile.lock" }}
paths:
- build/
- &persist_to_workspace
root: .
paths:
# needed to upload to fabric beta
- Pods/Crashlytics/
- output/
- fastlane/
- Gemfile
- Gemfile.lock
- &xcode_patch
name: xcode patch
command: bundle exec fastlane patch_app
defaults: &defaults
macos:
xcode: "10.2.1"
shell: /bin/bash --login -o pipefail
version: 2
jobs:
build_staging:
<<: *defaults
steps:
- checkout
- restore_cache: *restore_cache
- run: *run_cache
- save_cache: *save_cache
- restore_cache: *restore_pod
- run: *run_pod
- save_cache: *save_pod
- run: *xcode_patch
- run:
name: fastlane build staging
command: bundle exec fastlane build --env staging
- persist_to_workspace: *persist_to_workspace
build_production:
<<: *defaults
steps:
- checkout
- restore_cache: *restore_cache
- run: *run_cache
- save_cache: *save_cache
- restore_cache: *restore_pod
- run: *run_pod
- save_cache: *save_pod
- run: *xcode_patch
- run:
name: fastlane build production
command: bundle exec fastlane build
- persist_to_workspace: *persist_to_workspace
qa_release:
<<: *defaults
steps:
- attach_workspace:
at: .
- restore_cache: *restore_cache
- run: *run_cache
- save_cache: *save_cache
- run:
name: upload to fabric beta
command: bundle exec fastlane beta
appstore_release:
<<: *defaults
steps:
- attach_workspace:
at: .
- restore_cache: *restore_cache
- run: *run_cache
- save_cache: *save_cache
- run:
name: upload to testflight
command: bundle exec fastlane release
workflows:
version: 2
staging_build:
jobs:
- build_staging:
filters:
branches:
only:
- /pull\/[0-9]+/
- qa_release:
requires:
- build_staging
production_build:
jobs:
- build_production:
filters:
branches:
only:
- preDevelop
- develop
- qa_release:
requires:
- build_production
- appstore_release:
requires:
- build_production
filters:
branches:
only:
- develop
Sekarang semua keperluan untuk dikirim ke QA ditrigger hanya dengan membuat Pull Request, dan build production di trigger melalu branch preDevelop saja, dan akan dikirim ke appstore hanya ketika ada di branch develop.
Setelah coba dijalankan, hasilnya seperti berikut.
Wow, sekarang total build hanya sekitar 5-6 menit saja 🍻.
Oh iya, ini belum sama Unit Test ya, karena memang belum dibikin dan perlu beberapa re-factor yang belum sempat di kulik dari sisi code-nya untuk implement unit text dan ui test 😳.
Lessons learned
Dari kejadian di atas
, gue jadi belajar kalau CircleCI itu mahal project yang menggunakan cocoapod masih bisa dipecah dan dioptimalisasi lagi untuk build timenya bahkan di local environment. Selain itu, gue juga mengerti bahwa CircleCI memungkinkan untuk di build dr pull request, dan yaml bisa pake alias sehingga mengurangi copy-paste.
Kalau ada pertanyaan atau saran atau apapun, bisa pass comment dibawah.
Salam 👋