こんにちは、kyoです。
今回はたらいくのアプリ開発チームで、jenkins,fastlane,slackなどを利用して便利なCI環境を構築できましたのでその内容を共有します。

CIでできること

ネット上検索すると、fastlaneを使ってiOSアプリをビルドし、それを定常実行するようにjenkinsやTravis, CircleCIに連携する事例は結構あると思います。

しかし今回構築したものはその一般的にな構成よりさらに機能的になっています:

  • jenkinsのmultibranch pipeline機能を利用して、job自体の設定(秘伝のタレ)は殆どなく、ci as codeを実現します
  • Github Organizationの機能によって、jenkinsfileが入ってるレポジトリは自動的にjenkins上の対応jobを作成してくれます
  • 社内のGithub Enterpriseのwebhookに対応し、push/prの通知を受けた時にjobを走る、無駄なpolling処理はなくなります
  • PRのstatus check対応、PR出した時点でテストを実行し、結果をGithubに反映され、レビューの一環として利用します
  • fastlaneのmatch機能で全ての証明書とプロビジョニングプロファイルを一元管理します(普通matchは既存ものを対応できないですが、今回は少しトリッキーの方法で既存の本番用にも対応しました)
  • release用ブランチへの変更は自動的にbuildしてテスト配信用のdeploygate環境にアップロードします
  • slackのコマンド一発で最新バージョンをappstoreにアップロード、審査提出までの作業は全て自動化します
  • appstoreのアプリメタデータ(アプリの説明、プラポリリンク、リリースノートなど)はソースとして管理、変更したものはfastlaneから自動てきにappstoreに反映します

システム構成

jenkins1

iOSビルドのため、ビルドマシンはmac miniを用意し、社内のネットワーク環境に置いてますが、ソース管理用のGithub EnterpriseはAWS上にあるため、直接社内にあるmac miniにhookを送れないです。なので今回はJenkinsのmasterとslaveを分けて構築しました。

  1. MasterはAWS上のEC2で構築、Amazonlinuxをベースにし、jenkinsと他必須ライブラリだけをインスタンスした状態にします。
  2. jenkinsサーバのIPをEIPに設定し、Route53でDNSを振ります。
  3. Github Enterpriseのorganizaion webhookにjenkinsのURLを設定します。権限はrepository, push, pull_requestに設定します。
  4. 社内に置いてるmac miniをjenkins slaveとして、slave用のjava appletを起動してmasterに接続します。(firewallのポート制限があるため、接続ポートを8443に設定します)
  5. slaveのnodeにiosというラベルを設定し、jenkinsfileでそのラベルを指定してjobを起動します。
  6. aws上で直接jenkins apiを叩いてjobを走らせるlambdaを作成し、api gatewayをかませます。
  7. slackでslashcommandを作成し、post先URLをapi gatewayにします。

これで構築は完了します。Github EnterpriseからのhookはAWS上のjenkins masterが受信し(もしくはslackからのapiコール)、jenkinsfileに指定に従ってmac miniでビルドを走らせます。

jenkinsfile+fastlaneによるiOS自動ビルド

jenkins2.0からpipelineが結構使いやすくなりました、やはりCIもコードとして管理できるのは楽ですね。
今回のjenkinsfileは以下のような感じ:

pipeline {
  agent { 
    label "ios" 
  }

  parameters {
    string(defaultValue: "test", description: "build type", name: "type")
  }

  stages {
    stage('prepare') {
      steps {
        echo "build type: ${params.type}"
        sh "env"
      }
    }

    stage('test') {
      when {
        expression {
          return params.type == "test"
        }
      }
      steps {
        sh 'fastlane ios test'
      }
    }

    stage('beta') {
      when {
        branch "release/*"
        expression {
          return params.type != "release"
        }
      }
      steps {
        sh 'fastlane ios beta'
      }
    }

    stage('deploy') {
      when {
        branch "release/*"
        expression {
          return params.type == "release"
        }
      }
      steps {
        sh 'fastlane ios release'
      }
    }
  }
}

pipeline model definitionのpipeline syntaxにしたがって、全部宣言型で書いてます。実アクションはfastlaneの方に書きます。
下記のparmetersブロックを書くと、pipelineのjobもパラメータ付きビルドができます。

parameters {
  string(defaultValue: "test", description: "build type", name: "type")
}

これでpush/prによる起動と、slackからのコマンドによる起動を分けることができます。slackからコマンドを発行した場合、パラメータにreleaseやbetaを指定して、test stageを飛ばしてdeploygateやappstoreやipaをアップロードするだけになります。

fastlane自体に関しては公式ドキュメントやQiitaなどで結構情報があるので詳細は省きます。
今回のFastfileはこういう感じです:

fastlane_version "2.14.0"

default_platform :ios

