GitHub Actionsで手動と自動のどちらでも実行できるデプロイワークフローを作る

やりたいこと

GitHub Actionsで次の要件を実現できるデプロイのワークフローを作りたい。

  • 次のトリガーでデプロイできる
    • リポジトリへのAPI呼び出し
    • 手動
    • プルリクエストのマージ
  • デプロイするブランチと環境(主にstagingもしくはproduction)を選択できる

Web APIからだと次のようにデプロイできる。

# デプロイをリポジトリへのAPI呼び出しとして実行
$ curl -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <token> \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/<owner>/<repo>/dispatches \
  --data '{ "event_type": "deploy", "client_payload": { "branch": "main", "environment": "production" }}'

Web UIだと次のような画面を通じてデプロイできる。

デプロイワークフローを実行するWeb UI

このようなワークフローを作るメリットとして次の点がある。

  • ユースケースに応じて異なるデプロイ方法を選択できる
    • ふだんの開発フローでは自動デプロイしたstagingで動作確認する
    • 他システムやSlackと連携するときはAPI呼び出しする
    • 緊急時などにブラウザから手動でデプロイできる
  • 複数環境のデプロイを同じワークフローで管理しつつ、環境ごとに異なる設定を利用できる

deployment environmentの作成

あらかじめリポジトリのdeployment environment*1としてstagingとproductionの2つの環境を作っておく。ワークフローからはこのenvironmentを使うと次のようなメリットがある。

  • 環境ごとのデプロイ履歴が残る
  • デプロイを許可するブランチを設定できる

Web UI上でenvironmentを作成すると次のようになる。

作成したenvironment

API呼び出しと手動のどちらでも実行できるワークフロー

まず、API呼び出しか手動でワークフローに対して入力を渡し、その入力に基づいてデプロイを実行するようなワークフローを作る。つまり、1枚のワークフローファイルに

  • API呼び出しか手動で渡した入力を処理
  • 入力に基づくデプロイ

の両方を書く。

name: Deploy

on:
  # API呼び出しで実行
  repository_dispatch:
    types: [deploy]

  # 手動で実行
  workflow_dispatch:
    inputs:
      environment:
        default: staging
        options:
          - staging
          - production
        required: true
        type: choice

jobs:
  setup:
    runs-on: # some runner

    # トリガーするイベントに応じてinputをoutputに変換
    steps:
      - id: setup-from-inputs
        if: github.event_name != 'repository_dispatch'
        run: |
          echo environment=${{ inputs.environment }} >> $GITHUB_OUTPUT
          echo branch=${{ github.ref_name) }} >> $GITHUB_OUTPUT
      - id: setup-from-payload
        if: github.event_name == 'repository_dispatch'
        run: |
          echo environment=${{ github.event.client_payload.environment }} >> $GITHUB_OUTPUT
          echo branch=${{ github.event.client_payload.branch) }} >> $GITHUB_OUTPUT

    # ジョブのoutputとしてenvironmentとbranchを設定
    outputs:
      environment: ${{ steps.setup-from-inputs.outputs.environment || steps.setup-from-payload.outputs.environment }}
      branch: ${{ steps.setup-from-inputs.outputs.branch || steps.setup-from-payload.outputs.branch }}

  # setup.outputs.environmentがproduction以外のときにstagingへのデプロイを実行
  staging:
    needs: setup
    if: needs.setup.outputs.environment != 'production'
    runs-on: # some runner

    # deployment environmentに関する設定
    environment:
      name: staging
      url: https://staging.example.com
    concurrency: staging

    steps:
      - uses: actions/checkout@v3
      - name: deploy
        run: # ...

  # setup.outputs.environmentがproductionのときにproductionへのデプロイを実行
  production:
    needs: setup
    if: needs.setup.outputs.environment == 'production'
    runs-on: # some runner

    # deployment environmentに関する設定
    environment:
      name: production
      url: https://example.com
    concurrency: production

    steps:
      - uses: actions/checkout@v3
      - name: deploy
        run: # ...

repository_dispatchの場合はdeployイベントでenvironmentbranchをパラメータとして渡すことを想定している。

