モノレポで管理しているマイクロサービスの GitHub のリリース差分を管理する

目次

はじめに

こんにちは、 okarin です。モノレポでマイクロサービスを管理していると、GitHub 上で前回のリリースとの差分が正しく管理できない、といった悩みがでてきます。具体的には自動でリリースノートを生成すると、他のマイクロサービスの差分が含まれていたり、リリースしたいマイクロサービスの差分が含まれなかったりしてしまいます。一方で、release-drafter/release-drafter を使うとマイクロサービスごとにリリースノートを生成できますが、マイクロサービスごとに設定ファイルを作成する必要があり、管理が煩雑になってしまいます。これらのペインを解消するために、 GitHub Actions を作成したので記事にまとめました。

GitHub Actions

GitHub Actions の全体像は以下になります。マージされた PR がどのマイクロサービスに変更があったかを調べて、マイクロサービスごとの Draft のリリースを作成・更新します。この Draft のリリースを手動で公開することで、マイクロサービスごとにリリースの差分を管理することができます。

  1. PR を作成する
  2. PR の作成・更新をトリガーに、変更されたマイクロサービス名のラベルを PR に付与する
  3. PR をマージする
  4. PR のマージをトリガーに、Draft のリリースを作成・更新する
  5. 手動で GitHub Actions を実行し、 Draft のリリースを公開する

コードの詳細は GitHub にアップロードしているので適宜ご確認ください。

変更されたマイクロサービス名のラベル付与

PR 作成時や、コミットが push されたときに GitHub Actions を実行して、マイクロサービス名のラベルを付与します。

ファイルの差分検出

