Github Actionsを使用して自動化 〜Typescriptを添えて〜

# Github Actionsとは

リポジトリ内で処理を自動化してくれる機能になります。
CI/CDに使うことが多いですが、使い方次第では無限大の可能性があります。
また、自分自身で作るだけでなく世界中のエンジニアが公開しているGithub Actionsを使用して自分のプロジェクトで自動実行することも可能です。

エンジニアは基本面倒くさがり屋です。
だからこそGithub Actionsで徐々に自動化して楽なコーディングライフにして欲しいです。

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

# 自動化する処理

今回はあると便利な下記2つをGithub Actionsで自動化していこうと思います。

* GithubActions処理状況をSlack通知
* プルリク作成時にテスト

詳細な処理やコードは次で書いていきます。

# 実装

実装内容的には簡単ですので是非導入を考えて見てください。

今回はGithubActionsの他に`actions/toolkit`を使用していきます。
こちらはGithubActionsの処理をTypescriptで書くことが出来る便利なライブラリになります。

https://github.com/actions/toolkit

## ディレクトリ構成

実装していく2つの機能とも同じディレクトリを使用していきます。

```zsh
.
├── .github
│   └── actions
│       └── tests
├── slackNotion
│   ├── actions
│   │   └── yml
│   │       └── slack
│   │           └── Github Actionsのメタデータが入っているディレクトリ
│   └── src
│       ├── slack
│       │   └── Slack通知処理に共通処理を管理するディレクトリ
│       └── types
│           └── 型を管理するディレクトリ
├── src
│   └── Nodeで作成する簡単なプロジェクト
└── tests
    └── テストコードを管理
```

### Github Actionsのメタデータとは

Github Actionsで使用する共通処理を分けてファイル管理出来ます。

https://docs.github.com/ja/actions/creating-actions/metadata-syntax-for-github-actions

## GithubActions処理状況をSlack通知

名前の通り、GithubActionsの処理が「処理開始したか」「正常終了したか」「異常終了したか」「キャンセルしたか」の情報をSlackに通知してくれる処理になります。

通知するにはSlackAPIが必要になります。

https://api.slack.com/

### workflowについて

一連の流れとして

1. 開始通知
1. 行いたい処理(今回はテスト処理)
1. 終了通知
    1. `2. 行いたい処理(今回はテスト処理)`で成功時は正常終了通知
    1. `2. 行いたい処理(今回はテスト処理)`で失敗時は異常終了通知
    1. `2. 行いたい処理(今回はテスト処理)`でキャンセル時はキャンセル通知

という形に処理されます。
終了通知は3つのうちどれか一つが通知されます。

workflow図は下記になります。

![CleanShot 2024-07-13 at 21.21.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/ab9c4954-429e-35e0-5234-cce5c883ffbb.png)

### コード

メインとなるymlを書いていきます。
前述したworkflowに沿って書かれています。

