GitHub Actions Ruby

GitHub Actions上で独自のワークフローを構築しやすくするための仕組み

GitHub Actions Ruby

本エントリはGMOペパボエンジニア Advent Calendar 2021の25日目のエントリです。メリークリスマス!

はじめまして、技術部技術基盤チームの@k1LoW と申します。最近はYouTube Musicの「おすすめのアーティスト」をふらふらと漂流するのが好きです。その漂流で見つけた Nubiyan Twist がカッコいいです。

GMOペパボではGitHub Enterprise Server (以下、GHES)を利用しており、CI/CD基盤としてGitHub Actionsを活用しています。

本ブログでも様々なGitHub Actions活用事例を紹介しています。

#GitHub Actions に関する記事一覧

手動で実施していた業務やタスクをGitHub Actionsのワークフローとして構築しなおす

GMOペパボではGitHub Actionsをいわゆる「CI/CDの基盤」として活用するだけでなく、さまざまな業務やタスクの自動化の基盤としても活用しています。

その自動化の範囲は開発の現場だけでなく、開発の現場以外でも適用可能です(その一部ですが、 @ITにて連載させていただいた「GMOペパボに学ぶ「CI/CD」活用術 」でも紹介していますので是非)。

GitHub Actionsはワークフローという単位で処理を構築します。

GitHub Actionsのワークフローは1つもしくは複数のジョブで構成されており、ジョブは1つまたは複数のステップで構成されています。

ステップごとに uses: セクションで「Action」と呼ばれるGitHub Actions専用のアプリケーションを呼び出したり、 run: セクションで独自に処理を書いたりして、最終的にワークフローを構築します。

もともと手動で実施していた業務やタスクをGitHub Actionsのワークフローとして構築しなおして、その実行がバシッと決まるのは気持ちが良いものです。

また、GitHub Actionsに業務やタスクの実施を任せることによって本来注力すべきことに注力できるのも気持ちが良いものです。

独自のワークフロー

「手動で実施していた業務やタスク」を既に世の中に存在するActionの組み合わせのみで実現できれば最高です。

また、ローカルでの実行のための既にコード化されている処理( make XXXrake XXX ) の実行や、ちょっとした処理ならステップの run: セクションにそのままコマンドを書けばいいでしょう。

しかし、既存のActionだとあと少し痒い所に手が届かず、「ちょっとした処理」というには run: セクションのコマンドだと表現が難しい、ということがあります。

今回、上記のような「独自のワークフロー」をGitHub Actions上で構築するために作成した仕組みを2つ紹介したいと思います。

github-script-ruby

1つ目の紹介です。

「GitHub Actions上でGitHubのコンテキストに沿った操作を簡単に書けるAction」として思いつくのは actions/github-script かと思います。

octokit/rest.js が認証情報が設定されインスタンス化された状態で github に読み込まれていたり、GitHub Actionsの情報も context からアクセス可能になっていたり、他にも便利なライブラリが読み込まれた状態からすぐに必要な処理を書き出せます。

actions/github-script はとても使いやすいのですが、処理を書くプログラミング言語にはJavaScriptしか選択できません。

GMOペパボはJavaScriptもですがRubyも好きなエンジニアも在籍していますので(他の言語も好きなエンジニアもたくさんいます!)、 「Ruby版 actions/github-script」を用意してみました。

k1LoW/github-script-ruby

例えば actions/github-script のExamplesにある例ですが、 actions/github-script を使ってIssueにコメントするワークフローを書くと

on:
  issues:
    types: [opened]

jobs:
  comment:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v5
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '👋 Thanks for reporting!'
            })

となりますが、k1LoW/github-script-ruby を使って書くと

on:
  issues:
    types: [opened]

jobs:
  comment:
    runs-on: ubuntu-latest
    steps:
      - uses: k1LoW/github-script-ruby@v1
        with:
          script: |
            repo = "#{context.repo.owner}/#{context.repo.repo}"
            number = context.issue.number
            comment = '👋 Thanks for reporting!'
            github.add_comment(repo, number, comment)

となります。書き味はそのままに、Rubyスクリプトです。

Gemの追加

k1LoW/github-script-ruby のステップで使いたいGemをインストールしたい場合は gemfile: セクションでGemfileを書くことで追加可能です。

以下の例はSlack通知のために slack-ruby-client.gem を追加で事前インストールしている例です。

