Semantic VersioningからCalendar Versioningへ

当サイトでは、これまで Semantic Versioning (以下SemVer)を採用していたが、今後は Calendar Versioning (以下CalVer)を使っていくことにした。

Semverとは

SemVerのバージョン番号は、MAJOR.MINOR.PATCHいう形式で構成されている。

それぞれの役割は以下の通り:

  1. MAJOR(メジャー): 後方互換性のない変更が加えられた場合に更新される番号。例えば、APIの仕様が変更されて互換性が保てなくなった場合などに使用する
  2. MINOR(マイナー): 後方互換性を保ったまま新機能を追加した場合に更新される番号。新しい機能が提供されるものの、既存の機能には影響を及ぼさない場合などに使用する
  3. PATCH(パッチ): バグ修正など、後方互換性を保ちながら既存機能の改善や修正が行われた場合に更新される番号。小さなバグ修正やセキュリティパッチなどに使用する

これにより、バージョン番号を見ただけでそのリリースが含む変更の規模や影響範囲を予測できる仕組みになる。

とえば2.3.1いうバージョンの場合:

  • 2: メジャーバージョン。後方互換性のない変更があったことを示す
  • 3: マイナーバージョン。新機能が追加されていることを示す
  • 1: パッチバージョン。小さなバグ修正が含まれていることを示す

当サイトではSemVerの管理に semantic-release使っていた。

semantic-releaseは、コミットメッセージを解析し、コードベースへの変更が与える影響を判断することで、次のSemVerを自動的に決定し、変更ログを生成・リリースするツールである。

デフォルトで使用されるコミットメッセージ形式は Angular Commit Message Conventions従っている。

あるPRがmainブランチにマージされたときに、PRに含まれるコミットメッセージの接頭辞によって、リリースのバージョン番号を決定する。

以下がその例。

コミットメッセージ例リリースタイプ
fix(scope): descriptionパッチリリース
feat(scope): descriptionマイナーリリース
perf(scope): descriptionメジャーリリース

CalVerとは

CalVerとは、バージョン番号をカレンダー(年月日)に基づいて付与する方法である。

この式は、リリースの日時を直感的に伝えられるため、リリース時期が重要なプロジェクトや、定期的なリリースを行うプロジェクトに適したバージョニングと言える。 CalVerのバージョン番号には複数のフォーマットがあるが、一般的な形式は以下の通り:

  1. YYYY.MINOR: リリース年(例:「2025」)と、その年内のリリース回数を示すマイナーバージョンを使用。例: 2025.1, 2025.2
  2. YYYY.MM.MICRO: リリース年、(例:「04」)、バグ修正を示すMICRO番号を使用。例: 2025.04.3
  3. YYYYMMDD: 完全なリリース日をバージョン番号として利用。例: 20250412
  4. YY.MINOR: リリース年の下2桁とマイナーバージョンを組み合わせた簡易的な形式。例: 25.4

JetBrains系IDEや、UbuntuArch Linux などのOSでは、CalVerを採用している。

なぜCalVerに変更したのか

Angular Commit Message Conventionsに則ったSemVerでは以下の点が辛かった。

  • マイナーバージョンを上げたくないがために、コミットメッセージにfeat使えない
    • 結果的にコミットタイプにchore使うことが多くなってしまう
  • メジャーバージョンをどのタイミングで上げるか悩む
    • メジャーバージョンは頻繁に上げるべきではないという考えがある
  • 当サイトではマイナーバージョンを上げるたびにリリースノートを書いていたが、それも辛くなってきた

特に最後の部分が個人的に辛かったところである。

リリースノートを書くのは楽しいが、毎回書くのは大変。 それにリリースノートに更新した内容をまとめて書くよりも、分割して書いたほうが読者にもSEOにも優しい。

何より、頻繁にサイトを更新するため、過去のリリースノートと最新のコードとの差分が大きくなっているのも良くない。

いったわけで、CalVerに変更することにした。

※ Angular Commit Message Conventionsとは切り離したSemVerでも良いのだが、いずれにせよどのタイミングでバージョンを上げるのか悩むのは同じである

CalVerのリリースを自動化したい

今まではsemantic-releaseとGitHub Actionsを使ってリリースを自動化していた。 が、CalVerに変更したことで、CIを使った自動化は必要なくなった。

なぜなら、semantic-releaseのようにコミットメッセージを解析してバージョン番号を決定する必要がなくなったからである。 CalVerの場合、リリースのタイミングは自分で決めることができるため、CIを使って自動化する必要がない。

とはいえ、毎回git tagコマンドを使ってタグ付けし、GitHub上でリリースを作成するのは面倒である。 そのため、CalVerのリリースを1つのコマンドで行えるようにスクリプトを書いた。

