見出し画像

GitHub Actions向け自作アクションの作り方

こんにちは。Tably よういちろう(@yoichiro)です。

皆さんは普段GitHubをお使いでしょうか?お使いの方は、GitHub Actionsを使ってCI/CDしていますでしょうか?

GitHub Actionsを使うことで、Pull Requestの作成や更新、あるブランチへのマージといったタイミングで、コードのフォーマットを整えたり、テストを走らせたり、本番環境にデプロイしたり、一連の作業が終わったことをチャットに通知したり・・・といったことを自動的に行うことができます。GitHubにはGitHub Marketplaceというアクションが公開されているマーケットプレースがあります。やりたいことがあった時に、一般的なものであればそこで見つけることができるでしょう。

ものすごい数のアクションがMarketplaceにて公開されていますが、たまたま僕が行いたかった動作をしてくれるアクションを見つけることができませんでした。やりたかったことは、「ある特定のファイルの中に書かれている数字を +1 してくれる」という単純な動作です。今まで、ビルド番号を本番環境にデプロイするたびに +1 していくことを手動で行っていたのですが、更新を忘れてしまってそのまま形骸化していくといった状況でした。+1 していく作業は、人間が行うことではありません。やはりコンピュータにやらせるべきだなと。

Marketplaceで探すと「package.json の version 番号を +1 していく」というアクションはいくつもあるのに、単に連番でやってくれるアクションが見当たりません。ないものは自分で作れば良いので、GitHub Actions向けアクションの開発に着手しました。

作ったものは、以下で公開しています。もし同じようなことをしたいとお考えであれば、ぜひご利用ください。

僕が今回開発をしたときに得た作り方を、ここに残しておきたいと思います。この作り方は2021年2月24日時点で有効だったものです。参考にはなると思いますが、もし自作をお考えの場合は、最新の情報もご確認ください。

今回作ったものの使い方

あるファイル中の番号を +1 してくれる今回作ったアクションの使い方を最初に説明しましょう。例えば、foobar/baz ディレクトリに build-number.json というファイルが以下の内容で書かれていたとします。

{"buildNumber":0}

これをmainブランチにpushされる度に +1 していきたいとします。このファイルがあるプロジェクトの .github/workflows ディレクトリに、increment.yaml ファイルを以下の内容で作ります。

name: Increment the build number
on:
 push:
   branches:
     - main
   paths-ignore:
     - '**/build-number.json'
jobs:
 increment:
   name: Increment the build number
   runs-on: ubuntu-latest
   strategy:
     matrix:
       node-version: [10.x]
   steps:
     - name: Checkout Repository
       uses: actions/checkout@v2
       with:
         ref: main
     - name: Increment value
       uses: yoichiro/gh-action-increment-value@main
       with:
         target_directory: 'foobar/baz'
         target_file: 'build-number.json'
         prefix: '{"buildNumber":'
         suffix: '}'

actions/checkout@v2 を使って、mainブランチをチェックアウトします。そして、yoichiro/gh-aciton-increment-value@main を使って、build-number.json ファイルの中にある番号を +1 します。この際、以下の条件を指定します。

・target_directory - 対象のファイルがあるディレクトリのパス
・target_file - 対象のファイルの名前
・prefix - 番号が書かれている場所を特定するためのプレフィックス文字列
・suffix - 番号が書かれている場所を特定するためのサフィックス文字列

具体的には、ファイルの最初からprefixで指定した文字列が最初に出現するまでと、suffixで指定した文字列が最初に出現する場所からファイルの最後まで、の間に +1 される番号が来るように指定します。

ファイル構成

今回開発をしたアクションは、以下のファイル群で構成されています。

・actions.yaml - アクションに関する設定情報を記載します。
・Dockerfile - アクションをDockerコンテナとして作成する場合に必要となります。
・index.js - アクションの具体的な処理を記述するファイルです。
・package.json - 依存ライブラリなどを定義しているファイルです。

その他にも、Marketplaceにて公開する場合には、README.mdやLICENSEといったファイルが必要になります。

actions.yamlファイル

自作するアクションは、actions.yamlファイルにて設定情報を記載します。actions.yamlファイルには、アクション名や作者の情報、アイコンや色などのブランド情報、そして受け付ける設定値の定義を書いていきます。設定値とは、先ほどアクションの使い方で登場した with に指定可能な設定項目を指します。

name: Increment value automatically
description: Allow you to increment a value on your file automatically.
author: Yoichiro Tanaka
runs:
 using: docker
 image: Dockerfile
branding:
 icon: plus
 color: green
inputs:
 target_directory:
   description: 'A directory where has the target file'
   default: ''
   required: true
 target_file:
   description: 'A target file name'
   required: true
 prefix:
   description: 'A prefix string'
   required: true
 suffix:
   description: 'A suffix string'
   required: true

with で指定した項目が inputs に書かれていることがわかるかと思います。

Dockerfile ファイル

実は今回のアクションであれば Dockerfile は必要なかったのですが、勉強のために Dockerfile 込みの作り方を採用しています。具体的なアクションの処理を nodejs を使って動かすために、Dockerfile では node 環境の構築が主な仕事となります。

