TypeSctiptでGitHub Actionを作る

TypeSctiptを利用してGitHub Actionを作成する方法のメモ

はじめに

Github ActionsではNode.jsを利用した実行とDockerを利用した実行をサポートしています。

https://docs.github.com/ja/actions/creating-actions

Node.jsを利用する際はJavaScriptでの実行が前提になるわけですが、JavaScriptが使えると言うことは当然TypeScriptも利用ができるわけです!(JavaScriptにコンパイルしてから使うわけなので)
正直、型のゆるい言語での開発が辛いのでTypeScriptでActionの作成を行ってみたいと思います(TypeScriptもany使えばjsと大差無いけど…)。

今回作るもの

コミットメッセージが特定とパターンにマッチした時にGithubのリリースの作成を行ってくれるAction

ちなみにGithub公式のものにタグをプッシュするとリリースの作成を行ってくれるアクションがあります。
https://github.com/marketplace/actions/create-a-release

これのコミットメッセージ版だと思ってもらえると話が早いかも

事前準備

GitHub Action はGitHubのリポジトリに関連付けて動作させるものなので、actionの動作確認用にGitHubに一つリポジトリを作成しておきます。
以降、このリポジトリを動作確認用リポジトリと呼称します。

プロジェクトを作成する

公式のActionsのリポジトリにTypeScriptを利用したActionの例があるので、これを改造する形で作成するのが最も楽でしょう。

typescript-action https://github.com/actions/typescript-action

テンプレートリポジトリになっているので、これを元にリポジトリを作成します。

依存関係を追加する

リリースを作成するにあたってGitHubのAPIを利用して操作する必要があるので、pakeage.jsonに@actions/githubを追加します。

  "dependencies": {
    "@actions/core": "^1.2.6",
    "@actions/github": "^4.0.0" // 追加
  },

action.yml

リポジトリのルートにaction.ymlというファイルがあると思います。

このファイルが存在するリポジトリをactionとして認識してくれます。

このファイルにはactionに関する説明を記述します。

フォーク時の状態だとこんな感じの中身になっているはずです。

name: 'Your name here' # アクションの名前
description: 'Provide a description here' # アクションの説明
author: 'Your name or organization here' # アクションの作者
inputs: # アクションの入力パラメータ
  milliseconds: # change this
    required: true
    description: 'input description here'
    default: 'default value if applicable'
runs:
  using: 'node12'  # 利用する実行環境
  main: 'dist/index.js' # 実行対象のファイル

これを書き換えて、自分が作りたいactionに必要な入出力等を設定します。

今回は、コミットメッセージが特定のパターンにマッチした時にリリースの作成をするようにしたいので、入力として正規表現を設定できるようにします。(regexp)
また、リリースの作成が行われたかどうかをアクションの実行結果として出力するようにします。(created)

name: 'Release with commit'
description: 'Create a release from a commit message.'
author: 'ChanTsune'
inputs:
  regexp:
    description: 'The pattern of commit message. Create a release if commit message match this.'
    required: true
  tag_name:
    description: 'The name of the tag.'
    required: false
  release_name:
    description: 'The name of the release. For example, `Release v1.0.1`'
    required: false
  body:
    description: 'Text describing the contents of the tag.'
    required: false
outputs:
  id:
    description: 'The ID of the created Release'
  created:
    description: 'The Boolean value of whether a release was created'
runs:
  using: 'node12'
  main: 'dist/index.js'

※見やすいように一部を抜粋(実際には、便利に利用できるようにもう少し多く入出力パラメータを定義しています)。

作成

と言うわけで、完成したプログラムがこちらです。

https://github.com/ChanTsune/release-with-commit

import * as core from "@actions/core";
import { context, getOctokit } from "@actions/github";
import { Config } from "./config";

function setOutputs(
  releaseId: number,
  htmlUrl: string,
  uploadUrl: string,
  created: boolean
) {
  // Set the output variables for use by other actions: https://github.com/actions/toolkit/tree/master/packages/core#inputsoutputs
  core.setOutput("id", releaseId);
  core.setOutput("html_url", htmlUrl);
  core.setOutput("upload_url", uploadUrl);
  core.setOutput("created", created);
}

