使用 fastlane 进行 ios app自动打包

前言

公司 App工厂 项目每天要进行10来次打包分发测试,并且要根据要求按环境、配置进行打包。如果使用手动的方式,不仅效率低,还容易配置出错,对于开发人员来说相当折磨。

Xcodebuild 是一个命令行打包命令,配合脚本使用可以实现简单的打包分发流程,可是不方便更改项目内的一些配置和灵活管理开发者中心的 CertificatesIdentifiersProvisioning Profiles

Fastlane 不仅可以更快的打包,还能够帮助开发者团队管理证书,生成 App IDProvisioning Profile ,生成应用截图,处理代码签名,甚至提交审核。

接下来记录一下我使用 fastlane 时碰到的一些问题。

使用 fastlane

首先安装fastlane
1
xcode-select --install
1
[sudo] gem install fastlane -NV
加载.env文件里的环境变量,使用 dotenv —— A Ruby gem to load environment variables from .env.

Add this line to the top of your application’s Gemfile:

1
gem 'dotenv-rails', groups: [:development, :test]
1
$ bundle
1
$ gem install dotenv
使用 update_info_plist

error: You must specify either a plist path or a scheme

必须设置 plist path 或者 scheme ,然后就可以找到对应的 plist 文件更改CFBundleDisplayName,CFBundleShortVersionString之类的值了。

注意:

使用 update_info_plist 修改不了项目Bundle ID(一般info.plist文件里面CFBundleIdentifier的值是$(PRODUCT_BUNDLE_IDENTIFIER)),因为并不会修改Build SettingsPRODUCT_BUNDLE_IDENTIFIER的值。参考 issue

produce

error: User [email protected] doesn’t have enough permission for the following action: user_details_data

出现这个错误是由于我用的开发者账号(企业账号)没有 user_details_data 的权限(应该是跟 appstoreconnect 相关),而参数 skip_itc 没有设为 false

还有一个问题是:要想使用 produce 修改已经生成的App ID的 services ,目前produce cannot modify services with a lane,参考这个issue,可以改用命令行修改,

1
sh "fastlane produce enable_services --push_notification -a #{bundle_id}"

去文档上查了一下发现有 modify_services 这个 action 可以修改,不过我没有去使用过。

如果项目里有multiple targets (e.g. Today Widget or WatchOS Extension or Notification Extension),也需要使用produce创建对应的App ID。然而我碰到过另一个问题:

An App ID with Identifier ‘com.foo.bar.push’ is not available. Please enter a different string

这个错误应该是由于com.foo.bar.push这个Bundle ID被其它开发者账号用来创建了App ID,解决办法就是:

  1. 改用另一个
  2. 去那个开发者账号(如果你知道)删除对应的App ID。

可是Xcode开启了自动签名却能生成有效的pp文件,我检查了一下pp文件,发现App ID名称是XC Wildcard,能匹配所有的Bundle ID。接下来我去查了一下资料,发现这是因为Extension的target不使用capabilities,当Xcode开启自动签名,fix issue时,可能会adding a wildcard team provisioning profile,

Using a Wildcard App ID is convenient for all apps that do not use capabilities, as they can reuse the same provisioning profile for code signing

match

error: Could not create another Distribution certificate, reached the maximum number of available Distribution certificates.

当开发者账户中已经有了最大数量的证书时,fastlane match不能创建新的证书,然而也不能使用本地 keychain 中可用的证书并推送到git仓库。

这个 issue 就有人提到希望fastlane match可以使用本地可用的证书。

There will be good to have build-in initializer to export all existing certificates to git repo.

The steps are, for example, described here:
https://medium.com/@jonathancardoso/using-fastlane-match-with-existing-certificates-without-revoking-them-a325be69dac6

Fastlane could just fetch all certificates using Spaceship to get their cert_ids, then extract all identities existing in keychain according to fetched list and then push everything to repo.

I suppose it to be run once on a build node/developer mac/etc, and then everyone else could just use fastlane match to install all this certificates in a simple and easy way.

当你需要在多个team之间切换时,注意切换分支。

match also supports storing certificates of multiple teams in one repo, by using separate git branches. If you work in multiple teams, make sure to set the git_branch parameter to a unique value per team. From there, match will automatically create and use the specified branch for you.

