Developer's Blog

iOS ビルド環境を Jenkins と Docker と Ansible でコード化する(実際のコード付き)

こんにちは。アプリケーション共同開発部の門多です。

昨年の終わり頃に、 Mac mini 増殖中!iOS アプリのビルドをマスター・スレーブ化して時間を短縮するという pixiv さんの記事がありましたが、フェンリルの共同開発事業アプリ開発でも、2012 年ごろから Jenkins を使ったビルドを行っています。

なるべく多くの人に使ってもらえるように、基本的に制限しない運用を行なっていました。しかし数年経ってみると、OS やビルド環境の変化もありましたし、現在の構成が色々と問題を起こしていることもわかってきました。現在ビルド環境の改善を行っているので、ついでに Keychain 管理の自動化や Xcode 自動インストールなど、これまで経験した色々な問題とその対応方法をまとめました。かなり長い記事になってしまいましたが、実際に使っているプログラムをそのまま掲載していますので、そのうち一部でも、特に iOS アプリのビルドで困っている人の参考になれば良いなと思います。

とても長いのでページ内のリンクを用意しました。

過去の問題点
環境構築の自動化

現行の環境

2012 年から運用している環境は、2017 年 7 月現在、以下のような状況です。最初の頃に作成されたジョブは、ビルド構成毎に 1 つジョブを作っていました。なので整理すればもう少し減るだろうとは思います。

  • プロジェクト数: 475 個
  • Shelved Project数: 384 個
  • プラグイン数: 68 個
  • $JENKINS_HOME 以下の容量: 413 GB
  • マスターノード: macOS
  • スレーブノード数: 4 台

まずは、これまで運用してきて起こった問題と、その対策を洗い出してみました。

プロジェクト数が多くなると launchd から起動できない

最初は、Jenkins のプロセスを macOS の launchd で起動するように設定していました。しかしプロジェクト数が多くなってくると、Jenkins プラグインのロードあたりで突然再起動され、それがずっと繰り返されてしまうような状態になりました。何が起こっているのか詳しく調べていませんが、Jenkins は起動時に、プロジェクトの設定やビルド成果物などを読み込むので、ファイルが増えて遅くなった影響かもしれません。

起動が遅くなる問題について、Jenkins 勉強会で川口さんに良い方法がないか聞いてみたところ、$JENKINS_HOME にはなるべく早い SSD を使うといいとのことでした。しかし Mac mini はそう簡単にはディスクを換装できないので、回避方法としては、launchd を使わないです。launchd を経由しないなら、どれだけプロジェクトがあっても起動途中で再起動されることはありません。

または、マスターがビルドをしないなら macOS である必要はないので、マスターはビルドをしないように制限して、Mac よりハードウェアの選択肢が多い Linux や Windows もありだと思います。

Slave の環境維持は自分で行う必要がある

Jenkins が 1 台だけの場合、ビルドで必要な環境やコマンドを1台に設定すれば問題ないのですが、スレーブを使って分散ビルドする場合は、スレーブにも必要な環境を用意する必要があります。例えば複数スレーブで cocoapods を使いたい場合、スレーブそれぞれに cocoapods を準備しないと使えません。

アプリ共同開発部では、(他にもありますが)以下の問題に対応しました。

コードサインの鍵と証明書の同期

iOSアプリのビルドをするには、ビルドを行うノードの Keychain Access.app に、コード署名をするための鍵と証明書が保存されていて、その鍵がユーザのパスワード入力なく利用可能になっている必要があります。私たちの場合、署名をするための鍵と証明書は、お客さんから頂くことも多いため、Jenkins スレーブノードに必要になったら追加されるような運用となります。登録は以下のような手順です。

  1. p12 ファイルをスレーブマシンにコピーする
  2. p12 ファイルから鍵と証明書を Keychain Access.app に取り込む
  3. 鍵にアクセスするときパスワードを要求しないように変更
  4. スレーブマシンの数だけ繰り返す

わずかな手間ですが、プロジェクト追加のたびに手作業で行うのは面倒なので、El Capitanまでは以下のようなコマンドで動的な追加を行なっていました。

security import file.p12 -k login.keychain -P $password -T /usr/bin/codesign

しかし macOS Sierra から仕様が変わって、ユーザの操作なく取り込んだ鍵にアクセスする場合は、

security wants to use your confidential information stored in …

