As Ash said earlier we like using Continuous Integration. Today I spent a large amount of time migrating us to use the new CocoaPods caching system in Travis CI. To make up for my lost time I'm passing on what I've learned and also showing how we do CI at Artsy with Objective-C apps. If you're interested in how we do it in Swift, you can just check Eidolon.

First and foremost, this only works if you are paying for Travis CI.

Travis CI recently merged in support for Caching of CocoaPods - this is great! By using this, we've reduced our build times from an average of about 10 minutes, to about 7 minutes. It works by using your Podfile.lock as a key to cache your Pods directory, if the lock hasn't changed then there's no need to update the Cache and so pod install is not called on your project. This caused me an issue as the [Project].xcworkspace file that CocoaPods generates was not in source control, and the app wouldn't build. Useful note, if you're using development pods in your build you probably shouldn't use this as your Pods directory can get out of sync with the cached version.

We use a Makefile to separate the tasks required to build, test and deploy an app. The general structure of our Makefile is:

Action Reason
Constants A collection of constants that get resued by different make tasks.
CI Tasks Separate commands necessary for running Xcode projects from the terminal.
Actions Commands that manipulate your project state, or maintainance commands.
Deployment Commands to get your app ready for the App Store, or Hockey.

If you don't know the syntax for Make, essentially if it's on the same line you're either setting constants or calling other make commands. If it's on a separate line then you are running a shell command.

This is the Artsy Folio Makefile in full:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# Constants

WORKSPACE = Artsy Folio.xcworkspace
XCPROJECT = Artsy\ Folio.xcodeproj
SCHEME = ArtsyFolio
CONFIGURATION = Beta
APP_PLIST = Info.plist
PLIST_BUDDY = /usr/libexec/PlistBuddy
TARGETED_DEVICE_FAMILY = \"1,2\"

BUNDLE_VERSION = $(shell $(PLIST_BUDDY) -c "Print CFBundleVersion" $(APP_PLIST))
GIT_COMMIT = $(shell git log -n1 --format='%h')
ALPHA_VERSION = $(BUNDLE_VERSION)-$(BUILD_NUMBER)-$(GIT_COMMIT)

GIT_COMMIT_REV = $(shell git log -n1 --format='%h')
GIT_COMMIT_SHA = $(shell git log -n1 --format='%H')
GIT_REMOTE_ORIGIN_URL = $(shell git config --get remote.origin.url)

DATE_MONTH = $(shell date "+%e %h")
DATE_VERSION = $(shell date "+%Y.%m.%d")

CHANGELOG = CHANGELOG.md
CHANGELOG_SHORT = CHANGELOG_SHORT.md

IPA = ArtsyFolio.ipa
DSYM = ArtsyFolio.app.dSYM.zip

# Phony tasks are tasks that could potentially have a file with the same name in the current folder
.PHONY: build clean test ci

# CI Tasks

ci: CONFIGURATION = Debug
ci: pods build

build:
  set -o pipefail && xcodebuild -workspace "$(WORKSPACE)" -scheme "$(SCHEME)" -sdk iphonesimulator -destination 'name=iPad Retina' build | xcpretty -c

clean:
  xctool -workspace "$(WORKSPACE)" -scheme "$(SCHEME)" -configuration "$(CONFIGURATION)" clean

test:
  set -o pipefail && xcodebuild -workspace "$(WORKSPACE)" -scheme "$(SCHEME)" -configuration Debug test -sdk iphonesimulator -destination 'name=iPad Retina' | second_curtain | xcpretty -c --test

lint:
  bundle exec fui --path Classes find

  bundle exec obcd --path Classes find HeaderStyle
  bundle exec obcd --path "ArtsyFolio Tests" find HeaderStyle

# Actions

ipa:
  $(PLIST_BUDDY) -c "Set CFBundleDisplayName $(BUNDLE_NAME)" $(APP_PLIST)
  $(PLIST_BUDDY) -c "Set CFBundleVersion $(DATE_VERSION)" $(APP_PLIST)
  ipa build --scheme $(SCHEME) --configuration $(CONFIGURATION) -t

