Docker で Git の CI システムを手作りする

## 背景

四苦八苦しながら Docker 環境構築をなんとか完了し、Ubuntu なんかを実験用でサクッと用意できることが分かって感動。

そして思ったのが、「これ活用したら、GitHub Actions っぽいことできるんじゃね？」

Docker をより詳しく知ることも目的に、Git リモートリポジトリに push されたら、自動 CI が動く簡易システムを作ってみることにしたのでした。

## つくりたいもの

GitHub Actions のシステムはだいたいこんなイメージ。 (実際にこういう実装と確かめたわけではない)

```mermaid
sequenceDiagram
  actor C as Client
  participant G as GitHub
  participant R as GitHub Repository
  participant A as GitHub Actions<br>(runner)

  C ->> R: push

  R ->>+ A: CI start
  A ->> R: Source checkout
  R -->> A: 
  A -->> G: Console output & return code
  A ->>- A: Shutdown runner

  C ->>+ G: Result check
  G -->>- C: view
```

それを、こんな感じで構築してみます。

```mermaid
sequenceDiagram
  actor C as Client
  participant R as Git Repository<br>(remote)
  participant D as Docker container

  C ->>+ R: push

  R ->>+ D: docker start
  D ->> R: Source checkout
  R -->> D: 
  D -->>- R: Console output<br>& return code

  R -->>- C: Console output<br>& return code
```

`git push` から、CI 完了まで待たされることになりますが、今回は Docker 勉強がメインなので、利用の快適さは後回しで単純な実装にしています。

## 軽く仕様を検討

Git リポジトリがあって、そこに CI で実行するスクリプトを `workflow.sh` という名前で置いておく。

```sh
ls
# any_files.js  here.css  workflow.sh
```

そのリポジトリを push したとき、`workflow.sh` を実行してくれる。

```sh
git add .
git commit -m "Any commit message"
git push origin main
# すると workflow.sh が実行される
```

もちろん、実環境を汚さないために Docker コンテナ上で動作させる。

...というようなことを実現します。

## 要素技術とか

### 0. 作業場

```sh
cd ~
mkdir ci-work
cd ci-work
```

### 1. Git リモートリポジトリ

`--shared` `--bare` オプションで、リモートリポジトリを作成できます。リポジトリ名は `cirepo` とします。

```sh
git init --shared --bare cirepo.git

ls
# cirepo.git
```

### 2. リポジトリクローン

通常、サーバ上にリモートリポジトリを作成し、そこに SSH や HTTP でアクセスします。

でも、わざわざサーバを立てずとも、どっちもローカル環境上で clone やら push やらできます。実験のため全てローカル上で行います。

```sh
git clone cirepo.git

ls
# cirepo.git  cirepo
```

一旦、適当なコミットを作っておきます。

```sh
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](https://git-scm.com/book/ja/v2/Git-%e3%81%ae%e3%82%ab%e3%82%b9%e3%82%bf%e3%83%9e%e3%82%a4%e3%82%ba-Git-%e3%83%95%e3%83%83%e3%82%af) を利用します。

リモートリポジトリの `hooks` ディレクトリ内に、指定の名前でスクリプトを置いておくことで、特定の条件が発生したときにそのスクリプトが実行されます。

デフォルトではサンプルのスクリプトが置いてあります。

```sh
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
```

:::note
最終的には、この hook をいい感じに作れたらゴールということになります。
:::

今回は `post-receive` を使います。

### 4. `post-receive` hook をもうちょっと詳しく

`post-receive` には、以下のような標準入力が渡されます。

```sh
5ae8f240c3481860687def9028e6c94a0cc82f21 de402c2e48da4b27ce43155f6d4ec1bb487aece9 refs/heads/main
0cc5d4d22489181c1c23c9677e2706e7d5d5c044 e0b6c73c2acf73fe32f0bee7228b84db82e99d05 refs/heads/develop
```

- 左 - push される前の古いコミットのハッシュ値
  - 新しいブランチ、つまり push 前の古いコミットが無い場合、`000...` と全て 0 の値が渡されます
- 中 - push 後の新しいコミットのハッシュ
  - ブランチの削除、つまり push 後の新しいコミットが無い場合、`000...` と全て 0 の値が渡されます
- 右 - push 先リファレンス名 (ブランチ名)
  - `refs/heads/<branch_name>` というような文字列が渡されます
  - `refs/heads/` を除いてやれば、シンプルにブランチ名が得られます

1行 1ブランチでデータが渡され、同時に複数のブランチを push した場合は複数行の入力をされることになります。

ブランチ名は、この先のソースチェックアウトで必要になります。

:::note
`post-receive` は、push を受理した後、つまりリモートリポジトリの更新が確定した後に実行されます。

`pre-receive` や `update` では、push を反映する前の状態、つまり最新のソースでない所で CI が走ってしまいます。

push されるソースを取得して上手く走らせることも可能とは思いますが、単純に実装できそうという見積もりで `post-receive` を使用しました。
:::

この標準入力を加工して、先のスクリプトで扱いやすくします。

```sh
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` コマンドで、スペース区切りの文字列の、任意の列を得ます。

:::note
`awk` はデフォルトでスペースを区切り文字として扱いますが、オプション `-F` で他のセパレータにも対応可能です。

例えばオプション `-F ','` なら、コンマ区切りで処理します。
:::

:::note
「しれっと登場したコイツ `${REF_NAME#refs/heads/}` 何だ？」こちらの記事で詳しく紹介されています。

https://qiita.com/aosho235/items/c36568830a8d47288284
:::

### 5. git 入り ubuntu イメージ作成

今回は Ubuntu コンテナを CI 環境とします。ただ、デフォルトでは git がインストールされていない、最低限な状態です。CI 実行毎に git をインストールするのでは賢くないので、git インストール済み Ubuntu イメージを作ります。

任意のテキストエディタで `dockerfile` を作ります。

```sh
vim dockerfile
```

`ubuntu` イメージをベースに、`git` をインストールするだけ。

```dockerfile
FROM ubuntu

RUN apt update
RUN apt install -y git
```

あとはビルド。イメージ名は `git-ci-img` にします。

```sh
docker build -t git-ci-img .
```

### 6. Docker コンテナ起動 & コマンド実行

#### コンテナの作成と起動

```sh
CONTAINER_ID=`docker create git-ci-img`
docker start $CONTAINER_ID
```

:::note
通常、コンテナに名前をつけ、そのコンテナ名を指定して `start` などの各種コマンドを呼び出します。が、`create` したときに出力される ID を直接入力する形でも各種コマンドを実行できます。

名前をつけておくと、干渉する恐れがあります (少なくとも狙って干渉させることが可能になります)。なので、ここでは名前をつけず全て ID で実行します。
:::

この基本形に少しオプションを追加します。

```sh
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` を実行するには以下のような感じ。

```sh
docker exec $CONTAINER_ID echo hello
```

リポジトリのクローンをしたり、`workflow.sh` を実行したりする際に必要になります。

#### コンテナをシャットダウン

```sh
docker stop $CONTAINER_ID
```

`create` のときに `--rm` オプションをつけておいたので、`docker rm $CONTAINER_ID` は不要です。

### 7. ソースチェックアウト (経路確保 編)

最後の難関。コンテナにリポジトリの中身 (ソースコード) を展開できなくては、CI システムと呼べないも同然です。

コンテナからリモートリポジトリ (ホストのファイル) へアクセスする経路を確保できれば OK。そのためには mount が使えます。mount はコンテナの create 時に設定できます。

```sh
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 がコミットを作成するようなことをしたい場合は、このオプションをつけない

これで、コンテナからリモートリポジトリへの経路が確保できました。試しにこの設定でコンテナを作り、コンテナに入って確かめてみましょう。

```sh
# 作業場ディレクトリをカレントで
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 編)