- name: 'Slack  #general チャンネルにメッセージをポストする'
  uses: k1LoW/github-script-ruby@v1
  with:
    script: |
      require 'slack-ruby-client'
      Slack.configure do |config|
        config.token = ENV['SLACK_API_TOKEN']
      end
      client = Slack::Web::Client.new
      client.chat_postMessage(channel: '#general', text: 'Hello, Slack bot!')
    gemfile: |
      source 'https://rubygems.org'
      gem 'octokit', '~> 4.0'
      gem 'slack-ruby-client'
  env:
    SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }}

Native extentionのためのパッケージの追加

また、例えば mysql2.gem のようなCネイティブ拡張を含んだGemをビルドするために必要なパッケージを pre-command: セクションを使って、事前にインストールすることも可能です。

- name: 'List users'
  uses: k1LoW/github-script-ruby@v1
  with:
    script: |
      require 'mysql2'
      client = Mysql2::Client.new(:host => "localhost", :username => "root")
      client.query('SELECT * FROM users').each do |row|
        puts row['name']
      end
    pre-command: |
      apt-get update
      apt-get install -y libmysqld-dev
    gemfile: |
      source 'https://rubygems.org'
      gem 'mysql2'

github-script-ruby の個人的にお気に入りの特徴は、 actions/github-script と同様にワークフローの途中で突然Ruby環境を用意できるというところです。

PHPアプリケーションのCIのワークフローの途中でも、Goアプリケーションのビルドのワークフローの途中でも、すぐにRubyを書いて独自の処理を追加することができます。

これは github-script-ruby が「Docker container action」の仕組みを使って実装されているからなのですが、事前になにもセットアップせずにRubyスクリプトを書けるのは便利です。

社内の利用事例

Slackチャンネルにブログのfeedを取得して通知してくれるbotが生息しているのですが、そのfeedのURLを URI.regexp を使ってチェックしています。

    - name: validate feed.yaml
      uses: k1LoW/github-script-ruby@v1
      with:
        script: |
          require 'yaml'
          require 'uri'
          Dir.glob("path/to/config/*feed.yaml").each { |yaml|
            YAML.load_file(yaml).each { |url|
              unless URI.regexp.match(url)
                raise 'URL is invalid: "%s"' % url
              end
            }
          }

URI.regexp 知りませんでした。便利だ…。

ghdag

2つ目の紹介です。

独自のワークフローの多くのケースに

  1. GitHubリポジトリの操作(IssueやPull Request)
  2. Slack通知

があることが感覚としてありました。

ただ、その2つに元々の業務やタスクの独自性があるため、既存のActionでは表現が難しいことがありました。

そこで上記2つに特化して少し抽象化をした仕組みを作りました。

https://github.com/k1LoW/ghdag

ghdagはGitHub Actions上でIssueやPull Requestに対して動く小さなワークフローエンジンです。

