🍁

GitHub Actionsでいい感じのリリースノートを完全自動で作成する

2022/10/10に公開約17,100字1件のコメント

きっかけ

スプリントで実装した内容をリリースする際、リリースノートを毎回作成しています。
GitHub のリリースノート自動生成機能も便利なのですが、それでも「毎回ボタンをクリックする一手間が面倒だな。自動化したいな〜」と思っていました。
そこで、結構前に勉強も兼ねてリリースノート自動作成のアクションを自作したところ、チーム内で好評だったのでご紹介したいと思います。
(色々あってすっかり記事にするのが遅れてしまいました・・)

要件

  • main ブランチにマージされたら自動でタグとリリースノートが生成されること
  • リリースノートには前回リリースとの差分が表示されること
  • 同日に複数回リリースしても識別できること
  • リリースノートのテンプレートを指定できること

完成形はこちら

いきなりですが、生成されるリリースノートはこんな感じです。

完成形のアクションはこちらになります。

完成版
name: Create release tag and release note.

on:
  push:
    branches: [ main ]

jobs:
  create-release-tag:
    runs-on: ubuntu-latest

    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      TZ: 'Asia/Tokyo'

    steps:
      - uses: actions/checkout@v2

      # 前回のりリースタグを取得する
      - name: Get previous tag
        id: pre_tag
        run: |
          echo "::set-output name=pre_tag::$(curl -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)"

      # タグを生成する 「{YYYY.MM.DD}-{当日リリース回数}」
      - name: Generate release tag
        id: release_tag
        run: |
          today=$(date +'%Y.%m.%d')
          pre_release_date=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $1}')
          pre_release_count=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $2}')
          if [[ ! $pre_release_date = $today ]]; then
            echo "init count"
            pre_release_count=0
          fi
          echo "::set-output name=release_tag::$today-$(($pre_release_count + 1))"

      # 前回リリースからの差分をもとに、リリースノートの本文を生成する
      - name: Generate release note
        id: release_note
        run: |
          echo "::set-output name=release_note::$(curl -X POST -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/generate-notes -d '{"tag_name":"${{ steps.release_tag.outputs.release_tag }}", "previous_tag_name":"${{ steps.pre_tag.outputs.pre_tag }}"}' | jq .body | sed 's/"//g')"

      # タグを切り、リリースノートを作成する
      - name: Create Release
        run: |
          curl -X POST \
            -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            -d "{ \"tag_name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"body\": \"${{ steps.release_note.outputs.release_note }}\"}" \
            https://api.github.com/repos/${{ github.repository }}/releases

内容について順に確認していきましょう。

バージョニングについて

タグ・リリースノートの自動化にあたり、本アクションではカレンダーバージョニング(CalVer)を採用しています。
https://calver.org/

具体的には {YYYY.MM.DD}-{当日リリース回数} という形式です。

自動生成するタグについて当初はSemVerで管理しようと思っていたのですが、それだと「どのリリースでどのバージョンを上げるか」を人間が判断しなければなりません。

有名なrelease-drafterを使うのであればコミットを工夫することで自動化できるのですが、でも、どうせ自作するなら完全自動化したいなと思いました。

少し悩んだものの、私が関わっているサービスでは SemVer でなければならない理由が特になかったため、チーム内で相談して CalVer で運用することにしました。

リリースタグを生成する

まずはタグを生成します。

今回は {YYYY.MM.DD}-{当日リリース回数} という形で運用するので、前回のリリースと今回のリリースが同日かどうか判断しなければなりません。
(同日だったら当日リリース回数をインクリメントする)

前回リリース分のタグを見ればリリース日が分かるので、前回(=最新)のリリースが取得できれば良いですよね。

そこで利用するのが、GitHub の Get the latest release API です。

GET /repos/{owner}/{repo}/releases/latest

https://docs.github.com/en/rest/releases/releases#get-the-latest-release

