forked from Automattic/pocket-casts-ios
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathFastfile
1122 lines (961 loc) · 45.2 KB
/
Fastfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# frozen_string_literal: true
fastlane_version '2.0'
default_platform :ios
# Paths that are re-used across multiple lanes
PROJECT_ROOT_FOLDER = File.dirname(File.expand_path(__dir__))
FASTLANE_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'fastlane')
APP_STORE_METADATA_FOLDER = File.join(FASTLANE_FOLDER, 'metadata')
SECRETS_FOLDER = File.join(Dir.home, '.configure', 'pocketcasts-ios', 'secrets')
ASC_KEY_PATH = File.join(SECRETS_FOLDER, 'app_store_connect_fastlane_api_key.json')
VERSION_XCCONFIG_PATH = File.join(PROJECT_ROOT_FOLDER, 'config', 'Version.xcconfig')
# Unfortunately, ios_current_branch_is_hotfix relies on this ENV var under the hood.
# We can't get rid of it just yet.
ENV['PUBLIC_CONFIG_FILE'] = VERSION_XCCONFIG_PATH
RESOURCES_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'podcasts', 'Resources')
RELEASE_NOTES_SOURCE_PATH = File.join(PROJECT_ROOT_FOLDER, 'CHANGELOG.md')
EXTRACTED_RELEASE_NOTES_PATH = File.join(RESOURCES_FOLDER, 'release_notes.txt')
SENTRY_ORG_SLUG = 'a8c'
SENTRY_PROJECT_SLUG = 'pocket-casts-ios'
GHHELPER_REPO = 'Automattic/pocket-casts-ios'
DEFAULT_BRANCH = 'trunk'
SCHEME = 'pocketcasts'
TEAM_ID = 'PZYM8XX95Q'
# Instanstiate versioning classes
VERSION_CALCULATOR = Fastlane::Wpmreleasetoolkit::Versioning::SemanticVersionCalculator.new
VERSION_FORMATTER = Fastlane::Wpmreleasetoolkit::Versioning::FourPartVersionFormatter.new
BUILD_CODE_FORMATTER = Fastlane::Wpmreleasetoolkit::Versioning::FourPartBuildCodeFormatter.new
VERSION_FILE = Fastlane::Wpmreleasetoolkit::Versioning::IOSVersionFile.new(xcconfig_path: VERSION_XCCONFIG_PATH)
APP_STORE_VERSION_BUNDLE_IDENTIFIER = 'au.com.shiftyjelly.podcasts'
MAIN_BUNDLE_IDENTIFIERS = [
APP_STORE_VERSION_BUNDLE_IDENTIFIER,
'au.com.shiftyjelly.podcasts.NotificationExtension',
'au.com.shiftyjelly.podcasts.NotificationContent',
'au.com.shiftyjelly.podcasts.watchkitapp',
'au.com.shiftyjelly.podcasts.watchkitapp.watchkitextension',
'au.com.shiftyjelly.podcasts.PodcastsIntents',
'au.com.shiftyjelly.podcasts.PodcastsIntentsUI',
'au.com.shiftyjelly.podcasts.WidgetExtension',
'au.com.shiftyjelly.podcasts.Share-Extension'
].freeze
FROZEN_STRINGS_PATH = File.join(FASTLANE_FOLDER, 'Frozen.strings')
EN_LPROJ_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'podcasts', 'en.lproj')
# List of `.strings` files manually maintained by developers (as opposed to
# being automatically extracted from code and generated) which we will merge
# into the main `Localizable.strings` file imported by GlotPress, then extract
# back once we download the translations.
#
# Each `.strings` file to be merged/extracted is associated with a prefix to
# add to the keys, used to avoid conflicts and differentiate the source of the
# copies.
#
# See calls to `ios_merge_strings_files` and
# `ios_extract_keys_from_strings_files` for usage.
MANUALLY_MAINTAINED_STRINGS_FILES = {
File.join(EN_LPROJ_FOLDER, 'Localizable.strings') => nil,
File.join(EN_LPROJ_FOLDER, 'InfoPlist.strings') => 'infoplist_',
File.join(EN_LPROJ_FOLDER, 'Intents.strings') => 'siri_intent_definition_key_'
}.freeze
# URL of the GlotPress project containing the strings used in the app
GLOTPRESS_APP_STRINGS_PROJECT_URL = 'https://translate.wordpress.com/projects/pocket-casts/ios/'
# URL of the GlotPress project containing App Store Connect metadata
GLOTPRESS_APP_STORE_METADATA_PROJECT_URL = 'https://translate.wordpress.com/projects/pocket-casts/ios/release-notes/'
# List of locales used for the app strings (GlotPress code => `*.lproj` folder name`).
# Sorted like Xcode sorts them in the File Inspector for easier comparison.
#
# TODO: Replace with `LocaleHelper` once provided by release toolkit (https://github.com/wordpress-mobile/release-toolkit/pull/296)
GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES = {
'zh-cn' => 'zh-Hans', # Chinese (China, Simplified)
'zh-tw' => 'zh-Hant', # Chinese (Taiwan, Traditional)
'nl' => 'nl', # Dutch
'fr' => 'fr', # French
'fr-ca' => 'fr-CA', # French (Canadian)
'de' => 'de', # German
'it' => 'it', # Italian
'ja' => 'ja', # Japanese
'pt-br' => 'pt-BR', # Portuguese (Brazil)
'ru' => 'ru', # Russian
'es' => 'es', # Spanish
'es-mx' => 'es-MX', # Spanish (Mexico)
'sv' => 'sv', # Swedish
'ca' => 'ca' # Catalan
}.freeze
# Mapping of all locales which can be used for AppStore metadata
# (GlotPress code => AppStore Connect code)
#
# TODO: Replace with `LocaleHelper` once provided by release toolkit
# (https://github.com/wordpress-mobile/release-toolkit/pull/296)
GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES = {
'de' => 'de-DE',
'es' => 'es-ES',
'fr' => 'fr-FR',
'it' => 'it',
'ja' => 'ja',
'nl' => 'nl-NL',
'pt-br' => 'pt-BR',
'ru' => 'ru',
'sv' => 'sv',
'zh-cn' => 'zh-Hans',
'zh-tw' => 'zh-Hant'
}.freeze
before_all do
# This is necessary for `match` to work correctly in CI. When running
# locally, it has no effect so it's safe to run it before all lanes.
setup_ci
# Decrypt the secrets. This is redundant on dev machines most of the time,
# but it has such a negligible overhead that it's worth running it here to
# keep the individual lanes cleaner.
configure_apply
end
platform :ios do
# This explicit timeout is necessary for the `xcodebuild -showBuildSettings` call to succeed in CI.
# See https://buildkite.com/automattic/pocket-casts-ios/builds/4875#018b7eea-e8d7-40a1-8ce5-515dd8feafce/666-805
ENV['FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT'] = '120'
desc 'Run the unit tests'
lane :test do
run_tests(
scheme: SCHEME
)
end
desc 'This lane downloads and configures the code signing certificates and profiles.'
lane :configure_code_signing do |options|
match(
type: 'appstore',
team_id: TEAM_ID,
api_key_path: ASC_KEY_PATH,
app_identifier: MAIN_BUNDLE_IDENTIFIERS,
# This might turn out to be useful in the future
# template_name: 'CarPlay audio app (CarPlay + Media Player frameworks)iOS (Dist)'
storage_mode: 'google_cloud',
google_cloud_bucket_name: 'a8c-fastlane-match',
google_cloud_keys_file: File.join(SECRETS_FOLDER, 'google_cloud_keys.json'),
readonly: options.fetch(:readonly, true)
)
end
#####################################################################################
# Release Lanes
#####################################################################################
# Executes the code freeze
#
# - Call start_code_freeze
# - Call complete_code_freeze
# - Go over PRs with the current milestone and add a comment to move them to the next
# - Open a PR targeting trunk
# - Send a message to Slack channel confirming the code freeze was finished
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
#
desc 'Executes all the code freeze steps'
lane :code_freeze do |options|
start_code_freeze(options)
comment_on_prs(milestone: release_version_current)
complete_code_freeze(options)
end
# Go through PRs and check their milestones, leaving a message if the target milestone is being frozen
def comment_on_prs(milestone:)
version = milestone
UI.message("Checking open PRs pointing to #{version} milestone...")
github_api(
server_url: 'https://api.github.com',
api_token: ENV.fetch('GITHUB_TOKEN'),
http_method: 'GET',
path: "/repos/#{GHHELPER_REPO}/pulls",
error_handlers: {
'*' => proc do |_result|
UI.important('⚠️ Failed checking open PRs, skipping this step.')
end
}
) do |result|
result[:json].each do |pr|
next if pr['milestone'].nil?
next unless pr['milestone']['title'].include?(version)
comment_on_pr(
project: GHHELPER_REPO,
pr_number: pr['number'],
body: "\nThis PR is targeting #{version} which is entering code freeze. If this needs to be included in the #{version} release please merge it into `release/#{version}` if not please move it to the next milestone.",
reuse_identifier: 'code-freeze-in-progress'
)
end
end
end
def slack_message(version:, build_number:, is_beta:)
build_number_split = build_number.split('.')
github_url = "https://github.com/#{GHHELPER_REPO}"
message_root = lambda { |tag, display_name|
":announcement: <#{github_url}/releases/tag/#{tag}|*#{display_name}*>"
}
appstoreconnect_url = 'https://appstoreconnect.apple.com/apps/414834813'
testflight_submit_link = "<#{appstoreconnect_url}/testflight/ios|App Store Connect>"
appstore_submit_link = "<#{appstoreconnect_url}/appstore/ios/version/deliverable|App Store Connect>"
merge_pr_link = "<https://github.com/Automattic/pocket-casts-ios/pulls?q=is%3Apr+is%3Aopen+#{build_number}+into|merge the PR>"
if is_beta
if (build_number_split[3] || '0') == '0'
"#{message_root.call(build_number, version)} code freeze is completed.\nPlease submit #{build_number} for testers on #{testflight_submit_link} and #{merge_pr_link} (<https://wp.me/PdeCcb-1ju|need help?>)"
else
"#{message_root.call(build_number, build_number)} beta has been submitted to Apple.\nPlease distribute #{build_number} to testers on #{testflight_submit_link} and #{merge_pr_link} (<https://wp.me/PdeCcb-1ku|need help?>)"
end
elsif (build_number_split[2] || '0').to_i.positive?
"#{message_root.call(version, version)} hotfix has been uploaded to Apple.\nPlease submit it for review on #{appstore_submit_link} and #{merge_pr_link}."
else
"#{message_root.call(version, version)} final build has been uploaded to Apple.\nPlease submit it for review on #{appstore_submit_link} and #{merge_pr_link}."
end
end
# Executes the initial steps of the code freeze
#
# - Cuts a new release branch
# - Extracts the Release Notes
# - Freezes the GitHub milestone and enables the GitHub branch protection for the new branch
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
#
desc 'Executes the initial steps needed during code freeze'
lane :start_code_freeze do |options|
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
# Check out the up-to-date default branch, the designated starting point for the code freeze
Fastlane::Helper::GitHelper.checkout_and_pull(DEFAULT_BRANCH)
message = <<-MESSAGE
Code Freeze:
• New release branch from #{DEFAULT_BRANCH}: release/#{release_version_next}
• Current release version and build code: #{release_version_current} (#{build_code_current}).
• New release version and build code: #{release_version_next} (#{build_code_code_freeze}).
MESSAGE
UI.important(message)
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
# Create the release branch
UI.message 'Creating release branch...'
Fastlane::Helper::GitHelper.create_branch("release/#{release_version_next}", from: DEFAULT_BRANCH)
UI.success "Done! New release branch is: #{git_branch}"
# Bump the release version and build code and write it to the `xcconfig` file
UI.message 'Bumping release version and build code...'
VERSION_FILE.write(
version_short: release_version_next,
version_long: build_code_code_freeze
)
commit_version_bump
UI.success "Done! New Release Version: #{release_version_current}. New Build Code: #{build_code_current}."
extract_release_notes_for_version(
version: release_version_current,
release_notes_file_path: RELEASE_NOTES_SOURCE_PATH,
extracted_notes_file_path: EXTRACTED_RELEASE_NOTES_PATH
)
ios_update_release_notes(
new_version: release_version_current,
release_notes_file_path: RELEASE_NOTES_SOURCE_PATH
)
end
# Executes the final steps for the code freeze
#
# - Generates `.strings` files from code then merges the other, manually-maintained `.strings` files with it
# - Triggers the build of the first beta on CI
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
#
desc 'Completes the final steps for the code freeze'
desc 'Creates a new release branch from the current trunk'
lane :complete_code_freeze do |options|
# Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
ensure_git_branch(branch: '^release/')
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
UI.important("Completing code freeze for: #{release_version_current}")
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
generate_strings_file_for_glotpress
create_merge_release_branch
after_confirming_push(push_merge_branch: true) do
trigger_beta_build(branch_to_build: release_branch_name)
setbranchprotection(repository: GHHELPER_REPO, branch: release_branch_name)
setfrozentag(repository: GHHELPER_REPO, milestone: release_version_current)
end
end
def release_branch_name
"release/#{release_version_current}"
end
def merge_branch_name
"merge/release-#{build_code_current}-into-trunk"
end
# Triggers a beta build on CI
#
# @option [String] branch_to_build The name of the branch we want the CI to build, e.g. `release/19.3`
#
lane :trigger_beta_build do |options|
trigger_buildkite_release_build(branch: options[:branch_to_build], beta: true)
end
# Triggers a stable release build on CI
#
# @option [String] branch_to_build The name of the branch we want the CI to build, e.g. `release/19.3`
#
lane :trigger_release_build do |options|
trigger_buildkite_release_build(branch: options[:branch_to_build], beta: false)
end
# Finalizes a release at the end of a sprint to submit to the App Store
#
# - Updates store metadata
# - Bumps final version number
# - Removes branch protection and close milestone
# - Triggers the final release on CI
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
#
desc 'Trigger the final release build on CI'
lane :finalize_release do |options|
UI.user_error!('To finalize a hotfix, please use the finalize_hotfix_release lane instead') if ios_current_branch_is_hotfix
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
# Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
ensure_git_branch(branch: '^release/')
UI.important("Finalizing release: #{release_version_current}")
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
check_all_translations_progress(interactive: true)
download_localized_strings_and_metadata_from_glotpress
lint_localizations
# Bump the build code
UI.message 'Bumping build code...'
VERSION_FILE.write(version_long: build_code_next)
commit_version_bump
UI.success "Done! New Build Code: #{build_code_current}"
# Wrap up
removebranchprotection(repository: GHHELPER_REPO, branch: release_branch_name)
setfrozentag(repository: GHHELPER_REPO, milestone: release_version_current, freeze: false)
create_new_milestone(repository: GHHELPER_REPO)
close_milestone(repository: GHHELPER_REPO, milestone: release_version_current)
# Start the build
after_confirming_push(push_merge_branch: false) do
trigger_release_build(branch_to_build: release_branch_name)
end
end
# Builds the Pocket Casts app and uploads it to TestFlight, for beta-testing or final release
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
# @option [Boolean] skip_prechecks (default: false) If true, don't run the prechecks and ios_build_preflight
# @option [Boolean] create_release If true, creates a GitHub Release draft after the upload, with zipped xcarchive as artefact
# @option [Boolean] beta_release If true, the GitHub release will be marked as being a pre-release
#
# @called_by CI
#
desc 'Builds and uploads for distribution to App Store Connect'
lane :build_and_upload_app_store_connect do |options|
unless options[:skip_prechecks]
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean unless is_ci
ios_build_preflight
end
UI.important("Building version #{release_version_current} (#{build_code_current}) and uploading to TestFlight")
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
sentry_check_cli_installed
configure_code_signing
build_app(
scheme: SCHEME,
include_bitcode: false,
include_symbols: true,
clean: true,
export_options: {
method: 'app-store',
manageAppVersionAndBuildNumber: false
}
)
secrets_dir = File.join(Dir.home, '.configure', 'pocketcasts-ios', 'secrets')
testflight(
skip_waiting_for_build_processing: true,
team_id: TEAM_ID,
api_key_path: File.join(secrets_dir, 'app_store_connect_fastlane_api_key.json')
)
symbols_upload
next unless options[:create_release]
archive_zip_path = File.join(PROJECT_ROOT_FOLDER, 'PocketCasts.xcarchive.zip')
zip(path: lane_context[SharedValues::XCODEBUILD_ARCHIVE], output_path: archive_zip_path)
app_version = release_version_current
build_version = build_code_current
version = options[:beta_release] ? build_version : app_version
UI.message("Creating #{version} release on GitHub...")
set_github_release(
repository_name: GHHELPER_REPO,
api_token: ENV.fetch('GITHUB_TOKEN'),
name: version,
tag_name: version,
description: begin
File.read(File.join(RESOURCES_FOLDER, 'release_notes.txt'))
rescue StandardError
'No changelog provided'
end,
commitish: Git.open(PROJECT_ROOT_FOLDER).log.first.sha,
upload_assets: [archive_zip_path.to_s],
is_draft: !options[:beta_release],
is_prerelease: options[:beta_release]
)
# TODO: move this to a GH action
UI.message('Opening PR to merge changes to trunk...')
create_pull_request(
api_token: ENV.fetch('GITHUB_TOKEN'),
repo: GHHELPER_REPO,
title: "#{app_version} Release: Merge changes from #{build_version} into `trunk`",
body: "Merge changes from #{app_version} (#{build_version}) to `trunk`.\n\n## To test\n\n- Ensure the build is 🟢\n- The code changes here were tested in their own PRs",
head: options[:beta_release] ? merge_branch_name : release_branch_name,
base: 'trunk',
labels: ['Releases']
)
UI.message('Sending message to Slack...')
slack(
pretext: slack_message(version: app_version, build_number: build_version, is_beta: options[:beta_release]),
default_payloads: [],
slack_url: ENV.fetch('SLACK_WEBHOOK'),
fail_on_error: false
)
FileUtils.rm_rf(archive_zip_path)
end
# Creates a new beta by bumping the app version appropriately then triggering a beta build on CI
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
# @option [String] base_version (default: _current app version_) If set, bases the beta on the specified version
# and `release/<base_version>` branch instead of the current one. Useful for triggering betas on hotfixes for example.
#
desc 'Trigger a new beta build on CI'
lane :new_beta_release do |options|
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
# Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
ensure_git_branch(branch: '^release/')
# Check versions
message = <<-MESSAGE
Current build code: #{build_code_current}
New build code: #{build_code_next}
MESSAGE
# Check branch
unless !options[:base_version].nil? || Fastlane::Helper::GitHelper.checkout_and_pull(release: release_version_current)
UI.user_error!("#{message}Release branch for version #{release_version_current} doesn't exist. Abort.")
end
# Check user override
override_default_release_branch(options[:base_version]) unless options[:base_version].nil?
UI.important(message)
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
# Re-generate the strings for GlotPress, just in case there were localization fixes.
generate_strings_file_for_glotpress
download_localized_strings_and_metadata_from_glotpress
lint_localizations
# Bump the build code
UI.message 'Bumping build code...'
# Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
ensure_git_branch(branch: '^release/')
VERSION_FILE.write(version_long: build_code_next)
commit_version_bump
UI.success "Done! New Build Code: #{build_code_current}"
create_merge_release_branch
push_release_branches
trigger_beta_build(branch_to_build: release_branch_name)
end
# Sets the stage to start working on a hotfix
#
# - Cuts a new `release/x.y.z` branch from the tag from the latest (`x.y`) version
# - Bumps the app version numbers appropriately
#
# @option [Boolean] skip_confirm (default: false) If true, avoids any interactive prompt
# @option [String] version (required) The version number to use for the hotfix (`"x.y.z"`)
#
desc 'Creates a new hotfix branch for the given `version:x.y.z`. The branch will be cut from the `x.y` tag.'
lane :new_hotfix_release do |options|
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
new_version = options[:version] || UI.input('Version number for the new hotfix?')
build_code_hotfix = build_code_hotfix(release_version: new_version)
# Parse the provided version into an AppVersion object
parsed_version = VERSION_FORMATTER.parse(new_version)
previous_version = VERSION_FORMATTER.release_version(VERSION_CALCULATOR.previous_patch_version(version: parsed_version))
# Check versions
message = <<-MESSAGE
Current release version: #{release_version_current}
New hotfix version: #{new_version}
Current build code: #{build_code_current}
New build code: #{build_code_hotfix}
Branching from tag: #{previous_version}
MESSAGE
UI.important(message)
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
# Check tags
UI.user_error!("Version #{new_version} already exists! Abort!") if git_tag_exists(tag: new_version)
UI.user_error!("Version #{previous_version} is not tagged! A hotfix branch cannot be created.") unless git_tag_exists(tag: previous_version)
# Create the hotfix branch
UI.message 'Creating hotfix branch...'
Fastlane::Helper::GitHelper.create_branch("release/#{new_version}", from: previous_version)
UI.success "Done! New hotfix branch is: #{git_branch}"
# Bump the hotfix version and build code and write it to the `xcconfig` file
UI.message 'Bumping hotfix version and build code...'
VERSION_FILE.write(
version_short: new_version,
version_long: build_code_hotfix
)
UI.success "Done! New Release Version: #{release_version_current}. New Build Code: #{build_code_current}"
commit_version_bump
end
# Finalizes a hotfix, by triggering a release build on CI
#
desc 'Performs the final checks and triggers a release build for the hotfix in the current branch'
lane :finalize_hotfix_release do
# Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
ensure_git_branch(branch: '^release/')
# Verify that there's nothing in progress in the working copy
ensure_git_status_clean
UI.important("Triggering hotfix build for version: #{release_version_current}")
UI.user_error!('Aborted by user request') unless options[:skip_confirm] || UI.confirm('Do you want to continue?')
push_to_git_remote(tags: false)
trigger_release_build(branch_to_build: "release/#{release_version_current}")
end
# Generates the `.strings` file to be imported by GlotPress, by parsing source
# code.
#
# @option [Boolean] skip_commit (default: false) If true, does not commit the changes made to the `.strings` file.
#
# @note Uses `genstrings` under the hood.
# @called_by `complete_code_freeze`.
#
lane :generate_strings_file_for_glotpress do |options|
# For reference: Other apps run `cocoapods` here (equivalent to `bundle
# exec pod install`) because they have internal libraries that bring in
# their own strings. Pocket Casts does not have dependencies with strings
# fetched via CocoaPods, so we don't need to waste time on that.
# Delete the previous frozen `.strings` file before generating it again, to
# avoid duplicated keys.
FileUtils.rm(FROZEN_STRINGS_PATH)
# Other apps call `ios_generate_strings_file_from_code` as the first step
# of this process, but Pocket Casts iOS uses the convention of defining all
# localized strings in the `en.lproj/Localizable.strings` file and then use
# SwiftGen to generate reference to them for the code. With this approach,
# there are no `NSLocalizedStrings` in the codebase and that action would
# be useless.
# Merge the various `.strings` files into a single "frozen" `.strings`
# so that we can update all keys into a single GlotPress project.
#
# Note: We will re-extract the translations back during
# `download_localized_strings_from_glotpress` (via a call to
# `ios_extract_keys_from_strings_files`)
ios_merge_strings_files(
paths_to_merge: MANUALLY_MAINTAINED_STRINGS_FILES,
destination: FROZEN_STRINGS_PATH
)
next if options.fetch(:skip_commit, false)
git_commit(
path: FROZEN_STRINGS_PATH,
message: 'Freeze strings for localization',
allow_nothing_to_commit: true
)
end
desc 'Downloads localized strings and App Store Connect metadata from GlotPress'
lane :download_localized_strings_and_metadata_from_glotpress do
download_localized_strings_from_glotpress
download_localized_app_store_metadata_from_glotpress
end
desc 'Lint the `.strings` files'
lane :lint_localizations do
ios_lint_localizations(
input_dir: File.join(PROJECT_ROOT_FOLDER, 'podcasts'),
allow_retry: true,
check_duplicate_keys: false
)
end
# This lane updates the `AppStoreStrings.po` file for the Pocket Casts app
# with the latest content from the `release_notes.txt` file and the other
# text sources
#
desc 'Updates the `AppStoreStrings.po` file for the Pocket Casts app with the latest data'
lane :update_app_store_strings do
source_metadata_folder = File.join(APP_STORE_METADATA_FOLDER, 'default')
version = get_version_number(xcodeproj: 'podcasts.xcodeproj', target: 'podcasts')
files = {
whats_new: File.join(source_metadata_folder, 'release_notes.txt'),
app_store_subtitle: File.join(source_metadata_folder, 'subtitle.txt'),
app_store_desc: File.join(source_metadata_folder, 'description.txt'),
app_store_keywords: File.join(source_metadata_folder, 'keywords.txt')
}
ios_update_metadata_source(
po_file_path: File.join(PROJECT_ROOT_FOLDER, 'fastlane', 'AppStoreStrings.po'),
source_files: files,
release_version: version
)
end
desc 'Downloads localized `.strings` from GlotPress'
lane :download_localized_strings_from_glotpress do
# Use the same name as the frozen strings source to make it explicit these
# are not to be copied as-is in the `*.lproj/Localizable.strings`
table_basename = basename_without_extension(path: FROZEN_STRINGS_PATH)
# Notice that we don't need to track the files we'll download here in Git,
# because the content they carry will be read and ported into the
# appropriate individual localization files next.
download_dir = File.join(FASTLANE_FOLDER, 'app-localization-downloads')
ios_download_strings_files_from_glotpress(
project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL,
locales: GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES,
download_dir: download_dir,
table_basename: table_basename
)
# Redispatch the appropriate subset of translations back to the individual
# `.strings` files that we merged via `ios_merge_strings_files` during
# `complete_code_freeze`.
modified_files = ios_extract_keys_from_strings_files(
source_parent_dir: download_dir,
source_tablename: table_basename,
target_original_files: MANUALLY_MAINTAINED_STRINGS_FILES
)
git_commit(
path: modified_files,
message: 'Update localization files with up-to-date values from GlotPress',
allow_nothing_to_commit: true
)
end
desc 'Downloads localized metadata for App Store Connect from GlotPress'
lane :download_localized_app_store_metadata_from_glotpress do
# FIXME: Replace this with a call to the future replacement of
# `gp_downloadmetadata` once it's implemented in the release-toolkit (see
# paaHJt-31O-p2).
target_files = {
"v#{release_version_current}-whats-new": {
desc: 'release_notes.txt',
max_size: 4000
},
app_store_subtitle: { desc: 'subtitle.txt', max_size: 30 },
app_store_desc: { desc: 'description.txt', max_size: 4000 },
app_store_keywords: { desc: 'keywords.txt', max_size: 100 }
}
gp_downloadmetadata(
project_url: GLOTPRESS_APP_STORE_METADATA_PROJECT_URL,
target_files: target_files,
locales: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES,
download_path: APP_STORE_METADATA_FOLDER
)
files_to_commit = [File.join(APP_STORE_METADATA_FOLDER, '**', '*.txt')]
# Ensure that none of the `.txt` files in `en-US` would accidentally
# override our originals in `default`
target_files.values.map { |h| h[:desc] }.each do |file|
en_file_path = File.join(APP_STORE_METADATA_FOLDER, 'en-US', file)
next unless File.exist?(en_file_path)
UI.user_error!("File `#{en_file_path}` would override the same one in `#{APP_STORE_METADATA_FOLDER}/default
`, but `default/` is the source of truth. " \
+ "Delete the `#{en_file_path}` file, ensure the `default/` one has the expected orig
inal copy, and try again.")
end
# Ensure even empty locale folders have an empty `.gitkeep` file (in case
# we don't have any translation at all ready for some locales)
GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.each_value do |locale|
gitkeep = File.join(APP_STORE_METADATA_FOLDER, locale, '.gitkeep')
next if File.exist?(gitkeep)
FileUtils.mkdir_p(File.dirname(gitkeep))
FileUtils.touch(gitkeep)
files_to_commit.append(gitkeep)
end
# Commit
git_add(path: files_to_commit, shell_escape: false)
git_commit(
path: files_to_commit,
message: 'Update App Store metadata translations',
allow_nothing_to_commit: true
)
end
# Checks the translation progress (%) of all Mag16 for all the projects (app
# strings and metadata) in GlotPress.
#
# @option [Boolean] interactive (default: false) If true, will pause and ask
# confirmation to continue if it found any locale translated below the
# threshold
#
desc 'Check translation progress for all GlotPress projects'
lane :check_all_translations_progress do |options|
abort_on_violations = false
skip_confirm = options.fetch(:interactive, false) == false
UI.header('Checking app strings translation status...')
check_translation_progress(
glotpress_url: GLOTPRESS_APP_STRINGS_PROJECT_URL,
language_codes: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.keys,
abort_on_violations: abort_on_violations,
skip_confirm: skip_confirm
)
UI.header('Checking release notes strings translation status...')
check_translation_progress(
glotpress_url: GLOTPRESS_APP_STORE_METADATA_PROJECT_URL,
language_codes: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.keys,
abort_on_violations: abort_on_violations,
skip_confirm: skip_confirm
)
end
# Upload the localized metadata (from `fastlane/metadata/`) to App Store Connect
#
# @option [Boolean] with_screenshots (default: false) If true, will also upload the latest screenshot files to ASC
#
desc 'Upload the localized metadata to App Store Connect, optionally including screenshots.'
lane :update_metadata_on_app_store_connect do |options|
# Skip screenshots by default. The naming is "with" to make it clear that
# callers need to opt-in to adding screenshots. The naming of the deliver
# (upload_to_app_store) parameter, on the other hand, uses the skip verb.
with_screenshots = options.fetch(:with_screenshots, false)
skip_screenshots = with_screenshots == false
upload_to_app_store(
app_identifier: APP_STORE_VERSION_BUNDLE_IDENTIFIER,
app_version: release_version_current,
skip_binary_upload: true,
screenshots_path: File.join(FASTLANE_FOLDER, 'screenshots'),
skip_screenshots: skip_screenshots,
overwrite_screenshots: true, # won't have effect if `skip_screenshots` is true
phased_release: true,
precheck_include_in_app_purchases: false,
api_key_path: ASC_KEY_PATH
)
end
# -----------------------------------------------------------------------------------
# Kicks off a Buildkite build
# -----------------------------------------------------------------------------------
def trigger_buildkite_release_build(branch:, beta:)
buildkite_trigger_build(
buildkite_organization: 'automattic',
buildkite_pipeline: 'pocket-casts-ios',
branch: branch,
environment: { BETA_RELEASE: beta },
pipeline_file: 'release-builds.yml'
)
end
# Generates localized screenshots for the iPhone, and iPad.
# Tests run in the simulator so be sure to make any necessary Podfile changes such as
# converting to use google-cast-sdk-no-bluetooth-mock
#
desc 'Generates localized screenshots for the AppStore'
lane :screenshots do
iphone_devices = ['iPhone 12']
ipad_devices = ['iPad (9th generation)']
# Build once to speed up the other runs
scan(
workspace: 'podcasts.xcworkspace',
scheme: 'Screenshot Automation',
build_for_testing: true,
clean: true,
devices: iphone_devices + ipad_devices,
reset_simulator: true
)
# iPhone Light Intertace Screens
snapshot(
derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
devices: iphone_devices,
dark_mode: false,
erase_simulator: true,
testplan: 'ScreenshotAutomation_iPhone_Light_Interface'
)
# iPhone Dark Intertace Screens
snapshot(
derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
devices: iphone_devices,
dark_mode: true,
testplan: 'ScreenshotAutomation_iPhone_Dark_Interface'
)
# iPad Light Intertace Screens
snapshot(
derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
devices: ipad_devices,
dark_mode: false,
erase_simulator: true,
testplan: 'ScreenshotAutomation_iPad_Light_Interface'
)
# iPhone Dark Intertace Screens
snapshot(
derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
devices: ipad_devices,
dark_mode: true,
testplan: 'ScreenshotAutomation_iPad_Dark_Interface'
)
end
# Generates localized screenshots for the Apple Watch.
# Tests run in the simulator so be sure to make any necessary Podfile changes such as
# converting to use google-cast-sdk-no-bluetooth-mock
#
# Setup:
# - Log into an account with Plus. Run the test iPhone_GenerateScreenshots.test_watchSetup
# on a device that is connected to the watch mentioned in the test.
# - Ensure the data syncs to the simulated watch. Mocking out the ApplicationContext from
# the device can help ensure a consistent response.
#
desc 'Generates localized Watch screenshots for the AppStore'
lane :watch_screenshots do
watch_devices = ['Apple Watch Series 7 - 45mm']
snapshot(
scheme: 'Screenshot Automation Watch',
devices: watch_devices,
test_without_building: false
)
end
desc 'Registers a device to the Apple Developer Portal and adds it to the appropriate provisioning profiles'
lane :register_new_device do |options|
device_name = UI.input('Device name (leave empty if already added in portal): ') if options[:device_name].nil?
unless device_name.empty?
device_id = UI.input('Device ID: ') if options[:device_id].nil?
UI.message "Adding #{device_name} with ID #{device_id} to the Developer Portal"
UI.message 'Also registering it with any provisioning profiles associated with the following bundle identifiers:'
MAIN_BUNDLE_IDENTIFIERS.each do |identifier|
puts "\t#{identifier}"
end
# Register the user's device
register_device(
name: device_name,
udid: device_id,
team_id: TEAM_ID,
api_key_path: ASC_KEY_PATH
)
end
# We're about to use `add_development_certificates_to_provisioning_profiles` and `add_all_devices_to_provisioning_profiles`.
# These actions use Developer Portal APIs that don't yet support authentication via API key (-.-').
# Let's preemptively ask for and set the email here to avoid being asked twice for it if not set.
require 'credentials_manager'
# If Fastlane cannot instantiate a user, it will ask the caller for the email.
# Once we have it, we can set it as `FASTLANE_USER` in the environment (which has lifecycle limited to this call) so that the next commands will already have access to it.
# Note that if the user is already available to `AccountManager`, setting it in the environment is redundant, but Fastlane doesn't provide a way to check it so we have to do it anyway.
ENV['FASTLANE_USER'] = CredentialsManager::AccountManager.new.user
# Add all development certificates to the provisioning profiles (just in case – this is an easy step to miss)
add_development_certificates_to_provisioning_profiles(
team_id: TEAM_ID,
app_identifier: MAIN_BUNDLE_IDENTIFIERS
)
# Add all devices to the provisioning profiles
add_all_devices_to_provisioning_profiles(
team_id: TEAM_ID,
app_identifier: MAIN_BUNDLE_IDENTIFIERS
)
end
# Generates a HTML containing the libraries acknowledgments.
#
desc 'Generates a HTML with the list of used libraries and their licenses'
lane :acknowledgments do
require 'commonmarker'
acknowledgements = 'Acknowledgments'
markdown = File.read("#{PROJECT_ROOT_FOLDER}/podcasts/acknowledgements.md")
rendered_html = CommonMarker.render_html(markdown, :DEFAULT)
styled_html = "<head>
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 16px;
color: #1a1a1a;
margin: 20px;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: white;
}
}
pre {
white-space: pre-wrap;
}
</style>
<title>
#{acknowledgements}
</title>
</head>
<body>
#{rendered_html}
</body>"
## Remove the <h1>, since we've promoted it to <title>
styled_html = styled_html.sub('<h1>Acknowledgements</h1>', '')
## The glog library's license contains a URL that does not wrap in the web view,
## leading to a large right-hand whitespace gutter. Work around this by explicitly
## inserting a <br> in the HTML. Use gsub juuust in case another one sneaks in later.
styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING<br>')