Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions changelog/unreleased/github-release-title-format.md
Original file line number Diff line number Diff line change
@@ -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"
```
2 changes: 2 additions & 0 deletions src/tenzir_ship/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
61 changes: 50 additions & 11 deletions src/tenzir_ship/cli/_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -889,22 +916,17 @@ 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
else source_release_title
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:
Expand Down Expand Up @@ -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."""

Expand All @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
4 changes: 1 addition & 3 deletions src/tenzir_ship/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading