生産性向上ブログ

継続的な生産性向上を目指すエンジニアのためのブログ

GitHub Actions のアクションのつくりかた(JavaScript 編)

help.github.com

この記事では、新しくなった GitHub Actions における、JavaScript アクション作成方法について解説します。

www.kaizenprogrammer.com

GitHub Actions とはなんぞやという人は、先にこちらの過去記事をどうぞ。

目次

アクションについて

前回の記事で新しくなった GitHub Actions の使い方について簡単に解説しました。この記事執筆時点では GitHub Actions はまだベータなので、最新の情報を得るためには公式ドキュメントをご参照ください。

前の記事にも書いたとおり、GitHub Actions では「アクション」という単位で実行可能なタスクを作成し、コミュニティに公開することもできます。

アクションには以下の 2 種類が存在し、それぞれ利用可能な VM 環境が異なります。

種類 利用可能な VM 環境
Docker コンテナ Linux
JavaScript Linux, MacOS, Windows

この記事では、主に JavaScript によるアクションの作成方法について解説します。

JavaScript アクションは、GitHub が提供するすべての VM 環境から直接実行できます。Docker イメージのビルドやコンテナの起動コストが発生しない分、実行が高速です。

アクションの保存場所

公開するアクションの場合、アクションのためのリポジトリを作成して、他のコードとは別に管理することが推奨されています。

公開しない場合はどこに保存してもよく、単独のリポジトリからのみ利用する場合は、.github ディレクトリ下に保存することが推奨のようです。(.github/actions/action-a, .github/actions/action-b のように)

アクションのバージョニング

アクションを利用するときは、以下の 3 つの方法で参照できます。

参照方法
commit SHA actions/setup-node@74bc508
タグ actions/setup-node@v1.0
ブランチ actions/setup-node@master

タグ作成時は、セマンティックバージョニングに従って作成することが推奨されています。

一方で、アクションを利用する側としては commit SHA 方式で参照するのが一番安全です。タグやブランチは上書き可能なためです。GitHub 公式のアクションならまだしも、信頼性の低いサードパーティーのアクションを利用するときは、安全性を確認した時点での commit SHA を指定して利用するのが無難でしょう。

簡単な例 (Hello, World)

実際に簡単なアクションを作成してみます。

GitHub Actions が有効になったユーザーか organization 下に public なリポジトリを作成し、以下のような action.yml ファイルを作成します。

name: "Hello World"
runs:
  using: "node12"
  main: "index.js"

詳細は後述しますが、この action.yml はアクションのメタデータを定義しています。name でアクションの名前を、runsusing で JavaScript アクションであることを、main でアクション実行時に読み込まれる JavaScript ファイルを定義しています。

そして、次のような index.js ファイルを作成します。

console.log("Hello, World!");

これは、main で定義した JavaScript ファイルで、アクション実行時にこのファイルが GitHub Actions の VM 上で実行されます。

動作確認のために、同じリポジトリ内の .github/workflows/hello.yml を以下のように作成します。

on: [push]

jobs:
  hello-world:
    runs-on: ubuntu-latest
    name: Greeting
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      - name: Hello
        uses: ./

同じリポジトリ内であれば、actions/checkout を実行した後に ./ のように相対パスでアクションを呼び出すことが可能です。

これらのファイルをすべて push したら、Actions タブを開いてアクションが実行されることを確認します。

f:id:miya-jan:20191015034922p:plain

今度は、別リポジトリからこのアクションを実行できることを確認します。そのために、まずはアクションを作成したリポジトリで v1 タグを作成して push します。

次に、新しいリポジトリを作成し、.github/workflows/hello.yml を以下のように作成します。

on: [push]

jobs:
  hello-world:
    runs-on: ubuntu-latest
    name: Greeting
    steps:
      - name: Hello
        uses: miyajan/javascript-action-hello@v1

今度はアクションの実行のためにはチェックアウトは必要なく、アクション実行時に対象のリポジトリの中身がダウンロードされます。

このファイルを push したら、Actions タブを開いてアクションが実行されることを確認します。

f:id:miya-jan:20191015035100p:plain

"Set up job" の段階で作成したアクションがダウンロードされていることがわかります。

