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
16 changes: 16 additions & 0 deletions changelog/unreleased/release-scoped-changelog-entry-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Release-scoped changelog entry IDs
type: bugfix
authors:
- mavam
- codex
prs:
- 39
created: 2026-07-21T11:07:32.400232Z
---

Release creation now treats changelog entry IDs as unique within each release
instead of across the entire project history. Reusing a slug from an older
release remains eligible for automatic version bumps, validation checks
release-local references, and show output preserves every occurrence with its
release context.
1 change: 1 addition & 0 deletions schemas/release-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"entries": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "\\S",
Expand Down
8 changes: 5 additions & 3 deletions src/tenzir_ship/cli/_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from datetime import date
from pathlib import Path
from typing import Optional

from ..config import Config
Expand Down Expand Up @@ -42,6 +43,7 @@ def _entry_to_dict(

data = {
"id": entry.entry_id,
"release": entry.release,
"title": title,
"type": entry_type,
"created": entry.created_at.isoformat() if entry.created_at else None,
Expand Down Expand Up @@ -104,7 +106,7 @@ def _render_markdown_release_block(
manifest: ReleaseManifest | None,
entries: list[Entry],
config: Config,
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
*,
include_emoji: bool = True,
explicit_links: bool = False,
Expand Down Expand Up @@ -188,7 +190,7 @@ def _export_markdown_release(
manifest: Optional[ReleaseManifest],
entries: list[Entry],
config: Config,
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
*,
include_emoji: bool = True,
explicit_links: bool = False,
Expand Down Expand Up @@ -237,7 +239,7 @@ def _export_markdown_compact(
manifest: Optional[ReleaseManifest],
entries: list[Entry],
config: Config,
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
*,
include_emoji: bool = True,
explicit_links: bool = False,
Expand Down
12 changes: 12 additions & 0 deletions src/tenzir_ship/cli/_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,18 @@ def _combine_release_entries(
existing_entries = _load_manifest_entries(project_root, existing_manifest)
existing_entry_ids = {entry.entry_id for entry in existing_entries}

colliding_entry_ids = sorted(
{entry.entry_id for entry in selected_entries} & existing_entry_ids
)
if colliding_entry_ids:
target = render_release_tag(existing_manifest.version) if existing_manifest else "release"
quoted_ids = ", ".join(f"'{entry_id}'" for entry_id in colliding_entry_ids)
raise click.ClickException(
f"Cannot add entries to {target} because that release already contains "
f"the same entry ID(s): {quoted_ids}. Entry IDs must be unique within "
"a release. Rename the unreleased entry or choose a different target release."
)

new_entries = [entry for entry in selected_entries if entry.entry_id not in existing_entry_ids]
combined_entries: dict[str, Entry] = {entry.entry_id: entry for entry in existing_entries}
for entry in new_entries:
Expand Down
24 changes: 12 additions & 12 deletions src/tenzir_ship/cli/_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,15 @@ def _build_release_sort_order(project_root: Path) -> dict[str, int]:

def _sort_entries_for_display(
entries: Iterable[Entry],
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
release_order: dict[str, int],
) -> list[Entry]:
"""Sort entries so the newest entry ends up last in the table view."""
entries_list = list(entries)
unreleased_rank = len(release_order) + 1

def sort_key(entry: Entry) -> tuple[int, datetime, str]:
versions = release_index.get(entry.entry_id) or []
versions = release_index.get(entry.path) or []
if versions:
ranks = [release_order.get(version, unreleased_rank) for version in versions]
release_rank = min(ranks)
Expand All @@ -353,25 +353,25 @@ def sort_key(entry: Entry) -> tuple[int, datetime, str]:

def _entry_release_group(
entry: Entry,
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
release_order: dict[str, int],
) -> int:
"""Return a group key for sectioning entries by release."""
versions = release_index.get(entry.entry_id, [])
versions = release_index.get(entry.path, [])
if not versions:
return -1 # unreleased
return max(release_order.get(v, 0) for v in versions)


def _render_entries(
entries: Iterable[Entry],
release_index: dict[str, list[str]],
release_index: dict[Path, list[str]],
config: Config,
show_banner: bool = False,
release_order: dict[str, int] | None = None,
*,
include_emoji: bool = True,
release_versions: dict[str, str] | None = None,
release_versions: dict[Path, str] | None = None,
) -> None:
if show_banner:
_render_project_header(config)
Expand Down Expand Up @@ -488,7 +488,7 @@ def _render_entries(
metadata = entry.metadata
created_display = entry.created_date.isoformat() if entry.created_date else "β€”"
type_value = metadata.get("type", "change")
versions = release_index.get(entry.entry_id)
versions = release_index.get(entry.path)
version_display = ", ".join(versions) if versions else "β€”"
if include_emoji:
glyph = ENTRY_TYPE_EMOJIS.get(type_value, "β€’")
Expand All @@ -507,7 +507,7 @@ def _render_entries(
if "version" in visible_columns:
row.append(version_display)
if "release" in visible_columns:
release_display = release_versions.get(entry.entry_id, "β€”") if release_versions else "β€”"
release_display = release_versions.get(entry.path, "β€”") if release_versions else "β€”"
row.append(release_display)
if "prs" in visible_columns:
pr_numbers = _parse_pr_numbers(metadata)
Expand All @@ -532,9 +532,9 @@ def _render_entries(
end_section = False
if release_versions is not None and index < len(sorted_entries) - 1:
# Section dividers based on release_versions mapping
current_release = release_versions.get(entry.entry_id)
current_release = release_versions.get(entry.path)
next_entry = sorted_entries[index + 1]
next_release = release_versions.get(next_entry.entry_id)
next_release = release_versions.get(next_entry.path)
if current_release != next_release:
end_section = True
elif release_order is not None and index < len(sorted_entries) - 1:
Expand Down Expand Up @@ -728,7 +728,7 @@ def _render_entries_multi_project(
project_order = {config.id: index for index, (_, config) in enumerate(projects)}

# Build release index for each project
release_indices: dict[str, dict[str, list[str]]] = {}
release_indices: dict[str, dict[Path, list[str]]] = {}
for project_root, config in projects:
release_indices[config.id] = build_entry_release_index(project_root, project=config.id)

Expand Down Expand Up @@ -782,7 +782,7 @@ def sort_key(multi: MultiProjectEntry) -> tuple[int, float, str]:
type_value = metadata.get("type", "change")
# Get version from project-specific release index
proj_release_index = release_indices.get(project_id, {})
versions = proj_release_index.get(entry.entry_id)
versions = proj_release_index.get(entry.path)
version_display = ", ".join(versions) if versions else "β€”"
if include_emoji:
glyph = ENTRY_TYPE_EMOJIS.get(type_value, "β€’")
Expand Down
Loading
Loading