はじめてのGitHubActionsMarketplace公開 - PRにマイルストーンをつけるActionをつくってみた

Actions使ってなにかするworkflowをつくるんじゃなくてAction自体をつくる話。

なにか簡単にGitHubActionsをつくってみたいと思い、GitHub公式の[actions/labeler](https://github.com/actions/labeler)を参考にPRにラベルじゃなくてマイルストーンをつけるActionをつくろうと考えた。
[Marketplace](https://github.com/marketplace?utf8=%E2%9C%93&type=actions&query=milestone)で検索した結果、labelの方はちょいちょいあったがmilestoneの方はそんなにはまだなかった。マイルストーンの作成や更新がしたいのではなくてPRに既存のマイルストーンを結びつけたいってなると1,2件ぐらいしか無さそう。それらもこちらのやりたいこととは違った。
最終的につくったのは[iyu/actions-milestone](https://github.com/marketplace/actions/pull-request-milestone)。

## GitHub公式のテンプレートからリポジトリを作成する
GitHubのテンプレート機能は実は使ったことがなかったが、用意してくれているのだから使わない理由はない。javascript版([actions/javascript-action](https://github.com/actions/javascript-action))とtypescript版([actions/typescript-action](https://github.com/actions/typescript-action))が用意されている。あとはテンプレートと呼べるのか謎のミニマムなコンテナ版([actions/container-action](https://github.com/actions/container-action))もあった。今回は無難にtypescriptを選択する。

<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/24fffa96-a097-206e-7cfe-45c609d12fc8.png">
`[Use this template]`って書いてある緑色のボタンからリポジトリを作成する。

<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/19288b24-67ce-a3e5-6746-143d3aaf8cb2.png">
こんな画面が出てくるがあとはいつものリポジトリ作成と同じ手順。

<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/73bff531-1f1f-43a2-5647-b62cc95a5971.png">
作成し終わるとforkしたリポジトリみたいにリポジトリ名の下にテンプレートに使ったリポジトリが表示されていた。ただコミットとかは引き継がれずすべてコピペして`Initial commit`だけしたような感じの状態だった。別の世界線というより別世界だからテンプレートの今後のコミットをcherry-pickするのは難しいかもしれない。そんな心配する必要もないが。

## テンプレートでできたリポジトリからconfig情報を書き換える
すぐにプログラミングに取り掛かりたいところだけどまずはconfigだけを書き換える。完全に好みの問題だがプログラムの修正も全部同じコミットにすると次回テンプレートから作成したときに何が必要になるのかわかりにくいなと思ったので。
やることは、

- `README.md`の書き換え
  - Actionの開発方法とかが書いてあるので全部消して書き換えでいい
- `package.json`, `package-lock.json`の書き換え
  - リポジトリ名だったり説明文だったりurlだったり書き換える
  - package-lock.jsonの方はpackage.json書き換えた後に`npm install`でもしとけば自動で書き換わるので手動で書き換える必要はない
- `action.yml`の書き換え
  - action名だったり説明文だったり
  - この段階である程度input,output決まってるなら書き換えてもいいような、まだなような？

こちら書き換えたときのコミットなので参考に
https://github.com/iyu/actions-milestone/commit/4101ca509ee0fc10f84542abd65e416a3feca566

## プログラムを書く
ここからやっとプログラミング

### ESLintの設定
汚いコードをあとから修正するのは面倒なので最初に入れる。
tslintは2019年内に開発終了するらしいのでtypescriptのlintには[ESLint](https://eslint.org/)を使う。

```bash
$ npm install --save-dev eslint
```

初期セットアップ (対話型のCLIでなんか色々聞かれるけど詳細は割愛)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/c4f16316-b9e5-ec1b-0522-82cafca84006.png)

ものすごくただの好みの問題だが自分は[eslint-config-airbnb-base](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base)を長らく使っている。

```bash
$ npm install --save-dev eslint-config-airbnb-base
```

このテンプレートではtestにjestを使っているみたいなのでjestのプラグインも入れる。

```bash
$ npm install --save-dev eslint-plugin-jest
```

最終的な`.eslintrc.yml`はこんな感じ。好みで変えていく。

```yaml
env:
  es6: true
  node: true
  jest/globals: true
extends:
  - airbnb-base
globals:
  Atomics: readonly
  SharedArrayBuffer: readonly
parser: '@typescript-eslint/parser'
parserOptions:
  ecmaVersion: 2018
  sourceType: module
plugins:
  - '@typescript-eslint'
  - jest
rules:
  no-console: off
  no-unused-vars: off
  '@typescript-eslint/no-unused-vars': error
settings:
  import/resolver:
    node:
      extensions:
        - .ts
```

実行時にtypescriptの拡張子を教えてあげる必要がある。早く省略できるようにして欲しい。

```bash
$ eslint --ext .ts,.js .
```

### 今回作るもの
大まかに区分するとこんな感じ。

1. inputデータを取得
1. PRの情報を取得
1. configファイルの中身を取得
1. ブランチ名からマイルストーンを割り出す
1. PRにマイルストーンをつける

#### inputデータを取得する
action.ymlに欲しいinputデータを記述しておく。

```yaml
name: 'Pull Request Milestone'
description: 'Add milestone to PRs'
author: 'iyu'
inputs:
  repo-token:
    description: 'The GITHUB_TOKEN secret'
  configuration-path:
    description: 'The path for the milestone configurations'
    default: '.github/milestone.yml'
outputs:
  milestone:
    description: 'The Added Milestone'
runs:
  using: 'node12'
  main: 'lib/main.js'
```
上記の場合だと`repo-token`と`configuration-path`
これをAction利用者が入力してくれるはずなのでそれをtypescript側で取得する。
こういう基本的なものはすべて公式のライブラリが取得方法を用意してくれているので簡単に取得できる。

```typescript
import * as core from '@actions/core';

const token = core.getInput('repo-token', { required: true });
const configPath = core.getInput('configuration-path', { required: true });
```
requiredつけておけば値が存在しなかった時のエラー処理もいい感じにやってくれるのでエラー文考えなくても良い。便利。
（action.ymlでdefault付いてるくせにrequiredにしているのはただの嘘つき）

#### PRの情報を取得する
どのイベントから発火されたのかも公式のライブラリで簡単に取得できる。

```typescript
import * as github from '@actions/github';

const pullRequest = github.context.payload.pull_request;
if (!pullRequest) {
  console.log('Could not get pull_request from context, exiting');
  return;
}
```
ブランチ名を利用してマイルストーンをつけるActionなのでPR以外のイベントの場合は上記のようになんかメッセージ表示して終了させる。

後々にPRのBaseブランチやHeadブランチの名前や現在のマイルストーンを利用したいのだが、このライブラリのtypesがしっかり全部書いてないので必要なパラメータは自分で拡張する必要がある。かなしい。

```typescript
import * as github from '@actions/github';
import { WebhookPayload } from '@actions/github/lib/interfaces';

interface PullRequestWebhookPayload extends WebhookPayload {
  pull_request?: {
    [key: string]: any;
    number: number;
    html_url?: string;
    body?: string;

    milestone?: string;
    base: {
      ref: string;
    },
    head: {
      ref: string;
    },
  },
}
const pullRequest = (github.context.payload as PullRequestWebhookPayload).pull_request;
```

#### configファイルの中身を取得
inputで指定されたconfigファイルパスを取得する。action利用者がcheckoutをしているとも限らないのでファイルはAPI経由で取得する。このへんは[actions/labeler](https://github.com/actions/labeler)を参考にしている。

```typescript
import * as github from '@actions/github';

(async () => {
  // さっきのやつ
  // const token = core.getInput('repo-token', { required: true });
  // const configPath = core.getInput('configuration-path', { required: true });

  const client = new github.GitHub(token);
 
  const response = await client.repos.getContents({
    owner: github.context.repo.owner,
    repo: github.context.repo.repo,
    path: configPath,
    ref: github.context.sha,
  });
  const text = Buffer.from((response.data as { content: string }).content, 'base64').toString();
})();
```
（APIクライアントはPromise対応しているのでasync関数で囲ってる）
これでAPI経由でファイル取得可能だ。レスポンスの中のファイル内容についてはbase64文字列なのでtext変換している。

configファイルはこんな形式のyamlファイルを想定している。

```yaml
base-branch:
  - "(master)"
  - "releases\\/(v\\d+)"

head-branch:
  - "feature\\/(v\\d+)\\/.+"
```
yamlファイルの文字列持っててもtypescriptで扱いにくいので今回はこんな感じのパーサーを用意して変換した。

```typescript
import * as yaml from 'js-yaml';

interface ConfigObject {
  baseBranchList: RegExp[];
  headBranchList: RegExp[];
}

const parse = (text: string) => {
  const config: {
    'base-branch'?: string[],
    'head-branch'?: string[],
  } = yaml.safeLoad(text) || {};

  const result: ConfigObject = {
    baseBranchList: [],
    headBranchList: [],
  };
  result.baseBranchList = (config['base-branch'] || []).map((item) => new RegExp(item));
  result.headBranchList = (config['head-branch'] || []).map((item) => new RegExp(item));

  return result;
};

```

#### ブランチ名からマイルストーンを割り出す
このへんはもうこのAction固有の処理なのでそんなに詳しく書きません。baseブランチ、headブランチはWebhookのpayloadの中に入っているのでそれを使う。

```typescript
const pullRequest = (github.context.payload as PullRequestWebhookPayload).pull_request;
const {
  milestone,
  number: prNumber,
  base: { ref: baseBranch },
  head: { ref: headBranch },
} = pullRequest;
```

先程のconfigで取ってきた正規表現とmatchするものを見つけてくる。milestoneは1つしかつけることはないので1件ヒットしたら即返却で良い。

```typescript
export const match = (
  baseBranch: string,
  headBranch: string,
  configObject: ConfigObject,
): string|undefined => {
  let hit: string|undefined;
  configObject.baseBranchList.some((regexp) => {
    const m = baseBranch.match(regexp);
    if (m && m[1]) {
      ([, hit] = m);
    }
    return !!hit;
  });
  if (hit) {
    return hit;
  }

  configObject.headBranchList.some((regexp) => {
    const m = headBranch.match(regexp);
    if (m && m[1]) {
      ([, hit] = m);
    }
    return !!hit;
  });
  return hit;
};
```

#### PRにマイルストーンをつける
実はちょっとハマった。30分ぐらい悩んだ。
[actions/labeler](https://github.com/actions/labeler)を参考にしていたので、最後に[addLabel](https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue)のmilestone版のgithubのAPIを叩けば終わると思っていたがSDKにそんな関数は見当たらない。最初はpayloadが不完全だったこともあってSDKが対応していないだけだろうと思っていたのだがAPIリファレンスにもaddMilestoneだったりsetMilestoneなるものは見当たらなかった。悩んだ。
結論、milestoneは[issueUpdate](https://developer.github.com/v3/issues/#edit-an-issue)（PRもissueの一種なので同一API）や[issueCreate](https://developer.github.com/v3/issues/#create-an-issue)時につけてやるものらしい。labelだけaddLabelなんてAPIがあるから騙された。issueのtitleを変える感覚でmilestoneも変更するみたい。

さらにいうとlabelと違ってmilestoneはmilestone用のID(下記コードのnumberの部分)を使ってissueを更新する。先程の項目で割り出したのはmilestoneのタイトルなのでそれと一致するmilestoneのIDを既存のmilestone一覧から探してこなくてはならない。

```typescript
const addMilestone = 'v1.0';
const milestones = await client.issues.listMilestonesForRepo({
  owner: github.context.repo.owner,
  repo: github.context.repo.repo,
});
const { number: milestoneNumber } = milestones.data.find(({ title }) => title === addMilestone) || {};
```

既存のmilestoneが見つからなかった場合はmilestoneCreateとかで作成しても良かったわけだが、今回はメッセージ出すだけにした。やるならオプションで作成も一緒にするかみたいなフラグを追加しようかな。

```typescript
if (milestoneNumber) {
  await client.issues.update({
    owner: github.context.repo.owner,
    repo: github.context.repo.repo,
    issue_number: prNumber,
    milestone: milestoneNumber,
  });
  core.setOutput('milestone', addMilestone);
} else {
  console.log(`Milestone not found, Please create it first "${addMilestone}".`);
}
```

今回は紐付けたmilestoneをoutputとして出力するactionなので`core.setOutput('milestone', addMilestone);`という一文も忘れずに。

## Marketplaceに公開する
公式のドキュメントに画像つきで丁寧に書いてあるのでそこまで説明いらないとは思う。
https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/publishing-actions-in-github-marketplace

公開する成果物にはnode_modulesも含めビルド済みのjsファイルがないといけないので、masterブランチではgitignoreにnode_modulesが入っていてそれとは別にnode_modulesを除外しないreleaseブランチを作ろう、とテンプレートでは書かれている。
https://github.com/actions/typescript-action#publish-to-a-distribution-branch

他の手段としては[@zeit/ncc](https://www.npmjs.com/package/@zeit/ncc)を使ってそもそもnode_modulesも含めすべて1ファイルにしてしまうという方法もある。
https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-a-javascript-action

今回は前者の方法でやろうと思うが毎回そんな作業をするのも面倒なので`npm preversion`にビルド作業とnode_modules,ビルド成果物の追加を仕込んでおくことにした。`package.json`の`scripts`の部分を書き換える

```json
"scripts": {
 "preversion": "npm ci && npm run build && npm ci --production && git add node_modules lib -f"
}
```
該当する作業コミットはこれ。
https://github.com/iyu/actions-milestone/commit/a95202bc14122a66a01dc5aad55a6ebfed5279bd

これは何なのかというと、`npm version`というpackage.jsonのversion部分をアップデートしつつgit-tagをつけてくれるコマンドの前にhookされるスクリプト。npmにライブラリを公開している人ならおなじみだと思う。
詳しくはここ https://docs.npmjs.com/cli/version

```bash
$ npm version major

> actions-milestone@1.0.0 preversion iyu/actions-milestone
> npm ci && npm run build && npm ci --production && git add node_modules lib -f

npm WARN prepare removing existing node_modules/ before installation
added 651 packages in 7.098s

> actions-milestone@0.0.0 build git/iyu/actions-milestone
> tsc

npm WARN prepare removing existing node_modules/ before installation
added 50 packages in 2.174s
v1.0.0
```
やってることはシンプルで、

1. `npm ci` node_modulesを消してからinstall (clean installの略だった気がする）
1. `npm run build` scriptsのbuildを実行 （typescriptのビルドが書かれてる)
1. `npm ci --production` ビルドのときしか必要でないpackageを消すために実行してる
1. `git add node_modules lib -f` .gitignoreにかかれているnode_modulesとビルド成果物をforce add

このあとに本来の`npm version`が動く。 （package.jsonのversionの更新とgit-commit, git-tag）

githubにpushしたらリリース物として`[Draft a new release]`のボタンを押して`Publish this Action to the GitHub Marketplace`にチェックする。
<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/e4fa6be7-1085-84cb-2991-bfeae60f64d1.png">
カテゴリとかiconとか必要なものを埋めていく。iconは別になくてもいいっぽい。

これで無事完成！検索すればちゃんとMarketplaceに出てくるようになった！
<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19658/b7080fe7-2bea-1ce1-87ee-ae41e6285385.png">

## さいごに
今回は一発目だったので簡単なActionにしたがもっとちゃんと実用性のあるActionを作っていきたい。ただ仕事で使うやつとなるとたぶんMarketplaceには上げないと思うので今回はまぁ楽しめた。
~~近々、チーム開発でGitHubActionsを使うにあたって苦労した点を記事にして書きたいと思う。~~
(2019/12/09 追記) 書いた。[業務でGitHubActionsを取り入れる上で苦労した点](https://qiita.com/iyu/items/87a6f255bc7e5beb9a40)