Skip to content

リリースフローの自動化(release ブランチ作成・タグ付け・マージバックの無人化) #57

Closed

Description

@LibraryLibrarian

概要

pub.dev への公開そのものは #29 で自動化済みだが、その前後の Git 操作は手作業のまま残っている。1リリースあたり PR 2本とタグ push を含む8手順の手動介入が必要で、リリースの実行コストが下がりきっていない。

これを 「Prepare Release ワークフローの実行」と「release PR の承認」の2操作まで圧縮する。

3リポジトリは構成を意図的に揃えているため、同一方針で対応する: LibraryLibrarian/mastodon_client#38 / #57 / LibraryLibrarian/misskey_mfm_parser#25

現状

1リリースあたりの手作業

現行 publish.yml が引き受けているのは 6 のみで、残りはすべて手作業になっている。

# 操作 現状
1 develop から release/x.y.z を作成 手動
2 dart run tool/bump_version.dart x.y.z 手動
3 commit / push 手動
4 release/x.y.z → main の PR 作成・マージ 手動
5 main に vx.y.z タグを作成して push 手動
6 verify → pub.dev 公開 → GitHub Release 作成 自動
7 release/x.y.z → develop の PR 作成・マージ 手動
8 release ブランチの削除 手動

ブランチ運用の問題

