From b103be3653d4298b15e5a9ce1cf3942ebec41a6d Mon Sep 17 00:00:00 2001 From: Felix Meziere Date: Wed, 17 Apr 2019 20:44:38 +0100 Subject: [PATCH] feat(circleci): Add CircleCi generator handling all environments Get a state-of-the art CircleCI deployment script for RN provided you ran fastlane-setup and fastlane-env answering yes to the deployment + secret handling scripts --- generators/circleci/README.md | 3 + generators/circleci/index.js | 174 +++++++++++++++ generators/circleci/templates/config.yml | 204 ++++++++++++++++++ generators/fastlane-env/index.js | 75 ++++--- .../fastlane-env/templates/fastlane/env | 4 +- generators/fastlane-setup/index.js | 10 + generators/fastlane-setup/templates/deploy.sh | 10 +- .../templates/fastlane/Fastfile | 8 + generators/fastlane-setup/templates/gitignore | 2 + package.json | 1 + 10 files changed, 451 insertions(+), 40 deletions(-) create mode 100644 generators/circleci/README.md create mode 100644 generators/circleci/index.js create mode 100644 generators/circleci/templates/config.yml diff --git a/generators/circleci/README.md b/generators/circleci/README.md new file mode 100644 index 0000000..1405117 --- /dev/null +++ b/generators/circleci/README.md @@ -0,0 +1,3 @@ +# CircleCI setup for continuous integration + +CircleCI is a widely used CI service for both web and mobile projects. After running this generator, pushing your code to CircleCI should result in a succesful build configured with dependency caching and good practices. diff --git a/generators/circleci/index.js b/generators/circleci/index.js new file mode 100644 index 0000000..5ef512b --- /dev/null +++ b/generators/circleci/index.js @@ -0,0 +1,174 @@ +const Base = require('yeoman-generator'); +require('colors'); +const glob = require('glob'); + +// Command creators +const getPassphraseAliasForEnvironment = environment => + `${environment.envName.toUpperCase()}_SECRETS_PASSPHRASE`; +const getUnpackCommandForEnvironment = environment => + `sudo yarn unpack-secrets -e ${ + environment.envName + } -p \${${getPassphraseAliasForEnvironment(environment)}}`; +const getCodePushCommandForEnvironment = environment => + `yarn deploy -t soft -e ${environment.envName}`; +const getAndroidHardDeployCommandForEnvironment = environment => + `yarn deploy -t hard -o android -e ${environment.envName}`; +const getIosHardDeployCommandForEnvironment = environment => + `yarn deploy -t hard -o ios -e ${environment.envName}`; + +class CircleGenerator extends Base { + initializing() { + this.composeWith('rn-toolbox:checkversion'); + if (!this.config.get('fastlane')) + this.log.error( + 'You need to run `yo rn-toolbox:fastlane` first.'.red.bold + ); + + if (!this.config.get('circleci-ready')) + this.log.error( + 'You need to have the deployment script and secrets archive from fastlane-setup to use this generator. Get them by running yo rn-toolbox:fastlane-setup.' + .red.bold + ); + } + + prompting() { + const config = this.fs.readJSON(this.destinationPath('package.json')); + const envFilepaths = glob.sync('fastlane/.env.*', { + ignore: 'fastlane/.env.*.*', + }); + const environments = envFilepaths.map(filePath => { + const split = filePath.split('/'); + const envName = split[split.length - 1].split('.')[2]; + const fileString = this.fs.read(filePath); + const envGitBranch = fileString + .split("REPO_GIT_BRANCH='")[1] + .split("'")[0]; + return { + envName, + envGitBranch, + }; + }); + if (environments.length === 0) + this.log.error( + 'You need at least one environment setup with fastlane-env to run this generator. Run yo rn-toolbox:fastlane-env.' + .red.bold + ); + + const prompts = [ + { + type: 'input', + name: 'projectName', + message: + 'Please confirm the react-native project name (as in react-native-init )', + required: true, + default: config.name, + }, + { + type: 'input', + name: 'reactNativeDirectory', + message: + 'Path to the React Native project relative to the root of the repository (no trailing slash)', + required: true, + default: '.', + }, + ]; + + return this.prompt(prompts).then(answers => { + this.answers = answers; + this.environments = environments; + }); + } + + writing() { + // Command creators + const numberOfEnvironments = this.environments.length; + const getPerEnvironmentCommand = commandGetter => { + let switchString = ''; + this.environments.forEach((environment, index) => { + const prefix = index === 0 ? 'if' : 'elif'; + const suffix = index === numberOfEnvironments - 1 ? 'fi' : ''; + if (index > 0) switchString += ''; + switchString += `${prefix} [ "\${CIRCLE_BRANCH}" == "${ + environment.envGitBranch + }" ]; + then + ${commandGetter(environment)} + ${suffix}`; + }); + return switchString; + }; + const getForAllEnvironmentsCommand = command => { + let ifStatement = `if [ "\${CIRCLE_BRANCH}" == "${ + this.environments[0].envGitBranch + }" ]`; + this.environments.slice(1).forEach(environment => { + ifStatement += `|| [ "\${CIRCLE_BRANCH}" == "${environment.envName}" ]`; + }); + ifStatement += ';'; + + return `${ifStatement} + then + ${command} + fi`; + }; + + // Commands + const unpackSecretsCommand = getPerEnvironmentCommand( + getUnpackCommandForEnvironment + ); + const installAppcenterCommand = getForAllEnvironmentsCommand(`echo 'export PATH=$(yarn global bin):$PATH' >> $BASH_ENV + source $BASH_ENV + yarn global add appcenter-cli + appcenter login --token \${FL_APPCENTER_API_TOKEN} --quiet`); + const codepushCommand = getPerEnvironmentCommand( + getCodePushCommandForEnvironment + ); + const androidHardDeployCommand = getPerEnvironmentCommand( + getAndroidHardDeployCommandForEnvironment + ); + const iosHardDeployCommand = getPerEnvironmentCommand( + getIosHardDeployCommandForEnvironment + ); + let branchesOnlyCommand = ''; + this.environments.forEach(environment => { + branchesOnlyCommand += ` + - ${environment.envGitBranch}`; + }); + + // Create final config.yml file :-) + this.fs.copyTpl( + this.templatePath('config.yml'), + this.destinationPath('.circleci/config.yml'), + { + ...this.answers, + prefixRN: path => `${this.answers.reactNativeDirectory}/${path || ''}`, + unpackSecretsCommand, + installAppcenterCommand, + codepushCommand, + androidHardDeployCommand, + iosHardDeployCommand, + branchesOnlyCommand, + } + ); + } + + end() { + this.log( + `Custom config.yml created. Re-run yo rn-toolbox:circleci anytime to re-generate the file with latest environments information.` + .green.bold + ); + this.log( + `Please make sure that all of the following environment variables have been added in the Circle-CI console's Environment Variables section:` + .magenta.bold + ); + this.log( + ['FL_APPCENTER_API_TOKEN', 'MATCH_PASSWORD'] + .concat(this.environments.map(getPassphraseAliasForEnvironment)) + .join(', ').magenta.bold + ); + this.config.set('circleci', true); + this.config.save(); + } +} + +module.exports = CircleGenerator; diff --git a/generators/circleci/templates/config.yml b/generators/circleci/templates/config.yml new file mode 100644 index 0000000..5ba8985 --- /dev/null +++ b/generators/circleci/templates/config.yml @@ -0,0 +1,204 @@ +# prettier: disable + +version: 2 +jobs: + node: + working_directory: ~/<%= projectName %> + docker: + - image: circleci/android:api-28-node + steps: + - checkout + + - restore_cache: + key: yarn-v1-{{ checksum "<%= prefixRN('yarn.lock') %>" }}-{{ arch }} + + - restore_cache: + key: node-v1-{{ checksum "<%= prefixRN('package.json') %>" }}-{{ arch }} + + - run: + name: yarn install + command: | + cd <%= prefixRN() %> + yarn + + - run: + name: jest tests + command: | + cd <%= prefixRN() %> + mkdir -p test-results/jest + yarn test --maxWorkers=2 + environment: + JEST_JUNIT_OUTPUT: <%= prefixRN('test-results/jest/junit.xml') %> + + - run: + name: Unpack secrets + command: | + cd <%= prefixRN() %> + <%- unpackSecretsCommand %> + + - restore_cache: + key: bundle-v1-{{ checksum "<%= prefixRN('Gemfile.lock') %>" }}-{{ arch }} + + - run: cd <%= prefixRN() %> && bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4 --retry=3 + + - deploy: + name: Deploy with CodePush + command: | + cd <%= prefixRN() %> + <%- installAppcenterCommand %> + <%- codepushCommand %> + + - save_cache: + key: bundle-v1-{{ checksum "<%= prefixRN('Gemfile.lock') %>" }}-{{ arch }} + paths: + - ~/.bundle/ + - vendor/bundle + + - save_cache: + key: yarn-v1-{{ checksum "<%= prefixRN('yarn.lock') %>" }}-{{ arch }} + paths: + - ~/.cache/yarn + + - save_cache: + key: node-v1-{{ checksum "<%= prefixRN('package.json') %>" }}-{{ arch }} + paths: + - <%= prefixRN('node_modules') %> + + - persist_to_workspace: + root: ~/<%= projectName %> + paths: + - <%= prefixRN('node_modules') %> + - vendor/bundle + + - store_test_results: + path: <%= prefixRN('test-results') %> + + - store_artifacts: + path: <%= prefixRN('test-results') %> + + android: + working_directory: ~/<%= projectName %> + docker: + - image: circleci/android:api-28-node + steps: + - checkout: + path: ~/<%= projectName %> + + - attach_workspace: + at: ~/<%= projectName %> + + - run: + name: Unpack secrets + command: | + cd <%= prefixRN() %> + <%- unpackSecretsCommand %> + + - run: cd <%= prefixRN() %> && bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4 --retry=3 + + - run: + name: yarn install + command: | + cd <%= prefixRN() %> + yarn + + - deploy: + name: Build, Sign & Deploy + command: | + cd <%= prefixRN() %> + <%- androidHardDeployCommand %> + + - store_test_results: + path: <%= prefixRN('test-results') %> + + - store_artifacts: + path: <%= prefixRN('test-results') %> + + ios: + macos: + xcode: '10.1.0' + environment: + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 + working_directory: ~/<%= projectName %> + + # use a --login shell so our "set Ruby version" command gets picked up for later steps + shell: /bin/bash --login -o pipefail + + steps: + - checkout + + - run: + name: set Ruby version + command: echo "ruby-2.4" > ~/.ruby-version + + - run: + name: Install brew dependencies + command: HOMEBREW_NO_AUTO_UPDATE=1 brew install gpg + + - run: + name: Unpack secrets + command: | + cd <%= prefixRN() %> + <%- unpackSecretsCommand %> + + - restore_cache: + key: yarn-v1-{{ checksum "<%= prefixRN('yarn.lock') %>" }}-{{ arch }} + + - restore_cache: + key: node-v1-{{ checksum "<%= prefixRN('package.json') %>" }}-{{ arch }} + + # not using a workspace here as Node and Yarn versions + # differ between the macOS executor image and the Docker containers above + - run: + name: yarn install + command: | + cd <%= prefixRN() %> + yarn + + - save_cache: + key: yarn-v1-{{ checksum "<%= prefixRN('yarn.lock') %>" }}-{{ arch }} + paths: + - ~/.cache/yarn + + - save_cache: + key: node-v1-{{ checksum "<%= prefixRN('package.json') %>" }}-{{ arch }} + paths: + - <%= prefixRN('node_modules') %> + + - restore_cache: + key: bundle-v2-{{ checksum "<%= prefixRN('Gemfile.lock') %>" }}-{{ arch }} + + - run: + command: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs=4 --retry=3 + + - save_cache: + key: bundle-v2-{{ checksum "<%= prefixRN('Gemfile.lock') %>" }}-{{ arch }} + paths: + - ~/.bundle/ + - /Users/distiller/.gem/ruby/2.4.5/ + - vendor/bundle + + - deploy: + name: Match, Build, Sign & Deploy + command: | + cd <%= prefixRN() %> + sudo xcode-select -s /Applications/Xcode-10.1.app + <%- iosHardDeployCommand %> + +workflows: + version: 2 + node-android-ios: + jobs: + - node + - ios: + requires: + - node + filters: + branches: + only: <%- branchesOnlyCommand %> + - android: + requires: + - node + filters: + branches: + only: <%- branchesOnlyCommand %> diff --git a/generators/fastlane-env/index.js b/generators/fastlane-env/index.js index 857011b..bdcb6f5 100755 --- a/generators/fastlane-env/index.js +++ b/generators/fastlane-env/index.js @@ -72,7 +72,7 @@ class FastlaneEnvGenerator extends Base { type: 'input', name: 'companyName', message: - 'The name of the company which will be publishing this application (used to generate android Keytore)', + 'The name of the company which will be publishing this application (used to generate android Keystore)', default: 'My Company', }, { @@ -87,26 +87,6 @@ class FastlaneEnvGenerator extends Base { message: 'The app name for this environment', default: 'My App', }, - { - type: 'input', - name: 'iosAppCenterId', - message: - 'The iOS project id on AppCenter for this environment, should be different than Android and not contain spaces', - default: answers => - `${answers.appName.replace(/ /g, '')}-ios-${answers.environmentName}`, - when: answers => answers.deploymentPlatform === 'appcenter', - }, - { - type: 'input', - name: 'androidAppCenterId', - message: - 'The Android project id on AppCenter, should be different than iOS and not contain spaces', - default: answers => - `${answers.appName.replace(/ /g, '')}-android-${ - answers.environmentName - }`, - when: answers => answers.deploymentPlatform === 'appcenter', - }, { type: 'input', name: 'appId', @@ -171,37 +151,62 @@ class FastlaneEnvGenerator extends Base { message: 'A valid HockeyApp token', when: answers => answers.deploymentPlatform === 'hockeyapp', }, - { - type: 'input', - name: 'appCenterToken', - message: 'A valid App Center API token', - when: answers => answers.deploymentPlatform === 'appcenter', - }, - { - type: 'input', - name: 'appCenterUsername', - message: 'A valid App Center Username', - when: answers => answers.deploymentPlatform === 'appcenter', - }, + { type: 'input', name: 'appstoreConnectAppleId', message: 'An AppstoreConnect Apple Id (make sure the ID has "developer" acces - only allowed to upload builds). Can be entered later in fastlane/env.', - when: answers => answers.appstore, + when: answers => answers.deploymentPlatform === 'appstore', }, { type: 'input', name: 'androidPlayStoreJsonKeyPath', message: 'A Google Play JSON Key relative path. Can be entered later in fastlane/env.', - when: answers => answers.appstore, + when: answers => answers.deploymentPlatform === 'appstore', }, { type: 'confirm', name: 'useCodePush', message: 'Will you deploy with Appcenter CodePush on this environment?', }, + { + type: 'input', + name: 'appCenterUsername', + message: 'A valid App Center Username', + when: answers => + answers.deploymentPlatform === 'appcenter' || answers.useCodePush, + }, + { + type: 'input', + name: 'appCenterToken', + message: 'A valid App Center API token', + when: answers => + answers.deploymentPlatform === 'appcenter' || answers.useCodePush, + }, + { + type: 'input', + name: 'iosAppCenterId', + message: + 'The iOS project id on AppCenter for this environment, should be different than Android and not contain spaces', + default: answers => + `${answers.appName.replace(/ /g, '')}-ios-${answers.environmentName}`, + when: answers => + answers.deploymentPlatform === 'appcenter' || answers.useCodePush, + }, + { + type: 'input', + name: 'androidAppCenterId', + message: + 'The Android project id on AppCenter, should be different than iOS and not contain spaces', + default: answers => + `${answers.appName.replace(/ /g, '')}-android-${ + answers.environmentName + }`, + when: answers => + answers.deploymentPlatform === 'appcenter' || answers.useCodePush, + }, { type: 'input', name: 'iosCodePushDeploymentKey', diff --git a/generators/fastlane-env/templates/fastlane/env b/generators/fastlane-env/templates/fastlane/env index a08ef66..7792736 100644 --- a/generators/fastlane-env/templates/fastlane/env +++ b/generators/fastlane-env/templates/fastlane/env @@ -13,7 +13,7 @@ IOS_TEAM_ID='<%= appleTeamId %>' IOS_USER_ID='<%= appleId %>' IOS_PLIST_PATH='<%= projectName %>/Info.plist' <% if (deploymentPlatform === 'appstore') { %>IOS_ITC_TEAM_NAME='<%= itunesTeamName %>'<% } %> -<% if (appstore) { %>IOS_APPSTORECONNECT_USER_ID='<%= appstoreConnectAppleId %>'<% } %> +<% if (deploymentPlatform === 'appstore') { %>IOS_APPSTORECONNECT_USER_ID='<%= appstoreConnectAppleId %>'<% } %> ### IOS MATCH ### MATCH_GIT_URL='<%= matchGit %>' @@ -38,7 +38,7 @@ GRADLE_APP_NAME='<%= appName %>' <% if (deploymentPlatform === 'appcenter') { %>ANDROID_APPCENTER_APP_ID='<%= androidAppCenterId %>'<% } %> GRADLE_KEYSTORE='<%= lowerCaseProjectName %>.<%= environmentName %>.keystore' GRADLE_KEYSTORE_ALIAS='<%= lowerCaseProjectName %>' -<% if (appstore) { %>ANDROID_PLAYSTORE_JSON_KEY_PATH='<%= androidPlayStoreJsonKeyPath %>'<% } %> +<% if (deploymentPlatform === 'appstore') { %>ANDROID_PLAYSTORE_JSON_KEY_PATH='<%= androidPlayStoreJsonKeyPath %>'<% } %> ### CODEPUSH ### <% if (useCodePush) { %>IOS_CODEPUSH_DEPLOYMENT_NAME='<%= iosCodePushDeploymentName %>'<% } %> diff --git a/generators/fastlane-setup/index.js b/generators/fastlane-setup/index.js index dd45fb4..18518d3 100755 --- a/generators/fastlane-setup/index.js +++ b/generators/fastlane-setup/index.js @@ -37,10 +37,15 @@ class FastlaneGenerator extends Base { ]).then(answers => { this.answers = answers; this.answers.lowerCaseProjectName = answers.projectName.toLowerCase(); + if (answers.useSecretsArchive && answers.useDeploymentScript) { + this.config.set('circleci-ready', true); + this.config.save(); + } }); } writing() { + const config = this.fs.readJSON(this.destinationPath('package.json')); this.fs.copyTpl( this.templatePath('fastlane/*'), this.destinationPath('fastlane') @@ -73,6 +78,8 @@ class FastlaneGenerator extends Base { this.destinationPath(`secrets-scripts/unpack-secrets.sh`), this.answers ); + config.scripts['pack-secrets'] = './secrets-scripts/pack-secrets.sh'; + config.scripts['unpack-secrets'] = './secrets-scripts/unpack-secrets.sh'; } if (this.answers.useDeploymentScript) { @@ -80,8 +87,11 @@ class FastlaneGenerator extends Base { this.templatePath('deploy.sh'), this.destinationPath('deploy.sh') ); + config.scripts.deploy = './deploy.sh'; } + this.fs.writeJSON(this.destinationPath('package.json'), config, 'utf-8'); + this._extendGitignore(); this._extendGradle(); this._activateManualSigning(); diff --git a/generators/fastlane-setup/templates/deploy.sh b/generators/fastlane-setup/templates/deploy.sh index e46c6c4..860811e 100755 --- a/generators/fastlane-setup/templates/deploy.sh +++ b/generators/fastlane-setup/templates/deploy.sh @@ -9,7 +9,7 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NO_COLOR='\033[0m' -APP_ENV="test" +APP_ENV="staging" APP_OS="ios and android" DEPLOY_TYPE="soft" @@ -30,9 +30,9 @@ warn(){ check_environment(){ CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` - if [ "$CURRENT_BRANCH" != "$APP_ENV" ] + if [ "$CURRENT_BRANCH" != "$REPO_GIT_BRANCH" ] then - warn "Wrong branch, checkout $APP_ENV to deploy to $APP_ENV." + warn "Wrong branch, checkout $REPO_GIT_BRANCH to deploy to $APP_ENV." else success "Deploying to $APP_ENV." fi @@ -56,12 +56,16 @@ done [[ -z $(git status -s) ]] || warn 'Please make sure you deploy with no changes or untracked files. You can run *git stash --include-untracked*.' +source fastlane/.env.$APP_ENV + check_environment $APP_ENV if [ $DEPLOY_TYPE == "hard" ]; then echo -e "${BLUE}* * * * *" echo -e "👷 Hard-Deploy" echo -e "* * * * *${NO_COLOR}" + bundle exec fastlane set_build_numbers_to_current_timestamp + if [[ $APP_OS != "android" ]]; then echo -e "${GREEN}- - - - -" echo -e "Fastlane 🍎 iOS $APP_ENV" diff --git a/generators/fastlane-setup/templates/fastlane/Fastfile b/generators/fastlane-setup/templates/fastlane/Fastfile index 7556df2..d750298 100644 --- a/generators/fastlane-setup/templates/fastlane/Fastfile +++ b/generators/fastlane-setup/templates/fastlane/Fastfile @@ -13,6 +13,14 @@ lane :check_git_status do |options| end +lane :set_build_numbers_to_current_timestamp do |options| + incremented_build_number = Time.now.to_i.to_s + `sed -i -e "s#.*IOS_VERSION_BUILD_NUMBER=.*#IOS_VERSION_BUILD_NUMBER='#{incremented_build_number}'#g" .env` + `sed -i -e "s#.*ANDROID_VERSION_CODE=.*#ANDROID_VERSION_CODE='#{incremented_build_number}'#g" .env` + ENV['IOS_VERSION_BUILD_NUMBER'] = incremented_build_number + ENV['ANDROID_VERSION_CODE'] = incremented_build_number +end + # JS Environments lane :set_js_env do |options| diff --git a/generators/fastlane-setup/templates/gitignore b/generators/fastlane-setup/templates/gitignore index 6015ea1..90be3dc 100644 --- a/generators/fastlane-setup/templates/gitignore +++ b/generators/fastlane-setup/templates/gitignore @@ -4,3 +4,5 @@ dist fastlane/report.xml fastlane/.env.*.secret *.back +.bundle +vendor/bundle diff --git a/package.json b/package.json index 04e7106..0cea4c0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "circleci", "bitrise", "travisci", + "circleci", "ci", "wallabyjs", "visual studio code",