alpha_version:
  $(PLIST_BUDDY) -c "Set CFBundleVersion $(ALPHA_VERSION)" $(APP_PLIST)

change_version_to_date:
  $(PLIST_BUDDY) -c "Set CFBundleVersion $(DATE_VERSION)" $(APP_PLIST)

set_git_properties:
  $(PLIST_BUDDY) -c "Set GITCommitRev $(GIT_COMMIT_REV)" $(APP_PLIST)
  $(PLIST_BUDDY) -c "Set GITCommitSha $(GIT_COMMIT_SHA)" $(APP_PLIST)
  $(PLIST_BUDDY) -c "Set GITRemoteOriginURL $(GIT_REMOTE_ORIGIN_URL)" $(APP_PLIST)

pods: remove_debug_pods
pods:
  rm -rf Pods
  bundle install
  bundle exec pod install

remove_debug_pods:
  perl -pi -w -e "s{pod 'Reveal-iOS-SDK'}{}g" Podfile

update_bundle_version:
  @printf 'What is the new human-readable release version? '; \
      read HUMAN_VERSION; \
      $(PLIST_BUDDY) -c "Set CFBundleShortVersionString $$HUMAN_VERSION" $(APP_PLIST)

mogenerate:
  @printf 'What is the new Core Data version? '; \
      read CORE_DATA_VERSION; \
      mogenerator -m "Resources/CoreData/ArtsyPartner.xcdatamodeld/ArtsyFolio v$$CORE_DATA_VERSION.xcdatamodel/" --base-class ARManagedObject --template-path config/mogenerator/artsy --machine-dir Classes/Models/Generated/ --human-dir /tmp/ --template-var arc=true

# Deployment

deploy: ipa distribute

alpha: BUNDLE_NAME = 'Folio α'
alpha: NOTIFY = 0
alpha: alpha_version deploy

appstore: BUNDLE_NAME = 'Artsy Folio'
appstore: TARGETED_DEVICE_FAMILY = 2
appstore: remove_debug_pods update_bundle_version set_git_properties change_version_to_date

next: TARGETED_DEVICE_FAMILY = \"1,2\"
next: update_bundle_version set_git_properties change_version_to_date

distribute:
  cat $(CHANGELOG) | head -n 50 | awk '{ print } END { print "..." }' > $(CHANGELOG_SHORT)
  curl \
   -F status=2 \
   -F notify=$(NOTIFY) \
   -F "notes=<$(CHANGELOG_SHORT)" \
   -F notes_type=1 \
   -F ipa=@$(IPA) \
   -F dsym=@$(DSYM) \
   -H 'X-HockeyAppToken: $(HOCKEYAPP_TOKEN)' \
   https://rink.hockeyapp.net/api/2/apps/upload \
   | grep -v "errors"

That gives you a sense of the commands that you can run from the terminal in our projects, next we need to look at the .travis.yml file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
language: objective-c
cache:
  - bundler
  - cocoapods

env:
  - UPLOAD_IOS_SNAPSHOT_BUCKET_NAME=eigen-ci UPLOAD_IOS_SNAPSHOT_BUCKET_PR...

before_install:
  - 'echo ''gem: --no-ri --no-rdoc'' > ~/.gemrc'
  - cp .netrc ~
  - chmod 600 .netrc
  - pod repo add artsy https://github.com/artsy/Specs.git

before_script:
  - gem install second_curtain
  - make ci

script:
  - make test
  - make lint

This is nice and simple. It was built to use multiple travis build steps. This makes the CI output a lot more readable as an end user. Travis will by default collapse the shell output for different build stages leaving only the script stage defaulting to being exposed. Here is an example of what you see on a failing test:

Travis CI Failure

We use a gem with a binary in second_curtain, and this came with bundler caching issues in Travis. The solution was to ignore bundler and run gem install second_curtain each time. To increase the speed we also ensured that documentation is not being generated. If you are interested in what's going on with the .netrc, read my blog post on Artsy's first Closed Source Pod.

We will continue pushing the state of the art in iOS deployment, in building our own tools and using everything available to increase developer happiness. If you're into this we're always looking to hire people with a good open source track record or street smarts. Here's the jobs page.

Categories: continuous integration, ios, testing, travis