のようなダイアログが表示されて、必ず最初にユーザの操作が必要になりました。Jenkins スレーブはダイアログに応答できないので、undocumented なコマンドを使ってダイアログが出ないように許可を通しておく必要があります。

# security set-key-partition-listはSierra以降しか存在しない
if security set-key-partition-list --help 2>&1 | grep -q Usage:
then
    security set-key-partition-list -S apple-tool:,apple: -s -k $password login.keychain
fi

ただし、undocumented なコマンドなので、使えなくなる可能性があることは覚悟しておきましょう。

Xcode と Command line tools

iOS アプリのビルドには、必ず Xcode が必要ですが、Xcode はマイナーバージョンの違いで動作が若干異なる場合があります。また、最近はSwiftのバージョンによって、そもそもコンパイルできないこともあります。アプリケーション共同開発部の場合は、一括してバージョンを固定することが難しいので、広くバージョンを維持していますが、Xcode は macOS はのバージョンによって動かなかったりするため、OS のバージョンも複数維持する必要が出てきます。だけど /Library/Documentation/License.lpdf によると、Mac 1台当たりホスト 1 台とゲスト 2 台の合計 3 台までしか macOS をインストールできません。

この問題は、環境変数 $DEVELOPER_DIR を使うとプロセス単位で Xcode を切り替えられるので、必ずプロジェクト側でセットしてからビルドするようにしました。

export DEVELOPER_DIR=/Applications/Xcode8.3.3.app/Contents/Developer

また、Xcode のインストールは xcode-install という Gem がありますけど、これは Apple から直接ダウンロードを行うため少し扱いづらかったので、同じような処理をシェルスクリプトで実装しました。下の方にコードがあります。

Android SDK のバージョン

Android SDK は Gradle plugin のバージョンが 2.2 以上なら、必要なものを自動でダウンロードします。なので事前に、以下の対応をしておけば基本的には困りません。

  1. Android SDK をダウンロード・展開
  2. ライセンスに同意
  3. 環境変数 $ANDROID_HOME の設定

Ruby と CocoaPods など

ビルド前後の処理は、アプリケーション共同開発部の場合はRubyを使うことが多いです。ですが、それぞれのプロジェクトで依存する Ruby のバージョンや、Gem のバージョンが異なるので、どれか 1 つのバージョンを入れれば良いというものでもありませんし、プロジェクトの開始時期によっても異なるので、統一は難しいでしょう。

このため、anyenv 経由で rbenv と bundler を使って、プロジェクト毎に自分で環境構築を行うようにしました。この辺りの詳細は別の記事で紹介されると思います。

異なるバージョンの CoreSimulator が競合する

Xcode 7.2 前後からだったと記憶していますが、

CoreSimulator is attempting to unload a stale CoreSimulatorService job. Detected Xcode.app relocation or CoreSimulatorService version change.

とか

CoreSimulatorService connection interrupted. Resubscribing to notifications.

のようなエラーで CoreSimulator のプロセスが残ったままになってしまって、以後のビルドが全てエラーになってしまう現象がありました。これはとりあえず、異なるバージョンの CoreSimulator を起動しなければ問題なさそうだったので、スレーブあたりの同時ビルド数を 1 に制限することで対応しています。

macOS Sierra に SSH 接続すると System.keychain しか参照できない

Jenkins マスターからスレーブへ接続するときに SSH で接続していますが、macOS Sierra になってから、SSH で接続した後に security list-keychains コマンドを実行すると

$ security list-keychains
    "/Library/Keychains/System.keychain"
    "/Library/Keychains/System.keychain"

のように、System.keychain しか参照していない状態になります。security list-keychains -s で変更を行っても反映されません。

この現象は、パスワード認証で SSH ログインを行なった場合にのみ発生します。公開鍵認証でログインを行えれば、Keychain Access.app は login.keychain を参照しますし変更も可能です。

これらの他に、macOS に Server.app を入れると突然レンボーサークルが出たまま固まるとか、Provisioning Profile が ~/Library/MobileDevice/Provisioning Profiles に残り続けてしまうとか、色々な問題がありましたが、長くなってしまうのでこの記事では書きません。そのうち一部は、今後の記事で紹介されるかもしれません。

問題点を紹介したところで、次に、現在行っている方法を紹介します。


JenkinsマスターはDockerで構築する

