GitHub のAutomatically generated release notesを使ってリリースノートの内容を PR に基づいて自動生成するフローを作りました。

今までは、コミットメッセージのルールであるConventional Commitsconventional-github-releaserを使って、コミットからリリースノートを自動生成していました。 他の人の PR でも、squah merge でコミットメッセージを書き換えることで、リリースノートに反映されるようにしていました。

ただ GitHub に仕組みは違うけどほぼ似たことをするAutomatically generated release notesという機能が実装されているので、これをベースに移行しようと思って、そのワークフローを作っていました。

目的

目的としては、次を目標にしていました。

自分は大量のリポジトリを持っているので、段階的に移行できるようにしました。 Convential commitとAutomatically generated release notesは同時に使っても競合はしないので、実際にはConventional Commitsでコミットしながら、リリースするときに Convential commit かAutomatically generated release notesどちらを使うかを選択できるようにしています。 (たまに、PR を経由せずにコミットしちゃうことがあるので、それをリリースするときはConventional Commitsベースに切り替える)

また、今回は CI からの自動リリースへと切り替えるのは、目的にしませんでした。 npm パッケージなどを CI からリリースするにはまだトークン管理が必要になってしまうので、npm <-> GitHub 間の OIDC が実装されてから考えることにしました。

ただし、textlintSecretlintなどの大きな monorepo では、今回作ったワークフローを CI から実行するフローにしています。これは、monorepo だとローカルからリリースするよりも、CI からリリースした方が楽だったりすることが多いためです(単純に時間がかかったり、クリーンな環境でリリースしたいため)。

GitHubのAutomatically generated release notes

GitHubにはAutomatically generated release notesという、Pull Requestから自動的にリリースノートを生成する機能があります。

この機能は、次のような手順で利用できます。

  1. リポジトリに .github/release.yml というラベルをカテゴライズする設定ファイルを追加する
  2. PR にラベルを貼る
  3. GitHub Releaseで”Generate release notes”を実行 or gh release create --generate-notes コマンドを実行する
  4. リリースノートにPRのラベルに基づいたカテゴライズされたリリースノートが生成される

実際に生成されるリリースノートは、次のような感じです。

基本的に事前に準備するのは.github/release.ymlというファイルだけです。

.github/release.ymlの設定

元からgithub-label-setupというラベルをいい感じにセットアップするツールを作って使っていました。

github-label-setup v5.0.0.github/release.ymlの設定ファイルの生成もサポートしました。

そのため、次のコマンドでリポジトリにラベルのプリセットを追加して、そのラベルに対応した.github/release.ymlを生成できます。

npx @azu/github-label-setup --token "${AZU_GITHUB_TOKEN}" --allow-added-labels
npx @azu/github-label-setup --addReleaseYml

あとは、このラベルをPull Reuqestにつけていくだけで、リリースノートの自動生成機能が利用できます。

📝 自分の場合は、このリポジトリのセットアップは次のようなスクリプトで行っています。 ラベルのセットアップ、.github/release.ymlの生成、リポジトリのauto-merge/auto-branch-delete/discussionsの有効化を行っています。

local GITHUB_TOKEN="GITHUB TOKEN" # $(op read "op://Private/GITHUB_TOKEN/token")
npx @azu/github-label-setup --token "${AZU_GITHUB_TOKEN}" --allow-added-labels
echo "✓ Add .github/release.yml"
npx @azu/github-label-setup --addReleaseYml
if git ls-files --others --exclude-standard | grep --color=auto ".github/release.yml" -q
then
  git add .github/release.yml
  git commit -m "CI: add .github/release.yml"
fi
echo "✓ Enable auto-branch-delete/auto-merge/discussions"
gh repo edit --delete-branch-on-merge --enable-auto-merge --enable-discussions

gh repo edit --enable-discussionsはなかったので実装してv2.22.0で追加されています。

ラベルのカテゴライズ

.github/release.ymlの設定ファイルは、次のような感じです。 Type: * のラベルをPRの変更内容に合わせてつけていくことで、リリースノートのカテゴライズができます。

changelog:
  exclude:
    labels:
      - 'Type: Meta'
      - 'Type: Question'
      - 'Type: Release'

  categories:
    - title: Security Fixes
      labels: ['Type: Security']
    - title: Breaking Changes
      labels: ['Type: Breaking Change']
    - title: Features
      labels: ['Type: Feature']
    - title: Bug Fixes
      labels: ['Type: Bug']
    - title: Documentation
      labels: ['Type: Documentation']
    - title: Refactoring
      labels: ['Type: Refactoring']
    - title: Testing
      labels: ['Type: Testing']
    - title: Maintenance
      labels: ['Type: Maintenance']
    - title: CI
      labels: ['Type: CI']
    - title: Dependency Updates
      labels: ['Type: Dependencies', "dependencies"]
    - title: Other Changes
      labels: ['*']

