GitHub Actions で config pack をしたい (dictknife のススメ)

# はじめに

こんにちは Bugfire です。
早いものでクラウドワークスにジョインして1年がすぎました。
やりたかったことはできているでしょうか。

[クラウドワークス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/crowdworks) の4日目になりました。

# 目的

表題の通りです。Github Actions では、action を外部レポジトリから、もしくはローカルファイルから実行できます。

でも、表題のやつが欲しいのです。action を指定するところを何度も同じものを書きたくないのです。フラグを大量に用意して大部分を DRY に書くことはできそうですが、平易に書き下したいのです。if をいっぱい書きたくないのです。

ソースを見れば察する、という人は [GitHub bugfire/gh-actions-test](https://github.com/Bugfire/gh-actions-test) をどうぞ。

<!-- more -->

## さてどうするか

Template のツールはいくつもありますね。自分で作っても良いですが、今はコードを増やしたくないです。(前職では JSON Schema から API コード生成をしていたので作りました)

JSON Schema の [$ref 記法 - JSON Reference](https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03) がツールの選択肢も広そうです。その線で検索しました。[dictknife](https://github.com/podhmo/dictknife) を使ってみることにしました。

## dictknife を試してみる

### 環境を作る

python を直接使える環境で、`pip install pyyaml dictknife` をするなら、それで良いです。
自分はローカル環境にはあまりインストールしないので、Docker で環境を作ります。といっても小さいです。

```dockerfile:Dockerfile
FROM python:3
RUN apt-get update && pip install --upgrade pip && pip install pyyaml dictknife
VOLUME /work
WORKDIR /work
```

小さいですね。dictknife に入っている jsonknife を使います。(jsonknife って OSS がありますが、関係ないです)

```shell_session
$ docker build -t dictknife .
# 略
$ docker run --rm dictknife jsonknife
usage: jsonknife [-h]
                 [--log {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                 [-q] [--debug]
                 {cut,deref,select,bundle,separate,examples} ...
# 略
```

起動しました。

### 試してみる

こんな感じで書いてみます。

```src/test.yml
foo:
  bar: 1
ref_local:
  $ref: "#/sub_item"
ref_remote:
  $ref: "test_remote.yml#/remote_item/umu"
sub_item:
  dayo: ne
```

```src/test_remote.yml
remote_item:
  umu:
    homu: 9
```

見れば分かりますね。`$ref` に、`<URI>`, `#<JSON Path>`, `<URI>#<JSON Path>` を書くだけです。`<URI>`がなければ同じファイル、`#<JSON Path>`がなければファイル全体を指します。

実行してみましょう

```shell_session
$ docker run --rm -v $(PWD):/work dictknife jsonknife select --src src/test.yml
foo:
  bar: 1
ref_local:
  dayo: ne
ref_remote:
  homu: 9
sub_item:
  dayo: ne
```

すばらしいですね。

## GitHub Actions で使ってみる

GitHub Actions 用のファイルをどこにおくべきか困ったのですが、`.github` の中に `workflow` 以外のディレクトリを作って良いのか判断に迷ったので、`.github-actions-src` を作ります。
(公式ドキュメントでは、`.github/actions` にローカル Action を設置するようなサンプルはありますが）

ファイルを置くルールを決めます。深い理由はないです。

- `.github-actions-src/` スクリプトを置く
  - `src/` テンプレートが展開されるファイルを置く
     - `ref/` 参照のみされるファイルを置く

`.github-actions-src/src` の直下のファイルを jsonknife で `.github/workflow` にビルドする形ですね。以下の様なスクリプトを書きました。（source コマンド経由では動きません)

```.github-actions-src/build-docker.sh
#!/bin/sh
OUT_DIR=../../.github/workflows/
cd `dirname $0`/src
for i in *.yml
do
  docker run --rm -v $PWD:/work dictknife jsonknife select --src $i > $OUT_DIR/$i
done
```

### 統合実行のバリデーション

`.github/workflow` そのものは、workflow から起動されるため、github actions で生成はできません。手動で実行して commit しておく必要があります。

しかし、手動だと忘れるのは世の常。CI で統合を行い、正しい workflow がコミットされているか確かめます。

```.github-actions-src/src/workflow-settings-validation.yml
name: workflow settings validation

'on': [ push ]

jobs:
  validation:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-python@v2
      with:
        python-version: 3.9
    # https://github.com/actions/cache/blob/master/examples.md#using-a-script-to-get-cache-location
    - name: Get pip cache dir
      id: pip-cache
      run: 'echo "::set-output name=dir::$(pip cache dir)"'
    - uses: actions/cache@v2
      with:
        path: ${{ steps.pip-cache.outputs.dir }}
        key: ${{ runner.os }}-pip-dictknife
        restore-keys: |
          ${{ runner.os }}-pip-
    - name: Install dictknife
      run: |
        python -m pip install --upgrade pip
        pip install pyyaml dictknife
    - name: Validation
      run: |
        cp -R .github/workflows .github/workflows.orig
        .github-actions-src/build-local.sh
        diff -ur .github/workflows .github/workflows.orig || ( echo '.github/workflows に差分があります' && exit 1 )
        rm -R .github/workflows.orig
        echo 'validation complete'
```

#### 注意点

- key 名で `on` を使うと、なぜか true になります。明示的に文字列の `'on'` にします。
- コメントは綺麗さっぱり消えます。

## テストを書いてみる

フロントエンド側のワークフローを考えてみましょう。

![gh_flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180344/a330f212-6efb-617d-0f91-6ca73b8d0b9a.png)

並列化できるところは並列化しておきたいので、こんな感じですかね？
しかし、個々のプロセスが小さいなら Job を大量に作るより、一つにまとめた方が立ち上がりの時間の合計と比較すると早そうです。しかし、気がついたら肥大化しているのがプロジェクトです。

ともあれ、中身の定義は適当ですが、図のフローをそのまま workflow に起こしてみます。

```.github-actions-src/src/node-env.yml
name: Node env

'on': [ push ]

env:
  CACHE_VERSION: 'test-node'
  TARGET_PATH: '.'
  ARTIFACT_NAME: 'test-node'
  ARTIFACT_PATH: './build'

#         setup
#           |
#   +-------+--------+
#   |       |        |
# lint  unit_test  build
#   |       |        |
#   |       |      e2e_test
#   |       |        |
#   +-------+--------+
#           |
#         deploy

jobs:
  setup:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'

  lint:
    needs: [ setup ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'
      - $ref: 'ref/node-env-common.yml#/lint'

  unit_test:
    needs: [ setup ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'
      - $ref: 'ref/node-env-common.yml#/unit_test'

  build:
    needs: [ setup ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'
      - $ref: 'ref/node-env-common.yml#/build'
      - $ref: 'ref/node-env-common.yml#/store_artifact'

  e2e_test:
    needs: [ build ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'
      - $ref: 'ref/node-env-common.yml#/restore_artifact'
      - $ref: 'ref/node-env-common.yml#/e2e_test'

  deploy:
    needs: [ lint, unit_test, e2e_test ]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - $ref: 'ref/node-env-common.yml#/checkout'
      - $ref: 'ref/node-env-common.yml#/setup'
      - $ref: 'ref/node-env-common.yml#/cache'
      - $ref: 'ref/node-env-common.yml#/install'
      - $ref: 'ref/node-env-common.yml#/restore_artifact'
      - $ref: 'ref/node-env-common.yml#/deploy'
```

```.github-actions-src/src/ref/node-env-common.yml
# 使用 env
#   TARGET_PATH: package.json のあるディレクトリ
#   CACHE_VERSION: cache 用の prefix
#   ARTIFACT_NAME: artifact 保存用の名前
#   ARTIFACT_PATH: artifact を保存するディレクトリ
#

checkout:
  uses: actions/checkout@v2
setup:
  uses: actions/setup-node@v2.1.2
  with:
    node-version: 12.x
    check-latest: true
cache:
  uses: actions/cache@v2
  with:
    path: ${{ env.TARGET_PATH }}/node_modules
    key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-v12-${{ hashFiles(format('{0}/package-lock.json', env.TARGET_PATH)) }}
    restore-keys: |
      ${{ env.CACHE_VERSION }}-${{ runner.os }}-v12-
install:
  run: |
    cd ${{ env.TARGET_PATH }}
    cp package-lock.json package-lock.json.back
    npm install
    diff -u package-lock.json package-lock.json.back || ( echo '${{ env.TARGET_PATH }}/package-lock.json に差分があります' && exit 1)
store_artifact:
  uses: actions/upload-artifact@v2
  with:
    name: ${{ env.ARTIFACT_NAME }}
    path: ${{ env.ARTIFACT_PATH }}
restore_artifact:
  uses: actions/download-artifact@v2
  with:
    name: ${{ env.ARTIFACT_NAME }}
    path: ${{ env.ARTIFACT_PATH }}
lint:
  name: Lint
  run: npm run --silent lint
unit_test:
  name: Test
  run: npm run --silent test
build:
  name: Build
  run: npm run --silent build
e2e_test:
  name: E2E Test
  run: |
    echo 'E2E Test dummy'
    ls ${{ env.ARTIFACT_PATH }}
deploy:
  name: Deploy
  run: |
    echo 'Deploy dummy'
    ls ${{ env.ARTIFACT_PATH }}
```

凄まじいコピペ感を感じる...!!!

JSON Reference は Object のマージができないので、複数 step (配列要素) をまとめることは出来ないですが、直接記述するよりは全然よいです。

グローバル変数を使った引数渡しの関数呼び出しみたいですね...。
一応 env は、Global, job, job.step で設定できるので、job 単位のスコープにはできます。

## まとめ

[We're hiring！](https://www.wantedly.com/projects/73940)
dictknife 便利！

以上のサンプルは [GitHub bugfire/gh-actions-test](https://github.com/Bugfire/gh-actions-test) に置いてあります。