```yml:.github/workflows/slackNotify.yml
name: CI with Notify

on:
  push:
    branches:
      - main

jobs:
  notify_test_start:
    runs-on: ubuntu-latest
    outputs:
      slack-thread-ts: ${{ steps.notify_test_start.outputs.slack-thread-ts }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Install && Build
        uses: ./slackNotion/actions/yml/build

      - name: Cache node modules
        id: cache-slack-notify-node-modules
        uses: actions/cache@v4
        with:
          path: ./slackNotion/node_modules
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-modules

      - name: Cache dist
        id: cache-slack-notify-dist
        uses: actions/cache@v4
        with:
          path: ./slackNotion/dist
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-dist

      - name: Send Slack notification (test start)
        id: notify_test_start
        uses: ./slackNotion/actions/yml/slack
        with:
          slack-token: ${{ secrets.SLACK_TOKEN }}
          slack-channel: ${{ secrets.SLACK_CHANNEL }}
          status: "started"
          run-id: ${{ github.run_id }}
          job-name: ${{ github.job }}
          repository: ${{ github.repository }}
          ref: ${{ github.ref }}
          event-name: ${{ github.event_name }}
          workflow: ${{ github.workflow }}

  test:
    runs-on: ubuntu-latest
    needs: notify_test_start
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Initialize & Test
        uses: ./.github/workflows/actions/tests

  notify_test_success:
    runs-on: ubuntu-latest
    needs: [test, notify_test_start]
    if: success()
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Restore node modules
        id: cache-slack-notify-node-modules
        uses: actions/cache@v4
        with:
          path: ./slackNotion/node_modules
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-modules

      - name: Restore dist
        id: cache-slack-notify-dist
        uses: actions/cache@v4
        with:
          path: ./slackNotion/dist
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-dist

      - name: Send Slack notification (test success)
        id: notify_test_success
        uses: ./slackNotion/actions/yml/slack
        with:
          slack-token: ${{ secrets.SLACK_TOKEN }}
          slack-channel: ${{ secrets.SLACK_CHANNEL }}
          status: "succeeded"
          run-id: ${{ github.run_id }}
          job-name: ${{ github.job }}
          repository: ${{ github.repository }}
          ref: ${{ github.ref }}
          event-name: ${{ github.event_name }}
          workflow: ${{ github.workflow }}
          slack-thread-ts: ${{ needs.notify_test_start.outputs.slack-thread-ts }}

      - name: Check output
        run: echo ${{ needs.notify_test_start.outputs.slack-thread-ts }}

  notify_test_failure:
    runs-on: ubuntu-latest
    needs: [test, notify_test_start]
    if: failure()
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Restore node modules
        id: cache-slack-notify-node-modules
        uses: actions/cache@v4
        with:
          path: ./slackNotion/node_modules
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-modules

      - name: Restore dist
        id: cache-slack-notify-dist
        uses: actions/cache@v4
        with:
          path: ./dist
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-dist

      - name: Send Slack notification (test failure)
        id: notify_test_failure
        uses: ./slackNotion/actions/yml/slack
        with:
          slack-token: ${{ secrets.SLACK_TOKEN }}
          slack-channel: ${{ secrets.SLACK_CHANNEL }}
          status: "failed"
          run-id: ${{ github.run_id }}
          job-name: ${{ github.job }}
          repository: ${{ github.repository }}
          ref: ${{ github.ref }}
          event-name: ${{ github.event_name }}
          workflow: ${{ github.workflow }}
          slack-thread-ts: ${{ needs.notify_test_start.outputs.slack-thread-ts }}

  notify_test_cancelled:
    runs-on: ubuntu-latest
    needs: [test, notify_test_start]
    if: cancelled()
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Restore node modules
        id: cache-slack-notify-node-modules
        uses: actions/cache@v4
        with:
          path: ./slackNotion/node_modules
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-modules

      - name: Restore dist
        id: cache-slack-notify-dist
        uses: actions/cache@v4
        with:
          path: ./dist
          key: ${{ runner.os }}-${{ hashFiles('./slackNotion/package-lock.json') }}-dist

      - name: Send Slack notification (test cancelled)
        id: notify_test_cancelled
        uses: ./slackNotion/actions/yml/slack
        with:
          slack-token: ${{ secrets.SLACK_TOKEN }}
          slack-channel: ${{ secrets.SLACK_CHANNEL }}
          status: "cancelled"
          run-id: ${{ github.run_id }}
          job-name: ${{ github.job }}
          repository: ${{ github.repository }}
          ref: ${{ github.ref }}
          event-name: ${{ github.event_name }}
          workflow: ${{ github.workflow }}
          slack-thread-ts: ${{ needs.notify_test_start.outputs.slack-thread-ts }}
```

Slack通知処理を整えていきます。
始めにTypescriptを下記の役割で書いていきます。

* `slackNotion/src/slack.ts`
    * Slack通知を行うメインファイル
* `slackNotion/src/types/slack.ts`
    * Slack処理に使用する型の管理
* `slackNotion/src/slack/jobStatus.ts`
    * Slack通知に使用する値の管理
* `slackNotion/src/slack/baseAttachment.ts`
    * Slack通知のベースファイル

