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
  ...
}

privatetrueとしておきます。

また、アクション作成に便利なモジュール郡である@actions/coreTypeScriptなど、必要な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/coresetOutput()はこの出力のためのラッパです。

// 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を使って必要な依存だけをパッケージングすることにします。 nccpackage.jsonmainを参照して、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.jsonscriptsに、ビルドのクリーンと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に公開されます。

Releaseページ上でのアクションの公開

GitHub Marketplaceでのアクションの公開 - アクションの公開についてによると、action.yamlnameについて以下のような一定のユニーク制約がある旨が記載されていますので注意が必要です。

  • アクションのメタデータファイル中のnameがユニークであること。
    • nameはGitHub Marketplaceで公開されている既存のアクション名とマッチしてはならない。
    • nameは、そのアクションを公開しているユーザもしくはOrganizationのオーナー以外のGitHub上のユーザもしくはOrganizationとマッチしてはならない。 たとえばgithubという名前のアクションを公開できるのはGitHub Organizationだけである。
    • nameは既存のGitHub Marketplaceのカテゴリとマッチしてはならない。
    • GitHubはGitHubの機能の名前を予約している。

終わりに

GitHub Actionsのアクションは比較的簡単に作成できることがわかりました。 そのため、共通で切り出せそうだと思われる処理は、積極的にアクションにしていくことを検討しても良いと思いました。

この記事がJavaScriptアクションをTypeScriptで作りたい方などの参考になれば幸いです。