dotfiles にも CI を — 破壊的インストーラの冪等性を Ubuntu と Kali の二役で検証する
自分の開発環境が、ある日再現できなくなる
開発環境を1本のスクリプトにまとめている。シェル設定・プロンプト・ターミナルマルチプレクサ・言語ランタイム・クラウドCLIを、./setup_env.sh 一発で入れる。最終的に 1,358 行になった。新しいマシンでも、OS を入れ直した後でも、同じ環境がすぐ戻ってくる。便利だ。
便利なのだが、ひとつ不安が残った。今度は そのスクリプト自体が壊れたときに気づけない。うっかり片方の OS でしか通らない書き方をしても、次にそのマシンを触るまで分からない。設定ファイルを追記する処理が既存の .zshrc を壊しても、実際に事故ってから気づく。手作業をスクリプトにまとめた結果、こんどはその 1 本のスクリプトに、環境の再現を丸ごと預けることになった。
それで、dotfiles にも CI をつけた。やることは本番インフラの検証と変わらない。「危険な変更が入ったら、その場で赤くする」 を、開発環境の構築スクリプトに対してやる。この記事では、その CI(GitHub Actions)を抜粋して紹介する。リポジトリ固有の名前やレイアウト名は伏せるか一般化し、コードは要点を示す抜粋にしてある。
数字で見た中身
実環境の中身は出せないので、代わりに構成の規模で示す。以下はこの dotfiles と CI を実測した値だ。
| 指標 | 値 |
|---|---|
setup_env.sh 行数(単一エントリポイント) | 1,358 |
| CI ジョブ(builder / breaker / aggregate) | 3 |
| 検証環境(Ubuntu runner / Kali コンテナ) | 2 |
scripts/ci/validate_* チェッカ | 14 |
| CI で生成検証する Zellij レイアウト(総数9本のうち) | 8 |
インストーラ検証の段階(dry-run → 実行 → --clean 再構築) | 3 |
全体構成
push / PR / dispatch
│
┌────────────────┴────────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ builder (Ubuntu) │ │ breaker (Kali container) │
│ shellcheck / clippy │ │ shellcheck / clippy │
│ setup --dry-run │ │ setup --dry-run │
│ 隔離HOMEで実インストール │ │ 隔離HOMEで実インストール │
│ --clean で再構築 │ │ --clean で再構築 │
│ layout ×4 を assert │ │ layout ×4 を assert │
└──────────┬───────────┘ └────────────┬─────────────┘
│ report (artifact) │ report (artifact)
└────────────────┬──────────────────┘
▼
┌──────────────────────┐
│ aggregate │
│ 2環境のレポートを統合 │
│ → CI step summary │
└──────────────────────┘
CI は 3 ジョブに分けている。同じインストーラを 2 つの OS で流す builder と breaker、そして両者のレポートを 1 枚にまとめる aggregate だ。
同じインストーラを、2つの OS で流す
このインストーラは 2 つのモードを持っている。ふだん使いの開発機(Ubuntu)向けの builder と、セキュリティ検証用の Kali 環境向けの breaker で、/etc/os-release を見て自動で切り替わる。入るツールが違うので、片方だけ通ってももう片方で壊れていることは普通にある。だから CI も両方で回す。
builder は素の Ubuntu ランナーで、breaker は Kali のコンテナを被せて回す。
jobs:
builder:
runs-on: ubuntu-24.04
# ... shellcheck で shell script 群を、cargo fmt/clippy/test で同梱 Rust crate を検証
breaker:
runs-on: ubuntu-24.04
container:
image: kalilinux/kali-rolling # Kali を丸ごと被せる
# ... 同じ手順を Kali の上で回す
container: ブロックを足すだけで、その job のステップは丸ごと Kali の中で走る。ランナー自体を用意しなくても、「Kali でこのインストーラが通るか」を毎回確かめられる。builder 側で入る前提のツールが breaker には無い、といったズレは、この二役構成にした瞬間に炙り出される。
dry-run で満足しない — 実際に流して、出来たものを見る
dotfiles の CI は、shellcheck を通して終わり、というものを見かける。もちろん構文チェックは要る。でもそれは「文法として壊れていない」までしか言えない。流したときに本当に狙ったものが出来るか は別の話だ。
なので、隔離した HOME を用意して、そこへ本物のインストーラを流す。作業用の一時ディレクトリを HOME に差し替えてから実行し、出来上がっているべきファイルを test -f で確かめる。ここでは 4 種類のワークスペースレイアウトが生成されることをアサートしている(名前は一般化した)。
- name: Run installer with an isolated HOME
shell: bash
run: |
set -euo pipefail
export HOME="/tmp/ci-home"
mkdir -p "$HOME"
./setup_env.sh --skip-download --skip-ai-cli
# 出来ているべきものを1つずつ確かめる
for layout in workspace-a workspace-b workspace-c workspace-d; do
test -f "$HOME/.config/zellij/layouts/${layout}.kdl"
done
--skip-download(手動バイナリの取得を、既存が健全ならスキップ)と --skip-ai-cli(AI CLI のインストールを省く)で、重い処理をできるだけ削っている。CI で見たいのはインストーラの配置ロジック(何を、どこに、どう置くか)だからだ。なお、これらは重い I/O を減らすためのもので、mise 本体の導入や一部の apt 準備などは別経路で走ることがある。
壊してから建て直せるか — 破壊的 --clean の冪等性
このインストーラには --clean という破壊的なオプションがある。mise で管理しているツールと設定を一度リセットし、設定を書き換え直す、いわば作り直しモードだ。危険なフラグほど「本当に建て直るのか」を確かめておきたい。消すところまでは動いても、建て直しが欠けていたら、それは環境を壊すだけのコマンドになる。
だから CI では、一度ふつうに流した後に --clean を重ねて、もう一度レイアウトが生成されることをアサートしている。「消えた」で終わらず「建て直った」ことを見る。
# --clean はリセットして作り直す。作り直した結果を確かめる
# (レイアウトが「消えたまま」ではなく「再生成される」ことを見る)
./setup_env.sh --clean --skip-download --skip-ai-cli
for layout in workspace-a workspace-b workspace-c workspace-d; do
test -f "$HOME/.config/zellij/layouts/${layout}.kdl"
done
同じインストーラを 2 回、3 回と流しても同じ状態に落ち着く(冪等)。これが崩れていると、再実行のたびに設定が二重になったり、片方の環境だけ欠けたりする。冪等性はインストーラの生命線なので、CI に毎回見張らせている。
自分の設定ブロックを冪等に入れ替える
インストーラが一番やってはいけないのが、ユーザーの ~/.zshrc や ~/.bashrc を壊すことだ。設定を追記する処理は、既存の内容に手を入れる以上、事故の芽になる。
対策はシンプルで、自分が書く分を マーカーで囲む。BEGIN と END の目印で挟み、再実行時は自分のブロック(BEGIN を見つけたら BEGIN〜END の範囲)を丸ごと消して入れ直す。狙いは、再実行しても自分の追記が二重にならないことだ。そして書き換える前に、タイムスタンプ付きのバックアップを取る。
もう一つ地味に効くのが、昔の自分の置き土産を片付ける処理だ。過去にマーカー名を変えていると、旧マーカーで囲まれた古いブロックが残ってしまい、新しいブロックと二重になる。なので旧マーカーを見つけたら、それも消してから入れ直す。
backup_if_exists() {
local file="$1"
if [ -e "$file" ] || [ -L "$file" ]; then
# 上書きの前に、必ずタイムスタンプ付きで退避する
local bak="${file}.bak.$(date +%Y%m%d-%H%M%S-%N)"
cp "$file" "$bak"
fi
}
append_block_once() {
local file="$1" begin="$2" end="$3" content="$4"
touch "$file"
# 昔のマーカーで囲まれた旧ブロックが残っていたら、まず片付ける
local legacy_begin="# === LEGACY_BEGIN ==="
local legacy_end="# === LEGACY_END ==="
if grep -qF "$legacy_begin" "$file"; then
backup_if_exists "$file"
sed -i "/$legacy_begin/,/$legacy_end/d" "$file"
fi
# 既に自分のブロックがあれば、その区間だけ消して入れ直す(冪等)
if grep -qF "$begin" "$file"; then
backup_if_exists "$file"
sed -i "/$begin/,/$end/d" "$file"
fi
printf '%s\n%s\n%s\n' "$begin" "$content" "$end" >> "$file"
}
そして CI 側では、この「古いブロックからの移行」を実際に検証する。あらかじめ 旧マーカーで囲んだブロック を .zshrc に置いておき、インストーラを流した後に――
- 新しいマーカー(
INSTALLER_BEGIN/END)がちゃんと入っていること(grep) - 旧マーカーのブロックが消えていること(
! grepで不在を確認)
の両方を見る。これで、正常なペアで囲まれた古い置き土産が、新方式へ確実に移行されることを確かめている。ひとつ正直に書いておくと、実装は BEGIN を見つけると sed で BEGIN〜END の範囲を消すので、もし END が壊れて欠けていれば、その先まで巻き込んで消しうる。だからこの方式は「ユーザーの設定を絶対に壊さない」保証ではない。最後の歯止めは、書き換え前に取るタイムスタンプ付きバックアップのほうだ。
# 昔のバージョンのマーカーで囲んだブロックを模して置いておく
printf '# === LEGACY_BEGIN ===\nexport LEGACY=1\n# === LEGACY_END ===\n' > "$HOME/.zshrc"
./setup_env.sh --skip-download --skip-ai-cli
grep -qF "# === INSTALLER_BEGIN ===" "$HOME/.zshrc" # 新しいブロックは入った
! grep -qF "# === LEGACY_BEGIN ===" "$HOME/.zshrc" # 旧ブロックは消えた(移行された)
1条件=1チェッカにする
残りの検証は、scripts/ci/ に validate_* という小さなチェッカとして 14 本ぶら下げている。巨大なテストにまとめないのは、壊れたときに どの条件が落ちたのかをファイル名でそのまま読みたい からだ。この分け方自体は、以前に書いた Rust の CI/CD の「1条件=1ゲート」と同じ考えでやっている。
たとえば供給網まわり。GitHub Actions で使う action を、タグ(@v4 のような可動の参照)ではなく コミット SHA で固定 しているか、を機械的に見る。タグは中身が後からすり替わりうるので、pin していない参照が 1 つでもあれば落とす。
# uses: の参照が 40 桁の SHA で pin されているか
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)", re.MULTILINE)
SHA_PIN_RE = re.compile(r"@[0-9a-fA-F]{40}(?:\s|$)")
for match in USES_RE.finditer(workflow_text):
ref = match.group("ref")
if ref.startswith("./"):
continue # ローカル action は対象外
if not SHA_PIN_RE.search(ref):
failures.append(f"unpinned action reference: {ref}")
もう一つ、インストーラが どこからダウンロードするか も許可リストで縛っている。許可リストの実体は setup_env.sh 内の trusted_download_host() で、そこに列挙したホストだけを信頼する。守りは二層だ。CI の静的チェッカは、必須ホストが許可リストに揃っているかと、ダウンロード用ヘルパーに 直書きされた URL のホストが許可リスト内かを見る。変数から組み立てられる取得元まではこの静的検査では追えないが、そこは実行時に assert_trusted_url が全 URL のホストを(リダイレクト先も含めて)許可リストと突き合わせて弾く。「気づいたら知らないホストから何か落としていた」を防ぐための、意図的な摩擦だ。
# 基準となる必須ホスト(setup_env.sh が信頼すべき最低限の集合)
REQUIRED_TRUSTED_HOSTS = {
"api.github.com", "apt.fury.io", "claude.ai", "downloads.claude.ai",
"github.com", "mise.run", "objects.githubusercontent.com",
"release-assets.githubusercontent.com",
}
trusted = trusted_hosts(setup_script) # trusted_download_host() から抽出
literals = literal_download_hosts(setup_script) # ヘルパーに直書きされたURLのホスト
# 1) 必須ホストが許可リストから抜けていないか(先に確認)
if REQUIRED_TRUSTED_HOSTS - trusted:
raise SystemExit("required trusted host missing")
# 2) 直書きされたホストが1つでも許可リスト外なら落とす
if literals - trusted:
raise SystemExit("download helper uses untrusted host")
新しいダウンロード先を足すには、setup_env.sh の trusted_download_host() にそのホストを明記する必要がある。直書きの URL なら静的チェッカが、変数経由でも実行時の assert_trusted_url が、許可リスト外を弾く。取得元の追加を「うっかり」ではなく「意図した判断」にする、という狙いだ。
まとめ
本番インフラの検証も、dotfiles の検証も、やっていることは同じだと思う。危険な変更が入ったら、その場で赤くする。ただそれを、サーバではなく自分の開発環境の構築スクリプトに対してやる、というだけだ。
- 2 つの OS で流して、環境依存の壊れを炙り出す
- dry-run で終わらず、実際に流して出来たものを
test -fで見る - 破壊的な
--cleanの後、ちゃんと建て直ることまで確かめる - 旧マーカーで囲んだ古いブロックが新方式へ確実に移行されることを
grep/! grepで確認する
真似するなら、ここから
1,358 行のインストーラをそのまま真似する必要はない。効くのは、もっと小さく取り入れられる 2 つのほうだ。
- マーカーブロック+バックアップ:
.zshrcなどに追記するスクリプトを持っているなら、BEGIN/ENDで囲んで自分のブロックだけを入れ替える方式にして、書き換え前にタイムスタンプ付きバックアップを取る。再実行で自分の追記が二重にならず、失敗しても直前の状態へ戻せる。 - 隔離 HOME で実際に流す smoke:
HOMEを一時ディレクトリに差し替えて本物のインストーラを走らせ、出来るべきファイルをtest -fする数行。dry-run の一段上の安心感が、今日から足せる。
「動くはず」の開発環境を、「毎回動くことを確かめている」開発環境に変える。それだけで、新しいマシンに向かうときの気持ちがだいぶ違う。
構築スクリプトの冪等化、
.zshrc/.bashrcのバックアップ付き設定管理、GitHub Actions の CI 整備やサプライチェーンの pin 化など、このあたりのご相談は Services からどうぞ。