例:

  • 機能追加: Type: Feature
  • バグ修正: Type: Bug
  • ドキュメントの変更: Type: Documentation
  • リファクタリング: Type: Refactoring

リポジトリの種類

Automatically generated release notesはGitHubのリリースノートの機能なのでリポジトリの中身は関係ないです。 しかし、リリースと同時にnpmパッケージも公開するため、扱うパッケージによって微妙にリリースフローが変わってきます。

自分が扱うリポジトリは大きく分けて、2 種類のリポジトリがあります。 ここでは、どちらもnpmパッケージを公開するリポジトリとして扱います。

  • 単体のパッケージを扱うリポジトリ
  • monorepoなパッケージを扱うリポジトリ

どちらも同じ流れで、Automatically generated release notesベースでリリースできるように移行しています。

単体のパッケージを扱うリポジトリ

単体のパッケージを扱うリポジトリは、次のような形でnpmへのpublishと同時にリリースノートを公開できます。

# patchアップデート
$ npm version patch && npm publish && gh release create --generate-notes "$(git describe --tags --abbrev=0)"
# minorアップデート
$ npm version minor && npm publish && gh release create --generate-notes "$(git describe --tags --abbrev=0)"
# majorアップデート
$ npm version major && npm publish && gh release create --generate-notes "$(git describe --tags --abbrev=0)"

npm-versionコマンドを使うと、package.jsonのバージョン更新とGit Tagの作成が同時に行われます。 バージョンを更新したら、npm publishでnpmへの公開を行い、gh release create --generate-notesでGitHubリリースを行います。 gh release createにはタグ名が必要なので、git describe --tags --abbrev=0でタグ名を取得しています。

monorepoなパッケージを扱うリポジトリ

monorepoのパッケージも基本的な流れは同じです。 パッケージの公開にLernaを使ってる例を紹介します。

$ npx lerna version && npx lerna publish from-package && gh release create --generate-notes "$(git describe --tags --abbrev=0)"

lerna versionで対話的にバージョンが指定できます。 バージョンを決めたら、lerna publish from-packageでnpmへの公開を行い、gh release create --generate-notesでGitHubリリースを行います。

CIから公開するmonorepoなパッケージを扱うリポジトリ

先ほどの例はどちらも、ローカルからコマンドを叩いて公開する方法でした。

しかしCIから公開する場合には、どのバージョン(patch, minor, major)にアップデートするかをどう選ぶかが難しい問題になります。 Conventional Commitsの場合は、コミットによって決まりますが、Automatically generated release notesではその仕組みはありません。

そのため、リリース時に選ぶ形 or ラベルによって決めるかという選択肢があります。 今回は、リリース時にバージョンを選ぶ形で実装しています。

次のリポジトリに、CIからAutomatically generated release notesを使ってパッケージの公開とリリースノートを作る実装があります。

リポジトリのREADMEにも書いてあるように、次のようなフローでリリースできるようになっています。

ステップ:

  1. PRを作成するためのcreate-release-pr.ymlワークフローをディスパッチする
    • バージョンを選択できる
    • Create Release Pull Request Image
  2. [CI] PRを作成する
  3. PRをレビューする
    • PRの本文を修正できて、その内容がリリースノートに反映される
  4. PRをマージする
  5. [CI] npmとGitHub Releaseに公開する
    • リリースノートの内容はPRの本文と同じになる
    • CIでPRの本文を取得して、リリースノートに反映する

5のpublishはnpm registryやパッケージのビルド時に失敗することがあります。 その場合は、.github/workflows/release.ymlワークフローをDispatchすることで、5だけを再実行できます。 また、緊急時にはローカルからも次のコマンドで公開できます(これもリリースノートは自動生成されます)。

$ npm run versionup && npm run release && gh release create --generate-notes "$(git describe --tags --abbrev=0)"

実装は、先ほどのステップからもわかるように2つのワークフローになっています。

この実装を使ってる例としては、次のリポジトリがあります。

サンプルリポジトリはlernaを使ったmonorepoなパッケージですが、単独のパッケージでもlerna.json -> package.jsonへの変更とci:releaseのスクリプトの中身を変えるだけで対応できます

