├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── __tests__ └── cli-integration.test.js ├── bin └── react-native-ci ├── docs ├── ReactFinland-RN-CICD.pdf ├── commands.md └── plugins.md ├── package-lock.json ├── package.json ├── readme.md └── src ├── cli.js ├── commands ├── init.js ├── react-native-ci.js └── test.js ├── extensions ├── android-extension.js ├── circle-extension.js ├── ios-extension.js └── npm-extension.js ├── flows ├── android.js ├── ios.js └── shared.js ├── ios.rb └── templates ├── .env.ejs ├── circleci └── config.yml ├── fastlane ├── Gemfile ├── android │ ├── Appfile │ ├── Fastfile │ └── Pluginfile └── ios │ ├── Appfile │ ├── Fastfile │ ├── Matchfile │ └── Pluginfile ├── keystore.properties └── model.js.ejs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | 6 | defaults: &defaults 7 | docker: 8 | # Choose the version of Node you want here 9 | - image: circleci/node:10.11 10 | working_directory: ~/repo 11 | 12 | version: 2 13 | jobs: 14 | setup: 15 | <<: *defaults 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | name: Restore node modules 20 | keys: 21 | - v1-dependencies-{{ checksum "package.json" }} 22 | # fallback to using the latest cache if no exact match is found 23 | - v1-dependencies- 24 | - run: 25 | name: Install dependencies 26 | command: yarn install 27 | - save_cache: 28 | name: Save node modules 29 | paths: 30 | - node_modules 31 | key: v1-dependencies-{{ checksum "package.json" }} 32 | 33 | 34 | tests: 35 | <<: *defaults 36 | steps: 37 | - checkout 38 | - restore_cache: 39 | name: Restore node modules 40 | keys: 41 | - v1-dependencies-{{ checksum "package.json" }} 42 | # fallback to using the latest cache if no exact match is found 43 | - v1-dependencies- 44 | - run: 45 | name: Change Permissions 46 | command: sudo chown -R $(whoami) /usr/local 47 | - run: 48 | name: Run tests 49 | command: yarn ci:test # this command will be added to/found in your package.json scripts 50 | 51 | publish: 52 | <<: *defaults 53 | steps: 54 | - checkout 55 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 56 | - restore_cache: 57 | name: Restore node modules 58 | keys: 59 | - v1-dependencies-{{ checksum "package.json" }} 60 | # fallback to using the latest cache if no exact match is found 61 | - v1-dependencies- 62 | # Run semantic-release after all the above is set. 63 | - run: 64 | name: Publish to NPM 65 | command: yarn ci:publish 66 | 67 | workflows: 68 | version: 2 69 | test_and_release: 70 | jobs: 71 | - setup 72 | - tests: 73 | requires: 74 | - setup 75 | - publish: 76 | requires: 77 | - tests 78 | filters: 79 | branches: 80 | only: master 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | coverage 5 | .nyc_output 6 | yarn.lock 7 | package.lock 8 | 9 | build/ 10 | .idea/ 11 | .gradle 12 | local.properties 13 | *.iml 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 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 | -------------------------------------------------------------------------------- /__tests__/cli-integration.test.js: -------------------------------------------------------------------------------- 1 | const { system, filesystem } = require('gluegun') 2 | const { resolve } = require('path') 3 | 4 | const src = resolve(__dirname, '..') 5 | 6 | const cli = async cmd => 7 | system.run('node ' + resolve(src, 'bin', 'react-native-ci') + ` ${cmd}`) 8 | 9 | describe('Options', () => { 10 | test('test', () => { 11 | expect(1).toBe(1) 12 | }) 13 | // test('outputs version', async () => { 14 | // const output = await cli('--version') 15 | // expect(output).toContain('0.1.3') 16 | // }) 17 | 18 | // test('outputs help', async () => { 19 | // const output = await cli('--help') 20 | // expect(output).toContain('0.1.3') 21 | // }) 22 | }) 23 | -------------------------------------------------------------------------------- /bin/react-native-ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 4 | 5 | // run the CLI with the current process arguments 6 | require('../src/cli').run(process.argv) 7 | -------------------------------------------------------------------------------- /docs/ReactFinland-RN-CICD.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solinor/react-native-ci/c7d468e19f3ff2606d2734ac59cc8dc7cc895feb/docs/ReactFinland-RN-CICD.pdf -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Command Reference for react-native-ci 2 | 3 | TODO: Add your command reference here 4 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugin guide for react-native-ci 2 | 3 | Plugins allow you to add features to react-native-ci, such as commands and 4 | extensions to the `toolbox` object that provides the majority of the functionality 5 | used by react-native-ci. 6 | 7 | Creating a react-native-ci plugin is easy. Just create a repo with two folders: 8 | 9 | ``` 10 | commands/ 11 | extensions/ 12 | ``` 13 | 14 | A command is a file that looks something like this: 15 | 16 | ```js 17 | // commands/foo.js 18 | 19 | module.exports = { 20 | run: (toolbox) => { 21 | const { print, filesystem } = toolbox 22 | 23 | const desktopDirectories = filesystem.subdirectories(`~/Desktop`) 24 | print.info(desktopDirectories) 25 | } 26 | } 27 | ``` 28 | 29 | An extension lets you add additional features to the `toolbox`. 30 | 31 | ```js 32 | // extensions/bar-extension.js 33 | 34 | module.exports = (toolbox) => { 35 | const { print } = toolbox 36 | 37 | toolbox.bar = () => { print.info('Bar!') } 38 | } 39 | ``` 40 | 41 | This is then accessible in your plugin's commands as `toolbox.bar`. 42 | 43 | # Loading a plugin 44 | 45 | To load a particular plugin (which has to start with `react-native-ci-*`), 46 | install it to your project using `npm install --save-dev react-native-ci-PLUGINNAME`, 47 | and react-native-ci will pick it up automatically. 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-ci", 3 | "version": "0.2.1", 4 | "description": "react-native-ci CLI", 5 | "bin": { 6 | "react-native-ci": "bin/react-native-ci" 7 | }, 8 | "preferGlobal": true, 9 | "scripts": { 10 | "format": "prettier --write **/*.{js,json} && standard --fix", 11 | "lint": "standard", 12 | "test": "jest", 13 | "watch": "jest --watch", 14 | "snapupdate": "jest --updateSnapshot", 15 | "coverage": "jest --coverage", 16 | "ci:test": "jest", 17 | "ci:publish": "yarn semantic-release", 18 | "semantic-release": "semantic-release" 19 | }, 20 | "repository": "solinor/react-native-ci", 21 | "author": { 22 | "name": "Juha Linnanen", 23 | "email": "juha.linnanen@gofore.com", 24 | "url": "https://github.com/solinor/react-native-ci" 25 | }, 26 | "files": [ 27 | "LICENSE", 28 | "readme.md", 29 | "docs", 30 | "bin", 31 | "src" 32 | ], 33 | "license": "MIT", 34 | "dependencies": { 35 | "gluegun": "^3.2.0", 36 | "install-packages": "^0.2.5" 37 | }, 38 | "devDependencies": { 39 | "@semantic-release/git": "^7.0.8", 40 | "jest": "^23.6.0", 41 | "prettier": "^1.12.1", 42 | "semantic-release": "^15.13.3", 43 | "standard": "^12.0.1" 44 | }, 45 | "jest": { 46 | "testEnvironment": "node", 47 | "testMatch": [ 48 | "**/__tests__/**/*.[jt]s?(x)" 49 | ] 50 | }, 51 | "standard": { 52 | "env": [ 53 | "jest" 54 | ] 55 | }, 56 | "prettier": { 57 | "semi": false, 58 | "singleQuote": true 59 | }, 60 | "release": { 61 | "plugins": [ 62 | "@semantic-release/commit-analyzer", 63 | "@semantic-release/release-notes-generator", 64 | "@semantic-release/npm", 65 | "@semantic-release/github", 66 | [ 67 | "@semantic-release/git", 68 | { 69 | "assets": "package.json", 70 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 71 | } 72 | ] 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # react-native-ci CLI 2 | 3 | A CLI for integrating CI/CD pipeline to React Native project. 4 | It will generate, modify necessary files and install packages 5 | to achieve this. 6 | 7 | Historically, setting up CI/CD for React Native has been hard and 8 | by automating the most of it through react-native-ci it can become 9 | easy! 10 | 11 | Current status: *Experimental* 12 | 13 | Test first with cleanly init React Native project. 14 | Make sure you have commited your code before to avoid any data loss. 15 | 16 | ## Prerequisites 17 | 18 | - Currently runs on MacOS 19 | 20 | Opinionated stack: 21 | 22 | - Github 23 | - CircleCI 24 | - Dev, Staging, Production build flavors 25 | 26 | Possibility to extend supporting other choices, please contribute! 27 | 28 | ## Install 29 | 30 | ``` 31 | npm install -g react-native-ci 32 | ``` 33 | 34 | ## Usage 35 | 36 | Run command in your project root: 37 | 38 | ``` 39 | react-native-ci init 40 | ``` 41 | 42 | Provide the required information when prompted. Optionally you can give input as command line parameters 43 | or define config file. This is useful when you test command multiple times and don't want 44 | to have to input all the values manually each time. Especially useful when developing and testing 45 | react-native-ci itself! 46 | 47 | ### Command-line options: 48 | 49 | `--ci` - will initialize CI integration 50 | 51 | `--android` will initialize Android integration 52 | 53 | `--ios` will initialize iOS integartion. 54 | 55 | If none are provided, defaults to running all the ingerations. 56 | 57 | You can also provide all the values that are configurable in the config file as command line arguments. 58 | So `--appleDevAccount dev@company.com` to set your Apple dev account for example. 59 | 60 | ### Example config file: (react-native-ci.config.js) 61 | 62 | ``` 63 | module.exports = { 64 | defaults: { 65 | githubOrg: "org-name", 66 | repo: "github-repo", 67 | circleApi: "circleApiToken", 68 | googleJsonPath: "path/to/google/json", 69 | appleDevAccount: "dev@company.com", 70 | iTunesTeamId: "itunes-team-id", 71 | appConnectTeamId: "app-connect-team-id", 72 | certRepoUrl: "git@github.com:company/project-ios-certs.git", 73 | appId: "com.company.greatapp", 74 | matchPassword: "password", 75 | } 76 | } 77 | 78 | ``` 79 | 80 | ## What does it actually do? 81 | 82 | There are 8 different steps that all are automated through the tool. 83 | 84 | 1) Integrate CI/CD server to version control 85 | 2) Configure CI/CD server pipelines 86 | 3) Add build flavors to app 87 | 4) Share secrets 88 | 5) Setup app signing & certificates 89 | 6) Handle updating version numbering 90 | 7) Icon badges for dev and staging builds 91 | 8) Deployment to app stores 92 | 93 | Here are [slides from React Finland talk](docs/ReactFinland-RN-CICD.pdf) going through the steps. 94 | 95 | ## Contribute 96 | 97 | We welcome contributions to make react-native-ci even better. 98 | If you are interested in the library, come join us 99 | at #react-native-ci on [Infinite Red's Community Slack](http://community.infinite.red/). 100 | 101 | ## Thanks 102 | 103 | - [Gofore](https://www.gofore.com) Mobile team 104 | 105 | - [Infinite Red](https://infinite.red/) for [Gluegun](https://infinitered.github.io/gluegun/#/) 106 | 107 | 108 | ## License 109 | 110 | MIT - see LICENSE 111 | 112 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | const { build } = require('gluegun') 2 | 3 | /** 4 | * Create the cli and kick it off 5 | */ 6 | async function run (argv) { 7 | // create a CLI runtime 8 | const cli = build() 9 | .brand('react-native-ci') 10 | .src(__dirname) 11 | .plugins('./node_modules', { matching: 'react-native-ci-*', hidden: true }) 12 | .help() // provides default for help, h, --help, -h 13 | .version() // provides default for version, v, --version, -v 14 | .create() 15 | 16 | // and run it 17 | const toolbox = await cli.run(argv) 18 | 19 | // send it back (for testing, mostly) 20 | return toolbox 21 | } 22 | 23 | module.exports = { run } 24 | -------------------------------------------------------------------------------- /src/commands/init.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'init', 3 | alias: ['i'], 4 | run: async toolbox => { 5 | 6 | const { runShared } = require('../flows/shared') 7 | const { runAndroid } = require('../flows/android') 8 | const { runIOS } = require('../flows/ios') 9 | 10 | const { print: { info } } = toolbox 11 | let { ci, android, ios } = toolbox.parameters.options 12 | if (!ci && !android && !ios) { 13 | ci = true 14 | android = true 15 | ios = true 16 | } 17 | const { defaults } = toolbox.config.loadConfig('react-native-ci', process.cwd()) 18 | const defaultConfig = { 19 | ...defaults, 20 | ...toolbox.parameters.options 21 | } 22 | let sharedConfig = {} 23 | if (ci) { 24 | sharedConfig = await runShared(toolbox, defaultConfig) 25 | } 26 | if (android) { 27 | await runAndroid(toolbox, { ...defaultConfig, ...sharedConfig }) 28 | } 29 | if (ios) { 30 | await runIOS(toolbox, { ...defaultConfig, ...sharedConfig }) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/commands/react-native-ci.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'react-native-ci', 3 | run: async toolbox => { 4 | const { print } = toolbox 5 | print.info('react-native-ci CLI, please run: react-native-ci init') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/commands/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'test', 3 | run: async toolbox => { 4 | const { print } = toolbox 5 | print.info('react-native-ci CLI, please run: react-native-ci init') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/extensions/android-extension.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // add your CLI-specific functionality here, which will then be accessible 4 | // to your commands 5 | module.exports = toolbox => { 6 | toolbox.android = { 7 | getApplicationId: () => { 8 | const { 9 | filesystem, 10 | print: { info } 11 | } = toolbox 12 | 13 | let applicationId = '' 14 | const gradle = filesystem.read('android/app/build.gradle') 15 | const lines = gradle.split('\n') 16 | lines.forEach((line) => { 17 | const isAppId = line.includes('applicationId "') 18 | if (isAppId) { 19 | const start = line.indexOf('"') + 1 20 | const end = line.lastIndexOf('"') 21 | applicationId = line.substring(start, end) 22 | info('found applicationId line: ' + applicationId) 23 | } 24 | }) 25 | return applicationId 26 | }, 27 | getConfigSection: (sectionName) => { 28 | const { 29 | filesystem, 30 | } = toolbox 31 | 32 | const gradle = filesystem.read('android/app/build.gradle') 33 | const lines = gradle.split('\n') 34 | let isInSection = false 35 | let brackets = 0 36 | let sectionStr = '' 37 | lines.forEach((line) => { 38 | const buildType = line.includes(sectionName) 39 | if (buildType) { 40 | isInSection = true 41 | } 42 | if (isInSection) { 43 | sectionStr += line + '\n' 44 | const openings = (line.match(new RegExp('{', 'g')) || []).length 45 | const closings = (line.match(new RegExp('}', 'g')) || []).length 46 | brackets = brackets + openings - closings 47 | } 48 | if (isInSection && brackets <= 0) { 49 | isInSection = false 50 | } 51 | }) 52 | return sectionStr 53 | }, 54 | isLibraryLinked: (libraryName) => { 55 | const { 56 | filesystem, 57 | print: { info } 58 | } = toolbox 59 | 60 | let isLinked = false 61 | const gradle = filesystem.read('android/settings.gradle') 62 | const lines = gradle.split('\n') 63 | lines.forEach((line) => { 64 | if (line.includes(libraryName)) { 65 | isLinked = true 66 | } 67 | }) 68 | return isLinked 69 | }, 70 | createKeystore: async (options) => { 71 | const { 72 | system, 73 | template, 74 | print 75 | } = toolbox 76 | 77 | const { name, storePassword, alias, aliasPassword } = options 78 | const storeFile = `${name}-key.keystore` 79 | 80 | print.info('Checking if CircleCI keystore already exists.') 81 | const checkKeyStore = `keytool -v -list -keystore android/app/${storeFile} -storepass ${storePassword} -alias ${alias}` 82 | let keystore 83 | try { 84 | keystore = await system.run(checkKeyStore) 85 | print.info(`Existing certificate found, using it.`) 86 | } catch (e) { 87 | print.info(`${print.checkmark} No existing certificate found.`) 88 | } 89 | 90 | let encodedKeystore 91 | if (!keystore) { 92 | print.info('Generate new cert.') 93 | const command = `keytool -genkey -v -keystore android/app/${storeFile} -storepass ${storePassword} -alias ${alias} -keypass ${aliasPassword} -dname 'cn=Unknown, ou=Unknown, o=Unknown, c=Unknown' -keyalg RSA -keysize 2048 -validity 10000` 94 | await system.run(command) 95 | const encodeCommand = `openssl base64 -A -in android/app/${storeFile}` 96 | encodedKeystore = await system.run(encodeCommand) 97 | } 98 | 99 | const keystoreProperties = await template.generate({ 100 | template: 'keystore.properties', 101 | target: `android/app/${name}-keystore.properties`, 102 | props: { ...options, storeFile, name: name.toUpperCase() } 103 | }) 104 | 105 | return { 106 | keystore: encodedKeystore, 107 | keystoreProperties: Buffer.from(keystoreProperties).toString('base64') 108 | } 109 | }, 110 | base64EncodeJson: jsonPath => { 111 | const { system } = toolbox 112 | const encodeCommand = `openssl base64 -A -in ${jsonPath}` 113 | return system.run(encodeCommand) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/extensions/circle-extension.js: -------------------------------------------------------------------------------- 1 | module.exports = toolbox => { 2 | toolbox.circle = { 3 | postEnvVariable: async ({ 4 | org, 5 | project, 6 | apiToken, 7 | key, 8 | value 9 | }) => { 10 | const { http } = toolbox 11 | const api = http.create({ 12 | baseURL: 'https://circleci.com/api/v1.1/' 13 | }) 14 | 15 | await api.post( 16 | `project/github/${org}/${project}/envvar?circle-token=${apiToken}`, 17 | {name: key, value: value }, 18 | {headers: {'Content-Type': 'application/json'}} 19 | ) 20 | }, 21 | followProject: async ({ org, project, apiToken}) => { 22 | const { http } = toolbox 23 | const api = http.create({ 24 | baseURL: 'https://circleci.com/api/v1.1/' 25 | }) 26 | 27 | const { status } = await api.post( 28 | `project/github/${org}/${project}/follow?circle-token=${apiToken}` 29 | ) 30 | return status 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/extensions/ios-extension.js: -------------------------------------------------------------------------------- 1 | // add your CLI-specific functionality here, which will then be accessible 2 | // to your commands 3 | module.exports = toolbox => { 4 | toolbox.ios = { 5 | getProjectFilePath: async () => { 6 | const { print, system, meta } = toolbox 7 | return new Promise((resolve, reject) => { 8 | system.run(`ruby ${meta.src}/ios.rb get_project_path`) 9 | resolve() 10 | }) 11 | }, 12 | addBuildConfigurations: async teamId => { 13 | const { print, system, meta } = toolbox 14 | return new Promise((resolve, reject) => { 15 | system.run( 16 | `ruby ${meta.src}/ios.rb make_new_build_configurations ${teamId}` 17 | ) 18 | resolve() 19 | }) 20 | }, 21 | addBundleIdSuffixes: async () => { 22 | const { print, system, meta } = toolbox 23 | return new Promise((resolve, reject) => { 24 | // FIXME: there is some race condition going on here. 25 | // without setTimeout here, it won't be applied :shrug: 26 | setTimeout(() => { 27 | system.run(`ruby ${meta.src}/ios.rb add_bundle_id_suffixes`) 28 | resolve() 29 | }, 200) 30 | }) 31 | }, 32 | addSchemes: async () => { 33 | const { print, system, meta } = toolbox 34 | return new Promise((resolve, reject) => { 35 | system.run(`ruby ${meta.src}/ios.rb add_schemes`) 36 | resolve() 37 | }) 38 | }, 39 | produceApp: async ({ 40 | appId, 41 | devId, 42 | developerPassword, 43 | appName, 44 | developerTeamId, 45 | iTunesTeamId 46 | }) => { 47 | const { print, system } = toolbox 48 | return new Promise((resolve, reject) => { 49 | const output = system.run( 50 | `fastlane produce -u ${devId} -a ${appId} --app_name "${appName}" --team_id "${developerTeamId}" --itc_team_id "${iTunesTeamId}"` 51 | ) 52 | resolve(output) 53 | }) 54 | }, 55 | matchSync: async ({ certType, password }) => { 56 | const { print, system } = toolbox 57 | return new Promise((resolve, reject) => { 58 | const output = system.run( 59 | `cd ios && (export MATCH_PASSWORD=${password}; bundle exec fastlane match ${certType})` 60 | ) 61 | resolve(output) 62 | }) 63 | }, 64 | getTeamIds: async accountInfo => { 65 | const { print, system, meta } = toolbox 66 | return new Promise(async (resolve, reject) => { 67 | try { 68 | const devTeams = await parseTeam('dev', accountInfo, meta.src) 69 | const itcTeams = await parseTeam('itc', accountInfo, meta.src) 70 | const teams = { 71 | itcTeams: itcTeams, 72 | devTeams: devTeams 73 | } 74 | resolve(teams) 75 | } catch (e) { 76 | reject(e) 77 | } 78 | }) 79 | }, 80 | getAppId: async () => { 81 | const { print, system, meta } = toolbox 82 | return new Promise((resolve, reject) => { 83 | const appId = system.run( 84 | `ruby ${meta.src}/ios.rb get_app_id` 85 | , { trim: true }) 86 | resolve(appId) 87 | }) 88 | } 89 | } 90 | } 91 | 92 | const parseTeam = async ( 93 | teamType, 94 | { developerAccount, developerPassword }, 95 | path 96 | ) => { 97 | return new Promise((resolve, reject) => { 98 | const spawn = require('child_process').spawn 99 | const child = spawn('ruby', [ 100 | `${path}/ios.rb`, 101 | 'get_team_id', 102 | teamType, 103 | developerAccount, 104 | developerPassword 105 | ]) 106 | child.stdout.on('data', data => { 107 | const dataStr = data.toString() 108 | const regexpTeamLine = new RegExp(/^\d+[)]/) 109 | const regexpTeamName = new RegExp(/"(.*?)"/) 110 | const lines = dataStr.split('\n') 111 | const teams = [] 112 | //if only one team found 113 | if (lines.length === 2 && lines[1].length === 0) { 114 | teams.push(lines[0]) 115 | resolve(teams) 116 | } 117 | //parse multiple teams 118 | const regexpTeamId = 119 | teamType === 'dev' ? new RegExp(/\)(.*?)"/) : new RegExp(/\((.*?)\)/) 120 | lines.forEach(line => { 121 | if (regexpTeamLine.test(line)) { 122 | const teamName = regexpTeamName.exec(line)[1] 123 | const teamId = regexpTeamId.exec(line)[1].trim() 124 | teams.push({ name: teamName, id: teamId }) 125 | } 126 | }) 127 | if (teams.length === 0) { 128 | reject('Not able to retrieve teams, check account credentials') 129 | } 130 | resolve(teams) 131 | }) 132 | child.stderr.on('data', data => { 133 | reject('Not able to retrieve teams, check account credentials') 134 | }) 135 | child.stdin.write('2\n') 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /src/extensions/npm-extension.js: -------------------------------------------------------------------------------- 1 | const install = require('install-packages') 2 | 3 | module.exports = toolbox => { 4 | toolbox.npm = { 5 | isPackageInstalled: function(packageName) { 6 | const { 7 | filesystem 8 | } = toolbox 9 | let isInstalled = false 10 | const gradle = filesystem.read('package.json') 11 | const lines = gradle.split('\n') 12 | lines.forEach((line) => { 13 | const isPackageInstalled = line.includes(packageName) 14 | if (isPackageInstalled) { 15 | isInstalled = true 16 | } 17 | }) 18 | return isInstalled 19 | }, 20 | installPackage: function async (packageName, saveDependency = true, saveDevDependency = false) { 21 | return install({ 22 | packages: [packageName], 23 | saveDev: saveDevDependency, 24 | packageManager: 'npm' 25 | }) 26 | }, 27 | addPackageScript: (script) => { 28 | const { 29 | filesystem, 30 | print 31 | } = toolbox 32 | try { 33 | const packaged = filesystem.read('package.json', 'json') 34 | if (!packaged.scripts) packaged.scripts = {} 35 | if (!script.force && packaged.scripts[script.key]) { 36 | print.info('Script already exists') 37 | } 38 | packaged.scripts[script.key] = script.value 39 | filesystem.write('package.json', packaged, { jsonIndent: 2 }) 40 | } catch (e) { 41 | print.error(e) 42 | } 43 | }, 44 | addConfigSection: (section) => { 45 | const { 46 | filesystem, 47 | print 48 | } = toolbox 49 | try { 50 | const packaged = filesystem.read('package.json', 'json') 51 | //if (!packaged.xcodeSchemes) packaged.xcodeSchemes = {} 52 | packaged[section.key] = section.value 53 | filesystem.write('package.json', packaged, { jsonIndent: 2 }) 54 | } catch (e) { 55 | print.error(e) 56 | } 57 | 58 | } 59 | } 60 | 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/flows/android.js: -------------------------------------------------------------------------------- 1 | module.exports.runAndroid = async (toolbox, config) => { 2 | await initAndroid(toolbox, config) 3 | await initFastlane(toolbox) 4 | await setupGradle(toolbox, config) 5 | } 6 | 7 | const askQuestion = async (prompt, options) => { 8 | const askGooglePlayJSONPath = { 9 | type: 'input', 10 | initial: options.googleJsonPath, 11 | skip: () => options.googleJsonPath, 12 | name: 'googleJsonPath', 13 | message: 'Path to Google Play Store JSON?' 14 | } 15 | const { googleJsonPath } = await prompt.ask(askGooglePlayJSONPath) 16 | return { 17 | googleJsonPath: options.googleJsonPath, 18 | googleJsonPath 19 | } 20 | } 21 | 22 | const initAndroid = async ({ android, http, prompt, print, circle }, options) => { 23 | 24 | const { githubOrg, repo, circleApi } = options 25 | const { googleJsonPath } = await askQuestion(prompt, options) 26 | 27 | const keystoreFiles = await android.createKeystore({ 28 | name: repo, 29 | storePassword: '123456', 30 | alias: 'circleci', 31 | aliasPassword: '123456' 32 | }) 33 | 34 | print.info('Store keystore to secret variables') 35 | circle.postEnvVariable({ 36 | githubOrg, 37 | repo, 38 | circleApi, 39 | key: 'KEYSTORE', 40 | value: keystoreFiles.encodedKeystore 41 | }) 42 | 43 | print.info('Store keystore properties to secret variables') 44 | circle.postEnvVariable({ 45 | githubOrg, 46 | repo, 47 | circleApi, 48 | key: 'KEYSTORE_PROPERTIES', 49 | value: keystoreFiles.keystoreProperties 50 | }) 51 | 52 | if (googleJsonPath !== '' && googleJsonPath !== undefined) { 53 | print.info('Store Google Play JSON to secret variables') 54 | const encodedPlayStoreJSON = android.base64EncodeJson(googleJsonPath) 55 | circle.postEnvVariable({ 56 | githubOrg, 57 | repo, 58 | circleApi, 59 | key: 'GOOGLE_PLAY_JSON', 60 | value: encodedPlayStoreJSON 61 | }) 62 | } 63 | } 64 | 65 | const initFastlane = async ({ system, android, template, filesystem, print }) => { 66 | const fastlanePath = system.which('fastlane') 67 | if (!fastlanePath) { 68 | print.info('No fastlane found, install...') 69 | await system.run('sudo gem install fastlane -NV') 70 | } 71 | 72 | const appId = android.getApplicationId() 73 | 74 | await template.generate({ 75 | template: 'fastlane/Gemfile', 76 | target: 'android/Gemfile', 77 | props: {} 78 | }) 79 | 80 | await template.generate({ 81 | template: 'fastlane/android/Fastfile', 82 | target: 'android/fastlane/Fastfile', 83 | props: { appId } 84 | }) 85 | 86 | await template.generate({ 87 | template: 'fastlane/android/Appfile', 88 | target: 'android/fastlane/Appfile', 89 | props: { appId } 90 | }) 91 | 92 | await template.generate({ 93 | template: 'fastlane/android/Pluginfile', 94 | target: 'android/fastlane/Pluginfile', 95 | props: { } 96 | }) 97 | } 98 | 99 | const setupGradle = async ({ android, patching }, { repo }) => { 100 | const GRADLE_FILE_PATH = 'android/app/build.gradle' 101 | await patching.replace( 102 | GRADLE_FILE_PATH, 103 | 'versionCode 1', 104 | 'versionCode rootProject.hasProperty("VERSION_CODE") ? VERSION_CODE.toInteger() : 1' 105 | ) 106 | 107 | const keyStoreProperties = 108 | `def keystorePropertiesFile = rootProject.file("app/${repo}-keystore.properties") 109 | def keystoreProperties = new Properties() 110 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 111 | 112 | ` 113 | 114 | await patching.patch( 115 | GRADLE_FILE_PATH, 116 | { 117 | insert: keyStoreProperties, 118 | before: 'android {' 119 | } 120 | ) 121 | 122 | const buildFlavors = 123 | `flavorDimensions "env" 124 | productFlavors { 125 | dev { 126 | dimension "env" 127 | applicationIdSuffix ".dev" 128 | versionNameSuffix "-DEV" 129 | } 130 | staging { 131 | dimension "env" 132 | applicationIdSuffix ".staging" 133 | versionNameSuffix "-STAGING" 134 | } 135 | prod { 136 | dimension "env" 137 | } 138 | } 139 | 140 | ` 141 | 142 | await patching.patch( 143 | GRADLE_FILE_PATH, 144 | { 145 | insert: buildFlavors, 146 | before: 'buildTypes {' 147 | } 148 | ) 149 | 150 | const propsPrefix = repo.toUpperCase() 151 | const signingConfigs = 152 | `signingConfigs { 153 | release { 154 | keyAlias keystoreProperties['${propsPrefix}_RELEASE_KEY_ALIAS'] 155 | keyPassword keystoreProperties['${propsPrefix}_RELEASE_KEY_PASSWORD'] 156 | storeFile file(keystoreProperties['${propsPrefix}_RELEASE_STORE_FILE']) 157 | storePassword keystoreProperties['${propsPrefix}_RELEASE_STORE_PASSWORD'] 158 | } 159 | }\n 160 | ` 161 | 162 | await patching.patch( 163 | GRADLE_FILE_PATH, 164 | { 165 | insert: signingConfigs, 166 | before: 'buildTypes {' 167 | } 168 | ) 169 | 170 | const releaseType = new RegExp('buildTypes {\\n(\\s*)release\\s{') 171 | const signingConfigStr = '\n\t\t\tsigningConfig signingConfigs.release' 172 | await patching.patch( 173 | GRADLE_FILE_PATH, 174 | { 175 | insert: signingConfigStr, 176 | after: releaseType 177 | } 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /src/flows/ios.js: -------------------------------------------------------------------------------- 1 | module.exports.runIOS = async (toolbox, config) => { 2 | const input = await getInput(toolbox, config) 3 | await initFastlane(toolbox, { 4 | ...config, 5 | ...input 6 | }) 7 | await initXcode(toolbox, { 8 | ...config, 9 | ...input 10 | }) 11 | } 12 | 13 | const initXcode = async ( 14 | { print: { spin }, template, npm, system, ios }, 15 | config 16 | ) => { 17 | const iosSpinner = spin('Modifying iOS Project') 18 | await ios.addSchemes() 19 | await ios.addBuildConfigurations(config.developerTeamId) 20 | await ios.addBundleIdSuffixes() 21 | await system.run( 22 | `cd ios && fastlane run update_info_plist 'display_name:$(CUSTOM_PRODUCT_NAME)' plist_path:${ 23 | config.projectName 24 | }/Info.plist` 25 | ) 26 | 27 | iosSpinner.succeed('iOS Project modified') 28 | 29 | const rnConfigSpinner = spin( 30 | 'Installing and configuring react-native-config..' 31 | ) 32 | await npm.installPackage('react-native-config') 33 | await system.spawn('react-native link react-native-config', { 34 | stdio: 'ignore' 35 | }) 36 | rnConfigSpinner.succeed('react-native-config installed') 37 | 38 | const envSpinner = spin('Generating environment config files..') 39 | await template.generate({ 40 | template: '.env.ejs', 41 | target: '.env.dev', 42 | props: { env: 'DEV' } 43 | }) 44 | 45 | await template.generate({ 46 | template: '.env.ejs', 47 | target: '.env.staging', 48 | props: { env: 'STAGING' } 49 | }) 50 | 51 | await template.generate({ 52 | template: '.env.ejs', 53 | target: '.env.prod', 54 | props: { env: 'PRODUCTION' } 55 | }) 56 | envSpinner.succeed('Environments generated') 57 | 58 | const spinner = spin('Installing react-native-schemes-manager') 59 | await npm.installPackage('react-native-schemes-manager', false, true) 60 | npm.addPackageScript({ 61 | key: 'postinstall', 62 | value: 'react-native-schemes-manager all' 63 | }) 64 | npm.addConfigSection({ 65 | key: 'xcodeSchemes', 66 | value: { 67 | Debug: ['Dev Debug', 'Staging Debug'], 68 | Release: ['Dev Release', 'Staging Release'], 69 | projectDirectory: 'iOS' 70 | } 71 | }) 72 | await system.exec('npm run postinstall') 73 | spinner.succeed('react-native-schemes-manager installed..') 74 | } 75 | 76 | const initFastlane = async ( 77 | { 78 | ios, 79 | system, 80 | template, 81 | filesystem, 82 | http, 83 | circle, 84 | prompt, 85 | print, 86 | print: { info, spin, success } 87 | }, 88 | options 89 | ) => { 90 | const flSpinner = spin('Preparing Fastlane for iOS..') 91 | const fastlanePath = system.which('fastlane') 92 | if (!fastlanePath) { 93 | await system.run('sudo gem install fastlane -NV') 94 | } 95 | 96 | await template.generate({ 97 | template: 'fastlane/Gemfile', 98 | target: 'ios/Gemfile', 99 | props: {} 100 | }) 101 | 102 | await template.generate({ 103 | template: 'fastlane/ios/Appfile', 104 | target: 'ios/fastlane/Appfile', 105 | props: { 106 | ...options 107 | } 108 | }) 109 | 110 | flSpinner.start() 111 | 112 | const { appId } = options 113 | 114 | await template.generate({ 115 | template: 'fastlane/ios/Matchfile', 116 | target: 'ios/fastlane/Matchfile', 117 | props: { 118 | ...options, 119 | developerAccount: options.developerAccount, 120 | appIds: `["${appId}", "${appId}.dev", "${appId}.staging"]` 121 | } 122 | }) 123 | 124 | await template.generate({ 125 | template: 'fastlane/ios/Pluginfile', 126 | target: 'ios/fastlane/Pluginfile', 127 | props: {} 128 | }) 129 | 130 | await template.generate({ 131 | template: 'fastlane/ios/Fastfile', 132 | target: 'ios/fastlane/Fastfile', 133 | props: { 134 | projectName: options.projectName, 135 | appId: options.appId, 136 | slackHook: options.slackHook 137 | } 138 | }) 139 | 140 | await system.run(`fastlane fastlane-credentials add --username ${options.developerAccount} --password ${options.developerPassword}`) 141 | 142 | await ios.produceApp({ 143 | appId, 144 | devId: options.developerAccount, 145 | appName: options.projectName, 146 | developerTeamId: options.developerTeamId, 147 | iTunesTeamId: options.iTunesTeamId 148 | }) 149 | await ios.produceApp({ 150 | appId: `${appId}.dev`, 151 | devId: options.developerAccount, 152 | appName: `${options.projectName} Dev`, 153 | developerTeamId: options.developerTeamId, 154 | iTunesTeamId: options.iTunesTeamId 155 | }) 156 | await ios.produceApp({ 157 | appId: `${appId}.staging`, 158 | devId: options.developerAccount, 159 | appName: `${options.projectName} Staging`, 160 | developerTeamId: options.developerTeamId, 161 | iTunesTeamId: options.iTunesTeamId 162 | }) 163 | await ios.matchSync({ certType: 'appstore', password: options.matchPassword }) 164 | await ios.matchSync({ 165 | certType: 'development', 166 | password: options.matchPassword 167 | }) 168 | 169 | const { org, project, apiToken } = options 170 | circle.postEnvVariable({ 171 | org, 172 | project, 173 | apiToken, 174 | key: 'MATCH_PASSWORD', 175 | value: options.matchPassword 176 | }) 177 | 178 | circle.postEnvVariable({ 179 | org, 180 | project, 181 | apiToken, 182 | key: 'FASTLANE_PASSWORD', 183 | value: options.developerPassword 184 | }) 185 | 186 | flSpinner.succeed('Fastlane ready for iOS') 187 | success(`${print.checkmark} Fastlane iOS setup success`) 188 | } 189 | 190 | const getInput = async ( 191 | { system, filesystem, prompt, ios, print }, 192 | options 193 | ) => { 194 | const xcodeProjectName = filesystem.find('ios/', { 195 | matching: '*.xcodeproj', 196 | directories: true, 197 | recursive: false, 198 | files: false 199 | })[0] 200 | const projectName = xcodeProjectName.split(/\/|\./)[1] 201 | 202 | const devAnswers = await prompt.ask([ 203 | { 204 | type: 'input', 205 | initial: options.appleDevAccount, 206 | skip: () => options.appleDevAccount, 207 | name: 'developerAccount', 208 | message: 'Your Apple developer account?' 209 | }, 210 | { 211 | type: 'password', 212 | name: 'developerPassword', 213 | skip: () => options.appleDevPassword, 214 | message: 'Your Apple developer password?' 215 | } 216 | ]) 217 | const { developerAccount, developerPassword } = { 218 | developerAccount: devAnswers.developerAccount ? devAnswers.developerAccount : options.developerAccount, 219 | developerPassword: devAnswers.developerPassword ? devAnswers.developerPassword : options.developerPassword 220 | } 221 | 222 | let developerTeamId = options.iTunesTeamId; 223 | let iTunesTeamId = options.appConnectTeamId; 224 | if (!developerTeamId || !iTunesTeamId) { 225 | let itcTeams = [] 226 | let devTeams = [] 227 | const teamSpinner = print.spin('Trying to find your Apple teams..') 228 | try { 229 | const teams = await ios.getTeamIds({ 230 | developerAccount, 231 | developerPassword 232 | }) 233 | itcTeams.push(...teams.itcTeams) 234 | devTeams.push(...teams.devTeams) 235 | } catch (error) { 236 | teamSpinner.fail(error) 237 | print.error('there was an error: ' + error) 238 | process.exit(0) 239 | } 240 | teamSpinner.succeed('Apple teams search successful') 241 | 242 | developerTeamId = await promptForTeamId( 243 | devTeams, 244 | { 245 | message: 'Your Developer Team ID?', 246 | multiMessage: 'Please select the developer team you want to use' 247 | }, 248 | prompt 249 | ) 250 | 251 | iTunesTeamId = await promptForTeamId( 252 | itcTeams, 253 | { 254 | message: 'Your App connect Team ID?', 255 | multiMessage: 'Please select the app connect team you want to use' 256 | }, 257 | prompt 258 | ) 259 | } 260 | 261 | // print.info(`dev team id: ${developerTeamId}`) 262 | // print.info(`app team id: ${iTunesTeamId}`) 263 | 264 | 265 | const askCertRepo = { 266 | type: 'input', 267 | initial: options.certRepoUrl, 268 | skip: () => options.certRepoUrl, 269 | name: 'certRepoUrl', 270 | message: 'Specify path to iOS Signing key repo' 271 | } 272 | 273 | const appId = await ios.getAppId() 274 | const isValidAppId = await prompt.confirm( 275 | `We resolved Bundle ID for your project: ${appId}, is this correct?` 276 | ) 277 | 278 | const askAppId = { 279 | type: 'input', 280 | name: 'appId', 281 | skip: isValidAppId, 282 | message: 'What is your project Bundle ID?' 283 | } 284 | 285 | const askMatchPassword = { 286 | type: 'input', 287 | initial: options.matchPassword, 288 | skip: () => options.matchPassword, 289 | name: 'matchPassword', 290 | message: 'What do you want to be your match repo password?' 291 | } 292 | 293 | // ask a series of questions 294 | const questions = [askAppId, askCertRepo, askMatchPassword] 295 | 296 | const answers = await prompt.ask(questions) 297 | return { 298 | ...answers, 299 | appId, 300 | certRepoUrl: options.certRepoUrl, 301 | matchPassword: options.matchPassword, 302 | developerAccount, 303 | developerPassword, 304 | developerTeamId, 305 | iTunesTeamId, 306 | slackHook: '', 307 | projectName 308 | } 309 | } 310 | 311 | const promptForTeamId = async (teams, { message, multiMessage }, prompt) => { 312 | if (teams.length > 1) { 313 | const { teamId } = await prompt.ask({ 314 | type: 'select', 315 | name: 'teamId', 316 | choices: teams.map(team => { 317 | return { 318 | name: team.id, 319 | message: `${team.name} (${team.id})`, 320 | } 321 | }), 322 | message: multiMessage 323 | }) 324 | return teamId 325 | } else if (teams.length === 1) { 326 | return teams[0] 327 | } else { 328 | const { teamId } = await prompt.ask({ 329 | type: 'input', 330 | initial: options.iTunesTeamId, 331 | name: 'teamId', 332 | message: message 333 | }) 334 | return teamId 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/flows/shared.js: -------------------------------------------------------------------------------- 1 | module.exports.runShared = async (toolbox, config) => { 2 | const answers = await askQuestions(toolbox, config) 3 | await initCircleCI(toolbox, { ...config, ...answers }) 4 | return answers 5 | } 6 | 7 | const askQuestions = async ({ prompt }, options) => { 8 | // text input 9 | const askOrganization = { 10 | type: 'input', 11 | initial: options.githubOrg, 12 | skip: () => options.githubOrg, 13 | name: 'githubOrg', 14 | message: 'Your github organization?' 15 | } 16 | const askProject = { 17 | type: 'input', 18 | initial: options.repo, 19 | skip: () => options.repo, 20 | name: 'repo', 21 | message: 'Your github project name?' 22 | } 23 | const askApiToken = { 24 | type: 'input', 25 | initial: options.circleApi, 26 | skip: () => options.circleApi, 27 | name: 'circleApi', 28 | message: 'Your CircleCI API token?' 29 | } 30 | // ask a series of questions 31 | const questions = [askOrganization, askProject, askApiToken] 32 | const answers = await prompt.ask(questions) 33 | return { 34 | ...options, 35 | ...answers 36 | } 37 | } 38 | 39 | const initCircleCI = async ({ template, prompt, print, http, android, circle }, { githubOrg, repo, circleApi }) => { 40 | await template.generate({ 41 | template: 'circleci/config.yml', 42 | target: '.circleci/config.yml', 43 | props: {} 44 | }) 45 | 46 | const api = http.create({ 47 | baseURL: 'https://circleci.com/api/v1.1/' 48 | }) 49 | 50 | const { status } = await api.post( 51 | `project/github/${githubOrg}/${repo}/follow?circle-token=${circleApi}` 52 | ) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/ios.rb: -------------------------------------------------------------------------------- 1 | # TODO: Figure out how to define project dependencies 2 | 3 | require 'xcodeproj' 4 | 5 | def clone_build_config(project, dest, from_build_config, build_config_name) 6 | # Heavily inspired by Xcodeproj::Project.add_build_configuration 7 | existing_build_config = dest.build_configuration_list[build_config_name] 8 | 9 | if existing_build_config 10 | existing_build_config 11 | else 12 | new_config = project.new(Xcodeproj::Project::XCBuildConfiguration) 13 | new_config.build_settings = Xcodeproj::Project::ProjectHelper.deep_dup(from_build_config.build_settings) 14 | new_config.name = build_config_name 15 | 16 | dest.build_configuration_list.build_configurations << new_config 17 | new_config 18 | end 19 | end 20 | 21 | def deep_clone_build_config(project, variant, build_type, team_id) 22 | 23 | # 24 | # Clone a build configuration both at the project level and at target level. 25 | # Mimics what Xcode does when going to Project > Info > Configurations > + sign > Duplicate "X" Configuration 26 | # 27 | 28 | build_config_name = "#{variant} #{build_type}" 29 | from = project.build_configuration_list[build_type] 30 | 31 | clone_build_config(project, project, from, build_config_name) 32 | 33 | project.targets.each do |target| 34 | original_target_build_config = target.build_configuration_list[build_type] 35 | original_target_build_config.build_settings['DEVELOPMENT_TEAM'] = team_id 36 | 37 | unless target.name.end_with?("Tests") 38 | original_target_build_config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = "match Development $(PRODUCT_BUNDLE_IDENTIFIER)" 39 | end 40 | 41 | clone_build_config(project, target, original_target_build_config, build_config_name) 42 | end 43 | end 44 | 45 | 46 | def make_new_build_configurations(project, team_id) 47 | deep_clone_build_config(project, 'Dev', 'Debug', team_id) 48 | deep_clone_build_config(project, 'Dev', 'Release', team_id) 49 | deep_clone_build_config(project, 'Staging', 'Debug', team_id) 50 | deep_clone_build_config(project, 'Staging', 'Release', team_id) 51 | end 52 | 53 | def add_bundle_id_suffixes(project) 54 | # 1. extract current bundle id (from "Release"?) 55 | main_target = project.targets[0] 56 | 57 | # TODO: what if it fails? 58 | release_build_config = main_target.build_configurations.select {|config| config.name == 'Release'}.first 59 | original_bundle_id = release_build_config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] 60 | 61 | # 2. Create release phase-specific suffixes 62 | main_target.build_configurations.each do |config| 63 | config.build_settings['CUSTOM_PRODUCT_NAME'] = '$(PRODUCT_NAME)' 64 | 65 | if config.name == 'Staging Debug' or config.name == 'Staging Release' 66 | config.build_settings['BUNDLE_ID_SUFFIX'] = '.staging' 67 | config.build_settings['CUSTOM_PRODUCT_NAME'] = '$(PRODUCT_NAME) Staging' 68 | end 69 | 70 | if config.name == 'Dev Debug' or config.name == 'Dev Release' 71 | config.build_settings['BUNDLE_ID_SUFFIX'] = '.dev' 72 | config.build_settings['CUSTOM_PRODUCT_NAME'] = '$(PRODUCT_NAME) Dev' 73 | end 74 | end 75 | 76 | # 3. Combine these two 77 | main_target.build_configurations.each do |config| 78 | puts "adding bundle suffix to " + config.name 79 | if !original_bundle_id.include? "$(BUNDLE_ID_SUFFIX)" 80 | config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = original_bundle_id + "$(BUNDLE_ID_SUFFIX)" 81 | end 82 | end 83 | end 84 | 85 | def add_schemes(project_path) 86 | # TODO present a drop down list where the user can choose the base scheme. Also check that the scheme is shared. 87 | basename = File.basename(project_path, ".xcodeproj") 88 | main_scheme_name = Xcodeproj::Project.schemes(project_path).select {|name| name == basename}.first 89 | full_scheme_path = "#{project_path}/xcshareddata/xcschemes/#{main_scheme_name}.xcscheme" 90 | main_scheme = Xcodeproj::XCScheme.new(full_scheme_path) 91 | 92 | main_scheme.launch_action.build_configuration = "Dev Debug" 93 | main_scheme.test_action.build_configuration = "Dev Debug" 94 | main_scheme.analyze_action.build_configuration = "Dev Debug" 95 | main_scheme.archive_action.build_configuration = "Dev Release" 96 | main_scheme.profile_action.build_configuration = "Dev Release" 97 | 98 | main_scheme.save_as(project_path, basename + "Dev", shared=true) 99 | 100 | main_scheme.launch_action.build_configuration = "Staging Debug" 101 | main_scheme.test_action.build_configuration = "Staging Debug" 102 | main_scheme.analyze_action.build_configuration = "Staging Debug" 103 | main_scheme.archive_action.build_configuration = "Staging Release" 104 | main_scheme.profile_action.build_configuration = "Staging Release" 105 | 106 | main_scheme.save_as(project_path, basename + "Staging", shared=true) 107 | end 108 | 109 | def get_team_id(team_type, account, password) 110 | require 'spaceship' 111 | if (team_type == 'itc') 112 | Spaceship::Tunes.login(account, password) 113 | team_id = Spaceship::Tunes.select_team 114 | team_id 115 | elsif (team_type == 'dev') 116 | Spaceship::Portal.login(account, password) 117 | team_id = Spaceship::Portal.select_team 118 | team_id 119 | end 120 | end 121 | 122 | def resolve_variables(app_id, build_config) 123 | variables_in_string = /\$\((.*?)\)/ 124 | app_id = app_id.gsub(variables_in_string) do |m| 125 | sub = m[2..-2] 126 | var, *transformations = sub.split(':') 127 | resolved = build_config.resolve_build_setting(var) 128 | 129 | # puts "transformations #{transformations}" 130 | transformations.each do |trans| 131 | { 132 | 'lower' => resolved = resolved&.downcase, 133 | 'rfc1034identifier' => resolved = resolved&.gsub(/[\/\*\s]/, '-') 134 | } 135 | end 136 | # puts "for variable #{sub} we resolved: #{resolved}" 137 | recurred = resolve_variables(resolved, build_config) 138 | recurred 139 | end 140 | end 141 | 142 | def get_app_id(project) 143 | proj_name = get_project_name() 144 | bundle_id = Xcodeproj::Plist.read_from_path("ios/#{proj_name}/Info.plist")['CFBundleIdentifier'] 145 | target = project.targets[0] 146 | build_config = target.build_configurations.select {|config| config.name == 'Release'}.first 147 | app_id = resolve_variables(bundle_id, build_config) 148 | app_id 149 | end 150 | 151 | def get_project_name() 152 | Dir["ios/*.xcodeproj"].select {|f| File.directory? f}.first.split(/(\/|\.)/)[2] 153 | end 154 | 155 | def get_project_path() 156 | Dir["ios/*.xcodeproj"].select {|f| File.directory? f}.first 157 | end 158 | 159 | COMMAND = ARGV[0] 160 | project_path = get_project_path() 161 | project = Xcodeproj::Project.open(project_path) 162 | 163 | if COMMAND == "add_schemes" 164 | add_schemes(project_path) 165 | elsif COMMAND == "make_new_build_configurations" 166 | team_id = ARGV[1] 167 | make_new_build_configurations(project, team_id) 168 | project.save 169 | elsif COMMAND == "add_bundle_id_suffixes" 170 | add_bundle_id_suffixes(project) 171 | project.save 172 | elsif COMMAND == "get_project_path" 173 | get_project_path 174 | elsif COMMAND == "get_team_id" 175 | team_type = ARGV[1] 176 | account = ARGV[2] 177 | password = ARGV[3] 178 | team_id = get_team_id(team_type, account, password) 179 | puts team_id 180 | elsif COMMAND == "get_app_id" 181 | app_id = get_app_id(project) 182 | puts app_id 183 | end 184 | 185 | 186 | -------------------------------------------------------------------------------- /src/templates/.env.ejs: -------------------------------------------------------------------------------- 1 | ENV=<%= props.env %> -------------------------------------------------------------------------------- /src/templates/circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | working_directory: ~/project 5 | docker: 6 | - image: circleci/node:8 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: react-native-ci-{{ checksum "package-lock.json" }} 11 | - run: npm install 12 | - save_cache: 13 | key: react-native-ci-{{ checksum "package-lock.json" }} 14 | paths: 15 | - node_modules 16 | - persist_to_workspace: 17 | root: ~/project 18 | paths: 19 | - node_modules 20 | - store_test_results: 21 | path: ~/project/junit.xml 22 | 23 | android: 24 | working_directory: ~/project/android 25 | docker: 26 | - image: circleci/android:api-28-node 27 | steps: 28 | - checkout: 29 | path: ~/project 30 | - attach_workspace: 31 | at: ~/project 32 | - restore_cache: 33 | keys: 34 | - jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 35 | - restore_cache: 36 | keys: 37 | - 1-gems-android-{{ checksum "Gemfile.lock" }} 38 | - run: bundle install --path vendor/bundle 39 | - save_cache: 40 | key: 1-gems-android-{{ checksum "Gemfile.lock" }} 41 | paths: 42 | - vendor/bundle 43 | - run: bundle exec base64 -d <<< ${KEYSTORE} > app/${CIRCLE_PROJECT_REPONAME}-key.keystore 44 | - run: bundle exec base64 -d <<< ${KEYSTORE_PROPERTIES} > app/${CIRCLE_PROJECT_REPONAME}-keystore.properties 45 | - run: bundle exec fastlane build 46 | - save_cache: 47 | paths: 48 | - ~/.gradle 49 | key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 50 | 51 | build_deploy_android_dev: &build_deploy_android_template # to future maintainer: this is YAML's anchor feature 52 | working_directory: ~/project/android 53 | environment: 54 | ANDROID_ENVIRONMENT: dev 55 | docker: 56 | - image: circleci/android:api-28-node 57 | steps: 58 | - checkout: 59 | path: ~/project 60 | - attach_workspace: 61 | at: ~/project 62 | - restore_cache: 63 | keys: 64 | - jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 65 | - restore_cache: 66 | keys: 67 | - 1-gems-android-{{ checksum "Gemfile.lock" }} 68 | - run: bundle install --path vendor/bundle 69 | - save_cache: 70 | key: 1-gems-android-{{ checksum "Gemfile.lock" }} 71 | paths: 72 | - vendor/bundle 73 | - run: sudo apt-get update 74 | - run: sudo apt-get install imagemagick 75 | - run: echo ${KEYSTORE} | base64 --decode > app/${CIRCLE_PROJECT_REPONAME}-key.keystore 76 | - run: echo ${KEYSTORE_PROPERTIES} | base64 --decode > app/${CIRCLE_PROJECT_REPONAME}-keystore.properties 77 | - run: echo ${GOOGLE_PLAY_JSON} | base64 --decode > fastlane/google-play-store.json 78 | - run: bundle exec fastlane build_deploy env:${ANDROID_ENVIRONMENT} 79 | - save_cache: 80 | paths: 81 | - ~/.gradle 82 | key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }} 83 | 84 | build_deploy_android_staging: 85 | <<: *build_deploy_android_template 86 | environment: 87 | ANDROID_ENVIRONMENT: staging 88 | 89 | ios: 90 | macos: 91 | xcode: "10.1.0" 92 | working_directory: ~/project/ios 93 | environment: 94 | FL_OUTPUT_DIR: ~/project/output 95 | shell: /bin/bash --login -o pipefail 96 | steps: 97 | - checkout: 98 | path: ~/project 99 | - restore_cache: 100 | key: react-native-ci-iOS-{{ checksum "../package-lock.json" }} 101 | - run: npm install 102 | - save_cache: 103 | key: react-native-ci-iOS-{{ checksum "../package-lock.json" }} 104 | paths: 105 | - ../node_modules 106 | - run: 107 | name: Set Ruby Version 108 | command: echo "ruby-2.4" > ~/.ruby-version 109 | - run: 110 | name: Configure Bundler 111 | command: | 112 | echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV 113 | source $BASH_ENV 114 | gem install bundler 115 | - restore_cache: 116 | key: 1-gems-ios-{{ checksum "Gemfile.lock" }} 117 | # TODO Detect the existence of the Podfile and enable this 118 | # - run: 119 | # name: Fetch CocoaPods Specs 120 | # command: | 121 | # curl https://cocoapods-specs.circleci.com/fetch-cocoapods-repo-from-s3.sh | bash -s cf 122 | - run: bundle install --path vendor/bundle 123 | # - run: bundle exec pod install 124 | - run: bundle update fastlane 125 | - save_cache: 126 | key: 1-gems-ios-{{ checksum "Gemfile.lock" }} 127 | paths: 128 | - vendor/bundle 129 | 130 | - run: bundle exec fastlane build 131 | - store_artifacts: 132 | path: ~/project/output 133 | 134 | workflows: 135 | version: 2 136 | node-android-ios: 137 | jobs: 138 | - test 139 | 140 | - android: 141 | requires: 142 | - test 143 | filters: 144 | branches: 145 | ignore: 146 | - master 147 | - staging 148 | 149 | - ios: 150 | requires: 151 | - test 152 | 153 | - build_deploy_android_dev: 154 | filters: 155 | branches: 156 | only: master 157 | 158 | - build_deploy_android_staging: 159 | filters: 160 | branches: 161 | only: staging 162 | 163 | - build_deploy_ios_dev: 164 | requires: 165 | - test 166 | filters: 167 | branches: 168 | only: master 169 | 170 | - build_deploy_ios_staging: 171 | requires: 172 | - test 173 | filters: 174 | branches: 175 | only: staging 176 | 177 | 178 | -------------------------------------------------------------------------------- /src/templates/fastlane/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | 5 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 6 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 7 | -------------------------------------------------------------------------------- /src/templates/fastlane/android/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file("fastlane/google-play-store.json") 2 | package_name("<%= props.appId %>") 3 | -------------------------------------------------------------------------------- /src/templates/fastlane/android/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # Uncomment the line if you want fastlane to automatically update itself 9 | # update_fastlane 10 | #before_all do 11 | # update_fastlane 12 | ANDROID_VERSION_NAME = android_get_version_name() 13 | #end 14 | 15 | default_platform(:android) 16 | 17 | platform :android do 18 | desc "Runs all the tests" 19 | lane :test do 20 | gradle(task: "test") 21 | end 22 | 23 | desc "Build debug|dev|staging|release version" 24 | lane :build do |options| 25 | env = options[:env] 26 | if env then 27 | build_type = env.capitalize 28 | else 29 | puts "Choosing Debug as the default build type." 30 | build_type = 'Debug' 31 | end 32 | 33 | if env == 'dev' or env == 'staging' then 34 | build_num = ENV["CIRCLE_BUILD_NUM"] ? ENV["CIRCLE_BUILD_NUM"] : 'local' 35 | add_badge( 36 | shield: "#{ANDROID_VERSION_NAME}-#{build_num}-orange", 37 | glob: "/app/src/main/res/mipmap-*/*.{png,PNG}", 38 | no_badge: true 39 | ) 40 | end 41 | 42 | build_number = number_of_commits() 43 | gradle( 44 | task: 'assemble', 45 | build_type: build_type, 46 | properties: { 47 | "VERSION_CODE" => build_number 48 | } 49 | ) 50 | end 51 | 52 | desc "Submit a new Beta Build to Crashlytics Beta" 53 | lane :beta do 54 | gradle(task: "clean assembleRelease") 55 | crashlytics 56 | # sh "your_script.sh" 57 | # You can also use other beta testing services here 58 | end 59 | 60 | desc "Build and push to Google Play" 61 | lane :build_deploy do |options| 62 | env = options[:env] 63 | build(env: env) 64 | deploy(env: env) 65 | end 66 | 67 | desc "Push to Google Play" 68 | lane :deploy do |options| 69 | env = options[:env] 70 | if env != 'dev' and env != 'staging' and env != 'release' then 71 | raise 'env must be one of dev|staging|release' 72 | end 73 | env_name = env.capitalize 74 | package_name_postfix = { 75 | 'release' => '', 76 | 'dev' => '.dev', 77 | 'staging' => '.staging' 78 | }[env] 79 | upload_to_play_store( 80 | package_name: "<%= props.appId %>#{package_name_postfix}", 81 | track: 'internal', 82 | apk: "./app/build/outputs/apk/#{env}/release/app-#{env}-release.apk" 83 | ) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /src/templates/fastlane/android/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | gem 'fastlane-plugin-badge' 5 | gem 'fastlane-plugin-versioning_android' -------------------------------------------------------------------------------- /src/templates/fastlane/ios/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier ENV["APP_IDENTIFIER"] # The bundle identifier of your app 2 | apple_id("<%= props.developerAccount %>") # Your Apple email address 3 | 4 | itc_team_id "<%= props.iTunesTeamId %>" # iTunes Connect Team ID 5 | team_id "<%= props.developerTeamId %>" # Developer Portal Team ID 6 | 7 | # For more information about the Appfile, see: 8 | # https://docs.fastlane.tools/advanced/#appfile 9 | -------------------------------------------------------------------------------- /src/templates/fastlane/ios/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | 9 | # Uncomment the line if you want fastlane to automatically update itself 10 | fastlane_version "2.83.0" 11 | #update_fastlane 12 | 13 | default_platform(:ios) 14 | 15 | platform :ios do 16 | 17 | before_all do 18 | setup_circle_ci 19 | IOS_VERSION_NUMBER=get_version_number(xcodeproj: "<%= props.projectName %>.xcodeproj", target: "<%= props.projectName %>") 20 | end 21 | 22 | desc "Build Release debug version" 23 | lane :build do 24 | match(type: "development") 25 | build_app( 26 | scheme: "<%= props.projectName %>", 27 | configuration: "Debug", 28 | export_method: "development", 29 | silent: true, 30 | ) 31 | end 32 | 33 | desc "Build and deploy to testflight" 34 | lane :build_deploy do |options| 35 | env = options[:env] 36 | env_name = env.capitalize 37 | 38 | if env != 'dev' and env != 'staging' and env != 'release' then 39 | raise 'env must be one of dev|staging|release' 40 | end 41 | 42 | package_name_postfix = { 43 | 'release' => '', 44 | 'dev' => '.dev', 45 | 'staging' => '.staging' 46 | }[env] 47 | 48 | scheme_name_postfix = { 49 | 'release' => '', 50 | 'dev' => 'Dev', 51 | 'staging' => 'Staging' 52 | }[env] 53 | 54 | # Set app identifier 55 | ENV["APP_IDENTIFIER"] = "<%= props.appId %>#{package_name_postfix}" 56 | 57 | scheme = "<%= props.projectName %>#{scheme_name_postfix}" 58 | 59 | match(type: "appstore") 60 | 61 | build_number = number_of_commits() 62 | increment_build_number( 63 | build_number: build_number, 64 | xcodeproj: "<%= props.projectName %>.xcodeproj" 65 | ) 66 | 67 | # Create badge for env and staging 68 | if env == 'dev' or env == 'staging' then 69 | add_badge( 70 | shield: "#{IOS_VERSION_NUMBER}-#{ENV["CIRCLE_BUILD_NUM"]}-orange", 71 | no_badge: true 72 | ) 73 | end 74 | 75 | gym( 76 | scheme: scheme, 77 | ) 78 | 79 | upload_to_testflight(wait_processing_interval: 60) 80 | 81 | slack( 82 | username: "Fastlane", 83 | icon_url: "", 84 | channel: "#dev", # Note that this is not the same as dev env 85 | slack_url: "<%= props.slackHook %>", 86 | message: "New <%= props.projectName %> #{env_name} release to TestFlight! #{IOS_VERSION_NUMBER} (#{ENV["CIRCLE_BUILD_NUM"]}) 🚀", 87 | ) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /src/templates/fastlane/ios/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("<%= props.certRepo %>") 2 | type("development") # The default type, can be: appstore, adhoc, enterprise or development 3 | 4 | app_identifier(<%- props.appIds %>) 5 | username("<%= props.developerAccount %>") # Your Apple Developer Portal username 6 | 7 | # For all available options run `fastlane match --help` 8 | # Remove the # in the beginning of the line to enable the other options 9 | -------------------------------------------------------------------------------- /src/templates/fastlane/ios/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | gem 'fastlane-plugin-badge' 6 | -------------------------------------------------------------------------------- /src/templates/keystore.properties: -------------------------------------------------------------------------------- 1 | <%= props.name %>_RELEASE_STORE_FILE=<%= props.storeFile %> 2 | <%= props.name %>_RELEASE_STORE_PASSWORD=<%= props.storePassword %> 3 | <%= props.name %>_RELEASE_KEY_ALIAS=<%= props.alias %> 4 | <%= props.name %>_RELEASE_KEY_PASSWORD=<%= props.aliasPassword %> -------------------------------------------------------------------------------- /src/templates/model.js.ejs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: '<%= props.name %>' 3 | } 4 | --------------------------------------------------------------------------------