🤖

GitHub ActionsとPower Automateで実現するZenn執筆フローの効率化

に公開

はじめに

技術ブログの品質管理と効率的な運用は、多くのエンジニア組織が抱える課題です。
この課題を解決するためにエクサウィザースではZennのPublication Proを利用した効率化を行っています。

本記事では、GitHub Actions、Power Automate、LLM(Gemini)を組み合わせて構築した、Zennブログの自動化レビューシステムについて解説します。このシステムは実際にエクサウィザーズで運用されており、以下の機能を実現しています:

  • 🤖 LLM による自動レビュー: 機密情報漏洩チェックと誤字脱字の検出
  • 🎲 レビュワーの自動アサイン: チームメンバーからランダムに選出
  • 📢 Teams 通知システム: レビュー依頼と進捗を Teams で一元管理
  • 🌿 段階的公開フロー: ブランチ戦略による安全な記事公開
  • 📄 PRテンプレート: 段階に応じた適切なガイダンス

システム全体像

本システムの大まかなワークフローは以下のようになっています:

より細かいフローが見たい方はこちら※英語です

ブランチ戦略による段階的公開

まずは、ZennのPublication Proを利用した段階的な記事公開フローについて説明します。
Zennの特性を活かしつつ、安全な記事公開を実現するために、弊社ではブランチ名プレフィックスによる処理の自動分岐を採用しています。

ブランチ戦略の設計思想

Publication Proは指定ブランチ(弊社ではmain)の最新状態が即座に反映される仕組みのため、従来のgit-flow等の運用ではあまりフィットしませんでした。そこで、ブランチ名のプレフィックスで自動化処理を仕分けする手法を採用しました。

ブランチプレフィックス 用途 自動実行される処理
draft/ 記事の初回執筆 LLMレビュー + レビュワーアサイン + 広報Teams通知
rewrite/ 記事の修正 LLMレビューのみ
deploy/ 記事の公開 自動処理なし(反映のみ)

この戦略により、執筆フェーズに応じた適切な自動化処理が実行され、不必要に自動アサインやTeams通知が行われることを防ぎます。

GitHub Actions の詳細実装

次に、GitHub Actionsを用いた自動化フローの詳細について解説します。

ワークフローの概要

各ワークフローは以下のような役割を持っています:

  1. ブログ記事レビューワークフロー: 記事の下書き作成時にLLM自動レビューや静的チェックを実行
  2. ランダムレビュワーアサインワークフロー: レビュワーをランダムに選出し、Teamsで通知
  3. ドラフトデプロイワークフロー: 記事のドラフト公開と広報チームへの通知

1. ブログ記事レビューワークフロー

下書き作成時に実行される最も重要なワークフローです。

トリガー条件の設定

name: Review Blog Posts

on:
  pull_request:
    types: [opened, synchronize, reopened]
    paths:
      - '**/articles/**'   # 任意のフォルダのarticles 以下に変更があったときだけ

jobs:
  review-blog-posts:
    if: startsWith(github.event.pull_request.head.ref, 'draft') || startsWith(github.event.pull_request.head.ref, 'rewrite')
    runs-on: ubuntu-latest

