GitHub ActionsのRunner OSの情報を取得できるアクションを作った
GitHub Actionsのubuntu-latest
が18.04から20.04にアップグレードされる旨が2020年の10月に告知され、徐々に展開されてきました。
私が定期的に実行していたRubyのプロジェクトのWorkflowも、2021年2月ごろから以下のエラーが発生し始めました。
LoadError: libffi.so.6: cannot open shared object file: No such file or directory - /home/runner/work/kenchan0130.github.io/kenchan0130.github.io/vendor/bundle/ruby/2.7.0/gems/ffi-1.14.2/lib/ffi_c.so
これは、ffiがNative Extensionを使用しており、
- uses: actions/cache@v2
with:
path: vendor/bundle
key: v1-${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
v1-${{ runner.os }}-gems-
のようにactions/cacheのアクションでキャッシュ設定をしていたため、Ubuntu 18.04の際のパスを記憶してキャッシュされ、Ubuntuのバーションが20.04にバーションが変わった際に不具合が生じてしまいました。
今後また同じようなことが起こらないようにするためには、キャッシュのkeyにrunnerのOSのバージョンの情報を使用することが考えられます。
しかし、任意のstepでバーション情報を求め、stepのrun内で:set-output name=
の出力をすることでstepのoutputsとなり、キャッシュのkeyとして使用できますが、そのスクリプトを書くのも面倒ですし、各runnerのOSに対応するのもたいへんです。
そのため、runnerのOSの情報を取得できるアクションを用意したほうが汎用性が高いと考え、GitHub Actions runner OS system informationを作成しました。
前置きが長くなりましたが、今回はこの問題を解決するために役立つGitHub Actionsのアクションを作成したので、備忘の兼ねてアクションの作り方を紹介します。
アクションの種類の選択
GitHub Actionsのアクションは、
- Docker
- JavaScript
- 複合実行ステップ
の3つのどれかを選択できます。 詳しくは「アクションについて」を参照してください。
今回作成するアクションについて
今回は「OSのプラットフォームを識別する値」を、アクション呼び出し側がoutputsとして使用できるようにしてみます。
合わせて、Linux、macOSおよびWindowsの環境で動作させたいため、
- JavaScriptアクション
- 複合実行ステップ
のどちらかを選択することになります。
実行環境によってスクリプトを使い分けたくなかったため、JavaScirptアクションで作成することにします。 また、JavaScriptで書いてしまうと型情報がないため、TypeScriptで作成することにします。
執筆現在、JavaScriptアクションのNode.jsのランタイムはv12のみです。
JavaScriptアクションのしくみ
JavaScriptアクションは、rootディレクトリに設置したアクションのメタデータとなるaction.yml
(またはaction.yaml
)の設定に従って実行されます。
# action.yml
name: "GitHub Actions runner OS system information"
description: "This action provides GitHub Actions runner OS information."
branding:
icon: 'server' # You can use an icon list of https://feathericons.com/
color: 'gray-dark'
runs:
using: "node12"
main: "lib/index.js"
GitHub ActionsのWorkflowのstepでは、このaction.yml
が設定されたリポジトリをダウンロードしてきて、(おそらく)node lib/index.js
を実行します。
NPMのパッケージとは異なり、リポジトリ内にnode_moduels
の依存を含めたコードをリポジトリ内に用意する必要がある点に注意が必要です。
アクションの作成
プロジェクトの初期化
JavaScriptアクション、つまりNode.jsを使用するため、
npm init
を実行しpackage.json
を作成します。NPMのパッケージとしては登録しないため、
// package.json
{
"private": true
...
}
private
はtrue
としておきます。
また、アクション作成に便利なモジュール郡である@actions/coreやTypeScriptなど、必要なNPMのパッケージを依存に追加しておきます。
TypeScriptのエントリポイントの追加
TypeScriptのエントリポイントとなるファイルを追加します。 今回はsrc/index.ts
とします。
// src/index.ts
import * as core from "@actions/core";
async function main() Promise<void> {
// 後で処理を追加
}
main().catch((e: Error) => core.setFailed(e.message));
上記の例では、アクションとしては main().catch((e: Error) => core.setFailed(e.message))
が実行されます。
ファイルの分割
上記の例の場合、main
関数にすべての処理を記述しても良いですが、テストのしやすさのためファイルを分割することがあります。 アクションを作る場合でも、問題なくファイル分割が可能です。
// src/sytemInfo.ts
import os from "os"
type SystemInfo = {
readonly platform string;
}
export const getSystemInfo = async (): Promise<SystemInfo> => {
return Promise.resolve({
platform: os.platform(),
});
}
// src/index.ts
import * as core from "@actions/core";
import { getSystemInfo } from "./systemInfo";
async function main() Promise<void> {
const systemInfo = await getSystemInfo();
}
main().catch((e: Error) => core.setFailed(e.message));
アクションのアウトプットを定義
step内において、::set-output name=NAME::VALUE
の形式の標準出力があると、outputsとして認識されます。 @actions/core
のsetOutput()
はこの出力のためのラッパです。
// src/index.ts
import * as core from "@actions/core";
import { getSystemInfo } from "./systemInfo";
async function main() Promise<void> {
const systemInfo = await getSystemInfo();
core.setOutput("platform", systemInfo.platform);
}
main().catch((e: Error) => core.setFailed(e.message));
必ずしもmain
の関数内でsetOutput()
を呼び出す必要はないですが、インプットとアウトプットの処理をまとめるとより良い思います。
パッケージングの設定
前述したとおり、JavaScriptアクションはnode_moduels
の依存もリポジトリ内に含める必要があります。 node_moduels
ディレクトリをGit管理してもよいですが、差分管理したいわけでもないですし、devDependences
なども含まれてしまうため、ファイルが巨大になってしまう恐れもあります。
そのため、@vercel/nccを使って必要な依存だけをパッケージングすることにします。 ncc
はpackage.json
のmain
を参照して、node_modules
の必要なものをwebpackを使用して1つのファイルにします。
// package.json
{
"private": true
"main": "dist/index.js",
"scripts": {
"build": "tsc --outDir dist/",
"clean": "rimraf dist/",
"package": "npm run clean && npm run build && ncc build -o lib/ --license LICENSE -m -s"
},
...
}
今回はpackage.json
のscripts
に、ビルドのクリーンとTypeScriptのビルド、およびnccによるパッケージングを行うpackage
を定義しました。
また、上記の例の場合、dist/
に作られるファイル郡は中間ファイルとなるため、.gitignore
に追加しておくと良いです。
# .gititnore
dist/
アクションのCI
TypeScriptで作成しているため、通常通りユニットテストは実行できます。 ただ、実際にアクションとして動作するかも確認したくなります。
GitHub ActionsでCIを実行する場合、作成しているアクションをテストできます。
action.yaml
が定義されていれば、現在作成しているアクションは、
- name: Run this action
uses: ./
id: this-action
のように、uses: ./
とすることで実行できます。
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "12"
- name: Get npm cache directory path
id: npm-cache
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v2
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install Node.js dependences
run: |
npm ci
- name: Run package
run: |
npm run package
- name: Run this action
uses: ./
id: this-action
- name: Output this action
run: |
echo "${{ steps.this-action.outputs.platform }}"
上記の例では、npmの依存をインストール、およびパッケージングをしてアクションを実行しています。
アクションのデプロイ
パッケージングしたソースコードはGit管理しなければいけないですが、Pull Requset時に一緒にプッシュするのは億劫です。
そのため、今回は起点となるmasterブランチに変更があったらパッケージングをして、リポジトリにプッシュするようにしました。
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches:
- master
workflow_dispatch:
jobs:
Package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "12"
- name: Get npm cache directory path
id: npm-cache
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@v2.1.4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install Node.js dependences
run: |
npm ci
- name: Run package
run: |
npm run package
- name: Push package
run: |
git config --local user.email "メールアドレス"
git config --local user.name "ユーザー名"
git remote set-url origin https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
git add -N .
if git diff --exit-code --quiet;then
echo "No change."
else
git add .
git commit -m "[skip ci] Create package by ${GITHUB_SHA}"
git push origin master
fi
これでデプロイを自動化できました。
アクションの公開
作成したアクションはGitHub Marketplaceに公開が可能です。 もちろんアクションはuses: リポジトリ名/リビジョン
と指定するため、必ずしもGitHub Marketplaceに公開されていなくても使用できます。
GitHubのリリースを作成すると、GitHub Marketplaceに登録するかどうかを聞かれます。 アグリーメントに同意し、Publish this Action to the GitHub Marketplaceにチェックを入れることで自動でGitHub Marketplaceに公開されます。
GitHub Marketplaceでのアクションの公開 - アクションの公開についてによると、action.yaml
のname
について以下のような一定のユニーク制約がある旨が記載されていますので注意が必要です。
- アクションのメタデータファイル中のnameがユニークであること。
- nameはGitHub Marketplaceで公開されている既存のアクション名とマッチしてはならない。
- nameは、そのアクションを公開しているユーザもしくはOrganizationのオーナー以外のGitHub上のユーザもしくはOrganizationとマッチしてはならない。 たとえばgithubという名前のアクションを公開できるのはGitHub Organizationだけである。
- nameは既存のGitHub Marketplaceのカテゴリとマッチしてはならない。
- GitHubはGitHubの機能の名前を予約している。
終わりに
GitHub Actionsのアクションは比較的簡単に作成できることがわかりました。 そのため、共通で切り出せそうだと思われる処理は、積極的にアクションにしていくことを検討しても良いと思いました。
この記事がJavaScriptアクションをTypeScriptで作りたい方などの参考になれば幸いです。