之前简单写过一个iOS自动打包脚本,在实际使用过程中逐步增加了一些新的feature:
为方便使用,将部分参数做成可选并提供默认值;
打adhoc包自动读取最近的提交日志,最多30条;参考时间线是最后一次更新adhoc包的时间;
app内环境切换开关不依赖Debug/Release,通过脚本控制自定义预编译宏来把控;
打线上包时,自动从主工程同步version和build到Notification;
xcode11去掉了Loader.app, xcrun altool增加了验证参数;
都是一些简单的feature,大概说一下实现吧:
1、参数解析,由原来的sys.argv改成argparse,可设置可选参数、默认参数、固定参数值选项;
2、读取git提交日志,使用第三方python库git,这里顺便做了个简单的过滤(受益于提交规范化);
3、脚本控制自定义预编译宏来控制app内环境切换,借助pbxproj库来修改project.pbxproj中的配置(这里有个小问题,覆写pbxproj文件xcode会识别到文件损坏,但是覆写完成后xcode并不能自动恢复正常,需要重启xcode);
4、版本号同步,首先从蒲公英读取最新的build号,然后+1设置到当前的build,借用plistlib修改plist文件;
5、xcode11没有了Loader.app,上传和验证app直接用xcrun altool,另外增加了apiKey和apiIssuer参数(还有个额外的p8文件需要放置到当前用户目录下"~/.appstoreconnect/private_keys/AuthKey_xxx.p8"),原先的用户名和密码不需要了;
具体代码如下(代码中的xxx部分做了脱敏):
import argparse
import os
import plistlib
import re
import shutil
import sys
import time
import zipfile
import git
import requests
from pbxproj import XcodeProject
# iOS项目群机器人
DING_ROBOT_URL1 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# 测试群机器人
DING_ROBOT_URL2 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# iOS提审交流群机器人
DING_ROBOT_URL3 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# 蒲公英设置
USER_KEY = "xxx"
API_KEY = "xxx"
APP_KEY = "xxx"
# appstore
APPSTORE_API_KEY = 'xxx'
ISSUER_ID = 'xxx'
# 上一次上传app时间戳
lastBuildCreated = 0
def syncBuildVersionWithPGY():
success = False
global lastBuildCreated
url = 'http://www.pgyer.com/apiv2/app/view'
data = {
'_api_key': API_KEY,
'appKey': APP_KEY
}
r = requests.post(url, data=data)
if r.status_code == 200:
json = r.json()
buildVersion = json['data']['buildBuildVersion']
lastBuildCreated = json['data']['buildCreated']
# 修改工程build号
infoPlistPath = os.path.join(args.workPath, 'xxx/Info.plist')
if os.path.exists(infoPlistPath):
rewriteBuildVersion(infoPlistPath, str(int(buildVersion) + 1))
success = True
else:
print(infoPlistPath, '不存在!')
else:
print('获取蒲公英最新build失败...')
# 上一次上传app时间戳
if lastBuildCreated:
lastBuildCreated = time.mktime(time.strptime(lastBuildCreated, '%Y-%m-%d %H:%M:%S')) - 2 * 60
return success
def rewriteBuildVersion(infoPlist, buildNumStr):
with open(infoPlist, 'rb') as f:
plist = plistlib.load(f)
plist['CFBundleVersion'] = buildNumStr
appVersion = plist['CFBundleShortVersionString']
with open(infoPlist, 'wb') as f:
plistlib.dump(plist, f)
# 打线上包,同步Notification的版本号
infoPlistPath = os.path.join(args.workPath, 'Notification/Info.plist')
if args.distribution == 'app-store' and os.path.exists(infoPlistPath):
with open(infoPlistPath, 'rb') as f:
plist = plistlib.load(f)
plist['CFBundleVersion'] = buildNumStr
plist['CFBundleShortVersionString'] = appVersion
with open(infoPlistPath, 'wb') as f:
plistlib.dump(plist, f)
def rewriteXcodePrecompileMacro():
"""
覆写工程配置
1、ad-hoc包,支持APP内切换环境 (MT_ENV_SWITCHABLE=1);
2、app-store包,不支持APP内切换环境 (MT_ENV_SWITCHABLE=0);
"""
project = XcodeProject.load(os.path.join(args.workPath, 'xxx.xcodeproj/project.pbxproj'))
buildConfigurations = project.get_build_phases_by_name('XCBuildConfiguration')
buildSettings = None
# print(buildConfigurations)
for configuration in buildConfigurations:
if args.distribution == 'ad-hoc' and args.build == configuration.name:
# 内测包
buildSettings = configuration.buildSettings
elif args.distribution == 'app-store' and configuration.name == 'Release':
# 线上包
buildSettings = configuration.buildSettings
# 覆写预编译宏
if buildSettings and buildSettings['SDKROOT']:
if not buildSettings['GCC_PREPROCESSOR_DEFINITIONS']:
# None
if args.distribution == 'ad-hoc':
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = 'MT_ENV_SWITCHABLE=1'
else:
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = 'MT_ENV_SWITCHABLE=0'
elif type(buildSettings['GCC_PREPROCESSOR_DEFINITIONS']) == str:
# 字符串
if buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].startswith('MT_ENV_SWITCHABLE') \
or len(buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].strip()) == 0:
if args.distribution == 'ad-hoc':
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = 'MT_ENV_SWITCHABLE=1'
else:
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = 'MT_ENV_SWITCHABLE=0'
else:
if args.distribution == 'ad-hoc':
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = [buildSettings['GCC_PREPROCESSOR_DEFINITIONS'], 'MT_ENV_SWITCHABLE=1']
else:
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'] = [buildSettings['GCC_PREPROCESSOR_DEFINITIONS'], 'MT_ENV_SWITCHABLE=0']
else:
# 数组
for setting in buildSettings['GCC_PREPROCESSOR_DEFINITIONS']:
if setting.startswith('MT_ENV_SWITCHABLE'):
# 删除旧设置
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].remove(setting)
break
# 添加新设置
if args.distribution == 'ad-hoc':
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].append('MT_ENV_SWITCHABLE=1')
else:
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].append('MT_ENV_SWITCHABLE=0')
project.save()
# 覆写配置文件后需重启xcode
os.system('killall Xcode')
time.sleep(2)
os.system('open %s' % (os.path.join(args.workPath, 'xxx.xcworkspace')))
def cleanResult():
if os.path.exists(export_path):
shutil.rmtree(export_path)
def archive():
os.chdir(args.workPath)
if args.distribution == 'ad-hoc':
plistName = 'adhoc_config.plist'
configuration = args.build
else:
plistName = 'appstore_config.plist'
configuration = 'Release'
# clean
os.system('xcodebuild clean -workspace xxx.xcworkspace -scheme xxx -configuration %s' % configuration)
# archive
os.system('xcodebuild archive -workspace xxx.xcworkspace -scheme xxx -configuration %s -archivePath %s' % (configuration, xcarchive_path))
# export
os.system('xcodebuild -exportArchive -archivePath %s -exportPath %s '
'-exportOptionsPlist %s' % (xcarchive_path, export_path, os.path.join(bundlePlistDir(), plistName)))
def bundlePlistDir():
if getattr(sys, 'frozen', False):
# we are running in a PyInstaller bundle
basedir = sys._MEIPASS
else:
# we are running in a normal Python environment
basedir = os.path.dirname(__file__)
return os.path.join(basedir, 'plist')
def uploadToPGY():
print('开始上传至蒲公英...')
ipa_path = os.path.join(export_path, 'xxx.ipa')
if os.path.exists(ipa_path):
url = 'https://qiniu-storage.pgyer.com/apiv1/app/upload'
data = {
'uKey': USER_KEY,
'_api_key': API_KEY,
'installType': '1'
}
files = {'file': open(ipa_path, 'rb')}
r = requests.post(url, data=data, files=files)
if r.status_code == 200:
responseJSON = r.json()
sendMessageForAdhoc(responseJSON['data']['appVersion'], responseJSON['data']['appBuildVersion'])
print('上传成功!')
else:
print('上传失败!!!')
print(r.content)
cleanResult()
else:
print('目标ipa不存在')
def uploadToAppStore():
ipa_path = os.path.join(export_path, 'xxx.ipa')
if os.path.exists(ipa_path):
# validate
print('开始验证App...\n')
command = 'xcrun altool --validate-app -f %s --apiKey %s --apiIssuer %s -t ios --output-format xml' \
% (ipa_path, APPSTORE_API_KEY, ISSUER_ID)
ret = os.system(command)
if ret != 0:
print('App验证失败!!!')
cleanResult()
exit(0)
# upload
print('开始上传至itunes connect...')
command = 'xcrun altool --upload-app -f %s --apiKey %s --apiIssuer %s -t ios --output-format xml' \
% (ipa_path, APPSTORE_API_KEY, ISSUER_ID)
ret = os.system(command)
if ret == 0:
plist = retrievePlistFromIpa(ipa_path)
sendMessageForAppstore(plist['CFBundleShortVersionString'], plist['CFBundleVersion'])
print('上传成功!')
else:
print('上传失败!!!')
cleanResult()
else:
print('目标ipa不存在')
def retrievePlistFromIpa(ipa_path):
ipa_file = zipfile.ZipFile(ipa_path)
plist_path = findPlistPath(ipa_file)
plist_data = ipa_file.read(plist_path)
plist_root = plistlib.loads(plist_data)
return plist_root
def findPlistPath(zip_file):
name_list = zip_file.namelist()
pattern = re.compile(r'Payload/[^/]*.app/Info.plist')
for path in name_list:
m = pattern.match(path)
if m is not None:
return m.group()
def sendMessageForAdhoc(version, build):
# 读取最近的变更日志
logs = readRecentCommitLogs(lastBuildCreated)
msg = 'iOS内测版又双叒叕更新啦!!!\n版本: %s (build %s)\n下载地址: https://www.pgyer.com/xxx' % (version, build)
if len(logs) > 0:
msg += '\n\n更新日志:\n'
for log in logs:
msg += log + '\n'
# at群里的测试
data = {'msgtype': 'text', 'text': {'content': msg}, 'at': {"atMobiles": ['xxx']}}
requests.post(DING_ROBOT_URL1, json=data)
def sendMessageForAppstore(version, build):
msg = '最新发布版已上传至itunes connect!\n版本: %s (build %s)' % (version, build)
data = {'msgtype': 'text', 'text': {'content': msg}}
requests.post(DING_ROBOT_URL3, json=data)
def validLog(log):
if re.match('^feat|fix|chore|perf|refactor\s*[:, :].+', log):
return True
return False
def readRecentCommitLogs(limitTimestamp):
repo = git.Repo(os.path.join(args.workPath, '../'))
tmpCache = 'commitLog.temp'
logs = []
# 最多显示30条有效日志
countLimit = 30
if limitTimestamp <= 0:
# 当限制时间线无效时,最多显示15条有效日志
countLimit = 15
#
with open(tmpCache, 'w+') as f:
# 拉取日志并缓存
for item in repo.iter_commits():
if item.committed_date <= limitTimestamp:
break
if item.message.startswith('Merge pull request') or item.message.startswith('Merge branch'):
continue
f.write(item.message)
# 从缓存中过滤日志
f.seek(0)
while True:
logStr = f.readline()
if not logStr:
break
logStr = logStr.strip()
if validLog(logStr):
logs.append(logStr)
print(logStr)
if len(logs) == countLimit:
break
os.remove(tmpCache)
return logs
if __name__ == '__main__':
param_parser = argparse.ArgumentParser()
param_parser.add_argument('-build', type=str, help='编译选项', default='Debug', choices=['Debug', 'Release'])
param_parser.add_argument('-distribution', type=str, help='发布方式', default='ad-hoc', choices=['ad-hoc', 'app-store'])
param_parser.add_argument('workPath', type=str, help='工程路径')
args = param_parser.parse_args()
program_dir = os.path.abspath(os.path.dirname(sys.argv[0]))
export_path = os.path.join(program_dir, 'export')
xcarchive_path = os.path.join(export_path, 'xxx.xcarchive')
# 校验workPath
pbxprojPath = os.path.join(args.workPath, 'xxx.xcodeproj/project.pbxproj')
if not os.path.exists(pbxprojPath):
print('工程路径错误!!!\n')
print(pbxprojPath, '不存在!')
exit(0)
# 从蒲公英同步当前build号
print('同步蒲公英build版本...')
if not syncBuildVersionWithPGY():
exit(0)
# 设置预编译宏
print('设置预编译宏...')
rewriteXcodePrecompileMacro()
#
cleanResult()
archive()
if args.distribution == 'ad-hoc':
# 上传至蒲公英
uploadToPGY()
else:
# 上传至appstore
uploadToAppStore()