🦕

DenoでjsのActionを作成するテンプレートを用意しました

2023/11/30に公開

先日actions-timelineというactionを公開し、その記事を書きました。

https://zenn.dev/cybozu_ept/articles/20231002_actions_timeline

このActionはDenoで作成しています。普通はjsのActionは素のNode.js、もしくはTypeScriptで作成するのが一般的ですが、もしかしてDenoでもいけるんじゃない?とチャレンジしてみたところ案外普通に動かすことができました。

DenoでActionを作っている人、作れることを知っている人はまだかなり少ないと思いますので、Denoの布教のためにもactions-timelineから汎用化したテンプレートを作成してみました。

https://github.com/Kesin11/deno-action-template

このテンプレートから始めてもらうと、TypeScriptを使いながらも面倒なtsconfigの設定や、formatter, linterをインストールする必要がなくなります。ESLintやPrettierは最初にセットアップするのも少々面倒ですし、その後にそれぞれのバージョンアップに付き合っていくのもまた手間がかかるので、こういった小規模なリポジトリほどオールインワンのDenoの便利さが光ります。

GitHub Actionsの種類について

DenoでActionを作成する方法の前に、まずGitHub ActionsのActionの種類の復習をしておきましょう。Actionsの種類は3つあります。

今回作成するのはJavaScriptのActionになります。一般的にはJavaScript以外の言語、例えばGoなどでActionを作成する場合に手っ取り早いのはDocker containerになるのですが、このタイプはdocker pullに時間がかかるという大きなデメリットがあります。JavaScriptのActionはGitHub Actionsのランナーに内蔵されているNode.jsで実行されるため、ランタイムをAction本体に含める必要がないことからサイズを圧倒的に小さくできるのです。

JavaScriptのActionはTypeScriptで作成されることが多く、おそらくほとんどの作者は最初にGitHub公式のテンプレートである actions/typescript-action を参考にしていると思います。このテンプレートはtsを tsc でjsに変換し、その後に ncc で依存パッケージを含めて1つのindex.jsにバンドルします。

JavaScriptのActionは実行時に npm install による依存パッケージのインストール処理が実行されないため、Actionのリポジトリに依存パッケージも含めてコミットしておく必要があります。しかし node_modules も含めてコミットするとリポジトリのサイズが大きくなってしまうため、 ncc のようなバンドラーを用いてindex.jsといった1ファイルに全てをまとめてこれをコミットするという方法が使われています。

という仕組みであることを考えると、同じTypeScriptであるDenoでも何らかの方法でjsに変換し、さらに依存パッケージをバンドルできればJavaScriptのActionとして動かすことはできそうな気がしてきますね?

Denoをjsに変換する

まず活躍してもらうのが dnt です。 dnt は本来Denoからnpmパッケージを作るためのツールで、DenoのTypeScriptから js + node_modules を生成してくれます。

https://github.com/denoland/dnt

そして dnt が生成してくれたjsをesbuildでバンドルします。バンドルに使用するのは ncc でも問題ないと思いますが、esbuildはCLI以外にDeno用のパッケージも存在するので dnt と組み合わせてビルド用のスクリプトのDenoで書けるので便利です。ビルド用のスクリプトについては後ほど解説します。

actions/typescript-action をインスパイアして、この一連のビルドの仕組みも含めてDenoでActionを作成するための一式をテンプレートリポジトリとして用意しました。

https://github.com/Kesin11/deno-action-template

リポジトリを開いて頂いて右上の Use this template ボタンから自分のリポジトリとしてコピーが可能です。コピー後は action.yml や実際のコードを編集してもらってから deno task bundle を実行するとActionとして実行可能なjsが dist/ ディレクトリに生成されます。GitHub ActionsのCIも用意してあるので、pull-requestを作成すると自動で deno fmt, deno lint, deno test のチェックが実行され、またDenoのコードを修正後 dist/ に生成されたjsをコミットし忘れていないかのdiffチェックも行われます。

DenoでActionを作成することに興味を持った方はぜひ使ってみてください。

deno-action-templateの解説

さて、ここからは用意したテンプレートリポジトリの要素を順に解説していきます。とはいえ、中身について詳しく知らなくてもActionを作れるようにするためのテンプレートですので、解説を見ないと使えないということはありません。READMEを読んでもらえればリリース作業までの流れは分かるはずですが、より詳しく知りたい方は続きをどうぞ。

ちなみに以下の解説は Kesin11/deno-actions-templateのv0.1.0 の時点のものになります。将来的には使用するツールや設定は変更されている可能性があります。