```ts:slackNotion/src/slack.ts
import * as core from "@actions/core";
import axios from "axios";
import { baseAttachment } from "./slack/baseAttachment";
import { JOB_STATUS } from "./slack/jobStatus";
import {
  SendOrUpdateSlackNotification,
  SlackApi,
  SlackAttachment,
  SlackPayload,
} from "./types/slack";

const SLACK_API = {
  POST: "https://slack.com/api/chat.postMessage",
  UPDATE: "https://slack.com/api/chat.update",
};

const slackApi: SlackApi = async ({ url, payload, token }) => {
  return await axios.post(url, payload, {
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json; charset=utf-8",
    },
  });
};

const sendOrUpdateSlackNotification: SendOrUpdateSlackNotification = async (
  token,
  channel,
  attachments,
  threadTs
) => {
  try {
    const slackPayload: SlackPayload = {
      channel,
      attachments,
      token,
    };

    if (threadTs) slackPayload.ts = threadTs;

    const response = await slackApi({
      url: threadTs ? SLACK_API.UPDATE : SLACK_API.POST,
      payload: slackPayload,
      token,
    });

    if (response.data.ok) {
      core.setOutput("slack-thread-ts", response.data.ts);
      core.info("Message sent successfully");
      return response.data.ts;
    } else {
      core.setFailed(`Slack API error: ${response.data.error}`);
    }
  } catch (error) {
    core.setFailed(
      `Failed to send Slack notification: ${(error as Error).message}`
    );
  }
};

async function run() {
  try {
    const slackToken = core.getInput("slack-token");
    const slackChannel = core.getInput("slack-channel");
    const status = core.getInput("status") as JOB_STATUS;
    const runId = core.getInput("run-id");
    const jobName = core.getInput("job-name");
    const repository = core.getInput("repository");
    const ref = core.getInput("ref");
    const eventName = core.getInput("event-name");
    const workflow = core.getInput("workflow");
    const targetThreadTs = core.getInput("slack-thread-ts");

    const attachment = baseAttachment(
      status,
      repository,
      ref,
      eventName,
      workflow,
      runId
    );

    const attachments: SlackAttachment[] = [
      {
        pretext: `Job ${jobName} with run ID ${runId} has ${status}.`,
        color: attachment.color,
        fields: attachment.fields,
      },
    ];

    const threadTs = await sendOrUpdateSlackNotification(
      slackToken,
      slackChannel,
      attachments,
      targetThreadTs
    );

    if (threadTs && status === JOB_STATUS.START)
      core.setOutput("slack-thread-ts", threadTs);
  } catch (error) {
    core.setFailed(`Action failed with error: ${(error as Error).message}`);
  }
}

run();
```

```ts:slackNotion/src/types/slack.ts
import { BaseAttachmentFields } from "../slack/baseAttachment";

export type SlackAttachment = {
  pretext: string;
  color: string;
  fields: BaseAttachmentFields[];
};

type SlackApiProp = {
  url: string;
  payload: {
    channel: string;
    attachments: SlackAttachment[];
    ts?: string;
    token: string;
  };
  token: string;
};

type SlackApiReturn = {
  data: {
    ok: boolean;
    error: string;
    ts: string;
  };
};

export type SlackApi = ({
  url,
  payload,
}: SlackApiProp) => Promise<SlackApiReturn>;

export type SendOrUpdateSlackNotification = (
  token: string,
  channel: string,
  attachments: SlackAttachment[],
  threadTs?: string
) => Promise<string | undefined>;

export type SlackPayload = {
  channel: string;
  token: string;
  attachments: SlackAttachment[];
  ts?: string;
};
```

```ts:slackNotion/src/slack/jobStatus.ts
export const JOB_STATUS = {
  START: "started",
  SUCCESS: "succeeded",
  FAILURE: "failed",
  CANCELLED: "cancelled",
} as const;
export type JOB_STATUS = (typeof JOB_STATUS)[keyof typeof JOB_STATUS];

export const jobStatus: Record<JOB_STATUS, { color: string }> = {
  [JOB_STATUS.START]: {
    color: "#3ea8ff",
  },
  [JOB_STATUS.SUCCESS]: {
    color: "#2cbe4e",
  },
  [JOB_STATUS.FAILURE]: {
    color: "#cb2431",
  },
  [JOB_STATUS.CANCELLED]: {
    color: "#ffc107",
  },
};
```

