GitHub Actions の JavaScript Action を TypeScript で書いた

GitHub Action を TypeScript で作成したので,覚え書きがてらどうやって作ったかについて書きます. github-action-benchmark という Action をつくりました.

紹介記事:継続的にベンチマークを取るための GitHub Action をつくった

Action とは

今年9月に GitHub Action v2 がリリースされました.GitHub Action は GitHub が提供する CI/CD サービスです. 既存のサービスと大きく違う点は,処理を汎用的に Action として切り出して再利用できることです. 例えば,GitHub からのリポジトリのクローン actions/fetch や Node.js のセットアップ actions/setup-node などの基本的な実行ステップも Action として実装されています.

Action の種類

GitHub Action には JavaScript Action と Docker Action があります.下記のページに公式の解説があります.

https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-actions

この記事では後者のみに絞ります.

Hello world

まず JavaScript で雛形を書きます. Creating a JavaScript action を見て順番に試していけば良いので,ここで解説することは特にありません. action.yml を置き,npm init して @actions/* パッケージを入れ,index.js を書きます.

次に npm install --save-dev typescript して TypeScript コンパイラを入れ,index.jsindex.ts に書き換え,tsconfig.json を用意します.

{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "preserveConstEnums": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noEmitOnError": true,
    "strictNullChecks": true,
    "target": "es2019",
    "sourceMap": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "files": [
    "index.ts"
  ]
}

もしくは公式のテンプレートを使っても良いです.

公式の @actions/* パッケージは TypeScript で書かれていて型定義を持っているのでこのままでほぼ問題ありませんが,残念ながら一発ではコンパイルが通りません.

@actions/github が使っている @octokit/graphql は v4 から TypeScript に対応しているのですが,@actions/github が使っているのは v2 なので型定義がありません. 少し調べてみると,@actions/github@octokit/graphql の型定義を自前で抱えていることが分かったので,v4 に対応するまではそこのものをコピーしてくるか,下記のように回避するための型定義ファイルを書いて自分のプロジェクトに含めておく必要がありました.

// Workaround for @actions/github until @octokit/graphql v4 is used
declare module '@octokit/graphql' {
    type GraphQlQueryResponse = unknown;
    type Variables = unknown;
}

最後にテスト用の workflow ファイルを更新します.TypeScript のコンパイルが必要になるので,リポジトリを checkout してきて node をセットアップし,npm install して tscコンパイルします. 実行する Action を ./ で始まる相対パスにしておくとローカルのアクションを実行してくれます. ちなみに . ではダメで ./ にする必要があって,少しハマりました.

      runs-on: ubuntu-latest
      name: A job to say hello
      steps:
+     - uses: actions/checkout@v1
+     - uses: actions/setup-node@v1
+     - run: npm install
+     - run: ./node_modules/.bin/tsc -p .
+     - name: Hello world action step
        id: hello
-       uses: actions/hello-world-javascript-action@master
+       uses: ./
        with:
          who-to-greet: 'Mona the Octocat'
      # Use the output from the `hello` step

後は自分の好きなように入力を定義し,処理を書いて結果を出力するだけなので,基本的に下記のドキュメントを見て進めれば OK です.

この記事では今回 github-action-benchmark を作成する際に引っかかったポイントや考えたポイントだけ書きます.

Action の入力

Action への入力は workflow ファイルでは with:key: value で複数していします.JavaScript Action では @actions/core パッケージの getInput(name: string): string で値を取得できます.

少し注意する必要があるのは,入力は文字列型オンリーな点です.この入力には array や map が使えません.なので,例えば下記のようなリスト形式やキーバリュー形式の入力を定義することはできません.

uses: ./
with:
  awesome-array-option:
  - one
  - two
  - three
  awesome-map-option:
    key: value
    key2: value2

これは入力が Docker Action で環境変数 $INPUT_*マッピングされていることに由来していそうですが,理由は不明です.

リストを使いたい場合はどうすれば良いのかなといくつか公式 Action を調べてみると,

with:
  awesome-array-option: |
    one
    two
    three

のように改行区切りの文字列で指定させて Action 側で処理しているものがありました.

Action のデバッグ

GitHub Action の実行環境と同じ環境変数(例えば入力 foo-bar$INPUT_FOO_BAR)を与えて node index.js で実行するか,Action をプッシュしてテスト用の workflow の実行結果を見てデバッグすることになります.簡単なものであれば前者だけで事足りますが,最終的に GitHub Action で実行して結果を確認することになると思います.

@actions/corecore.debug()デバッグ情報を埋め込んでおくのですが,これは(デバッグ情報なので当然ですが)デフォルトでは表示されません.

デバッグ情報は特定の secret に true をセットすることで初めて表示されます.secret は本来リポジトリ固有の秘密の情報(API トークンとか)を保存する場所ですが,デバッグ情報の設定にも利用されています.リポジトリページ → Settings → Secrets タブ で設定できます.

  • ACTIONS_RUNNER_DEBUG: workflow を実行する Runner の実行ログ
  • ACTIONS_STEP_DEBUG: 各ステップの実行ログ

Action のデバッグ情報を見るには後者が必要です.

Action のテスト

単体テスト

普通の npm パッケージと同じようにテストすれば OK です. GitHub Action と同じ環境変数を与えてテストを実行すれば OK です.

github-action-benchmark では単体テスト中に git コマンドを実行されると困るのと,@actions/core の実装のロジックに依存するのが嫌だったので @actions/coremock-require で mock してしまってます. 真面目にやるならテスト用の Git リポジトリを作ってその中でテストケースを走らせるべきですが,その辺はサボってます.

E2Eテスト

実際に Action を走らせて期待した動作になっているかは GitHub Action で走らせて結果を見るのが一番良いと思います. github-action-benchmark では CI 用の workflow の中で action をビルドして実行し,実行後に期待した状態になっているかをスクリプトでチェックしています.

これだと「正しく fail するか」をテストすることができない(action が fail するとその時点で workflow が止まってしまうので)のですが,どうやれば良いかはまだよく分かっていないです…(誰かご存知なら教えてもらえると嬉しいです)

Action の公開

Action は GitHub の公開リポジトリに置いておくと即使えるようになります.例えば user/foo-action というリポジトリをつくると

name: user/foo-action@ref

のように書くだけで使えるようになります.ここで ref は Git リポジトリの ref で,ブランチ名やタグ名,コミットハッシュなどが使えます. なので,この ref で Action のバージョンを制御するのですが,セマンティックバージョニングをサポートするような仕組みは無いので,自前でルールを定める必要があります.

github-action-benchmark ではセマンティックバージョニングに従ってリリースすることを決めました.

  • rhysd/github-action-benchmark@v1 は v1.x.y の最新を表す
  • rhysd/github-action-benchmark@v1.1.0 は v1.0.0 に固定することを表す
  • マイナーバージョンまでの固定や範囲指定はサポートしない(必要性を感じなかったので)

としました.

また,JavaScript Action は Action のリポジトリを checkout した時点で即 node . で実行できるようになっていないといけないので,TypeScript や babel を使っている場合は事前にコンパイルした JavaScript コードをリポジトリに置いておく必要があります. また,Action 実行時に npm install も行わないので node_modules も丸ごと置いておく必要があります.native extension を利用しているパッケージが node_modules に含まれている時にどうすれば良いのかはよく分かりません…

master ブランチにビルドした生成物と依存パッケージを直置きしたくなかったので,下記のように運用することにしました

  • 各メジャーバージョンごとに orphan ブランチを切る.例えば v1.x.y 向けは v1 ブランチ
  • 新しいバージョンのリリースは対応するメジャーバージョンのブランチで行う.例えば現在の master ブランチを v1.0.2 にリリースする場合は,まずは master ブランチで TypeScript コードを JavaScriptコンパイルし,v1 ブランチに切り替えてそれらを add して commit し,v1.0.2 タグをつけて push します
  • 次のメジャーバージョン(例えば v2)をリリースする時は前バージョンの開発用ブランチ(例えば dev/v1)を作っておいて v1.x.y 向けの修正はこちらから v1 ブランチに入れる

f:id:rhysd:20191115211932p:plain
branch 戦略

ちなみに公式の推奨では release/v1 ブランチをつくって v1 タグを毎回最新に張り替えることを推奨しています.これはリリース前に release/v1 上でテストを行う前提なんだと思いますが,毎回張り替えないといけないのと,master でテストしてから v1 ブランチに成果物を置く前提だったのでブランチ名を v1 としています.

ちなみに masterv1 へは簡単なスクリプトを使って成果物を置いてます.

https://github.com/rhysd/github-action-benchmark/blob/master/scripts/prepare-release.sh

Action で発行される GitHub API トークンの注意

GitHub Action では実行ごとに GitHub APIトークンが自動生成され secrets.GITHUB_TOKEN に格納されます.基本的には普通の API トークンのように使えるのですが,どうやらパーソナルアクセストークンとは異なりイベントを起こす権限を持っていないようです. 例えば GitHub Pages のブランチに push することはできますが,GitHub Pages のデプロイをすることはできません.

https://github.community/t5/GitHub-Actions/Github-action-not-triggering-gh-pages-upon-push/td-p/26869

この制限のため,github-action-benchmark ではパーソナルアクセストークンが必要になってしまっています.

ちなみにこの制限はなぜか public repo のみで,private repo では問題なく GitHub Pages のデプロイができました.