export async function main(github: ReturnType<typeof getOctokit>) {
  try {
    const { owner, repo } = context.repo;
    const commits = context.payload.commits;
    if (commits.length === 0) {
      core.info("No commits detected!");
      setOutputs(-1, "", "", false);
      return;
    }
    const headCommit = commits[0];
    console.log(JSON.stringify(headCommit));
    console.log(headCommit.message);

    // Get the inputs from the workflow file: https://github.com/actions/toolkit/tree/master/packages/core#inputsoutputs
    const config = Config.parse({
      regexp: core.getInput("regexp", { required: true }),
      regexp_options: core.getInput("regexp_options", { required: false }),
      release_name: core.getInput("release_name", { required: false }),
      tag_name: core.getInput("tag_name", { required: false }),
      body: core.getInput("body", { required: false }),
      body_path: core.getInput("body_path", { required: false }),
      draft: core.getInput("draft", { required: false }),
      prerelease: core.getInput("prerelease", { required: false }),
      commitish: core.getInput("commitish", { required: false }) || context.sha,
      repo: repo,
      owner: owner,
    });
    if (!config) {
      core.info("Parse Failed.");
      setOutputs(-1, "", "", false);
      return;
    }
    const releaseInfo = config.exec(headCommit.message);
    if (!releaseInfo) {
      core.info("Commit message does not matched.");
      setOutputs(-1, "", "", false);
      return;
    }

    // Create a release
    // API Documentation: https://developer.github.com/v3/repos/releases/#create-a-release
    // Octokit Documentation: https://octokit.github.io/rest.js/#octokit-routes-repos-create-release
    const createReleaseResponse = await github.repos.createRelease({
      owner,
      repo,
      tag_name: releaseInfo.tag_name,
      name: releaseInfo.name,
      body: releaseInfo.body,
      draft: releaseInfo.draft,
      prerelease: releaseInfo.prerelease,
      target_commitish: config.commitish,
    });
    // Get the ID, html_url, and upload URL for the created Release from the response
    const {
      data: { id: releaseId, html_url: htmlUrl, upload_url: uploadUrl },
    } = createReleaseResponse;

    setOutputs(releaseId, htmlUrl, uploadUrl, true);
  } catch (error) {
    core.setFailed(error.message);
  }
}

async function run(): Promise<void> {
  const env = process.env as any;
  const github = getOctokit(env.GITHUB_TOKEN);
  await main(github);
}

run();

※ 2020/7/6日追記 “@actions/github”: “5.0.0” では、github.repos.createRelease -> github.rest.repos.createReleaseとなりました。
以上追記

細かい説明は省きますが、 コミットメッセージが設定した正規表現にマッチした時にリリースを作成するようにしています。 設定の値をcore.getInput関数で取得。 実行結果の値をcore.setOutput関数でセットしています。

値の設定の仕方については実行セクションにて後述します。

あとはこれをコンパイルしてjsファイルにしてあげます。

フォークした状態のままなら

npm run build && npm run package

distディレクトリにjsファイルが生成されるはずです。

コンパイルして生成されたjsファイルもコミットしてあげます。

実行

ここまで出来たら、動作確認用リポジトリの.github/workflowsディレクトリ配下にymlファイルを追加します。
ymlファイルの名前はなんでもいいですがここではrelease.ymlということにしておきます。

name: "Release with commit"

on:
  push:
    branches:
      - master
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: ChanTsune/release-with-commit@v2.0.0 # ユーザー名/アクションを管理しているリポジトリ名@実行ブランチorタグ
        id: create_release
        with: # ここで入力パラメータを設定する
          regexp: "Release (\\d+([.]\\d+)*)((\\s|\\S)+)"
          release_name: "version $1"
          tag_name: "v$1"
          body: "$3"
        env:
          GITHUB_TOKEN: '${{secrets.GITHUB_TOKEN}}'

      - uses: actions/checkout@v2 # 後続にリリースが作成された時に行うアクションを設定する場合
        if: ${{ steps.create_release.outputs.created == 'true' }} # if でリリースが作成されたかチェックして実行の可否を決定できる

上記の設定の場合、masterブランチにコミットがあった場合にアクションが実行されてRelease 1.0.0 のようなコミットメッセージだった時にリリースが作成されます。

ActionをMarketplaceで公開する

Actionを管理しているリポジトリのリリースの作成画面にMarketplaceに公開するかどうかのチェックボックスが出ているはずです。
それにチェックを入れてリリースを作成するとMarketplaceに作成したActionを公開できます。

チェックをつけてリリースを作成するとこんな感じで公開されます。

https://github.com/marketplace/actions/release-with-commit

さいごに

以前、今回作成したActionと近しい事をするアプリをprobotを利用して作成していましたが、GitHub Actionsを利用する方が圧倒的に便利でした。

Probotも便利なのですが、probot用にサーバーを用意する必要があるのでその点が少し面倒かなと。
あとは、アップデートを行う際、probotだと旧バージョンを利用している人に向けてサーバー側でいろいろ頑張らないといけなかった点が、GitHub Actionsではtagを利用してバージョンごとに完全に切り分けができるので、ある程度の変更を行っても旧バージョンの利用者に影響を与えにくいのも大きなメリットだと思います。

何より、全てがGitHub上で完結するのが最高です。

また今回、作成したActionの詳細な使い方は別記事にまとめてあるのでご興味のある方はそちらをご参照下さい。