まずは tj-actions/changed-files を使って、どのマイクロサービスに差分があるかを調べます。今回は microservices ディレクトリ配下に、各マイクロサービスのコードを配置しているので、 with.files に microservices/** を指定します。こうすると、 microservices ディレクトリ配下で差分のあるファイルがスペース区切りで取得できます。

その後のステップで "${{ steps.changed-microservice-files.outputs.all_changed_files }}".split(" "); とすることで、差分のあったファイルのパスを配列として取得できます。

jobs:
  add-label:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          sparse-checkout: |
            .github
            microservices
      - name: Get all changed microservices files
        id: changed-microservice-files
        uses: tj-actions/changed-files@v44
        with:
          dir_names: "true"
          dir_names_max_depth: 2
          exclude_submodules: "true"
          files: |
            microservices/**
      - name: Label PR based on microservice directory
        uses: actions/github-script@v6
        with:
          script: |
            const changedFiles = "${{ steps.changed-microservice-files.outputs.all_changed_files }}".split(" ");

追加・削除するラベルの選定

次に、PR に付与されているラベルを取得して、すでに付与されているマイクロサービス名のラベルを抽出します。ラベルに id を設定することはできないので、ラベルの色でフィルタリングします。ラベルの作成に関しては後ほど出てきますが、他のラベルの色と被りにくくするために、 default color から少しずらした色でラベルを作成しています。

その後、差分のあるファイルのパスから、差分のあるサービス名を取得していきます。現在のラベルに含まれない場合は、追加するラベルの Set に追加します。逆に commit によって差分がなくなったサービスがあれば削除するラベルの Set に追加します。

const labelColor = "0052CA";
const labelsToAdd = new Set();

const currentLabels = await github.rest.issues.listLabelsOnIssue({
  owner: context.repo.owner,
  repo: context.repo.repo,
  issue_number: context.payload.pull_request.number,
});

// filter by color to get services' label
const currentServiceLabelNames = currentLabels.data
  .filter((label) => label.color === labelColor)
  .map((label) => label.name);
const changedServiceNames = new Set();

changedFiles.forEach((file) => {
  const directories = file.split("/");
  if (directories.length > 1) {
    // file => microservices/{service_name}/**
    // directories => ["microservices", {service_name}, ...]
    const serviceName = directories[1];
    changedServiceNames.add(serviceName);
    // https://docs.github.com/ja/rest/issues/labels?apiVersion=2022-11-28#list-labels-for-an-issue
    if (!currentServiceLabelNames.includes(serviceName)) {
      labelsToAdd.add(serviceName);
    }
  }
});

const labelsToRemove = new Set(
  [...currentServiceLabelNames].filter(
    (currentServiceLabelName) =>
      !changedServiceNames.has(currentServiceLabelName)
  )
);

ラベルの追加・削除

追加・削除するラベルが取得できたので、ラベルを追加・削除します。そもそも追加したいマイクロサービス名のラベルが未作成の場合は、ラベルの色を指定して作成するようにしています。

if (labelsToAdd.size > 0) {
  Array.from(labelsToAdd).forEach(async (label) => {
    // Create a service label
    try {
      await github.rest.issues.createLabel({
        owner: context.repo.owner,
        repo: context.repo.repo,
        name: label,
        color: labelColor,
      });
    } catch (error) {
      // status 422 means that a label has already created
      // https://docs.github.com/ja/rest/issues/labels?apiVersion=2022-11-28#create-a-label--status-codes
      if (error.status !== 422) throw error;
    }
  });

  // Assign new service labels
  await github.rest.issues.addLabels({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: context.payload.pull_request.number,
    labels: [...labelsToAdd],
  });
}

// remove unnecessary service labels
if (labelsToRemove.size > 0) {
  Array.from(labelsToRemove).forEach(async (label) => {
    await github.rest.issues.removeLabel({
      owner: context.repo.owner,
      repo: context.repo.repo,
      issue_number: context.payload.pull_request.number,
      name: label,
    });
  });
}

Draft のリリースを作成・更新

PR を main ブランチにマージしたタイミングで、 Draft のリリースを作成・更新します。

変更のあったマイクロサービス名を取得

先ほどの Action で付与したマイクロサービスのラベルから、それらの名前を取得します。

jobs:
  update-release-draft:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: read
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Create or Update Release Draft
        uses: actions/github-script@v7
        with:
          script: |
            const labelColor = '0052CD';

            const currentLabels = await github.rest.issues.listLabelsOnIssue({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
            })

            // filter by color to get services' label
            const currentServiceLabelNames = currentLabels.data.filter(label => label.color === labelColor).map(label => label.name);

Draft のリリース取得

続いて、過去の GitHub のリリースを取得します。その後、変更のあったマイクロサービスの draft のリリースがなければ、作成する必要があるのでフィルタリングしておきます。

// paginate method get all data
// https://docs.github.com/ja/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#example-using-the-octokitjs-pagination-method
const releases = await github.paginate(github.rest.repos.listReleases, {
  owner: context.repo.owner,
  repo: context.repo.repo,
});

const draftReleases = releases.filter(
  (release) => release.draft && currentServiceLabelNames.includes(release.name)
);

const servicesToCreate = currentServiceLabelNames.filter(
  (serviceName) =>
    draftReleases.find((release) => release.name === serviceName) === undefined
);

Draft のリリースを作成・更新

まず、リリースに追加する文章を作成します。今回は - feat: implement FooBar by @ksrnnb in #1234 のような文章を追加します。

その後、 各マイクロサービスの Draft のリリースが作成済みであれば、文章を追加します。 Draft のリリースがない場合は、リリースを Draft で作成してから文章を追加します。

こうすることで、マイクロサービスごとに Draft のリリースが更新され続け、どの PR がリリースに含まれるのかが分かるようになります。

const prTitle = context.payload.pull_request.title;
const prNumber = context.payload.pull_request.number;
const authorId = context.payload.pull_request.user.login;
const newBody = `- ${prTitle} by @${authorId} in #${prNumber}`;

// update draft release
for (const draftRelease of draftReleases) {
  const body = `${draftRelease.body}\n${newBody}`;
  await github.rest.repos.updateRelease({
    owner: context.repo.owner,
    repo: context.repo.repo,
    release_id: draftRelease.id,
    body,
  });
}

// Create new draft
for (const service of servicesToCreate) {
  await github.rest.repos.createRelease({
    owner: context.repo.owner,
    repo: context.repo.repo,
    tag_name: `${service}-draft-${new Date().toISOString()}`,
    name: service,
    body: newBody,
    draft: true,
  });
}

Draft のリリースを公開

今回は GitHub Actions でトリガーして Draft のリリースを公開します。直接操作して公開することも可能ですが、タグ名の編集などが面倒なので GitHub Actions で実行するようにしました。

手動トリガー

まずは、 Actions から手動で実行するために、トリガーの設定をしておきます。リリースするマイクロサービス名の選択と、セマンティックバージョンのどれを上げるのかを指定します。

name: Publish Release

on:
  workflow_dispatch:
    inputs:
      service:
        description: "Service to release"
        required: true
        type: choice
        options:
          - bar
          - foo
          - hoge
          - piyo
      versionType:
        description: "Version to increment"
        required: true
        default: "patch"
        type: choice
        options:
          - major
          - minor
          - patch

次のバージョンを生成する関数の定義

手動トリガーで選択した内容に応じて、次のバージョンを生成するための関数を準備しておきます。

jobs:
  update-release-draft:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Publish Release
        uses: actions/github-script@v7
        with:
          script: |
            const nextVersion = (currentVersion, versionType) => {
              const versions = currentVersion.split(".");
              if (versions.length !== 3) {
                throw Error(`version has unexpected format ${currentVersion}, expected format is like "x.y.z"`);
              }

              const intVersions = versions.map(version => Number(version));
              switch (versionType) {
                case "major":
                  intVersions[0]++;
                  intVersions[1] = 0;
                  intVersions[2] = 0;
                  break;
                case "minor":
                  intVersions[1]++;
                  intVersions[2] = 0;
                  break;
                case "patch":
                  intVersions[2]++;
                  break;
              }

              return intVersions.join(".");
            }

タグの名前を生成

リリースしたいサービスのリリースのうち、公開されていて最新のものと、 Draft のものをそれぞれ取得します。最新のものがない場合は 0.0.0 としておきます。それ以外の場合は先ほど定義した関数を使って新しいバージョンを生成します。その後、タグの名前を生成します。

const service = context.payload.inputs.service;
const versionType = context.payload.inputs.versionType;

// paginate method get all data
// https://docs.github.com/ja/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28#example-using-the-octokitjs-pagination-method
const releases = await github.paginate(github.rest.repos.listReleases, {
  owner: context.repo.owner,
  repo: context.repo.repo,
});

const latestRelease = releases.find(
  (release) => !release.draft && release.name.includes(service)
);
const draftRelease = releases.find(
  (release) => release.draft && service === release.name
);

let newVersion;

if (!latestRelease) {
  // if this is first release, base version is "0.0.0"
  newVersion = nextVersion("0.0.0", versionType);
} else {
  const latestVersion = latestRelease.name.split("/").at(2);
  if (!latestVersion) {
    throw Error(`latest version cannot be got from tag ${latestRelease.name}`);
  }
  newVersion = nextVersion(latestVersion, versionType);
}

const newTagName = `microservices/${service}/${newVersion}`;

リリースの公開

公開したいマイクロサービスの Draft のリリースがない場合はリリースを作成し、 Draft のリリースが存在する場合は draft: false とすることで公開します。

if (!draftRelease) {
  await github.rest.repos.createRelease({
    owner: context.repo.owner,
    repo: context.repo.repo,
    tag_name: newTagName,
    name: newTagName,
    body: `there is no difference between previous release \`${latestRelease?.name}\` and \`${newTagName}\``,
    draft: false,
  });
  return;
}

await github.rest.repos.updateRelease({
  owner: context.repo.owner,
  repo: context.repo.repo,
  release_id: draftRelease.id,
  tag_name: newTagName,
  name: newTagName,
  draft: false,
});

リリース作成・公開後のながれ

ここまでで、マイクロサービスごとに差分のある PR のみを GitHub のリリースに記述する仕組みを解説してきました。リリースを作成・公開した後は自動でイメージをビルドしたり、以前の記事で解説したように自動でリリース用の PR を作成するのもよいかと思います。

まとめ

本記事では、モノレポで管理しているマイクロサービスの GitHub リリース差分を管理する方法をみてきました。 GitHub Actions の導入によって、前回のリリースとの差分がはっきりと分かるようになり、どの機能がリリースされるのかが分かりやすくなりました。また、不具合があった時もどの PR が原因か調べるときのあたりもつけやすくなりました。今後もより良い方法がないか模索して、改善していきたいと思います。