リモート端末では問題なくビルドできるのに、CIジョブに切り替えると command not found が発生したり、作業ディレクトリがずれたりすることがあります。同じスクリプトでも、手動実行なら成功し、launchd 経由では失敗するケースもあります。多くの場合、ツール自体が壊れているのではなく、3つの起動経路で渡される環境が異なることが原因です。対話型Shellはユーザー設定を読み込み、非対話型Shellはその一部だけを読み込みます。一方、launchd はさらに限定された環境からプロセスを起動します。対策の要点は、設定ファイルにPATHを追加し続けることではありません。すべての自動化タスクを、明示的に定義した単一の起動経路に集約することです。
3つの起動経路から環境情報を保存する
最初から設定を変更してはいけません。リモート端末、CIジョブ、launchd のそれぞれで同じ情報を収集し、差分がPATH、カレントディレクトリ、ツールの選択のどこで生じているかを確認します。
#!/bin/zsh
set -eu
printf 'user=%s
' "$(id -un)"
printf 'uid=%s
' "$(id -u)"
printf 'shell=%s
' "${SHELL:-unset}"
printf 'home=%s
' "${HOME:-unset}"
printf 'pwd=%s
' "$PWD"
printf 'path=%s
' "${PATH:-unset}"
for tool in zsh git xcodebuild ruby python3; do
printf '%s=' "$tool"
command -v "$tool" || printf 'missing
'
done
/usr/bin/xcode-select -p 2>/dev/null || true
/usr/bin/sw_vers
このスクリプトを scripts/inspect-env.sh として保存し、3つの起動経路から実行して、それぞれの出力を残します。本番ジョブで env の出力を丸ごと収集するのは避けてください。トークンや一時的な認証情報が含まれている可能性があります。
2回の実行で同じツールが異なる絶対パスに解決された場合、現時点でバージョン番号が同じでも、環境ドリフトが発生していると判断すべきです。将来のアップグレード後には、2つのパスで異なる結果になる可能性があります。
最小環境で再現する
env -i を使うと、ユーザー固有の設定を読み込まないジョブを再現できます。
/usr/bin/env -i \
HOME="$HOME" \
USER="$USER" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
/bin/zsh ./scripts/inspect-env.sh
この環境で問題を安定して再現できるなら、ツールを何度も再インストールする必要はありません。起動スクリプトとPATHの定義を確認してください。
タスクの起動スクリプトを1つに統一する
自動化タスクを .zshrc に依存させてはいけません。このファイルは対話操作向けであり、プロンプト、エイリアス、端末判定、ログインセッションでのみ成立する処理などが含まれていることがあります。リポジトリ専用の起動スクリプトとして、たとえば scripts/run-ci.sh を用意します。
#!/bin/zsh
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
required_tools=(git xcodebuild)
for tool in "${required_tools[@]}"; do
if ! command -v "$tool" >/dev/null 2>&1; then
printf 'required tool missing: %s
' "$tool" >&2
exit 127
fi
done
exec ./scripts/build.sh
PATHの順序は固定し、システムディレクトリも残してください。タスクで追加のツールが必要な場合は、まず対象マシン上で command -v を実行して実際の場所を確認し、そのパスを起動スクリプトに追加します。ユーザー設定全体をコピーしてはいけません。
起動スクリプトでは、リポジトリのルートディレクトリへの移動も行います。これにより、ビルドスクリプト内の相対パスが、CIランナーの一時ディレクトリや launchd のデフォルトディレクトリに左右されなくなります。
launchdの役割を起動だけに限定する
launchd の設定は簡潔に保ちます。インタープリタ、起動スクリプト、作業ディレクトリ、ログファイルだけを指定し、plist内で複雑なコマンドを組み立てたり、機密値を保存したりしないでください。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "file://localhost/System/Library/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ci-runner</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>/Users/runner/project/scripts/run-ci.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/runner/project</string>
<key>StandardOutPath</key>
<string>/Users/runner/Library/Logs/ci-runner.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/runner/Library/Logs/ci-runner.err.log</string>
</dict>
</plist>
読み込む前に構文を検証します。
plutil -lint ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl bootstrap "gui/$(id -u)" \
~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl kickstart -k \
"gui/$(id -u)/com.example.ci-runner"
plistを変更したら、まず bootout で既存のジョブをアンロードし、その後で改めて bootstrap を実行します。kickstart を実行するだけでは、読み込み済みの古い設定は自動更新されません。
ツールのバージョンと機密変数を管理する
パスを固定しても、バージョンまで固定されるわけではありません。失敗したジョブを比較できるように、起動スクリプトではジョブ開始時に、機密性のないツールのバージョン、解決されたパス、作業ディレクトリを出力します。ただし、環境変数全体を出力してはいけません。
| 確認項目 | 推奨方法 | 失敗時に保存する情報 |
|---|---|---|
| ツールの解決先 | command -v |
ツールの絶対パス |
| ツールのバージョン | 公式のバージョン表示オプションを使用 | バージョン情報 |
| 作業ディレクトリ | 明示的にリポジトリルートへ cd |
pwd の出力 |
| システムツールチェーン | 現在の開発ディレクトリを確認 | パスと終了コード |
| 機密変数 | 存在するかどうかだけを確認 | 変数名のみ。値は記録しない |
機密値は、管理された認証情報の仕組みを通じてジョブ実行時に渡します。スクリプトでは ${TOKEN:?TOKEN is required} を使い、値がない場合に即座に失敗させることはできますが、set -x、env、エラー出力によって内容を漏らしてはいけません。調査が終わった後は、リクエストヘッダー、コマンド引数、一時ファイルのパスなどがログに誤って記録されていないかも確認してください。
受け入れテストのマトリクスで問題を完了させる
修正後は、3つの起動経路から同じスクリプトを実行する必要があります。現在の端末だけで確認してはいけません。
- 対話型端末で
./scripts/run-ci.shを直接実行します。 env -iを使用し、HOME、USER、基本PATHだけを渡して実行します。launchctl kickstartから起動し、終了ステータスと2つのログを確認します。- 3回の実行について、リポジトリのルートディレクトリ、ツールの絶対パス、バージョン、終了コードを比較します。
- ユーザーセッションを一度再起動してから再実行し、一時的にexportされた変数に依存していないことを確認します。
よくある誤りは、修正を .zshrc に書き込み、手動テストだけは直ったもののCIでは失敗し続けることです。plist側でも別のPATHを管理すると、数週間後にはリポジトリのスクリプトと再び食い違う可能性があります。より安定した責務分担は、スケジューラは起動だけを担い、起動スクリプトが環境を定義し、処理本体のスクリプトがビルドを実行する形です。この3層を分離すれば、環境差分を記録し、再現し、レビューできるようになります。
OwnAMacのリモートMacにこの方法を導入する場合も、plistを生成する前に、ジョブを実際に実行するユーザーとリポジトリのパスを確認してください。サンプルのユーザー名をそのままコピーしてはいけません。最終的な合格基準は「端末で動くこと」ではなく、最小環境とスケジューラ環境の両方で同じツール群が解決され、一貫した結果が返ることです。
よくある質問
端末で使えるコマンドがCIでは見つからないのはなぜですか?
対話型Shellはユーザー設定を読み込んでPATHを拡張しますが、非対話ジョブやlaunchdは同じ設定を読み込むとは限りません。起動スクリプトでPATHを明示してください。
launchdのplistにアクセストークンを書いてもよいですか?
推奨できません。plistがバックアップや診断情報に含まれる可能性があります。機密値は実行時に安全な経路から渡し、ログには値を出力しない構成にします。
修正が現在の端末だけに依存していないことを確認する方法は?
同じ検証スクリプトを対話型端末、env -iによる最小環境、launchdの三つから実行し、PATH、作業場所、ツールの絶対パス、終了コードを比較します。
次のビルドを専有Apple Silicon物理ノードで実行
タスクの規模に合わせて3つの構成と5つのノードから選択できます。計算リソースとストレージは他のテナントと共有されません。すべてのノードは365日、年間を通じて安定稼働しています。実際の利用可能状況はコンソールのリアルタイム表示をご確認ください。