公式のサンプルとなりますが、以下のようにリクエストすると、

リクエストサンプル
curl \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  https://api.github.com/repos/OWNER/REPO/releases/latest

このようなレスポンスが返ってきます。(簡便の為、一部省略)
tag_name も含まれていますね。

レスポンスサンプル
{
  "url": "https://api.github.com/repos/octocat/Hello-World/releases/1",
  "html_url": "https://github.com/octocat/Hello-World/releases/v1.0.0",
  "assets_url": "https://api.github.com/repos/octocat/Hello-World/releases/1/assets",
  "upload_url": "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}",
  "tarball_url": "https://api.github.com/repos/octocat/Hello-World/tarball/v1.0.0",
  "zipball_url": "https://api.github.com/repos/octocat/Hello-World/zipball/v1.0.0",
  "discussion_url": "https://github.com/octocat/Hello-World/discussions/90",
  "id": 1,
  "node_id": "MDc6UmVsZWFzZTE=",
  "tag_name": "v1.0.0",  // <-- これがタグ名
  "target_commitish": "master",
  "name": "v1.0.0",
  "body": "Description of the release",
  "draft": false,
  "prerelease": false,
  "created_at": "2013-02-27T19:35:32Z",
  "published_at": "2013-02-27T19:35:32Z",
  "author": {
    "login": "octocat",
    "id": 1,
    ...(省略)...
  },
  "assets": [
    {
      "url": "https://api.github.com/repos/octocat/Hello-World/releases/assets/1",
      "browser_download_url": "https://github.com/octocat/Hello-World/releases/download/v1.0.0/example.zip",
      "id": 1,
      ...(省略)...
    }
  ]
}

この API で前回のタグを取得して今回のタグ名を生成しているのが、以下の部分になります。

# 前回のりリースタグを取得する
- name: Get previous tag
  id: pre_tag
  run: |
    echo "::set-output name=pre_tag::$(curl -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)"

# タグを生成する 「{YYYY.MM.DD}-{当日リリース回数}」
- name: Generate release tag
  id: release_tag
  run: |
    today=$(date +'%Y.%m.%d')
    pre_release_date=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $1}')
    pre_release_count=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $2}')
    if [[ ! $pre_release_date = $today ]]; then
      echo "init count"
      pre_release_count=0
    fi
    echo "::set-output name=release_tag::$today-$(($pre_release_count + 1))"

前回と同日のリリースだった場合は、タグの{当日リリース回数}の部分をインクリメントしています。
以下の$(($pre_release_count + 1))の部分です。

echo "::set-output name=release_tag::$today-$(($pre_release_count + 1))"

当日初回の場合はpre_release_count=0としているので、タグの{当日リリース回数}の部分は 1 となります。

    if [[ ! $pre_release_date = $today ]]; then
      echo "init count"
      pre_release_count=0
    fi

前回からの差分をもとにリリースノートの本文を生成する

次にリリースノートを生成したいです。

リリースノートの生成と一口で言っても、実は 2 段階に分けられます。

  1. リリースノートの本文を生成する
  2. リリースノートを生成する

ということで、まずはリリースノート本文の生成からいきましょう。

ここでは、GitHub の Generate release notes content for a release API を利用します。

POST /repos/{owner}/{repo}/releases/generate-notes

https://docs.github.com/en/rest/releases/releases#generate-release-notes-content-for-a-release

公式のサンプルを確認しましょう。
以下のようにリクエストすると、

リクエストサンプル
curl \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  https://api.github.com/repos/OWNER/REPO/releases/generate-notes \
  -d '{"tag_name":"v1.0.0","target_commitish":"main","previous_tag_name":"v0.9.2","configuration_file_path":".github/custom_release_config.yml"}'

このように、自動生成されたリリースノートの本文が body に入って返ってきます。