以上で、"Hello, World!" と表示するだけの簡単なアクションが作成&公開でき、他のリポジトリからも利用可能になりました。

メタデータ

アクションは YAML 形式で書かれたメタデータが必要です。詳細は公式ドキュメントを参照してもらうとして、簡単に説明すると以下のような情報を定義できます。

  • name: アクション名
  • author: アクションの作成者
  • description: アクションの説明
  • inputs: アクション利用時に with で指定するパラメータ
  • outputs: このアクション以降のステップで利用するために出力されるパラメータ
  • runs: アクションが実行するコマンドについての情報
  • branding: Marketplace 公開時のアイコン情報

JavaScript アクションの使用する Node.js バージョン

現時点だと、action.ymlmainusing に指定できるのは、node12 だけです。

仮にアクションを呼び出す前に actions/setup-node で環境の Node.js のバージョンを変更していても、アクションの実行には Node.js の 12 系が使用されます。アクションの実行に使用される Node.js はある程度は環境と切り離されてると考えていいようです。

なので、JavaScript アクション開発時は、ローカルには Node.js 12 系を用意しましょう。

toolkit

github.com

アクションを作りやすくするために、公式で toolkit というリポジトリで複数のパッケージが公開されています。

詳細については各パッケージの README.md を見てもらうのが一番いいですが、簡単に説明すると以下のような機能を持つパッケージが存在します。

  • @actions/core: inputs の読み込み、outputs の設定、環境変数の出力、シークレットの設定、PATH の操作、終了ステータスの変更、ログ、状態管理など
  • @actions/exec: コマンドライン実行
  • @actions/io: ファイルシステム操作
  • @actions/tool-cache: ダウンロード、解凍、キャッシュ(ビルド間のキャッシュではなく、そのビルド内のみで使えるキャッシュ)など
  • @actions/github: context の操作、Ocktokit クライアント

node_modules の管理

アクションは、実行時に指定したリポジトリのソースコードをまるごとダウンロードして利用します。なので、node_modules 下の依存ライブラリもリポジトリに含める必要があります。

しかし、node_modulesmaster に含めてしまうと開発がしづらくなってしまうので、以下のようなリリースブランチとタグのみに node_modules を含める運用が推奨されています。

  1. master では .gitignorenode_modules を含め、masternode_modules をコミットしない
  2. メジャーバージョンごとにリリースブランチを作成し、リリースタイミングで master から変更を取り込む
    • このときに、.gitignore から node_modules を削除して、リリースブランチに node_modules を push
    • すでに node_modules が存在するときは、一度全削除して作り直してから push
  3. リリースブランチが安定したら、タグを作成して push

詳細は、以下のページをご参照ください。

github.com

node_modules をリポジトリに含めたくないという場合は、ncc というツールを使うとすべての依存関係を 1 つの JS ファイルにまとめることができるので、そちらが使えるようです。

github.com

プラットフォーム特有のコンパイルが必要な依存が node_modules 下に含まれてるときにどうなるのかは、まだ試していないのでよくわかっていないです。要探求です。

README.md

public なアクションを保存するリポジトリの README.md には次のような情報を含めることが推奨されています。

  • アクションがなにをするかについての詳細な説明
  • inputs/outputs についての説明
  • アクションが使用する秘密情報についての説明
  • アクションが使用する環境変数についての説明
  • アクションのワークフロー内での使用例

公式が提供しているアクションのサンプルリポジトリを見るとわかりやすいと思います。

github.com

公式テンプレート

github.com

公式で JavaScript アクションを作成時のためのテンプレートとなるリポジトリが用意されています。

.gitignore、ESLint による lint、Jest によるユニットテスト、それらを実行する GitHub Actions ワークフロー、公開やバージョニングについて書かれた README.md といったものが含まれています。

このリポジトリの "Use this template" ボタンからリポジトリを複製できます。新規アクション作成時は、とりあえずこのテンプレートから作り始めて、自分の作りたいアクションに合わせて修正していくのがよさそうです。

ただ、テンプレートからだと修正が必要な部分も多いので、慣れてきたら自分用のテンプレートを作成しておくのがよさそうにも思います。

少し実践的な例

