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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: Fix release progress panel truncating failed commands
type: bugfix
authors:
- mavam
- claude
pr: 7
created: 2026-02-15T10:29:09.765161Z
---

When a release step fails, the full command now prints below the progress panel so you can copy-paste it for manual recovery.

Previously, long commands would get truncated in the release progress panel, making it difficult to reproduce the failure manually. Now when a step fails, the complete command is displayed in full below the panel, giving you what you need to debug and retry the operation.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ include = [
"/src/**/*",
"/tests",
"/README.md",
"/DEVELOPMENT.md",
"/LICENSE",
"/changelog",
"/pyproject.toml",
Expand Down
23 changes: 20 additions & 3 deletions src/tenzir_ship/cli/_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import shlex
import shutil
import subprocess
from dataclasses import dataclass, field
Expand All @@ -13,6 +14,7 @@
import click
from click.core import ParameterSource
from packaging.version import InvalidVersion, Version
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
Expand All @@ -30,6 +32,7 @@
)
from ..utils import (
abort_on_user_interrupt,
console,
create_annotated_git_tag,
create_git_commit,
emit_output,
Expand Down Expand Up @@ -109,6 +112,12 @@ def fail(self, name: str) -> None:
if step.name == name:
step.status = StepStatus.FAILED

def update_command(self, name: str, command: str) -> None:
"""Update the command string for a step."""
for step in self.steps:
if step.name == name:
step.command = command


def _render_release_progress(tracker: StepTracker) -> None:
"""Render release progress summary to stderr on failure."""
Expand All @@ -120,22 +129,28 @@ def _render_release_progress(tracker: StepTracker) -> None:
for step in tracker.steps:
if step.status == StepStatus.COMPLETED:
icon = "[green]\u2714[/green]"
cmd = f"[dim]{step.command}[/dim]"
cmd = f"[dim]{escape(step.command)}[/dim]"
elif step.status == StepStatus.FAILED:
icon = "[red]\u2718[/red]"
cmd = f"[red]{step.command}[/red]"
cmd = f"[red]{escape(step.command)}[/red]"
elif step.status == StepStatus.SKIPPED:
continue # Don't show skipped steps
else: # PENDING
icon = "[dim]\u25cb[/dim]"
cmd = f"[dim]{step.command}[/dim]"
cmd = f"[dim]{escape(step.command)}[/dim]"
lines.append(f"{icon} {cmd}")

if lines:
content = Text.from_markup("\n".join(lines))
title = f"Release Progress ({progress})"
_print_renderable(Panel(content, title=title, border_style="red"))

for step in tracker.steps:
if step.status == StepStatus.FAILED:
console.print()
console.print("[bold]To retry the failed step, run:[/bold]", highlight=False)
console.print(f" {step.command}", highlight=False, markup=False, soft_wrap=True)


__all__ = [
"create_release",
Expand Down Expand Up @@ -671,6 +686,8 @@ def _fail_step_and_raise(step_name: str, exc: Exception) -> NoReturn:
command.append("--latest=false")
confirmation_action = "gh release create"

tracker.update_command("publish", shlex.join(command))

if not assume_yes:
prompt_question = f"Publish {manifest.version} to GitHub repository {config.repository}?"
log_info(prompt_question.lower())
Expand Down
78 changes: 78 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1883,6 +1883,84 @@ def fake_run(
assert commands[0][:3] == ["/usr/bin/gh", "release", "view"]


def test_release_publish_retry_hint_preserves_bracketed_title(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
runner = CliRunner()
project_dir = tmp_path / "project"
project_dir.mkdir()

add_entry = runner.invoke(
cli,
[
"--root",
str(project_dir),
"add",
"--title",
"Retry Hint Test",
"--type",
"feature",
"--description",
"Ensures retry hint output is copy-paste safe.",
"--author",
"codex",
],
)
assert add_entry.exit_code == 0, add_entry.output

create_release = runner.invoke(
cli,
[
"--root",
str(project_dir),
"release",
"create",
"v3.1.0",
"--title",
"[LTS] Stable",
"--yes",
],
)
assert create_release.exit_code == 0, create_release.output

config_path = project_dir / "config.yaml"
config_data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
config_data["repository"] = "tenzir/example"
config_path.write_text(yaml.safe_dump(config_data, sort_keys=False), encoding="utf-8")

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:
if len(args) >= 3 and args[1:3] == ["release", "view"]:
raise subprocess.CalledProcessError(returncode=1, cmd=args)
if len(args) >= 3 and args[1:3] == ["release", "create"]:
raise subprocess.CalledProcessError(returncode=23, cmd=args)
raise AssertionError(f"Unexpected command: {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",
"v3.1.0",
"--yes",
],
)
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


def test_release_publish_updates_existing_release(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
Loading