Menu

Mobile navigation

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");
}
bun run scripts/release.ts

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

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

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 を使ってリリースを行っていくので、リリースノートは廃止してトピックごとに技術記事を書いていく予定である。