actions.yml

通常のaction.ymlと特に変わるところはありません。Actionを呼び出したときに実行するコードは main: dist/main.js になっています。前処理(pre)や後処理(post)も実行したい場合はそれぞれ prepost を設定してください。テンプレートの段階では mainpost だけが設定済みです。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/action.yml

また、name, description, author はコピー後に書き換えてください。

src/main.ts, src/post.ts

Actionとして実際の処理を行うエントリーポイントのコードです。ファイル名は何でも良いですが、actions.ymlに対応するように main.tspost.ts にしています。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/src/main.ts

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/src/post.ts

サンプルとして @actions/core を使ったログ出し、ジョブのサマリーへの書き込みを実装しています。

scripts/build.ts

今回のテンプレートの本丸となるのがこのビルドスクリプトです。dntとesbuildの部分についてそれぞれ解説します。

まずはdntの部分です。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/scripts/build.ts#L7-L25

typecheck, declaration などnpmパッケージを作る場合は有効にするべきでしょうが、Actionでは不要なのでfalseにします。 dntはなんと律儀にもjsに変換した後Node.jsでテストを実行する機能が備わっていますが、Denoでテストを動かせば十分だと思うので test もfalseにしています。jsに変換後の挙動の互換性に関しては後述するCIでの統合テストで担保しています。

esModule もfalseにしてます。現状のランナー内蔵のNode.js v20では通常はCommonJSとして実行されるのが主な理由です。頑張ればESModuleでも動かせるかもしれませんが、今のところは特に困っていないのでCommonJSとして出力することにしました。

shims: { deno: true } はDenoの組み込み機能をNode.jsでも再現するためのコードを含めるために必要です。これを有効にしないと、例えば Deno.env などを含むコードをNode.jsが実行できなくなってしまいますが、変換後のjsにshimsのコードが含まれるぶんだけコードサイズは増えてしまいます。自分で確認した限りでは、このオプションを有効にしていた場合でもshims必要なコードが一切含まれなければ変換後のjsにshimsのコードは含まれないので無駄にコードサイズは増えないようでした[1]。つまり、例えば変換前のTypeScriptで環境変数を参照するコードは Deno.env を使う代わりに Node.jsの node:process をimportして process.env を使うようにすればコードサイズは増えずに済みます。ただ、対応忘れが1箇所でも存在すると実行時エラーになってしまいますので、念のための保険としてshimsは有効にしておいても損はないでしょう。

最後の package 部分はdntが生成するpackage.jsonにコピーされる内容となっています。npmパッケージとして配布する場合は重要ですが、Actionではpackage.jsonは使用されないので本来は不要です。しかし何かしらの値が入っていないとdntがエラーで実行されなくなってしまうので適当な値を入れておきます。

次はesbuildの部分です。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/scripts/build.ts#L27-L42

まず node_modules/ の中身も含めて1ファイルのjsにまとめたいので bundle: true にします。 platform は当然 nodetarget はaction.ymlで指定したNode.jsのバージョンに合わせて node20format はdntと同じ理由でCommonJSの cjs にします。

minifysourcemap もfalseにします。ここまで各所でコードサイズについて気にしてきたのにminifyしないのかよ!という声が聞こえてきますが、minifyを有効にするとActionが万が一エラーで止まってしまった場合にスタックトレースがminifyされたjsで表示されてしまうため、ビルドログを見ても何が原因だったのか全く分からなくなってしまうという問題があります。

sourcemapも同時に出力して配布すればその問題自体の解決は可能であり、実際にGitHub Actionsでも以下のように envNODE_OPTIONS: --enable-source-maps を指定すればスタックトレースで元のTypeScriptのコード位置を表示可能です。

# deno-action-templateリポジトリの.github/workflows/ci.ymlで
# 自分自身を `uses` して実行する際にsourcemapを有効にする例

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run self
        uses: ./
        env:
          NODE_OPTIONS: "--enable-source-maps"
        with:
          message: foobar

# 適当な位置でエラーを発生させてみると、スタックトレースで元のTypeScriptのコード位置が表示される
# Run ./
# Start main process
# /home/runner/work/deno-action-template/deno-action-template/npm/src/github.ts:5
#   throw new Error("Raise error");
#         ^