ここまでの情報を使って、簡単な例よりもう少し実践的なアクションを作成してみます。

今度は、pull_request イベント時にプルリクエストにコメントを書き込むアクションを作成します。

まず、上記で説明したテンプレートから新たにリポジトリを作成します。

そして、action.yml を以下のように修正します。

name: "PR Comment"
description: "Comment on PR"
runs:
  using: "node12"
  main: "index.js"
inputs:
  message:
    description: "Message to comment"
    required: true
outputs:
  commentUrl:
    description: "Comment URL"

inputsmessage でプルリクエストに書き込むコメントの内容を指定し、書き込んだコメントへの URL が outputscommentUrl に保存される想定です。

テンプレートに含まれてない、アクションに必要なパッケージを npm install します。

$ npm install --save @actions/github
$ npm install --save-dev nock

package.json がテンプレートのままだと実態と合わないので、アクションに合わせて修正します。記事上では省略します。

次に、index.js を修正します。

const run = require('./run');

run();

テンプレートだと index.js 内に若干のロジックが書かれていますが、自分は完全にコード実行部分のみにしました。というのも、コード実行部分とロジックが同じファイルに同居してしまうとテストできなくなってしまうためです。

そして、ロジック部分を run.js に分離しました。

const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    // pull_request exists on payload when a pull_request event is triggered
    // Do nothing when pull_request does not exist on payload
    const pr = github.context.payload.pull_request;
    if (!pr) {
      console.log('github.context.payload.pull_request not exist');
      return;
    }

    // Retrieve GITHUB_TOKEN from environment variable
    // Do nothing when GITHUB_TOKEN does not exist
    const token = process.env['GITHUB_TOKEN'];
    if (!token) {
      console.log('GITHUB_TOKEN not exist');
      return;
    }

    // Get input
    const message = core.getInput('message');
    console.log(`message: ${message}`);

    // Create octokit client
    const octokit = new github.GitHub(token);

    // GITHUB_REPOSITORY is GitHub Action's built-in environment variable
    // https://help.github.com/en/articles/virtual-environments-for-github-actions#environment-variables
    const repoWithOwner = process.env['GITHUB_REPOSITORY'];
    const [owner, repo] = repoWithOwner.split('/');

    // Create a comment on PR
    // https://octokit.github.io/rest.js/#octokit-routes-issues-create-comment
    const response = await octokit.issues.createComment({
      owner,
      repo,
      issue_number: pr.number,
      body: message,
    });
    console.log(`created comment URL: ${response.data.html_url}`);

    core.setOutput('commentUrl', response.data.html_url);
  } catch (error) {
    core.setFailed(error.message);
  }
}

module.exports = run;

そして、テンプレートに存在した index.test.jsrun.test.js にして、以下のようにしました。(Jest に慣れてないので、これが一般的な書き方なのかは怪しいです)

const core = require('@actions/core');
const github = require('@actions/github');
const nock = require('nock');
nock.disableNetConnect();
const run = require('./run');

beforeEach(() => {
  jest.resetModules();

  github.context.payload = {
    action: 'opened',
    pull_request: {
      number: 1,
    },
  };
});

describe('run', () => {
  it('comments on PR', async () => {

    process.env['INPUT_MESSAGE'] = 'Test Comment';
    process.env['GITHUB_REPOSITORY'] = 'testorg/testrepo';
    process.env['GITHUB_TOKEN'] = 'test-github-token';

    nock('https://api.github.com')
      .post('/repos/testorg/testrepo/issues/1/comments',
        body => body.body === 'Test Comment')
      .reply(200, {html_url: 'https://github.com/testorg/testrepo/issues/1#issuecomment-1'});
    const setOutputMock = jest.spyOn(core, 'setOutput');

    await run();

    expect(setOutputMock).toHaveBeenCalledWith(
      'commentUrl',
      'https://github.com/testorg/testrepo/issues/1#issuecomment-1');
  });
});

inputs のパラメータは、環境変数の INPUT_<パラメータ名の大文字> で実は指定可能です。なので、ユニットテストでも process.env でモックできます。環境変数ももちろんモック可能です。

