git-flow (not GitHub-Flow) はgitにおけるブランチ運用のプラクティスのひとつです。

git-flowによるリリースまでの手順は定型作業のためできるだけ自動化を進めたいところですが、アウェアファイでは半自動でワークフローが運用されていました(います)。元々半自動化状態になっていたのは完全自動化の仕組みを簡単につくる時間をとれていなかったということなのですが、改めて調べてみても、そのまま利用できるGitHub Actionsや流用できるGitHub Actionsの例は見当たりませんでした。

そこであらたにゼロベースでGitHub Actionsをつくってみることにしました、というのが本稿の趣旨となります。

git-flowをGitHub Actionsに任せるまでのステップ

git-flowによるリリースプロセスの自動化において実現したいステップとしては次のようなものがあります。

  • 最新のdevelopブランチから release/x.x.x ブランチを作成する
  • release/x.x.x ブランチからmainブランチにpull-requestを作成する
  • pull-requestのタイトルにリリースバージョンを、概要として今回のリリースに含まれるdevelopブランチにマージ済みのpull-request差分の情報を記載する
  • pull-requestを(GitHub 上で)マージすると、マージコミットへのバージョニング(タグ付け)、GitHub Releaseの作成を行う
  • pull-requestをマージすると、release/x.x.x ブランチからdevelopブランチへのマージ(バックマージ)を行う

git-flowにのっとって進めるだけではあるのですが、GitHub上のリソースを作成したり、後述するブランチ保護との兼ね合いなど、いくつかの考慮事項があります。

なお、リリース作業そのものについてはデプロイ先に依存するため本稿では取り扱いません。mainブランチにタグが付けられたことをトリガーにリリースプロセスが開始される、といった既存のワークフローがあることを想定しています。

ブランチ保護ルールについて

git-flowに限らない話しですが、GitHub Actions上でブランチに対する操作を自動化しようとすると、ブランチ保護ルールとの競合が問題になります。

ブランチ保護ルールを管理する - GitHub Docs
ブランチ保護ルールを作成して、1 つ以上のブランチに特定のワークフローを強制することができます。たとえば、承認レビューを要求したり、保護されたブランチにマージされるすべての pull request について状態チェックを渡したりすることができます。

ブランチ保護ルールを利用してmainブランチやdevelopブランチへの直接pushを禁止していたり、マージできる条件にpull-requestでacceptされているかの条件を付与しているチームは多いのではないかと思います。こうしたブランチ保護ルールがあると、GitHub Actions上でdevelopブランチにpushする、ブランチを自動でマージするといったことが行えない状態になります。

この対応策としてGitHub Appを作成してバイパスリストに加える、ということが2025年1月時点だと唯一できる妥当な選択肢のようでしたので、この方法を検討することをお勧めします。

参考 :

semantic-release と GitHub Protected Branch の共存を実現する

調べるとPersonal access tokens(PAT)を利用する選択肢がでてくるのですが、PATは個人アカウントに紐付いてしまうデメリットがあります。また手元で検証した限り、現在GitHubが推奨しているあたらしいPAT形式であるFine-grained PATでは、ブランチ保護ルールをバイパスできなかったこともありPATの採用は見送りました。

git-flow自動化のためのGitHub Actions

今回作成したGitHub Actionsを紹介します。

※ エラーハンドリングやブランチ・コミットの状況への依存がどこまであるか検証が足りない部分もあるかと思いますので、参考にされる場合はその点ご留意ください。

name: Git-Flow Release Workflow