マスターはプロジェクトが増えるごとに重くなるので、ハードウェアの選択肢が多い Linux で構築しました。Linux であれば、Jenkins の公式 Docker イメージが利用できて、これは、Groovy を使って初期設定を行えたり、必要なプラグインを追加できたりするので、便利に使わせてもらうことにしました。

まずは Dockerfile です。必要なスクリプト類を追加しているだけですね。

FROM jenkins:2.60.1

ENV SHARE_DIR /usr/share/jenkins/ref

COPY *.groovy* $SHARE_DIR/init.groovy.d/
COPY plugins.txt $SHARE_DIR/
RUN /usr/local/bin/install-plugins.sh `grep -v -e "^\s*#" -e "^\s*$" $SHARE_DIR/plugins.txt`

ADD scriptApproval.xml $JENKINS_HOME/scriptApproval.xml

plugins.txt には、1 行 1 つのプラグインを書きます。コロン(:)で区切ってバージョンを書くことができ、2.0.3 のように特定のバージョンで固定することもできるし、latest で常にその時点の最新バージョンを取得するようにもできます。

以下が実際に使っている plugins.txt です。イメージ構築時にプラグインのインストールを行うだけなので、起動したコンテナでのプラグインのアップデートは、別の方法で行う必要があります。

### suggested plugins
ant:latest
antisamy-markup-formatter:latest
build-timeout:latest
cloudbees-folder:latest
credentials-binding:latest
email-ext:latest
git:latest
github-branch-source:latest
gradle:latest
ldap:latest
mailer:latest
matrix-auth:latest
pam-auth:latest
pipeline-stage-view:latest
ssh-slaves:latest
subversion:latest
timestamper:latest
workflow-aggregator:latest
ws-cleanup:latest

### User needs
active-directory:latest
ansicolor:latest
blueocean:latest
copyartifact:latest
git-client:latest
ssh-agent:latest