workflow_dispatchの場合はWeb UIまたはGitHub CLIから手動で実行する*2。デプロイ先の環境を入力として受け取る。type: choiceにすると、Web UIのセレクトボックスからoptionで渡した選択肢を選択できる。

setupジョブではどのトリガーでワークフローが実行されたかを判別して、environmentbranchをジョブのoutputとして設定している。stagingジョブとproductionジョブはsetupジョブに依存しており、setupのoutputに基づいてstagingproductionのどちらか1つのジョブだけを実行する。

environmentのセクションでnameで作成済みのenvironmentの名前を指定し、さらにデプロイ先をURLとして指定する。このURLはdeploymentに関するUIで利用される。2023年3月の時点ではnameには式を渡せない*3ので、ここでは文字列を直接渡している。

また、concurrencyにenvironmentの名前を指定することで、あるenvironmentに対して同時に複数のデプロイジョブが実行されることを防ぐ。

プルリクエストをマージすると自動でデプロイするワークフロー

上のワークフローを使って、自動デプロイワークフローを作る。上のワークフローにトリガーとしてworkflow_callを追加する。

name: Deploy

on:
  repository_dispatch:
    types: [deploy]
  workflow_call: # 追加
    inputs:
      environment:
        default: staging
        type: string
  workflow_dispatch:
    # ...

workflow_callをトリガーとして追加すると、reusable workflowとして他のワークフローから呼び出せるようになる。

これで次のように自動でデプロイするワークフローを作れる。

name: Auto deploy

on:
  push:
    branches:
      - master

jobs:
  staging:
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
    secrets: inherit

usesにdeploy.ymlへのパスを、入力としてenvironment: stagingを指定してデプロイを実行する。reusable workflowを使うときはstepではなくjob直下にusesを書くことに注意。また、リポジトリのシークレットをreusable workflowで使いたいときは、secrets: inheritと書くとreusable workflowにそのままシークレットを渡せる。第三者のreusable workflowを使うときはsecrets: inheritは使わず、必要なシークレットだけを明示的に渡すほうがよい。

コーヒー環境: カプセル式コーヒーメーカーとタンブラー

家のコーヒー環境が完全に安定したので書いておく。

コーヒーメーカー

GENIO Sを使っている。

ドルチェグストは8年ぐらい使っていて、先代機は煙を吹いて壊れたのでおととし買い替えた。

カプセル式はとりあえずそれなりのコーヒーが飲めればいい、掃除は楽なほうがいいという人にはかなり楽だと思う。

抽出開始の操作が昔のレバー式ではなくタッチパネル式なので、抽出終わりでレバーが戻るときの「バチン!」という音はなくなったのはよい。ただ、タッチパネルによる設定が繊細なので、誤設定のまま抽出開始してしまうことがたまにある。

ポイントがためられるモバイルアプリがあり、マシンとBluetooth連携できるのだが、接続性がやたら悪い。アプリ自体もそこまでメリットがないので、結局ほぼ使っていない。

コーヒーカプセル

いまはサニーデイブレンドを飲んでいる。

オンライン専用商品らしく、スーパーなどでは見かけたことがないので、存在を知ったのはつい先日だった。飲みやすい。今はAmazon定期おトク便で3週間に1回3箱セットを発注している*1

タンブラー

サーモスの真空断熱タンブラー (JDP-300 BK)を使っている。

GENIO Sでコーヒーを抽出すると、そこまで熱々で出てくわけではない(70℃程度)。保温しないとすぐに冷めるので、タンブラーに直接抽出するのがよい。

シンプルな構造で使いやすく満足している。取っ手はないがとくに困っていない。洗うときも取っ手がないほうが若干楽かもしれない*2

*1:ネスレのオンラインストアは使いにくすぎて挫折した

*2:食洗機を持っていない

GitHub ActionsのDockerコンテナアクションでproblem matchersを使う

Dockerコンテナアクション

GitHub Actions (GHA)のDockerコンテナアクション

  • アクションを呼び出したときに実行したいスクリプト(entrypoint.sh)
  • その実行環境としてのDockerfile