# Trigger workflow either manually with version input or when a PR is closed
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Specify the release version (e.g., 1.0.0)'
        required: true
  pull_request:
    types: [closed]
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  # Job to create a release PR from develop to main
  create-release-pr:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Fetch all history for proper git operations

      # Ensure we're working with the latest develop branch
      - name: Ensure develop is up to date
        run: |
          git fetch origin
          git checkout develop
          git pull origin develop

      # Get list of PRs merged since the last release
      - name: Get unreleased PRs
        id: get-prs
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Find the last tag, if any exists
          LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
          
          # Build search query based on last release date
          SEARCH_QUERY=""
          if [ -n "$LAST_TAG" ]; then
            SEARCH_QUERY="merged:>$(git log -1 --format=%aI $LAST_TAG)"
          fi
          
          # Generate markdown list of PRs for release notes
          echo "Generating PR list since $LAST_TAG ..."
          gh pr list \
            --base develop \
            --state merged \
            --json title,number,url,author,mergedAt \
            --search "$SEARCH_QUERY" \
          | jq -r '.[] | "- [#\(.number) \(.title)](\(.url)) by @\(.author.login)"' > pr_list.md

      # Create or checkout existing release branch
      - name: Checkout or create release branch
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          VERSION=${{ github.event.inputs.version }}
          BRANCH="release/$VERSION"

          # Check if release branch already exists
          if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
            echo "Branch $BRANCH already exists. Checking out the existing branch."
            git checkout -B "$BRANCH" "origin/$BRANCH"
          else
            echo "Branch $BRANCH does not exist. Creating a new branch from develop."
            git checkout -b "$BRANCH" develop
            
            git config --global user.email "github-actions[bot]@users.noreply.github.com"
            git config --global user.name "github-actions[bot]"

            # Push both the branch and the version update
            git push --set-upstream origin "$BRANCH"
          fi

      # Create new PR or update existing one
      - name: Create or update PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          VERSION=${{ github.event.inputs.version }}
          BRANCH="release/$VERSION"

          # Check for existing PR
          EXISTING_PR=$(gh pr view --base main --head "$BRANCH" --json number --jq '.number' 2>/dev/null || true)

          if [ -n "$EXISTING_PR" ]; then
            echo "PR #$EXISTING_PR already exists. Updating..."
            gh pr edit "$EXISTING_PR" \
              --title "Release v$VERSION" \
              --body "$(cat pr_list.md)"
          else
            echo "No existing PR found. Creating a new one."
            gh pr create \
              --base main \
              --head "$BRANCH" \
              --title "Release v$VERSION" \
              --body "$(cat pr_list.md)"
          fi

  # Post-merge job to create release and sync develop
  post-merge:
    if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
    runs-on: ubuntu-24.04
    steps:
      # Generate GitHub App token for authentication
      - name: Generate Token
        id: generate-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.GITHUB_APP_ID }}
          private-key: ${{ secrets.GITHUB_APP_ID_PRIVATE_KEY }}
       
      # Checkout repository with the generated token
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: true
          token: ${{ steps.generate-token.outputs.token }}
      
      # Create release, tag, and sync develop branch
      - name: Extract version and create release
        env:
          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
        run: |
          # Extract version from PR title
          VERSION=$(echo "${{ github.event.pull_request.title }}" | grep -oP 'v\d+\.\d+\.\d+')
          MERGE_SHA=${{ github.event.pull_request.merge_commit_sha }}

          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git config --global user.name "github-actions[bot]"

          # Create and push tag
          git tag $VERSION $MERGE_SHA
          git push origin $VERSION

          # Create GitHub release
          echo "${{ github.event.pull_request.body }}" > release_notes.md

          gh release create $VERSION \
            --title "Release $VERSION" \
            --generate-notes \
            --notes-file release_notes.md \
            --target $MERGE_SHA
          
          # Sync develop branch with the release
          git checkout develop
          git checkout -b release/${VERSION#v} origin/release/${VERSION#v}
          git switch develop
          git merge --no-ff release/${VERSION#v}
          git push origin develop
          
          # Delete release branch
          git branch -d release/${VERSION#v}
          git push origin --delete release/${VERSION#v}

このGitHub Actionsは2つの機能を提供します。

  1. workflow_dispatchを利用して、開発者が指定したバージョンにもとづいてgit-flowプロセスを開始、pull-requestを作成する機能
  2. mainブランチへのマージをトリガーとし、タグ付けやバックマージなどの航続処理を行う機能

サードパーティのGitHub Actionsを利用していないため少しステップが長いですが、各処理自体は粛々と行っているだけなので、読み解いてみてください。(にしては完成するのに時間を溶かしましたが...)

工夫がある点としては、developブランチにマージされておりmainブランチにはマージされていないpull-requestの履歴をリストアップしてくれる点や、同名のブランチや、pull-requestがすでに作成されている場合は再利用するようにしている点でしょうか。

前述のとおり、あらかじめGitHub Appを作成し適切なパーミッションを与え、GitHub AppのIDとトークンを入手しておく必要があります。GitHub Appの設定の詳細については今回割愛いたします。

このワークフローは開発者が明示的にバージョンを指定し、開始できます。

GitHub Actionsにバージョンを指定してgit-flowリリースプロセスを開始

作成されるpull-requestは次のようになります。

GitHub Actionsにより作成されたpull-request

掲載したスクリーンショットはマージ済みのものですが、pull-requestをマージすると、タグ付けやReleaseの作成が行われます。

GitHub Actionsにより作成されたRelease

今回作成したGitHub Actionsには残課題があるため備忘録として以下に記します。

  • hotfixリリースに対応していないこと(baseとheadブランチを調整すればすぐできそうではあります)
  • pull-requestの概要欄テキスト作成処理のエスケープ処理が不充分で、マージ済みのpull-requestに「`」が利用されていたりすると処理が失敗すること
    • => 内容をファイルに書き出し --notes-file オプションを指定することで対応できました

以上です。ぜひ、感想や改善点があれば @iktakahiro までお気軽にコメントください。