├── .gitignore ├── LICENSE ├── README.md ├── build_scripts ├── build.py ├── ios_builder.py ├── pgyer_uploader.py └── requirements.txt ├── images ├── Jenkins_Job_Build_View.jpg └── Jenkins_Job_Overview.jpg └── jobs └── job_template └── config.xml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Mac OS 92 | .DS_Store 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Leo Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | **Jenkins job overview:** 4 | 5 | ![](images/Jenkins_Job_Overview.jpg) 6 | 7 | **Jenkins job build page view:** 8 | 9 | ![](images/Jenkins_Job_Build_View.jpg) 10 | 11 | 12 | ## 开箱即用 13 | 14 | **1,添加构建脚本;** 15 | 16 | - 在构建脚本中配置`PROVISIONING_PROFILE`和`pgyer/fir.im`账号; 17 | - 将`build_scripts`文件夹及其文件拷贝至目标构建代码库的根目录下; 18 | - 将`build_scripts`提交到项目的仓库中。 19 | 20 | 除了与Jenkins实现持续集成,构建脚本还可单独使用,使用方式如下: 21 | 22 | ```bash 23 | $ python ${WORKSPACE}/build_scripts/build.py \ 24 | --scheme ${SCHEME} \ 25 | --workspace ${WORKSPACE}/Store.xcworkspace \ 26 | --sdk ${SDK} 27 | --configuration ${CONFIGURATION} \ 28 | --output_folder ${WORKSPACE}/${OUTPUT_FOLDER} 29 | ``` 30 | 31 | 需要特别说明的是,若要构建生成可在移动设备中运行的`.ipa`文件,则要将`${SDK}`设置为`iphoneos`;若要构建生成可在模拟器中运行的`.app`文件,则要将`${SDK}`设置为`iphonesimulator`。 32 | 33 | **2、运行jenkins,安装必备插件;** 34 | 35 | ```bash 36 | $ nohup java -jar jenkins_located_path/jenkins.war & 37 | ``` 38 | 39 | **3、创建Jenkins Job;** 40 | 41 | - 在Jenkins中创建一个`Freestyle project`类型的Job,先不进行任何配置; 42 | - 然后将`config.xml`文件拷贝到`~/.jenkins/jobs/YourProject/`中覆盖原有配置文件,重启Jenkins; 43 | - 完成配置文件替换和重启后,刚创建好的Job就已完成了大部分配置; 44 | - 在`Job Configure`中根据项目实际情况调整配置,其中`Git Repositories`是必须修改的,其它配置项可选择性地进行调整。 45 | 46 | **4、done!** 47 | 48 | ## Read More ... 49 | 50 | - [《使用Jenkins搭建iOS/Android持续集成打包平台》](http://debugtalk.com/post/iOS-Android-Packing-with-Jenkins) 51 | - [《关于持续集成打包平台的Jenkins配置和构建脚本实现细节》](http://debugtalk.com/post/iOS-Android-Packing-with-Jenkins-details) 52 | - 微信公众号:[DebugTalk](http://debugtalk.com/images/wechat_qrcode.png) 53 | -------------------------------------------------------------------------------- /build_scripts/build.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | from __future__ import print_function 3 | import os 4 | import sys 5 | import argparse 6 | from ios_builder import iOSBuilder 7 | from pgyer_uploader import uploadIpaToPgyer 8 | from pgyer_uploader import saveQRCodeImage 9 | 10 | def parse_args(): 11 | parser = argparse.ArgumentParser(description='iOS app build script.') 12 | 13 | parser.add_argument('--build_method', dest="build_method", default='xcodebuild', 14 | help="Specify build method, xctool or xcodebuild.") 15 | parser.add_argument('--workspace', dest="workspace", default=None, 16 | help="Build the workspace name.xcworkspace") 17 | parser.add_argument("--scheme", dest="scheme", default=None, 18 | help="Build the scheme specified by schemename. \ 19 | Required if building a workspace") 20 | parser.add_argument("--project", dest="project", default=None, 21 | help="Build the project name.xcodeproj") 22 | parser.add_argument("--target", dest="target", default=None, 23 | help="Build the target specified by targetname. \ 24 | Required if building a project") 25 | parser.add_argument("--sdk", dest="sdk", default='iphoneos', 26 | help="Specify build SDK, iphoneos or iphonesimulator, \ 27 | default is iphonesimulator") 28 | parser.add_argument("--build_version", dest="build_version", default='1.0.0.1', 29 | help="Specify build version number") 30 | parser.add_argument("--provisioning_profile", dest="provisioning_profile", default=None, 31 | help="specify provisioning profile") 32 | parser.add_argument("--plist_path", dest="plist_path", default=None, 33 | help="Specify build plist path") 34 | parser.add_argument("--configuration", dest="configuration", default='Release', 35 | help="Specify build configuration, Release or Debug, \ 36 | default value is Release") 37 | parser.add_argument("--output_folder", dest="output_folder", default='BuildProducts', 38 | help="specify output_folder folder name") 39 | parser.add_argument("--update_description", dest="update_description", 40 | help="specify update description") 41 | 42 | args = parser.parse_args() 43 | print("args: {}".format(args)) 44 | return args 45 | 46 | def main(): 47 | args = parse_args() 48 | 49 | if args.plist_path is None: 50 | plist_file_name = '%s-Info.plist' % args.scheme 51 | args.plist_path = os.path.abspath( 52 | os.path.join(os.path.dirname(__file__), 53 | os.path.pardir, 54 | plist_file_name 55 | ) 56 | ) 57 | 58 | ios_builder = iOSBuilder(args) 59 | 60 | if args.sdk.startswith("iphonesimulator"): 61 | app_zip_path = ios_builder.build_app() 62 | print("app_zip_path: {}".format(app_zip_path)) 63 | sys.exit(0) 64 | 65 | ipa_path = ios_builder.build_ipa() 66 | app_download_page_url = uploadIpaToPgyer(ipa_path, args.update_description) 67 | try: 68 | output_folder = os.path.dirname(ipa_path) 69 | saveQRCodeImage(app_download_page_url, output_folder) 70 | except Exception as e: 71 | print("Exception occured: {}".format(str(e))) 72 | 73 | 74 | if __name__ == '__main__': 75 | main() 76 | -------------------------------------------------------------------------------- /build_scripts/ios_builder.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | from __future__ import print_function 3 | import os 4 | import subprocess 5 | import shutil 6 | import plistlib 7 | 8 | class iOSBuilder(object): 9 | """docstring for iOSBuilder""" 10 | def __init__(self, options): 11 | self._build_method = options.build_method 12 | self._sdk = options.sdk 13 | self._configuration = options.configuration 14 | self._provisioning_profile = options.provisioning_profile 15 | self._output_folder = options.output_folder 16 | self._plist_path = options.plist_path 17 | self._build_version = options.build_version 18 | self._archive_path = None 19 | self._build_params = self._get_build_params( 20 | options.project, options.target, options.workspace, options.scheme) 21 | self._prepare() 22 | 23 | def _prepare(self): 24 | """ get prepared for building. 25 | """ 26 | self._change_build_version() 27 | 28 | print("Output folder for ipa ============== {}".format(self._output_folder)) 29 | try: 30 | shutil.rmtree(self._output_folder) 31 | except OSError: 32 | pass 33 | finally: 34 | os.makedirs(self._output_folder) 35 | 36 | self._udpate_pod_dependencies() 37 | self._build_clean() 38 | 39 | def _udpate_pod_dependencies(self): 40 | podfile = os.path.join(os.getcwd(), 'Podfile') 41 | podfile_lock = os.path.join(os.getcwd(), 'Podfile.lock') 42 | if os.path.isfile(podfile) or os.path.isfile(podfile_lock): 43 | print("Update pod dependencies =============") 44 | cmd_shell = 'pod repo update' 45 | self._run_shell(cmd_shell) 46 | print("Install pod dependencies =============") 47 | cmd_shell = 'pod install' 48 | self._run_shell(cmd_shell) 49 | 50 | def _change_build_version(self): 51 | """ set CFBundleVersion and CFBundleShortVersionString. 52 | """ 53 | build_version_list = self._build_version.split('.') 54 | cf_bundle_short_version_string = '.'.join(build_version_list[:3]) 55 | with open(self._plist_path, 'rb') as fp: 56 | plist_content = plistlib.load(fp) 57 | plist_content['CFBundleShortVersionString'] = cf_bundle_short_version_string 58 | plist_content['CFBundleVersion'] = self._build_version 59 | with open(self._plist_path, 'wb') as fp: 60 | plistlib.dump(plist_content, fp) 61 | 62 | def _run_shell(self, cmd_shell): 63 | process = subprocess.Popen(cmd_shell, shell=True) 64 | process.wait() 65 | return_code = process.returncode 66 | assert return_code == 0 67 | 68 | def _get_build_params(self, project, target, workspace, scheme): 69 | if project is None and workspace is None: 70 | raise "project and workspace should not both be None." 71 | elif project is not None: 72 | build_params = '-project %s -scheme %s' % (project, scheme) 73 | # specify package name 74 | self._package_name = "{0}_{1}".format(scheme, self._configuration) 75 | self._app_name = scheme 76 | elif workspace is not None: 77 | build_params = '-workspace %s -scheme %s' % (workspace, scheme) 78 | # specify package name 79 | self._package_name = "{0}_{1}".format(scheme, self._configuration) 80 | self._app_name = scheme 81 | 82 | build_params += ' -sdk %s -configuration %s' % (self._sdk, self._configuration) 83 | return build_params 84 | 85 | def _build_clean(self): 86 | cmd_shell = '{0} {1} clean'.format(self._build_method, self._build_params) 87 | print("build clean ============= {}".format(cmd_shell)) 88 | self._run_shell(cmd_shell) 89 | 90 | def _build_archive(self): 91 | """ specify output xcarchive location 92 | """ 93 | self._archive_path = os.path.join( 94 | self._output_folder, "{}.xcarchive".format(self._package_name)) 95 | cmd_shell = '{0} {1} archive -archivePath {2}'.format( 96 | self._build_method, self._build_params, self._archive_path) 97 | print("build archive ============= {}".format(cmd_shell)) 98 | self._run_shell(cmd_shell) 99 | 100 | def _export_ipa(self): 101 | """ export archive to ipa file, return ipa location 102 | """ 103 | if self._provisioning_profile is None: 104 | raise "provisioning profile should not be None!" 105 | ipa_path = os.path.join(self._output_folder, "{}.ipa".format(self._package_name)) 106 | cmd_shell = 'xcodebuild -exportArchive -archivePath {}'.format(self._archive_path) 107 | cmd_shell += ' -exportPath {}'.format(ipa_path) 108 | cmd_shell += ' -exportFormat ipa' 109 | cmd_shell += ' -exportProvisioningProfile "{}"'.format(self._provisioning_profile) 110 | print("build archive ============= {}".format(cmd_shell)) 111 | self._run_shell(cmd_shell) 112 | return ipa_path 113 | 114 | def build_ipa(self): 115 | """ build ipa file for iOS device 116 | """ 117 | self._build_archive() 118 | ipa_path = self._export_ipa() 119 | return ipa_path 120 | 121 | def _build_archive_for_simulator(self): 122 | cmd_shell = '{0} {1} -derivedDataPath {2}'.format( 123 | self._build_method, self._build_params, self._output_folder) 124 | print("build archive for simulator ============= {}".format(cmd_shell)) 125 | self._run_shell(cmd_shell) 126 | 127 | def _archive_app_to_zip(self): 128 | app_path = os.path.join( 129 | self._output_folder, 130 | "Build", 131 | "Products", 132 | "{0}-iphonesimulator".format(self._configuration), 133 | "{0}.app".format(self._app_name) 134 | ) 135 | app_zip_filename = os.path.basename(app_path) + ".zip" 136 | app_zip_path = os.path.join(self._output_folder, app_zip_filename) 137 | cmd_shell = "zip -r {0} {1}".format(app_zip_path, app_path) 138 | self._run_shell(cmd_shell) 139 | print("app_zip_path: %s" % app_zip_path) 140 | return app_zip_path 141 | 142 | def build_app(self): 143 | """ build app file for iOS simulator 144 | """ 145 | self._build_archive_for_simulator() 146 | app_zip_path = self._archive_app_to_zip() 147 | return app_zip_path 148 | -------------------------------------------------------------------------------- /build_scripts/pgyer_uploader.py: -------------------------------------------------------------------------------- 1 | #coding=utf-8 2 | from __future__ import print_function 3 | import os 4 | import requests 5 | import time 6 | import re 7 | from datetime import datetime 8 | 9 | # configuration for pgyer 10 | USER_KEY = "9667e5933d************540b83ed7c" 11 | API_KEY = "d2e517468e7************e24310b65" 12 | PGYER_UPLOAD_URL = "https://www.pgyer.com/apiv1/app/upload" 13 | 14 | 15 | def parseUploadResult(jsonResult): 16 | print('post response: %s' % jsonResult) 17 | resultCode = jsonResult['code'] 18 | 19 | if resultCode != 0: 20 | print("Upload Fail!") 21 | raise Exception("Reason: %s" % jsonResult['message']) 22 | 23 | print("Upload Success") 24 | appKey = jsonResult['data']['appKey'] 25 | app_download_page_url = "https://www.pgyer.com/%s" % appKey 26 | print("appDownloadPage: %s" % app_download_page_url) 27 | return app_download_page_url 28 | 29 | def uploadIpaToPgyer(ipaPath, updateDescription): 30 | print("Begin to upload ipa to Pgyer: %s" % ipaPath) 31 | headers = {'enctype': 'multipart/form-data'} 32 | payload = { 33 | 'uKey': USER_KEY, 34 | '_api_key': API_KEY, 35 | 'publishRange': '2', # 直接发布 36 | 'isPublishToPublic': '2', # 不发布到广场 37 | 'updateDescription': updateDescription # 版本更新描述 38 | } 39 | 40 | try_times = 0 41 | while try_times < 5: 42 | try: 43 | print("uploading ... %s" % datetime.now()) 44 | ipa_file = {'file': open(ipaPath, 'rb')} 45 | resp = requests.post(PGYER_UPLOAD_URL, headers=headers, files=ipa_file, data=payload) 46 | assert resp.status_code == requests.codes.ok 47 | result = resp.json() 48 | app_download_page_url = parseUploadResult(result) 49 | return app_download_page_url 50 | except requests.exceptions.ConnectionError: 51 | print("requests.exceptions.ConnectionError occured!") 52 | time.sleep(60) 53 | print("try again ... %s" % datetime.now()) 54 | try_times += 1 55 | except Exception as e: 56 | print("Exception occured: %s" % str(e)) 57 | time.sleep(60) 58 | print("try again ... %s" % datetime.now()) 59 | try_times += 1 60 | 61 | if try_times >= 5: 62 | raise Exception("Failed to upload ipa to Pgyer, retried 5 times.") 63 | 64 | def parseQRCodeImageUrl(app_download_page_url): 65 | try_times = 0 66 | while try_times < 3: 67 | try: 68 | response = requests.get(app_download_page_url) 69 | regex = ' 2 | 3 | 4 | 5 | false 6 | 7 | 8 | 9 | 10 | SCHEME 11 | scheme configuration of this project 12 | StoreCI 13 | 14 | 15 | CONFIGURATION 16 | configuration of packing, Release/Debug 17 | Release 18 | 19 | 20 | OUTPUT_FOLDER 21 | output folder for build artifacts, it is located in workspace/project root dir. 22 | build_outputs 23 | 24 | 25 | BRANCH 26 | git repository branch 27 | NPED_2.6 28 | 29 | 30 | 31 | 32 | 33 | 2 34 | 35 | 36 | https://github.com/debugtalk/XXXX 37 | 483f9c64-3560-4977-a0a7-71509f718fd4 38 | 39 | 40 | 41 | 42 | refs/heads/${BRANCH} 43 | 44 | 45 | false 46 | 47 | 48 | 49 | false 50 | false 51 | 52 | 120 53 | 0 54 | 55 | 56 | 57 | true 58 | false 59 | false 60 | false 61 | 62 | 63 | H H/3 * * * 64 | false 65 | 66 | 67 | false 68 | 69 | 70 | python ${WORKSPACE}/Build_scripts/build.py \ 71 | --scheme ${SCHEME} \ 72 | --workspace ${WORKSPACE}/Store.xcworkspace \ 73 | --configuration ${CONFIGURATION} \ 74 | --output ${WORKSPACE}/${OUTPUT_FOLDER} 75 | 76 | 77 | appDownloadPage: (.*)$ 78 | <img src='${BUILD_URL}artifact/build_outputs/QRCode.png'>\n<a href='\1'>Install Online</a> 79 | 80 | 81 | 82 | 83 | ${OUTPUT_FOLDER}/*.ipa,${OUTPUT_FOLDER}/QRCode.png,${OUTPUT_FOLDER}/*.xcarchive/Info.plist 84 | true 85 | true 86 | false 87 | true 88 | true 89 | 90 | 91 | 92 | 93 | 94 | true 95 | true 96 | 97 | 98 | 99 | --------------------------------------------------------------------------------