重要なポイントは、記事ディレクトリ(**/articles/**)の変更のみを対象とし、draft/ または rewrite/ プレフィックスのブランチでのみ実行されるような条件を設定している点です。
ジョブに応じてこのプレフィックス条件で分岐することで、不要な実行を防いでいます。

変更記事の特定

Git diff を使用して変更された記事ファイルを特定し、後続のステップで使用できるよう出力変数に設定します。

- name: Get changed articles
  id: get-changed-articles
  run: |
    echo "Finding changed article ..."
    if [ "${{ github.event_name }}" = "pull_request" ]; then
      git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1
      CHANGED=$(git diff --name-only --line-prefix=`git rev-parse --show-toplevel`/ origin/${{ github.event.pull_request.base.ref }} HEAD | grep '/articles/.*\.md' | sort | uniq | xargs)
    else
      CHANGED=$(git diff --name-only --line-prefix=`git rev-parse --show-toplevel`/ HEAD~1 HEAD | grep '/articles/.*\.md' | sort | uniq | xargs)
    fi
    echo "changed_articles=$CHANGED" >> $GITHUB_OUTPUT
    echo "Changed articles: $CHANGED"

Zenn Slug バリデーション

実運用中に記事のSlugが不正な形式で設定されることが多かったため、Zenn の記事 URL となる slug の形式を自動チェックする機構を追加しました。
極力執筆者の負担を減らすため、Zenn CLIなどでのローカルチェックは義務化せず、GitHub Actions上での自動チェックに留めています。

- name: Validate Zenn slug format
  if: steps.get-changed-articles.outputs.changed_articles != ''
  run: |
    echo "Validating Zenn slug format..."
    validation_failed=false
    
    for article in ${{ steps.get-changed-articles.outputs.changed_articles }}; do
      filename=$(basename "$article")
      slug="${filename%.md}"
      
      echo "Checking slug: $slug (from file: $filename)"
      
      # slugの長さをチェック(12〜50字)
      slug_length=${#slug}
      if [ $slug_length -lt 12 ] || [ $slug_length -gt 50 ]; then
        echo "❌ エラー: Slug '$slug' の文字数が無効です(${slug_length}文字)。12〜50文字である必要があります。"
        validation_failed=true
      fi
      
      # slugの文字種をチェック(半角英小文字、数字、ハイフン、アンダースコアのみ)
      if ! echo "$slug" | grep -qE '^[a-z0-9_-]+$'; then
        echo "❌ エラー: Slug '$slug' に無効な文字が含まれています。半角英小文字(a-z)、数字(0-9)、ハイフン(-)、アンダースコア(_)のみ使用可能です。"
        validation_failed=true
      fi
      
      if [ "$validation_failed" = false ]; then
        echo "✅ Slug '$slug' は有効です。"
      fi
    done
    
    if [ "$validation_failed" = true ]; then
      echo "::error title=Zenn Slug検証エラー::Zennのslug形式が要件を満たしていません。"
      exit 1
    fi

画像サイズチェック

こちらも運用中に記事内の画像サイズが3MB制約を超えることが多かったり、不必要に画像が大きかったりしたのでエラーや警告を自動で出すようにしました。

また、誤ってPushされた大きな画像などがmainブランチの履歴に残らないよう、PRのマージはSquashマージに限定しています。Squashマージでは、マージ元ブランチの最終状態のみがmainに反映されるため、履歴の汚染を防げます。

※Squashマージは詳細なコミットが残らないため、同一ファイルの同時編集や孫ブランチの運用がある場合はコンフリクトが起きやすく、解消も複雑になるので注意が必要です。今回のユースケースはそうではないため、Squashマージが適していると判断しました。

- name: Check image sizes for changed articles
  id: check-image-sizes
  working-directory: ./.github/workflows/scripts/reviewer
  run: |
    image_check_output_file="../image_check_output.txt"
    rm -f $image_check_output_file
    image_check_failed=false
    
    for article in ${{ steps.get-changed-articles.outputs.changed_articles }}; do
      echo "### Checking image sizes for article: $article" >> $image_check_output_file
      echo '```' >> $image_check_output_file
      if python check_image_sizes.py --target_article "$article" >> $image_check_output_file 2>&1; then
        echo "✅ Image size check passed for $article" >> $image_check_output_file
      else
        echo "❌ Image size check failed for $article" >> $image_check_output_file
        image_check_failed=true
      fi
      echo '```' >> $image_check_output_file
      echo "" >> $image_check_output_file
    done

LLM による自動レビュー

人手でのレビューを最小限化するために LLM を活用した自動レビューを実行しています。
詳細は別記事のTechBlogの自動レビューのためのLLM選定も参照してください。

- name: Authenticate to Google Cloud via Workload Identity Federation
  id: auth
  uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.WIF_PROVIDER }} # e.g., "projects/1234567890/locations/global/workloadIdentityPools/github-pool/providers/github"
    service_account: ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }} # e.g., "my-svc-account@my-project.iam.gserviceaccount.com"

- name: Run blog post review for changed articles
  id: run-review
  working-directory: ./.github/workflows/scripts/reviewer
  env:
    GOOGLE_CLOUD_PROJECT_ID: ${{ secrets.GOOGLE_CLOUD_PROJECT_ID }}
  run: |
    output_file="../review_output.txt"
    rm -f $output_file
    if [ -z "${{ steps.get-changed-articles.outputs.changed_articles }}" ]; then
      echo "No changed articles found. Skipping review." >> $output_file
    else
      for article in ${{ steps.get-changed-articles.outputs.changed_articles }}; do
        echo "### Reviewing article: $article" >> $output_file
        echo '```' >> $output_file
        python review_blogpost_gemini.py --target_article "$article" >> $output_file 2>&1
        echo '```' >> $output_file
      done
    fi
    echo "review_result<<EOF" >> $GITHUB_OUTPUT
    cat $output_file >> $GITHUB_OUTPUT
    echo "EOF" >> $GITHUB_OUTPUT

2. ランダムレビュワーアサインワークフロー

GitHub上で作成したレビュワー候補をまとめたGitHub Teamからレビュワーをランダムにアサインし、Power Automateを用いてTeams上でも通知を行います。
公平なレビュー負荷分散を実現するために導入しています。

トリガー条件の設定

name: Assign Random Reviewer

on:
  pull_request:
    branches: [main]
    # PR 作成(opened) と Draft→Ready for review(ready_for_review) のタイミングで起動
    types:
      - opened
      - ready_for_review

permissions:
  pull-requests: write
  contents: read

jobs:
  # head ブランチ名が "draft" で始まり、かつ PRのdraft フラグが false のときだけ実行
  assign-random-reviewers:
    if: >-
      startsWith(github.head_ref, 'draft') &&
      github.event.pull_request.draft == false
    runs-on: ubuntu-latest

重要なポイントは、if 条件で「ドラフト状態でない PR」のみに絞ることで、PRがドラフト状態のときはレビュワーアサインを行わないようにしている点です。

Draft Pull Request の段階ではレビュワーをアサインしないことで、執筆者は LLM と壁打ちしながら気軽に内容をブラッシュアップできます。
もし Draft の時点でアサインしてしまうと、LLM による自動レビューでも通知が飛び、レビュワーがいつレビューを始めればよいか曖昧になります。そのため、「Ready for review」になったタイミングでのみアサインすることで、レビュー開始の意図を明確にしています。

レビュワー候補の取得

まずGitHubのAPIを使用してGitHub Teamsからランダムにレビュワー候補を2人取得します。

- name: Fetch candidate reviewers
  id: roster
  run: |
    echo "::group::Fetching team members"
    team=$(curl -s -H "Accept: application/vnd.github+json" \
           -H "Authorization: Bearer ${{ secrets.ORG_TOKEN }}" \
           ${{ secrets.REVIEWER_TEAM_API_URL }} \
           | jq -r '.[].login' \
           | grep -v "^${{ github.actor }}$")
    echo "Candidate pool:"
    echo "$team"
    echo "::endgroup::"

    # 配列化 → シャッフル → 2人抽選
    mapfile -t list <<<"$team"
    size=${#list[@]}
    if [ "$size" -le 2 ]; then
      picked=("${list[@]}")
    else
      picked=($(printf '%s\n' "${list[@]}" | shuf -n 2))
    fi

    echo "Picked reviewers: ${picked[*]}"
    echo "candidates=$(IFS=, ; echo "${list[*]}")" >> "$GITHUB_OUTPUT"
    echo "reviewers=$(IFS=, ; echo "${picked[*]}")" >> "$GITHUB_OUTPUT"

GitHub上でのアサイン

レビュワーを取得した後、GitHubのAPIを使用してPRにレビュワーをアサインし、投稿者自身もセルフアサインします。

- name: Assign reviewers & self-assign
  uses: actions/github-script@v7
  with:
    script: |
      const reviewers = '${{ steps.roster.outputs.reviewers }}'
        .split(',').filter(Boolean);

      // 自分自身を assignee に追加
      await github.rest.issues.addAssignees({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.payload.pull_request.number,
        assignees: [context.actor]
      });

      // reviewer をリクエスト
      if (reviewers.length) {
        await github.rest.pulls.requestReviewers({
          owner: context.repo.owner,
          repo: context.repo.repo,
          pull_number: context.payload.pull_request.number,
          reviewers
        });
      }

Teams への通知送信

Teamsへの通知はPower Automateを利用して行います。
通知(メンション)を飛ばすためには、レビュワーや執筆者のメールアドレスを取得する必要があります。幸い、エクサウィザーズのGitHubアカウントはアカウント名から簡単にメールアドレスの形式に変換することが可能だったため、sedなどを使用してメールアドレスを取得しました。
※迷惑メール防止等のため、実際のメールアドレス変換処理は省略しています。

- name: Teams notification
  env:
    TEAMS_ENGINEER_REVIEW_WEBHOOK: ${{ secrets.TEAMS_ENGINEER_REVIEW_WEBHOOK }}
    REVIEWERS: ${{ steps.roster.outputs.reviewers }}
  run: |
    reviewer1_email=<$REVIEWERS(githubアカウント名リスト)からメールアドレスに変更する処理>
    reviewer2_email=<$REVIEWERS(githubアカウント名リスト)からメールアドレスに変更する処理>
    writer_email=<$GITHUB_ACTOR(githubアカウント名)からメールアドレスに変更する処理>
    pr_url="${{ github.event.pull_request.html_url }}"

    payload=$(jq -n \
      --arg url "$pr_url" \
      --arg writer_email "$writer_email" \
      --arg reviewer1_email "$reviewer1_email" \
      --arg reviewer2_email "$reviewer2_email" \
      '{
        type: "message",
        attachments: [
          {
            contentType: "application/vnd.microsoft.card.adaptive",
            content: {
              url: $url,
              writer: $writer_email,
              reviewer1: $reviewer1_email,
              reviewer2: $reviewer2_email
            }
          }
        ]
      }')

    curl --fail -X POST \
        -H "Content-Type: application/json" \
        -d "$payload" \
        "$TEAMS_ENGINEER_REVIEW_WEBHOOK"

3. ドラフトデプロイワークフロー

mainブランチに記事のドラフトが公開されると、広報チームへの通知が行われます。
これはdraft/ ブランチからの PR がマージされた時点で実行されます。

トリガー条件の設定

name: Deploy Draft

on:
  pull_request:
    types:
      - closed  # PRがマージ/クローズされたときの発火条件
    branches:
      - main
    paths:
      - '**/articles/**'   # 任意のフォルダのarticles 以下に変更があったときだけ

jobs:
  notify-article-published:
    if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'draft')
    runs-on: ubuntu-latest

重要なポイントは、PRが正常にマージされたときのみジョブが実行されるよう、closed イベントと merged == true 条件を組み合わせている点です。
また、マージ元ブランチ名が draft で始まる場合にのみ実行されるため、広報チームへの通知は「ドラフト記事が確定したタイミング」の1回のみになり、不要な通知を避けることができます。

記事URLの生成と通知

変更されたMarkdownファイル名からSlugを生成し、ZennのURLを組み立て、Teamsに通知を送信することで、広報チームがすぐに記事確認を行えるようにしています。

- name: PR Department check
  env:
    TEAMS_PR_REVIEW_WEBHOOK: ${{ secrets.TEAMS_PR_REVIEW_WEBHOOK }}
  run: |
    writer_email=<$GITHUB_ACTOR(githubアカウント名)からメールアドレスに変更する処理>
    
    base_sha="${{ github.event.pull_request.base.sha }}"
    head_sha="${{ github.event.pull_request.head.sha }}"
    git fetch origin "$base_sha" "$head_sha" --depth=1
    article_filepath=$(git diff --name-only $base_sha $head_sha | grep -E '.*/articles/.*\.md$' | head -n 1 || true)
    article_slug=$(basename "$article_filepath" .md)
    article_url="https://zenn.dev/exwzd/articles/$article_slug"
    
    payload=$(jq -n \
      --arg url "$article_url" \
      --arg writer_email "$writer_email" \
      '{
        type: "message",
        attachments: [
          {
            contentType: "application/vnd.microsoft.card.adaptive",
            content: {
              url: $url,
              writer: $writer_email
            }
          }
        ]
      }')
    
    curl --fail -X POST \
        -H "Content-Type: application/json" \
        -d "$payload" \
        "$TEAMS_PR_REVIEW_WEBHOOK"

Power Automate による Teams 通知システム

Webhookの詳細

Teams 通知は Power Automate の「Webhook要求を受信するとチャネルに投稿する」テンプレートを使用しています。このテンプレートを利用することで、GitHub ActionsからのWebhookを受け取り、Teamsの指定チャンネルにメッセージを投稿することができます。

Webhookの設定手順

簡単にWebhookの設定手順を説明します。

  1. Teamsでフロー作成

    • Teamsの「ワークフロー」から該当テンプレートを選択
    • 投稿先チャンネルを指定してフローを作成
  2. URLのPayloadをJSONとしてパースする処理を追加

    • Reviewerや Writer、URL等の情報を取得するために、Adaptive Cardの入力をJSONとしてパースする処理を追加
    • 取得した情報を用いてメンションを含むメッセージを送信するように変更
    • ※入力形式は、既に記載のGitHub Actionsから送信されるJSON形式のデータを想定
    • ※Adaptive Cardそのままの形式でTeamsにメッセージを送信するのは視認性の観点から避けました
  3. Webhook URLの取得と設定

    • 作成されたフローから Webhook URL をコピー
    • GitHub の Secrets に登録

これらの手順により、GitHub ActionsからのWebhookを受け取り、Teamsに以下のようなメッセージが投稿されます。

Teams Notification Example

具体的なFlowがどの様になっているか、気になる方は以下をご覧ください。

実際のPower AutomateのFlow

今回使用したエンジニアレビュー依頼フローの例です。

  • こちらがAdaptive Cardの入力をJSONとしてパースする処理です
    Power Automate Flow1

  • 各種メンションを取得した後、メンションやURLを含むメッセージを送信します。
    Power Automate Flow2

  • メッセージの中のcoalesce関数はこの様になっており、正しくメンションが作成出来なかった場合は、メンション取得に使用したメールアドレスをそのまま表示するようにしています。
    Power Automate Flow3

通知タイミングの最適化

2つのタイミングで Teams 通知をするように設定しています。

  1. エンジニアレビュー依頼: PR作成時にレビュワーアサインと同時に通知
  2. ドラフト公開時の広報レビュー依頼: 記事がドラフトとして公開された際に広報チームに通知

これにより、関係者が適切なタイミングで必要な情報を受け取れるようにしています。

複数のPRテンプレートを用いたガイダンス

フェーズに応じて執筆者にPRテンプレートの形式で情報を提示することで、認知負荷を軽減するという策を取りました。

GitHubでは複数のPRテンプレートの使用が可能ですが、GUI上で簡易に選択できるような仕組みがありません。既存のPRのURLにクエリパラメータを直接指定して、?template=template1.md のように指定することで、PRテンプレートを選択することはできますが、執筆者が毎回URLを手動で入力するのは面倒であり、現実的ではありません。

そこで、執筆フェーズに応じたPRテンプレートを選択できるように、デフォルトのPRテンプレートで必要なテンプレートのURLに簡単に移動できるような仕組みを導入しました。

メインの PR テンプレート(.github/PULL_REQUEST_TEMPLATE.md)は以下のようになっており、Preview画面でリンクを選択することにより執筆フェーズに応じたテンプレートに簡単に移動できます。
各テンプレートには確認すべきチェックリストと、次のステップが明確に記載(Teams上のxxチャネルでやりとりなど)されており、執筆者が迷わないように設計されています。

# PR Template Selector

↑の`Preview`をクリックして、以下のPRテンプレートURLを選択してください。
Please click ↑ `Preview` and select below PR template URL.

## 記事の執筆者向けテンプレート / PR Template for Article Writers

記事執筆のフェーズに合わせて選んでください。はじめは`記事の下書`を選択し、下書きを作成してください。

> [!Note]
> ブランチはフェーズに合わせて必ず`draft/`, `rewrite/`, `deploy/`のようにprefixがつけられているか確認してください。

1. [記事の下書 / draft article](?expand=1&template=01.draft-article.md)
2. [記事の修正 / rewrite article](?expand=1&template=02.rewrite-article.md)
3. [記事の公開 / deploy article](?expand=1&template=03.deploy-article.md)

## 機能追加者向けテンプレート / PR Template for Feature Adders

- [機能の追加 / add feature](?expand=1&template=10.add-feature.md)
PRテンプレートの配置例
.github
├── PULL_REQUEST_TEMPLATE
│   ├── 01.draft-article.md
│   ├── 02.rewrite-article.md
│   ├── 03.deploy-article.md
│   └── 10.add-feature.md
└── PULL_REQUEST_TEMPLATE.md

まとめ

本記事では、GitHub Action, Power Automate, LLMを組み合わせた、技術ブログ自動化システムの実装について詳しく解説しました。

このシステムの最大の特徴は、**すべてを機械任せにしない「段階的な自動化」**にあります。
たとえば、slugルールや画像サイズといった定型的なチェックはGitHub Actionsで機械的に処理し、誤字脱字や機密情報の検出といった文脈依存の判断はLLMに委ねています。一方で、「表現のトーン」や「伝わりやすさ」など、創造性や人間の感覚が求められる部分は、あえて人のレビューに残す設計とすることで品質の担保と効率化を両立しています。
また、レビュワーの自動アサインや、Teams通知による進捗管理を取り入れることで、レビュー作業の属人化を防ぎつつ、チーム全体でのレビュー負荷の分散と可視化を行っています。
これらの施策により、シニアなメンバーが些末なレビューや確認作業に時間を奪われることなく、本質的なレビューに集中できる体制を実現しています。

特に重要なポイントをまとめると以下の通りです:

  1. ブランチ戦略による処理分岐: 執筆フェーズに応じた適切な自動化
  2. LLM による品質チェック: 機密情報と基本的な品質の事前確認
  3. Teams 通知による一元管理: 進捗の可視化と見落とし防止
  4. PR テンプレートによるガイダンス: 認知負荷の軽減

このような仕組みにより、技術ブログの運用において以下を実現できます:

  • 高い品質: 多層的なチェック機構による品質担保
  • 効率的な運用: 自動化による作業時間の大幅削減
  • スケーラブルなプロセス: チーム規模に依存しない仕組み
  • 安全な公開フロー: 段階的確認による事故防止

皆様の技術ブログ運用の改善に、この記事が少しでもお役に立てれば幸いです。

エクサウィザーズ Tech Blog

Discussion