スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

【本編】スクリプト言語と GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.3 -自動化ワークフロー実装編-

最終更新日: 2025年03月11日(火)

1. ご挨拶

こんにちは。
前回の「【本編】スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.2 -スクリプト実装編-」の続編を投稿します、@hayat01sh1da です。

blog.studysapuri.jp

ソフトウェアエンジニアとして、以下のサービスの進路・学事向け機能(e.g. アンケート配信機能、進路希望調査配信機能、面談ダッシュボード)を開発・運用・保守しています(for SCHOOL ブランドは先生の管理画面である「先生アプリ」の開発に従事)。

前回の記事の中で、古の時代から現在まで多くのチームやメンバーが相乗りで Snippets 置き場として活用している GitHub Wiki を、Ownership 不在かつ要・不要が不明な玉石混交な状態から Ownership 単位で Wiki が整理された状態にするスクリプトを実装したお話をしました。
しかし、その段階では残課題があり、その中で私が強調したのは「スクリプトを組んだだけでは完全な自動化ではない、その実行を含めてシステムに任せてこそ属人性が排除出来る」ということでした。

さて、今回は前回の記事で言及した要件内容を踏まえ、どのような仕様で GitHub Actions の Workflows を組んだのかを共有させて頂きます。
スクリプトの追加実装が必要でしたので、併せて説明させて頂きます。
スクリプトは非常にシンプルな実装ですので、Ruby/Python歴の浅い方でも理解出来る内容かと思います。
Workflows は YAML 記法と、GitHub Actions の知識が少々必要となりますが、出来るだけ噛み砕いて説明します。

【余談】 RubyPython の2つのスクリプト言語で同じアルゴリズムを実装しているのには、一応理由があります。
単純に「普段 Python を書く機会が乏しいので錆びつかせたくない」という個人的な思惑と「言語の制約でどのような実装差分が出るかや書き味の違いを知り続けたい」というプログラマーとしての思惑があります。
PyCall 使えばええやん!」というツッコミがありそうなので予防線張っておきました(今回はそもそも Python Library の API をコールしていないので不要)。

個人的には、最初の言語ということと業務で使う機会が多いこともあり、Ruby の方が好きです。
あと、Python は完全独学でレビュー受けたことがないので、どなたかツッコミください 🙏

また、GitHub Actions の Workflows のデバッグの良い方法をご存知の方は教えてください 🙇🏻‍♂️

2. 「スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう!」シリーズ

全4部作の構成でお届けしています!

blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp

3. スクリプトの要件と仕様と登場クラス群と GitHub Actions

3-1. 要件

  1. GitHub Wiki の Home と Sidebar を最新化する GitHub Actions の Workflows が平日の 12:00 と 22:00 の2回走り、その実行結果が Slack の指定チャンネルに通知されること。
  2. 月曜日の 09:00 に「Ownerチームが不明だが必要なページ群」「Ownerチーム・要or不要が不明なページ群」「Owner記名なし」のそれぞれの件数が Slack の指定チャンネルに通知されること。

3-2. 仕様

【本編】スクリプト言語と GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.2 -スクリプト実装編- > 3-2. 仕様」からの増分のみ掲載しています。

  1. Ownership なし Wiki 群(以下、マッピング②)を生成します。
    • 使い回す際に再計算を行うことで CPU に負荷をかけないようにメモリ上にキャッシュしておきます
  2. Ownership なし Wiki 群の分類ごとの件数をテキストファイルに書き込みます。
    • 掲載フォーマットは以下の通りです(掲載順は必ず固定)
      • Ownerチームが不明だが必要なページ群: ◯件
      • Ownerチーム・要or不要が不明なページ群: ◯件
      • Owner記名なし: ◯件
    • Owner 名が記名されるとマッピング②のキーに存在しない分類が発生し得ます
    • 上記3分類とマッピング②のキーの差集合を求め、その分類は0件と記載します
  3. 平日の 12:00 と 22:00 に Update Wiki List on Home and Sidebar を実行し、GitHub Wiki の Home と Sidebar を Ownership 単位で Wiki を一覧化し、その実行結果を指定の Slack チャンネルに通知します。
  4. 月曜日の 09:00 に Notify Unowned Wiki List を実行し、Ownership なし Wiki 群の分類ごとの件数を指定の Slack チャンネルに通知します。

3-3. 登場クラス群

github.com

3-3-1. Ruby