を用意してGHAのアクションとしてまとめたもの。かんたんに他のリポジトリから利用できるアクションを作ることができる。

problem matchers

GHAにはproblem matchersという機能がある。この機能を使うと、アクション実行時に標準出力に現れる文字列(多くは静的解析ツールが出力する警告など)を解析する正規表現を含む設定ファイルを事前に::add-matcherコマンドで登録することで、プルリクエスト (PR)の"Files changed"タブにアノテーションを表示できる。

アノテーションとは、具体的には次のような表示のこと。

problem matchersによるアノテーションの表示。この例ではRuboCopの解析結果を表示している

Dockerコンテナアクションの実行時にproblem matchersを有効化する

以上の機能を組み合わせて、Dockerコンテナアクションで静的解析ツールを実行して、その結果をproblem matchersによってアノテーションとして表示したいときは次のようにする。

まず、アクションのentrypoint.shで::add-matcherをechoしておく。

#!/bin/sh -l

# アクションのリポジトリの.github/matcher.jsonにmatcherの定義を書いておく
echo "::add-matcher::.github/matcher.json"

# 静的解析ツールの実行など…

これでmatcherの設定ファイルがGHAに認識され、登録される。

あとは、matcherで定義したパターンの文字列(ファイル行を含む)を標準出力に出力すると、matcherがパターンを解析して、アクションを呼び出したPRの"Files changed"タブのdiff上で対応する行にアノテーションを付与する。

::add-matcherのドキュメントの例では、ワークフロー上でmatcherを追加する例だけが載っているだけなのだが、GHAとしては標準出力に::add-matcherが出ていれば、それを見てmatcherを登録するだけなので、アクション側のスクリプトでechoしても問題ないというのが発見だった。

macOSの日本語入力プログラムでトグルではない方法でひらがなと英字を切り替える

前提

  • あえてmacOSの日本語入力プログラム(macOSデフォルトの日本語IME)だけで生活しようとしている場合の記事
  • ふつうはKarabiner Elementsを使えばよいはず

これまで

以前Ctrlでひらがなと英字モードをトグルするようにした。

Macのセットアップでやること2022 - kymmt

結果としては、これは使いにくかった。理由:

  • ノートブックのキーボードだとCtrlが小さい
  • Option と誤打する
  • どちらのモードなのかメニュー上のIMEの表示を見ないと人間が把握できない
    • 見ればいいのだが見ない

日本語入力ソースの中のモードを使う

実はトグルではない方法で日本語と英語の入力モードを切り替える方法がある。

