diff --git a/changelog/unreleased/github-release-title-format.md b/changelog/unreleased/github-release-title-format.md new file mode 100644 index 0000000..a376891 --- /dev/null +++ b/changelog/unreleased/github-release-title-format.md @@ -0,0 +1,51 @@ +--- +title: GitHub release title formatting +type: feature +authors: + - IyeOnline + - mavam + - codex +components: + - cli + - python +prs: + - 38 +created: 2026-07-06T17:30:00Z +--- + +The `release publish` command now formats GitHub release titles as +`PROJECT VERSION: TITLE` by default when the release has a title: + +```sh +tenzir-ship release create v1.2.3 --title "Faster ingest" +tenzir-ship release publish v1.2.3 +# GitHub release title: "Tenzir Ship v1.2.3: Faster ingest" +``` + +When the release has no title, `release create` leaves the manifest `title` +absent and GitHub receives `PROJECT VERSION` without the trailing `: TITLE` +segment: + +```sh +tenzir-ship release create v1.2.3 +tenzir-ship release publish v1.2.3 +# GitHub release title: "Tenzir Ship v1.2.3" +``` + +This keeps the release manifest title focused on the release itself while +making GitHub release pages show the project and version more clearly. To +customize the GitHub title, pass a format string to `release publish --title` +or `Changelog.release_publish(title=...)`: + +```sh +tenzir-ship release publish v1.2.3 --title '[$PROJECT $VERSION] $TITLE' +# GitHub release title: "[Tenzir Ship v1.2.3] Faster ingest" +``` + +The `$PROJECT`, `$VERSION`, and `$TITLE` variables are optional. A plain string +without variables overrides the GitHub title literally: + +```sh +tenzir-ship release publish v1.2.3 --title "Faster ingest" +# GitHub release title: "Faster ingest" +``` diff --git a/src/tenzir_ship/api.py b/src/tenzir_ship/api.py index 0339462..869493d 100644 --- a/src/tenzir_ship/api.py +++ b/src/tenzir_ship/api.py @@ -175,6 +175,7 @@ def release_publish( create_commit: bool = False, commit_message: str | None = None, assume_yes: bool = False, + title: str | None = None, ) -> None: """Publish a release to GitHub using the same workflow as the CLI. @@ -198,6 +199,7 @@ def release_publish( create_commit=create_commit, commit_message=commit_message, assume_yes=assume_yes, + github_title_format=title, ) def validate(self, *, lenient: bool = False) -> None: diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 8b4e94a..6f08c3e 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -9,6 +9,7 @@ from datetime import date, datetime from enum import Enum from pathlib import Path +from string import Template from typing import Literal, NoReturn, Optional, cast import click @@ -742,6 +743,32 @@ def _build_module_release_plan( return ModuleReleasePlan(entries_by_module, current_versions, previous_release) +DEFAULT_GITHUB_RELEASE_TITLE_FORMAT = "$PROJECT $VERSION: $TITLE" + + +def _release_title_component(manifest_title: str) -> str: + """Return the meaningful title segment for a GitHub release title.""" + return manifest_title.strip() + + +def _format_github_release_title( + project_name: str, + tag_version: str, + manifest_title: str, + github_title_format: str | None, +) -> str: + """Resolve the title passed to ``gh release create/edit --title``.""" + title = _release_title_component(manifest_title) + if github_title_format is None: + default_title = f"{project_name} {tag_version}" + return f"{default_title}: {title}" if title else default_title + return Template(github_title_format).safe_substitute( + PROJECT=project_name, + VERSION=tag_version, + TITLE=title, + ) + + def create_release( ctx: CLIContext, *, @@ -889,14 +916,9 @@ 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 + source_release_title = metadata_source_manifest.title release_title = ( title if title_explicit @@ -904,7 +926,7 @@ def create_release( if source_release_title is not None else existing_manifest.title if existing_manifest - else default_release_title + else "" ) if intro_text and intro_file: @@ -1205,6 +1227,7 @@ def publish_release( create_commit: bool, commit_message: str | None, assume_yes: bool, + github_title_format: str | None = None, ) -> None: """Python wrapper around the ``release publish`` command.""" @@ -1227,6 +1250,12 @@ def publish_release( release_version = normalize_release_version(manifest.version) tag_name = render_release_tag(release_version) + github_release_title = _format_github_release_title( + config.name, + tag_name, + manifest.title, + github_title_format, + ) release_dir = release_manifest_root(project_root, manifest) notes_path = release_dir / NOTES_FILENAME if not notes_path.exists(): @@ -1334,8 +1363,8 @@ def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn: "--notes-file", str(notes_path), ] - if manifest.title: - command.extend(["--title", manifest.title]) + if github_release_title: + command.extend(["--title", github_release_title]) if resolved_prerelease: command.append("--prerelease") if resolved_no_latest: @@ -1352,8 +1381,8 @@ def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn: "--notes-file", str(notes_path), ] - if manifest.title: - command.extend(["--title", manifest.title]) + if github_release_title: + command.extend(["--title", github_release_title]) if draft: command.append("--draft") if resolved_prerelease: @@ -1523,6 +1552,14 @@ def release_version_cmd(ctx: CLIContext, bare: bool) -> None: @release_group.command("publish") @click.argument("version", required=False) +@click.option( + "--title", + "github_title_format", + help=( + "Format for the GitHub release title, using $PROJECT, $VERSION, and " + f"$TITLE placeholders. Default: {DEFAULT_GITHUB_RELEASE_TITLE_FORMAT!r}." + ), +) @click.option( "--draft/--no-draft", default=False, @@ -1564,6 +1601,7 @@ def release_version_cmd(ctx: CLIContext, bare: bool) -> None: def release_publish_cmd( ctx: CLIContext, version: Optional[str], + github_title_format: Optional[str], draft: bool, prerelease: bool, no_latest: bool, @@ -1596,4 +1634,5 @@ def release_publish_cmd( create_commit=create_commit, commit_message=commit_message, assume_yes=assume_yes, + github_title_format=github_title_format, ) diff --git a/src/tenzir_ship/releases.py b/src/tenzir_ship/releases.py index 8b6b53e..ab1a323 100644 --- a/src/tenzir_ship/releases.py +++ b/src/tenzir_ship/releases.py @@ -202,9 +202,7 @@ def iter_release_manifests(project_root: Path) -> Iterable[ReleaseManifest]: version_value = data.get("version") or path.parent.name - title_value = str(data.get("title", "")) - if not title_value: - title_value = render_release_tag(str(version_value)) + title_value = str(data.get("title", "") or "") entry_values = data.get("entries") raw_modules = data.get("modules") diff --git a/tests/test_api.py b/tests/test_api.py index cca9b06..56695e2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -162,6 +162,26 @@ def test_python_api_release_version_defaults_to_tag(tmp_path: Path) -> None: assert client.release_version(bare=True) == "1.2.3" +def test_python_api_release_publish_accepts_github_title_format( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + project_dir = _bootstrap_project(tmp_path) + client = Changelog(root=project_dir) + captured: dict[str, object] = {} + + def fake_publish_release(ctx: cli_module.CLIContext, **kwargs: object) -> None: + captured["ctx"] = ctx + captured.update(kwargs) + + monkeypatch.setattr("tenzir_ship.api.publish_release", fake_publish_release) + + client.release_publish(version="v1.2.3", title="$PROJECT $VERSION - $TITLE") + + assert captured["ctx"] is client.context + assert captured["version"] == "v1.2.3" + assert captured["github_title_format"] == "$PROJECT $VERSION - $TITLE" + + def test_python_api_release_version_ignores_release_candidates(tmp_path: Path) -> None: project_dir = _bootstrap_project(tmp_path) client = Changelog(root=project_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py index b2e63ae..65f3dfa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -214,7 +214,7 @@ def test_add_initializes_and_release(tmp_path: Path) -> None: assert "description" not in manifest_data assert manifest_data["intro"] == "Welcome to the release!\n\n![Image](assets/hero.png)" assert "entries" not in manifest_data - assert manifest_data.get("title", "").endswith("v1.0.0") + assert "title" not in manifest_data release_entries_dir = release_dir / "entries" assert release_entries_dir.is_dir() @@ -644,6 +644,47 @@ def _write_legacy_entry(project_root: Path, entry_id: str, title: str) -> Path: return entry_path +def _set_repository(project_dir: Path, repository: str = "tenzir/example") -> None: + config_path = project_dir / "config.yaml" + config_data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config_data["repository"] = repository + config_path.write_text(yaml.safe_dump(config_data, sort_keys=False), encoding="utf-8") + + +def _captured_title(command: list[str]) -> str | None: + return command[command.index("--title") + 1] if "--title" in command else None + + +def _rewrite_release_manifest_title(project_dir: Path, tag: str, title: str) -> None: + manifest_path = project_dir / "releases" / tag / "manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["title"] = title + manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") + + +def _setup_publishable_release( + project_dir: Path, + runner: CliRunner, + *, + tag: str = "v1.0.0", + title: str | None = None, +) -> None: + _create_project_with_entry( + project_dir, + "project", + "Project", + entry_id="feature-one", + title="Feature One", + created=date(2026, 1, 1), + ) + create_args = ["--root", str(project_dir), "release", "create", tag, "--yes"] + if title is not None: + create_args[5:5] = ["--title", title] + create_result = runner.invoke(cli, create_args) + assert create_result.exit_code == 0, create_result.output + _set_repository(project_dir) + + def test_release_create_anchors_unreleased_directory_for_git_merges(tmp_path: Path) -> None: runner = CliRunner() repo = tmp_path / "repo" @@ -3992,10 +4033,124 @@ def fake_run( assert "v3.0.0" in recorded_args assert "--repo" in recorded_args and "tenzir/example" in recorded_args assert "--notes-file" in recorded_args + assert _captured_title(recorded_args) == "Project v3.0.0" # Ensure existence check ran first. assert commands[0][:3] == ["/usr/bin/gh", "release", "view"] +def test_release_publish_composes_github_title_from_release_title( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner, title="Faster ingest") + + commands: list[list[str]] = [] + + def fake_which(command: str) -> str: + assert command == "gh" + return "/usr/bin/gh" + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + commands.append(args) + if len(args) >= 3 and args[1:3] == ["release", "view"]: + raise subprocess.CalledProcessError(returncode=1, cmd=args) + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", fake_which) + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + publish_result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "publish", "v1.0.0", "--yes"], + ) + + assert publish_result.exit_code == 0, publish_result.output + assert _captured_title(commands[-1]) == "Project v1.0.0: Faster ingest" + manifest = yaml.safe_load( + (project_dir / "releases" / "v1.0.0" / "manifest.yaml").read_text(encoding="utf-8") + ) + assert manifest["title"] == "Faster ingest" + + +def test_release_publish_omits_title_component_when_manifest_has_no_title( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner) + manifest = yaml.safe_load( + (project_dir / "releases" / "v1.0.0" / "manifest.yaml").read_text(encoding="utf-8") + ) + assert "title" not in manifest + + commands: list[list[str]] = [] + + def fake_which(command: str) -> str: + assert command == "gh" + return "/usr/bin/gh" + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + commands.append(args) + if len(args) >= 3 and args[1:3] == ["release", "view"]: + raise subprocess.CalledProcessError(returncode=1, cmd=args) + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", fake_which) + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + publish_result = runner.invoke( + cli, + ["--root", str(project_dir), "release", "publish", "v1.0.0", "--yes"], + ) + + assert publish_result.exit_code == 0, publish_result.output + assert _captured_title(commands[-1]) == "Project v1.0.0" + + +def test_release_publish_custom_github_title_format( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _setup_publishable_release(project_dir, runner, title="Faster ingest") + + commands: list[list[str]] = [] + + def fake_which(command: str) -> str: + assert command == "gh" + return "/usr/bin/gh" + + def fake_run( + args: list[str], *, check: bool, stdout: object = None, stderr: object = None + ) -> None: + commands.append(args) + if len(args) >= 3 and args[1:3] == ["release", "view"]: + raise subprocess.CalledProcessError(returncode=1, cmd=args) + + monkeypatch.setattr("tenzir_ship.cli._release.shutil.which", fake_which) + monkeypatch.setattr("tenzir_ship.cli._release.subprocess.run", fake_run) + + publish_result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "release", + "publish", + "v1.0.0", + "--title", + "$PROJECT $VERSION - $TITLE", + "--yes", + ], + ) + + assert publish_result.exit_code == 0, publish_result.output + assert _captured_title(commands[-1]) == "Project v1.0.0 - Faster ingest" + + def test_release_publish_retry_hint_preserves_bracketed_title( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -4071,7 +4226,7 @@ def fake_run( assert publish_result.exit_code != 0 plain_output = click.utils.strip_ansi(publish_result.output) assert "To retry the failed step, run:" in plain_output - assert "--title '[LTS] Stable'" in plain_output + assert "--title 'Project v3.1.0: [LTS] Stable'" in plain_output def test_release_publish_updates_existing_release( @@ -4150,6 +4305,7 @@ def fake_run( assert publish_result.exit_code == 0, publish_result.output assert calls[0][:3] == ["/usr/bin/gh", "release", "view"] assert calls[1][:3] == ["/usr/bin/gh", "release", "edit"] + assert _captured_title(calls[1]) == "Project v4.0.0" def test_release_publish_handles_abort(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: