How We Automate Our iOS Workflow at Instabug Using CircleCI

We believe in automating repetitive tasks to increase our productivity and keep things moving fast. Since we build an SDK, this means that most tools created for automating tasks around development and deployment of iOS apps don’t work for us, so we usually need to develop our own bespoke solutions.

This post goes through what our workflow is and how we automate it using CircleCI.

Overview

With CircleCI 2.0, we can create multiple jobs under one workflow, with the ability to define dependencies between jobs and run some of them in parallel, which is great.

workflows:
  version: 2
  build-test-and-generate-binary:
    jobs:
      - unit-tests
      - uitests-analyzer:
          filters:
            branches:
              only: 
                - master
                - /fix\/.*/
                - /release\/.*/
                - /Release\/.*/
      - hold:
          type: approval
          requires:
            - unit-tests
            - uitests-analyzer
      - generate-binary:
          requires:
            - hold
      - release:
          requires:
            - generate-binary
          filters:
            branches:
              only: 
                - master

Our workflow has 4 jobs:

  1. Running unit tests, integration tests and Danger
  2. Running UI tests and static analyzer
  3. Generating a binary
  4. Releasing the SDK

Let’s go through each one in details.

1. Running Unit Tests, Integration Tests and Danger

unit-tests:
  macos:
    xcode: "9.3.1"
  working_directory: /Users/distiller/project
  environment:
    BUNDLE_PATH: vendor/bundle  # path to install gems and use for caching
    ARTIFACTS_DIRECTORY: /Users/distiller/project/artifacts
  steps:
    - checkout
    - restore_cache:
        keys:
        - gems-
        # Fall back to using the latest cache if no exact match is found.
        - v1-gems-
    - run: 
        name: Create artifacts directory
        command: mkdir $ARTIFACTS_DIRECTORY
    # Install gems.
    - run:
        name: Bundle install
        command: bundle check || bundle install
        environment:
          BUNDLE_JOBS: 4
          BUNDLE_RETRY: 3
    - save_cache:
        key: gems-
        paths:
          - vendor/bundle
    - run:
        name: Pre-start simulator
        command: xcrun instruments -w "iPhone 8 (11.3) [" || true
    - run:
        name: Run Instabug unit tests
        command: set -o pipefail && xcodebuild -workspace Instabug/Instabug.xcworkspace -scheme AllUnitTests -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=11.3,name=iPhone 8' -enableCodeCoverage YES test | xcpretty --color --report junit --output $ARTIFACTS_DIRECTORY/AllTests_unittest_results.xml
    - run:
        name: Run Danger
        command: bundle exec Danger
    - run: 
        name: Run integration tests
        command: set -o pipefail && xcodebuild -workspace Instabug/Instabug.xcworkspace -scheme InstabugIntegrationTests -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=11.3,name=iPhone 8' -enableCodeCoverage YES test | xcpretty --color --report junit --output $ARTIFACTS_DIRECTORY/InstabugIntegrationTests_unittest_results.xml

First, we install all gems used by our project, like Danger and fastlane either by restoring the cache of a previous install, or by doing a fresh install.

We then run our unit tests. Due to how our project is structured, we have around 15 framework targets, with a separate test target for each. To run all our unit tests at once, we have an AllUnitTests scheme that runs all test targets.

After running our unit tests, we run Danger, which is a great tool to automate some of the chores around code reviews. Here’s what we use it for right now.

  1. Enforce having a description and a link to a Jira issue for all pull requests
  2. Ensure that all pull requests that add new user-facing strings also add the localized versions of those strings in all supported languages.
  3. Run xcov to generate a report about our tests coverage, and post it as a comment on the pull request
  4. Ensure that all pull requests that modify any UI file, run UI tests before merging the pull request.
has_ui_changes = !git.modified_files.grep(/View Controllers/).empty? || !git.modified_files.grep(/Views/).empty?

if !ENV['RUN_UI_TESTS'] && has_ui_changes
	fail("UI has been changed but UI tests were not run. Please make sure to run them before merging the PR.")
end

# Make sure PR has a description.
if github.pr_body.length < 3 && git.lines_of_code > 10
	fail "Please provide a summary of the changes in the Pull Request description."
end

# Check if PR title has reference to a Jira issue.
if !github.pr_title[/\[[a-zA-Z]*-[0-9]*\]/]
	fail("Pull request should include Jira card number in the name. For example: [IBGProj-123]")
end

The last step of this job is to run our integration tests, which is just a test target with a similar configuration to our unit test targets.

This job runs on every pull request, so we have to make sure it contains all the essential checks/tests, and that it also runs in a reasonable time. It currently runs in around 8 minutes, with the majority of the time going to making a clean build of the SDK for running tests.

Running UI Tests and Static Analyzer

uitests-analyzer:
  macos:
    xcode: "9.3.1"
  working_directory: /Users/distiller/project
  environment:
    BUNDLE_PATH: vendor/bundle  # path to install gems and use for caching
    ARTIFACTS_DIRECTORY: /Users/distiller/project/artifacts
  steps:
    - checkout
    - restore_cache:
        keys:
        - gems-
        # Fall back to using the latest cache if no exact match is found.
        - v1-gems-
    - run: 
        name: Create artifacts directory
        command: mkdir $ARTIFACTS_DIRECTORY
    # Install gems.
    - run:
        name: Bundle install
        command: bundle check || bundle install
        environment:
          BUNDLE_JOBS: 4
          BUNDLE_RETRY: 3
    - run:
        name: Pre-start simulator
        command: xcrun instruments -w "iPhone 8 (11.3) [" || true
    - run:
        name: Run UI tests
        command: |
          if [[ -n "${RUN_UI_TESTS}" || $CIRCLE_BRANCH = 'master' ]]; then
            set -o pipefail && xcodebuild -workspace Instabug/Instabug.xcworkspace -scheme InstabugDemoUITests -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=11.3,name=iPhone 8' test | xcpretty --color --report junit --output $ARTIFACTS_DIRECTORY/xcode/uitest_results.xml
          else
            echo 'Skipping running UI Tests.'
          fi
        no_output_timeout: 15m

    - run:
        name: Run static analyzer on other frameworks
        command: sh ./Scripts/analyze_Instabug.sh

This job is very similar to what we do in the first job, but it instead runs a UI tests target and the static analyzer using xcodebuild analyze.

This job only runs on master, and fix/release branches, so we’re okay with it taking a bit longer to run. It currently finishes in around 40 minutes and runs in parallel with the first job. It can also be run on demand on any pull request regardless of its branch by mentioning a simple GitHub bot we wrote that uses the CircleCI API to trigger a specific job.

Generating a Binary

generate-binary: 
  macos:
    xcode: "9.3.1"
  working_directory: /Users/distiller/project
  environment:
    BUNDLE_PATH: vendor/bundle  # path to install gems and use for caching
    ARTIFACTS_DIRECTORY: /Users/distiller/project/artifacts
  steps:
    - checkout
    - restore_cache:
        keys:
        - gems-
        # Fall back to using the latest cache if no exact match is found.
        - v1-gems-
    - run: 
        name: Create artifacts directory
        command: mkdir $ARTIFACTS_DIRECTORY
    # Install gems.
    - run:
        name: Bundle install
        command: bundle check || bundle install
        environment:
          BUNDLE_JOBS: 4
          BUNDLE_RETRY: 3
    - run:
        name: Install signing identity
        command: |
          bundle exec fastlane setup_signing
    - run:
        name: Increment version number
        command: |
          ./Scripts/IncrementSDKVersion.swift
          /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $CIRCLE_BUILD_NUM" "Instabug/InstabugI/Info.plist"
    - run:
        name: Generate fat binary for Instabug static
        command: |
          xcodebuild -workspace Instabug/Instabug.xcworkspace -scheme Framework -sdk iphoneos -destination generic/platform=iOS clean build | xcpretty
    - run:
        name: Link Instabug static with dynamic project
        command: |
          ruby ./Instabug-dynamic/linkInstabug.rb
    - run:
        name: Generate fat binary for Instabug dynamic
        command: |
          xcodebuild -project Instabug-dynamic/Instabug.xcodeproj -scheme Framework -sdk iphoneos -destination generic/platform=iOS clean archive | xcpretty
    - run:
        name: Generate appledoc
        command: |
          sh ./Scripts/generate_appledoc.sh
    - run:
        name: Create framework archive
        command: |
          find ./Instabug/Instabug-SDK-Static -path '*/.*' -prune -o -type f -print | zip $ARTIFACTS_DIRECTORY/Instabug-static.zip -@
          find ./Instabug-dynamic/Instabug-SDK -path '*/.*' -prune -o -type f -print | zip $ARTIFACTS_DIRECTORY/Instabug.zip -@
          find ./Instabug-Docs -path '*/.*' -prune -o -type f -print | zip $ARTIFACTS_DIRECTORY/appledoc.zip -@
    - run:
        name: Test fat binaries are not corrupted
        command: |
          xcodebuild -project InstabugProductionDemo/InstabugProductionDemo.xcodeproj -scheme InstabugProductionDemoUITests -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=11.3,name=iPhone 8' test | xcpretty
    - store_artifacts:
        path: artifacts
    - persist_to_workspace:
        root: .
        paths:
          - .

After the first two jobs have finished, we can generate a binary of the SDK from any branch. This requires the approval of a hold from the CircleCI dashboard.

This job will install our code signing identity, which we share across the team using fastlane . It then generates both static and dynamic variants of our framework. For more on building binary framework, check our previous blog post.

It then bundles up both frameworks in a zip archive and stores it in the artifacts directory to be available for download.

Releasing the SDK

release:
  macos:
    xcode: "9.3.1"
  working_directory: /Users/distiller/project
  environment:
    ARTIFACTS_DIRECTORY: /Users/distiller/project/artifacts
  steps:
    - attach_workspace:
        at: /Users/distiller/project
    - run:
        name: Release
        command: ./release

The last job releases the SDK to the public. It runs Unleash, our homegrown CLI app for releasing the SDK.

Unleash does the following:

  1. Create and push a new tag to our private repository
  2. Push the updated framework to https://github.com/Instabug/Instabug-iOS/ and create a GitHub release
  3. Publish framework to CocoaPods
  4. Update our Carthage JSON file
  5. Upload the release to our own API for consumption on the Instabug dashboard and website.

Conclusion

We’re pretty happy with the workflow we have so far. Having a reliable CI process makes us ship with confidence, and automating our release process saves a ton of time since we release once a week.

CircleCI has been a great tool to use for our continuous integration, specially with CircleCI 2.0.