背景
四苦八苦しながら Docker 環境構築をなんとか完了し、Ubuntu なんかを実験用でサクッと用意できることが分かって感動。
そして思ったのが、「これ活用したら、GitHub Actions っぽいことできるんじゃね?」
Docker をより詳しく知ることも目的に、Git リモートリポジトリに push されたら、自動 CI が動く簡易システムを作ってみることにしたのでした。
つくりたいもの
GitHub Actions のシステムはだいたいこんなイメージ。 (実際にこういう実装と確かめたわけではない)
それを、こんな感じで構築してみます。
git push
から、CI 完了まで待たされることになりますが、今回は Docker 勉強がメインなので、利用の快適さは後回しで単純な実装にしています。
軽く仕様を検討
Git リポジトリがあって、そこに CI で実行するスクリプトを workflow.sh
という名前で置いておく。
ls
# any_files.js here.css workflow.sh
そのリポジトリを push したとき、workflow.sh
を実行してくれる。
git add .
git commit -m "Any commit message"
git push origin main
# すると workflow.sh が実行される
もちろん、実環境を汚さないために Docker コンテナ上で動作させる。
...というようなことを実現します。
要素技術とか
0. 作業場
cd ~
mkdir ci-work
cd ci-work
1. Git リモートリポジトリ
--shared
--bare
オプションで、リモートリポジトリを作成できます。リポジトリ名は cirepo
とします。
git init --shared --bare cirepo.git
ls
# cirepo.git
2. リポジトリクローン
通常、サーバ上にリモートリポジトリを作成し、そこに SSH や HTTP でアクセスします。
でも、わざわざサーバを立てずとも、どっちもローカル環境上で clone やら push やらできます。実験のため全てローカル上で行います。
git clone cirepo.git
ls
# cirepo.git cirepo
一旦、適当なコミットを作っておきます。
cd cirepo
touch any_files.js here.css workflow.sh
git add .
git commit -m "commit message"
git push origin main
cd ..
3. push 時にコマンド実行
CI を実現するためには、push されたときにサーバ (リモートリポジトリがある所) でコマンドを実行できる必要があります。そこで Git Hooks を利用します。
リモートリポジトリの hooks
ディレクトリ内に、指定の名前でスクリプトを置いておくことで、特定の条件が発生したときにそのスクリプトが実行されます。
デフォルトではサンプルのスクリプトが置いてあります。
ls cirepo.git/hooks
# applypatch-msg.sample pre-applypatch.sample pre-receive.sample
# commit-msg.sample pre-commit.sample prepare-commit-msg.sample
# fsmonitor-watchman.sample pre-merge-commit.sample push-to-checkout.sample
# post-update.dep pre-push.sample update.sample
# post-update.sample pre-rebase.sample
最終的には、この hook をいい感じに作れたらゴールということになります。
今回は post-receive
を使います。
4. post-receive
hook をもうちょっと詳しく
post-receive
には、以下のような標準入力が渡されます。
5ae8f240c3481860687def9028e6c94a0cc82f21 de402c2e48da4b27ce43155f6d4ec1bb487aece9 refs/heads/main
0cc5d4d22489181c1c23c9677e2706e7d5d5c044 e0b6c73c2acf73fe32f0bee7228b84db82e99d05 refs/heads/develop
- 左 - push される前の古いコミットのハッシュ値
- 新しいブランチ、つまり push 前の古いコミットが無い場合、
000...
と全て 0 の値が渡されます
- 新しいブランチ、つまり push 前の古いコミットが無い場合、
- 中 - push 後の新しいコミットのハッシュ
- ブランチの削除、つまり push 後の新しいコミットが無い場合、
000...
と全て 0 の値が渡されます
- ブランチの削除、つまり push 後の新しいコミットが無い場合、
- 右 - push 先リファレンス名 (ブランチ名)
-
refs/heads/<branch_name>
というような文字列が渡されます -
refs/heads/
を除いてやれば、シンプルにブランチ名が得られます
-
1行 1ブランチでデータが渡され、同時に複数のブランチを push した場合は複数行の入力をされることになります。
ブランチ名は、この先のソースチェックアウトで必要になります。
post-receive
は、push を受理した後、つまりリモートリポジトリの更新が確定した後に実行されます。
pre-receive
や update
では、push を反映する前の状態、つまり最新のソースでない所で CI が走ってしまいます。
push されるソースを取得して上手く走らせることも可能とは思いますが、単純に実装できそうという見積もりで post-receive
を使用しました。
この標準入力を加工して、先のスクリプトで扱いやすくします。
while read line; do
HASH_OLD=`echo -n "$line" | awk '{print $1}'`
HASH_NEW=`echo -n "$line" | awk '{print $2}'`
REF_NAME=`echo -n "$line" | awk '{print $3}'`
BRANCH_NAME="${REF_NAME#refs/heads/}"
# この先、ブランチ毎に実行したいコマンド...
done
while read line; do ... done
で、標準入力を 1行ずつ読んで、変数に入れます。(上の例では変数名 line
ですが、任意の名前で OK)
awk
コマンドで、スペース区切りの文字列の、任意の列を得ます。
awk
はデフォルトでスペースを区切り文字として扱いますが、オプション -F
で他のセパレータにも対応可能です。
例えばオプション -F ','
なら、コンマ区切りで処理します。
「しれっと登場したコイツ ${REF_NAME#refs/heads/}
何だ?」こちらの記事で詳しく紹介されています。
5. git 入り ubuntu イメージ作成
今回は Ubuntu コンテナを CI 環境とします。ただ、デフォルトでは git がインストールされていない、最低限な状態です。CI 実行毎に git をインストールするのでは賢くないので、git インストール済み Ubuntu イメージを作ります。
任意のテキストエディタで dockerfile
を作ります。
vim dockerfile
ubuntu
イメージをベースに、git
をインストールするだけ。
FROM ubuntu
RUN apt update
RUN apt install -y git
あとはビルド。イメージ名は git-ci-img
にします。
docker build -t git-ci-img .
6. Docker コンテナ起動 & コマンド実行
コンテナの作成と起動
CONTAINER_ID=`docker create git-ci-img`
docker start $CONTAINER_ID
通常、コンテナに名前をつけ、そのコンテナ名を指定して start
などの各種コマンドを呼び出します。が、create
したときに出力される ID を直接入力する形でも各種コマンドを実行できます。
名前をつけておくと、干渉する恐れがあります (少なくとも狙って干渉させることが可能になります)。なので、ここでは名前をつけず全て ID で実行します。
この基本形に少しオプションを追加します。
CONTAINER_ID=`docker create --rm -w /home/ci -it git-ci-img`
docker start $CONTAINER_ID > /dev/null
-
--rm
- コンテナが
stop
されたとき、コンテナを自動で削除する
- コンテナが
-
-w /home/ci
- ワーキングディレクトリの指定
-
-it
- STDIN を保持し、疑似 TTY を割り当てる
- (起動した後に自動で始まるターミナルを保持する、みたいな?)
- ここでは、コンテナを起動状態で保つのに必要なオプションとお考えください
起動中のコンテナ上でコマンド実行
例えば echo hello
を実行するには以下のような感じ。
docker exec $CONTAINER_ID echo hello
リポジトリのクローンをしたり、workflow.sh
を実行したりする際に必要になります。
コンテナをシャットダウン
docker stop $CONTAINER_ID
create
のときに --rm
オプションをつけておいたので、docker rm $CONTAINER_ID
は不要です。
7. ソースチェックアウト (経路確保 編)
最後の難関。コンテナにリポジトリの中身 (ソースコード) を展開できなくては、CI システムと呼べないも同然です。
コンテナからリモートリポジトリ (ホストのファイル) へアクセスする経路を確保できれば OK。そのためには mount が使えます。mount はコンテナの create 時に設定できます。
docker create --mount type=bind,source="$REPO_PATH",target="/home/$REPO_NAME",readonly -it git-ci-img
-
type
- mount のタイプ
- (bind の他に volume, tmpfs があるみたいだが、違いを分かってない)
-
source
- コンテナに接続したい、ホスト環境のディレクトリ
- 相対パスでは指定できないので注意
-
target
- コンテナ上の接続先パス
-
readonly
- コンテナ内から書き換えることが不可になる
- CI がコミットを作成するようなことをしたい場合は、このオプションをつけない
これで、コンテナからリモートリポジトリへの経路が確保できました。試しにこの設定でコンテナを作り、コンテナに入って確かめてみましょう。
# 作業場ディレクトリをカレントで
pwd
# /home/akuad/ci-work
docker create --name test-con -w /home/ci --mount type=bind,source="cirepo.git",target="/home/cirepo.git",readonly -it git-ci-img
docker start test-con
docker exec -it test-con /bin/sh
# -- ここからコンテナ内 --
pwd
# /home/ci
ls /home
# ci cirepo.git ubuntu
# あとは git clone のみだが、色々トラブルあったので次の節で詳しく
8. ソースチェックアウト (git clone 編)
/
|- home/
|- ci/ <- カレント
|- cirepo.git/
|- ubuntu (なんかデフォルトであったディレクトリ, 特に触れることはない)
こうなれば、あとは git clone ../cirepo.git
だけ!・・・と言いたいところですが、いつも通りの clone と違い、以下の要件を満たしたいです。
- clone 後、指定のブランチにいる状態にしたい
- 通常、デフォルトブランチ (通常
main
ormaster
) の状態から始まります - CI では push したブランチを対象に処理するので、そのまま指定のブランチに checkout した状態から初めたいです
- 通常、デフォルトブランチ (通常
- 全履歴は要らないので早く clone したい
- デフォルトでは、全ての履歴を取得します
- CI では push された最新のコミットさえあれば良いので、全履歴を取得してしまうと時間の無駄になってしまいます
- 新規ディレクトリを作らず、カレント内にソースを展開したい
-
cirepo
ディレクトリをリポジトリにするのではなく、./
をリポジトリにします - これにより、cd でリポジトリに入る手間が省けます
- GitHub Actions の
actions/checkout
と同じような動きにします
-
ブランチ指定は -b <branch_name>
、クローン深さは --depth <depth>
、ローカルリポジトリにするディレクトリはリモートリポジトリ URI 後に指定できます。これらを適用してクローンしてみましょう。
# -- 引き続きコンテナ内 --
git clone --depth 1 -b main ../cirepo.git ./
# Cloning into 'cirepo'...
# warning: --depth is ignored in local clones; use file:// instead.
# done.
ls -a
# . .. .git any_files.js here.css workflow.sh
うん?--depth
が無視された?
file://
でないとダメなようです。それをつけてやり直し。
git clone --depth 1 -b main file:///home/cirepo.git ./
# Cloning into '.'...
# remote: Enumerating objects: 4, done.
# remote: Counting objects: 100% (4/4), done.
# remote: Compressing objects: 100% (2/2), done.
# remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
# Receiving objects: 100% (4/4), done.
la -a
# . .. .git any_files.js here.css workflow.sh
git log
# commit a6aa2b92dc78b5ab201735d31ce9f98d7e23657e (grafted, HEAD -> main, origin/main)
# Author: aKuad <53811809+aKuad@users.noreply.github.com>
# Date: Fri Nov 1 23:44:22 2024 +0900
#
# commit message
うまくできました。コミット履歴も 1つだけです。
実装と試運転
以上を踏まえて、post-receive
を実装します。
#!/usr/bin/env sh
DOCKER_IMAGE="git-ci-img"
REPO_PATH=`pwd`
REPO_NAME="${REPO_PATH##*/}"
while read line; do
HASH_OLD=`echo -n "$line" | awk '{print $1}'`
HASH_NEW=`echo -n "$line" | awk '{print $2}'`
REF_NAME=`echo -n "$line" | awk '{print $3}'`
BRANCH_NAME="${REF_NAME#refs/heads/}"
if [ "$HASH_NEW" = "0000000000000000000000000000000000000000" ]; then
echo "CI skip for branch deletion '$BRANCH_NAME'"
continue
fi
echo "CI starting for '$BRANCH_NAME' ..."
CONTAINER_ID=`docker create --rm -w /home/ci --mount type=bind,source="$REPO_PATH",target="/home/$REPO_NAME",readonly -it $DOCKER_IMAGE`
docker start $CONTAINER_ID > /dev/null
docker exec $CONTAINER_ID git clone --depth 1 -b $BRANCH_NAME "file:///home/$REPO_NAME" ./
docker exec $CONTAINER_ID /bin/sh -c "if [ -x workflow.sh ]; then ./workflow.sh; fi"
echo "CI shutting down ..."
docker stop $CONTAINER_ID > /dev/null
done
このスクリプトを、cirepo.git/hooks/update
に書き込みます。実行権限の付与もお忘れなく。
cd ~/ci-work
ls
# cirepo.git cirepo
vim cirepo.git/hooks/post-receive # 任意のテキストエディタで上のスクリプトを書く
chmod +x cirepo.git/hooks/post-receive
それでは、ローカルリポジトリに入って、早速動かしてみましょう。
cd ~/ci-work
cd cirepo
vim workflow.sh # 任意のテキストエディタで
とりあえず、何か echo
してもらうだけの CI です。
#!/usr/bin/env sh
echo HELLO
そして commit と push。
git add .
git commit -m "commit message"
git push origin main
# Enumerating objects: 5, done.
# Counting objects: 100% (5/5), done.
# Delta compression using up to 16 threads
# Compressing objects: 100% (2/2), done.
# Writing objects: 100% (3/3), 957 bytes | 957.00 KiB/s, done.
# Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
# remote: CI starting for 'main' ...
# remote: Cloning into '.'...
# remote: HELLO
# remote: CI shutting down ...
# To /home/akuad/myci/cirepo.git/
# a6aa2b9..f79d83f main -> main
HELLO
とプリントされています。うまくできたようです。
もうちょっと応用 - deno test する
echo
だけでは味気無いので、CI らしい例として deno test を回してみます。
まず初めに、Deno と git 入り Ubuntu イメージを作成します。Deno 入り Ubuntu イメージが Deno 公式から上げられているので、そこに git を入れる形でイメージを作ります。
FROM denoland/deno:ubuntu
RUN apt update
RUN apt install -y git
CMD ["/bin/bash"]
# CMD /bin/bash
# でも動きますが、Warning が出ます
イメージ ubuntu
では、起動後にデフォルトで /bin/bash
を実行するように作られています。
しかし denoland/deno
ではそうなっていません。すると、ターミナルが起動されることなく、すぐにコンテナが終了してしまいます。
そこで、CMD
で /bin/bash
を実行するようにして、コンテナが立ち上がった状態で保たれるようにします。
ビルドします。
# 古い git-ci-img を削除しておく
docker rmi git-ci-img
docker build -t git-ci-img .
イメージ名は、先の完成品と変えていないので、post-receive
編集の必要は無し。
次に、テストコードの追加。雑に足し算のテストをします。
import { assertEquals } from "jsr:@std/assert@1";
Deno.test(function test() {
const excepted = 2;
const actual = 1 + 1;
assertEquals(actual, excepted);
});
最後に、deno test
を実行するように、workflow.sh
を編集します。
#!/usr/bin/env sh
echo HELLO
deno test *.test.ts
commit と push。
git add .
git commit -m "deno test"
git push origin main
# Enumerating objects: 6, done.
# Counting objects: 100% (6/6), done.
# Delta compression using up to 16 threads
# Compressing objects: 100% (4/4), done.
# Writing objects: 100% (4/4), 1.15 KiB | 1.15 MiB/s, done.
# Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
# remote: CI starting for 'main' ...
# remote: Cloning into '.'...
# remote: HELLO
# remote: Download https://jsr.io/@std/assert/meta.json
# remote: Download https://jsr.io/@std/assert/1.0.7_meta.json
# ... 他ダウンロードログ
# remote: Download https://jsr.io/@std/internal/1.0.5/types.ts
# remote: Check file:///home/ci/sum.test.ts
# remote: running 1 test from ./sum.test.ts
# remote: sum test ... ok (0ms)
# remote:
# remote: ok | 1 passed | 0 failed (2ms)
# remote:
# remote: CI shutting down ...
# To /home/akuad/myci/cirepo.git/
# f79d83f..9dcbf59 main -> main
一気に CI らしくなりました。
感想
もっと応用すれば、使用するイメージを workflow.sh
内で選択できる、みたいなのも作れるかもしれません。あれもこれもとやると、際限なく複雑になってしまうので、この辺で止めにしました。
完走した感想ですが、ソースのチェックアウトはなかなかに難関でした。おかげで Docker も git も勉強になりましたが。GitHub Action では actions/checkout
呼ぶだけで済みますので、CI ツールってキレイに整備してくれてるんやなって・・・。
完成品ですが、まあ使い心地はそんなに良いものではありません (ログも保存されないし)。よほど特殊な環境でもない限り、こんな感じで実装することはそう無いでしょう。