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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ uvx --from 'release-creator' release-creator notes \
For automation, replace `release-creator` after `--from` with a tested `release-creator==VERSION`. The command reads the
tagged section, maps `Added` to `Features` and `Fixed` to `Bug fixes`, changes linked author handles to GitHub mentions,
and leaves bullets unwrapped. It keeps stable category anchors, omits empty categories when they are absent from the
source, and adds artifact and comparison links. Use `--artifact-label` for an ecosystem other than PyPI; omit
`--artifact-url` when there is no external artifact.
source, and adds artifact and comparison links. Use `--artifact-label` for an ecosystem other than PyPI, repeat both
options for each published package, and omit `--artifact-url` when there is no external artifact.

The command also accepts a `.rst` changelog with the same category names. It links `:issue:` roles to issues and `:pr:`
or `:pull:` roles to pull requests:
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/+artifact-links.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Repeat ``--artifact-url``, with a matching ``--artifact-label`` for each, to list every published package on one
line. Each package link starts with an emoji for its ecosystem, such as 🐍 for PyPI, and the comparison link now
reads ``📜 Full Changelog``.
9 changes: 6 additions & 3 deletions docs/how-to/render-notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ the :doc:`tags command <select-release-tag>` can find it. ``--artifact-url`` add

- Avoid stale cache reads ([#42](https://github.com/owner/project/pull/42)) by @alice.

[PyPI release](https://pypi.org/project/project/1.2.3/)
[🐍 PyPI release](https://pypi.org/project/project/1.2.3/)

[Full Changelog](https://github.com/owner/project/compare/1.2.2...1.2.3)
[📜 Full Changelog](https://github.com/owner/project/compare/1.2.2...1.2.3)

Review that file before passing it to ``gh release create --notes-file release-notes.md``. The ``by`` credit stays in
the changelog; the GitHub body uses an ``@alice`` mention.
Expand All @@ -84,7 +84,10 @@ Changelog`` line, ``notes`` replaces it with one link built from ``--previous``
anchors remain stable. See :doc:`the format reference <../reference/formats>` for every category name.

Omit ``--artifact-url`` if there is no external artifact. Use ``--artifact-label`` to name another ecosystem's package
link. If ``--previous`` is absent, the final link points to the commits at this tag instead of a comparison.
link. A project that publishes several packages repeats ``--artifact-url``, each followed by its ``--artifact-label``;
the links share one line, separated by commas. Each link starts with an emoji picked from its host: 🐍 for PyPI, 🧩 for
the JetBrains Marketplace, and 📦 for anything else. If ``--previous`` is absent, the final link points to the commits
at this tag instead of a comparison.

Shared changelog with separate packages
=======================================
Expand Down
4 changes: 2 additions & 2 deletions docs/tutorial/first-release.rst
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ linked author to an ``@alice`` mention. With no previous numeric release, the fi

- Avoid stale cache reads ([#42](https://github.com/owner/project/pull/42)) by @alice.

[PyPI release](https://pypi.org/project/project/0.1.0/)
[🐍 PyPI release](https://pypi.org/project/project/0.1.0/)

[Full Changelog](https://github.com/owner/project/commits/0.1.0)
[📜 Full Changelog](https://github.com/owner/project/commits/0.1.0)

Review the complete body, then create the GitHub Release if the repository does not do that in its tag workflow:

Expand Down
24 changes: 17 additions & 7 deletions src/release_creator/notes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,28 @@ def register(commands: argparse._SubParsersAction[argparse.ArgumentParser]) -> N
notes.add_argument("--repository", required=True, help="GitHub owner/repository")
notes.add_argument("--package", help="package section in a shared Markdown changelog")
notes.add_argument("--previous", help="previous tag for the comparison link")
notes.add_argument("--artifact-url", help="link to the published package or artifact")
notes.add_argument("--artifact-label", default="PyPI release", help="text for the artifact link")
notes.add_argument(
"--artifact-url", action="append", default=[], help="link to a published package or artifact; repeatable"
)
notes.add_argument(
"--artifact-label",
action="append",
default=[],
help="text for each --artifact-url, in the same order; default PyPI release",
)
notes.set_defaults(handler=_render)


def _render(args: argparse.Namespace) -> None:
labels = args.artifact_label or ["PyPI release"] * len(args.artifact_url)
if len(labels) != len(args.artifact_url):
msg = f"got {len(labels)} --artifact-label for {len(args.artifact_url)} --artifact-url"
raise ValueError(msg)
context = ReleaseContext(
tag=args.tag,
repository=args.repository,
previous=args.previous,
artifact_url=args.artifact_url,
artifact_label=args.artifact_label,
artifacts=tuple(zip(labels, args.artifact_url, strict=True)),
package=args.package,
)
changelog = args.changelog.read_text(encoding="utf-8")
Expand All @@ -49,10 +59,10 @@ def _render(args: argparse.Namespace) -> None:
explain(f"Comparison link: commits/{args.tag} (no --previous tag)")
else:
explain(f"Comparison link: compare/{args.previous}...{args.tag}")
if args.artifact_url is None:
if not args.artifact_url:
explain("Artifact link: none (no --artifact-url)")
else:
explain(f"Artifact link: {args.artifact_label} -> {args.artifact_url}")
for label, url in context.artifacts:
explain(f"Artifact link: {label} -> {url}")
sys.stdout.write(notes)


Expand Down
27 changes: 20 additions & 7 deletions src/release_creator/notes/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@

lazy import re
lazy from dataclasses import dataclass
lazy from typing import Final
lazy from urllib.parse import urlsplit

lazy import mdformat
lazy from packaging.version import InvalidVersion, Version

lazy from release_creator.categories import RELEASE_CATEGORIES
lazy from release_creator.notes.rst import TILDE_PLACEHOLDER, render_rst_section

_ECOSYSTEM_EMOJI: Final = {"pypi.org": "🐍", "plugins.jetbrains.com": "🧩"}


@dataclass(frozen=True, kw_only=True)
class ReleaseContext:
Expand All @@ -20,8 +24,7 @@ class ReleaseContext:
tag: str
repository: str
previous: str | None = None
artifact_url: str | None = None
artifact_label: str = "PyPI release"
artifacts: tuple[tuple[str, str], ...] = ()
package: str | None = None


Expand Down Expand Up @@ -49,15 +52,19 @@ def render_rst(changelog: str, context: ReleaseContext) -> str:


def _finish(notes: str, context: ReleaseContext) -> str:
if context.artifact_url is not None:
link = f"[{context.artifact_label}]({context.artifact_url})"
notes = f"{notes}\n\n{link}" if notes else link
if context.artifacts:
links = ", ".join(f"[{_ecosystem_emoji(url)} {label}]({url})" for label, url in context.artifacts)
notes = f"{notes}\n\n{links}" if notes else links
comparison = (
f"compare/{context.previous}...{context.tag}" if context.previous is not None else f"commits/{context.tag}"
)
if notes:
notes += "\n\n"
return f"{notes}[Full Changelog](https://github.com/{context.repository}/{comparison})\n"
return f"{notes}[📜 Full Changelog](https://github.com/{context.repository}/{comparison})\n"


def _ecosystem_emoji(url: str) -> str:
return _ECOSYSTEM_EMOJI.get(urlsplit(url).hostname or "", "📦")


def _extract_rst_body(changelog: str, context: ReleaseContext) -> str:
Expand Down Expand Up @@ -235,7 +242,13 @@ def _convert_markdown_body(lines: list[str], category_prefix: str) -> str:


def _is_comparison_line(line: str) -> bool:
return line.strip().startswith(("**Full Changelog**:", "Full Changelog:", "[Full Changelog](", "`Full Changelog <"))
return line.strip().startswith((
"**Full Changelog**:",
"Full Changelog:",
"[Full Changelog](",
"[📜 Full Changelog](",
"`Full Changelog <",
))


def _is_version(tag: str) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion tests/release_creator/contributors/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def test_add_new_contributors_package_renders_in_notes(changelog: Path, github:
'<a name="release-pyproject-fmt-1-1-0-new-contributors"></a>\n\n### New Contributors\n\n'
"- [@alice](https://github.com/alice) made their first contribution in "
"[#42](https://github.com/tox-dev/toml-fmt/pull/42)\n\n"
"[Full Changelog](https://github.com/tox-dev/toml-fmt/commits/pyproject-fmt/1.1.0)\n"
"[📜 Full Changelog](https://github.com/tox-dev/toml-fmt/commits/pyproject-fmt/1.1.0)\n"
)


Expand Down
6 changes: 3 additions & 3 deletions tests/release_creator/contributors/test_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ def test_release_body_matches_changelog_section(tmp_path: Path, github: GitHub,
tag="1.2.3",
repository="tox-dev/example",
previous="1.2.2",
artifact_url="https://pypi.org/project/example/1.2.3/",
artifacts=(("PyPI release", "https://pypi.org/project/example/1.2.3/"),),
)

body = (render_markdown if extension == "md" else render_rst)(changelog.read_text(), context)
Expand All @@ -439,8 +439,8 @@ def test_release_body_matches_changelog_section(tmp_path: Path, github: GitHub,
+ added
+ '<a name="release-1-2-3-new-contributors"></a>\n\n### New Contributors\n\n'
+ contributor
+ "[PyPI release](https://pypi.org/project/example/1.2.3/)\n\n"
"[Full Changelog](https://github.com/tox-dev/example/compare/1.2.2...1.2.3)\n"
+ "[🐍 PyPI release](https://pypi.org/project/example/1.2.3/)\n\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/compare/1.2.2...1.2.3)\n"
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def test_notes_package_drops_anchor_after_heading() -> None:
"### Features\n\n"
"- **Require 3.11 and default to 3.15.** ([#480](https://github.com/tox-dev/toml-fmt/pull/480)) by "
"[@gaborbernat](https://github.com/gaborbernat).\n\n"
"[Full Changelog](https://github.com/tox-dev/toml-fmt/commits/pyproject-fmt/2.30.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/toml-fmt/commits/pyproject-fmt/2.30.3)\n"
)


Expand Down
12 changes: 6 additions & 6 deletions tests/release_creator/fragments/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def test_markdown_template_keeps_numbered_and_named_pull_requests(
f"by [@alice](https://github.com/alice).{issue_suffix}\n"
"- Fix a named bug ([#124](https://github.com/tox-dev/example/pull/124)) "
"by [@alice](https://github.com/alice).\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand Down Expand Up @@ -108,7 +108,7 @@ def test_markdown_template_preserves_prose_continuations(tmp_path: Path, source:
assert notes == (
'<a name="release-1-2-3-fixed"></a>\n\n### Bug fixes\n\n'
f"{entry}\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand Down Expand Up @@ -164,7 +164,7 @@ def test_markdown_template_preserves_balanced_literal_block(tmp_path: Path) -> N
"- Add support ([#42](https://github.com/tox-dev/example/pull/42)) "
"by [@alice](https://github.com/alice).\n\n"
" <pre>sample</pre>\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand Down Expand Up @@ -311,7 +311,7 @@ def test_rst_template_renders_notes_with_stable_anchors(
'<a name="release-1-2-3-fixed"></a>\n\n### Bug fixes\n\n'
"- Fix the loader ([#2](https://github.com/tox-dev/example/pull/2)) by @alice. "
"([#1](https://github.com/tox-dev/example/pull/1))\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand All @@ -327,7 +327,7 @@ def test_rst_template_renders_literal_marker_credit(tmp_path: Path) -> None:
'<a name="release-1-2-3-fixed"></a>\n\n### Bug fixes\n\n'
"- Fix the loader ([#2](https://github.com/tox-dev/example/pull/2)) by @alice:\n\n"
" ```\n code output\n ```\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand All @@ -342,7 +342,7 @@ def test_rst_template_preserves_two_paragraphs(tmp_path: Path) -> None:
assert notes == (
'<a name="release-1-2-3-fixed"></a>\n\n### Bug fixes\n\n'
"- First. Second ([#2](https://github.com/tox-dev/example/pull/2)) by @alice.\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand Down
32 changes: 31 additions & 1 deletion tests/release_creator/notes/test_explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,44 @@ def test_notes_explains_on_stderr(
)


def test_notes_pairs_each_artifact_with_its_label(
changelog: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_render(
monkeypatch,
changelog,
*("--artifact-url", "https://pypi.org/project/example/1.2.3/", "--artifact-label", "PyPI: example"),
*("--artifact-url", "https://pypi.org/project/example-bare/1.2.3/", "--artifact-label", "PyPI: example-bare"),
)

assert capsys.readouterr().err.splitlines()[-2:] == [
"Artifact link: PyPI: example -> https://pypi.org/project/example/1.2.3/",
"Artifact link: PyPI: example-bare -> https://pypi.org/project/example-bare/1.2.3/",
]


def test_notes_rejects_unpaired_artifact_label(
changelog: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
with pytest.raises(SystemExit):
_render(
monkeypatch,
changelog,
*("--artifact-url", "https://pypi.org/project/example/1.2.3/", "--artifact-label", "PyPI: example"),
*("--artifact-url", "https://pypi.org/project/example-bare/1.2.3/"),
)

assert capsys.readouterr().err.endswith("error: got 1 --artifact-label for 2 --artifact-url\n")


def test_notes_keeps_stdout_to_the_body(
changelog: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_render(monkeypatch, changelog)

assert capsys.readouterr().out == (
"### Features\n\n- Add a flag.\n- Add a cache.\n\n### Bug fixes\n\n- Correct a bug.\n\n"
"[Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
"[📜 Full Changelog](https://github.com/tox-dev/example/commits/1.2.3)\n"
)


Expand Down
Loading
Loading