Skip to content
Draft
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
94 changes: 94 additions & 0 deletions .github/scripts/generate_release_metadata.py
Original file line number Diff line number Diff line change
@@ -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 <release-plan.json>", 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())
44 changes: 38 additions & 6 deletions .github/workflows/trigger-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion skills/tenzir-ship/references/create-remote-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,6 +58,9 @@ gh workflow run <workflow-file> \
-f title="<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
Expand Down
17 changes: 17 additions & 0 deletions src/tenzir_ship/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CLIContext,
ShowView,
_get_latest_release_manifest,
build_release_plan_payload,
create_cli_context,
create_entry,
create_release,
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/tenzir_ship/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@

# Re-export release commands
from ._release import (
build_release_plan_payload,
create_release,
publish_release,
release_group,
Expand Down Expand Up @@ -173,6 +174,7 @@
"create_entry",
"add",
# Release
"build_release_plan_payload",
"create_release",
"publish_release",
"release_group",
Expand Down
Loading
Loading