まず GitHub Actionsの .github/workflows/*.yml とは別にghdag専用のワークフローファイルを用意します。

ghdag init でサンプルワークフローファイルを生成可能です。

$ ghdag init myworkflow
2021-02-23T23:29:48+09:00 [INFO] ghdag version 0.2.3
2021-02-23T23:29:48+09:00 [INFO] Creating myworkflow.yml
Do you generate a workflow YAML file for GitHub Actions? (y/n) [y]: y
2021-02-23T23:29:48+09:00 [INFO] Creating .github/workflows/ghdag_workflow.yml

タスクとアクション

ghdagにはタスクという単位でIssueやPull Requestに対して処理を実行します。実際のGitHubの操作やSlack通知などはアクションという名前で提供されています。

ghdagのサンプルワークフローファイルを見てみます。

# myworkflow.yml
tasks:
  -
    id: set-question-label
    if: 'is_issue && len(labels) == 0 && title endsWith "?"'
    do:
      labels: [question]
    ok:
      run: echo 'Set labels'
    ng:
      run: echo 'failed'
    name: Set 'question' label

1つのワークフローファイルには tasks: に複数のタスクを設定できます。タスクは大きく4つのセクションに分かれてます。

  • if: … そのタスクを実行するための条件を定義
  • do:if: セクションの条件が true の場合に発動するアクションを定義
  • ok:do: セクションの実行が成功した場合に発動するアクションを定義
  • ng:do: セクションの実行が失敗した場合に発動するアクションを定義

アクションには以下の8種類のみが用意されています。

  • run: … 任意のコマンドを実行します
  • labels: … 対象のIssue/Pull Requestにラベルを設定します
  • assignees: … 対象のIssue/Pull RequestにAssigneeを設定します
  • reviewers: … 対象のPull Requestにレビュアーを設定します
  • comment: … 対象のIssue/Pull Requestにコメントします
  • state: … 対象のIssue/Pull Requestの状態を変更します
  • notify: … Slack通知を送ります
  • next: … 別のタスクを実行します

上記のサンプルワークフローファイルに設定されたタスクは

もし対象がIssueで、かつセットされたラベルが0、かつIssueタイトルの末尾に ? がついていたら、 question のラベルをつける。成功したら echo 'Set labels'、失敗したら echo 'failed' を実行する

という意味になります。

タスクの実行タイプ

タスクは全てIssueかPull Requestに対して実行します。

また、ghdagは「CloseしたIssueやPull Requestは対象として扱わない」「IssueやPull RequestのOpenからCloseまでのワークフローを管理する」という割り切りをしています。

タスクの実行タイプは2種類あり、それぞれghdagは自動で判定します。

実行タイプ1. 特定のIssueやPull Requestに対して実行

実行タイプ1. 特定のIssueやPull Requestに対して実行

on.issues:on.pull_request: などでghdagが実行された場合、ghdagはそのトリガーを発火させたIssueやPull Requestに対してしかタスクを実行しません。

タスクは対象のIssueやPull Requestに対して tasks: セクションの上から順に実行されます。

例えば、

Pull Requestに'needs review'ラベルがついたとき、まだレビュアーがいなかったら、ランダムでアサインする

を実現したい場合、以下のようなタスクとGitHub Actionsのワークフローになります。

# myworkflow.yml
---
tasks:
  -
    id: assign-pull-request
    if: 'is_pull_request && len(reviewers) == 0 && "needs review" in labels'
    do:
      reviewers: [alice bob charlie]
    env:
      GITHUB_REVIEWERS_SAMPLE: 1
# .github/workflows/ghdag_workflow.yml
name: ghdag workflow
on:
  pull_request:
    types: [labeled]

jobs:
  run-workflow:
    name: 'Run workflow for A **single** `opened` issue that triggered the event'
    runs-on: ubuntu-latest
    container: ghcr.io/k1low/ghdag:latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
      - name: Run ghdag
        run: ghdag run myworkflow.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

実行タイプ2. Openしている全てのIssueやPull Requestに対して実行

実行タイプ2. OpenしているIssueやPull Requestに対して実行

on.push:on.schedule: など、特定のIssueやPull Requestに紐づかないイベントでghdagが実行された場合、ghdagはOpenしている全てのIssueとPull Requestに対してタスクを実行します。

タスクはそれぞれのIssueやPull Requestに対して tasks: セクションの上から順に実行されます。

例えば、

Issueが30日間放置されているとき、かつコメントが1件もついていなかったら、Closeする

を実現したい場合、以下のようなタスクとGitHub Actionsのワークフローになります。

# myworkflow.yml
---
tasks:
  -
    id: close-issues-30days
    if: is_issue && hours_elapsed_since_updated > (30 * 24) && number_of_comments == 0
    do:
      state: close
# .github/workflows/ghdag_workflow.yml
name: ghdag workflow
on:
  schedule:
    # Run at 00:05 UTC every day.
    - cron: 5 0 * * *

jobs:
  run-workflow:
    name: 'Run workflow for **All** `opened` and `not draft` issues and pull requests'
    runs-on: ubuntu-latest
    container: ghcr.io/k1low/ghdag:latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
      - name: Run ghdag
        run: ghdag run myworkflow.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

ghdag の個人的にお気に入りの特徴は、1つのステップ内で多くのタスクを同時に処理できるところです。タスクを next: アクションでうまくつなげていくことで小さなワークフローを1ステップ内に作ることも可能です。

GitHub Actionsでも if: セクションや needs: セクションを使って同じような処理を書くことができるので好みにはなりますが、選択肢が増えるのは良いことだと思っています。

社内の利用事例

Pull Requestのレビューのステータスをラベルでわかりやすくして運用しているリポジトリで、そのラベルの操作を自動化しています。

---
tasks:
  -
    id: remove-label-In-Review
    name: レビューされてApproveされていたらIn Reviewラベルを外す
    if: |
      github.event_name == "pull_request_review"
      && github.event.action in ["submitted", "edited"]
      && is_approved
      && is_pull_request
      && "In Review" in labels
    do:
      labels: ["In Review"]
    env:
      GHDAG_ACTION_LABELS_BEHAVIOR: remove

確かに、レビュー後に「レビュー中」ラベルは外し忘れがちですよね。

まとめ

今回は「GitHub Actions上で独自のワークフローを構築しやすくする」という少しメタな視点での仕組みの紹介でした。

GitHub Actionsで何かを実現したいときの1つの選択肢となればとても嬉しいです。