【GitHub Actions】Slack通知用のActionsをTypeScriptで開発してみた

# 前置き
皆さん、CI・CD機能が追加されたGitHub Actionsを使っていますか？
私はステージングや本番環境へデプロイするためにGitHub Actionsを使っています。
新しくなったGitHub Actionsによって様々な恩恵を受けていますが、CI・CDの経過や結果の通知に困っていました。今までは、既に組み込まれていたメールの通知機能を使ってわざわざ結果を確認したり、その場しのぎでcurlコマンドを使ってSlackへ投稿したりしていました。さすがにしんどくなったので、結果をSlackに通知するためのactionを開発してみました。

# 開発環境
- MacOS 10.14
- TypeScript 3.5.1

# 開発の流れ
actionを開発するざっくりとした流れは↓のようになります。GitHubがactionの開発をサポートするためにテンプレートを既に用意しているので、それを基に開発していきます。

1. actionの実行環境を決める
2. テンプレートからリポジトリを作成 ([javascript-template](https://github.com/actions/javascript-template)、[container-toolkit-template](https://github.com/actions/container-toolkit-template)、[container-template](https://github.com/actions/container-template))
3. actionの定義
4. いざ、開発！
5. ビルド
6. リリース

今回は3と４について説明していきます！

# action定義
actionの定義はYAMLで書きます。何を定義するのかというと、actionの名前や入力値などです。実際に書いたYAMLを例に説明していきます。

```action.yml
name: 'Slatify'
description: 'Slack Notification for Github Actions'
author: 'homoluctus'
inputs:
  type:
    description: 'job status (success or failure)'
    required: true
  job_name:
    description: 'job name of workflow (format: markdown)'
    required: true
  username:
    description: 'slack username'
    required: false
    default: 'Github Actions'
  icon_emoji:
    description: 'slack icon emoji'
    required: false
    default: 'github'
  channel:
    description: 'slack channel'
    required: false
    default: '#general'
runs:
  using: 'node12'
  main: 'lib/main.js'
branding:
  icon: 'bell'
  color: 'white'
```

### name
開発したactionの名前です。GitHub Actionsの環境変数`GITHUB_ACTION`の構成要素として使われたり、[GitHub Actions Marketplace](https://github.com/marketplace)での表示名として使われます。

### description
actionの説明を書きます。

### author
actionの開発者名を書きましょう。

### inputs
actionの本体(コード)側で入力値として受け取りたいものを書きます。例では`type`、`job_name`、`icon_emoji`、`channel`、`username`を変数名とした入力値を受け取れることを定義しています。`inputs.<VARIABLE_NAME>`には、入力値が必須かどうかを示す`required`やデフォルト値を宣言する`default`パラメーターがあります。`required`パラメーターの指定は必須ですが、`default`パラメーターはオプションです。
inputsの実体は環境変数です。ジョブが起動する際に`INPUT_<VARIABLE_NAME>`という環境変数として値が挿入されます。

### runs
actionを動作させるためのパラメーターを指定します。`using`はmainを動かすためのアプリケーションを指定し、`main`はactionのメインスクリプトのPATHを指定します。GitHubが用意してくれているテンプレートを使ってTypeScriptで開発すると、JSのアウトプット先(outDir)がデフォルトでlibとなっているので`lib/main.js`となっています。これもテンプレートをいじらなければそのようになっています。メインのスクリプト名をindex.jsにしたい場合などには変更が必要です。
docker用のテンプレートを使っている場合は`using: 'docker'`、`main: 'Dockerfile'`というようになっています。

### branding
[GitHub Actions Marketplace](https://github.com/marketplace)に表示されるアイコンを設定します。↓こんな感じになります。

<img width="372" alt="スクリーンショット 2019-08-28 18.51.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173064/258b6afb-d152-4978-7f94-622b6f7f32dc.png">


# ソースコードの解説
actionの開発に直接関わる入力値の受け取り方とSlack通知の部分を解説します。
すべてのソースは[こちら](https://github.com/homoluctus/slatify)にあるので参考にして下さい。

## main.ts

```main.ts
import * as core from '@actions/core';
import { Slack } from './slack';
import { Status, getStatus } from './utils';

async function run() {
  try {
    const type: string = core.getInput('type', { required: true });
    const job_name: string = core.getInput('job_name', { required: true });
    const username: string = core.getInput('username') || 'GitHub Actions';
    const icon_emoji: string = core.getInput('icon_emoji') || 'github';
    const channel: string = core.getInput('channel') || '#general';

    const SLACK_WEBHOOK: string = process.env.SLACK_WEBHOOK || '';

    if (SLACK_WEBHOOK === '') {
      throw new Error('ERROR: Missing "SLACK_WEBHOOK"\nPlease configure "SLACK_WEBHOOK" as environment variable');
    }

    const status: Status = getStatus(type);
    const slack = new Slack(SLACK_WEBHOOK, username, icon_emoji, channel);
    const result = await slack.notify(status, job_name);

    core.debug(`Response from Slack: ${JSON.stringify(result)}`);

  } catch (err) {
    console.log(err)
    core.setFailed(err.message);
  }
}

run();
```

### core.getInput
@actions/coreのgetInputメソッドの第1引数にaction.ymlのinputsパラメーターで定義した変数名を渡すと、workflowのwithパラメーターで指定した値が取得できます。オプションで`{required: true}`と指定すると、値がfalseと判定される場合にErrorをはかせることができます。
先程、inputsで指定した変数は環境変数として挿入されると説明しましたが、@actions/coreのソースコードでそれを確認することができます↓

```@actions/core/core.ts
1  export function getInput(name: string, options?: InputOptions): string {
2    const val: string =
3      process.env[`INPUT_${name.replace(' ', '_').toUpperCase()}`] || ''
4    if (options && options.required && !val) {
5      throw new Error(`Input required and not supplied: ${name}`)
6    }
7
8    return val.trim()
9  }
```

3行目から、name引数を大文字に変えてINPUT_<VARIABLE_NAME>という環境変数を取得していることが分かりますね。

### core.debug
標準出力にログをアウトプットします。

### core.setFailed
これはプロセスのexitcodeを1に指定しかつ、エラーメッセージを標準出力にアウトプットするメソッドです。


## slack.ts
今回はSlackへ通知するためにIncoming WebHookを使用しました。公式のnpmパッケージがあるので便利です。
job_nameに絵文字を入れたり、よりリッチにするためにSlackのBlocksを採用しました。ただ、Blocksであると色をつけられないのでレガシーのAttachmentsと組み合わせて使っています。
Blocksにのせたいのは、GitHubに関する情報（リポジトリやイベント）なので`@actions/github`を使って欲しい情報を取得しています。これは`GITHUB_`で始まる環境変数をラップしていい感じにしてくれるパッケージです。ですので、GitHubのトークンは必要ありません。

```slack.ts
import * as github from '@actions/github';
import * as core from '@actions/core';
import { Status } from './utils';
import { SectionBlock, MessageAttachment, MrkdwnElement } from '@slack/types';
import {
  IncomingWebhook, IncomingWebhookSendArguments,
  IncomingWebhookResult
} from '@slack/webhook';

export class Slack extends IncomingWebhook {
  // 0: failure, 1: success
  static readonly color: string[] = ['#cb2431', '#2cbe4e'];

  constructor(
    url: string,
    username: string = 'GitHub Actions',
    icon_emoji: string = 'github',
    channel: string = '#general'
  ) {
    super(url, {username, icon_emoji, channel});
  }

  /**
   * Get slack blocks UI
   */
  protected get blocks(): SectionBlock {
    const context = github.context;
    const { sha, eventName, workflow, ref } = context;
    const { owner, repo } = context.repo;
    const repo_url: string = `https://github.com/${owner}/${repo}`;
    const action_url: string = `${repo_url}/commit/${sha}/checks`;

    const blocks: SectionBlock = {
      type: 'section',
      fields: [
        { type: 'mrkdwn', text: `*repository*\n<${repo_url}|${owner}/${repo}>` },
        { type: 'mrkdwn', text: `*ref*\n${ref}` },
        { type: 'mrkdwn', text: `*event name*\n${eventName}` },
        { type: 'mrkdwn', text: `*workflow*\n<${action_url}|${workflow}>` },
      ]
    }

    return blocks;
  }

  /**
   * Generate slack payload
   */
  protected generatePayload(status: Status, text: string): IncomingWebhookSendArguments {
    const text_for_slack: MrkdwnElement = { type: 'mrkdwn', text };
    const blocks: SectionBlock = { ...this.blocks, text: text_for_slack };
    const attachments: MessageAttachment = {
      color: Slack.color[status],
      blocks: [blocks]
    }
    const payload: IncomingWebhookSendArguments = {
      attachments: [attachments]
    }

    core.debug(`Generated payload for slack: ${JSON.stringify(payload)}`);

    return payload;
  }

  /**
   * Notify information about github actions to Slack
   */
  public async notify(status: Status, text: string): Promise<IncomingWebhookResult> {
    try {
      const payload: IncomingWebhookSendArguments = this.generatePayload(status, text);
      const result = await this.send(payload);

      core.debug('Sent message to Slack');

      return result;
    } catch (err) {
      throw err;
    }
  }
}
```

# Workflowの例
開発したactionをGitHub Actionsのworkflowの一部に取り入れた例を紹介します。基本的な使い方は他のactionなどと一緒です。
以下の例はこのactionのリポジトリで走らせているものになります。

```..github/workflows/main.yml
on: [push, pull_request]
name: TS Lint Check
jobs:
  lint:
    name: Lint Check
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@master

      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: '10.x'
      - name: Setup TS
        run: npm install tslint typescript -g

      - name: Lint check with tslint
        run: tslint './src/*.ts'

      # 開発したSlack通知用のaction
      - name: Slack Notification
        uses: homoluctus/slatify@master
        if: always()  # jobの成否に関わらずSlackへ通知
        with:
          type: ${{ job.status }}  # jobの成否を代入(Failure/Success)
          job_name: '*Lint Check*'
          channel: '#random'
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
```

# 出来上がったSlack通知
## job成功例

<img width="636" alt="スクリーンショット 2019-08-28 11.26.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173064/d8329374-f33b-b44a-ed80-abfb11942abc.png">

## job失敗例

<img width="636" alt="スクリーンショット 2019-08-28 11.29.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173064/2108c1da-1495-016f-f6ec-9058a5f37fd3.png">

repositoryはGitHub Actionsが動いているリポジトリのトップページへのリンク、workflowはActionsタブへのリンクが張られています。なので、なにかしらのjobが失敗した場合にはworkflow名をクリックすればすぐにログを見ることができます。
これでslackで結果を視覚的に素早く確認でき、情報元(GitHub)にワンクリックで移動できるようになりました:clap:

# おわりに
私は、このactionを開発する時にはじめてTypeScriptを書いた初心者(歴2・3日)なので、こういう風に書いたほうがいいとかがあればコメントやGitHubにイシューやプルリクを送ってください。
また、こんな機能を追加してほしいとか、UIの希望があれば可能な限り開発していこうと思ってますので、希望があれば遠慮なく下さい。

# Refenrence
- [Creating a JavaScript Action](https://github.com/actions/toolkit/blob/master/docs/javascript-action.md)
- [Slack Incoming Webhook](https://github.com/slackapi/node-slack-sdk/tree/master/packages/webhook)