环境变量MATCH_PASSWORD是你最开始使用match时创建的git仓库设置的权限密码。

gym

这个 action 是进行 archive 和 export 的。

archive 需要配置好对应的Bundle ID,Provisioning Profile(development), developer 证书。

export需要配置好method,Provisioning Profile(appstore, adhoc, enterprise), distribution 证书。

如果项目设置了 Automatically manage signing,gym会使用Xcode默认选择的pp文件,不会使用之前match下载的。这对于Bundle ID不会修改来发布企业包的项目来说没什么操心的,但是如果项目Bundle ID以前是com.foo.bar1,现在需要用新的com.foo.bar2来打包,那么archive得到的文件可能Bundle ID为com.foo.bar2,使用的pp文件却是Bundle ID为com.foo.bar1的。之后export(使用的pp文件是新生成的Bundle ID为com.foo.bar2的的)就会报错如下:

error: exportArchive: Provisioning profile “match InHouse com.foo.bar2” doesn’t match the entitlements file’s value for the application-identifier entitlement.

就是 archive 和 export 使用的 pp 文件Bundle ID不同。

gym 虽然有 xcargs 这个参数可以配置 archive 时候的Provisioning Profile,可是项目如果有multiple targets (e.g. Today Widget or WatchOS Extension or Notification Extension),这一个参数怎么给多个target配置pp文件。

于是查看了很多issue之后,发现了automatic_code_signing这个action既可以关闭Automatically manage signing,又可以根据target来设置pp文件。

做好了 archive 和 export 的签名匹配之后,gym打包就能成功了。

1
2
3
4
5
-allowProvisioningUpdates  
Allow xcodebuild to communicate with the Apple Developer website.
For automatically signed targets, xcodebuild will create and update profiles, app IDs, and certificates.
For manually signed targets, xcodebuild will download missing or updated provisioning profiles.
Requires a developer account to have been added in Xcode's Accounts preference pane.
1
According to xcodebuild -help, signingStyle defaults to automatic for apps that were automatically signed when archived (which is our case). I assume this is the reason xcodebuild seems to ignore the provisioningProfiles entry..

gym error:

gym archive 的过程是根据Xcode中配置的Provisioning Profile(type是development,证书是开发者证书)来 archive 的,export 需要的Provisioning Profile(type是production,证书是发布证书),得注意检查这个

1
error: exportArchive: "PushSerives.appex" requires a provisioning profile.

由于项目包含notification service extension,需要 provisioning profile ,所以先使用produce生成App ID,再使用match(其实是sign)生成 provisioning profile ,

error: exportArchive: Provisioning profile “match InHouse com.sobeycloud.enterprise.test1” doesn’t support the Push Notifications capability.

error: exportArchive: Provisioning profile “match InHouse com.sobeycloud.enterprise.test1” doesn’t include the aps-environment entitlement and doesn’t match the entitlements file’s value for the application-identifier entitlement.

error: exportArchive: Provisioning profile “match InHouse com.sobeycloud.enterprise.test1.push” doesn’t match the entitlements file’s value for the application-identifier entitlement.

Jenkins+fastlane

Error:

fastlane: command not found

bundle: command not found

stackoverflow上有解释,不过在使用全路径时,到后面可能又会碰到error:

xcpretty: command not found

根据issue里的描述,可以在”构建”中的shell命令中添加

1
PATH="/usr/local/bin:$PATH"

Fatal: could not read Username for ‘https://foo.bar‘: terminal prompts disabled

可参照:issue

As you probably know, since you can’t type in a password during your CI builds, match & Git will have trouble using the https authentication method unless your build machine has its keychain set up with the right credentials for your repository.

The error message you provided is letting you know that match could not determine the right password to use. It needed to prompt you, but on CI, it is not allowed to prompt because that would cause the build to hang.

Our suggestion would be to investigate the Keychains and Provisioning Profiles Plugin mentioned

Error:

jenkins-fastlane error检查后发现证书和配置文件是下载到打包机器上了的,可是没有打开钥匙串访问权限,所以 CodeSign 失败。

Success:

jenkins-fastlane success1

钉钉上的机器人提醒:

success2

总结

