fastlane 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/assets/FastfileTemplate +3 -0
- data/lib/fastlane.rb +1 -0
- data/lib/fastlane/action.rb +5 -0
- data/lib/fastlane/action_collector.rb +3 -1
- data/lib/fastlane/actions/actions_helper.rb +5 -0
- data/lib/fastlane/actions/add_git_tag.rb +21 -15
- data/lib/fastlane/actions/cert.rb +2 -2
- data/lib/fastlane/actions/clean_build_artifacts.rb +1 -1
- data/lib/fastlane/actions/commit_version_bump.rb +15 -8
- data/lib/fastlane/actions/crashlytics.rb +51 -66
- data/lib/fastlane/actions/deliver.rb +32 -15
- data/lib/fastlane/actions/deploygate.rb +29 -22
- data/lib/fastlane/actions/ensure_git_status_clean.rb +1 -1
- data/lib/fastlane/actions/frameit.rb +2 -2
- data/lib/fastlane/actions/gcovr.rb +2 -2
- data/lib/fastlane/actions/hipchat.rb +36 -23
- data/lib/fastlane/actions/hockey.rb +37 -23
- data/lib/fastlane/actions/increment_build_number.rb +14 -19
- data/lib/fastlane/actions/increment_version_number.rb +42 -44
- data/lib/fastlane/actions/install_carthage.rb +1 -1
- data/lib/fastlane/actions/install_cocapods.rb +1 -1
- data/lib/fastlane/actions/ipa.rb +69 -47
- data/lib/fastlane/actions/notify.rb +1 -1
- data/lib/fastlane/actions/pem.rb +1 -1
- data/lib/fastlane/actions/produce.rb +3 -4
- data/lib/fastlane/actions/push_to_git_remote.rb +24 -14
- data/lib/fastlane/actions/register_devices.rb +23 -11
- data/lib/fastlane/actions/reset_git_repo.rb +13 -5
- data/lib/fastlane/actions/resign.rb +19 -16
- data/lib/fastlane/actions/s3.rb +56 -37
- data/lib/fastlane/actions/sigh.rb +1 -1
- data/lib/fastlane/actions/slack.rb +31 -13
- data/lib/fastlane/actions/snapshot.rb +13 -6
- data/lib/fastlane/actions/team_id.rb +1 -1
- data/lib/fastlane/actions/team_name.rb +1 -1
- data/lib/fastlane/actions/testmunk.rb +28 -14
- data/lib/fastlane/actions/typetalk.rb +1 -1
- data/lib/fastlane/actions/update_fastlane.rb +115 -0
- data/lib/fastlane/actions/update_project_code_signing.rb +22 -7
- data/lib/fastlane/actions/xcode_select.rb +1 -1
- data/lib/fastlane/actions/xcodebuild.rb +56 -12
- data/lib/fastlane/actions_list.rb +2 -2
- data/lib/fastlane/configuration_helper.rb +28 -0
- data/lib/fastlane/fast_file.rb +17 -0
- data/lib/fastlane/fastlane_folder.rb +3 -3
- data/lib/fastlane/setup.rb +11 -5
- data/lib/fastlane/version.rb +1 -1
- metadata +7 -5
@@ -5,7 +5,7 @@ module Fastlane
|
|
5
5
|
|
6
6
|
class TeamIdAction < Action
|
7
7
|
def self.run(params)
|
8
|
-
team = params.first
|
8
|
+
team = (params.first rescue nil)
|
9
9
|
raise "Please pass your Team ID (e.g. team_id 'Q2CBPK58CA')".red unless team.to_s.length > 0
|
10
10
|
|
11
11
|
Helper.log.info "Setting Team ID to '#{team}' for all build steps"
|
@@ -5,7 +5,7 @@ module Fastlane
|
|
5
5
|
|
6
6
|
class TeamNameAction < Action
|
7
7
|
def self.run(params)
|
8
|
-
team = params.first
|
8
|
+
team = (params.first rescue nil)
|
9
9
|
raise "Please pass your Team Name (e.g. team_name 'Felix Krause')".red unless team.to_s.length > 0
|
10
10
|
|
11
11
|
Helper.log.info "Setting Team Name to '#{team}' for all build steps"
|
@@ -12,20 +12,13 @@
|
|
12
12
|
module Fastlane
|
13
13
|
module Actions
|
14
14
|
class TestmunkAction < Action
|
15
|
-
def self.run(
|
16
|
-
raise "Please pass your Testmunk email address using `ENV['TESTMUNK_EMAIL'] = 'value'`" unless ENV['TESTMUNK_EMAIL']
|
17
|
-
raise "Please pass your Testmunk API Key using `ENV['TESTMUNK_API'] = 'value'`" unless ENV['TESTMUNK_API']
|
18
|
-
raise "Please pass your Testmunk app name using `ENV['TESTMUNK_APP'] = 'value'`" unless ENV['TESTMUNK_APP']
|
19
|
-
|
20
|
-
ipa_path = ENV['TESTMUNK_IPA'] || ENV[Actions::SharedValues::IPA_OUTPUT_PATH.to_s]
|
21
|
-
raise "Please pass a path to your ipa file using `ENV['TESTMUNK_IPA'] = 'value'`" unless ipa_path
|
22
|
-
|
15
|
+
def self.run(config)
|
23
16
|
Helper.log.info 'Testmunk: Uploading the .ipa and starting your tests'.green
|
24
17
|
|
25
18
|
response = system("#{"curl -H 'Accept: application/vnd.testmunk.v1+json'" +
|
26
|
-
" -F 'file=@#{
|
27
|
-
" -F 'email=#{
|
28
|
-
" https://#{
|
19
|
+
" -F 'file=@#{config[:ipa]}' -F 'autoStart=true'" +
|
20
|
+
" -F 'email=#{config[:email]}'" +
|
21
|
+
" https://#{config[:api]}@api.testmunk.com/apps/#{config[:app]}/testruns"}")
|
29
22
|
|
30
23
|
if response
|
31
24
|
Helper.log.info 'Your tests are being executed right now. Please wait for the mail with results and decide if you want to continue.'.green
|
@@ -40,9 +33,30 @@ module Fastlane
|
|
40
33
|
|
41
34
|
def self.available_options
|
42
35
|
[
|
43
|
-
|
44
|
-
|
45
|
-
|
36
|
+
FastlaneCore::ConfigItem.new(key: :ipa,
|
37
|
+
env_name: "TESTMUNK_IPA",
|
38
|
+
description: "Path to IPA",
|
39
|
+
verify_block: Proc.new do |value|
|
40
|
+
raise "Please pass to existing ipa" unless File.exists?value
|
41
|
+
end),
|
42
|
+
FastlaneCore::ConfigItem.new(key: :email,
|
43
|
+
env_name: "TESTMUNK_EMAIL",
|
44
|
+
description: "Your email address",
|
45
|
+
verify_block: Proc.new do |value|
|
46
|
+
raise "Please pass your Testmunk email address using `ENV['TESTMUNK_EMAIL'] = 'value'`" unless value
|
47
|
+
end),
|
48
|
+
FastlaneCore::ConfigItem.new(key: :api,
|
49
|
+
env_name: "TESTMUNK_API",
|
50
|
+
description: "Testmunk API Key",
|
51
|
+
verify_block: Proc.new do |value|
|
52
|
+
raise "Please pass your Testmunk API Key using `ENV['TESTMUNK_API'] = 'value'`" unless value
|
53
|
+
end),
|
54
|
+
FastlaneCore::ConfigItem.new(key: :app,
|
55
|
+
env_name: "TESTMUNK_APP",
|
56
|
+
description: "Testmunk App Name",
|
57
|
+
verify_block: Proc.new do |value|
|
58
|
+
raise "Please pass your Testmunk app name using `ENV['TESTMUNK_APP'] = 'value'`" unless value
|
59
|
+
end),
|
46
60
|
]
|
47
61
|
end
|
48
62
|
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'rubygems/spec_fetcher'
|
2
|
+
require 'rubygems/commands/update_command'
|
3
|
+
|
4
|
+
module Fastlane
|
5
|
+
module Actions
|
6
|
+
# Makes sure fastlane tools are up-to-date when running fastlane
|
7
|
+
class UpdateFastlaneAction < Action
|
8
|
+
|
9
|
+
ALL_TOOLS = [
|
10
|
+
"fastlane",
|
11
|
+
"fastlane_core",
|
12
|
+
"deliver",
|
13
|
+
"snapshot",
|
14
|
+
"frameit",
|
15
|
+
"pem",
|
16
|
+
"sigh",
|
17
|
+
"produce",
|
18
|
+
"cert",
|
19
|
+
"codes",
|
20
|
+
"credentials_manager"
|
21
|
+
]
|
22
|
+
|
23
|
+
def self.run(options)
|
24
|
+
if options[:no_update]
|
25
|
+
return
|
26
|
+
end
|
27
|
+
|
28
|
+
tools_to_update = options[:tools].split ',' unless options[:tools].nil?
|
29
|
+
tools_to_update ||= all_installed_tools
|
30
|
+
|
31
|
+
if tools_to_update.count == 0
|
32
|
+
Helper.log.error "No tools specified or couldn't find any installed fastlane.tools".red
|
33
|
+
return
|
34
|
+
end
|
35
|
+
|
36
|
+
updater = Gem::Commands::UpdateCommand.new
|
37
|
+
|
38
|
+
sudo_needed = !File.writable?(Gem.dir)
|
39
|
+
|
40
|
+
if sudo_needed
|
41
|
+
Helper.log.warn "It seems that your Gem directory is not writable by your current User."
|
42
|
+
Helper.log.warn "Fastlane would need sudo rights to update itself, however, running 'sudo fastlane' is not recommended."
|
43
|
+
Helper.log.warn "If you still want to use this action, please read the Actions.md documentation on a guide how to set this up."
|
44
|
+
return
|
45
|
+
end
|
46
|
+
|
47
|
+
highest_versions = updater.highest_installed_gems.keep_if {|key| tools_to_update.include? key }
|
48
|
+
update_needed = updater.which_to_update(highest_versions, tools_to_update)
|
49
|
+
|
50
|
+
if update_needed.count == 0
|
51
|
+
Helper.log.info "Nothing to update! 😮".yellow
|
52
|
+
return
|
53
|
+
end
|
54
|
+
|
55
|
+
#suppress updater output - very noisy
|
56
|
+
Gem::DefaultUserInteraction.ui = Gem::SilentUI.new
|
57
|
+
|
58
|
+
update_needed.each do |tool_info|
|
59
|
+
tool = tool_info[0]
|
60
|
+
local_version = Gem::Version.new(highest_versions[tool].version)
|
61
|
+
latest_version = FastlaneCore::UpdateChecker.fetch_latest(tool)
|
62
|
+
Helper.log.info "Updating #{tool} from #{local_version} to #{latest_version} ... 🚀"
|
63
|
+
|
64
|
+
# Approximate_recommendation will create a string like "~> 0.10" from a version 0.10.0, e.g. one that is valid for versions >= 0.10 and <1.0
|
65
|
+
updater.update_gem tool, Gem::Requirement.new(local_version.approximate_recommendation)
|
66
|
+
|
67
|
+
Helper.log.info "Finished updating #{tool}"
|
68
|
+
end
|
69
|
+
|
70
|
+
any_updates = updater.installer.installed_gems.any? do |updated_tool|
|
71
|
+
updated_tool.version > highest_versions[updated_tool.name].version
|
72
|
+
end
|
73
|
+
|
74
|
+
if any_updates
|
75
|
+
Helper.log.info "fastlane.tools succesfully updated! I will now restart myself... 😴"
|
76
|
+
|
77
|
+
# Set no_update to true so we don't try to update again
|
78
|
+
exec "FL_NO_UPDATE=true #{$PROGRAM_NAME} #{ARGV.join ' '}"
|
79
|
+
else
|
80
|
+
Helper.log.info "All fastlane tools are up-to-date!"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.all_installed_tools
|
85
|
+
Gem::Specification.select { |s| ALL_TOOLS.include? s.name }.map {|s| s.name}.uniq
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.description
|
89
|
+
"Makes sure fastlane-tools are up-to-date when running fastlane"
|
90
|
+
end
|
91
|
+
|
92
|
+
def self.available_options
|
93
|
+
[
|
94
|
+
FastlaneCore::ConfigItem.new(key: :tools,
|
95
|
+
env_name: "FL_TOOLS_TO_UPDATE",
|
96
|
+
description: "Comma separated list of fastlane tools to update (e.g. fastlane,deliver,sigh). If not specified, all currently installed fastlane-tools will be updated",
|
97
|
+
optional: true),
|
98
|
+
FastlaneCore::ConfigItem.new(key: :no_update,
|
99
|
+
env_name: "FL_NO_UPDATE",
|
100
|
+
description: "Don't update during this run. Defaults to false",
|
101
|
+
is_string: false,
|
102
|
+
default_value: false),
|
103
|
+
]
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.author
|
107
|
+
"milch"
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.is_supported?(platform)
|
111
|
+
true
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -5,19 +5,16 @@ module Fastlane
|
|
5
5
|
|
6
6
|
class UpdateProjectCodeSigningAction < Action
|
7
7
|
def self.run(params)
|
8
|
-
path = params
|
8
|
+
path = params[:path]
|
9
9
|
path = File.join(path, "project.pbxproj")
|
10
10
|
raise "Could not find path to project config '#{path}'. Pass the path to your project (not workspace)!".red unless File.exists?(path)
|
11
11
|
|
12
|
-
|
13
|
-
udid ||= ENV["SIGH_UDID"]
|
14
|
-
|
15
|
-
Helper.log.info("Updating provisioning profile UDID (#{udid}) for the given project '#{path}'")
|
12
|
+
Helper.log.info("Updating provisioning profile UDID (#{params[:udid]}) for the given project '#{path}'")
|
16
13
|
|
17
14
|
p = File.read(path)
|
18
|
-
File.write(path, p.gsub(/PROVISIONING_PROFILE = ".*";/, "PROVISIONING_PROFILE = \"#{udid}\";"))
|
15
|
+
File.write(path, p.gsub(/PROVISIONING_PROFILE = ".*";/, "PROVISIONING_PROFILE = \"#{params[:udid]}\";"))
|
19
16
|
|
20
|
-
Helper.log.info("Successfully updated project settings to use UDID '#{udid}'".green)
|
17
|
+
Helper.log.info("Successfully updated project settings to use UDID '#{params[:udid]}'".green)
|
21
18
|
end
|
22
19
|
|
23
20
|
def self.description
|
@@ -28,6 +25,24 @@ module Fastlane
|
|
28
25
|
"This feature is not yet 100% finished"
|
29
26
|
end
|
30
27
|
|
28
|
+
def self.available_options
|
29
|
+
[
|
30
|
+
FastlaneCore::ConfigItem.new(key: :path,
|
31
|
+
env_name: "FL_PROJECT_SIGNING_PROJECT_PATH",
|
32
|
+
description: "Path to your Xcode project",
|
33
|
+
verify_block: Proc.new do |value|
|
34
|
+
raise "Path is invalid".red unless File.exists?(value)
|
35
|
+
end),
|
36
|
+
FastlaneCore::ConfigItem.new(key: :udid,
|
37
|
+
env_name: "FL_PROJECT_SIGNING_UDID",
|
38
|
+
description: "The UDID of the provisioning profile you want to use",
|
39
|
+
default_value: ENV["SIGH_UDID"],
|
40
|
+
verify_block: Proc.new do |value|
|
41
|
+
raise "Path is invalid".red unless File.exists?(value)
|
42
|
+
end)
|
43
|
+
]
|
44
|
+
end
|
45
|
+
|
31
46
|
def self.author
|
32
47
|
"KrauseFx"
|
33
48
|
end
|
@@ -19,7 +19,7 @@ module Fastlane
|
|
19
19
|
#
|
20
20
|
class XcodeSelectAction < Action
|
21
21
|
def self.run(params)
|
22
|
-
xcode_path = params.first
|
22
|
+
xcode_path = (params.first rescue nil)
|
23
23
|
|
24
24
|
# Verify that a param was passed in
|
25
25
|
raise "Path to Xcode application required (e.x. \"/Applications/Xcode.app\")".red unless xcode_path.to_s.length > 0
|
@@ -66,7 +66,7 @@ module Fastlane
|
|
66
66
|
end
|
67
67
|
|
68
68
|
|
69
|
-
if params
|
69
|
+
if params
|
70
70
|
# Operation bools
|
71
71
|
archiving = params.key? :archive
|
72
72
|
exporting = params.key? :export_archive
|
@@ -246,9 +246,9 @@ module Fastlane
|
|
246
246
|
|
247
247
|
class XcarchiveAction < Action
|
248
248
|
def self.run(params)
|
249
|
-
params_hash = params
|
249
|
+
params_hash = params || {}
|
250
250
|
params_hash[:archive] = true
|
251
|
-
XcodebuildAction.run(
|
251
|
+
XcodebuildAction.run(params_hash)
|
252
252
|
end
|
253
253
|
|
254
254
|
def self.description
|
@@ -262,13 +262,22 @@ module Fastlane
|
|
262
262
|
def self.is_supported?(platform)
|
263
263
|
platform == :ios
|
264
264
|
end
|
265
|
+
|
266
|
+
def self.available_options
|
267
|
+
[
|
268
|
+
['archive_path', 'The path to archive the to. Must contain `.xcarchive`'],
|
269
|
+
['workspace', 'The workspace to use'],
|
270
|
+
['scheme', 'The scheme to build'],
|
271
|
+
['build_settings', 'Hash of additional build information']
|
272
|
+
]
|
273
|
+
end
|
265
274
|
end
|
266
275
|
|
267
276
|
class XcbuildAction < Action
|
268
277
|
def self.run(params)
|
269
|
-
params_hash = params
|
278
|
+
params_hash = params || {}
|
270
279
|
params_hash[:build] = true
|
271
|
-
XcodebuildAction.run(
|
280
|
+
XcodebuildAction.run(params_hash)
|
272
281
|
end
|
273
282
|
|
274
283
|
def self.description
|
@@ -282,13 +291,23 @@ module Fastlane
|
|
282
291
|
def self.is_supported?(platform)
|
283
292
|
platform == :ios
|
284
293
|
end
|
294
|
+
|
295
|
+
def self.available_options
|
296
|
+
[
|
297
|
+
['archive', 'Set to true to build archive'],
|
298
|
+
['archive_path', 'The path to archive the to. Must contain `.xcarchive`'],
|
299
|
+
['workspace', 'The workspace to use'],
|
300
|
+
['scheme', 'The scheme to build'],
|
301
|
+
['build_settings', 'Hash of additional build information']
|
302
|
+
]
|
303
|
+
end
|
285
304
|
end
|
286
305
|
|
287
306
|
class XccleanAction < Action
|
288
307
|
def self.run(params)
|
289
|
-
params_hash = params
|
308
|
+
params_hash = params || {}
|
290
309
|
params_hash[:clean] = true
|
291
|
-
XcodebuildAction.run(
|
310
|
+
XcodebuildAction.run(params_hash)
|
292
311
|
end
|
293
312
|
|
294
313
|
def self.description
|
@@ -302,13 +321,23 @@ module Fastlane
|
|
302
321
|
def self.is_supported?(platform)
|
303
322
|
platform == :ios
|
304
323
|
end
|
324
|
+
|
325
|
+
def self.available_options
|
326
|
+
[
|
327
|
+
['archive', 'Set to true to build archive'],
|
328
|
+
['archive_path', 'The path to archive the to. Must contain `.xcarchive`'],
|
329
|
+
['workspace', 'The workspace to use'],
|
330
|
+
['scheme', 'The scheme to build'],
|
331
|
+
['build_settings', 'Hash of additional build information']
|
332
|
+
]
|
333
|
+
end
|
305
334
|
end
|
306
335
|
|
307
336
|
class XcexportAction < Action
|
308
337
|
def self.run(params)
|
309
|
-
params_hash = params
|
338
|
+
params_hash = params || {}
|
310
339
|
params_hash[:export_archive] = true
|
311
|
-
XcodebuildAction.run(
|
340
|
+
XcodebuildAction.run(params_hash)
|
312
341
|
end
|
313
342
|
|
314
343
|
def self.description
|
@@ -319,6 +348,16 @@ module Fastlane
|
|
319
348
|
"dtrenz"
|
320
349
|
end
|
321
350
|
|
351
|
+
def self.available_options
|
352
|
+
[
|
353
|
+
['archive', 'Set to true to build archive'],
|
354
|
+
['archive_path', 'The path to archive the to. Must contain `.xcarchive`'],
|
355
|
+
['workspace', 'The workspace to use'],
|
356
|
+
['scheme', 'The scheme to build'],
|
357
|
+
['build_settings', 'Hash of additional build information']
|
358
|
+
]
|
359
|
+
end
|
360
|
+
|
322
361
|
def self.is_supported?(platform)
|
323
362
|
platform == :ios
|
324
363
|
end
|
@@ -326,17 +365,22 @@ module Fastlane
|
|
326
365
|
|
327
366
|
class XctestAction < Action
|
328
367
|
def self.run(params)
|
329
|
-
params_hash = params
|
368
|
+
params_hash = params || {}
|
330
369
|
params_hash[:test] = true
|
331
|
-
XcodebuildAction.run(
|
370
|
+
XcodebuildAction.run(params_hash)
|
332
371
|
end
|
333
372
|
|
334
373
|
def self.description
|
335
374
|
"Runs tests on the given simulator"
|
336
375
|
end
|
337
376
|
|
338
|
-
def available_options
|
377
|
+
def self.available_options
|
339
378
|
[
|
379
|
+
['archive', 'Set to true to build archive'],
|
380
|
+
['archive_path', 'The path to archive the to. Must contain `.xcarchive`'],
|
381
|
+
['workspace', 'The workspace to use'],
|
382
|
+
['scheme', 'The scheme to build'],
|
383
|
+
['build_settings', 'Hash of additional build information'],
|
340
384
|
['destination', 'The simulator to use, e.g. "name=iPhone 5s,OS=8.1"']
|
341
385
|
]
|
342
386
|
end
|
@@ -107,7 +107,7 @@ module Fastlane
|
|
107
107
|
end
|
108
108
|
|
109
109
|
private
|
110
|
-
def self.parse_options(options,
|
110
|
+
def self.parse_options(options, fill_all = true)
|
111
111
|
rows = []
|
112
112
|
rows << [options] if options.kind_of?String
|
113
113
|
|
@@ -119,7 +119,7 @@ module Fastlane
|
|
119
119
|
raise "Invalid number of elements in this row: #{current}. Must be 2 or 3".red unless ([2, 3].include?current.count)
|
120
120
|
rows << current
|
121
121
|
rows.last[0] = rows.last.first.yellow # color it yellow :)
|
122
|
-
rows.last << nil
|
122
|
+
rows.last << nil while (fill_all and rows.last.count < 3) # to have a nice border in the table
|
123
123
|
end
|
124
124
|
end
|
125
125
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Fastlane
|
2
|
+
class ConfigurationHelper
|
3
|
+
def self.parse(action, params)
|
4
|
+
begin
|
5
|
+
first_element = (action.available_options.first rescue nil) # might also be nil
|
6
|
+
|
7
|
+
if first_element and first_element.kind_of?FastlaneCore::ConfigItem
|
8
|
+
# default use case
|
9
|
+
return FastlaneCore::Configuration.create(action.available_options, params)
|
10
|
+
|
11
|
+
elsif first_element
|
12
|
+
Helper.log.error "Action '#{action}' uses the old configuration format."
|
13
|
+
puts "Old configuration format for action '#{action}'".red if Helper.is_test?
|
14
|
+
return params
|
15
|
+
else
|
16
|
+
|
17
|
+
# No parameters... we still need the configuration object array
|
18
|
+
FastlaneCore::Configuration.create(action.available_options, {})
|
19
|
+
|
20
|
+
end
|
21
|
+
rescue => ex
|
22
|
+
Helper.log.fatal "You provided an option to action #{action.action_name} which is not supported.".red
|
23
|
+
Helper.log.fatal "Check out the available options below or run `fastlane action #{action.action_name}`".red
|
24
|
+
raise ex
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|