FROM node:14.14-slim

COPY package*.json ./

RUN apt-get update
RUN apt-get -y install git
RUN npm install

COPY . .

ENTRYPOINT ["node", "/index.js"]

動作環境は Ubuntu となるので、apt-get コマンドを使って git コマンドをインストールしています。また、package.json に基づいて依存ライブラリのインストールも行っています。最後に、index.js を nodejs 上で実行しています。

git コマンドをインストールしている理由は、対象のファイル内の数値を +1 した後に、git commit を実行してコミットを行い、更に git push を実行して対象のブランチに push をしたいからです。

package.json ファイル

今回のアクションの本体は nodejs で動作する処理となるので、yarn init を実行して package.json ファイルを作成し、その後 yarn add actions-toolkit を実行して依存ライブラリを追加します。

{
 "name": "gh-action-increment-value",
 "version": "1.0.0",
 "main": "index.js",
 "dependencies": {
   "actions-toolkit": "^6.0.1"
 }
}

上記は最低限の package.json の記載内容です。公開する際には、ライセンスやコードリポジトリの場所、作者の情報などを記載する必要があるでしょう。しかし、動作させることだけであれば、上記で十分です。

actions-toolkit ライブラリは、内部で @actions/core などを利用している、GitHub 公式ライブラリのラッパーライブラリです。

index.js ファイル

では、実際にアクションの処理を記載することになる index.js ファイルを見ていきましょう。少し長いので、順を追って解説していきます。

まず、必要なライブラリを読み込みます。そして、カレントディレクトリを移動しておきます。

const { Toolkit } = require('actions-toolkit');
const fs = require('fs');

if (process.env.INPUT_TARGET_DIRECTORY) {
  const workspace = process.env.GITHUB_WORKSPACE;
  const targetDir = process.env.INPUT_TARGET_DIRECTORY;
  process.env.GITHUB_WORKSPACE = `${workspace}/${targetDir}`;
  process.chdir(process.env.GITHUB_WORKSPACE);
}

移動しておく理由はないのですが、勉強のためにやってみました。

ここで注目点としては、with で指定した各設定値の取り出し方となります。with で設定した値は、環境変数として渡されます。この際、接頭語として「INPUT_」が付きます。つまり、target_directory: ‘foobar’ と with に書いた場合は、process.env.INPUT_TARGET_DIRECTORY で取り出すことが可能です。

次に、実際にアクションが呼び出されたときの処理を書いていきます。その処理は、Toolkit.run() 関数の中で非同期処理として書いていきます。

Toolkit.run(async tools => {
 try {
   // Read the target file
   const targetFile = process.env.INPUT_TARGET_FILE;
   console.log(`Target file: ${targetFile}`);
   const content = fs.readFileSync(`./${targetFile}`, 'utf8');

with の中で target_file を使って指定されたファイルの内容を読み込みます。console.log() にて出力した内容は、実際にアクションが実行される際のログに出力されます。

次に、prefix と suffix から番号が書かれた位置を特定し、実際に +1 して、結果をファイルに書き戻します。これは通常の nodejs での処理なので、特殊なことはありません。

    // Increment value
   const prefix = process.env.INPUT_PREFIX;
   const suffix = process.env.INPUT_SUFFIX;
   const firstPart = content.substring(0, content.indexOf(prefix) + prefix.length);
   const lastPart = content.substring(content.indexOf(suffix));
   const targetPart = content.substring(content.indexOf(prefix) + prefix.length, content.indexOf(suffix));
   const current = Number(targetPart);
   const next = current + 1;
   // Write the target file
   const newContent = `${firstPart}${String(next)}${lastPart}`;
   fs.writeFileSync(targetFile, newContent);
   console.log(`Increment the value from ${current} to ${next}.`);

ここから今回のアクション特有の処理になります。更新したファイルについて、まず git commit コマンドを実行することでコミットします。

    // Set git user
   await tools.exec(`git config user.name "${process.env.GITHUB_USER || 'Automated Increment value'}"`);
   await tools.exec(`git config user.email "${process.env.GITHUB_EMAIL || 'gh-action-increment-value@users.noreply.github.com'}"`);
   // Fetch current branch name
   let currentBranch = /refs\/[a-zA-Z]+\/(.*)/.exec(process.env.GITHUB_REF)[1];
   let isPullRequest = false;
   if (process.env.GITHUB_HEAD_REF) {
     currentBranch = process.env.GITHUB_HEAD_REF;
     isPullRequest = true;
   }
   console.log(`Current branch: ${currentBranch}`);
   // Commit
   await tools.exec(`git commit -a -m "ci: Increment the value to  ${next}"`);

まず、コミット時に記録されるユーザの情報をセットします。そして、コミットを行っています。tools.exec() 関数に実行したいコマンドを文字列で指定しています。わかりやすいですね。もちろん、脆弱性を生みやすい処理となるので、注意が必要です。

コミット後、push を行います。

   // Push
   const remoteRepo = `https://${process.env.GITHUB_ACTOR}:${process.env.GITHUB_TOKEN}@github.com/${process.env.GITHUB_REPOSITORY}.git`;
   await tools.exec(`git push ${remoteRepo}`);
   tools.exit.success('Incrementing the value successfully.');
 } catch (e) {
   tools.log.fatal(e);
   tools.exit.failure('Failed to increment the value.');
 }
});

