From 931b41bc888a72a5c2b3a693e686b4ac4df9df8e Mon Sep 17 00:00:00 2001 From: Matthias Vallentin Date: Sun, 19 Apr 2026 19:54:31 +0200 Subject: [PATCH] Add release planning payloads for automation --- .github/scripts/generate_release_metadata.py | 94 +++ .github/workflows/trigger-release.yaml | 44 +- README.md | 6 + ...oads-and-auto-generated-workflow-intros.md | 8 + .../references/create-remote-release.md | 7 +- src/tenzir_ship/api.py | 17 + src/tenzir_ship/cli/__init__.py | 2 + src/tenzir_ship/cli/_release.py | 568 +++++++++++++----- tests/test_cli.py | 131 ++++ tests/test_workflows.py | 20 +- 10 files changed, 750 insertions(+), 147 deletions(-) create mode 100644 .github/scripts/generate_release_metadata.py create mode 100644 changelog/unreleased/add-release-planning-payloads-and-auto-generated-workflow-intros.md diff --git a/.github/scripts/generate_release_metadata.py b/.github/scripts/generate_release_metadata.py new file mode 100644 index 0000000..c4d977e --- /dev/null +++ b/.github/scripts/generate_release_metadata.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Generate release workflow metadata from a structured tenzir-ship release plan.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +def _pluralize(count: int, singular: str, plural: str | None = None) -> str: + if count == 1: + return f"{count} {singular}" + return f"{count} {plural or singular + 's'}" + + +def _join_phrases(phrases: list[str]) -> str: + if not phrases: + return "" + if len(phrases) == 1: + return phrases[0] + if len(phrases) == 2: + return f"{phrases[0]} and {phrases[1]}" + return f"{', '.join(phrases[:-1])}, and {phrases[-1]}" + + +def _build_intro(plan: dict[str, Any]) -> str: + release = plan["release"] + resolved_intro = release.get("resolved_intro") + if isinstance(resolved_intro, str) and resolved_intro.strip(): + return resolved_intro.strip() + + project_name = str(plan["project"]["name"]) + entry_counts = release["entry_counts"] + parts: list[str] = [] + if entry_counts.get("breaking"): + parts.append(_pluralize(int(entry_counts["breaking"]), "breaking change")) + if entry_counts.get("feature"): + parts.append(_pluralize(int(entry_counts["feature"]), "feature")) + if entry_counts.get("bugfix"): + parts.append(_pluralize(int(entry_counts["bugfix"]), "bug fix", "bug fixes")) + if entry_counts.get("change"): + parts.append(_pluralize(int(entry_counts["change"]), "additional change")) + + if not parts: + return f"This release updates {project_name}." + + if entry_counts.get("breaking") or entry_counts.get("feature"): + lead_verb = "introduces" + elif entry_counts.get("bugfix") and int(entry_counts["bugfix"]) == int(entry_counts["total"]): + lead_verb = "fixes" + else: + lead_verb = "updates" + + first_sentence = f"This release {lead_verb} {_join_phrases(parts)} for {project_name}." + + highlights = plan.get("highlights") or [] + titles = [ + str(item.get("title", "")).strip() + for item in highlights + if isinstance(item, dict) and str(item.get("title", "")).strip() + ] + if not titles: + return first_sentence + + if len(titles) == 1: + second_sentence = f'The headline change is "{titles[0]}".' + else: + quoted = [f'"{title}"' for title in titles[:2]] + second_sentence = f"Highlights include {_join_phrases(quoted)}." + return f"{first_sentence} {second_sentence}" + + +def _emit_output(key: str, value: str) -> None: + delimiter = "__TENZIR_SHIP_EOF__" + print(f"{key}<<{delimiter}") + print(value) + print(delimiter) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: generate_release_metadata.py ", file=sys.stderr) + return 2 + plan_path = Path(sys.argv[1]) + plan = json.loads(plan_path.read_text(encoding="utf-8")) + intro = _build_intro(plan) + _emit_output("intro", intro) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/trigger-release.yaml b/.github/workflows/trigger-release.yaml index b98c70e..d9a1b80 100644 --- a/.github/workflows/trigger-release.yaml +++ b/.github/workflows/trigger-release.yaml @@ -7,10 +7,13 @@ on: description: | Intro of the release - The intro should be 1-2 sentences long, must be written in active - voice, and should clearly communicate the theme of the release to the - user. Use full sentences and avoid abbreviations. - required: true + Leave empty to auto-generate the intro from the pending changelog + entries. When set manually, the intro should be 1-2 sentences long, + must be written in active voice, and should clearly communicate the + theme of the release to the user. Use full sentences and avoid + abbreviations. + required: false + default: "" type: string title: description: | @@ -19,6 +22,33 @@ on: type: string jobs: + prepare-release-metadata: + name: Prepare release metadata + runs-on: ubuntu-latest + outputs: + intro: ${{ steps.metadata.outputs.intro }} + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: false + + - name: Generate release metadata + id: metadata + run: | + uv sync --dev + uv run tenzir-ship release plan --json > release-plan.json + python .github/scripts/generate_release_metadata.py release-plan.json >> "$GITHUB_OUTPUT" + validate-release-config: name: Validate release workflow configuration runs-on: ubuntu-latest @@ -47,12 +77,14 @@ jobs: release: name: Create GitHub release - needs: validate-release-config + needs: + - prepare-release-metadata + - validate-release-config permissions: contents: write uses: ./.github/workflows/release.yaml with: - intro: ${{ inputs.intro }} + intro: ${{ inputs.intro != '' && inputs.intro || needs.prepare-release-metadata.outputs.intro }} title: ${{ inputs.title }} git-add-paths: pyproject.toml uv.lock pre-create: | diff --git a/README.md b/README.md index d075f73..39ea3da 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ pre/post hooks, and several release-control options. See the [reference](https://docs.tenzir.com/reference/ship-framework) for the full list of inputs, secrets, and auth modes. +For release automation, `tenzir-ship release plan --json` emits a structured +snapshot of the pending release queue, including the resolved target version, +entry counts, highlights, and inherited release metadata. This repository's +`trigger-release.yaml` wrapper uses that plan to auto-generate the release +intro when the workflow dispatch leaves `intro` empty. + ## 📚 Documentation Consult our [user diff --git a/changelog/unreleased/add-release-planning-payloads-and-auto-generated-workflow-intros.md b/changelog/unreleased/add-release-planning-payloads-and-auto-generated-workflow-intros.md new file mode 100644 index 0000000..09b9f74 --- /dev/null +++ b/changelog/unreleased/add-release-planning-payloads-and-auto-generated-workflow-intros.md @@ -0,0 +1,8 @@ +--- +title: Add release planning payloads and auto-generated workflow intros +type: feature +author: codex +created: 2026-04-03T19:11:53.234426Z +--- + +tenzir-ship now exposes `release plan --json` for structured release automation, and the bundled release trigger workflow can derive its intro automatically from that plan when the workflow dispatch leaves the intro empty. diff --git a/skills/tenzir-ship/references/create-remote-release.md b/skills/tenzir-ship/references/create-remote-release.md index fb268aa..6105fdc 100644 --- a/skills/tenzir-ship/references/create-remote-release.md +++ b/skills/tenzir-ship/references/create-remote-release.md @@ -29,7 +29,9 @@ Inspect the workflow to understand its shape. The release workflow in this repository accepts these common inputs: - **intro**: Summarize unreleased entries in `changelog/unreleased/` into 1–2 - sentences describing the release highlights. + sentences describing the release highlights. Some wrapper workflows can now + auto-generate this from `tenzir-ship release plan --json` when you leave the + input empty. - **title**: Identify the lead topic—the single most important change from a user's perspective. - **bump**: Optional manual bump for a stable release (`patch`, `minor`, or @@ -56,6 +58,9 @@ gh workflow run \ -f title="" ``` +If the workflow auto-generates the intro, you can omit `-f intro=...` and let +it derive the text from the pending changelog entries. + Do not specify a version bump unless explicitly requested. The workflow will pick the appropriate bump according to the changelog entry types. If an outstanding release candidate exists, this same version-less invocation diff --git a/src/tenzir_ship/api.py b/src/tenzir_ship/api.py index 286970d..b767848 100644 --- a/src/tenzir_ship/api.py +++ b/src/tenzir_ship/api.py @@ -10,6 +10,7 @@ CLIContext, ShowView, _get_latest_release_manifest, + build_release_plan_payload, create_cli_context, create_entry, create_release, @@ -147,6 +148,22 @@ def release_create( compact_explicit=compact is not None, ) + def release_plan( + self, + *, + version: str | None = None, + version_bump: str | None = None, + release_candidate: bool = False, + ) -> dict[str, Any]: + """Describe the release snapshot that would be created for the current queue.""" + + return build_release_plan_payload( + self._ctx, + version=version, + version_bump=version_bump, + release_candidate=release_candidate, + ) + def release_version(self, *, bare: bool = False) -> str: """Get the latest stable released version. diff --git a/src/tenzir_ship/cli/__init__.py b/src/tenzir_ship/cli/__init__.py index 579d0b0..0fbd6d0 100644 --- a/src/tenzir_ship/cli/__init__.py +++ b/src/tenzir_ship/cli/__init__.py @@ -112,6 +112,7 @@ # Re-export release commands from ._release import ( + build_release_plan_payload, create_release, publish_release, release_group, @@ -173,6 +174,7 @@ "create_entry", "add", # Release + "build_release_plan_payload", "create_release", "publish_release", "release_group", diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 4a24a06..2270ed7 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import shlex import shutil import subprocess @@ -59,6 +60,7 @@ from ..version_files import apply_version_file_updates, plan_version_file_updates from ._core import ( CLIContext, + ENTRY_EXPORT_ORDER, ENTRY_TYPE_EMOJIS, _enforce_structure_is_valid, _warn_on_structure_issues, @@ -73,6 +75,7 @@ _render_module_entries_compact, _release_entry_sort_key, ) +from ._export import _entry_to_dict from ._manifests import ( _find_release_manifest, _get_latest_release_manifest, @@ -172,9 +175,11 @@ def _render_release_progress(tracker: StepTracker) -> None: __all__ = [ + "build_release_plan_payload", "create_release", "publish_release", "release_group", + "release_plan_cmd", "release_create_cmd", "release_version_cmd", "release_publish_cmd", @@ -206,6 +211,323 @@ class ModuleReleasePlan: previous_release: ReleaseManifest | None +@dataclass +class ReleaseSnapshotPlan: + """Resolved release snapshot inputs shared by planning and creation.""" + + version: str + tag_version: str + version_source: ReleaseVersionSource + release_candidate: bool + is_prerelease: bool + release_mode: str + existing_manifest: ReleaseManifest | None + active_rc_series: list[ReleaseManifest] + active_rc_manifest: ReleaseManifest | None + source_manifest: ReleaseManifest | None + metadata_source_manifest: ReleaseManifest | None + previous_release: ReleaseManifest | None + copy_entries: bool + selected_entries: list[Entry] + new_entries: list[Entry] + entries_sorted: list[Entry] + cleanup_unreleased_paths: list[Path] + cleanup_missing_entry_ids: list[str] + module_plan: ModuleReleasePlan + + +def _resolve_default_release_metadata( + config: Config, + tag_version: str, + *, + existing_manifest: ReleaseManifest | None, + metadata_source_manifest: ReleaseManifest | None, +) -> tuple[str, str | None]: + """Return the title and intro that release creation would inherit by default.""" + + default_release_title = f"{config.name} {tag_version}" + source_release_title = None + if metadata_source_manifest is not None: + source_tag = render_release_tag(metadata_source_manifest.version) + if metadata_source_manifest.title in {source_tag, f"{config.name} {source_tag}"}: + source_release_title = default_release_title + else: + source_release_title = metadata_source_manifest.title + release_title = ( + source_release_title + if source_release_title is not None + else existing_manifest.title + if existing_manifest + else default_release_title + ) + if metadata_source_manifest is not None: + release_intro = ( + metadata_source_manifest.intro.strip() if metadata_source_manifest.intro else None + ) + elif existing_manifest and existing_manifest.intro: + release_intro = existing_manifest.intro.strip() or None + else: + release_intro = None + return release_title, release_intro + + +def _count_entries_by_type(entries: list[Entry]) -> dict[str, int]: + counts = {entry_type: 0 for entry_type in ENTRY_EXPORT_ORDER} + for entry in entries: + entry_type = str(entry.metadata.get("type", "change")) + counts[entry_type] = counts.get(entry_type, 0) + 1 + counts["total"] = len(entries) + return counts + + +def _select_highlight_entries(entries: list[Entry], *, limit: int = 3) -> list[Entry]: + type_priority = {entry_type: index for index, entry_type in enumerate(ENTRY_EXPORT_ORDER)} + ordered = sorted( + entries, + key=lambda entry: ( + type_priority.get(str(entry.metadata.get("type", "change")), len(type_priority)), + _release_entry_sort_key(entry), + ), + ) + return ordered[:limit] + + +def _build_module_plan_payload(module_plan: ModuleReleasePlan) -> list[dict[str, object]]: + payload: list[dict[str, object]] = [] + for module_id in sorted(module_plan.entries_by_module.keys()): + module_config, module_entries = module_plan.entries_by_module[module_id] + payload.append( + { + "id": module_id, + "name": module_config.name, + "version": module_plan.version_map.get(module_id), + "entry_counts": _count_entries_by_type(module_entries), + "entries": [ + _entry_to_dict(entry, module_config, compact=True) for entry in module_entries + ], + } + ) + return payload + + +def _plan_release_snapshot( + ctx: CLIContext, + *, + version: str | None, + version_bump: ReleaseBump | None, + release_candidate: bool, +) -> ReleaseSnapshotPlan: + """Resolve the release snapshot that would be created for the current inputs.""" + + config = ctx.ensure_config() + project_root = ctx.project_root + requested_version = version + active_rc_series = _get_active_release_candidate_series(project_root) + + preview_entries = _collect_unused_entries_for_release( + project_root, + config, + include_prereleases=False, + ) + version, version_source = _resolve_requested_release_version( + project_root, + version, + version_bump, + unreleased_entries=preview_entries, + release_candidate=release_candidate, + ) + tag_version = render_release_tag(version) + existing_manifest = _find_release_manifest(project_root, version) + if version_source in {"manual", "auto"} and existing_manifest is not None: + follow_up = ( + "Supply a different bump flag or explicit version." + if version_source == "manual" + else "Supply an explicit version or a manual bump flag." + ) + raise click.ClickException(f"Release '{tag_version}' already exists. {follow_up}") + + active_rc_manifest = active_rc_series[-1] if active_rc_series else None + active_rc_base = ( + stable_release_version(active_rc_manifest.version) + if active_rc_manifest is not None + else None + ) + + source_manifest = None + if not release_candidate and active_rc_manifest is not None and active_rc_base is not None: + active_rc_target = parse_release_version(active_rc_base) + resolved_version = parse_release_version(version) + if requested_version is None and version_bump is None: + source_manifest = active_rc_manifest + elif normalize_release_version(version) == active_rc_base: + raise click.ClickException( + f"Release candidates already exist for '{active_rc_base}'. " + f"Omit the version and bump flags to promote {render_release_tag(active_rc_manifest.version)} automatically, " + "or use --rc to continue the RC series." + ) + elif ( + requested_version is not None + and existing_manifest is None + and resolved_version < active_rc_target + ): + raise click.ClickException( + f"Cannot create {tag_version} while {render_release_tag(active_rc_manifest.version)} is active because " + f"it does not advance beyond the active RC target {render_release_tag(active_rc_base)}. " + "Omit the version and bump flags to promote the latest candidate automatically, " + "or choose an explicit later version." + ) + elif version_bump is not None and resolved_version <= active_rc_target: + raise click.ClickException( + f"Cannot use --{version_bump} while {render_release_tag(active_rc_manifest.version)} is active because " + f"it resolves to {tag_version}, which does not advance beyond the active RC target " + f"{render_release_tag(active_rc_base)}. Omit the bump flag to promote the latest candidate automatically, " + "or choose a higher bump or explicit later version." + ) + closing_active_rc_cycle = ( + not release_candidate and existing_manifest is None and active_rc_manifest is not None + ) + metadata_source_manifest = ( + active_rc_manifest + if active_rc_manifest is not None and (release_candidate or closing_active_rc_cycle) + else source_manifest + ) + is_prerelease = release_candidate + copy_entries = release_candidate or source_manifest is not None + release_mode = ( + "snapshot-prerelease" + if release_candidate + else "promote-prerelease" + if source_manifest is not None + else "sync-stable-queue" + ) + + if release_candidate: + selected_entries = _build_cumulative_release_candidate_entries( + project_root, config, active_rc_series + ) + elif source_manifest is not None: + selected_entries = _load_manifest_entries(project_root, source_manifest) + else: + selected_entries = _collect_unused_entries_for_release( + project_root, + config, + include_prereleases=existing_manifest is not None, + ) + + _, new_entries, entries_sorted = _combine_release_entries( + existing_manifest, selected_entries, project_root + ) + cleanup_unreleased_paths: list[Path] = [] + cleanup_missing_entry_ids: list[str] = [] + if source_manifest is not None: + cleanup_unreleased_paths, cleanup_missing_entry_ids = _plan_promoted_unreleased_cleanup( + project_root, + config, + source_manifest, + ) + + previous_release = _resolve_release_baseline( + project_root, + version=version, + existing_manifest=existing_manifest, + source_manifest=source_manifest, + ) + module_plan = _build_module_release_plan( + ctx, + project_root, + existing_manifest=existing_manifest, + source_manifest=source_manifest, + is_prerelease=is_prerelease, + previous_release=previous_release, + ) + return ReleaseSnapshotPlan( + version=version, + tag_version=tag_version, + version_source=version_source, + release_candidate=release_candidate, + is_prerelease=is_prerelease, + release_mode=release_mode, + existing_manifest=existing_manifest, + active_rc_series=active_rc_series, + active_rc_manifest=active_rc_manifest, + source_manifest=source_manifest, + metadata_source_manifest=metadata_source_manifest, + previous_release=previous_release, + copy_entries=copy_entries, + selected_entries=selected_entries, + new_entries=new_entries, + entries_sorted=entries_sorted, + cleanup_unreleased_paths=cleanup_unreleased_paths, + cleanup_missing_entry_ids=cleanup_missing_entry_ids, + module_plan=module_plan, + ) + + +def build_release_plan_payload( + ctx: CLIContext, + *, + version: str | None, + version_bump: str | None, + release_candidate: bool, +) -> dict[str, object]: + """Return a machine-readable description of the next release snapshot.""" + + _enforce_structure_is_valid(ctx, action="plan a release") + config = ctx.ensure_config() + normalized_bump = _coerce_release_bump(version_bump) + snapshot = _plan_release_snapshot( + ctx, + version=version, + version_bump=normalized_bump, + release_candidate=release_candidate, + ) + resolved_title, resolved_intro = _resolve_default_release_metadata( + config, + snapshot.tag_version, + existing_manifest=snapshot.existing_manifest, + metadata_source_manifest=snapshot.metadata_source_manifest, + ) + entry_counts = _count_entries_by_type(snapshot.entries_sorted) + highlights = _select_highlight_entries(snapshot.entries_sorted) + return { + "project": { + "id": config.id, + "name": config.name, + "repository": config.repository, + }, + "release": { + "version": snapshot.tag_version, + "version_source": snapshot.version_source, + "mode": snapshot.release_mode, + "prerelease": snapshot.is_prerelease, + "release_candidate": snapshot.release_candidate, + "existing": snapshot.existing_manifest is not None, + "copy_entries": snapshot.copy_entries, + "resolved_title": resolved_title, + "resolved_intro": resolved_intro, + "active_release_candidate": ( + render_release_tag(snapshot.active_rc_manifest.version) + if snapshot.active_rc_manifest is not None + else None + ), + "source_release_candidate": ( + render_release_tag(snapshot.source_manifest.version) + if snapshot.source_manifest is not None + else None + ), + "previous_stable": ( + render_release_tag(snapshot.previous_release.version) + if snapshot.previous_release is not None + else None + ), + "entry_counts": entry_counts, + }, + "entries": [_entry_to_dict(entry, config) for entry in snapshot.entries_sorted], + "highlights": [_entry_to_dict(entry, config, compact=True) for entry in highlights], + "modules": _build_module_plan_payload(snapshot.module_plan), + } + + def _github_release_exists(repository: str, tag_name: str, gh_path: str) -> bool: command = [gh_path, "release", "view", tag_name, "--repo", repository] try: @@ -748,103 +1070,26 @@ def create_release( config = ctx.ensure_config() _enforce_structure_is_valid(ctx, action="create a release") project_root = ctx.project_root - requested_version = version normalized_bump = _coerce_release_bump(version_bump) - active_rc_series = _get_active_release_candidate_series(project_root) - - preview_entries = _collect_unused_entries_for_release( - project_root, - config, - include_prereleases=False, - ) - version, version_source = _resolve_requested_release_version( - project_root, - version, - normalized_bump, - unreleased_entries=preview_entries, + snapshot = _plan_release_snapshot( + ctx, + version=version, + version_bump=normalized_bump, release_candidate=release_candidate, ) - tag_version = render_release_tag(version) - existing_manifest = _find_release_manifest(project_root, version) - if version_source in {"manual", "auto"} and existing_manifest is not None: - follow_up = ( - "Supply a different bump flag or explicit version." - if version_source == "manual" - else "Supply an explicit version or a manual bump flag." - ) - raise click.ClickException(f"Release '{tag_version}' already exists. {follow_up}") - active_rc_manifest = active_rc_series[-1] if active_rc_series else None - active_rc_base = ( - stable_release_version(active_rc_manifest.version) - if active_rc_manifest is not None - else None - ) - - source_manifest = None - if not release_candidate and active_rc_manifest is not None and active_rc_base is not None: - active_rc_target = parse_release_version(active_rc_base) - resolved_version = parse_release_version(version) - if requested_version is None and normalized_bump is None: - source_manifest = active_rc_manifest - elif normalize_release_version(version) == active_rc_base: - raise click.ClickException( - f"Release candidates already exist for '{active_rc_base}'. " - f"Omit the version and bump flags to promote {render_release_tag(active_rc_manifest.version)} automatically, " - "or use --rc to continue the RC series." - ) - elif ( - requested_version is not None - and existing_manifest is None - and resolved_version < active_rc_target - ): - raise click.ClickException( - f"Cannot create {tag_version} while {render_release_tag(active_rc_manifest.version)} is active because " - f"it does not advance beyond the active RC target {render_release_tag(active_rc_base)}. " - "Omit the version and bump flags to promote the latest candidate automatically, " - "or choose an explicit later version." - ) - elif normalized_bump is not None and resolved_version <= active_rc_target: - raise click.ClickException( - f"Cannot use --{normalized_bump} while {render_release_tag(active_rc_manifest.version)} is active because " - f"it resolves to {tag_version}, which does not advance beyond the active RC target " - f"{render_release_tag(active_rc_base)}. Omit the bump flag to promote the latest candidate automatically, " - "or choose a higher bump or explicit later version." - ) - closing_active_rc_cycle = ( - not release_candidate and existing_manifest is None and active_rc_manifest is not None - ) - metadata_source_manifest = ( - active_rc_manifest - if active_rc_manifest is not None and (release_candidate or closing_active_rc_cycle) - else source_manifest - ) - is_prerelease = release_candidate - copy_entries = release_candidate or source_manifest is not None - release_mode = ( - "snapshot-prerelease" - if release_candidate - else "promote-prerelease" - if source_manifest is not None - else "sync-stable-queue" - ) - - if release_candidate: - selected_entries = _build_cumulative_release_candidate_entries( - project_root, config, active_rc_series - ) - elif source_manifest is not None: - selected_entries = _load_manifest_entries(project_root, source_manifest) - else: - selected_entries = _collect_unused_entries_for_release( - project_root, - config, - include_prereleases=existing_manifest is not None, - ) - - _, new_entries, entries_sorted = _combine_release_entries( - existing_manifest, selected_entries, project_root - ) + version = snapshot.version + tag_version = snapshot.tag_version + existing_manifest = snapshot.existing_manifest + active_rc_series = snapshot.active_rc_series + source_manifest = snapshot.source_manifest + metadata_source_manifest = snapshot.metadata_source_manifest + is_prerelease = snapshot.is_prerelease + copy_entries = snapshot.copy_entries + release_mode = snapshot.release_mode + selected_entries = snapshot.selected_entries + new_entries = snapshot.new_entries + entries_sorted = snapshot.entries_sorted release_dir = ( release_manifest_root(project_root, existing_manifest) if existing_manifest is not None @@ -853,14 +1098,8 @@ def create_release( manifest_path = release_dir / "manifest.yaml" release_entries_dir = release_dir / "entries" notes_path = release_dir / NOTES_FILENAME - cleanup_unreleased_paths: list[Path] = [] - cleanup_missing_entry_ids: list[str] = [] - if source_manifest is not None: - cleanup_unreleased_paths, cleanup_missing_entry_ids = _plan_promoted_unreleased_cleanup( - project_root, - config, - source_manifest, - ) + cleanup_unreleased_paths = snapshot.cleanup_unreleased_paths + cleanup_missing_entry_ids = snapshot.cleanup_missing_entry_ids if cleanup_missing_entry_ids: log_warning( f"{len(cleanup_missing_entry_ids)} promoted entry file(s) were not found in " @@ -870,23 +1109,13 @@ def create_release( if title is not None and not title_explicit: # Treat explicitly provided empty strings as intentional overrides. title_explicit = True - default_release_title = f"{config.name} {tag_version}" - source_release_title = None - if metadata_source_manifest is not None: - source_tag = render_release_tag(metadata_source_manifest.version) - if metadata_source_manifest.title in {source_tag, f"{config.name} {source_tag}"}: - source_release_title = default_release_title - else: - source_release_title = metadata_source_manifest.title - release_title = ( - title - if title_explicit - else source_release_title - if source_release_title is not None - else existing_manifest.title - if existing_manifest - else default_release_title + default_release_title, default_manifest_intro = _resolve_default_release_metadata( + config, + tag_version, + existing_manifest=existing_manifest, + metadata_source_manifest=metadata_source_manifest, ) + release_title = title if title_explicit else default_release_title if intro_text and intro_file: raise click.ClickException("Use only one of --intro or --intro-file, not both.") @@ -894,14 +1123,8 @@ def create_release( manifest_intro: Optional[str] = intro_text.strip() or None elif intro_file: manifest_intro = intro_file.read_text(encoding="utf-8").strip() or None - elif metadata_source_manifest is not None: - manifest_intro = ( - metadata_source_manifest.intro.strip() if metadata_source_manifest.intro else None - ) - elif existing_manifest and existing_manifest.intro: - manifest_intro = existing_manifest.intro.strip() or None else: - manifest_intro = None + manifest_intro = default_manifest_intro if not entries_sorted and not manifest_intro: raise click.ClickException( @@ -972,20 +1195,8 @@ def create_release( prefer_compact = False compact_flag = prefer_compact - previous_release = _resolve_release_baseline( - project_root, - version=version, - existing_manifest=existing_manifest, - source_manifest=source_manifest, - ) - module_plan = _build_module_release_plan( - ctx, - project_root, - existing_manifest=existing_manifest, - source_manifest=source_manifest, - is_prerelease=is_prerelease, - previous_release=previous_release, - ) + previous_release = snapshot.previous_release + module_plan = snapshot.module_plan manifest = ReleaseManifest( version=tag_version, created=release_dt, @@ -1368,6 +1579,87 @@ def release_group(ctx: CLIContext) -> None: ctx.ensure_config() +@release_group.command("plan") +@click.argument("version", required=False) +@click.option( + "--patch", + "patch", + is_flag=True, + help="Plan a patch release from the latest stable version.", +) +@click.option( + "--minor", + "minor", + is_flag=True, + help="Plan a minor release from the latest stable version.", +) +@click.option( + "--major", + "major", + is_flag=True, + help="Plan a major release from the latest stable version.", +) +@click.option( + "--rc", + "release_candidate", + is_flag=True, + help="Plan a release candidate for the resolved stable version.", +) +@click.option( + "--json", + "json_output", + is_flag=True, + help="Emit the plan as JSON.", +) +@click.pass_obj +def release_plan_cmd( + ctx: CLIContext, + version: Optional[str], + patch: bool, + minor: bool, + major: bool, + release_candidate: bool, + json_output: bool, +) -> None: + """Inspect the release snapshot that would be created for the current queue.""" + + version_bump = _resolve_manual_bump_flags(patch=patch, minor=minor, major=major) + payload = build_release_plan_payload( + ctx, + version=version, + version_bump=version_bump, + release_candidate=release_candidate, + ) + if json_output: + emit_output(json.dumps(payload, indent=2)) + return + + project_payload = cast(dict[str, object], payload["project"]) + release_payload = cast(dict[str, object], payload["release"]) + entry_counts = cast(dict[str, int], release_payload["entry_counts"]) + highlights = cast(list[dict[str, object]], payload["highlights"]) + lines = [ + f"Release plan for {release_payload['version']}", + f"Project: {project_payload['name']}", + f"Mode: {release_payload['mode']}", + ( + "Entries: " + f"{entry_counts['total']} total " + f"({entry_counts['breaking']} breaking, {entry_counts['feature']} features, " + f"{entry_counts['bugfix']} bug fixes, {entry_counts['change']} changes)" + ), + ] + if release_payload.get("resolved_intro"): + lines.append(f"Resolved intro: {release_payload['resolved_intro']}") + if release_payload.get("active_release_candidate"): + lines.append(f"Active RC: {release_payload['active_release_candidate']}") + if highlights: + lines.append("Highlights:") + for highlight in highlights: + lines.append(f"- {highlight['title']}") + emit_output("\n".join(lines)) + + @release_group.command("create") @click.argument("version", required=False) @click.option("--title", help="Display title for the release.") diff --git a/tests/test_cli.py b/tests/test_cli.py index 1efcfb3..e30b69f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6529,6 +6529,137 @@ def test_release_create_major_bump_closing_active_rc_preserves_metadata(tmp_path assert (release_dir / "notes.md").read_text(encoding="utf-8").startswith("Curated RC intro.") +def test_release_plan_command_outputs_json_payload(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + + add_result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Test Feature", + "--type", + "feature", + "--description", + "A test feature.", + "--author", + "tester", + "--pr", + "42", + ], + ) + assert add_result.exit_code == 0, add_result.output + + plan_result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "plan", + "--json", + ], + ) + assert plan_result.exit_code == 0, plan_result.output + + payload = json.loads(plan_result.stdout) + assert payload["project"]["name"] == "Project" + assert payload["release"]["version"] == "v0.1.0" + assert payload["release"]["mode"] == "sync-stable-queue" + assert payload["release"]["entry_counts"] == { + "breaking": 0, + "feature": 1, + "bugfix": 0, + "change": 0, + "total": 1, + } + assert payload["entries"][0]["title"] == "Test Feature" + assert payload["entries"][0]["prs"] == [ + {"number": 42}, + ] + assert payload["highlights"][0]["excerpt"] == "A test feature." + + +def test_release_plan_reuses_release_candidate_intro_when_promoting(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + + stable_entry = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Stable Feature", + "--type", + "feature", + "--description", + "Ships stable.", + "--author", + "tester", + ], + ) + assert stable_entry.exit_code == 0, stable_entry.output + + stable_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.0.0", "--yes"], + ) + assert stable_release.exit_code == 0, stable_release.output + + rc_entry = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Preview Feature", + "--type", + "feature", + "--description", + "Queued for a later stable.", + "--author", + "tester", + ], + ) + assert rc_entry.exit_code == 0, rc_entry.output + + rc_release = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "create", + "v1.1.0", + "--rc", + "--intro", + "Preview intro.", + "--yes", + ], + ) + assert rc_release.exit_code == 0, rc_release.output + + plan_result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "plan", "--json"], + ) + assert plan_result.exit_code == 0, plan_result.output + + payload = json.loads(plan_result.stdout) + assert payload["release"]["version"] == "v1.1.0" + assert payload["release"]["mode"] == "promote-prerelease" + assert payload["release"]["source_release_candidate"] == "v1.1.0-rc.1" + assert payload["release"]["resolved_intro"] == "Preview intro." + + def test_release_version_command(tmp_path: Path) -> None: """Test the release version command outputs the latest stable version.""" runner = CliRunner() diff --git a/tests/test_workflows.py b/tests/test_workflows.py index df14956..2037b4a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -212,9 +212,21 @@ def test_ci_smoke_jobs_cover_reusable_release_for_default_and_push_token_modes() assert push_secrets["push_token"] == "${{ secrets.GITHUB_TOKEN }}" -def test_repo_release_workflow_validates_required_secrets_and_opts_into_signed_releases() -> None: +def test_repo_release_workflow_validates_required_secrets_and_prepares_intro() -> None: workflow = _load_workflow("trigger-release.yaml") + workflow_inputs = _as_mapping(_as_mapping(workflow["on"])["workflow_dispatch"])["inputs"] + intro_input = _as_mapping(_as_mapping(workflow_inputs)["intro"]) + assert intro_input["required"] is False + assert intro_input["default"] == "" + + prepare_job = _job(workflow, "prepare-release-metadata") + prepare_steps = _as_sequence(prepare_job["steps"]) + metadata_step = _step_by_name(prepare_steps, "Generate release metadata") + metadata_run = cast(str, metadata_step["run"]) + assert "uv run tenzir-ship release plan --json > release-plan.json" in metadata_run + assert "python .github/scripts/generate_release_metadata.py release-plan.json" in metadata_run + validate_job = _job(workflow, "validate-release-config") validate_steps = _as_sequence(validate_job["steps"]) validate_step = _step_by_name(validate_steps, "Validate release configuration") @@ -239,9 +251,13 @@ def test_repo_release_workflow_validates_required_secrets_and_opts_into_signed_r ) release_job = _job(workflow, "release") - assert release_job["needs"] == "validate-release-config" + assert release_job["needs"] == ["prepare-release-metadata", "validate-release-config"] forwarded_inputs = _as_mapping(release_job["with"]) + assert ( + forwarded_inputs["intro"] + == "${{ inputs.intro != '' && inputs.intro || needs.prepare-release-metadata.outputs.intro }}" + ) assert forwarded_inputs["github_app_id"] == "${{ vars.TENZIR_GITHUB_APP_ID }}" assert forwarded_inputs["sign_commits"] is True assert forwarded_inputs["sign_tags"] is True