```ts:slackNotion/src/slack/baseAttachment.ts
import { JOB_STATUS, jobStatus } from "./jobStatus";

export type BaseAttachmentFields = {
  title: string;
  value: string;
  short: boolean;
};

export type BaseAttachment = {
  color: string;
  fields: BaseAttachmentFields[];
};

export const baseAttachment = (
  status: JOB_STATUS,
  repository: string,
  ref: string,
  eventName: string,
  workflow: string,
  runId: string
): BaseAttachment => {
  const jobUrl = `https://github.com/${repository}/actions/runs/${runId}`;

  return {
    color: jobStatus[status].color,
    fields: [
      { title: "Repository", value: repository, short: true },
      { title: "Ref", value: ref, short: true },
      { title: "Event Name", value: eventName, short: true },
      { title: "Workflow", value: `<${jobUrl}|${workflow}>`, short: true },
    ],
  };
};
```

書けたらメタデータファイルも書いていきます

* `slackNotion/actions/yml/build/action.yml`
    * 前述したTypescriptを実行するyml
    * `inputs`で`.github/slackNotify.yml`から値を取得可能

```yml:slackNotion/actions/slack/action.yml
name: "Slack Notification Action"
description: "Sends a notification to Slack using a token and outputs the message thread timestamp"
inputs:
  slack-token:
    description: "The Slack OAuth token"
    required: true
  slack-channel:
    description: "The Slack channel ID"
    required: true
  status:
    description: "The Status of the workflow"
    required: true
  run-id:
    description: "The GitHub Actions run ID"
    required: true
  job-name:
    description: "The GitHub Actions job name"
    required: true
  repository:
    description: "The GitHub Repository name"
    required: true
  ref:
    description: "The GitHub Ref"
    required: true
  event-name:
    description: "The GitHub event name"
    required: true
  workflow:
    description: "The GitHub Workflow name"
    required: true
outputs:
  slack-thread-ts:
    description: "The Slack message thread timestamp"
runs:
  using: "node20"
  main: "../../../dist/slack.js"
```

実行時実行ディレクトリですがメタデータymlが入っているディレクトリになるため処理するファイルが実行されるようにパスを書いていきます。

### 実行

実行してあげると下記のようにslackに通知されます。
最初は青色で通知、処理が完了したら緑色で通知してくれるので目で見てもわかりやすいようになっています。

![CleanShot 2024-07-14 at 07.22.49.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/3aed812b-9c3a-5415-0a58-354b0fc83748.gif)

## プルリク作成時にテスト

こちらはとてもシンプルなのでコードのみ記述していきます。

### コード

テストを流すnode.js環境を簡単に実装していきます。

```ts:src/index.ts
import app from "./app";

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
```

```ts:src/app.ts
import express, { Application } from "express";

const app: Application = express();

app.get("/", (req, res) => {
  res.send("Hello World!");
});

export default app;
```

コードが書けたらjestでテストコードを実装していきます。

```ts:tests/app.test.ts
import request from "supertest";
import app from "../src/app";

describe("GET /", () => {
  it("should return Hello World", async () => {
    const res = await request(app).get("/");
    expect(res.status).toBe(200);
    expect(res.text).toBe("Hello World!");
  });
});
```

コードが書けたらgithubActionsを整えていきます。

```yml:.github/workflows/pullRequest.yml
name: Run Jest
on: pull_request

jobs:
  job-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "18"

      - name: Initialize & Tests
        uses: ./.github/workflows/actions/tests
```

```yml:.github/workflows/actions/tests/action.yml
runs:
  using: "Composite"
  steps:
    - name: Install dependencies
      run: npm ci
      shell: bash

    - name: Run tests
      run: npm test
      shell: bash
```

### 実行

実際にプルリクエストを作成するとテストが走り、成功したらチェックマークが表示されます。

今回は設定していませんが、GitHubのブランチ保護ルールを設定してあげればGithubActions上のテストが失敗した場合は`Merge pull Request`を禁止する制御を行えます。

![CleanShot 2024-07-20 at 17.59.59.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/57a72569-55b0-ac45-9c5c-41d5995fc97c.gif)

# 感想

GithubActionsを実装しとくことで開発を楽に進めるのを再確認することが出来ました。
また、Typescriptを使用することでymlを使った実装するよりも簡単に実装することが出来ました。

今回はymlとTypescriptの組み合わせで実装していきましたが、Typescriptに機能を持たせてymlではTypescriptの実行のみすることも出来そうだったので今後も学んでいきます。