Replace static release notes files with dynamic OpenRouter-based generation in Docker publish workflow
- Remove RELEASE_NOTES_DIR env var and docs/release_notes/ directory with v1.0 and v1.1 markdown files - Add OPENROUTER_API_KEY and OPENROUTER_MODEL to workflow environment variables - Add OPENROUTER_CHAT_COMPLETIONS_URL constant and OPENROUTER_SYSTEM_PROMPT_PATH pointing to scripts/openrouter_release_notes_system_prompt.md - Add require_env, load_text, github_repository_parts, github_api_get helpers
frdel committed
Mar 26, 2026 at 20:02 UTC
84798abf902cb3802e80d45dcdec4d7bf3477d5b
10 files changed
+275
-62
.github/scripts/docker_release_plan.py
+247
-11
@@ -7,6 +7,15 @@ import re
7
import subprocess
8
import sys
9
from dataclasses import asdict, dataclass
10
+from pathlib import Path
11
+from urllib.error import HTTPError, URLError
12
+from urllib.parse import urlencode
13
+from urllib.request import Request, urlopen
14
+
15
+
16
+REPO_ROOT = Path(__file__).resolve().parents[2]
17
+OPENROUTER_CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions"
18
+OPENROUTER_SYSTEM_PROMPT_PATH = REPO_ROOT / "scripts" / "openrouter_release_notes_system_prompt.md"
19
20
21
def fail(message: str) -> None:
@@ -61,6 +70,13 @@ def split_branches(raw: str) -> list[str]:
70
return [part for part in parts if part]
71
72
73
+def require_env(name: str) -> str:
74
+ value = os.environ.get(name, "").strip()
75
+ if not value:
76
+ fail(f"Required environment variable `{name}` is missing.")
77
+ return value
78
+
79
+
80
@dataclass(frozen=True)
81
class Config:
82
allowed_branches: list[str]
@@ -93,6 +109,12 @@ class Candidate:
109
reason: str
110
111
112
+@dataclass(frozen=True)
113
+class CommitEntry:
114
+ heading: str
115
+ description: str
116
+
117
+
118
def load_config() -> Config:
119
allowed_branches = split_branches(os.environ["ALLOWED_BRANCHES"])
120
if not allowed_branches:
@@ -138,6 +160,20 @@ def tag_commit(tag: str) -> str:
160
return git("rev-list", "-n", "1", f"refs/tags/{tag}")
161
162
163
+def commit_is_ancestor(older_ref: str, newer_ref: str) -> bool:
164
+ return (
165
+ run_command(
166
+ "git",
167
+ "merge-base",
168
+ "--is-ancestor",
169
+ older_ref,
170
+ newer_ref,
171
+ check=False,
172
+ ).returncode
173
+ == 0
174
+ )
175
+
176
+
177
def branch_contains_commit(branch: str, commit: str) -> bool:
178
return (
179
run_command(
@@ -416,11 +452,215 @@ def unique(items: list[str]) -> list[str]:
452
return output
453
454
455
+def load_text(path: Path) -> str:
456
+ if not path.exists():
457
+ fail(f"Expected file `{path}` to exist.")
458
+ return path.read_text(encoding="utf-8").strip()
459
+
460
+
461
+def github_repository_parts() -> tuple[str, str]:
462
+ repository = require_env("GITHUB_REPOSITORY")
463
+ owner, separator, repo = repository.partition("/")
464
+ if not owner or not separator or not repo:
465
+ fail(f"GITHUB_REPOSITORY must be in `owner/repo` format, got `{repository}`.")
466
+ return owner, repo
467
+
468
+
469
+def github_api_get(path: str, params: dict[str, str | int] | None = None) -> object:
470
+ api_base = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
471
+ token = require_env("GITHUB_TOKEN")
472
+ query = f"?{urlencode(params)}" if params else ""
473
+ request = Request(
474
+ f"{api_base}{path}{query}",
475
+ headers={
476
+ "Accept": "application/vnd.github+json",
477
+ "Authorization": f"Bearer {token}",
478
+ "X-GitHub-Api-Version": "2022-11-28",
479
+ },
480
+ method="GET",
481
+ )
482
+
483
+ try:
484
+ with urlopen(request, timeout=30) as response:
485
+ return json.loads(response.read().decode("utf-8"))
486
+ except HTTPError as exc:
487
+ details = exc.read().decode("utf-8", errors="replace").strip()
488
+ fail(f"GitHub API request failed ({path}): {exc.code} {exc.reason}\n{details}")
489
+ except URLError as exc:
490
+ fail(f"GitHub API request failed ({path}): {exc.reason}")
491
+
492
+
493
+def list_github_releases() -> list[dict[str, object]]:
494
+ owner, repo = github_repository_parts()
495
+ releases: list[dict[str, object]] = []
496
+ page = 1
497
+
498
+ while True:
499
+ payload = github_api_get(
500
+ f"/repos/{owner}/{repo}/releases",
501
+ {"per_page": 100, "page": page},
502
+ )
503
+ if not isinstance(payload, list):
504
+ fail("GitHub releases response was not a list.")
505
+ page_items = [item for item in payload if isinstance(item, dict)]
506
+ releases.extend(page_items)
507
+ if len(page_items) < 100:
508
+ break
509
+ page += 1
510
+
511
+ return releases
512
+
513
+
514
+def previous_published_release_tag(config: Config, source_tag: str) -> str | None:
515
+ source_version = parse_release_tag(config, source_tag)
516
+ if source_version is None:
517
+ fail(f"Tag `{source_tag}` is not a releasable tag.")
518
+
519
+ previous: list[tuple[tuple[int, int], str]] = []
520
+ for release in list_github_releases():
521
+ if release.get("draft") or release.get("prerelease"):
522
+ continue
523
+ tag_name = str(release.get("tag_name", "")).strip()
524
+ version = parse_release_tag(config, tag_name)
525
+ if version is None or version >= source_version:
526
+ continue
527
+ previous.append((version, tag_name))
528
+
529
+ previous.sort(key=lambda item: item[0])
530
+ return previous[-1][1] if previous else None
531
+
532
+
533
+def parse_commit_entries(raw_log: str) -> list[CommitEntry]:
534
+ entries: list[CommitEntry] = []
535
+ for raw_entry in raw_log.split("\x1e"):
536
+ entry = raw_entry.strip()
537
+ if not entry:
538
+ continue
539
+ heading, separator, description = entry.partition("\x1f")
540
+ if not separator:
541
+ continue
542
+ entries.append(
543
+ CommitEntry(
544
+ heading=re.sub(r"\s+", " ", heading).strip(),
545
+ description=description.strip(),
546
+ )
547
+ )
548
+ return entries
549
+
550
+
551
+def collect_release_commits(previous_release_tag: str | None, source_tag: str) -> list[CommitEntry]:
552
+ range_ref = source_tag
553
+ if previous_release_tag:
554
+ if not tag_exists(previous_release_tag):
555
+ fail(f"Previous published release tag `{previous_release_tag}` is not available in the repository.")
556
+ if not commit_is_ancestor(
557
+ f"refs/tags/{previous_release_tag}^{{commit}}",
558
+ f"refs/tags/{source_tag}^{{commit}}",
559
+ ):
560
+ fail(
561
+ f"Previous published release tag `{previous_release_tag}` is not an ancestor of `{source_tag}`."
562
+ )
563
+ range_ref = f"{previous_release_tag}..{source_tag}"
564
+
565
+ raw_log = git("log", "--reverse", "--format=%s%x1f%b%x1e", range_ref)
566
+ return parse_commit_entries(raw_log)
567
+
568
+
569
+def build_release_notes_user_message(commits: list[CommitEntry]) -> str:
570
+ lines = ["Commit headings and descriptions:"]
571
+
572
+ if not commits:
573
+ lines.append("No commits were found in this release range.")
574
+ return "\n".join(lines)
575
+
576
+ for index, commit in enumerate(commits, start=1):
577
+ lines.append(f"{index}. Heading: {commit.heading}")
578
+ if commit.description:
579
+ lines.append("Description:")
580
+ lines.append(commit.description)
581
+ else:
582
+ lines.append("Description: (none)")
583
+ lines.append("")
584
+
585
+ return "\n".join(lines).strip()
586
+
587
+
588
+def extract_openrouter_message_content(payload: object) -> str:
589
+ if not isinstance(payload, dict):
590
+ return ""
591
+
592
+ content = payload.get("content")
593
+ if isinstance(content, str):
594
+ return content
595
+ if not isinstance(content, list):
596
+ return ""
597
+
598
+ parts: list[str] = []
599
+ for part in content:
600
+ if not isinstance(part, dict):
601
+ continue
602
+ text = part.get("text")
603
+ if isinstance(text, str):
604
+ parts.append(text)
605
+ return "\n".join(parts)
606
+
607
+
608
+def generate_release_body_with_openrouter(commits: list[CommitEntry]) -> str:
609
+ api_key = require_env("OPENROUTER_API_KEY")
610
+ model = require_env("OPENROUTER_MODEL_NAME")
611
+ system_prompt = load_text(OPENROUTER_SYSTEM_PROMPT_PATH)
612
+ repository = require_env("GITHUB_REPOSITORY")
613
+ user_message = build_release_notes_user_message(commits)
614
+
615
+ payload = {
616
+ "model": model,
617
+ "messages": [
618
+ {"role": "system", "content": system_prompt},
619
+ {"role": "user", "content": user_message},
620
+ ],
621
+ "temperature": 0.2,
622
+ }
623
+ request = Request(
624
+ OPENROUTER_CHAT_COMPLETIONS_URL,
625
+ data=json.dumps(payload).encode("utf-8"),
626
+ headers={
627
+ "Authorization": f"Bearer {api_key}",
628
+ "Content-Type": "application/json",
629
+ "HTTP-Referer": f"https://github.com/{repository}",
630
+ "X-OpenRouter-Title": "Agent Zero Docker Release Notes",
631
+ },
632
+ method="POST",
633
+ )
634
+
635
+ try:
636
+ with urlopen(request, timeout=60) as response:
637
+ response_payload = json.loads(response.read().decode("utf-8"))
638
+ except HTTPError as exc:
639
+ details = exc.read().decode("utf-8", errors="replace").strip()
640
+ fail(f"OpenRouter request failed: {exc.code} {exc.reason}\n{details}")
641
+ except URLError as exc:
642
+ fail(f"OpenRouter request failed: {exc.reason}")
643
+
644
+ if not isinstance(response_payload, dict):
645
+ fail("OpenRouter response was not a JSON object.")
646
+
647
+ choices = response_payload.get("choices")
648
+ if not isinstance(choices, list) or not choices:
649
+ fail(f"OpenRouter response did not include choices: {json.dumps(response_payload)}")
650
+
651
+ first_choice = choices[0]
652
+ if not isinstance(first_choice, dict):
653
+ fail("OpenRouter returned an invalid choice payload.")
654
+
655
+ message = first_choice.get("message")
656
+ body = extract_openrouter_message_content(message).strip()
657
+ return body or "No release notes."
658
+
659
+
660
def resolve_release_command() -> None:
661
config = load_config()
662
branch = os.environ["TARGET_BRANCH"].strip()
663
source_tag = os.environ["TARGET_TAG"].strip()
423
- notes_dir = os.environ["RELEASE_NOTES_DIR"].strip()
664
665
if branch != config.main_branch:
666
write_output("should_release", "false")
@@ -452,20 +692,16 @@ def resolve_release_command() -> None:
692
)
693
return
694
455
- notes_path = os.path.join(notes_dir, f"{source_tag}.md")
456
- if not os.path.exists(notes_path):
457
- fail(
458
- f"Expected release notes file `{notes_path}` for GitHub release `{source_tag}`."
459
- )
460
-
461
- with open(notes_path, "r", encoding="utf-8") as handle:
462
- body = handle.read().strip()
695
+ previous_release_tag = previous_published_release_tag(config, source_tag)
696
+ commits = collect_release_commits(previous_release_tag, source_tag)
697
+ body = generate_release_body_with_openrouter(commits)
698
699
write_output("should_release", "true")
700
write_output("release_tag", source_tag)
701
write_output("release_name", source_tag)
467
- write_output("release_notes_path", notes_path)
468
- write_output("release_body", body or "No release notes.")
702
+ write_output("previous_release_tag", previous_release_tag or "")
703
+ write_output("release_commit_count", str(len(commits)))
704
+ write_output("release_body", body)
705
print(source_tag)
706
707
.github/workflows/docker-publish.yml
+2
-2
@@ -22,7 +22,6 @@ env:
22
RELEASE_TAG_REGEX: "^v([0-9]+)\\.([0-9]+)$"
23
MIN_RELEASE_MAJOR: "1"
24
MIN_RELEASE_MINOR: "0"
25
- RELEASE_NOTES_DIR: "docs/release_notes"
25
DOCKERFILE_DIR: "docker/run"
26
DOCKERFILE_PATH: "docker/run/Dockerfile"
27
DOCKER_IMAGE_NAME: "agent-zero"
@@ -174,7 +173,8 @@ jobs:
173
DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
174
TARGET_BRANCH: ${{ matrix.branch }}
175
TARGET_TAG: ${{ matrix.source_tag }}
177
- RELEASE_NOTES_DIR: ${{ env.RELEASE_NOTES_DIR }}
176
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
177
+ OPENROUTER_MODEL_NAME: ${{ vars.OPENROUTER_MODEL_NAME }}
178
run: python3 .github/scripts/docker_release_plan.py resolve-release
179
180
- name: Skip GitHub release
AGENTS.md
+7
-7
@@ -103,7 +103,7 @@ Key Files:
103
- helpers/plugins.py: Plugin discovery and configuration logic.
104
- webui/js/AlpineStore.js: Store factory for reactive frontend state.
105
- helpers/api.py: Base class for all API endpoints.
106
-- docs/release_notes/: Markdown files used by the release workflow to populate GitHub releases for the latest `main` tag.
106
+- scripts/openrouter_release_notes_system_prompt.md: Editable system prompt used to generate GitHub release notes during Docker publishing.
107
- knowledge/main/about/: Agent self-knowledge files, indexed into the vector DB for runtime recall. Not user-facing docs - written for the agent's internal reference.
108
- docs/agents/AGENTS.components.md: Deep dive into the frontend component architecture.
109
- docs/agents/AGENTS.modals.md: Guide to the stacked modal system.
@@ -149,10 +149,10 @@ Key Files:
149
- Docker publishing automation lives in `.github/workflows/docker-publish.yml`.
150
- Releasable tags follow `v{X}.{Y}` and only tags `>= v1.0` are considered by the workflow.
151
- The latest eligible tag on `main` also creates or updates a GitHub release after the Docker image push succeeds.
152
-- Release notes live in `docs/release_notes/<tag>.md`.
153
-- When asked to prepare release notes, compare the repo changes against the previous release notes tag in `docs/release_notes/` and write a concise Markdown summary of the meaningful changes since that release.
152
+- GitHub release notes are generated on the fly in `.github/scripts/docker_release_plan.py` by comparing the new tag against the previous published GitHub release tag, collecting commit subjects and descriptions in that range, and sending them to OpenRouter.
153
+- The OpenRouter call uses `OPENROUTER_API_KEY` and `OPENROUTER_MODEL_NAME` from the workflow environment, with the system prompt stored in `scripts/openrouter_release_notes_system_prompt.md`.
154
- Prioritize user-visible features, important fixes, infra or packaging changes, and breaking notes. Skip low-signal churn.
155
-- If no notes are needed, an empty `docs/release_notes/<tag>.md` is valid and publishes `No release notes.`
155
+- If the generated summary has no meaningful content, the release body falls back to `No release notes.`
156
157
### Lifecycle Synchronization
158
| Action | Backend Extension | Frontend Lifecycle |
@@ -230,9 +230,9 @@ class MyTool(Tool):
230
231
## Release Notes
232
233
-- Store release notes in `docs/release_notes/` as `vX.Y.md`.
234
-- Keep them concise and summarize changes since the previous release notes tag.
235
-- The latest eligible `main` tag uses that file for the GitHub release body after Docker publish succeeds.
233
+- The latest eligible `main` tag generates its GitHub release notes during Docker publish instead of reading committed Markdown files.
234
+- The release-note prompt is editable in `scripts/openrouter_release_notes_system_prompt.md`.
235
+- The commit range starts at the previous published GitHub release tag, not merely the previous semantic tag in the repository.
236
237
## Troubleshooting
238
README.md
+1
-2
@@ -170,12 +170,11 @@ docker run -p 50001:80 agent0ai/agent-zero
170
| [Architecture](./docs/developer/architecture.md) | System design and components |
171
| [Contributing](./docs/guides/contribution.md) | How to contribute |
172
| [Troubleshooting](./docs/guides/troubleshooting.md) | Common issues and their solutions |
173
-| [Release Notes](./docs/release_notes/README.md) | Release note format used by the automated Docker and GitHub release workflow |
173
174
175
## 🎯 Changelog
176
178
-New release-note files for current releases live in [docs/release_notes](./docs/release_notes/README.md). The latest eligible `main` tag uses `docs/release_notes/vX.Y.md` for the GitHub release body.
177
+GitHub release notes for the latest eligible `main` tag are generated during `.github/workflows/docker-publish.yml` from commit subjects and descriptions since the previous published release, using OpenRouter and the editable prompt in `scripts/openrouter_release_notes_system_prompt.md`.
178
179
### v0.9.8 - Skills, UI Redesign & Git projects
180
[Release video](https://youtu.be/NV7s78yn6DY)
docs/README.md
-1
@@ -30,7 +30,6 @@ Welcome to the Agent Zero documentation hub. Whether you're getting started or d
30
- **[Notifications](developer/notifications.md):** Notification system architecture and setup.
31
- **[Contributing Skills](developer/contributing-skills.md):** Create and share agent skills.
32
- **[Contributing Guide](guides/contribution.md):** Contribute to the Agent Zero project.
33
-- **[Release Notes](release_notes/README.md):** File format and process used by the automated Docker and GitHub release workflow.
33
34
## Community & Support
35
docs/release_notes/README.md
deleted
-10
@@ -1,10 +0,0 @@
1
-# Release Notes
2
-
3
-Create one file per release tag in this folder using the exact name `vX.Y.md`, for example `v2.33.md`.
4
-
5
-Rules:
6
-- The automated Docker publish workflow reads `docs/release_notes/<tag>.md` when the current latest `main` release tag is built successfully.
7
-- Keep the notes concise and release-ready. Summarize the meaningful changes since the previous release notes tag in this folder.
8
-- Prefer user-facing features, major fixes, notable infrastructure or packaging changes, and breaking or migration notes. Skip low-signal internal churn.
9
-- Use normal Markdown. A short heading plus a flat bullet list is enough.
10
-- If you intentionally want a release with no notes, leave the file empty and the workflow will publish `No release notes.`
docs/release_notes/v1.0.md
deleted
-14
@@ -1,14 +0,0 @@
1
-# v1.1
2
-
3
-Covers changes from the `v0.9.8` series through the current `v1.5` state, including the `v0.9.9` work that landed along the way.
4
-
5
-- Agent Zero now has a real plugin platform: developers can build and ship functionality independently of the core, with a large refactor that makes more of the framework extensible through plugins, hooks, and user-side development instead of core-only changes.
6
-- The new Plugin Hub brings an app-store-like workflow for discovery, installation, configuration, validation, scanning, README viewing, thumbnails, scoped toggles, and safer plugin management.
7
-- Core extensibility was expanded across prompts, tools, secrets, APIs, model providers, lifecycle hooks, file watching, and other internal systems, making custom integrations and deeper framework extensions much easier to build and maintain.
8
-- New communication integrations: built-in Email and Telegram plugins with config UIs, attachment handling, dispatcher and model routing, notifications, group-chat support, and a long round of reliability fixes.
9
-- Model and prompt architecture upgrades: model presets and search, per-chat model switching, browser-agent model simplification, OpenRouter and provider fixes, the PromptInclude plugin, and related configuration improvements.
10
-- Self-update and release-flow improvements: native Git-based self-update, stronger tag validation and remote tag fetching, better disconnect handling in the update UI, and the simplified release tag format from `vX.Y.Z` to `vX.Y`.
11
-- UI and chat improvements: chat-branching fixes, expanded `ALL`-mode responses, taller chat input, sidebar and header cleanup, quick-action redesign, plugin and settings modal polish, and more stable WebSocket and frontend cache behavior.
12
-- Security and reliability hardening: CSRF fixes for HTTPS and Chromium, dependency security pins, secret masking in code-execution output, safer plugin warnings, cross-device file move fixes, improved email whitelist handling, and cleaner retry and exception handling.
13
-- Core framework refactors: broad Python, API, and helper cleanup, extension-system rework, routing and caching improvements, generic tool-output update hooks, and extraction of more capabilities into built-in plugins.
14
-- Ongoing polish across the project: welcome-banner and default-preset cleanup, browse and extract improvements, built-in plugin branding and docs refreshes, project-doc fixes, and a fix for `skills_tool` loading when `loaded_skills` is uninitialized.
docs/release_notes/v1.1.md
deleted
-14
@@ -1,14 +0,0 @@
1
-# v1.1
2
-
3
-Covers changes from the `v0.9.8` series through the current `v1.5` state, including the `v0.9.9` work that landed along the way.
4
-
5
-- Agent Zero now has a real plugin platform: developers can build and ship functionality independently of the core, with a large refactor that makes more of the framework extensible through plugins, hooks, and user-side development instead of core-only changes.
6
-- The new Plugin Hub brings an app-store-like workflow for discovery, installation, configuration, validation, scanning, README viewing, thumbnails, scoped toggles, and safer plugin management.
7
-- Core extensibility was expanded across prompts, tools, secrets, APIs, model providers, lifecycle hooks, file watching, and other internal systems, making custom integrations and deeper framework extensions much easier to build and maintain.
8
-- New communication integrations: built-in Email and Telegram plugins with config UIs, attachment handling, dispatcher and model routing, notifications, group-chat support, and a long round of reliability fixes.
9
-- Model and prompt architecture upgrades: model presets and search, per-chat model switching, browser-agent model simplification, OpenRouter and provider fixes, the PromptInclude plugin, and related configuration improvements.
10
-- Self-update and release-flow improvements: native Git-based self-update, stronger tag validation and remote tag fetching, better disconnect handling in the update UI, and the simplified release tag format from `vX.Y.Z` to `vX.Y`.
11
-- UI and chat improvements: chat-branching fixes, expanded `ALL`-mode responses, taller chat input, sidebar and header cleanup, quick-action redesign, plugin and settings modal polish, and more stable WebSocket and frontend cache behavior.
12
-- Security and reliability hardening: CSRF fixes for HTTPS and Chromium, dependency security pins, secret masking in code-execution output, safer plugin warnings, cross-device file move fixes, improved email whitelist handling, and cleaner retry and exception handling.
13
-- Core framework refactors: broad Python, API, and helper cleanup, extension-system rework, routing and caching improvements, generic tool-output update hooks, and extraction of more capabilities into built-in plugins.
14
-- Ongoing polish across the project: welcome-banner and default-preset cleanup, browse and extract improvements, built-in plugin branding and docs refreshes, project-doc fixes, and a fix for `skills_tool` loading when `loaded_skills` is uninitialized.
docs/setup/dev-setup.md
+1
-1
@@ -174,4 +174,4 @@ These environment variables automatically override the hardcoded defaults in `ge
174
- Navigate to your project root in the terminal and run `docker build -f DockerfileLocal -t agent-zero-local --build-arg CACHE_DATE=$(date +%Y-%m-%d:%H:%M:%S) .`
175
- The `CACHE_DATE` argument is optional, it is used to cache most of the build process and only rebuild the last steps when the files or dependencies change.
176
- See `docker/run/build.txt` for more build command examples.
177
-- Automated Docker Hub publishing for release tags is handled by `.github/workflows/docker-publish.yml`. Latest `main` releases also read `docs/release_notes/vX.Y.md` to create the GitHub release body.
177
+- Automated Docker Hub publishing for release tags is handled by `.github/workflows/docker-publish.yml`. The latest eligible `main` tag generates its GitHub release body on the fly from commit subjects and descriptions via OpenRouter.
scripts/openrouter_release_notes_system_prompt.md
new
+17
@@ -0,0 +1,17 @@
1
+You write GitHub release notes for Agent Zero.
2
+
3
+Produce release-ready Markdown only. Do not add preambles, explanations, code fences, or commentary about the prompt.
4
+
5
+Requirements:
6
+- Base the release notes only on the commit headings and descriptions provided by the user.
7
+- Prefer the most meaningful user-facing changes, important fixes, notable infrastructure or packaging changes, and any clearly stated breaking changes.
8
+- Skip low-signal churn, duplicate points, and purely procedural wording unless it materially affects users or operators.
9
+- Group related items when that improves readability, but keep the output concise.
10
+- Do not invent features, bug fixes, migrations, or breaking notes that are not supported by the commits.
11
+- Do not mention commit hashes, pull request numbers, authors, files, or internal implementation trivia unless the commit text makes them essential to understanding the release.
12
+- If the commit list does not justify any meaningful notes, return exactly: `No release notes.`
13
+
14
+Preferred format:
15
+- A short introductory line or heading is allowed but optional.
16
+- Then use a flat bullet list of the key release points.
17
+- Add a short `Breaking changes` section only when the commit content clearly warrants it.