Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- リリースノートに入るのは「## 概要」の本文だけです。機能の改善・変更を書いてください。見出しが無い場合は PR タイトルだけが載ります。 -->

## 概要

16 changes: 11 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Create draft release with PR body
- name: Create draft release from PR overviews
id: create-release
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const { buildReleaseNotes } = require(path.join(
process.env.GITHUB_WORKSPACE,
'scripts',
'release-notes.cjs'
));
const tagName = context.ref.startsWith('refs/tags/')
? context.ref.replace('refs/tags/', '')
: context.payload.inputs.tag_name;
Expand All @@ -54,18 +60,18 @@ jobs:
core.warning(`pr-release-relation.yml の読み込みに失敗: ${e.message}`);
}

// 各PRのbodyを取得して結合
const bodies = [];
// ## 概要 だけを載せる。節が無ければ PR タイトル。
const pulls = [];
for (const prNumber of prNumbers) {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.body) bodies.push(pr.body.trim());
pulls.push({ title: pr.title, body: pr.body || '' });
}

const body = bodies.join('\n\n---\n\n') || `Release ${tagName}`;
const body = buildReleaseNotes(pulls) || `Release ${tagName}`;

const { data: release } = await github.rest.repos.createRelease({
owner: context.repo.owner,
Expand Down
82 changes: 82 additions & 0 deletions scripts/release-notes.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// リリースノートは各 PR の「## 概要」だけ。節が無ければタイトルの箇条書き。

function extractOverview(markdown) {
const lines = String(markdown ?? "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n");
let fence = null;
let capturing = false;
const out = [];

const atFence = (line) => {
const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
if (!m) return false;
const marker = m[2];
const rest = m[3];
if (!fence) {
fence = { char: marker[0], len: marker.length };
return true;
}
if (marker[0] === fence.char && marker.length >= fence.len && rest.trim() === "") {
fence = null;
return true;
}
return false;
};

for (const line of lines) {
if (atFence(line) || fence) {
if (capturing) out.push(line);
continue;
}
if (/^##\s+概要\s*$/.test(line)) {
capturing = true;
continue;
}
if (capturing && /^#{1,2}\s+\S/.test(line)) break;
if (capturing) out.push(line);
}

const text = out.join("\n").trim();
return text || null;
}

function buildReleaseNotes(pulls) {
const chunks = [];
const bullets = [];
const flush = () => {
if (bullets.length === 0) return;
chunks.push(bullets.join("\n"));
bullets.length = 0;
};

for (const pr of pulls || []) {
const overview = extractOverview(pr && pr.body);
if (overview) {
flush();
chunks.push(`## 概要\n\n${overview}`);
continue;
}
const title = String((pr && pr.title) || "")
.replace(/\s+/g, " ")
.trim();
if (title) bullets.push(`- ${title}`);
}
flush();
return chunks.join("\n\n");
}

module.exports = { extractOverview, buildReleaseNotes };

if (require.main === module) {
const fs = require("fs");
const file = process.argv[2];
if (!file) {
process.stderr.write("usage: node scripts/release-notes.cjs <pulls.json>\n");
process.exit(2);
}
const notes = buildReleaseNotes(JSON.parse(fs.readFileSync(file, "utf8")));
process.stdout.write(notes);
if (notes && !notes.endsWith("\n")) process.stdout.write("\n");
}
96 changes: 96 additions & 0 deletions scripts/release-notes.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractOverview, buildReleaseNotes } = require("./release-notes.cjs");

const pr16 = `## 概要

next版の7項目を実装。

| コミット | 内容 |
|---|---|
| \`feat:\` ローカルAPI + MCP | HTTP API |

### 補足

フォーカス移動を含む。

## 確認

- \`tsc\` 成功

## レビューしてほしい点

1. Google連携は未検証です。

## 既知の割り切り(\`ponytail:\` コメントで明記)

- トークンファイルの 0600 は unix のみ(Windows未対応)
`;

test("概要だけを抜き、確認・レビュー・割り切りは捨てる", () => {
const notes = buildReleaseNotes([{ title: "feat: next版", body: pr16 }]);
assert.match(notes, /next版の7項目を実装/);
assert.match(notes, /ローカルAPI \+ MCP/);
assert.match(notes, /### 補足/);
assert.match(notes, /フォーカス移動を含む/);
assert.doesNotMatch(notes, /## 確認/);
assert.doesNotMatch(notes, /tsc/);
assert.doesNotMatch(notes, /レビューしてほしい点/);
assert.doesNotMatch(notes, /未検証/);
assert.doesNotMatch(notes, /既知の割り切り/);
assert.doesNotMatch(notes, /Windows未対応/);
});

test("見出しが無い本文はタイトルにフォールバックする", () => {
const notes = buildReleaseNotes([
{ title: "fix: バグ修正", body: "- ウィンドウが閉じない\n- 日付が更新されない\n" },
]);
assert.equal(notes, "- fix: バグ修正");
});

test("概要が空、または確認しか無いときは本文を載せない", () => {
const empty = buildReleaseNotes([
{ title: "feat: 空", body: "## 概要\n\n\n## 確認\n\n- cargo test\n" },
]);
assert.equal(empty, "- feat: 空");

const checklistOnly = buildReleaseNotes([
{ title: "chore: 確認だけ", body: "## 確認\n\n- 目視した\n\n## レビューしてほしい点\n\n- なし\n" },
]);
assert.equal(checklistOnly, "- chore: 確認だけ");
});

test("コードフェンス内の見出しでは節を切らない", () => {
const body = [
"## 概要",
"",
"手順:",
"",
"```",
"## 確認",
"レビューしてほしい点",
"```",
"",
"以上。",
"",
"## 確認",
"",
"- 実機",
].join("\n");
const section = extractOverview(body);
assert.match(section, /## 確認/);
assert.match(section, /以上。/);
assert.doesNotMatch(section, /実機/);
});

test("複数 PR は概要とタイトル箇条書きを順に結合する", () => {
const notes = buildReleaseNotes([
{ title: "chore: 先", body: "" },
{ title: "feat: 本体", body: "## 概要\r\n\r\nカレンダーを削除。\r\n" },
{ title: "chore: 後\nの続き", body: "本文は使わない" },
]);
assert.equal(
notes,
["- chore: 先", "## 概要\n\nカレンダーを削除。", "- chore: 後 の続き"].join("\n\n"),
);
});
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app", "dmg"],
"icon": [
"icons/icon_16x16.png",
"icons/icon_32x32.png",
Expand Down