GitHub のリリースタグ作成時に Circle CI でリリース用の Pull Request を自動で作成する

目次

はじめに

こんにちは、 @okarin です。みなさんはどんなフローでプロダクトをリリースしていますか? リリースタグを作成したときに Docker イメージを自動でビルドしているものの、 K8s の Deployment のマニフェストをローカルで更新して GitHub に push する、という方も多いのではないでしょうか?

今回は、リリースタグを作成したときに、 CircleCI でリリース用の Pull Request(以下、PR と表記)を自動で作成するようにしたので、記事にまとめました。

CircleCI 設定

今回作成した .circleci/config.yml ファイルは以下のようになります。詳細は GitHub をみていただければと思います。

この workflow で行っていることは以下の 4 つになります。

  • ssh key の設定
  • 必要なコマンドのインストール
  • git の設定
  • PR の作成(別スクリプトファイル)
version: 2.1
commands:
  install_commands:
    steps:
      - run:
          name: install commands
          command: |
            apk add curl jq yq git bash openssh make
  set_up_git:
    steps:
      - checkout
      - run:
          name: setup git user
          command: |
            git config user.email $GITHUB_EMAIL
            git config user.name $GITHUB_USER
  release_pr:
    steps:
      - checkout
      - run:
          name: release_pr
          command: |
            service_name=$(echo ${CIRCLE_TAG} | cut -d/ -f2)
            version=$(echo $CIRCLE_TAG | cut -d/ -f3)
            make microservices/app/release_pr SERVICE_NAME=${service_name} VERSION=${version} GITHUB_TOKEN=${GITHUB_TOKEN}
jobs:
  release_pr_job:
    docker:
      - image: alpine:3.19.0
    shell: /bin/sh -leo pipefail
    environment:
      BASH_ENV: /etc/profile
    steps:
      - add_ssh_keys:
          fingerprints:
            - "SHA256:7p8XHnZsyzH7w0g/mBhp4dEeHNO6gPyTV5io5esfZPs"
      - install_commands
      - set_up_git
      - release_pr

workflows:
  version: 2
  build:
    jobs:
      - release_pr_job:
          filters:
            tags:
              only: /^microservices.*/
            branches:
              ignore: /.*/

ssh key の設定

まずは CircleCI から PR を作成するために ssh key の設定を行います。CircleCI で GitHub のリポジトリをセットアップしたときに自動で生成される ssh key では読み取り権限しかないので、新しく鍵を生成して CircleCI と GitHub に登録する必要があります。

まずは CircleCI のドキュメントを参考にしつつ、 ssh key を生成し、秘密鍵を登録します。今回は GitHub を利用するので、 Hostname は github.com にしておきます。

鍵を生成したら、GitHub のリポジトリを開いて、 Settings > Deploy keys > Add deploy key で生成した鍵の公開鍵を登録します。

このとき「Allow write access」にチェックを入れて、書き込み権限を付与する必要があります。

この手順もドキュメントがあるので参考にしてください。

これらの登録作業が完了したら、CircleCI、GitHub のどちらも以下のように fingerprint がみえるようになっています。

これをコピーして .circleci/config.yml の add_ssh_keys.fingerprints に設定します。

jobs:
  release_pr_job:
    docker:
      - image: xxxx
    steps:
      - add_ssh_keys:
          fingerprints:
            - "SHA256:7p8XHnZsyzH7w0g/mBhp4dEeHNO6gPyTV5io5esfZPs"

必要なコマンドのインストール

今回は alpine のイメージを利用したので、いろいろと必要なコマンドをインストールしておきます。 jq と yq は後ほど利用します。

install_commands:
  steps:
    - run:
        name: install commands
        command: |
          apk add curl jq yq git bash openssh make

git の設定

git command で PR を push するため、 config を設定しておきます。これらの環境変数は CircleCI 側で設定しておきます。

set_up_git:
  steps:
    - checkout
    - run:
        name: setup git user
        command: |
          git config user.email $GITHUB_EMAIL
          git config user.name $GITHUB_USER

PR の作成

このステップで PR を作成します。とはいえ少し長いので別スクリプトで実行するようにしています。また、私たちはモノレポでマイクロサービスを管理しており、リリースタグは microservices/<service name>/<version> といったフォーマットを使用しています。そのため、今回もそのフォーマットを想定して、 service name や version を抽出してからスクリプトを実行しています。

release_pr:
  steps:
    - checkout
    - run:
        name: release_pr
        command: |
          service_name=$(echo ${CIRCLE_TAG} | cut -d/ -f2)
          version=$(echo $CIRCLE_TAG | cut -d/ -f3)
          make microservices/app/release_pr SERVICE_NAME=${service_name} VERSION=${version} GITHUB_TOKEN=${GITHUB_TOKEN}

PR 作成のスクリプト

まず Makefile は以下のようになっています。受け取った引数をそのままスクリプトファイルに渡しているだけになります。

.PHONY: microservices/app/release_pr
microservices/app/release_pr:
	scripts/app/release_pr $(SERVICE_NAME) $(VERSION) $(GITHUB_TOKEN)

scripts/app/release_pr ファイルは以下のようになります。これも長いので少しずつ解説していきます。

#!/bin/bash

