Repository navigation
リリースフローの自動化(release ブランチ作成・タグ付け・マージバックの無人化) #57
Description
概要
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}}/ Environmentpub.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つ使い、実際に通しで確認する。
- Actions タブから
Prepare Releaseを実行する release/x.y.zブランチとmain宛の PR が作成され、その PR で CI が走ることを確認する(App トークンが正しく効いていれば走る)- PR をマージする
Tag Releaseが発火し、vx.y.zタグが作成されることを確認するPublish to pub.devが発火し、verify → publish → release → merge-back まで通ることを確認する- 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.devEnvironment に required reviewer を設定すれば公開前の承認待ちを挟めるが、release PR の承認と二重になるため今回は採用しない。必要になった時点で設定のみで追加できる
Activity