レスポンスサンプル
{
  "name": "Release v1.0.0 is now available!",
  "body": "##Changes in Release v1.0.0 ... ##Contributors @monalisa"
}

この API の素敵なところは、今回のタグと前回のタグを指定することで差分を取得してくれるというところです!

tag_name string Required
The tag name for the release. This can be an existing tag or a new one.

previous_tag_name string
The name of the previous tag to use as the starting point for the release notes. Use to manually specify the range for the set of changes considered as part this release.

今回のタグと前回のタグは先ほど取得したので、そちらを使いましょう。

# 前回リリースからの差分をもとに、リリースノートの本文を生成する
- name: Generate release note
  id: release_note
  run: |
    echo "::set-output name=release_note::$(curl -X POST -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/generate-notes -d '{"tag_name":"${{ steps.release_tag.outputs.release_tag }}", "previous_tag_name":"${{ steps.pre_tag.outputs.pre_tag }}"}' | jq .body | sed 's/"//g')"

リリースタグとリリースノートを作成する

最後はリリースノートの生成ですね。

リリースノートの生成には、GitHub の Create a release API を使います。

POST /repos/{owner}/{repo}/releases

https://docs.github.com/en/rest/releases/releases#create-a-release

公式のサンプルは以下の通りです。
リクエストでは、タグ名やリリースノートの本文を指定します。

リクエストサンプル
curl \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <YOUR-TOKEN>" \
  https://api.github.com/repos/OWNER/REPO/releases \
  -d '{"tag_name":"v1.0.0","target_commitish":"master","name":"v1.0.0","body":"Description of the release","draft":false,"prerelease":false,"generate_release_notes":false}'

レスポンスは先ほど Get the latest release API で取得したものと同様なので省略します。

それでは、これまでに作成したタグ名と本文を指定しましょう。

# タグを切り、リリースノートを作成する
- name: Create Release
  run: |
    curl -X POST \
      -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
      -d "{ \"tag_name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"body\": \"${{ steps.release_note.outputs.release_note }}\"}" \
      https://api.github.com/repos/${{ github.repository }}/releases

これでデフォルトのリリースノートは生成できるようになりました。
折角なので、最後にもう少し見やすくなるように工夫していきましょう。

テンプレートを用意してリリースノートを見やすくする

.github/release.yml ファイルを用意することで、リリースノートにテンプレートを設定できます。

https://docs.github.com/ja/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuring-automatically-generated-release-notes

今回は機能追加と不具合修正を分けて表示したいので、以下のようにしてみました。
マージされた PR のラベルに応じて振り分けます。

.github/release.yml
changelog:
  categories:
    - title: New Features 🎉
      labels:
        - "enhancement"
    - title: Bug Fix 💊
      labels:
        - "bug"
    - title: Other Changes 🛠
      labels:
        - "*"

excludeauthors を設定することで、リリースノートから除外したいユーザーを指定できます。
GitHub Actions で生成した PR がある場合はノイズになるので除外してしまいましょう。

.github/release.yml
changelog:
  exclude:
    # リリースノートから除外したいユーザー
    authors:
      - github-actions # <-- 追加
  categories:
    - title: New Features 🎉
      labels:
        - "enhancement"
    - title: Bug Fix 💊
      labels:
        - "bug"
    - title: Other Changes 🛠
      labels:
        - "*"

私の場合はリリース用 PR を自動生成するアクションを設定しているので、github-actions を除外します。

https://zenn.dev/kshida/articles/auto-generate-release-pr-with-github-actions

PR にラベルを自動付与できるようにする

これでテンプレートは用意できましたが、今のままでは PR にラベルを手動で付与しなければなりません。
ついでにここも自動化しましょう。

Gitflow に則っている場合、PR の種別はブランチ名で判断できます。
https://www.atlassian.com/ja/git/tutorials/comparing-workflows/gitflow-workflow