platform :ios do
  before_all do
    ENV["DELIVER_ITMSTRANSPORTER_ADDITIONAL_UPLOAD_PARAMETERS"] = "-t DAV" #firewall経由でappstoreに転送する場合この設定が必要
    ENV["SLACK_URL"] = "https://hooks.slack.com/services/xxxxx"
    cocoapods

  end

  desc "Runs swiftlint check"
  lane :lint do
    swiftlint
  end

  desc "Runs all the tests"
  lane :test do
    match
    scan
    xcov(
      scheme: "HatalikeSwift",
      output_directory: "./output",
      derived_data_path: "./derived_data"
    )
  end

  desc "Build ipa with debug info"
  lane :build do
    debug
    gym
  end

  desc "Submit a new Beta Build to DeployGate"
  desc "This will also make sure the profile is up to date"
  lane :beta do
    match(
      type: "enterprise"
    ) #inhouse用の証明書とプロビジョニングプロファイルを取得
    build
    deploygate(
      api_token: "xxxxxxxx",
      user: "username",
      ipa: "./output/HatalikeSwift.ipa",
      message: "Build from fastlane"
    )
    slack(
      message: "New version has been uploaded to deploygate"
    )
  end

  desc "Deploy a new version to the App Store"
  lane :release do
    match(
      type: "appstore",
      git_branch: "release",
      app_identifier: "本番用のbundle id",
      username: "本番用のapple id",
      readonly: true
    ) #本番用の証明書とプロビジョニングプロファイルを取得
    update_app_identifier(
      xcodeproj: "HatalikeSwift.xcodeproj",
      plist_path: "HatalikeSwift/Info.plist",
      app_identifier: "本番用のbundle id"
    ) #本番リリースの場合はこれでプロジェクトの設定を書き換え
    gym(
      configuration: "Release"
    )
    clear_derived_data(
      derived_data_path: "./derived_data"
    )
    deliver
    slack(
      message: "New version has been uploaded to App Store"
    )
  end

  error do |lane, exception|
    slack(
      message: exception.message,
      success: false
    )
  end

gym,scan,deliverコマンドの詳細オプションは別々の設定ファイルに書きます。

fastlane matchで既存証明書とプロファイルを管理

fastlaneのmatchというツールはiosのcode signingを簡単に管理するツールです。
参考リンク
簡単に解説するとチーム全員が同じ証明書とプロファイルを利用し、それらをプライベートのGithubレポジトリで管理します。証明書とプロファイル自体は暗号化されて、且つレポジトリもプライベートのためセキュリティも十分保てます。

しかしmatchを使うと、fastlaneのドキュメントにも書いた通り:

Getting started with match requires you to revoke your existing certificates.

既存の証明書を全てrevoke(削除)しないと行けない。developmentやstagingのものは問題ないですが、release(本番用)のもの会社のアカウントで作られているため簡単に削除できません(開発チームからも直接新規作れない)。
事例を聞くとそこは諦めて別管理にしたこと多いみたいです。でもいろいろ調べたら、matchと同じ暗号化の手順を踏めば既存のものもマージできるみたいです。
特に参考になったのはこちらの記事です:

http://macoscope.com/blog/simplify-your-life-with-fastlane-match/

今回は既存の本番用証明書とプロファイルをマイグレーションするという例で手順説明します。

  • 最初はmatchのドキュメント通りレポジトリを作成し、fastlane match developmentで開発用のものを作成。この時レポジトリを暗号化するためのpassphraseを設定しますがそれを忘れず覚えておきます。
  • 本番用の証明書がkeychainに入っていることを確認します、なければdeveloper portalからダウンロードしましょう(プロビジョニングプロファイルも)。
  • 需要に応じてfastlane match adhocfastlane match enterpriseでadhocとinhouse用のものも作成します。(enterpriseの利用はfastlane 2.14以降が必要)
  • fastlaneのspaceshipを使って既存の本番用証明書のIDを取得します。下記のrubyコードを実行し、表示されたリストから該当した証明書を探し、IDをメモしておきます。
require 'spaceship'

Spaceship.login('your@apple.id')
Spaceship.select_team

Spaceship.certificate.all.each do |cert| 
  cert_type = Spaceship::Portal::Certificate::CERTIFICATE_TYPE_IDS[cert.type_display_id].to_s.split("::")[-1]
  puts "Cert id: #{cert.id}, name: #{cert.name}, expires: #{cert.expires}, type: #{cert_type}"
end

  • レポジトリにreleaseブランチを作成します。(アカウントが複数の場合、アカウント毎にbranchを切ることが推奨されてます)
  • releaseブランチに先ほど作成した開発用やinhouse用のものは全部削除して、certs/distributionprofiles/appstoreのディレクトリを作成します。
  • keychainから本番用証明書を.cer.p12それぞれ書き出します。ファイル名はcert.cer and cert.p12としましょう。
  • 下記opensslコマンドでp12ファイルからプライベートキーをpem形式に書き出します:
openssl pkcs12 -nocerts -nodes -out key.pem -in cert.p12
  • プライベートキーをmatch用のキーで再度暗号化します:
openssl aes-256-cbc -k [暗号化のpassphrase] -in key.pem -out [メモした証明書ID].p12 -a
openssl aes-256-cbc -k [暗号化のpassphrase] -in cert.cer -out [メモした証明書ID].cer -a
  • プロビジョニングプロファイルに対して同じ操作を行います:
openssl aes-256-cbc -k [暗号化のpassphrase] -in [ダウンロードしたプロファイル] -out AppStore_[本番用のbundle id].mobileprovision.p12 -a
  • 作成した証明証の2つをcerts/distributionに、プロファイルをprofiles/appstoreにコミットしてreleaseブランチにpushします。

これで既存の証明書とプロファイルもmatchで管理できるようになりました。実際使う時(例えば新しい開発メンバーが入って、マシンのセットアップの場合)は下記のコマンドで全部導入できます

sudo gem install fastlane #まずはfastlaneを入れます
cd your_repo #アプリのレポジトリに入る(fastlane/Matchfileが存在することを想定してます)
fastlane match development #開発用の証明書とプロビジョニングプロファイルをダウンロード
fastlane match appstore --git_branch release --app_identifier [本番用bundle id] --username [本番用アカウント名] --readonly true #本番用のものをダウンロード

以上で完了します。証明書とプロファイルはチーム間で共有し、個人用のものは作る必要はありません。検証や更新もfastlaneが自動でやってくれますのでかなり楽になります。

slack botからのリリース

はたらいくアプリチームの開発フローとして、リリース前にrelease/x.x.xのブランチを切って、テスト・修正が終わったらreleaseブランチからapp storeにリリースする。審査が通ったらリリースして、relaseブランチをdevelopとmasterにマージする。
なので実際のリリースのタイミングはpushやmergeではなく、人が決めています。そのためCIに載せるものと別のトリガーが必要ということでslack + lambdaでjenkinsのjobをキックする仕組みを構築しました。
lambdaはまずjenkinsの情報を取得し、現状のjob一覧を取ってきます。job一覧から指定したversionパラメータに合っているreleaseブランチ(パラメータなければ最新のreleaseブランチ)に対してjobを走らせます。

"use strict";
//node.js -v => 4.3.2

process.env.TZ = 'Asia/Tokyo';
const AWS = require('aws-sdk');
const http = require('http');
const querystring = require('querystring');

exports.handler = (event, context, callback) => {
  console.log(event);
  validation(event)
  .then(processEvent)
  .then((job) => {
    callback(null, {
      text : ` \`${decodeURIComponent(job.name)}\` ブランチのリリース作業を開始しました。\nJOB URL: ${job.url}`,
      response_type: "in_channel",
      link_names: 1
    });   
  })
  .catch((err) => {
    callback(null, {text: err});
  });
};

function validation(event) {
  return new Promise((resolve, reject) => {
    if (event.token !== process.env.TOKEN) {
      reject("Invalid request token");
     } else if(event.channel_id !== process.env.CHANNEL) {
       reject("Invalid channel");
    } else {
      resolve(event);
    }
  });
}

function processEvent(event) {
  const command = event.text.split("+");
  const type = command[0];
  const version = command.length > 1 ? command[1] : null;

  switch(type) {
    case "release" :
    case "beta" :
      return releaseApp(type, version);
    default:
      throw "action not support";
  }
}

function releaseApp(type, version) {
  const options = {
    protocol: 'http:',
    host: process.env.JENKINS,
    port: 80,
    method: 'GET',
    path: `${process.env.JOB}/api/json`
  };
  return new Promise((resolve, reject) => {
    http.request(options, resolve)
    .on("error", reject)
    .end();
  })
  .then((response) => {
    return new Promise((resolve, reject) => {
      let responseBody = '';
      response.on('data', (chunk) => {
        responseBody += chunk;
      });

      response.on('end', () => {
        resolve(responseBody);
      });
    });
  })
  .then((responseBody) => {
    const branches = JSON.parse(responseBody).jobs.filter((job) => job.name.startsWith("release"));
    console.log(branches);
    if (branches.length === 0) {
      throw "no release branch";
    }
    let job;
    if(version === null) {
      job = branches[branches.length - 1];
    } else {
      const jobname = encodeURIComponent(`release/${version}`);
      const index = branches.map((branch) => branch.name).indexOf(jobname);
      if (index == -1) {
        throw "no such version";
      }
      job = branches[index];
    }
    console.log(`release from ${job.name}`);

    const postData = querystring.stringify({
      json : `{parameter: [{"name":"type", "value":"${type}"}]}`
    });

    const options = {
      protocol: 'http:',
      host: process.env.JENKINS,
      port: 80,
      method: 'POST',
      path: `${process.env.JOB}/job/${encodeURIComponent(job.name)}/build?delay=0sec`,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData)
      }
    };

    http.request(options).end(postData);
    return job;
  })
  .catch((err) => { throw err; });
}

slackのslash commandからbetaやreleaseのオプションを付けて、depolygateにアップするか、appstoreにアップするかを分けます。

jenkins2

真っ白なjenkinsおじさんがよしなにやってくれますー

終わりに

今回ははたらいくのiOSアプリのCI環境を構築しましたが、次はAndroidアプリにも同じフローに載せってCIで色々やってみます。