GitHub Actions で Issue の内容に応じてファイル変更・プッシュ・プルリク作成までを自動化

issue を作成したら、その内容に応じてファイルを変更し、そのままプッシュとプルリクの作成、あとはマージするだけ、というところまで GitHub Actions で自動化するメモです。

概要

GitHub Actions ドキュメント も是非参照してください。また記事内容にはパブリックプレビューの機能を含みます。

今回、下記のフローを想定して実装していきます。

  1. 修正したい json ファイルの ID (ファイル名)を指定して固定フォーマットの issue を作成
  2. issue 作成をトリガーに対象 json ファイルを編集して自動プッシュ・自動プルリク作成
  3. 対象のプルリクを確認して問題なければプルリクマージ
  4. マージによって対象ブランチ削除、対象 issue クローズ

最終的なファイル構成は下記のとおりです。

.
├── .github
│   ├── ISSUE_TEMPLATE
│   │   └── update_data.yml
│   └── workflows
│       └── auto-create-pr.yml
└── json
    └── 001.json (更新対象ファイル)

001.json の内容は下記を想定しています。

{
  "properties": {
    "id": "001",
    "name": "元の名前"
  },
}

issue を固定フォーマットにする

GitHub のリポジトリページから issue を登録する際に、ISSUE_TEMPLATE を作ることで登録フォームに独自の項目を作って issue を固定のフォーマットにすることができます。

現在この機能はパブリックプレビューということなので、この後に書いた issue の内容からブランチ名を設定する方法は無効になるかもしれません

.github/ISSUE_TEMPLATE というディレクトリを作成し、その下に定義ファイルを作成します。

ファイル名はなんでもいいですが、今回は update_data.yml とします。

name: "JSON ファイル更新"
description: "指定した ID の JSON ファイルを更新"
title: "[update] JSON ID: "
labels: ["data update"]
body:
  - type: input
    id: json_id
    attributes:
      label: "JSON_ID"
      description: "修正するJSONのIDを入力してください"
      placeholder: "例: 001"
    validations:
      required: true

  - type: input
    id: new_name
    attributes:
      label: "NAME"
      description: "修正後のNAMEを入力してください"
    validations:
      required: true
      
  - type: textarea
    id: reason
    attributes:
      label: "REASON"
      description: "変更理由"
    validations:
      required: false

こちらを作成した後、上部メニューの Issues から New issue ボタンをクリックすると、下記の様に設定したフォーマットで issue を作ることができます。

Issue テンプレートによる登録フォーム

workflow の作成

ワークフローの定義は .github/workflows ディレクトリ配下に yaml ファイルで設定します。

今回は auto-create-pr.yml として作成します。

まずは全体を載せて詳しくは後述します。

注意点として、下記は issue の内容を検査していないので、コマンドインジェクションなどの脆弱性があるかもしれません。

実際には厳密な検査やバリデが必要です。

参考:

name: Auto Create Pull Request

on:
  issues:
    types: [opened]
    
jobs:
  create-pr:
    runs-on: ubuntu-latest
    if: contains(github.event.issue.title, '[update]')
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        
      - name: Extract JSON ID
        id: extract_id
        run: |
          ISSUE_BODY="${{ github.event.issue.body }}"

          # issue の内容を取得
          JSON_ID=$(echo "$ISSUE_BODY" |  awk '/### JSON_ID/ { getline; getline; print $0; }')
          NAME=$(echo "$ISSUE_BODY" |  awk '/### NAME/ { getline; getline; print $0; }')
          
          # TIMESTAMP を取得
          TIMESTAMP=$(date +%Y%m%d%H%M%S)

          # 環境変数として登録
          echo "JSON_ID=$JSON_ID" >> $GITHUB_ENV
          echo "NAME=$NAME" >> $GITHUB_ENV
          echo "BRANCH_NAME=update/$JSON_ID/$TIMESTAMP" >> $GITHUB_ENV

      - name: Modify json
        run: |
          jq --arg id "$JSON_ID" \
             --arg name "$NAME" \
             '
             if .properties.id == $id then
                .properties.name = $name
             else .
             end
             ' "json/$JSON_ID.json" > temp.json && mv temp.json "json/$JSON_ID.json"

      - name: Commit & Push Changes
        run: |
           git config --global user.name "github-actions[bot]"
           git config --global user.email "github-actions@users.noreply.github.com"
           git checkout -b "${{ env.BRANCH_NAME }}"
           git add "json/$JSON_ID.json"
           git commit -m "Update json data for $JSON_ID"
           git push origin "${{ env.BRANCH_NAME }}"

      - name: Create Pull Request using GitHub CLI
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr create --base main --head "${{ env.BRANCH_NAME }}" \
            --title "Update json data for ${{ env.JSON_ID }}" \
            --body "Closes #${{ github.event.issue.number }}"

issue の内容を参照してブランチ名を取得する

まずは、issue 作成のトリガーにより、issue の内容を取得してブランチ名を決定します。

name: Auto Create Pull Request

on:
  issues:
    types: [opened]
    