# Error: Raise error
#     at writeSummary (/home/runner/work/deno-action-template/deno-action-template/npm/src/github.ts:5:9)
#     at main (/home/runner/work/deno-action-template/deno-action-template/npm/src/main.ts:9:9)
#     at Object.<anonymous> (/home/runner/work/deno-action-template/deno-action-template/npm/src/main.ts:13:1)
#     at Module._compile (node:internal/modules/cjs/loader:1241:14)
#     at Module._extensions..js (node:internal/modules/cjs/loader:1295:10)
#     at Module.load (node:internal/modules/cjs/loader:1091:32)
#     at Module._load (node:internal/modules/cjs/loader:938:12)
#     at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12)
#     at node:internal/main/run_main_module:23:47

# Node.js v20.8.1

ただし、sourcemapを有効にするためには当然ながらminifyしたjsに加えてsourcemapの.js.mapもdist/にコミットして同梱する必要があります。この.js.mapのサイズは意外と大きいため、同梱すると実はminifyをしていない.jsのみと比較してトータルのファイルサイズが大きいため本末転倒になってしまうのです。

minify: true, sourcemap: trueの場合。

$ ls -lh dist
total 3.3M
-rw-r--r-- 1 kesin kesin 428K Nov 28 00:07 main.js
-rw-r--r-- 1 kesin kesin 1.2M Nov 28 00:07 main.js.map
-rw-r--r-- 1 kesin kesin 428K Nov 28 00:07 post.js
-rw-r--r-- 1 kesin kesin 1.2M Nov 28 00:07 post.js.map

minify: false, sourcemap: falseの場合。

$ ls -lh dist
total 1.5M
-rw-r--r-- 1 kesin kesin 752K Nov 28 00:14 main.js
-rw-r--r-- 1 kesin kesin 752K Nov 28 00:14 post.js

minifyを無効化しておけばesbuildでバンドルされたjsであっても、スタックトレースのjsからそれなりに元のTypeScriptのコード位置を推測可能です。自分は最終的なコードサイズと物事をシンプルにするためminifyとsourcemapはfalseにしましたが、コードサイズに関しても大差がつくほどのものではないので好みの問題だと思います。スタックトレースに元のTypeScriptのコード位置を表示させたいという場合はminifyとsourcemapを有効にしてください。

そして最後の esbuild.stop() はesbuildのプロセスを終了させるために必要であるとesbuildのドキュメントに記載されているためです。子プロセスとして立ち上げるesbuildを終了させるためとのことなので、念のために esbuild.build({ ... }).finally() でstopさせることで esbuild.build が成功してもエラーなどで失敗してもstopは確実に実行されるようにしてみました。

テスト

テストはDenoの単体テストと、実際にGitHub Actions上で自分自身を実行させる統合テストの2種類を用意しています。

単体テストは一般的なDenoのテストと全く同じです。 Deno.test などを使用してテストコードを書き、 deno test で実行します。テスト用のコードはdntでjsに変換されてコミットされる内容に影響しないため、Deno.test を始めとしたDenoの組み込み関数をガンガン使っても問題ありません。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/tests/github_test.ts

統合テストではGitHub Actions上で実際に自分自身を uses: ./ で実行させています。単体テストのようにアサーションで何かの値を明確にチェックするわけではないのですが、Actionとしてエラーなく完走できるかどうかを確かめています。もしもDenoからjsへの変換に問題があったり、Actionの実行中に意図しないエラーが発生した場合はGitHub Actionsのジョブがfailするので分かるようになっています。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/.github/workflows/ci.yml#L40-L43

CI

CIでは前述の2種類のテストに加えて、 deno fmtdeno lint によるチェックジョブ、そして dnt + esbuild を実行する deno task bundle によって出力されるjsが最新のコミットと差分がないかをチェックしています。

GitHub Actionsは Kesin11/actions-timeline@main のようにブランチ指定で利用可能なため、常に deno task bundle の最新の結果をコミットしていないとTypeScriptのコードに対して実際に使用されるjsが古いままという状態が発生してしまいます。そのためpull-requestの時点で deno task bundle で生成される最新のjsをコミットを忘れていないかをCIでチェックしてもらいます。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/.github/workflows/ci.yml

リリースフロー

main ブランチが更新されるとGitHub Releasesに次のバージョンのドラフトが自動生成され、これまでにマージしたpull-requestを元にリリースノートも自動的に生成されています。新しいバージョンとしてリリースしても良いと判断したら、リリース用のワークフローをActionsのタブから手動で実行します。手動で実行するとドラフトであったGitHub Releasesが公開され、git tagタグも自動的に作成されるのでリリース作業自体はこれで完了です。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/.github/workflows/release.yml

実際に作成されるリリースノートはこのようになります。

https://github.com/Kesin11/deno-action-template/releases/tag/v0.1.0