今回は、ブランチ名が feature/~ の場合はenhancementラベル、fix/~ or hotfix/~ の場合はbugラベルを付与するようにしましょう。

ラベル自動付与アクションの完成形はこちらです。

完成形
name: Automatically labeling pull request.

on:
  pull_request:
    types: [opened]

jobs:
  auto-labeling-pr:
    runs-on: ubuntu-latest

    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    steps:
      - uses: actions/checkout@v2

      # ラベル名を取得する
      - name: Get label name
        id: label_name
        run: |
          branch_type=$(echo ${{github.head_ref}} | cut -d "/" -f1)
          if [ $branch_type == 'feature' ]; then
            label_name=$(echo "enhancement")
          elif [ $branch_type == 'fix' ] || [ $branch_type == 'hotfix' ]; then
            label_name=$(echo "bug")
          else
            label_name=""
          fi
          echo "::set-output name=label_name::$label_name"

      # PRにラベルを付与する
      - name: Auto labeling
        if: ${{ steps.label_name.outputs.label_name }}
        run: |
          number=$(echo $GITHUB_REF | sed -e 's/[^0-9]//g')
          gh pr edit $number --add-label ${{ steps.label_name.outputs.label_name }}

簡単に解説します。

以下箇所では、ラベル名を決定するためにブランチ名を取得しています。

# ラベル名を取得する
- name: Get label name
  id: label_name
  run: |
    branch_type=$(echo ${{github.head_ref}} | cut -d "/" -f1)
    if [ $branch_type == 'feature' ]; then
      label_name=$(echo "enhancement")
    elif [ $branch_type == 'fix' ] || [ $branch_type == 'hotfix' ]; then
      label_name=$(echo "bug")
    else
      label_name=""
    fi
    echo "::set-output name=label_name::$label_name"

マージする側のブランチ名は ${{github.head_ref}} で取得できます。
ラベル付与の条件を変更したい場合は、こちらの if 文に条件を追加することで対応可能です。

そして、ラベル付与部分ですね。

# PRにラベルを付与する
- name: Auto labeling
  if: ${{ steps.label_name.outputs.label_name }}
  run: |
    number=$(echo $GITHUB_REF | sed -e 's/[^0-9]//g')
    gh pr edit $number --add-label ${{ steps.label_name.outputs.label_name }}

ラベルの付与には GitHub CLI を使います。

gh pr edit [<number> | <url> | <branch>] [flags]

https://cli.github.com/manual/gh_pr_edit

GitHub CLI でラベルを付与するには PR の番号が必要なので、以下で番号を取り出しています。

number=$(echo $GITHUB_REF | sed -e 's/[^0-9]//g')

それでは実際に試してみましょう。

まずは feature の場合。

そして fix の場合。

先ほど指定した条件に従ってラベルが付与されています。
これでラベルを自動で付与できるようになりましたね。

完成したリリースノート

実際に生成されたリリースノートを再掲します。
タグもリリースノートも{YYYY.MM.DD}-{当日リリース回数}の形式で生成されていますね。

当日 2 回目以降の場合は、以下のように{当日リリース回数}の部分がインクリメントされます。

これでリリースノートの生成が自動化されました。

このアクションは今でもスプリントリリースのたびに活躍してくれています!

おわりに

やはり実際に自分でアクションを作成してみると理解が深まりますね!
色々確認しながら進めるのはとても楽しかったです。

今年も残り僅かですが、小さな改善を積み重ねていけるように精進していこうと思います。

今回作ったアクションを再掲して終了です。

リリースノート自動化アクション完成形
name: Create release tag and release note.

on:
  push:
    branches: [ main ]

