之前簡(jiǎn)單寫過一個(gè)iOS自動(dòng)打包腳本,在實(shí)際使用過程中逐步增加了一些新的feature:
為方便使用,將部分參數(shù)做成可選并提供默認(rèn)值;
打adhoc包自動(dòng)讀取最近的提交日志,最多30條;參考時(shí)間線是最后一次更新adhoc包的時(shí)間;
app內(nèi)環(huán)境切換開關(guān)不依賴Debug/Release,通過腳本控制自定義預(yù)編譯宏來(lái)把控;
打線上包時(shí),自動(dòng)從主工程同步version和build到Notification;
xcode11去掉了Loader.app, xcrun altool增加了驗(yàn)證參數(shù);
都是一些簡(jiǎn)單的feature,大概說(shuō)一下實(shí)現(xiàn)吧:
1、參數(shù)解析,由原來(lái)的sys.argv改成argparse,可設(shè)置可選參數(shù)、默認(rèn)參數(shù)、固定參數(shù)值選項(xiàng);
2、讀取git提交日志,使用第三方python庫(kù)git,這里順便做了個(gè)簡(jiǎn)單的過濾(受益于提交規(guī)范化);
3、腳本控制自定義預(yù)編譯宏來(lái)控制app內(nèi)環(huán)境切換,借助pbxproj庫(kù)來(lái)修改project.pbxproj中的配置(這里有個(gè)小問題,覆寫pbxproj文件xcode會(huì)識(shí)別到文件損壞,但是覆寫完成后xcode并不能自動(dòng)恢復(fù)正常,需要重啟xcode);
4、版本號(hào)同步,首先從蒲公英讀取最新的build號(hào),然后+1設(shè)置到當(dāng)前的build,借用plistlib修改plist文件;
5、xcode11沒有了Loader.app,上傳和驗(yàn)證app直接用xcrun altool,另外增加了apiKey和apiIssuer參數(shù)(還有個(gè)額外的p8文件需要放置到當(dāng)前用戶目錄下"~/.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項(xiàng)目群機(jī)器人
DING_ROBOT_URL1 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# 測(cè)試群機(jī)器人
DING_ROBOT_URL2 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# iOS提審交流群機(jī)器人
DING_ROBOT_URL3 = 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
# 蒲公英設(shè)置
USER_KEY = "xxx"
API_KEY = "xxx"
APP_KEY = "xxx"
# appstore
APPSTORE_API_KEY = 'xxx'
ISSUER_ID = 'xxx'
# 上一次上傳app時(shí)間戳
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號(hào)
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時(shí)間戳
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的版本號(hào)
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內(nèi)切換環(huán)境 (MT_ENV_SWITCHABLE=1);
2、app-store包,不支持APP內(nèi)切換環(huán)境 (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:
# 內(nèi)測(cè)包
buildSettings = configuration.buildSettings
elif args.distribution == 'app-store' and configuration.name == 'Release':
# 線上包
buildSettings = configuration.buildSettings
# 覆寫預(yù)編譯宏
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:
# 數(shù)組
for setting in buildSettings['GCC_PREPROCESSOR_DEFINITIONS']:
if setting.startswith('MT_ENV_SWITCHABLE'):
# 刪除舊設(shè)置
buildSettings['GCC_PREPROCESSOR_DEFINITIONS'].remove(setting)
break
# 添加新設(shè)置
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('目標(biāo)ipa不存在')
def uploadToAppStore():
ipa_path = os.path.join(export_path, 'xxx.ipa')
if os.path.exists(ipa_path):
# validate
print('開始驗(yàn)證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驗(yàn)證失敗!!!')
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('目標(biāo)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èi)測(cè)版又雙叒叕更新啦!!!\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群里的測(cè)試
data = {'msgtype': 'text', 'text': {'content': msg}, 'at': {"atMobiles": ['xxx']}}
requests.post(DING_ROBOT_URL1, json=data)
def sendMessageForAppstore(version, build):
msg = '最新發(fā)布版已上傳至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:
# 當(dāng)限制時(shí)間線無(wú)效時(shí),最多顯示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='編譯選項(xiàng)', default='Debug', choices=['Debug', 'Release'])
param_parser.add_argument('-distribution', type=str, help='發(fā)布方式', 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')
# 校驗(yàn)workPath
pbxprojPath = os.path.join(args.workPath, 'xxx.xcodeproj/project.pbxproj')
if not os.path.exists(pbxprojPath):
print('工程路徑錯(cuò)誤?。?!\n')
print(pbxprojPath, '不存在!')
exit(0)
# 從蒲公英同步當(dāng)前build號(hào)
print('同步蒲公英build版本...')
if not syncBuildVersionWithPGY():
exit(0)
# 設(shè)置預(yù)編譯宏
print('設(shè)置預(yù)編譯宏...')
rewriteXcodePrecompileMacro()
#
cleanResult()
archive()
if args.distribution == 'ad-hoc':
# 上傳至蒲公英
uploadToPGY()
else:
# 上傳至appstore
uploadToAppStore()