Yosemite + CocoaPods + Jenkins -> TestFlight + Slack

みんな大好き自動化。
みんな大好きJenkinsおじさん。
CocoaPodsを利用していると、Xcode Botでのビルドが面倒だったので、
Jenkinsを利用して自動化を進めてみました。


大まかな流れとしては、
1. Homebrewをインストール
2. Jenkinsインストール&セットアップ
3. shenzhenをインストール
4. shell scriptを記述
5. Jenkins上でジョブを作成
という感じです。

Jenkinsにジョブを作成することでリポジトリの監視を行うようにし、 実際のipaファイルのビルド、TestFlightへの配布等については4.で実装することになります。
4.についてはJenkinsの"シェルの実行"に直に記述しても良いですが、
僕はshファイルを作成し、プロジェクトで管理するようにしています。


1, 2についてはWeb上に割と情報で転がっていますので割愛します。
1.については以下のリンクが、
MacにHomebrewをインストールする - Qiita

2.については以下のリンクが参考になると思います。
OS XにJenkinsをHomebrewでセットアップする - Qiita


次に 3. shenzhenのインストール。
shenzhenは、ipaファイルの生成、配布を簡単にするためのgemで、
今回はこの一部の機能であるipaコマンドを利用します。
shenzhenを利用しなくてもxcodebuildを使う手もありますが、
パラメータ周りの設定が面倒であること、
Yosemite以降、archiveする際に --sign optionがdeprecateとなっており、
これを実行しようとしても
Warning: usage of --preserve-metadata with option "resource-rules" (deprecated in Mac OS X >= 10.10)!
というエラーが発生してしまうため、今回はshenzhenを利用することにしました。
shenzhenのインストールは以下の通りです。

gem install shenzhen


以上の3ステップにおいて、ipaファイルの生成自動化に必要なものは全てインストールできたので、
4.shell scriptを記述、では以下の処理を実際に記述していきます。
- 各ビルドを格納するためのディレクトリを作成
- CocoaPodsの更新
- pod install
- ipaファイルの生成
- TestFlight用のRelease noteをdevelopブランチのコミットログから生成
- ipaをTestFlight経由で配布、配布完了した場合、Slackに通知
- ipaファイルやPodfile.lock、TestFlightのRelease noteのtxtファイルを格納用のディレクトリに格納

僕はプロジェクトごとに以下のようなshellファイルを作成し、プロジェクト内で管理しています。

# Description:
#   build ipa file and distribute it via testflight. Processing Details are as below. 
#
#     1. create Archive directory if it doesn't exist.
#     2. remove Pods directory
#     3. update cocoapods local repos and install pods for current directory
#     4. build ipa file
#     5. distribute ipa file via testflight
#     6. copy ipa file, Podfile.lock, and content of testflight release notes  
#
#

export LC_ALL="en_US.UTF-8";


send_success_message_to_slack ()
{
  send_message_to_slack "Jenkins" "$1" ":jenkins:"
}

send_failure_message_to_slack ()
{
  send_message_to_slack "Jenkins Oops" "$1" ":jenkins_oops:"
}

send_message_to_slack ()
{
  local payload={\"channel\”:\”#<CHANNEL_NAME>\”\,\”username\":\"$1\"\,\"text\":\"$2\"\,\"icon_emoji\":\"$3\"}
  curl -X POST --data-urlencode "payload=${payload}" <SLACK_INCOMING_WEBHOOKS_URL>
}

fail ()
{
  send_failure_message_to_slack "$1"
  exit 1
}

#variables which jenkins allow us to use
current_workspace=${WORKSPACE}
build_id=${BUILD_ID}
build_number=${BUILD_NUMBER}

#create Archive directory to Archive .ipa file, Podfile.lock, and so on
if [ ! -d "${current_workspace}/Archive" ] ; then
  mkdir "${current_workspace}/Archive"
fi


cd <PROJECT_NAME>


#remove Pods directory
rm -rf Pods

#update cocoapods local repos and install pods for current project.
pod repo update
pod install
pod update


#increase bundle version using plistBuddy
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${build_number}" “<PROJECT_NAME>/Info.plist"

#build .ipa file using ipa gem
ipa build -s <SCHEME_NAME> -c <CONFIGURATION_NAME>


#generate release not for TestFlight from git commit log
github_pr_url="https://github.com/<TEAM_NAME>/<APP_NAME>/pull/"
pr_number=$(perl -e '`git log --format=%s -n 1`=~/(merge)( +)(pull)( +)(request)( +)(\#)([^ .*])/i; print $8')
commit_hash=$(git rev-parse HEAD)
testflight_note="pull-request:${github_pr_url}${pr_number}\nhash-value:${commit_hash}"
echo $testflight_note > testflight_note.txt


#upload ipa binary to testflight
testflight_api_token=“<TESTFLIGHT_API_TOKEN>”
testflight_team_token=“<TESTFLIGHT_TEAM_TOKEN>”
testflight_distribution_list_name=“<TESTFLIGHT_DISTRIBUTION_LIST_NAME>”
if [ -e “<IPA_FILE_NAME>” ]; then
  ipa distribute:testflight -a ${testflight_api_token} -T ${testflight_team_token} -m ${testflight_note} -l ${testflight_distribution_list_name} --notify
  send_success_message_to_slack "I distributed ipa via testflight."
else  
  fail "I could not distribute ipa file, because ipa file does not exist." 
fi


#copy current .ipa file, Podfile.lock and testflight_note.txt(it is backup of testflight note.)
mkdir "${current_workspace}/Archive/${build_id}"
if [ ! -d "${current_workspace}/Archive/${build_id}" ] ; then
  fail "I failed to create current build archive directory."
fi

mv <IPA_NAME> "${current_workspace}/Archive/${build_id}/<IPA_NAME>"
cp Podfile.lock "${current_workspace}/Archive/${build_id}/Podfile.lock"
mv testflight_note.txt "${current_workspace}/Archive/${build_id}/testflight_note.txt"

if [ ! -e "${current_workspace}/Archive/${build_id}/<IPA_NAME>" -o ! -e "${current_workspace}/Archive/${build_id}/Podfile.lock" -o ! -e "${current_workspace}/Archive/${build_id}/testflight_note.txt" ] ; then 
  fail "I failed to archive some of ipa file, Podfile.lock and testflight_note.txt"
fi

以上で、Jenkinsを運用する準備が整いましたので、
5.Jenkins上でジョブを作成 、を行います。

まず、"フリースタイル・プロジェクトのビルド"でジョブを作成します。

f:id:nerd0geek1:20141124182539p:plain

リポジトリ、ビルドするブランチ名については好きなものを選択します。

ビルドトリガについては、"SCMをポーリング"を選択し、パラメータについては"H/60 * * * *"とし、 ビルド>シェルの実行については、4.で作成したシェルファイルを指定します。

f:id:nerd0geek1:20141124183426p:plain

これでJenkinsが60分ごとにリポジトリを監視し、更新されていた場合は自動的にTestFlightを飛ばすようにできました。 なお、ビルドトリガについては、"Build when a change is pushed to GitHub"というものもありますが、 これについてはCI用のマシンをどこからでもアクセス可能にする必要がありそうでしたので、 より簡単な"SCMをポーリング"で設定しました。

ここまででiOSアプリのCIを始められるようにはなったと思います。 それでは良いCIライフを!