ここで push 先を特定するために、GITHUB_ACTOR と GITHUB_TOKEN 環境変数を使っています。GITHUB_TOKEN は、このアクションの実行のためだけに都度発行され、対象のリポジトリの操作が可能な権限を有しています。

git push コマンドを tools.exec() 関数で実行していることは、先ほどと同じです。最後に、tools.exit.success() 関数で成功したことを伝え、もし何らかの例外が発生してしまった際には、tools.exit.failure() 関数を呼び出して、ジョブを停止させます。

以上で必要な開発が終わりとなります。GitHub Actions向けのアクション開発ができそうに思えてきましたでしょうか?

ハマった点その1 pushの失敗

さて、今まで説明してきたコードに間違いはなかったのですが、あるプロジェクトで適用したときに「どうしても push に失敗してしまう」という現象に出くわしました。その問題を解決するために半日以上かかってしまったので、皆さんが同じ失敗をしないように、個々に解決策を載せておきます。

まず、push に失敗した理由は、GitHub の main ブランチに対して、ルールを適用していたことが原因でした。main ブランチに良くない状況のコードが混入してしまうことを防ぐために、「GitHub Actionsとして登録してある○○ジョブが正常に実行できたこと」をmainブランチへのpushの条件としていたのです。

GitHub Actions向けアクションからの今回のpushは、このルールをこなしていない状況での「直接的なpush」になってしまうため、push が拒否されてしまっていた、というのが理由です。つまり、失敗して当然な状況でした。

では、このルールを適用せずにpushすることはできないのか?なにかルールの適用外になる状況を作ることができれば良いわけです。そこで、一つアイディアが浮かびました。それは、

「リポジトリの管理権限保持者からのpushとすれば良い」

ということです。Pull Requestのマージボタンは、管理権限保持者であれば「ホントはできないけど管理権限持ってるようだから押せるよ」と警告付きで押せるようになっています。これを思い出し、同じ状況になればいけるかな、と想像しました。

その状況を作り出すための方法ですが、ここでパーソナルアクセストークンを利用します。管理権限を持っているアカウントの設定画面で、パーソナルアクセストークンを発行します。この際、repoスコープを有効にしておきます。

そして、今回のアクションを適用するリポジトリのSecrets設定にて、例えば「PAT_YOICHIRO」という名前で、先ほど発行したパーソナルアクセストークンを登録しておきます。そして、対象のリポジトリをチェックアウトする際に、その PAT_YOICHIRO をトークンとして指定します。

    steps:
     - name: Checkout Repository
       uses: actions/checkout@v2
       with:
         ref: main
         token: ${{ secrets.PAT_YOICHIRO }}
     - name: Increment value
       uses: yoichiro/gh-action-increment-value@main
       with:
         target_directory: 'foobar/baz'
         target_file: 'build-number.json'
         prefix: '{"buildNumber":'
         suffix: '}'

これにより、管理権限を持つユーザとしてチェックアウトを行うことができます。これを push する際には、チェックアウトしたときのユーザの権限で実行されるため、めでたくルールを飛び越えて push ができるようになります。

ハマった点その2 永久ループ

さて、めでたく push できるようになったのは良いのですが、その後に待っていた問題がありました。それは「永久にアクションが実行し続けられる」という現象です。

今回動作確認したときは、mainブランチにpushがあったらアクション実行、というタイミングを仕掛けました。

はい、ここでピンと来た方は、さすがです。

そう、アクションの中で行っていたことは、まさに「mainブランチにpush」なんです。これにより、自分自身を再度実行することになってしまいます。僕は、+1 され続け、28 あたりまで進んでしまったときに気が付きました。恐ろしいです。

この永久ループから抜けるためにはどうしたら良いでしょうか?そう、アクション実行の条件としてもう少し指定することが必要でした。

具体的には、「mainブランチにpushがあったらアクション実行、ただし特定のファイルの更新については適用対象外」という指定を行えば、再実行を回避できます。今回例に上げたファイルは「foobar/baz/build-number.json」でした。これを以下のようにして対象外のファイルとして明記します。

name: Increment the build number
on:
 push:
   branches:
     - main
   paths-ignore:
     - '**/build-number.json'

paths-ignore の指定が対象外とするファイルの指定となります。最初に僕はこれを書いていなかったために、延々とアクションが呼び出され続けることになってしまったというわけです。

GitHub Actionsは、開発者が指定したとおりに黙々と動作します。その結果、気がついたら大変なことになっていた、という状況になってもおかしくありません。そうならないためにも、特にGitHub Actions向けアクションの処理に関しては、ファイルの更新とアクション起動条件に気をつけてください。