From 90293c9ee88ad847cff83ab01003e06680ca984b Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Tue, 21 Jul 2026 13:07:01 +0200 Subject: [PATCH 1/7] Scope changelog entry IDs to releases Preserve each historical entry occurrence and resolve release manifests against their own entry directories. Reused IDs remain eligible for release creation and retain release context in show output and statistics. Assisted-by: GPT-5 (Codex) --- schemas/release-manifest.schema.json | 1 + .../references/add-changelog-entry.md | 16 ++ src/tenzir_ship/cli/_export.py | 8 +- src/tenzir_ship/cli/_release.py | 12 ++ src/tenzir_ship/cli/_rendering.py | 24 +-- src/tenzir_ship/cli/_show.py | 160 ++++++++++----- src/tenzir_ship/cli/_stats.py | 2 +- src/tenzir_ship/entries.py | 13 +- src/tenzir_ship/releases.py | 27 +-- src/tenzir_ship/validate.py | 6 +- tests/test_cli.py | 182 ++++++++++++++++++ tests/test_modules.py | 24 +++ 12 files changed, 381 insertions(+), 94 deletions(-) diff --git a/schemas/release-manifest.schema.json b/schemas/release-manifest.schema.json index 260c3d3..cb1c91b 100644 --- a/schemas/release-manifest.schema.json +++ b/schemas/release-manifest.schema.json @@ -25,6 +25,7 @@ }, "entries": { "type": "array", + "uniqueItems": true, "items": { "type": "string", "pattern": "\\S", diff --git a/skills/tenzir-ship/references/add-changelog-entry.md b/skills/tenzir-ship/references/add-changelog-entry.md index 1644547..0d09562 100644 --- a/skills/tenzir-ship/references/add-changelog-entry.md +++ b/skills/tenzir-ship/references/add-changelog-entry.md @@ -220,6 +220,22 @@ When merging into an unreleased entry: Never touch already-released changelog entries outside the `unreleased` directory. +## Treat entry IDs as release-scoped + +An entry ID is the Markdown filename without its extension. It must be unique +within `unreleased/` and within each individual `releases//entries/` +directory, but it does not need to be unique across the project's complete +release history. + +Do not rename a new entry merely because an older release contains the same +slug. A later change with the same user-facing title may legitimately reuse +that slug. The `add` command rejects a collision in the current `unreleased/` +directory, and `validate` checks every release manifest against its own entry +directory. + +When an exact `show ` lookup finds the ID in multiple namespaces, it +returns every occurrence with its release or unreleased context. + ## Create the entry Begin with writing the description to a temporary file, e.g., diff --git a/src/tenzir_ship/cli/_export.py b/src/tenzir_ship/cli/_export.py index c1f5063..985fff8 100644 --- a/src/tenzir_ship/cli/_export.py +++ b/src/tenzir_ship/cli/_export.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date +from pathlib import Path from typing import Optional from ..config import Config @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 6f08c3e..69ff48f 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -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: diff --git a/src/tenzir_ship/cli/_rendering.py b/src/tenzir_ship/cli/_rendering.py index d458817..b6704fe 100644 --- a/src/tenzir_ship/cli/_rendering.py +++ b/src/tenzir_ship/cli/_rendering.py @@ -330,7 +330,7 @@ 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.""" @@ -338,7 +338,7 @@ def _sort_entries_for_display( 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) @@ -353,11 +353,11 @@ 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) @@ -365,13 +365,13 @@ def _entry_release_group( 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) @@ -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, "•") @@ -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) @@ -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: @@ -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) @@ -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, "•") diff --git a/src/tenzir_ship/cli/_show.py b/src/tenzir_ship/cli/_show.py index 69c0345..1dda458 100644 --- a/src/tenzir_ship/cli/_show.py +++ b/src/tenzir_ship/cli/_show.py @@ -39,8 +39,6 @@ load_release_entry, parse_release_version, render_release_tag, - unused_entries, - used_entry_ids, ) from ..utils import ( emit_output, @@ -170,11 +168,27 @@ def _collect_unused_entries_for_release( *, include_prereleases: bool = False, ) -> list[Entry]: - """Collect unreleased entries that haven't been included in matching releases.""" - all_entries = list(iter_entries(project_root)) - used = used_entry_ids(project_root, include_prereleases=include_prereleases) - unused = unused_entries(all_entries, used) - filtered = [entry for entry in unused if entry.project is None or entry.project == config.id] + """Collect entries from the current unreleased namespace. + + Entry IDs may be reused in later releases, so historical manifests do not + affect this selection. When editing an existing stable release, callers can + request that entries already owned by a release-candidate lineage remain + outside the older target release. + """ + entries = list(iter_entries(project_root)) + if include_prereleases: + # Editing an older stable release must not pull in entries owned by an + # active release-candidate cycle. This is the one intentional + # cross-manifest relationship: candidates and their eventual stable + # release form a single release lineage. + prerelease_entry_ids = { + entry_id + for manifest in iter_release_manifests(project_root) + if is_release_candidate(manifest.version) + for entry_id in manifest.entries + } + entries = [entry for entry in entries if entry.entry_id not in prerelease_entry_ids] + filtered = [entry for entry in entries if entry.project is None or entry.project == config.id] return filtered @@ -337,7 +351,7 @@ def _append_release_module_sections( def _load_release_entries_for_display( project_root: Path, release_version: str, - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], ) -> tuple[ReleaseManifest, list[Entry]]: """Load entries for a specific release version.""" normalized_version = render_release_tag(release_version).lower() @@ -356,7 +370,9 @@ def _load_release_entries_for_display( if entry is None: missing_entries.append(entry_id) continue - entry_map[entry_id] = entry + occurrences = entry_map.setdefault(entry_id, []) + if all(existing.path != entry.path for existing in occurrences): + occurrences.append(entry) release_entries.append(entry) if missing_entries: missing_list = ", ".join(sorted(missing_entries)) @@ -372,7 +388,7 @@ def _resolve_identifier( project_root: Path, config: Config, sorted_entries: list[Entry], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], known_versions: dict[str, str], allowed_kinds: Optional[Iterable[IdentifierKind]] = None, ) -> IdentifierResolution: @@ -428,25 +444,24 @@ def _resolve_identifier( manifest=manifest, ) - exact_match = entry_map.get(token) - if exact_match: - return IdentifierResolution(kind="entry", entries=[exact_match], identifier=token) + exact_matches = entry_map.get(token) + if exact_matches: + return IdentifierResolution(kind="entry", entries=exact_matches, identifier=token) - matches = [(entry_id, entry) for entry_id, entry in entry_map.items() if token in entry_id] - if not matches: + matching_ids = [entry_id for entry_id in entry_map if token in entry_id] + if not matching_ids: raise click.ClickException( f"No entry found matching '{token}'. Use 'tenzir-ship show' to see all entries." ) - if len(matches) > 1: - match_ids = [entry_id for entry_id, _ in matches] + if len(matching_ids) > 1: raise click.ClickException( f"Multiple entries match '{token}':\n " - + "\n ".join(match_ids) + + "\n ".join(matching_ids) + "\n\nPlease be more specific or use a row number." ) - entry_id, entry = matches[0] - return IdentifierResolution(kind="entry", entries=[entry], identifier=entry_id) + entry_id = matching_ids[0] + return IdentifierResolution(kind="entry", entries=entry_map[entry_id], identifier=entry_id) def _resolve_identifiers_sequence( @@ -455,7 +470,7 @@ def _resolve_identifiers_sequence( project_root: Path, config: Config, sorted_entries: list[Entry], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], known_versions: dict[str, str], allowed_kinds: Optional[Iterable[IdentifierKind]] = None, ) -> list[IdentifierResolution]: @@ -477,13 +492,15 @@ def _resolve_identifiers_sequence( def _gather_entry_context( project_root: Path, modules: list[Module] | None = None, -) -> tuple[dict[str, Entry], dict[str, list[str]], dict[str, int], list[Entry]]: +) -> tuple[dict[str, list[Entry]], dict[Path, list[str]], dict[str, int], list[Entry]]: """Gather all entries and build release index for display.""" entries = list(iter_entries(project_root)) - entry_map = {entry.entry_id: entry for entry in entries} + entry_map: dict[str, list[Entry]] = {} + for entry in entries: + entry_map.setdefault(entry.entry_id, []).append(entry) released_entries = collect_release_entries(project_root) - for entry_id, entry in released_entries.items(): - entry_map.setdefault(entry_id, entry) + for entry in released_entries: + entry_map.setdefault(entry.entry_id, []).append(entry) release_index_all = build_entry_release_index(project_root, project=None) release_order = _build_release_sort_order(project_root) @@ -491,10 +508,10 @@ def _gather_entry_context( for module in modules: module_entries = list(iter_entries(module.root)) for entry in module_entries: - entry_map.setdefault(entry.entry_id, entry) + entry_map.setdefault(entry.entry_id, []).append(entry) module_released = collect_release_entries(module.root) - for entry_id, entry in module_released.items(): - entry_map.setdefault(entry_id, entry) + for entry in module_released: + entry_map.setdefault(entry.entry_id, []).append(entry) module_release_index = build_entry_release_index(module.root, project=None) for entry_id, versions in module_release_index.items(): if entry_id in release_index_all: @@ -505,15 +522,34 @@ def _gather_entry_context( for version, order in module_release_order.items(): release_order.setdefault(version, order) - sorted_entries = _sort_entries_for_display(entry_map.values(), release_index_all, release_order) + all_entries = [entry for occurrences in entry_map.values() for entry in occurrences] + sorted_entries = _sort_entries_for_display(all_entries, release_index_all, release_order) return entry_map, release_index_all, release_order, sorted_entries +def _group_entries_by_release_context( + project_root: Path, + entries: Iterable[Entry], +) -> list[tuple[ReleaseManifest | None, list[Entry]]]: + """Group entry occurrences by their release namespace.""" + manifests_by_tag = { + render_release_tag(manifest.version): manifest + for manifest in iter_release_manifests(project_root) + } + grouped: dict[str | None, list[Entry]] = {} + for entry in entries: + grouped.setdefault(entry.release, []).append(entry) + return [ + (manifests_by_tag.get(release) if release else None, grouped_entries) + for release, grouped_entries in grouped.items() + ] + + def _render_release_card( manifest: ReleaseManifest | None, entries: list[Entry], config: Config, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], *, include_emoji: bool, compact: bool = True, @@ -618,7 +654,7 @@ def _show_entries_table_all( all_entries: list[Entry] if release_mode: # Build unified table with Release column (oldest release first) - release_versions: dict[str, str] = {} + release_versions: dict[Path, str] = {} all_entries = [] # Add released entries grouped by release (oldest first) @@ -631,12 +667,12 @@ def _show_entries_table_all( release_entries.append(entry) filtered = _filter_entries_by_component(release_entries, components) for entry in filtered: - release_versions[entry.entry_id] = render_release_tag(manifest.version) + release_versions[entry.path] = render_release_tag(manifest.version) all_entries.append(entry) # Add unreleased entries last (they are the "newest" release) for entry in unreleased_entries: - release_versions[entry.entry_id] = UNRELEASED_LABEL + release_versions[entry.path] = UNRELEASED_LABEL all_entries.append(entry) # Sort entries by release first, then by date (oldest first) @@ -681,7 +717,7 @@ def _show_entries_table_release_mode( *, components: set[str], include_emoji: bool, - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], ) -> None: @@ -708,7 +744,7 @@ def _show_entries_table_release_mode( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for release_manifest in iter_release_manifests(project_root): @@ -739,12 +775,12 @@ def _show_entries_table_release_mode( raise click.ClickException("No entries found for the given identifiers.") # Build release_versions mapping and flat entry list (preserving release group order) - release_versions: dict[str, str] = {} + release_versions: dict[Path, str] = {} all_entries: list[Entry] = [] for manifest, entries in release_groups: version_label = render_release_tag(manifest.version) if manifest else UNRELEASED_LABEL for entry in entries: - release_versions[entry.entry_id] = version_label + release_versions[entry.path] = version_label all_entries.append(entry) if not all_entries: @@ -817,16 +853,18 @@ def filtered_with_modules( components = _normalize_component_filters(component_filter, config) entries = list(iter_entries(project_root)) - entry_map = {entry.entry_id: entry for entry in entries} + entry_map: dict[str, list[Entry]] = {} + for entry in entries: + entry_map.setdefault(entry.entry_id, []).append(entry) released_entries = collect_release_entries(project_root) - for entry_id, entry in released_entries.items(): - if entry_id not in entry_map: - entry_map[entry_id] = entry + for entry in released_entries: + entry_map.setdefault(entry.entry_id, []).append(entry) release_index = build_entry_release_index(project_root, project=config.id) release_order = _build_release_sort_order(project_root) - sorted_entries = _sort_entries_for_display(entry_map.values(), release_index, release_order) + all_entries = [entry for occurrences in entry_map.values() for entry in occurrences] + sorted_entries = _sort_entries_for_display(all_entries, release_index, release_order) # Handle scope-based filtering (no specific identifiers provided) if not identifiers: @@ -971,7 +1009,7 @@ def _show_entries_card( raise click.ClickException("No entries found.") for entry in all_entries: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) _render_single_entry(entry, versions, include_emoji=include_emoji) return @@ -998,7 +1036,7 @@ def _show_entries_card( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index_all.get(entry.entry_id, []) + versions = release_index_all.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for release_manifest in iter_release_manifests(project_root): @@ -1070,8 +1108,6 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: sorted_multi = sorted(multi_entries, key=sort_key) sorted_entries = [item.entry for item in sorted_multi] - for item in sorted_multi: - entry_map.setdefault(item.entry.entry_id, item.entry) resolutions = _resolve_identifiers_sequence( identifiers, @@ -1089,7 +1125,7 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: if not filtered_entries: continue for entry in filtered_entries: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) if resolution.kind == "release" and resolution.manifest: version = render_release_tag(resolution.manifest.version) if version and version not in versions: @@ -1291,7 +1327,7 @@ def _show_entries_export_release_mode( include_emoji: bool, explicit_links: bool, components: set[str], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], ) -> None: @@ -1428,7 +1464,7 @@ def _show_entries_export_release_mode( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for manifest in iter_release_manifests(project_root): @@ -1546,11 +1582,10 @@ def _show_entries_export( if len(resolutions) == 1 and resolutions[0].kind == "release": manifest_for_export = resolutions[0].manifest - ordered_entries: dict[str, Entry] = {} + ordered_entries: dict[Path, Entry] = {} for resolution in resolutions: for entry in resolution.entries: - if entry.entry_id not in ordered_entries: - ordered_entries[entry.entry_id] = entry + ordered_entries.setdefault(entry.path, entry) filtered_entries = _filter_entries_by_component(ordered_entries.values(), components) export_entries = sort_entries_desc(filtered_entries) @@ -1582,6 +1617,27 @@ def _show_entries_export( ) if view == "markdown": + if ( + len(resolutions) == 1 + and resolutions[0].kind == "entry" + and len({entry.release for entry in export_entries}) > 1 + ): + blocks = [ + _render_markdown_release_block( + manifest, + entries, + config, + release_index_export, + include_emoji=include_emoji, + explicit_links=explicit_links, + compact=compact_flag, + ) + for manifest, entries in _group_entries_by_release_context( + project_root, export_entries + ) + ] + emit_output("\n---\n\n".join(blocks), newline=False) + return if compact_flag: content = _export_markdown_compact( manifest_for_export, diff --git a/src/tenzir_ship/cli/_stats.py b/src/tenzir_ship/cli/_stats.py index 069b1c6..2d15bea 100644 --- a/src/tenzir_ship/cli/_stats.py +++ b/src/tenzir_ship/cli/_stats.py @@ -126,7 +126,7 @@ def _collect_project_stats(project_root: Path) -> dict: # including them here would double-count entry totals. released_entries = collect_release_entries(project_root, include_prereleases=False) released_types: Counter[str] = Counter() - for entry in released_entries.values(): + for entry in released_entries: released_types[entry.type] += 1 shipped_count = len(released_entries) diff --git a/src/tenzir_ship/entries.py b/src/tenzir_ship/entries.py index cc668fb..85f9784 100644 --- a/src/tenzir_ship/entries.py +++ b/src/tenzir_ship/entries.py @@ -32,6 +32,7 @@ class Entry: metadata: dict[str, Any] body: str path: Path + release: Optional[str] = None @property def title(self) -> str: @@ -351,14 +352,10 @@ def iter_multi_project_entries(projects: list[tuple[Path, Any]]) -> Iterable[Mul for project_root, config in projects: project_id = getattr(config, "id", slugify(project_root.name)) project_name = getattr(config, "name", str(project_root.name)) - # Collect all entries (unreleased and released), avoiding duplicates - entry_map: dict[str, Entry] = {} - for entry in iter_entries(project_root): - entry_map[entry.entry_id] = entry - for entry_id, entry in collect_release_entries(project_root).items(): - if entry_id not in entry_map: - entry_map[entry_id] = entry - for entry in entry_map.values(): + # Entry IDs are unique within one release namespace, not across the + # project's complete history. Preserve every occurrence here. + entries = [*iter_entries(project_root), *collect_release_entries(project_root)] + for entry in entries: yield MultiProjectEntry( entry=entry, project_root=project_root, diff --git a/src/tenzir_ship/releases.py b/src/tenzir_ship/releases.py index ab1a323..6af0bd5 100644 --- a/src/tenzir_ship/releases.py +++ b/src/tenzir_ship/releases.py @@ -319,40 +319,41 @@ def load_release_entry( entry_path = resolve_release_entry_path(project_root, manifest, entry_id) if entry_path is None: return None - return read_entry(entry_path) + entry = read_entry(entry_path) + entry.release = render_release_tag(manifest.version) + return entry -def collect_release_entries( - project_root: Path, *, include_prereleases: bool = True -) -> dict[str, Entry]: - """Return a mapping of entry ids to entries across release manifests. +def collect_release_entries(project_root: Path, *, include_prereleases: bool = True) -> list[Entry]: + """Return every entry occurrence across release manifests. By default, prerelease manifests are included. Pass ``include_prereleases=False`` when only stable releases should contribute to shipped entry counts. """ - collected: dict[str, Entry] = {} + collected: list[Entry] = [] for manifest in iter_release_manifests(project_root): if not include_prereleases and not is_stable_release(manifest.version): continue for entry_id in manifest.entries: - if entry_id in collected: - continue entry = load_release_entry(project_root, manifest, entry_id) if entry is not None: - collected[entry_id] = entry + collected.append(entry) return collected def build_entry_release_index( project_root: Path, *, project: Optional[str] = None -) -> dict[str, list[str]]: - """Return a mapping from entry id to associated release versions.""" - index: dict[str, list[str]] = {} +) -> dict[Path, list[str]]: + """Return a mapping from entry occurrence paths to release versions.""" + index: dict[Path, list[str]] = {} for manifest in iter_release_manifests(project_root): version = render_release_tag(manifest.version) for entry_id in manifest.entries: - versions = index.setdefault(entry_id, []) + entry_path = resolve_release_entry_path(project_root, manifest, entry_id) + if entry_path is None: + continue + versions = index.setdefault(entry_path, []) if version not in versions: versions.append(version) diff --git a/src/tenzir_ship/validate.py b/src/tenzir_ship/validate.py index 4b1cca9..51fa919 100644 --- a/src/tenzir_ship/validate.py +++ b/src/tenzir_ship/validate.py @@ -419,17 +419,13 @@ def validate_entry(entry: Entry, config: Config) -> Iterable[ValidationIssue]: def validate_release_ids( - entries: Iterable[Entry], releases: Iterable[ReleaseManifest], project_root: Path, issues: list[ValidationIssue], ) -> None: """Ensure release manifests reference existing entry IDs.""" - entry_ids = {entry.entry_id for entry in entries} for manifest in releases: for entry_id in manifest.entries: - if entry_id in entry_ids: - continue entry_path = resolve_release_entry_path(project_root, manifest, entry_id) if entry_path is None: issues.append( @@ -467,7 +463,7 @@ def run_validation(project_root: Path, config: Config) -> list[ValidationIssue]: for entry in all_entries: issues.extend(validate_entry(entry, config)) - validate_release_ids(all_entries, releases, project_root, issues) + validate_release_ids(releases, project_root, issues) return issues diff --git a/tests/test_cli.py b/tests/test_cli.py index 65f3dfa..6ca3d0e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8929,3 +8929,185 @@ def test_show_version_case_insensitive(tmp_path: Path) -> None: ) assert markdown_result.exit_code == 0, markdown_result.output assert "Case Test Feature" in markdown_result.output + + +def test_entry_ids_are_scoped_to_each_release(tmp_path: Path) -> None: + """Historical ID reuse stays releasable, visible, valid, and countable.""" + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + + def add_compatibility_update(description: str) -> None: + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Dependency Compatibility Updates", + "--type", + "bugfix", + "--description", + description, + "--author", + "tester", + ], + ) + assert result.exit_code == 0, result.output + + add_compatibility_update("Original compatibility update.") + first_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.0", "--yes"], + ) + assert first_release.exit_code == 0, first_release.output + + # Reusing the generated slug in the current unreleased namespace is valid. + add_compatibility_update("A later compatibility update.") + validation = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + assert validation.exit_code == 0, validation.output + + # The same ID cannot be appended to the release that already owns it. + target_collision = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.0", "--yes"], + ) + assert target_collision.exit_code != 0 + assert "already contains the same entry ID" in target_collision.output + + # Auto-bumping must see the reused unreleased entry instead of treating it + # as consumed by v3.0.0. + second_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "--yes"], + ) + assert second_release.exit_code == 0, second_release.output + assert ( + project_dir / "releases" / "v3.0.1" / "entries" / "dependency-compatibility-updates.md" + ).exists() + + add_compatibility_update("An unreleased compatibility update.") + + show_json = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + "dependency-compatibility-updates", + "--json", + ], + ) + assert show_json.exit_code == 0, show_json.output + payload = json.loads(show_json.output) + assert [entry["release"] for entry in payload["entries"]] == [ + None, + "v3.0.1", + "v3.0.0", + ] + + show_markdown = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + "dependency-compatibility-updates", + "--markdown", + ], + ) + assert show_markdown.exit_code == 0, show_markdown.output + assert "# Unreleased Changes" in show_markdown.output + assert "# v3.0.0" in show_markdown.output + assert "# v3.0.1" in show_markdown.output + + show_card = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + "dependency-compatibility-updates", + "--card", + ], + ) + assert show_card.exit_code == 0, show_card.output + card_output = click.utils.strip_ansi(show_card.output) + assert card_output.count("Entry ID:") == 3 + assert "Released in v3.0.0" in card_output + assert "Released in v3.0.1" in card_output + assert "Unreleased" in card_output + + release_candidate = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.2", "--rc", "--yes"], + ) + assert release_candidate.exit_code == 0, release_candidate.output + + stats = runner.invoke(cli, ["--root", str(project_dir), "stats", "--json"]) + assert stats.exit_code == 0, stats.output + stats_payload = json.loads(stats.output) + assert stats_payload["parent"]["entries"]["shipped"] == 2 + assert stats_payload["parent"]["entries"]["unreleased"] == 1 + assert stats_payload["parent"]["entries"]["total"] == 3 + + +def test_validate_resolves_manifest_entries_within_their_release(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _bootstrap_changelog_project(project_dir) + + write_entry( + project_dir, + {"title": "Shared ID", "type": "bugfix", "created": date(2024, 1, 1)}, + "Present only in the first release.", + entry_id="shared-id", + default_project="project", + ) + first_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.0.0", "--yes"], + ) + assert first_release.exit_code == 0, first_release.output + + missing_release_dir = project_dir / "releases" / "v2.0.0" + (missing_release_dir / "entries").mkdir(parents=True) + (missing_release_dir / "manifest.yaml").write_text( + yaml.safe_dump( + {"created": "2024-02-01", "entries": ["shared-id"]}, + sort_keys=False, + ), + encoding="utf-8", + ) + + result = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + + assert result.exit_code != 0 + assert "Release references missing entry id 'shared-id'" in result.output + + +def test_validate_rejects_duplicate_ids_within_one_manifest(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _bootstrap_changelog_project(project_dir) + + release_dir = project_dir / "releases" / "v1.0.0" + entries_dir = release_dir / "entries" + entries_dir.mkdir(parents=True) + (release_dir / "manifest.yaml").write_text( + yaml.safe_dump( + {"created": "2024-01-01", "entries": ["shared-id", "shared-id"]}, + sort_keys=False, + ), + encoding="utf-8", + ) + (entries_dir / "shared-id.md").write_text( + "---\ntitle: Shared ID\ntype: bugfix\ncreated: 2024-01-01T00:00:00Z\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + + assert result.exit_code != 0 + assert "non-unique elements" in result.output diff --git a/tests/test_modules.py b/tests/test_modules.py index fa0c2db..a1eb97a 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -322,6 +322,30 @@ def test_cli_show_includes_modules_by_default(tmp_path: Path) -> None: assert "Parent Feature" in result.output +def test_cli_show_preserves_reused_entry_ids_in_modules(tmp_path: Path) -> None: + """Module history keeps one occurrence per release namespace.""" + packages = tmp_path / "packages" + mod_root = create_module(packages, "mymod", "My Module") + create_released_entry(mod_root, "Compatibility Update", "v1.0.0") + create_released_entry(mod_root, "Compatibility Update", "v1.1.0") + + project_dir = tmp_path / "changelog" + project_dir.mkdir() + write_yaml( + project_dir / "config.yaml", + {"id": "parent", "name": "Parent", "modules": "../packages/*/changelog"}, + ) + (project_dir / "unreleased").mkdir() + + runner = CliRunner() + result = runner.invoke(cli, ["--root", str(project_dir), "show"]) + + assert result.exit_code == 0, result.output + assert result.output.count("Compatibility Update") == 2 + assert "v1.0.0" in result.output + assert "v1.1.0" in result.output + + def test_cli_validate_with_modules(tmp_path: Path) -> None: """validate command checks parent and modules.""" packages = tmp_path / "packages" From f70dfb2da6dd7715e943054d6eaea8882e10885d Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Tue, 21 Jul 2026 13:07:57 +0200 Subject: [PATCH 2/7] Document release-scoped entry IDs Assisted-by: GPT-5 (Codex) --- .../release-scoped-changelog-entry-ids.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 changelog/unreleased/release-scoped-changelog-entry-ids.md diff --git a/changelog/unreleased/release-scoped-changelog-entry-ids.md b/changelog/unreleased/release-scoped-changelog-entry-ids.md new file mode 100644 index 0000000..a5d8c9e --- /dev/null +++ b/changelog/unreleased/release-scoped-changelog-entry-ids.md @@ -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. From ab1a1b988d5d39a9dfc2ac4c80300f888a2cca50 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 13:32:25 +0200 Subject: [PATCH 3/7] Limit entry filtering to active RCs Ignore retained release-candidate manifests after their stable release exists. Historical RC IDs no longer suppress reused unreleased entries when another stable release is updated. Assisted-by: GPT-5 (Codex) --- src/tenzir_ship/cli/_show.py | 11 ++++- tests/test_cli.py | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/tenzir_ship/cli/_show.py b/src/tenzir_ship/cli/_show.py index 1dda458..e020944 100644 --- a/src/tenzir_ship/cli/_show.py +++ b/src/tenzir_ship/cli/_show.py @@ -35,10 +35,12 @@ build_entry_release_index, collect_release_entries, is_release_candidate, + is_stable_release, iter_release_manifests, load_release_entry, parse_release_version, render_release_tag, + stable_release_version, ) from ..utils import ( emit_output, @@ -181,10 +183,17 @@ def _collect_unused_entries_for_release( # active release-candidate cycle. This is the one intentional # cross-manifest relationship: candidates and their eventual stable # release form a single release lineage. + manifests = list(iter_release_manifests(project_root)) + stable_versions = { + stable_release_version(manifest.version) + for manifest in manifests + if is_stable_release(manifest.version) + } prerelease_entry_ids = { entry_id - for manifest in iter_release_manifests(project_root) + for manifest in manifests if is_release_candidate(manifest.version) + and stable_release_version(manifest.version) not in stable_versions for entry_id in manifest.entries } entries = [entry for entry in entries if entry.entry_id not in prerelease_entry_ids] diff --git a/tests/test_cli.py b/tests/test_cli.py index 6ca3d0e..8c467ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8233,6 +8233,99 @@ def test_release_create_edit_existing_stable_ignores_rc_snapshots(tmp_path: Path assert "Preview Feature" not in stable_notes +def test_release_create_edit_existing_stable_ignores_closed_rc_ids(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + + shared_entry = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Shared ID", + "--type", + "bugfix", + "--description", + "Ships in the historical release.", + "--author", + "tester", + ], + ) + assert shared_entry.exit_code == 0, shared_entry.output + + historical_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.2.0", "--yes"], + ) + assert historical_release.exit_code == 0, historical_release.output + + historical_entry_path = project_dir / "releases" / "v1.2.0" / "entries" / "shared-id.md" + closed_rc_dir = project_dir / "releases" / "v1.2.0-rc.1" + (closed_rc_dir / "entries").mkdir(parents=True) + (closed_rc_dir / "manifest.yaml").write_text( + "version: v1.2.0-rc.1\ncreated: 2025-01-01\nentries:\n - shared-id\n", + encoding="utf-8", + ) + (closed_rc_dir / "entries" / "shared-id.md").write_bytes(historical_entry_path.read_bytes()) + + target_entry = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Target Entry", + "--type", + "feature", + "--description", + "Creates the release that will be updated.", + "--author", + "tester", + ], + ) + assert target_entry.exit_code == 0, target_entry.output + + target_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.3.0", "--yes"], + ) + assert target_release.exit_code == 0, target_release.output + + reused_entry = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Shared ID", + "--type", + "bugfix", + "--description", + "Reuses the ID after the historical RC cycle closed.", + "--author", + "tester", + ], + ) + assert reused_entry.exit_code == 0, reused_entry.output + + edit_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.3.0", "--yes"], + ) + assert edit_release.exit_code == 0, edit_release.output + + release_entries = { + path.stem for path in (project_dir / "releases" / "v1.3.0" / "entries").glob("*.md") + } + assert release_entries == {"shared-id", "target-entry"} + assert not (project_dir / "unreleased" / "shared-id.md").exists() + + def test_release_version_bare_flag(tmp_path: Path) -> None: """Test the release version --bare flag strips the v prefix.""" runner = CliRunner() From a46b12fdf0e31740a0ec59e6ec015c01a9c02706 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 13:39:10 +0200 Subject: [PATCH 4/7] Focus RC filtering coverage Cover the outstanding-to-closed candidate transition in the existing helper test instead of reproducing the full release workflow. Assisted-by: GPT-5 (Codex) --- tests/test_cli.py | 115 +++++++++------------------------------------- 1 file changed, 21 insertions(+), 94 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c467ae..55935e0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7139,7 +7139,7 @@ def test_release_create_ignores_closed_release_candidate_history(tmp_path: Path) assert (project_dir / "releases" / "v1.3.0-rc.2").exists() -def test_collect_unused_entries_for_release_ignores_release_candidates_by_default( +def test_collect_unused_entries_for_release_filters_only_outstanding_candidates( tmp_path: Path, ) -> None: runner = CliRunner() @@ -7175,6 +7175,26 @@ def test_collect_unused_entries_for_release_ignores_release_candidates_by_defaul ) assert [entry.entry_id for entry in unused_entries] == ["rc-feature"] + unused_entries = _collect_unused_entries_for_release( + project_dir, + load_config(project_dir / "config.yaml"), + include_prereleases=True, + ) + assert unused_entries == [] + + stable_dir = project_dir / "releases" / "v1.2.3" + stable_dir.mkdir() + (stable_dir / "manifest.yaml").write_text( + "version: v1.2.3\ncreated: 2025-01-02\nentries:\n - rc-feature\n", + encoding="utf-8", + ) + unused_entries = _collect_unused_entries_for_release( + project_dir, + load_config(project_dir / "config.yaml"), + include_prereleases=True, + ) + assert [entry.entry_id for entry in unused_entries] == ["rc-feature"] + def test_collect_unused_entries_for_release_ignores_non_stable_legacy_prereleases( tmp_path: Path, @@ -8233,99 +8253,6 @@ def test_release_create_edit_existing_stable_ignores_rc_snapshots(tmp_path: Path assert "Preview Feature" not in stable_notes -def test_release_create_edit_existing_stable_ignores_closed_rc_ids(tmp_path: Path) -> None: - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - - shared_entry = runner.invoke( - cli, - [ - "--root", - str(project_dir), - "add", - "--title", - "Shared ID", - "--type", - "bugfix", - "--description", - "Ships in the historical release.", - "--author", - "tester", - ], - ) - assert shared_entry.exit_code == 0, shared_entry.output - - historical_release = runner.invoke( - cli, - ["--root", str(project_dir), "release", "create", "v1.2.0", "--yes"], - ) - assert historical_release.exit_code == 0, historical_release.output - - historical_entry_path = project_dir / "releases" / "v1.2.0" / "entries" / "shared-id.md" - closed_rc_dir = project_dir / "releases" / "v1.2.0-rc.1" - (closed_rc_dir / "entries").mkdir(parents=True) - (closed_rc_dir / "manifest.yaml").write_text( - "version: v1.2.0-rc.1\ncreated: 2025-01-01\nentries:\n - shared-id\n", - encoding="utf-8", - ) - (closed_rc_dir / "entries" / "shared-id.md").write_bytes(historical_entry_path.read_bytes()) - - target_entry = runner.invoke( - cli, - [ - "--root", - str(project_dir), - "add", - "--title", - "Target Entry", - "--type", - "feature", - "--description", - "Creates the release that will be updated.", - "--author", - "tester", - ], - ) - assert target_entry.exit_code == 0, target_entry.output - - target_release = runner.invoke( - cli, - ["--root", str(project_dir), "release", "create", "v1.3.0", "--yes"], - ) - assert target_release.exit_code == 0, target_release.output - - reused_entry = runner.invoke( - cli, - [ - "--root", - str(project_dir), - "add", - "--title", - "Shared ID", - "--type", - "bugfix", - "--description", - "Reuses the ID after the historical RC cycle closed.", - "--author", - "tester", - ], - ) - assert reused_entry.exit_code == 0, reused_entry.output - - edit_release = runner.invoke( - cli, - ["--root", str(project_dir), "release", "create", "v1.3.0", "--yes"], - ) - assert edit_release.exit_code == 0, edit_release.output - - release_entries = { - path.stem for path in (project_dir / "releases" / "v1.3.0" / "entries").glob("*.md") - } - assert release_entries == {"shared-id", "target-entry"} - assert not (project_dir / "unreleased" / "shared-id.md").exists() - - def test_release_version_bare_flag(tmp_path: Path) -> None: """Test the release version --bare flag strips the v prefix.""" runner = CliRunner() From db5973fe0585321ec2b9e5ff5441756e21f165b6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 13:39:15 +0200 Subject: [PATCH 5/7] Keep the add workflow task-focused Remove product behavior from the bundled add-entry instructions. The skill explains how to use the command. Product documentation remains the source for system semantics. Assisted-by: GPT-5 (Codex) --- .../references/add-changelog-entry.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/skills/tenzir-ship/references/add-changelog-entry.md b/skills/tenzir-ship/references/add-changelog-entry.md index 0d09562..1644547 100644 --- a/skills/tenzir-ship/references/add-changelog-entry.md +++ b/skills/tenzir-ship/references/add-changelog-entry.md @@ -220,22 +220,6 @@ When merging into an unreleased entry: Never touch already-released changelog entries outside the `unreleased` directory. -## Treat entry IDs as release-scoped - -An entry ID is the Markdown filename without its extension. It must be unique -within `unreleased/` and within each individual `releases//entries/` -directory, but it does not need to be unique across the project's complete -release history. - -Do not rename a new entry merely because an older release contains the same -slug. A later change with the same user-facing title may legitimately reuse -that slug. The `add` command rejects a collision in the current `unreleased/` -directory, and `validate` checks every release manifest against its own entry -directory. - -When an exact `show ` lookup finds the ID in multiple namespaces, it -returns every occurrence with its release or unreleased context. - ## Create the entry Begin with writing the description to a temporary file, e.g., From 97173084affd137c0668e1b57d6a3cb75eb0bf02 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 13:44:02 +0200 Subject: [PATCH 6/7] Respect scopes for reused entry IDs Apply scope tokens before resolving row, exact, and partial entry identifiers. Constrain release identifiers to the same scope so reused IDs return only the requested namespace. Assisted-by: GPT-5 (Codex) --- src/tenzir_ship/cli/_show.py | 40 +++++++++++++++++++++++++++++++++++- tests/test_cli.py | 19 +++++++++++------ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/tenzir_ship/cli/_show.py b/src/tenzir_ship/cli/_show.py index e020944..7a49f6b 100644 --- a/src/tenzir_ship/cli/_show.py +++ b/src/tenzir_ship/cli/_show.py @@ -482,9 +482,32 @@ def _resolve_identifiers_sequence( entry_map: dict[str, list[Entry]], known_versions: dict[str, str], allowed_kinds: Optional[Iterable[IdentifierKind]] = None, + scope: Scope = "all", ) -> list[IdentifierResolution]: """Resolve a list of identifiers into their matching entries.""" - return [ + if scope != "all": + latest_version = None + if scope == "latest": + latest = _get_latest_release_manifest(project_root) + if latest is None: + raise click.ClickException("No stable releases found.") + latest_version = render_release_tag(latest.version) + + def in_scope(entry: Entry) -> bool: + if scope == "unreleased": + return entry.release is None + if scope == "released": + return entry.release is not None + return entry.release == latest_version + + entry_map = { + entry_id: matches + for entry_id, occurrences in entry_map.items() + if (matches := [entry for entry in occurrences if in_scope(entry)]) + } + sorted_entries = [entry for entry in sorted_entries if in_scope(entry)] + + resolutions = [ _resolve_identifier( identifier, project_root=project_root, @@ -496,6 +519,10 @@ def _resolve_identifiers_sequence( ) for identifier in identifiers ] + if scope != "all": + for resolution in resolutions: + resolution.entries = [entry for entry in resolution.entries if in_scope(entry)] + return resolutions def _gather_entry_context( @@ -729,6 +756,7 @@ def _show_entries_table_release_mode( entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], + scope: Scope, ) -> None: """Handle --release flag with identifiers: display entries in a unified table with Release column.""" config = ctx.ensure_config() @@ -742,6 +770,7 @@ def _show_entries_table_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) # Build flat list of entries with their release versions @@ -896,6 +925,7 @@ def filtered_with_modules( entry_map=entry_map, sorted_entries=sorted_entries, known_versions=known_versions, + scope=scope, ) return @@ -907,6 +937,7 @@ def filtered_with_modules( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release" and not components: release_resolution = resolutions[0] @@ -1035,6 +1066,7 @@ def _show_entries_card( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_groups: list[tuple[ReleaseManifest | None, list[Entry]]] = [] @@ -1125,6 +1157,7 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_index = release_index_all @@ -1339,6 +1372,7 @@ def _show_entries_export_release_mode( entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], + scope: Scope, ) -> None: """Handle --release flag with explicit identifiers: group entries by release.""" config = ctx.ensure_config() @@ -1353,6 +1387,7 @@ def _show_entries_export_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release": if view == "json": @@ -1463,6 +1498,7 @@ def _show_entries_export_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_groups: list[tuple[ReleaseManifest | None, list[Entry]]] = [] @@ -1573,6 +1609,7 @@ def _show_entries_export( entry_map=entry_map, sorted_entries=sorted_entries, known_versions=known_versions, + scope=scope, ) return @@ -1586,6 +1623,7 @@ def _show_entries_export( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release": diff --git a/tests/test_cli.py b/tests/test_cli.py index 55935e0..0db8ba9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8956,6 +8956,7 @@ def test_entry_ids_are_scoped_to_each_release(tmp_path: Path) -> None: runner = CliRunner() project_dir = tmp_path / "project" project_dir.mkdir() + entry_id = "dependency-compatibility-updates" def add_compatibility_update(description: str) -> None: result = runner.invoke( @@ -9003,9 +9004,7 @@ def add_compatibility_update(description: str) -> None: ["--root", str(project_dir), "release", "create", "--yes"], ) assert second_release.exit_code == 0, second_release.output - assert ( - project_dir / "releases" / "v3.0.1" / "entries" / "dependency-compatibility-updates.md" - ).exists() + assert (project_dir / "releases" / "v3.0.1" / "entries" / f"{entry_id}.md").exists() add_compatibility_update("An unreleased compatibility update.") @@ -9015,7 +9014,7 @@ def add_compatibility_update(description: str) -> None: "--root", str(project_dir), "show", - "dependency-compatibility-updates", + entry_id, "--json", ], ) @@ -9027,13 +9026,21 @@ def add_compatibility_update(description: str) -> None: "v3.0.0", ] + unreleased_json = runner.invoke( + cli, + ["--root", str(project_dir), "show", "unreleased", entry_id, "-j"], + ) + assert unreleased_json.exit_code == 0, unreleased_json.output + payload = json.loads(unreleased_json.output) + assert [entry["release"] for entry in payload["entries"]] == [None] + show_markdown = runner.invoke( cli, [ "--root", str(project_dir), "show", - "dependency-compatibility-updates", + entry_id, "--markdown", ], ) @@ -9048,7 +9055,7 @@ def add_compatibility_update(description: str) -> None: "--root", str(project_dir), "show", - "dependency-compatibility-updates", + entry_id, "--card", ], ) From 3ba8dfe9f023e7d072be89709dac4c36db139593 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 14:50:48 +0200 Subject: [PATCH 7/7] Reject releases outside the latest scope Prevent scoped show queries from rendering or exporting a release manifest when a newer stable release defines the latest scope. Assisted-by: GPT-5 (pi) --- src/tenzir_ship/cli/_show.py | 9 +++++++++ tests/test_cli.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/src/tenzir_ship/cli/_show.py b/src/tenzir_ship/cli/_show.py index 7a49f6b..7621704 100644 --- a/src/tenzir_ship/cli/_show.py +++ b/src/tenzir_ship/cli/_show.py @@ -521,6 +521,15 @@ def in_scope(entry: Entry) -> bool: ] if scope != "all": for resolution in resolutions: + if ( + resolution.kind == "release" + and scope == "latest" + and resolution.manifest is not None + and render_release_tag(resolution.manifest.version) != latest_version + ): + raise click.ClickException( + f"Release '{resolution.identifier}' is outside the 'latest' scope." + ) resolution.entries = [entry for entry in resolution.entries if in_scope(entry)] return resolutions diff --git a/tests/test_cli.py b/tests/test_cli.py index 0db8ba9..2e589cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9006,6 +9006,13 @@ def add_compatibility_update(description: str) -> None: assert second_release.exit_code == 0, second_release.output assert (project_dir / "releases" / "v3.0.1" / "entries" / f"{entry_id}.md").exists() + out_of_scope_release = runner.invoke( + cli, + ["--root", str(project_dir), "show", "latest", "v3.0.0"], + ) + assert out_of_scope_release.exit_code != 0 + assert "Release 'v3.0.0' is outside the 'latest' scope" in out_of_scope_release.output + add_compatibility_update("An unreleased compatibility update.") show_json = runner.invoke(