Groovy スクリプトは、マスターの /usr/share/jenkins/ref/init.groovy.d/ 以下に入れておくと、最初の起動時に $JENKINS_HOME/init.groovy.d/* へコピーされて、ファイル名の順番に実行されます。2回目以降の起動時には、最初にコピーされた Groovy スクリプトがそのまま実行されます。

Groovy スクリプトは、/usr/share/jenkins/ref/init.groovy.d/ のファイルを更新しても、同じ名前のファイルが $JENKINS_HOME/init.groovy.d/ に存在する場合はコピーされません。これは、 file.groovy.overrideのように .override を付けると、同名のファイルがあっても上書きされるようになります。

10-executors.groovy

マスターはビルドをしないので0にセットします。

import jenkins.model.*
Jenkins.instance.setNumExecutors(0)

15-environ.groovy.override

次に $ANDROID_HOME など環境変数をセットするスクリプトです。これは追加や変更の可能性があるので .override を付けています。

import jenkins.model.*

def props = Jenkins.instance.globalNodeProperties
def env = props.getAll(hudson.slaves.EnvironmentVariablesNodeProperty.class)

def environmentVars = [
    "ANDROID_HOME": "/Groups/jenkins2/pkg/android-sdk",
]

if(env == null || env.size == 0){
    env = new hudson.slaves.EnvironmentVariablesNodeProperty()
    props.add(env)
}
def p = props.get(0).getEnvVars()
environmentVars.each { name, v -> p.put(name, v) }
Jenkins.instance.save()

20-plugins.groovy

プラグインにアップデートがあって互換性があるなら、コンテナ起動時にアップデートしましょう。

import jenkins.model.*

updated = false

def c = Jenkins.instance.updateCenter
c.updateAllSites()
c.updates.each {
  if (it.isCompatibleWithInstalledVersion()) {
    def future = it.deploy(true)
    future.get() // wait
    updated = true
  }
}
if (updated)
  Jenkins.instance.safeRestart()

30-ssh-credentials.groovy.override

スレーブにSSHで接続するため、Jenkins マスターの Credentials に SSH 秘密鍵の登録を行っています。

import jenkins.model.*
import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.common.*
import com.cloudbees.plugins.credentials.domains.*
import com.cloudbees.plugins.credentials.impl.*
import com.cloudbees.jenkins.plugins.sshcredentials.impl.*
import hudson.plugins.sshslaves.*;

def domain = Domain.global()

def exts = Jenkins.instance.getExtensionList("com.cloudbees.plugins.credentials.SystemCredentialsProvider")
def store = exts[0].store

def credentialsId = "ssh_slave_cert"
def username = "SSH接続ユーザ名"
def passphrase = "xxxx"

def privateKey = """
秘密鍵ファイルの内容
"""

def privateKeySource = new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey)
def description = ""

def priveteKey = new BasicSSHUserPrivateKey(
  CredentialsScope.GLOBAL,
  credentialsId,
  username,
  privateKeySource,
  passphrase,
  description)
store.addCredentials(domain, priveteKey)

37-nodes.groovy.override

SSH スレーブを登録するコードです。スレーブが増えたら nodeProperies に追加するような運用ですね。

import jenkins.model.*
import hudson.slaves.*
import hudson.plugins.sshslaves.SSHLauncher
import hudson.plugins.sshslaves.verifiers.*

def env = System.getenv()
def nodeProperties = [
  "slave6": [
    "addr": "Jenkinsスレーブのホスト名またはアドレス",
    "label": "android sierra",
    "env": [
      "GOPATH": "/home/jenkins2/go",
    ],
  ],
]

def defaultProperties = [
  // SSHLauncher"s properties
  "port": 22,
  "credentialsId": "ssh_slave_cert",
  "jvmOptions": "-Djava.awt.headless=true",
  "javaPath": "",
  "prefixStartSlaveCmd": "",
  "suffixStartSlaveCmd": "",
  "launchTimeoutSeconds": null,
  "maxNumRetries": 0,
  "retryWaitTime": 0,
  "sshHostKeyVerificationStrategy": new NonVerifyingKeyVerificationStrategy(),

  // DumbSlave"s properties
  "description": "",
  "remotePath": "/Groups/jenkins/home",
  "numExecutors": "1",
  "mode": hudson.model.Node.Mode.NORMAL,
  "label": "",
  "retentionStrategy": RetentionStrategy.Always.INSTANCE,
  "properties": [],
]

nodeProperties.each { name, p ->
  def existentNode = Jenkins.instance.getNode(name)
  if (existentNode != null)
    Jenkins.instance.removeNode(existentNode)

  def launcher = new SSHLauncher(
    p.addr,
    p.port ?: defaultProperties.port,
    p.credentialsId ?: defaultProperties.credentialsId,
    p.jvmOptions ?: defaultProperties.jvmOptions,
    p.javaPath ?: defaultProperties.javaPath,
    p.prefixStartSlaveCmd ?: defaultProperties.prefixStartSlaveCmd,
    p.suffixStartSlaveCmd ?: defaultProperties.suffixStartSlaveCmd,
    p.launchTimeoutSeconds ?: defaultProperties.launchTimeoutSeconds,
    p.maxNumRetries ?: defaultProperties.maxNumRetries,
    p.retryWaitTime ?: defaultProperties.retryWaitTime,
    p.sshHostKeyVerificationStrategy ?: defaultProperties.sshHostKeyVerificationStrategy)
  def slave = new DumbSlave(
    name,
    p.description ?: defaultProperties.description,
    p.remotePath ?: defaultProperties.remotePath,
    p.numExecutors ?: defaultProperties.numExecutors,
    p.mode ?: defaultProperties.mode,
    p.label ?: defaultProperties.label,
    launcher,
    p.retentionStrategy ?: defaultProperties.retentionStrategy,
    p.properties ?: defaultProperties.properties)
  def envp = new LinkedList()
  p.env.each { key, val ->
    envp.add(new EnvironmentVariablesNodeProperty.Entry(key, val))
  }
  slave.nodeProperties.add(new EnvironmentVariablesNodeProperty(envp))
  Jenkins.instance.addNode(slave)
}

numExecutors で同時ビルド数を1に制限しているのは、CoreSimulator 問題のところでも書いたように、同時に複数の Xcode バージョンが起動しないようにするためです。また、その他には、

  • Keychain Access.app へ Keychain を追加するときに重複する問題を防ぐ
  • Xcode の自動インストールで、同時に同じバージョンをダウンロードすると xip ファイルが壊れるので防ぐ

など、複数実行を考えると手間がかかるので、シンプルにしました。

これらの Groovy スクリプトの他にも、LDAP サーバを追加したり、GitHub Organization Folder設定したりなど、色々とGroovyで構築していますが、長くなるので省略します。

docker-compose.yml

最後に、docker コマンドを直接実行するのは大変だしオプションを忘れがちなので、docker-compose を使っています。

version: "2"
services:
  master:
    build: ./master
    volumes:
      - home_data:/var/jenkins_home
    ports:
      - "8080:8080"
      - "50000:50000"
    environment:
      JENKINS_HOME: /var/jenkins_home
      JAVA_OPTS: -Xms2048m -Xmx2048m -Duser.timezone=Asia/Tokyo
    restart: always
volumes:
  home_data:
    driver: local

Jenkins スレーブは Ansible で構築する

各スレーブ(macOS と Linux)は、Ansible で初期設定を行なっています。
基本的に Ansible で行なっているタスクは、

  • jenkins グループの作成
  • jenkins ユーザの作成
  • Jenkinsマスタから接続する際の authorized_keys 登録
  • anyenv + rbenv のインストール
  • Android SDKのインストール
  • その他必要なコマンドのインストール

です。基本的にはファイルをコピーしているだけなので、少し変わったことをしている部分だけ書きます。

Jenkinsユーザの作成

スレーブの OS は macOS が多いんですが Linux も存在するため、Jenkins ユーザのルートディレクトリやグループは、スレーブごとに異なります。なのでファイルの階層は Ansible Best Practiceに則って、group_vars/macosgroup_vars/linux のファイルにユーザの定義を書いています。以下はmacOSの場合です。

jenkins_users:
  - name: jenkins
    groups: staff,jenkins
    primary_group: staff
    shell: /bin/bash
    password: xxxxx
    keyring: "{{ lookup("file", "authorized_keys") }}"
    jenkins_root: /Groups/jenkins

これを、roles/jenkins/tasks/users.yml で実行させます。

- name: Jenkinsユーザを作成
  user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    group: "{{ item.primary_group }}"
    password: "{{ item.password }}"
    shell: "{{ item.shell }}"
  with_items: "{{ jenkins_users }}"
  become: yes

- name: authorized_keysに公開鍵を登録
  authorized_key: user={{ item.name }} key={{ item.keyring }}
  with_items: "{{ jenkins_users }}"
  become: yes

Android SDK のインストール

ちょっと長くなっていますが、やっていることは zip ファイルを落としてきて、それを特定の場所に展開しているだけです。

- name: Android SDKのダウンロード
  get_url: >
    url="https://dl.google.com/android/repository/tools_r25.2.3-macosx.zip"
    dest="{{ item.jenkins_root }}/cache/tools_r25.2.3-macosx.zip"
    owner="{{ item.name }}"
    group="{{ item.primary_group }}"
  with_items: "{{ jenkins_users }}"
  when: ansible_distribution == "MacOSX"

- name: android-sdkディレクトリを作成
  file: >
    path="{{ item.jenkins_root }}/pkg/android-sdk"
    owner="{{ item.name }}"
    group="{{ item.primary_group }}"
    state=directory
  with_items: "{{ jenkins_users }}"

- name: Android SDKの圧縮ファイルを展開
  command: >
    unzip -d pkg/android-sdk cache/tools_r25.2.3-macosx.zip
    creates="{{ item.jenkins_root }}/pkg/android-sdk/tools"
    chdir="{{ item.jenkins_root }}"
  with_items: "{{ jenkins_users }}"
  when: ansible_distribution == "MacOSX"

- name: Android SDKに適切なパーミッション等を設定
  file: >
    path="{{ item.jenkins_root }}/pkg/android-sdk/tools"
    owner="{{ item.name }}"
    group="{{ item.primary_group }}"
    recurse=yes
  with_items: "{{ jenkins_users }}"

- name: Android SDKのライセンスを置くディレクトリを作成
  file: >
    path="{{ item.jenkins_root }}/pkg/android-sdk/licenses"
    owner="{{ item.name }}"
    group="{{ item.primary_group }}"
    state=directory
  with_items: "{{ jenkins_users }}"

- name: Android SDKライセンスに同意する
  copy: >
    src=android-sdk-license
    dest="{{ item.jenkins_root }}/pkg/android-sdk/licenses/android-sdk-license"
    owner="{{ item.name }}"
    group="{{ item.primary_group }}"
  with_items: "{{ jenkins_users }}"

ライセンスは、上の例だと android-sdk-license しか書いていませんが、実際は

  • android-sdk-license
  • android-sdk-preview-license
  • intel-android-extra-license

の3つを、別のマシンで同意して、生成されたファイルをコピーしています。

独自ツールの導入

この記事の上で説明した問題点を解消するため、社内独自のコマンド類を作成しています。Xcode の自動インストールと、コードサイン関連のコマンドを以下のルールでスレーブへインストールしています。

- name: Xcode 関連ツールのインストール
  copy: src={{ item }} dest="/usr/local/bin/{{ item }}" mode=0755
  with_items:
    - pkcs12add
    - bind-keychain
    - pbzx
    - sudo_pass
    - xcodew
  when: ansible_os_family == "Darwin"
  become: yes

これらのコマンドを最後に記載して終わりにしましょう。

bind-keychain

コードサイン証明書の SHA1 ハッシュをもとにビルド用 Keychain Access.app へ鍵を登録するコマンドです。問題点のところで紹介した、macOS Sierra の問題なども一緒に対応しています。

#!/usr/bin/env bash

prog=$(basename $0)
password="(Keychainアンロックパスワード)"
passphrase="(p12ファイルのパスフレーズ)"
keyname=build.keychain
url="(p12ファイルだけを入れたリポジトリURL)"
dir=$(mktemp -d -t $prog)

trap "rm -rf $dir; exit 1" 1 2 3 15

usage()
{
    echo usage: $prog [-u url] [hash ...] >&2
    exit 1
}

fingerprint()
{
    openssl pkcs12 -nokeys -in "$1" -out /dev/stdout -passin pass:$passphrase 2>/dev/null |
    openssl x509 -fingerprint -noout |
    sed -n "/^SHA1/s/.*=//; s/://gp"
}

while getopts u: OPT
do
    case $OPT in
    u)  url="$OPTARG" ;;
    *)  usage ;;
    esac
done
shift $((OPTIND - 1))

if ! git clone --depth=1 -q $url $dir
then
    echo failed to clone $url >&2
    exit 1
fi

targets=()
for i
do
    s=$(echo $i | tr -d " :")
    for p in $dir/*.p12
    do
        hash="$(fingerprint "$p")"
        if [[ $hash = $s ]]
        then
            targets+=($p)
        fi
    done
done
pkcs12add -p "$password" -P "$passphrase" -k $keyname ${targets[@]}
rm -rf $dir

pkcs12add

これは bind-keychain の中で呼ばれるコマンドです。

#!/usr/bin/env bash

keyname=build.keychain
defaults=login.keychain

password="(デフォルトのKeychainアンロックパスワード)"
passphrase="(デフォルトのp12パスフレーズ)"
prog=$(basename $0)

usage()
{
    echo usage: $prog [-p unlock-password] [-P passphrase] [-k keychain] [file.p12 ...] >&2
    exit 1
}

while getopts p:P:k: OPT
do
    case $OPT in
    p)  password="$OPTARG" ;;
    P)  passphrase="$OPTARG" ;;
    k)  keyname="$OPTARG" ;;
    *)  usage ;;
    esac
done
shift $((OPTIND - 1))

cleanup()
{
    security list-keychain -d user -s $defaults
    security delete-keychain "$keyname" 2>/dev/null
}

trap "cleanup; exit 2" 1 2 3 15

security delete-keychain "$keyname" 2>/dev/null
security create-keychain -p "$password" "$keyname" || exit 1
security list-keychains -d user -s $defaults "$keyname" || exit 1
security unlock-keychain -p "$password" "$keyname" || exit 1
for i in "$@"
do
    security import "$i" -k "$keyname" -P "$passphrase" -T /usr/bin/codesign >/dev/null
done
security set-keychain-settings -t 3600 $keyname

# macOS Sierra don"t allow to use a key what is imported with command until accepting manually.
# To allow to use a key without user interuction, we must use undocumented subcommand of security command.
if security set-key-partition-list --help 2>&1 | grep -q Usage:
then
    security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keyname"
fi

xcodew

Xcode の自動インストールコマンドです。xcode-installがすでに存在していましたが、ダウンロードのために Apple ID が必要であったり、転送が遅かったりするので参考にする程度に留めました。

また、sudo で実行するコマンドが複数あるので、sudo -A オプションで無理やり対応しています。

#!/usr/bin/env bash
# usage: xcodew [-d] [-v version] [arg ...]

# 以下のコマンドを参考にした
# https://github.com/KrauseFx/xcode-install/blob/master/lib/xcode/install.rb

storage="(Xcodeの.xipを置いたサーバアドレス)"
dir=/Applications
cache=~/Library/Caches/com.fenrir-inc.xcodew
license=/Library/Preferences/com.apple.dt.Xcode.plist

export SUDO_ASKPASS=/usr/local/bin/sudo_pass

function usage()
{
    echo usage: $(basename $0) [-d] [-v version] [arg ...] >&2
    exit 1
}

function unxip()
{
    rm -f Content Metadata
    pkgutil --check-signature "$1" &&
    xar -xf "$1" &&
    pbzx Content | sudo -A tar x --strip-components=1
    rm -f Content Metadata
}

function newer_than()
{
    local IFS=.
    local ver1=($1) ver2=($2)
    local i

    for ((i=${#ver1[@]}; i<${#ver2[@]}; i++))
    do
        ver1[i]=0
    done
    for ((i=0; i<${#ver1[@]}; i++))
    do
        if [[ -z ${ver2[i]} ]]
        then
            # fill empty fields in ver2 with zeros
            ver2[i]=0
        fi
        if ((10#${ver1[i]} > 10#${ver2[i]}))
        then
            return 0
        fi
        if ((10#${ver1[i]} < 10#${ver2[i]}))
        then
            return 1
        fi
    done
    return 0
}

function pick_license_code()
{
    local rtf=Contents/Resources/English.lproj/License.rtf
    sed -nE "s/.*(EA[0-9]{4}).*/\1/p" "$1/$rtf"
}