```txt
/
|- home/
   |- ci/  <- カレント
   |- cirepo.git/
   |- ubuntu (なんかデフォルトであったディレクトリ, 特に触れることはない)
```

こうなれば、あとは `git clone ../cirepo.git` だけ！･･･と言いたいところですが、いつも通りの clone と違い、以下の要件を満たしたいです。

- clone 後、指定のブランチにいる状態にしたい
  - 通常、デフォルトブランチ (通常 `main` or `master`) の状態から始まります
  - CI では push したブランチを対象に処理するので、そのまま指定のブランチに checkout した状態から初めたいです
- 全履歴は要らないので早く clone したい
  - デフォルトでは、全ての履歴を取得します
  - CI では push された最新のコミットさえあれば良いので、全履歴を取得してしまうと時間の無駄になってしまいます
- 新規ディレクトリを作らず、カレント内にソースを展開したい
  - `cirepo` ディレクトリをリポジトリにするのではなく、`./` をリポジトリにします
  - これにより、cd でリポジトリに入る手間が省けます
  - GitHub Actions の `actions/checkout` と同じような動きにします

ブランチ指定は `-b <branch_name>`、クローン深さは `--depth <depth>`、ローカルリポジトリにするディレクトリはリモートリポジトリ URI 後に指定できます。これらを適用してクローンしてみましょう。

```sh
# -- 引き続きコンテナ内 --

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://` でないとダメなようです。それをつけてやり直し。

```sh
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` を実装します。

```sh
#!/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/post-receive` に書き込みます。実行権限の付与もお忘れなく。

```sh
cd ~/ci-work

ls
# cirepo.git  cirepo

vim cirepo.git/hooks/post-receive  # 任意のテキストエディタで上のスクリプトを書く

chmod +x cirepo.git/hooks/post-receive
```

それでは、ローカルリポジトリに入って、早速動かしてみましょう。

```sh
cd ~/ci-work
cd cirepo

vim workflow.sh  # 任意のテキストエディタで
```

とりあえず、何か `echo` してもらうだけの CI です。

```workflow.sh
#!/usr/bin/env sh

echo HELLO
```

そして commit と push。

```sh
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 を入れる形でイメージを作ります。

```dockerfile
FROM denoland/deno:ubuntu

RUN apt update
RUN apt install -y git
CMD ["/bin/bash"]

# CMD /bin/bash
# でも動きますが、Warning が出ます
```

:::note
イメージ `ubuntu` では、起動後にデフォルトで `/bin/bash` を実行するように作られています。

しかし `denoland/deno` ではそうなっていません。すると、ターミナルが起動されることなく、すぐにコンテナが終了してしまいます。

そこで、`CMD` で `/bin/bash` を実行するようにして、コンテナが立ち上がった状態で保たれるようにします。
:::

ビルドします。

```sh
# 古い git-ci-img を削除しておく
docker rmi git-ci-img

docker build -t git-ci-img .
```

イメージ名は、先の完成品と変えていないので、`post-receive` 編集の必要は無し。

次に、テストコードの追加。雑に足し算のテストをします。

```sum.test.ts
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` を編集します。

```workflow.sh
#!/usr/bin/env sh

echo HELLO

deno test *.test.ts
```

commit と push。

```sh
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 ツールってキレイに整備してくれてるんやなって･･･。

完成品ですが、まあ使い心地はそんなに良いものではありません (ログも保存されないし)。よほど特殊な環境でもない限り、こんな感じで実装することはそう無いでしょう。