nock を使えば GitHub API とのやりとりも含めて GitHub Actions の一連の流れをテストすることが可能です。しかし、実際にこのアクションを作成するとしてこのテストを書くかと言われると、たぶんもう少しテストしやすい形にコードを整理すると思いますが、あくまでサンプルとして読んでください。

続いて、.github/workflows/test.yml も修正します。

name: "test-local"
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1

      - run: npm ci
      - run: npm test
      - uses: ./
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          message: PR comment test
        id: prComment
      - run: echo "Comment URL - ${{ steps.prComment.outputs.commentUrl }}"
        if: steps.prComment.outputs.commentUrl

GITHUB_TOKEN は組み込みで存在する秘密情報に存在するので、それを環境変数に渡すだけで使えます。

if は文字列が空のときは false の扱いになるので、特定の outputs が存在するときだけステップを実行するには上記のように ifoutputs の変数をそのまま指定すれば大丈夫です。

README.md もアクションに合わせて修正します。LICENSE も "GitHub Actions" の部分だけ修正が必要です。この記事内では省略します。

テンプレートに含まれてる wait.js はいらなくなるので消します。

もろもろ修正が終わったら、ブランチを作成して push します。

$ git checkout -b first-commit
$ git add .
$ git commit -m "first commit"
$ git push origin first-commit

push イベントでトリガーされるワークフローでは、payloadpull_request が存在しないからアクション内部でなにもしないで終了します。最後の URL 出力ステップもスキップされるので以下のようになります。

f:id:miya-jan:20191015040517p:plain

プルリクエストを作成すると、pull_request イベントでワークフローがトリガーされ、アクションによってプルリクエストにコメントが作成され、最後の URL 出力ステップも実行されます。

f:id:miya-jan:20191015040613p:plain

そして、プルリクエスト上でコメントを確認できます。

f:id:miya-jan:20191015040634p:plain

GITHUB_TOKEN に組み込みの秘密情報を渡しているため github-actions bot によってコメントが作成されます。自分がコメントしてるように表示させたいのであれば、自分のアクセストークンを GITHUB_TOKEN 環境変数に渡せば大丈夫です。

プルリクエストを master にマージし、公開するためにリリースブランチを作成します。まずは、.gitignore から node_modules をコメントアウトします。

# comment this out distribution branches
# node_modules/

リリースブランチを作成して、production の依存のみ残し、.gitignorenode_modulesgit add して、push します。

$ git checkout -b releases/v1
$ rm -rf node_modules
$ npm install --production
$ git add node_modules .gitignore
$ git commit -m node_modules
$ git push origin releases/v1

リリースブランチができたら、そのブランチからアクションを実行してみて問題ないかを確認します。適当なリポジトリの GitHub Actions で以下のような感じに実行してみます。

uses: miyajan/javascript-action-pr-comment@releases/v1
env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
  message: Nice PR!👍

無事動いてることを確認できたら、タグを作成します。

$ git tag v1
$ git push origin v1

これでアクションがタグ指定で実行できるようになりました。

uses: miyajan/javascript-action-pr-comment@v1
env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
  message: Nice PR!👍

f:id:miya-jan:20191015040752p:plain

その他

GitHub 公式のアクション

github.com

actions org 下に GitHub 公式のアクションが複数存在します。(アクション以外の toolkit とかもありますが)

サードパーティーのアクション

github.com

GitHub Marketplace の Actions 一覧から、サードパーティーのアクションを検索できます。(公式のアクションも含まれます)

まとめ

GitHub Actions における JavaScript アクションのつくりかたについて、サンプルを示しつつ解説しました。TypeScript 編とか Docker コンテナアクション編とかはまた別記事でやります。(時間があれば…)

基本的には公式ドキュメントを読みつつ、テンプレートや既存のアクションの実装を参考にしていけばどんどんアクションを作っていけそうという感想です。

NPM エコシステムに乗っかることによって、けっこう高機能なアクションでも簡単に作れそうなのがいいですね。あと、GitHub API との親和性が高いのもやはり強いです。

アクションの拡張性が高そうなので、GitHub Actions 組み込みで機能が足りないところは、どんどんアクションを作ってしまえばいいという感じになるのですかね。よいアクションが出てくるのを楽しみにしつつ、自分でもなにかしら作って探求していきたいです。