main() {
    set -eo pipefail

    if [[ $# -ne 3 ]]; then
        exit 1
    fi

    local service_name=${1}
    local version=${2}
    local github_token=${3}
    local branch_name="release/$(echo ${service_name})_$(echo ${version})"

    local owner="ksrnnb"
    local repo="ci_create_pr"

    echo "[INFO] Running 'scripts/app/release_pr' for ${service_name}"

    git switch -c $branch_name

    deployment_files=$(find ./microservices/${service_name} -type d -name "Deployment" -exec find {} -type f -name "*.yaml" \;)
    for file in $deployment_files; do
        echo "[INFO] Updating file: $file"

        # tags.datadoghq.com/version を更新
        yq e ".metadata.labels[\"tags.datadoghq.com/version\"] = \"${version}\"" -i "$file"
        yq e ".spec.template.metadata.labels[\"tags.datadoghq.com/version\"] = \"${version}\"" -i "$file"

        # service のイメージのタグのみを更新
        yq e ".spec.template.spec.containers[] |= (select(.image | contains(\"${service_name}\")) .image |= sub(\":.*$\"; \":${version}\"))" -i "$file"
    done

    git add .
    git commit -m "release: ${service_name} ${version}"
    git push origin $branch_name

    echo "[INFO] Git push has completed"

    # create PR
    response=$(curl -s -L \
        -X POST \
        -H "Accept: application/vnd.github+json" \
        -H "Authorization: Bearer ${github_token}" \
        -H "X-GitHub-Api-Version: 2022-11-28" \
        https://api.github.com/repos/${owner}/${repo}/pulls \
        -d "{ \
            \"title\": \"release: ${service_name} ${version}\", \
            \"body\": \"release ${service_name} ${version}\", \
            \"head\": \"${branch_name}\", \
            \"base\": \"main\" \
        }")

    # `// empty`` is used to convert null to empty string
    # ref: https://jqlang.github.io/jq/manual/#:~:text=Example-,empty,-empty%20returns%20no
    pr_url=$(echo $response | jq -r '.html_url // empty')
    if [ -z "$pr_url" ]; then
        echo "[Error] PR URL not found in the response."
        exit 1
    fi

    echo "[INFO] release PR has been created"

    echo $pr_url
}

main "$@"
ブランチの作成

まずは引数が 3 つ渡されていることを確認して、それらをもとにブランチ名を決めてブランチを作成しています。

#!/bin/bash

main() {
    set -eo pipefail

    if [[ $# -ne 3 ]]; then
        exit 1
    fi

    local service_name=${1}
    local version=${2}
    local github_token=${3}
    local branch_name="release/$(echo ${service_name})_$(echo ${version})"

    local owner="ksrnnb"
    local repo="ci_create_pr"

    echo "[INFO] Running 'scripts/app/release_pr' for ${service_name}"

    git switch -c $branch_name
Deployment ファイルの Docker image のバージョン更新

先述したとおり、私たちはモノレポでマイクロサービスを管理しているので、k8s の Deployment のファイルが複数存在します。microservices/<service name>/kubernetes/<environment>/Deployment/<service name>.yaml というファイルで管理しているので、この Deployment のファイルを探して、バージョンを更新しています。

yq は yaml ファイルを更新するのに便利なコマンドとなります。3 つ目の yq が少しややこしいですが、containers で定義された image のうち、 service name が含まれるもののみ更新するようにしています。yq コマンドの詳細はドキュメントをご参照ください。

deployment_files=$(find ./microservices/${service_name} -type d -name "Deployment" -exec find {} -type f -name "*.yaml" \;)
for file in $deployment_files; do
    echo "[INFO] Updating file: $file"

    # tags.datadoghq.com/version を更新
    yq e ".metadata.labels[\"tags.datadoghq.com/version\"] = \"${version}\"" -i "$file"
    yq e ".spec.template.metadata.labels[\"tags.datadoghq.com/version\"] = \"${version}\"" -i "$file"

    # service のイメージのタグのみを更新
    yq e ".spec.template.spec.containers[] |= (select(.image | contains(\"${service_name}\")) .image |= sub(\":.*$\"; \":${version}\"))" -i "$file"
done
PR の作成

Deployment のファイルを更新したら、差分をコミットして GitHub に Push して、REST API を叩いて PR を作成します。今回は REST API を利用しましたが、 gh コマンドを利用するなどもう少し工夫の余地はありそうです。

git add .
git commit -m "release: ${service_name} ${version}"
git push origin $branch_name

echo "[INFO] Git push has completed"

# create PR
response=$(curl -s -L \
    -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${github_token}" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    https://api.github.com/repos/${owner}/${repo}/pulls \
    -d "{ \
        \"title\": \"release: ${service_name} ${version}\", \
        \"body\": \"release ${service_name} ${version}\", \
        \"head\": \"${branch_name}\", \
        \"base\": \"main\" \
    }")
PR の URL 取得

PR を作成したら jq コマンドを利用して、 PR の URL を取得しています。今回は URL を取得して出力するだけにしていますが、必要に応じて Slack 通知させるなど応用ができると思います。

# `// empty` is used to convert null to empty string
# ref: https://jqlang.github.io/jq/manual/#:~:text=Example-,empty,-empty%20returns%20no
    pr_url=$(echo $response | jq -r '.html_url // empty')
    if [ -z "$pr_url" ]; then
        echo "[Error] PR URL not found in the response."
        exit 1
    fi

    echo "[INFO] release PR has been created"

    echo $pr_url

まとめ

本記事では GitHub のリリースタグ作成時に CircleCI でリリース用 PR を自動で作成するための workflow をみてきました。モノレポでマイクロサービスを管理しているという前提があるので、少し複雑になっている箇所もありましたが、みなさんが応用する時はもう少しシンプルに実装できるかと思います。

またモノレポだと、リリースタグの Description に含まれる差分がマイクロサービスごとの差分にならないという悩みがあるので、それも解消できたら次の記事にしたいと思います。