スクリプトの要件

  • Gitのタグ付けとGitHubへのリリース作成までを自動化する
  • タグの形式はYYYY.0M.0D
  • タグが重複する場合はYYYY.0M.0D-MICRO(MICROの部分はインクリメント)
  • リリース本文には前回のリリースとのコミット比較URLを含める
  • Bunで実行する
  • dry runオプションをつけると、実際にはリリースしない(ログを出力する)
scripts/release.ts
import { $ } from "bun";
const isDryRun = process.argv.includes("--dry-run");
const GITHUB_ACCESS_TOKEN = process.env.GITHUB_ACCESS_TOKEN;
if (!GITHUB_ACCESS_TOKEN) {
console.error(
"🚨 GitHub token is missing. Set GITHUB_ACCESS_TOKEN in your environment variables.",
);
process.exit(1);
}
const now = new Date();
const BASE_VERSION = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, "0")}.${String(now.getDate()).padStart(2, "0")}`;
let version = BASE_VERSION;
const tags = (await $`git tag`.text())
.split("\n")
.filter((t) => t.startsWith(BASE_VERSION));
if (tags.includes(version)) {
let suffixCounter = 2;
while (tags.includes(`${version}-${suffixCounter}`)) {
suffixCounter++;
}
version = `${version}-${suffixCounter}`;
}
console.log(`🔖 Creating tag: ${version}`);
if (isDryRun) console.log("💡 Dry run mode is ON");
const CURRENT_BRANCH = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim();
if (!isDryRun) {
await $`git checkout main`;
await $`git tag -f ${version}`;
await $`git push -f origin ${version}`;
await $`git checkout ${CURRENT_BRANCH}`;
console.log(`✅ Released tag: ${version} and returned to ${CURRENT_BRANCH}`);
} else {
console.log("🚫 Would checkout main");
console.log(`🚫 Would tag -f ${version}`);
console.log(`🚫 Would push -f origin ${version}`);
console.log(`🚫 Would checkout ${CURRENT_BRANCH}`);
}
const REPO_OWNER = "kkhys";
const REPO_NAME = "me";
console.log(`🚀 Preparing to create GitHub release for ${version}`);
const getPreviousTag = async (): Promise<string | null> => {
const response = await fetch(
`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`,
{
method: "GET",
headers: {
Authorization: `Bearer ${GITHUB_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
},
);
if (response.ok) {
const releases: { tag_name: string }[] = (await response.json()) as {
tag_name: string;
}[];
if (releases && releases.length > 0 && releases[0]?.tag_name) {
return releases[0].tag_name;
}
} else {
console.error("❌ Failed to fetch releases from GitHub.");
const errorData = (await response.json()) as { message: string };
console.error(`Error: ${errorData.message}`);
}
return null;
};
const createGitHubRelease = async () => {
const previousTag = await getPreviousTag();
let body = `Automatic release for version ${version}.`;
if (previousTag) {
const compareUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/compare/${previousTag}...${version}`;
body += `\n\n[View changes since ${previousTag}](${compareUrl})`;
} else {
body += "\n\n(No previous release found for comparison.)";
}
const response = await fetch(
`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`,
{
method: "POST",
headers: {
Authorization: `Bearer ${GITHUB_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
tag_name: version,
name: version,
body,
draft: false,
prerelease: false,
}),
},
);
if (response.ok) {
const responseData = (await response.json()) as { html_url: string };
console.log(`✅ GitHub Release created: ${responseData.html_url}`);
} else {
const errorData = (await response.json()) as { message: string };
console.error("❌ Failed to create GitHub release.");
console.error(`Error: ${errorData.message}`);
}
};
if (!isDryRun) {
await createGitHubRelease();
} else {
console.log("🚫 Would create GitHub release");
console.log(`🚫 Release tag: ${version}`);
console.log(`🚫 Release title: ${version}`);
console.log("✅ Dry run completed for GitHub release process");
}
Terminal window
bun run scripts/release.ts

上記を実行すると、以下のようにリリースが作成される。

dry runモードだと以下の出力になる。

Terminal window
bun run scripts/release.ts --dry-run
🔖 Creating tag: 2025.04.12-3
💡 Dry run mode is ON
🚫 Would checkout main
🚫 Would tag -f 2025.04.12-3
🚫 Would push -f origin 2025.04.12-3
🚫 Would checkout develop
🚀 Preparing to create GitHub release for 2025.04.12-3
🚫 Would create GitHub release
🚫 Release tag: 2025.04.12-3
🚫 Release title: 2025.04.12-3
Dry run completed for GitHub release process

さいごに

CalVerに変更したことで、本質的ではないところで悩む無駄を減らせたのは大きい(変なところで悩む性格を根本から治せば良い話だが)。 今後はCalVerを使ってリリースを行っていくので、リリースノートは廃止してトピックごとに技術記事を書いていく予定である。