jobs:
  create-release-tag:
    runs-on: ubuntu-latest

    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      TZ: 'Asia/Tokyo'

    steps:
      - uses: actions/checkout@v2

      # 前回のりリースタグを取得する
      - name: Get previous tag
        id: pre_tag
        run: |
          echo "::set-output name=pre_tag::$(curl -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)"

      # タグを生成する 「{YYYY.MM.DD}-{当日リリース回数}」
      - name: Generate release tag
        id: release_tag
        run: |
          today=$(date +'%Y.%m.%d')
          pre_release_date=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $1}')
          pre_release_count=$(echo ${{ steps.pre_tag.outputs.pre_tag }} | awk -F'-' '{print $2}')
          if [[ ! $pre_release_date = $today ]]; then
            echo "init count"
            pre_release_count=0
          fi
          echo "::set-output name=release_tag::$today-$(($pre_release_count + 1))"

      # 前回リリースからの差分をもとに、リリースノートの本文を生成する
      - name: Generate release note
        id: release_note
        run: |
          echo "::set-output name=release_note::$(curl -X POST -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/generate-notes -d '{"tag_name":"${{ steps.release_tag.outputs.release_tag }}", "previous_tag_name":"${{ steps.pre_tag.outputs.pre_tag }}"}' | jq .body | sed 's/"//g')"

      # タグを切り、リリースノートを作成する
      - name: Create Release
        run: |
          curl -X POST \
            -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            -d "{ \"tag_name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"name\": \"${{ steps.release_tag.outputs.release_tag }}\", \"body\": \"${{ steps.release_note.outputs.release_note }}\"}" \
            https://api.github.com/repos/${{ github.repository }}/releases
.github/release.yml
changelog:
  exclude:
    # リリースノートから除外したいユーザー
    authors:
      - github-actions
  categories:
    - title: New Features 🎉
      labels:
        - "enhancement"
    - title: Bug Fix 💊
      labels:
        - "bug"
    - title: Other Changes 🛠
      labels:
        - "*"
ラベル自動付与アクション完成形
name: Automatically labeling pull request.

on:
  pull_request:
    types: [opened]

jobs:
  auto-labeling-pr:
    runs-on: ubuntu-latest

    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    steps:
      - uses: actions/checkout@v2

      # ラベル名を取得する
      - name: Get label name
        id: label_name
        run: |
          branch_type=$(echo ${{github.head_ref}} | cut -d "/" -f1)
          if [ $branch_type == 'feature' ]; then
            label_name=$(echo "enhancement")
          elif [ $branch_type == 'fix' ] || [ $branch_type == 'hotfix' ]; then
            label_name=$(echo "bug")
          else
            label_name=""
          fi
          echo "::set-output name=label_name::$label_name"

      # PRにラベルを付与する
      - name: Auto labeling
        if: ${{ steps.label_name.outputs.label_name }}
        run: |
          number=$(echo $GITHUB_REF | sed -e 's/[^0-9]//g')
          gh pr edit $number --add-label ${{ steps.label_name.outputs.label_name }}

参考にさせていただいた記事

素晴らしい記事をありがとうございました!
https://made.livesense.co.jp/entry/2021/12/08/083000
https://qiita.com/takech9203/items/b96eff5773ce9d9cc9b3
https://qiita.com/iery/items/43b72813c394050b8bbc
https://zenn.dev/shimat/articles/40823df7551e1b

Discussion

記事参考になりました、ありがとうございます。
2点ほど指摘があります。

    echo "::set-output name=release_note::$(curl -X POST -H 'Accept: application/vnd.github.v3+json' -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' https://api.github.com/repos/${{ github.repository }}/releases/generate-notes -d '{"tag_name":"${{ steps.release_tag.outputs.release_tag }}", "previous_tag_name":"${{ steps.pre_tag.outputs.pre_tag }}"}' | jq .body | sed 's/"//g')"

とありますが、sed 's/"//g' これをしてしまうと body の中に " が含まれている場合、jq コマンドがすでに"\" にエスケープするので、余分な \ が残ってしまうかと思います。
sed を削除した場合は、後続のAPI呼び出しの部分の修正も必要かと思います。

ログインするとコメントできます