このリリース作業の自動化は release-drafter とちょっとしたスクリプトで実現しています。release-drafterは次バージョンの決定やリリースノートの作成をGitHub Actionsから自動化することに特化しているツールで、言語に依存しないシンプルな機能だけを提供してくれるので個人的にはかなり気に入っています。

release-drafterはpull-requestのラベルに応じて次のバージョンをSemantic Versioningで決定します。Semantic Versioningを自動的に決定する仕組みとしてはConventional Commitsが有名ですが、release-drafterはコミットメッセージの代わりにpull-requestのラベルを使用します。例えば、現在のバージョンが v1.0.0 であったときに feature のラベルが付いたpull-requestをマージすると次のバージョンは v1.1.0BREAKING CHANGES のラベルが追加pull-requestをマージすると次のバージョンは v2.0.0 という具合です。どのラベルをSemantic Versioningの major, minor, patch に対応させるかは .github/release-drafter.yml でカスタマイズ可能で、 deno-action-template では以下のように設定しています。

  • major
    • BREAKING CHANGES
  • minor
    • feature
    • enhancement
  • patch
    • bug
    • security

また、マージしたpull-requestをリリースノート上のどのセクションに分類させるかもこのyamlで設定します。deno-action-templateのyamlはこのようになっており、例えば featureenhancement のラベルが付いたpull-requestは Features のセクションに分類されます。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/.github/release-drafter.yml

release-drafterをGitHub Actionsで実行する際、publish: true を設定しない場合はSemantic Versioningによって決定された次バージョンでドラフトのGitHub Releasesが作成されます。ドラフトのGitHub Releasesにはマージしたpull-requestからリリースノートが自動的に準備されているので、もしもこの時点でpull-requestのカテゴリ分けや次バージョンが意図しないものであった場合はマージ済みのpull-requestのラベルをこの時点で修正しておきます。

リリースノートに問題がなければリリース用のワークフローを手動で実行します。手動で実行した場合は publish: true でrelease-drafterを実行するジョブが起動するので、今までドラフトだったGitHub Releasesが実際に公開されます。自動的にGitHub Releasesが作られたバージョンのgit tagも作成されるのですが、このタグは v1.0.0 のようにpatchバージョンまで完全に指定されたタグになっています。Actionのリリース作業では例えば v1.0.0 をリリースする際には v1 というタグもアップデートするというのがデファクトスタンダードな方法なのですが、release-drafterはそこまで面倒を見てくれないのでちょっとしたスクリプトを書いて自力で対応させます。

release-drafterが決定したバージョンは steps.release-drafter.outputs.tag_name で参照できるため、以下のようにちょっとした正規表現で v1.0.0 から v1v1.0 といったmajor, minorまでを示すバージョン文字列を抽出します。後はこれらのバージョン文字列を使用して git push -f でtagを上書きするだけです。

https://github.com/Kesin11/deno-action-template/blob/v0.1.0/.github/workflows/release.yml#L40-L56

このような仕組みを整備するとリリース作業はブラウザでGitHubを開いてぽちぽちとリリース用のワークフローを起動するだけの簡単な作業になります。リリース作業が面倒だと心理的に新しいバージョンのリリースを後回しにしがちになり、色々な修正を含むビッグバンアップデートが生まれてしまいます。OSSのメンテコストを下げるためにリリース作業は極力簡単で自動化するべしという自分の信条をこのテンプレートリポジトリにも入れ込みました。

まとめ

自分が最近DenoでActionを作成した経験から、DenoでActionを作成するためのテンプレートリポジトリを用意しました。Denoが好きな方、Node.jsでjsのActionを作成した経験はあるがDenoもちょっと遊んでみたいという方はぜひこのテンプレートを使ってみてください。

また、このテンプレートの構成自体はまだ発展の余地がたくさんあると思っています。例えばKesin11/actions-timelineの方では、依存しているDenoとnpmのパッケージ両方のアップデートをRenovateで自動化しています。Renovateは公式にはDenoに対応していないので自力で設定を書いたのですが、まだ安定して動作するかどうかの様子見中です。

他には最近リリースされたDeno v1.38からnpmパッケージをpackage.jsonとnode_modules/でインストールする機能がunstableで追加されたので気になっています。Actionを作成するにはどうしてもoctokitactions/toolkitなどのnpmパッケージが色々と必要になるため、npmパッケージをより簡単に管理する方法が提供されるのであれば従来のDenoのやり方から積極的に変更してもいいかもしれないと思っています。

脚注
  1. 生成されたjsに対して Deno などの文字列を検索して確認しました。 ↩︎

Discussion