リリースする際に一度PRを経由することで、PRを送った人にどのバージョンに含まれるかを明示できます(PRに書かれたリリースノートの内容にmentionが入ってるため通知される)。

面倒な人用に、既存のリポジトリにこのCIからのリリースフローを導入するためのマイグレーションスクリプトを用意しています。

curl -fsSL https://raw.githubusercontent.com/azu/monorepo-github-releases/main/migrate.sh | bash

このマイグレーションスクリプトは、次のような処理をやってくれます。

  • ワークフローの追加
  • npm run ci:* スクリプトの追加(CIからpublishするときのコマンドを定義している)
  • monorepoと単独のパッケージに対応
  • npmとyarnに対応

実際に利用してるローカルからpublishするスクリプト

ここまでで、基本的なAutomatically generated release notesベースのリリースフローを紹介しました。

しかし、実際にはもっと色々なことをローカルでは実装しています。 最後に、実際に使ってるスクリプトを紹介します。

# conventional-github-releaserを使ってリリース
function releaseConventialGitHub(){
    local GITHUB_TOKEN=$(op read "op://Private/GITHUB_TOKEN/Section_xxx/token")
    CONVENTIONAL_GITHUB_RELEASER_TOKEN="${GITHUB_TOKEN}" conventional-github-releaser -p angular
}
# .github/release.ymlがあるかで分岐
function releaseGitHub(){
  # if FORCE_CONVENTIONAL is defined
  if [[ -n "${FORCE_CONVENTIONAL}" ]]; then
    echo "🤖 Use conventional-github-releaser"
    releaseConventialGitHub
    return 0
  fi
  # .github/release.ymlがあるかで分岐
  declare hasGitHubReleaseYaml
  hasGitHubReleaseYaml=$(([[ -f .github/release.yml ]] || [[ -f .github/release.yaml ]]) && echo "true" || echo "false")
  if [[ "${hasGitHubReleaseYaml}" == "true" ]]; then
    echo "🤖 Use gh release"
    gh release create --generate-notes "$(git describe --tags --abbrev=0)" --discussion-category "announcements"
  else
    echo "🤖 Use conventional-github-releaser"
    releaseConventialGitHub
  fi
}
# npm
function _confirm-npm(){
  # .github/release.ymlがあるかで分岐
  declare hasGitHubReleaseYaml
  hasGitHubReleaseYaml=$(([[ -f .github/release.yml ]] || [[ -f .github/release.yaml ]]) && echo "true" || echo "false")
  if [[ "${hasGitHubReleaseYaml}" == "true" ]]; then
    echo "🔎 前回のリリース以降にマージされて、ラベルがついてないPRを検索中…"
    echo "" # 空改行
    searchQuery="no:label merged:>$(gh release view --json createdAt --jq .createdAt)"
    noLabelPRs=$(gh pr list -s merged -S "${searchQuery}")
    if [[ -z "${noLabelPRs}" ]]; then
      echo "🎉 ラベルがついてないPRはありません"
      echo ""
    else
      echo "🚨 ラベルがついてないPRがあります"
      echo "🚨 ラベルをつけてください"
      gh pr list -s merged -S "${searchQuery}" -w
      echo "" # 空改行
      return 1
    fi
  else
    echo "🤖 Use conventional-github-releaser"
  fi
  # Enterを連打してても無視されるようにする
  while true; do
    echo -n "🤔 npm publish to \033[036m$1\033[0m(y/N/c)?"
    read yn
    case $yn in
        [Yy]* ) return 0;;
        [Nn]* ) return 1;;
        # c を入力すると conventional-github-releaser を使う
        [Cc]* ) export FORCE_CONVENTIONAL=1; echo "🤖 Use conventional-github-releaser"; return 0;;
         * ) echo " Please answer Y or N or C";;
    esac
  done
}

# Worflow
# 1. Check
# 2. Tagを貼る
# 3. Publishする
# 3. Release Noteを作成