version=""
while getopts :dv: OPT
do
    case $OPT in
    d)  set -x ;;
    v)  version=$OPTARG ;;
    *)  usage ;;
    esac
done
shift $((OPTIND - 1))

if [[ -z $version ]]
then
    exec "$@" || exit
fi

mkdir -p $cache
cd $cache || exit 1

target="$dir/Xcode$version.app"
if ! [[ -d "$target" ]]
then
    pkg="Xcode$version.xip"
    if ! curl -s -f -O $resume "http://$storage/xcode/$pkg"
    then
        curl -s "http://$storage/list.txt"
        exit 1
    fi
    unxip "$pkg"
    sudo -A mv Xcode.app "$target" || exit
    rm -f "$pkg"
fi

v=$(defaults read $license IDEXcodeVersionForAgreedToGMLicense)
if newer_than "$version" "$v"
then
    code=$(pick_license_code "$target")
    sudo -A plutil -replace IDELastGMLicenseAgreedTo -string "$code" $license
    sudo -A plutil -replace IDEXcodeVersionForAgreedToGMLicense -string "$version" $license
fi

over=$(sw_vers -buildVersion)
pver=$(defaults read "$target/Contents/version.plist" ProductBuildVersion)
d=$(getconf DARWIN_USER_CACHE_DIR)
p="${d}com.apple.dt.Xcode.InstallCheckCache_${over}_${pver}"
if ! [[ -f "$p" ]]
then
    if newer_than "9.0.0" "$version"
    then
        for x in "$target"/Contents/Resources/Packages/*
        do
            sudo -A installer -pkg "$x" -target /
        done
    else
        sudo -A "$target/Contents/Developer/usr/bin/xcodebuild" -runFirstlaunch
    fi
    touch "$p"
fi

cd -
export DEVELOPER_DIR="$target/Contents/Developer"
exec "$@" || exit

このコマンドでは、sudo を使ってxipを展開しているのですが、これは CoreSimulator 関連のファイルに正しいパーミッション等を与えるため、sudo が必要になっているようです。

xcode-install コマンドでは、Archive Utility を直接実行していて、これならsudo がなくてもファイルの展開が行えたのでその方がいいのかもしれませんが、Archive Utility で展開したファイルの保存場所は設定に依存していて、手元の環境では ~/Downloads に出力されてしまったので、使うのをやめました。

終わりに

とても長くなりましたが、いかがだったでしょうか。「こんなに面倒なことやってられない」と思われた方もいらっしゃると思いますけれど、β版のXcodeやmacOSが使えたりするのは有用だと思います。

また、Jenkinsにこだわっているわけではないので、他のCIも色々と検討しています。明日はその辺りの話が公開されると思いますので、ご期待ください。

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

 

フェンリルの Facebook ページでは、最新トピックをお知らせしています。よろしければいいね!してください!

Copyright © 2019 Fenrir Inc. All rights reserved.