由于项目总共有大量租户App,工作时间随时都可能会有发布企业包测试要求,而每个租户App的环境与配置都不一样,我们开发人员一个一个手动修改环境和配置文件将耗费很大的精力与时间,且容易出错。于是我将所需配置的变量全部放在 .env 文件中,包括要上架 App Store 的开发者账号,将证书同步到Git中管理,保证能够在企业账号和不同公司账号之间自动切换。最后成功将打包+分发时间从 30分钟以上 降低到 3分钟以内 ,且大大降低了手动修改环境和配置造成的出错概率。

以下是 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

xcodeproj_path = "./MyApp.xcodeproj"

username = ENV["FASTLANE_USER"]
team_id = ENV["TEAM_ID"]
scheme = ENV["SCHEME"]
bundle_id = ENV["BUNDLE_ID"]
app_name = ENV["APP_NAME"]
version_number = ENV["VERSION"]
build_number = ENV["BUILD"]
export_method = ENV["EXPORT_METHOD"]

pgy_api_key = ENV["pgy_api_key"]
pgy_user_key = ENV["pgy_user_key"]
sentry_auth_token = ENV["sentry_auth_token"]
sentry_org_slug = ENV["sentry_org_slug"]
sentry_project_slug = ENV["sentry_project_slug"]

url_schemes_wechat = [ENV["app_key_wechat"]]
url_schemes_qq = ["tencent" + ENV["app_key_qq"]]
url_schemes_sina = ["wb" + ENV["app_key_sina"]]
url_schemes_eg = [ENV["app_key_eg"]]
url_schemes_yz = [ENV["app_key_yz"]]
url_schemes_appfactory = [bundle_id.delete(".")]


platform :ios do

before_all do |lane, options|

setup_jenkins

end

desc "Push a new beta build to pgy"
lane :build do |options|

on_update_info_plist
on_match
on_gym

upload_symbols_to_sentry(
auth_token: sentry_auth_token,
org_slug: sentry_org_slug,
project_slug: sentry_project_slug,
) if export_method == "appstore"

pgyer(
api_key: pgy_api_key,
user_key: pgy_user_key,
) unless export_method == "appstore"

end

lane :on_match do

produce(
username: username,
app_identifier: bundle_id,
app_name: app_name,
skip_itc: true,
enable_services: {
push_notification: "on",
}
)

match(
git_branch: username,
username: username,
type: "development",
app_identifier: [bundle_id],
)

match(
git_branch: username,
username: username,
type: export_method,
app_identifier: [bundle_id],
)

end

lane :on_gym do

profile_name = ENV["sigh_#{bundle_id}_development_profile-name"]

UI.important "profile_name = '#{profile_name}'"

automatic_code_signing(
path: xcodeproj_path,
targets: [scheme],
use_automatic_signing: false,
profile_name: profile_name,
team_id: team_id,
bundle_identifier: bundle_id,
)

gym(
scheme: scheme,

silent: true,
export_options: {
method: export_method,
}
)

end

lane :on_update_info_plist do

update_info_plist(
xcodeproj: xcodeproj_path,
scheme: scheme,
block: proc do |plist|
plist["CFBundleDisplayName"] = app_name
plist["CFBundleName"] = app_name
plist["CFBundleShortVersionString"] = version_number
plist["CFBundleVersion"] = build_number
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "appfactory" }
if urlScheme
urlScheme["CFBundleURLSchemes"] = url_schemes_appfactory
else
plist["CFBundleURLTypes"] = plist["CFBundleURLTypes"] + [{"CFBundleTypeRole" => "Editor", "CFBundleURLName" => "appfactory", "CFBundleURLSchemes" => url_schemes_appfactory}]
end
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "weixin" }
urlScheme["CFBundleURLSchemes"] = url_schemes_wechat if urlScheme
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "tencentLogin" }
urlScheme["CFBundleURLSchemes"] = url_schemes_qq if urlScheme
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "wb" }
urlScheme["CFBundleURLSchemes"] = url_schemes_sina if urlScheme
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "EGMonitor" }
urlScheme["CFBundleURLSchemes"] = url_schemes_eg if urlScheme
urlScheme = plist["CFBundleURLTypes"].find{ |scheme| scheme["CFBundleURLName"] == "YZYUN" }
urlScheme["CFBundleURLSchemes"] = url_schemes_yz if urlScheme
end
)

end

end