モノレポで管理しているマイクロサービスの GitHub のリリース差分を管理する
目次
はじめに
こんにちは、 okarin です。モノレポでマイクロサービスを管理していると、GitHub 上で前回のリリースとの差分が正しく管理できない、といった悩みがでてきます。具体的には自動でリリースノートを生成すると、他のマイクロサービスの差分が含まれていたり、リリースしたいマイクロサービスの差分が含まれなかったりしてしまいます。一方で、release-drafter/release-drafter を使うとマイクロサービスごとにリリースノートを生成できますが、マイクロサービスごとに設定ファイルを作成する必要があり、管理が煩雑になってしまいます。これらのペインを解消するために、 GitHub Actions を作成したので記事にまとめました。
GitHub Actions
GitHub Actions の全体像は以下になります。マージされた PR がどのマイクロサービスに変更があったかを調べて、マイクロサービスごとの Draft のリリースを作成・更新します。この Draft のリリースを手動で公開することで、マイクロサービスごとにリリースの差分を管理することができます。
- PR を作成する
- PR の作成・更新をトリガーに、変更されたマイクロサービス名のラベルを PR に付与する
- PR をマージする
- PR のマージをトリガーに、Draft のリリースを作成・更新する
- 手動で 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 が原因か調べるときのあたりもつけやすくなりました。今後もより良い方法がないか模索して、改善していきたいと思います。