現在は release ブランチを main と develop の両方へ別々の PR でマージしている(1.0.0-beta.7 では #52 → main、#51 → develop)。この結果 main は develop の祖先にならず、履歴が恒久的に分岐している。

$ git merge-base --is-ancestor origin/main origin/develop; echo $?
1
$ git rev-list --left-right --count origin/main...origin/develop
6	10

マージバックを自動化するには「何を develop に入れれば良いか」が機械的に定まる必要があるが、この形では release ブランチを記憶しておく必要があり、レビュー対象の PR も2本のまま残る。

現行 publish.yml の検証範囲

verify ジョブは tool/verify_release.dart により、タグと pubspec.yaml のバージョン一致・Semantic Versioning 形式・README 6言語および docs 6言語のバージョン参照・CHANGELOG の見出しと日付を検証している。検証内容としては十分だが、タグがどのブランチ上に付いているかは見ていないため、develop や release/* に誤ってタグを付けた場合そのまま公開まで進む。

対応方針

git-flow 本来の形である release → main → タグ → main を develop へマージバック に運用を寄せた上で、Git 操作を Actions 側へ移す。

[手動] Prepare Release ワークフローを version 入力で実行
  ↓ 自動: release/x.y.z 作成 + bump_version + main への PR 作成
[手動] release PR をレビューしてマージ
  ↓ 自動: vx.y.z タグを作成して push
  ↓ 自動: verify → pub.dev 公開 → GitHub Release 作成
  ↓ 自動: main を develop へマージバック
  ↓ 自動: head ブランチ削除

前提: GitHub App トークンの用意

GITHUB_TOKEN が起こした push / PR 作成は、原則として他のワークフローを起動しない(Trigger a workflow)。この制限により、素朴に組むと以下が成立しない。

  • Actions がタグを push しても publish.yml が起動せず、公開されない
  • Actions が作成した PR で ci.yml が走らず、required status check が永久に未完了になる

そのため、Actions 側からの Git 操作には GitHub App の短命トークンを使う。PAT でも代替可能だが、個人アカウントに紐づく・権限が粗い・期限管理が必要という欠点があるため App を採用する。

項目 設定内容
App の権限 Contents: Read and write / Pull requests: Read and write
インストール先 本リポジトリ
vars.RELEASE_APP_ID App ID
secrets.RELEASE_APP_PRIVATE_KEY App の秘密鍵(PEM 全文)

Ruleset で main / develop を保護している場合は、この App を bypass actor に追加する。

Phase 1: Prepare Release ワークフローの追加

.github/workflows/release.yml を新規作成する。develop から release ブランチを起こし、バージョンを更新し、main への PR を作るところまでを引き受ける。

name: Prepare Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'リリースするバージョン(例: 1.2.0 / 1.2.0-beta.1)'
        required: true
        type: string

permissions:
  contents: read

jobs:
  prepare:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - uses: actions/checkout@v7
        with:
          ref: develop
          token: ${{ steps.app-token.outputs.token }}

      - uses: dart-lang/setup-dart@v1

      - name: Bump version
        env:
          TZ: Asia/Tokyo
          VERSION: ${{ inputs.version }}
        run: dart run tool/bump_version.dart "$VERSION"

      - name: Create release branch
        env:
          VERSION: ${{ inputs.version }}
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git switch -c "release/$VERSION"
          git commit -am "chore(release): $VERSION"
          git push -u origin "release/$VERSION"

      - name: Open pull request
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          VERSION: ${{ inputs.version }}
        run: |
          gh pr create \
            --base main \
            --head "release/$VERSION" \
            --title "release: $VERSION" \
            --body "\`tool/bump_version.dart\` による自動生成。マージすると \`v$VERSION\` タグが作成され、pub.dev への公開と GitHub Release 作成まで自動実行される。"

TZ: Asia/Tokyo を指定しているのは、bumpVersion() が DateTime.now() で CHANGELOG の日付を決めるため。runner は UTC 既定であり、日本時間の午前中に実行すると前日の日付が入る。

Phase 2: 自動タグ付けの追加

main に取り込まれたバージョンに対して、タグを自動で作成する。

2-1. tool/src/release_project.dart にバージョン取得の公開 API を追加

_readProject() は既にあるので、それを露出するだけで足りる。

/// Returns the package version declared in `pubspec.yaml` under [root].
String readPubspecVersion(Directory root) => _readProject(root).version;

2-2. tool/print_version.dart を新規作成

import 'dart:io';

import 'src/release_project.dart';

void main() {
  try {
    stdout.writeln(readPubspecVersion(Directory.current));
  } on ReleaseToolException catch (error) {
    stderr.writeln('Failed to read version: ${error.message}');
    exitCode = 1;
  } on FileSystemException catch (error) {
    stderr.writeln('Failed to read version: ${error.message}');
    exitCode = 1;
  }
}

pubspec.yaml を sed で読むのではなくツール経由にするのは、_readSingleValue() が「トップレベルに version: がちょうど1つ」を検証しており、コメントや引用符が付いた場合に静かに誤読しないため。

2-3. .github/workflows/tag.yml を新規作成

name: Tag Release

on:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: tag-release
  cancel-in-progress: false

jobs:
  tag:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}

      - uses: dart-lang/setup-dart@v1

      - name: Install dependencies
        run: dart pub get

      - name: Create tag if missing
        run: |
          version="$(dart run tool/print_version.dart)"
          if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
            echo "Tag v$version already exists. Nothing to do."
            exit 0
          fi
          git tag "v$version"
          git push origin "v$version"

「既にタグがあれば何もしない」というガードが要点で、これがあるため main への通常のマージ(ドキュメント修正など、バージョンを上げない変更)では何も起きない。事前に dart pub get を実行しているのは、dart run の暗黙の依存解決メッセージが標準出力へ混ざるのを避けるため。

Phase 3: publish.yml の拡張

3-1. verify ジョブにタグ位置の検証を追加

タグが main 上のコミットに付いていることを確認する。develop や release/* へ誤ってタグを付けた場合の事故を防ぐ。

  verify:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - uses: dart-lang/setup-dart@v1

      - name: Verify tag commit is on main
        run: |
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
          git merge-base --is-ancestor "$GITHUB_SHA" origin/main

      - name: Verify release version
        run: dart run tool/verify_release.dart "${GITHUB_REF_NAME#v}"

fetch-depth: 0 が必須。既定の浅い checkout のままでは git merge-base が失敗する。

3-2. マージバックのジョブを追加

release ジョブの後段に追加する。

  merge-back:
    needs: release
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - uses: actions/checkout@v7
        with:
          ref: develop
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}

      - name: Merge main back into develop
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main

          if git merge-base --is-ancestor origin/main HEAD; then
            echo "develop already contains main. Nothing to do."
            exit 0
          fi

          if git merge --no-ff -m "chore: merge back $GITHUB_REF_NAME into develop" origin/main \
            && git push origin develop; then
            echo "Merged main into develop."
            exit 0
          fi

          # 競合、またはブランチ保護により直接 push できない場合のフォールバック
          git merge --abort || true
          branch="chore/merge-back-$GITHUB_REF_NAME"
          git switch -c "$branch" origin/main
          git push -u --force-with-lease origin "$branch"
          gh pr create \
            --base develop \
            --head "$branch" \
            --title "chore: merge back $GITHUB_REF_NAME into develop" \
            --body "自動マージバックに失敗したため、手動での競合解決が必要。" \
            || echo "既存の PR があるため作成をスキップした。"
          exit 1

フォールバックの最後で exit 1 しているのは意図的で、人の対応が必要な状態をジョブの失敗として可視化するため。競合したまま黙って終了させない。

Phase 4: リポジトリ設定と初回移行

4-1. リポジトリ設定

  • Settings → General → Pull Requests → Automatically delete head branches を有効化(現在は無効)。これで release ブランチの後始末が不要になる
  • Ruleset で main / develop を保護している場合、Phase 1〜3 で使う GitHub App を bypass actor に追加する
  • pub.dev の管理画面は現行設定(Tag pattern v{{version}} / Environment pub.dev / OIDC)のまま変更不要

4-2. ブランチ運用の切り替え

今回から release ブランチは main にのみマージし、develop へは main からのマージバックで反映する。従来の「release ブランチを main と develop の両方へ別々の PR でマージする」運用は行わない。

あわせて、初回だけ手動で祖先関係を作っておく必要がある。現在 main は develop の祖先ではないため、この状態のまま Phase 3 のマージバックを初回実行すると、蓄積された差分をまとめて解決することになり競合しやすい。事前に以下を通常の PR として済ませておく。

git switch develop
git merge --no-ff origin/main
# 競合があればここで解決する

以降は毎リリースで main が develop に取り込まれるため、差分が溜まらず自動マージバックが安定する。

4-3. ワークフローの配置

  • release.yml は default branch である develop に入った時点で Actions タブの workflow_dispatch に表示される
  • tag.yml は main に存在しないと発火しないため、通常の PR で main まで到達させる必要がある

受け入れ条件

  • GitHub App を作成・インストールし、vars.RELEASE_APP_ID と secrets.RELEASE_APP_PRIVATE_KEY を設定した
  • .github/workflows/release.yml を追加した
  • tool/src/release_project.dart に readPubspecVersion() を追加した
  • tool/print_version.dart を追加した
  • .github/workflows/tag.yml を追加した
  • publish.yml の verify ジョブでタグが main 上にあることを検証している
  • publish.yml に merge-back ジョブを追加した
  • Automatically delete head branches を有効化した
  • main が develop の祖先になっている
  • プレリリース版で 1 サイクル通しの動作確認が取れた

検証手順

プレリリース版を1つ使い、実際に通しで確認する。

  1. Actions タブから Prepare Release を実行する
  2. release/x.y.z ブランチと main 宛の PR が作成され、その PR で CI が走ることを確認する(App トークンが正しく効いていれば走る)
  3. PR をマージする
  4. Tag Release が発火し、vx.y.z タグが作成されることを確認する
  5. Publish to pub.dev が発火し、verify → publish → release → merge-back まで通ることを確認する
  6. pub.dev に公開されていること、GitHub Release が作成されていること、develop に main が取り込まれていること、release ブランチが削除されていることを確認する

失敗時のロールバックについては、タグ作成までは git push --delete origin vx.y.z で巻き戻せるが、pub.dev への公開以降は取り消せない点に注意する。

補足

  • 本対応を入れると main へのマージがそのまま公開の確定操作になる。承認のゲートは release PR の1点に集約されるため、release PR のレビューを省略しない運用が前提となる
  • より慎重にしたい場合は Phase 2(自動タグ付け)のみ見送り、タグは手動で作成する構成も選べる。その場合でも Phase 1 と Phase 3 だけで手作業は大幅に減る
  • pub.dev Environment に required reviewer を設定すれば公開前の承認待ちを挟めるが、release PR の承認と二重になるため今回は採用しない。必要になった時点で設定のみで追加できる
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions