iOS自动打包(升级版)

之前简单写过一个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()


你可能感兴趣的:(iOS自动打包(升级版))