こんにちは。アプリケーション共同開発部の門多です。
昨年の終わり頃に、 Mac mini 増殖中!iOS アプリのビルドをマスター・スレーブ化して時間を短縮するという pixiv さんの記事がありましたが、フェンリルの共同開発事業アプリ開発でも、2012 年ごろから Jenkins を使ったビルドを行っています。
なるべく多くの人に使ってもらえるように、基本的に制限しない運用を行なっていました。しかし数年経ってみると、OS やビルド環境の変化もありましたし、現在の構成が色々と問題を起こしていることもわかってきました。現在ビルド環境の改善を行っているので、ついでに Keychain 管理の自動化や Xcode 自動インストールなど、これまで経験した色々な問題とその対応方法をまとめました。かなり長い記事になってしまいましたが、実際に使っているプログラムをそのまま掲載していますので、そのうち一部でも、特に iOS アプリのビルドで困っている人の参考になれば良いなと思います。
とても長いのでページ内のリンクを用意しました。
- プロジェクト数が多くなると launchd から起動できない
- Slave の環境維持は自分で行う必要がある
- 異なるバージョンの CoreSimulator が競合する
- macOS Sierra に SSH 接続すると System.keychain しか参照できない
現行の環境
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 スレーブノードに必要になったら追加されるような運用となります。登録は以下のような手順です。
- p12 ファイルをスレーブマシンにコピーする
- p12 ファイルから鍵と証明書を Keychain Access.app に取り込む
- 鍵にアクセスするときパスワードを要求しないように変更
- スレーブマシンの数だけ繰り返す
わずかな手間ですが、プロジェクト追加のたびに手作業で行うのは面倒なので、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 以上なら、必要なものを自動でダウンロードします。なので事前に、以下の対応をしておけば基本的には困りません。
- Android SDK をダウンロード・展開
- ライセンスに同意
- 環境変数 $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/macos や group_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 ページでは、最新トピックをお知らせしています。よろしければいいね!してください!