# npmに公開しないリポジトリ用
alias release-node-patch='_confirm-npm "Public" && pre-version-no-npm && npm version patch && post-version && releaseGitHub'
alias release-node-minor='_confirm-npm "Public" && pre-version-no-npm && npm version minor && post-version && releaseGitHub'
alias release-node-major='_confirm-npm "Public" && pre-version-no-npm && npm version major && post-version && releaseGitHub'
# npmをpublicで公開する
alias npm-patch='_confirm-npm "Public" && pre-version && npm version patch && post-version && npm publish --access=public --otp=$(npm-get-otp) && releaseGitHub'
alias npm-minor='_confirm-npm "Public" && pre-version && npm version minor && post-version && npm publish --access=public --otp=$(npm-get-otp) && releaseGitHub'
alias npm-major='_confirm-npm "Public" && pre-version && npm version major && post-version && npm publish --access=public --otp=$(npm-get-otp) && releaseGitHub'
# npmをprivateで公開する - https://github.com/azu/scoped-modules-checker
alias private-npm-patch='_confirm-npm "Private" && scoped-modules-checker && pre-version && npm version patch && post-version && npm publish --access=restricted --otp=$(npm-get-otp) && releaseGitHub'
alias private-npm-minor='_confirm-npm "Private" && scoped-modules-checker && pre-version && npm version minor && post-version && npm publish --access=restricted --otp=$(npm-get-otp) && releaseGitHub'
alias private-npm-major='_confirm-npm "Private" && scoped-modules-checker && pre-version && npm version major && post-version && npm publish --access=restricted --otp=$(npm-get-otp) && releaseGitHub'
# npmをbetaで公開する
alias npm-beta-release='_confirm-npm "Public" && pre-version && npm version prerelease --preid=beta && post-version && npm publish --tag beta --access=public --otp=$(npm-get-otp) && releaseGitHub'
alias npm-beta-patch='_confirm-npm "Public" && pre-version && npm version prepatch --preid=beta && post-version && npm publish --tag beta --access=public --otp=$(npm-get-otp) && releaseGitHub'
alias npm-beta-minor='_confirm-npm "Public" && pre-version && npm version preminor --preid=beta && post-version && npm publish --tag beta --access=public --otp=$(npm-get-otp) && releaseGitHub'
alias npm-beta-major='_confirm-npm "Public" && pre-version && npm version premajor --preid=beta && post-version && npm publish --tag beta --access=public --otp=$(npm-get-otp) && releaseGitHub'
# monorepo用のコマンド
# require "versionup" && "release" command
alias npm-monorepo-release='_confirm-npm "Public" && opr npm run versionup && npm run release -- --yes --otp=$(npm-get-otp) && gh release create --generate-notes "$(git describe --tags --abbrev=0)" --discussion-category "announcements"'

# publish前に色々チェックする処理 - 自由に追加できる
alias pre-version='git diff --exit-code && npm run --if-present build && git diff --exit-code && npm test && git diff --exit-code'
alias post-version='git push && git push --tags'
alias pre-version-no-npm='git diff --exit-code && npm prune && npm install -q --no-shrinkwrap && npm test && git diff --exit-code'

# 1password経由でTOTPを取得
alias npm-get-otp='op item get --otp XXXX'

めちゃくちゃ色々定義していますが、npm-{patch,minor,major} が単独のリポジトリからパッケージを公開するときのコマンドです。

やっていることは、次のようなことをやっています

  1. Publicに公開するかの確認
  2. ラベルがついてないPRの確認 -> ある場合はラベルがないPRの一覧が開かれる
  3. Automatically generated release notes or Conventional Commitsのどっちでリリースするか選択
  4. git diffがないか、ビルドやテストが通るかのチェック
  5. バージョンアップとリリース
  6. リリースノートの作成(Discussionにも投稿)

monorepo用のnpm-monorepo-releaseやprivate package用のprivate-npm-{patch,minor,major}もやっていることは大体同じです。

細かい処理を自分で書きたかったので書いていますが、大体の人は次のコマンドとかでも問題ないかもしれません。 (これらのツールが出る前から、このコマンドを使っていて、中身を適宜書き換えている)

まとめ

GitHubのAutomatically generated release notesベースのリリースフローについて紹介しました。

最初に書いていたように既存のリポジトリを全て移行する予定はないので、必要になったらその都度 .github/release.ymlの設定で書いていたスクリプトで移行しています(冪等性のあるマイグレーションコマンドを実装して、何も考えずにコマンドを叩くようにしてる)。 実際に使ってるスクリプトで実装していましたがAutomatically generated release notesConventional Commitsは共存できます。 そのため、実際にはリポジトリごとにどちらの方法を使うかを選択しています。

Automatically generated release notesベースはPRを経由しないとリリースノートに入らないという問題があるので、mainブランチにpushしまくっているようなリポジトリだとConventional Commitsの方が適切だったりします。

将来、npmのOIDC連携などがきたらまた変わる気がします。 そのため変更に対応しやすように、基本的にはコマンドを&&で繋いで実装できるようなものを書いて使っています。