クラス名 役割 説明
Application 基底クラス HomeSidebar が共通で扱うデータを保持します。#run は継承先で実装されることが期待されているので、直接呼び出すと NotImplementedError を Raise します。
対応するテストクラスは ApplicationTest です。
UnknownWikiListExporter Ownership なし Wiki 群ごとの件数書込みクラス h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します。
対応するテストクラスは UnknownWikiListExporterTest です。
exec/export_unowned_wiki_list.rb 実行ファイル 実行時に README > 2. Execution > 2-1. Update Wiki List on Home and Sidebar 記載の標準出力を行います。

3-3-2. Python

クラス名 役割 説明
Application 基底クラス HomeSidebar が共通で扱うデータを保持します。#run は継承先で実装されることが期待されているので、直接呼び出すと NotImplementedError を Raise します。
対応するテストクラスは TestApplication です。
UnknownWikiListExporter Ownership なし Wiki 群ごとの件数書込みクラス h2(Markdown 記法の ##)で名前空間を作り、その単位で Wiki を一覧化します。
対応するテストクラスは TestHome です。
exec/export_unowned_wiki_list.py 実行ファイル 実行時に README > 2. Execution > 2-1. Update Wiki List on Home and Sidebar 記載の標準出力を行います。

3-3-3. GitHub Actions

クラス名 役割 説明
.github/workflows/update-wiki-list-on-home-and-sidebar.yml Ownership ごとの GitHub Wiki の Home と Sidebar 上の Wiki 一覧化ジョブ 平日の 12:00 と 22:00 に定期実行される CronJob です。
.github/workflows/notify-unowned-wikis.yml Ownership なし Wiki 群の分類ごとの件数通知ジョブ 毎週月曜日の 09:00 に定期実行される CronJob です。

4. スクリプト実装

ここではそれぞれのクラスの実装を、テストファイルで期待されている仕様から逆算して説明します。
ソースコードハイパーリンクの遷移先をご参照下さい。

説明にあたり、手元に Clone した Wiki は以下のディレクトリ構造を持つものとします。

$ tree
.
├── Home.md
├── Ownerチーム・要or不要が不明なページ.md
├── Owner記名ありページ.md
├── Owner記名なしページ1.md
├── Owner記名なしページ2.md
├── Ownerチームが不明だが必要なページ.md
├── _Sidebar.md
├── export_unowned_wiki_list.sh
├── github-wiki-organisers
│   ├── LICENCE.txt
│   ├── README.md
│   ├── python
│   │   ├── README.md
│   │   ├── exec
│   │   │   ├── export_unowned_wiki_list.py
│   │   │   └── update_wiki_list_on_home_and_sidebar.py
│   │   ├── src
│   │   │   ├── application.py
│   │   │   ├── home.py
│   │   │   ├── sidebar.py
│   │   │   └── unowned_wiki_list_exporter.py
│   │   └── test
│   │       ├── test_application.py
│   │       ├── test_home.py
│   │       ├── test_sidebar.py
│   │       └── test_unowned_wiki_list_exporter.py
│   └── ruby
│       ├── README.md
│       ├── Rakefile
│       ├── exec
│       │   ├── export_unowned_wiki_list.rb
│       │   └── update_wiki_list_on_home_and_sidebar.rb
│       ├── src
│       │   ├── application.rb
│       │   ├── home.rb
│       │   ├── sidebar.rb
│       │   └── unowned_wiki_list_exporter.rb
│       └── test
│           ├── application_test.rb
│           ├── home_test.rb
│           ├── sidebar_test.rb
│           └── unowned_wiki_list_exporter_test.rb
└── update_wiki_list_on_home_and_sidebar.sh

10 directories, 34 files

4-1. Application クラス

Application クラスでは以下の処理を行います。

  1. 全ての Wiki 一覧を Array で取得し、昇順でソートし ./Home.md./Sidebar を除いてキャッシュします。
  2. Wiki の行頭を読み込み Owner 名を読み取り、{ '@test-owner' => ['Owner記名ありページ.md'], 'Ownerチームが不明だが必要なページ群' => ['Ownerチームが不明だが必要なページ.md'], 'Ownerチーム・要or不要が不明なページ群' => ['Ownerチーム・要or不要が不明なページ.md'], 'Owner記名なし' => ['Owner記名なしページ1.md', 'Owner記名なしページ2.md'] }マッピングをキャッシュします(以下、マッピング①)。
    • Wiki の行頭に Owner 記名がない場合は「Owner記名なし」をキー名とします
    • ディレクトリ構造がフラットである都合上、Home と Sidebar での Wiki 一覧化時の要件を満たすため Owner 名で昇順ソートをかけます
    • Ruby では Hash#sortArray[Array<String>] の二重配列が返るため、Array#to_h を実行して Hash に戻しておきます
    • Python では sorted(dict.items())list[tuple<str>] の二重配列が返るため、dict() の引数に取って dict に戻しておきます
  3. マッピング①から Ownership なし Wiki 群(以下、マッピング②)を生成・キャッシュします。

4-2. UnknownWikiListExporter クラス

UnknownWikiListExporter クラスでは以下の処理を行います。
実装の説明の都合上、「Owner: @OWNER_TEAM の記名がされていなかった Wiki に全て Owner チームが記入された場合」を前提とします。

  1. 定数で Ownership なし Wiki 群の3分類を配列で定義します。
    • Ownerチームが不明だが必要なページ群
    • Ownerチーム・要or不要が不明なページ群
    • Owner記名なし
  2. 1 の定数とマッピング②のキーの差集合を求め、その解に対する件数を「0件」をした配列を計算します。
    • 前提に基づくと、「Owner記名なし」はマッピング②に含まれないことになります
    • そのまま出力すると、「Owner記名なし」はテキストファイルに掲載されなくなってしまいます
    • しかし、GitHub Actions の通知で空文字が通知されるのを防ぎたいため思惑がありました
    • そこで、マッピング②に含まれなくても「Owner記名なし: 0件」と掲載されるための実装をしました
  3. マッピング②の分類と対応する Wiki の件数から '分類: ◯件' の形式の文字列の配列を作り、それに 2 で求めた配列を結合します。
    • 元々の配列は ['Ownerチームが不明だが必要なページ群: 1件:', 'Ownerチーム・要or不要が不明なページ群: 1件']
    • 2 で求めた配列は ['Owner記名なし: 0件']
    • 結合した配列は ['Ownerチームが不明だが必要なページ群: 1件:', 'Ownerチーム・要or不要が不明なページ群: 1件', 'Owner記名なし: 0件']
    • 掲載順は必ず固定するため、マッピング②に含まれる件数にマッピング②に含まれない件数を足す処理をしています
  4. 文字列を ./unowned_wiki_count_list_by_namespace.txt に書き込みます。
    • Python では配列の書き込みが出来ないため、要素を結合して str に変換した上で書き込みを行います

4-3. 実行ファイル

実行ファイルでは以下の処理を行います。

  1. UnknownWikiListExporter クラス(Ruby/Python) をインポートします。
  2. 処理開始の旨を標準出力で通知します。
  3. 分類ごとの件数を標準出力で通知します。
  4. ./unowned_wiki_count_list_by_namespace.txt に分類ごとの件数を書き込みます。
  5. 実行結果を標準出力で通知します。
  6. 処理完了の旨を標準出力で通知します。

5. GitHub Actions 実装

5-1. Update Wiki List on Home and Sidebar

Update Wiki List on Home and Sidebar では以下の処理を行います。

  1. 本体リポジトリをチェックアウトしソースコードを取得します。
  2. Wiki リポジトリをチェックアウトし Wiki 群を取得します。
  3. Git 操作を行う上でのユーザー情報(ユーザー名・メールアドレス)を設定します。
    • ユーザー名は GitHub Actions による実行であることが分かるような任意のユーザー名を設定します
    • メールアドレスは Dependabot などの Bot ユーザーに対して付与する有効なメールアドレス 41898282+github-actions[bot]@users.noreply.github.com を設定します(Ref. GitHub Actions bot email address?#26560)
  4. Wiki リポジトリディレクトリに移動します。
  5. 実行コマンドとその引数をバックトレース情報として出力しつつ(-x)、エラーが発生したら即時処理を終了する(-e)シェルの設定を行います。
  6. Wiki リポジトリの最新状態を取り込みます。
  7. ./update_wiki_list_on_home_and_sidebar.sh を叩いて ruby/update_wiki_list_on_home_and_sidebar.rb の実行ファイルをコールし、./Home.md./_Sidebar.md を更新します。
  8. 差分をコミット&プッシュします
    • || を挟んで true を評価することでエラーを回避しています
      • set コマンドの -e オプションを外せば良いのでは?と思い試しましたが、エラー時はコケました
    • コミットメッセージは識別可能にするため YYYY-mm-dd HH:MM:SS 形式の年月日時分秒の現在時刻をタイムスタンプとして末尾に付与します
      • デフォルトでは UTC タイムになっているので、タイムゾーンを日本時間にセットします
      • このタイムスタンプは環境変数で参照出来るように保持します
  9. git log --oneline -1 で取得した直前のコミットメッセージに識別子である 8 取得したタイムスタンプが含まれていれば GitHub Actions による更新ありとみなし、この場合のみ Slack 通知処理に進みます。
    • --oneline はコミットの詳細情報のうち、コミットハッシュとコミットメッセージのみを1行表示するためのオプションです
    • -N は直前の N 個のコミットを表示するためにオプションです
    • on:schedule の Cron Workflows は on: push とは異なりプッシュイベントに Hook して発火していないので、github.event.head_commit.message を参照しても空文字が返るためこの方法を採用しています
  10. Payload に指定した JSON リクエストボディのメッセージを指定の Slack チャンネルに通知します。

5-2. Notify Unowned Wiki List

Notify Unowned Wiki List では以下の処理を行います。

  1. Wiki リポジトリをチェックアウトし Wiki 群を取得します。
  2. Git 操作を行う上でのユーザー情報(ユーザー名・メールアドレス)を設定します。
  3. Wiki リポジトリディレクトリに移動します。
  4. 実行コマンドとその引数をバックトレース情報として出力しつつ(-x)、エラーが発生したら即時処理を終了する(-e)シェルの設定を行います。
  5. Wiki リポジトリの最新状態を取り込みます。
  6. ./exec/export_unowned_wiki_list.sh を叩いて ./unowned_wiki_count_list_by_namespace.txt のテキストファイルを読み込み、Ownership なし Wiki 群ごとの件数を Slack 通知で使う Payload の JSON レスポンスボディで展開出来るように環境変数に保持します。
    • bash における配列の扱いがとても難しかったです
  7. Payload に指定した JSON リクエストボディのメッセージを指定の Slack チャンネルに通知します。

6. 実行結果・差分

6-1. Ownership なし Wiki 群の分類ごとの件数エクスポートスクリプト

このテキストファイルの情報を、月曜日の 09:00 に Notify Unowned Wiki List を実行し、Ownership なし Wiki 群の分類ごとの件数を指定の Slack チャンネルに通知します。

実行結果

$ bash exec/export_unowned_wiki_list.sh 
==================== Exporting Unowned Wiki List... ====================
Here is the result:

Ownerチームが不明だが必要なページ群: 1件
Ownerチーム・要or不要が不明なページ群: 1件
Owner記名なし: 2件

Check it out result on '../../unowned_wiki_count_list_by_namespace.txt' !!
==================== Done Exporting Unowned Wiki List 🎉 ====================

出力テキストファイル

Ownerチームが不明だが必要なページ群: 1件
Ownerチーム・要or不要が不明なページ群: 1件
Owner記名なし: 0件

6-2. Ownership ごとの GitHub Wiki の Home と Sidebar 上の Wiki 一覧化 GitHub Actions

6-3. Ownership なし Wiki 群の分類ごとの件数通知 GitHub Actions

7. 「スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう!」本編の最後に

古の時代から現在まで多くのチームやメンバーが相乗りで Snippets 置き場として活用している GitHub Wiki を、Ownership 不在かつ要・不要が不明な玉石混交な状態から Ownership 単位で Wiki が整理された状態を、以下の手順を踏んで実現しました。

  1. ヒアリングと手作業による泥臭い属人的作業
  2. 要件と仕様を基に手元での作業を自動化するためのスクリプト実装
  3. GitHub Actions による属人性を排した自動化 Workflows 実装

振り返ると、まあ順当な手順を無理なく踏んだと思います。
これでソースコードと同等程度の保守性は担保できたのではないかと思います。
あとは運用を続けていく中で、実行頻度や通知メッセージを最適化していけば良いでしょう。

ソースコードもドキュメントも、中長期的な目線で品質を高いレベルで担保し続けるために重要なのは Ownership の明確化属人性の排除に尽きると考えます。
前者は、誰が何に責任を持つかを明らかにすることで良い意味で緊張感を持って品質担保に腐心する力学を働かせるため必要です。
後者は、一生懸命泥臭い仕事をしてくれるメンバーやスマートに仕組み化をしてくれるメンバーがいるうちは良いですが、そういったメンバーが抜けても運用が滞りなく回り続ける組織であり続けるために必要です。

私自身、上記のことを意識して技術力研鑽と私自身の働き方の改善に勤しみ続けたいと思います。

8. 次回予告

次回は「【番外編】スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう! Vol.4 -しくじり編-」をお届けします!
GitHub Wiki には本体リポジトリにおけるファイルの History に相当するページの Revisions という編集履歴の概念が存在します。
今回の自動化における作業の過程でそれをほぼ丸ごと吹っ飛ばすという大失敗をしました。
それを解決し自動化を何とか完遂出来ました。
どのように Revisions を吹っ飛ばしたのか、それをどのように復旧し最新の履歴も反映し前方互換性を保った状態まで持っていったかを共有します(いわば、「しくじり先生」回です)。
次回が「スクリプト言語GitHub Actions で GitHub Wiki に秩序をもたらそう!」シリーズの最終回です。
それではまた、お楽しみに 👋🏻

blog.studysapuri.jp

9. バックナンバー

blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp blog.studysapuri.jp