jobs:
  create-pr:
    runs-on: ubuntu-latest
    if: contains(github.event.issue.title, '[update]')
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        
      - name: Extract JSON ID
        id: extract_id
        run: |
          ISSUE_BODY="${{ github.event.issue.body }}"

          # issue の内容を取得
          JSON_ID=$(echo "$ISSUE_BODY" |  awk '/### JSON_ID/ { getline; getline; print $0; }')
          NAME=$(echo "$ISSUE_BODY" |  awk '/### NAME/ { getline; getline; print $0; }')
          
          # TIMESTAMP を取得
          TIMESTAMP=$(date +%Y%m%d%H%M%S)

          # 環境変数として登録
          echo "JSON_ID=$JSON_ID" >> $GITHUB_ENV
          echo "NAME=$NAME" >> $GITHUB_ENV
          echo "BRANCH_NAME=update/$JSON_ID/$TIMESTAMP" >> $GITHUB_ENV

ワークフローのトリガー にある通り、on.issues.types[opened] を指定することで issue の作成をトリガーすることができます。

if: contains(github.event.issue.title, '[update]') によって、issue のタイトルに [update] が含まれていれば後続の処理を実行します。これは.github/ISSUE_TEMPLATE/update_data.ymltitle にあたる部分です。

「# issue の内容を取得」のところで、awk コマンドを使って見出し ### JSON_ID次の次の行にあるデータを取得しています。固定フォーマットの場合、issue の中身は

### JSON_ID

001

### NAME

新しい名前

の様に記述されるためです。

その後、後続のステップで使えるよう、環境変数に登録しています。

JSON ファイルを更新する

jq コマンドを使って JSON ファイルを更新します。

      - name: Modify json
        run: |
          jq --arg id "$JSON_ID" \
             --arg name "$NAME" \
             '
             if .properties.id == $id then
                .properties.name = $name
             else .
             end
             ' "json/$JSON_ID.json" > temp.json && mv temp.json "json/$JSON_ID.json"

取得済みの JSON_ID 変数を使って該当ファイルを探し、ファイル内の id とも一致した場合に properties.nameNAME 変数で更新しています。

GitHub Actions と関係ないので詳しい説明は省きます。

コミットとプルリク作成

コミットは git コマンドで、プルリクは gh コマンドで実行しています。

      - name: Commit & Push Changes
        run: |
           git config --global user.name "github-actions[bot]"
           git config --global user.email "github-actions@users.noreply.github.com"
           git checkout -b "${{ env.BRANCH_NAME }}"
           git add "json/$JSON_ID.json"
           git commit -m "Update json data for $JSON_ID"
           git push origin "${{ env.BRANCH_NAME }}"

      - name: Create Pull Request using GitHub CLI
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr create --base main --head "${{ env.BRANCH_NAME }}" \
            --title "Update json data for ${{ env.JSON_ID }}" \
            --body "Closes #${{ github.event.issue.number }}"

git コマンドでは特に特別なことはしておらず、環境変数をもとにプッシュまでを実行しています。

git のユーザー名を github-actions[bot] としていますが、これは GitHub Actions が行うコミットを識別しやすくするための慣習だと思います。必要に応じて他の名前に変更することも可能です。

gh コマンドを使用するには GH_TOKEN という環境変数に必要なスコープを持つトークンを設定する必要があります。今回は自動的に提供される secrets.GITHUB_TOKEN を使用しています。

--body に "Closes #${{ github.event.issue.number }}" を設定していますが、こうすることでプルリクがマージされたときに、対象の issue も併せて閉じることができます

補足 - プルリクのワークフローを分ける

今回はプッシュ・プルリク作成を一つの workflow にまとめていますが、push トリガーを使ってプルリクを別ワークフローに分けることもできます。

※トリガーではなく直接後続のワークフローを指定するには workflow_dispatchrepository_dispatch を使います

ただ、プルリク作成側の workflow ではそのまま issue 番号を取得できないため、プルリクマージ後に issue を削除したい場合、プッシュするブランチ名やコミットメッセージに issue 番号を含めるなどの調整が必要になるかと思います。

また、トリガーによる別ワークフローの実行は無限ループを防止するために、デフォルトの権限ではトリガーが機能しません。

例えば、先行 workflow のプッシュによって後続の push トリガーを機能させるには、先行 workflow で GITHUB_TOKEN の代わりに、GitHub App インストール アクセス トークンまたは personal access token を使う必要があります。

PAT (Fine-grained personal access token) を使う場合、GitHub のページで作成し後続 workflow の実行に最低限必要な Contents, Workflows の Read and write 権限を付与したものを用意し、下記の様に with.token を設定することで後続の workflow のトリガーが機能します。

※「Fine-grained personal access token」は現在パブリックプレビューです ※「personal access tokens (classic)」を使う場合、repo, workflow の権限を付与します

jobs:
  update-json:
    runs-on: ubuntu-latest
    if: contains(github.event.issue.title, '[update]')
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        with:
           token: ${{ secrets.PAT_TOKEN }}