日本語入力プログラムのドキュメント (https://support.apple.com/ja-jp/guide/japanese-input-method/jpimf6ffb247/mac) を読むと、日本語入力ソースに「ひらがな」と「英字」という日本語入力モードがあることがわかる(英語だと"Hiragana"と"Romaji"という表記)。この「英字」は"ABC"で表される英字ソースとは別物。

「ひらがな」と「英字」のモードはそれぞれCtrl+Shift+JCtrl+Shift+;で切り替えることができる*1。トグルではないので、キーバインドを打鍵すれば確実にそのモードになったことが保証される。

最近はこちらの方法を使っている。

現在の問題点

英字モードのときにGoogle Chromeでフォームのテキストエリアっぽい箇所に文字を入力すると、ブラウザがしばらく反応しなくなることがある(少なくともM1 Macbook Pro + mac OS 12.6 + Chrome 109で発生)。GitHubのインクリメンタルサーチやRedashでクエリを書くときに困る。現象が起きている最中に日本語入力プログラムのメニューを開くと、ロード中のようなUIのまま固まっていることが確認できる。日本語ソースではなく"ABC"表記の英字ソースだと発生しないので、必要ならそちらに切り替えるのがワークアラウンド。

他には、Shiftでローマ字モードに切り替える機能もあるのだが、機能を有効にしても動かない。USキーボードだとうまく動かないという仮説があるが、JISキーボードを持っていないので未検証。Ctrl+JCtrl+;でとくに困らないというのはある。

*1:入力モードとしてあらかじめ「ひらがな」と「英字」だけを使うようにしておくと、「英字」への切り替えはCtrl+Shift+;が使える。設定を変えて「半角カタカナ」なども使えるようにしてしまうと、そちらにCtrl+Shift+;が奪われてしまい、「英字」はCtrl+Shift+'に変わる。ホームポジションに近いほうがいいので「ひらがな」と「英字」だけ入力モードとして有効にしておくのがベター

Gemfile.lockをもとに特定のバージョンのRubyで利用できないgemの一覧を出す

Railsアプリなど、Bundlerでgemの依存管理をしているプロジェクトでRubyのアップデートをしようとするとき、不幸なことにgemが全体的に古いことがある。

また、gemspecでrequired_ruby_versionというメタデータでRubyのバージョンの範囲を設定していると、その範囲のRubyの環境下でしかそのgemはインストールできない。よくある例として、次のようにRuby 2系でしか使えないようにしているものがある。

Gem::Specification.new do |spec|
  spec.required_ruby_version = "~> 2.0"
end

このような状況下でRubyだけアップデートするとgemをインストールできないことがある。

こうなると、まずgemをアップデートしていく必要がある。この作業の範囲がどれぐらいか把握するために、Gemfile.lockに載っているgemのうち、特定のバージョンのRubyだとrequired_ruby_versionを満たせないgemの一覧を出せるようなスクリプトを書いた。

# unusable_gems.rb

require 'bundler'

def resolve_gemspec_path(spec)
  # nokogiriはarchとosをgemspecのファイル名に持つ場合がある
  if spec.name == 'nokogiri'
    native_gem_path = Bundler.specs_path.join("#{spec.name}-#{spec.version}-#{Gem::Platform.local.cpu}-#{Gem::Platform.local.os}.gemspec")
    return native_gem_path if native_gem_path.exist?
  end

  case spec.source
  when Bundler::Source::Rubygems
    # default gem
    default_gem_path = Pathname.new(Gem.default_specifications_dir).join("#{spec.name}-#{spec.version}.gemspec")
    return default_gem_path if default_gem_path.exist?

    # ふつうのgem
    Bundler.specs_path.join("#{spec.name}-#{spec.version}.gemspec")
  when Bundler::Source::Git
    # git sourceからインストールしたgem
    Bundler.bundle_path.join("#{spec.source.install_path}", "#{spec.name}.gemspec")
  else
    warn "failed to resolve: #{spec.inspect}"
    nil
  end
end

gemspec_paths = Bundler::LockfileParser.new(Bundler.default_lockfile.read).specs.map { |spec|
  path = resolve_gemspec_path(spec)

  unless path&.exist?
    warn "gemspec not found: #{path}"
    next nil
  end

  path
}.compact

gemspecs = gemspec_paths.map { |path| Gem::Specification.load(path.to_s) }

checked_ruby_version = Gem::Version.create(ARGV[0])
puts gemspecs.reject { |gemspec| gemspec.required_ruby_version.satisfied_by?(checked_ruby_version) }.map { |gemspec| "#{gemspec.name} (#{gemspec.version})" }

例として、まず次のGemfileでbundle installしておく。

source "https://rubygems.org"

gem "activerecord", "7.0.4.2" # required_ruby_version = ">= 2.7.0"

Gemfile.lockが存在するディレクトリで次のように実行する。かなり古いバージョン2.6.0、2.7.0を例として使う。

$ ruby unusable_gems.rb 2.6.0
activemodel (7.0.4.2)
activerecord (7.0.4.2)
activesupport (7.0.4.2)
nokogiri (1.14.1)
oauth (1.1.0)
oauth-tty (1.0.5)

$ ruby unusable_gems.rb 2.7.0
# 2.7.0だと使えないgemがないので結果は空

たとえば間接依存のoauth 1.1.0も https://github.com/oauth-xx/oauth-ruby/blob/v1.1.0/oauth.gemspec#L36 を見るとわかるように2.7.0以上の制限がある。

注意点としては、今回必要だったユースケースではgemのソースの種類としてパス (Bundler::Source::Path) などはなかったので入れていない。また、nokogiriのような特殊なパターンもまだあるかもしれない。