From ceb15e4cf9cbc72d4394a34f44e73f382a0d187a Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Fri, 12 Jun 2026 07:01:46 +0000 Subject: [PATCH 1/5] feat(node): add changelog support to comfy node publish --- comfy_cli/command/custom_nodes/command.py | 55 ++++++++++++++++++++++- comfy_cli/registry/api.py | 9 +++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/comfy_cli/command/custom_nodes/command.py b/comfy_cli/command/custom_nodes/command.py index 2c41b673..edd3f0cb 100644 --- a/comfy_cli/command/custom_nodes/command.py +++ b/comfy_cli/command/custom_nodes/command.py @@ -9,6 +9,7 @@ from typing import Annotated import typer +from click.core import ParameterSource from rich import print from rich.console import Console @@ -1023,14 +1024,61 @@ def validate(): # print("[green]✓ All validation checks passed successfully[/green]") +def resolve_publish_changelog(ctx: typer.Context, changelog: str | None, changelog_file: str | None) -> str: + """ + Resolve the changelog text from --changelog/COMFY_NODE_CHANGELOG or --changelog-file. + + `--changelog-file -` reads stdin. An explicit --changelog-file overrides an + env-provided changelog; combining it with an explicit --changelog is an error. + """ + if changelog is not None and changelog_file is not None: + if ctx.get_parameter_source("changelog") == ParameterSource.COMMANDLINE: + print("[red]Error: --changelog and --changelog-file are mutually exclusive.[/red]") + raise typer.Exit(code=1) + changelog = None + + if changelog_file is None: + return (changelog or "").strip() + + if changelog_file == "-": + try: + return sys.stdin.buffer.read().decode("utf-8-sig").strip() + except (OSError, UnicodeDecodeError) as e: + print(f"[red]Error: could not read changelog from stdin: {e}[/red]") + raise typer.Exit(code=1) + + try: + # `utf-8-sig` strips a leading BOM, mirroring pyproject.toml parsing. + with open(changelog_file, encoding="utf-8-sig") as f: + return f.read().strip() + except (OSError, UnicodeDecodeError) as e: + print(f"[red]Error: could not read changelog file `{changelog_file}`: {e}[/red]") + raise typer.Exit(code=1) + + @app.command("publish", help="Publish node to registry") @tracking.track_command("publish") def publish( + ctx: typer.Context, token: str | None = typer.Option(None, "--token", help="Personal Access Token for publishing", hide_input=True), + changelog: str | None = typer.Option( + None, + "--changelog", + envvar="COMFY_NODE_CHANGELOG", + help="Changelog text for this version, shown in the registry's Updates section.", + ), + changelog_file: str | None = typer.Option( + None, + "--changelog-file", + help="Read the changelog for this version from a file; use '-' to read stdin. " + "Mutually exclusive with --changelog.", + ), ): """ Publish a node with optional validation. """ + changelog_text = resolve_publish_changelog(ctx, changelog, changelog_file) + config = validate_node_for_publishing() # Prompt for API Key @@ -1043,7 +1091,12 @@ def publish( # Call API to fetch node version with the token in the body typer.echo("Publishing node version...") try: - response = registry_api.publish_node_version(config, token) + response = registry_api.publish_node_version(config, token, changelog=changelog_text) + if changelog_text and (response.node_version.changelog or "").strip() != changelog_text: + print( + "[yellow]Warning: the registry did not echo the changelog back; " + "the Updates section may not show it for this version.[/yellow]" + ) # Zip up all files in the current directory, respecting .gitignore files. signed_url = response.signedUrl zip_filename = NODE_ZIP_FILENAME diff --git a/comfy_cli/registry/api.py b/comfy_cli/registry/api.py index 148de31a..928b3606 100644 --- a/comfy_cli/registry/api.py +++ b/comfy_cli/registry/api.py @@ -27,13 +27,17 @@ def determine_base_url(self): else: return "https://api.comfy.org" - def publish_node_version(self, node_config: PyProjectConfig, token) -> PublishNodeVersionResponse: + def publish_node_version( + self, node_config: PyProjectConfig, token, changelog: str | None = None + ) -> PublishNodeVersionResponse: """ Publishes a new version of a node. Args: node_config (PyProjectConfig): The node configuration. token (str): The token to authenticate with the API server. + changelog (str | None): Optional changelog text stored on the published + version and shown in the registry's Updates section. Returns: PublishNodeVersionResponse: The response object from the API server. @@ -69,7 +73,8 @@ def publish_node_version(self, node_config: PyProjectConfig, token) -> PublishNo "supported_comfyui_frontend_version": node_config.project.supported_comfyui_frontend_version, }, } - print(request_body) + if changelog: + request_body["node_version"]["changelog"] = changelog url = f"{self.base_url}/publishers/{node_config.tool_comfy.publisher_id}/nodes/{node_config.project.name}/versions" headers = {"Content-Type": "application/json"} body = request_body From 6515c21eb120087601a5083e9bceef9aef41a0cd Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Fri, 12 Jun 2026 07:10:13 +0000 Subject: [PATCH 2/5] test: cover node publish changelog inputs and request body --- tests/comfy_cli/command/nodes/test_publish.py | 246 ++++++++++++++++++ tests/comfy_cli/registry/test_api.py | 58 +++++ 2 files changed, 304 insertions(+) diff --git a/tests/comfy_cli/command/nodes/test_publish.py b/tests/comfy_cli/command/nodes/test_publish.py index 9dfd1988..b178f69b 100644 --- a/tests/comfy_cli/command/nodes/test_publish.py +++ b/tests/comfy_cli/command/nodes/test_publish.py @@ -1,3 +1,5 @@ +import contextlib +from types import SimpleNamespace from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -204,3 +206,247 @@ def test_publish_with_includes_parameter(): assert mock_publish.called assert mock_zip.called assert mock_upload.called + + +@contextlib.contextmanager +def publish_flow_mocks(echo_changelog=None): + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + + response = MagicMock(signedUrl="https://test.url") + response.node_version.changelog = echo_changelog + + with ( + patch("subprocess.run", return_value=mock_result), + patch("comfy_cli.command.custom_nodes.command.extract_node_configuration") as mock_extract, + patch( + "comfy_cli.command.custom_nodes.command.registry_api.publish_node_version", return_value=response + ) as mock_publish, + patch("comfy_cli.command.custom_nodes.command.zip_files") as mock_zip, + patch("comfy_cli.command.custom_nodes.command.upload_file_to_signed_url") as mock_upload, + ): + mock_extract.return_value = create_mock_config() + yield SimpleNamespace(extract=mock_extract, publish=mock_publish, zip=mock_zip, upload=mock_upload) + + +def flatten(output: str) -> str: + # rich wraps long lines at terminal width; collapse whitespace before matching + return " ".join(output.split()) + + +def test_publish_changelog_flag_is_stripped_and_sent(): + with publish_flow_mocks(echo_changelog="Fixed a bug") as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", " Fixed a bug "]) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "Fixed a bug" + + +def test_publish_changelog_file_is_read_with_bom_stripped(tmp_path): + changelog_path = tmp_path / "notes.md" + changelog_path.write_text("## 1.0.1\n- multi\n- line\n", encoding="utf-8-sig") + + with publish_flow_mocks(echo_changelog="## 1.0.1\n- multi\n- line") as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog-file", str(changelog_path)]) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "## 1.0.1\n- multi\n- line" + + +def test_publish_changelog_file_dash_reads_stdin(): + with publish_flow_mocks(echo_changelog="from stdin\nline two") as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", "-"], + input="from stdin\nline two\n", + ) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "from stdin\nline two" + + +def test_publish_changelog_stdin_strips_bom(): + with publish_flow_mocks(echo_changelog="piped notes") as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", "-"], + input="\ufeffpiped notes\n", + ) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "piped notes" + + +def test_publish_changelog_stdin_invalid_utf8_fails(): + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", "-"], + input=b"\xff\xfe\x00bad", + ) + + assert result.exit_code == 1 + assert "could not read changelog from stdin" in flatten(result.stdout) + assert not mocks.extract.called + assert not mocks.publish.called + + +def test_publish_changelog_env_var_is_used(): + with publish_flow_mocks(echo_changelog="from env") as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token"], + env={"COMFY_NODE_CHANGELOG": "from env"}, + ) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "from env" + + +def test_publish_changelog_flag_overrides_env_var(): + with publish_flow_mocks(echo_changelog="from flag") as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog", "from flag"], + env={"COMFY_NODE_CHANGELOG": "from env"}, + ) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "from flag" + + +def test_publish_changelog_file_overrides_env_var(tmp_path): + changelog_path = tmp_path / "notes.md" + changelog_path.write_text("from file", encoding="utf-8") + + with publish_flow_mocks(echo_changelog="from file") as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", str(changelog_path)], + env={"COMFY_NODE_CHANGELOG": "from env"}, + ) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "from file" + + +def test_publish_changelog_flags_are_mutually_exclusive(tmp_path): + changelog_path = tmp_path / "notes.md" + changelog_path.write_text("from file", encoding="utf-8") + + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog", "text", "--changelog-file", str(changelog_path)], + ) + + assert result.exit_code == 1 + assert "mutually exclusive" in flatten(result.stdout) + assert not mocks.extract.called + assert not mocks.publish.called + + +def test_publish_changelog_file_missing_fails_before_validation(tmp_path): + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", str(tmp_path / "missing.md")], + ) + + assert result.exit_code == 1 + assert "could not read changelog file" in flatten(result.stdout) + assert not mocks.extract.called + assert not mocks.publish.called + + +def test_publish_changelog_file_invalid_utf8_fails(tmp_path): + changelog_path = tmp_path / "bad.md" + changelog_path.write_bytes(b"\xff\xfe\x00bad") + + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", str(changelog_path)], + ) + + assert result.exit_code == 1 + assert "could not read changelog file" in flatten(result.stdout) + assert not mocks.publish.called + + +def test_publish_changelog_file_directory_fails(tmp_path): + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--token", "test-token", "--changelog-file", str(tmp_path)], + ) + + assert result.exit_code == 1 + assert "could not read changelog file" in flatten(result.stdout) + assert not mocks.publish.called + + +def test_publish_changelog_whitespace_only_file_treated_as_absent(tmp_path): + changelog_path = tmp_path / "blank.md" + changelog_path.write_text(" \n\t\n", encoding="utf-8") + + with publish_flow_mocks() as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog-file", str(changelog_path)]) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "" + assert "did not echo" not in flatten(result.stdout) + + +def test_publish_warns_when_registry_drops_changelog(): + with publish_flow_mocks(echo_changelog="") as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", "some notes"]) + + assert result.exit_code == 0 + assert "Warning: the registry did not echo the changelog back" in flatten(result.stdout) + assert mocks.zip.called + assert mocks.upload.called + + +def test_publish_warns_when_echo_changelog_is_none(): + with publish_flow_mocks(echo_changelog=None) as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", "some notes"]) + + assert result.exit_code == 0 + assert "Warning: the registry did not echo the changelog back" in flatten(result.stdout) + assert mocks.upload.called + + +def test_publish_no_warning_when_changelog_echoed(): + with publish_flow_mocks(echo_changelog="some notes") as _mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", "some notes"]) + + assert result.exit_code == 0 + assert "did not echo" not in flatten(result.stdout) + + +def test_publish_no_warning_when_echo_differs_only_by_whitespace(): + with publish_flow_mocks(echo_changelog=" some notes \n") as _mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", "some notes"]) + + assert result.exit_code == 0 + assert "did not echo" not in flatten(result.stdout) + + +def test_publish_without_changelog_sends_empty_and_does_not_warn(): + with publish_flow_mocks() as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token"]) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "" + assert "did not echo" not in flatten(result.stdout) + + +def test_publish_empty_changelog_flag_treated_as_absent(): + with publish_flow_mocks() as mocks: + result = runner.invoke(app, ["publish", "--token", "test-token", "--changelog", ""]) + + assert result.exit_code == 0 + assert mocks.publish.call_args.kwargs["changelog"] == "" + assert "did not echo" not in flatten(result.stdout) diff --git a/tests/comfy_cli/registry/test_api.py b/tests/comfy_cli/registry/test_api.py index deab2010..9521b65e 100644 --- a/tests/comfy_cli/registry/test_api.py +++ b/tests/comfy_cli/registry/test_api.py @@ -1,3 +1,6 @@ +import contextlib +import io +import json import unittest from unittest.mock import MagicMock, patch @@ -70,6 +73,61 @@ def test_publish_node_version_failure(self, mock_post): self.registry_api.publish_node_version(self.node_config, self.token) self.assertIn("Failed to publish node version", str(context.exception)) + def _mock_publish_response(self, changelog=""): + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "node_version": { + "id": "7f2d0a4e-0000-4000-8000-000000000001", + "version": "0.1.0", + "changelog": changelog, + "dependencies": ["dep1", "dep2"], + "deprecated": False, + "downloadUrl": "https://example.com/download", + }, + "signedUrl": "https://example.com/signed", + } + return mock_response + + @patch("requests.post") + def test_publish_node_version_sends_changelog_verbatim(self, mock_post): + changelog = "## 0.1.0\n\n- Fixed flux capacitor ⚡\n- Added docs" + mock_post.return_value = self._mock_publish_response(changelog=changelog) + + response = self.registry_api.publish_node_version(self.node_config, self.token, changelog=changelog) + + sent_body = json.loads(mock_post.call_args[1]["data"]) + self.assertEqual(sent_body["node_version"]["changelog"], changelog) + self.assertEqual(response.node_version.changelog, changelog) + + @patch("requests.post") + def test_publish_node_version_omits_changelog_when_not_given(self, mock_post): + mock_post.return_value = self._mock_publish_response() + + self.registry_api.publish_node_version(self.node_config, self.token) + + sent_body = json.loads(mock_post.call_args[1]["data"]) + self.assertNotIn("changelog", sent_body["node_version"]) + + @patch("requests.post") + def test_publish_node_version_omits_changelog_when_empty(self, mock_post): + mock_post.return_value = self._mock_publish_response() + + self.registry_api.publish_node_version(self.node_config, self.token, changelog="") + + sent_body = json.loads(mock_post.call_args[1]["data"]) + self.assertNotIn("changelog", sent_body["node_version"]) + + @patch("requests.post") + def test_publish_node_version_does_not_print_token(self, mock_post): + mock_post.return_value = self._mock_publish_response() + + captured = io.StringIO() + with contextlib.redirect_stdout(captured): + self.registry_api.publish_node_version(self.node_config, self.token, changelog="notes") + + self.assertNotIn(self.token, captured.getvalue()) + @patch("requests.get") def test_list_all_nodes_success(self, mock_get): mock_response = MagicMock() From 0495c098b55aa6e13968ee203d6280a6ab950e9f Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Fri, 12 Jun 2026 07:30:59 +0000 Subject: [PATCH 3/5] fix(node): read COMFY_NODE_CHANGELOG manually to avoid click enum identity mismatch --- comfy_cli/command/custom_nodes/command.py | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/comfy_cli/command/custom_nodes/command.py b/comfy_cli/command/custom_nodes/command.py index edd3f0cb..49410b28 100644 --- a/comfy_cli/command/custom_nodes/command.py +++ b/comfy_cli/command/custom_nodes/command.py @@ -9,7 +9,6 @@ from typing import Annotated import typer -from click.core import ParameterSource from rich import print from rich.console import Console @@ -1024,21 +1023,23 @@ def validate(): # print("[green]✓ All validation checks passed successfully[/green]") -def resolve_publish_changelog(ctx: typer.Context, changelog: str | None, changelog_file: str | None) -> str: +def resolve_publish_changelog(changelog: str | None, changelog_file: str | None) -> str: """ - Resolve the changelog text from --changelog/COMFY_NODE_CHANGELOG or --changelog-file. + Resolve the changelog text from --changelog, --changelog-file, or COMFY_NODE_CHANGELOG. - `--changelog-file -` reads stdin. An explicit --changelog-file overrides an - env-provided changelog; combining it with an explicit --changelog is an error. + `--changelog-file -` reads stdin. The env var is read manually (not via the + option's `envvar`) so that either explicit flag overrides it and the two + flags only conflict when both are actually passed on the command line. """ if changelog is not None and changelog_file is not None: - if ctx.get_parameter_source("changelog") == ParameterSource.COMMANDLINE: - print("[red]Error: --changelog and --changelog-file are mutually exclusive.[/red]") - raise typer.Exit(code=1) - changelog = None + print("[red]Error: --changelog and --changelog-file are mutually exclusive.[/red]") + raise typer.Exit(code=1) + + if changelog is not None: + return changelog.strip() if changelog_file is None: - return (changelog or "").strip() + return os.environ.get("COMFY_NODE_CHANGELOG", "").strip() if changelog_file == "-": try: @@ -1059,13 +1060,12 @@ def resolve_publish_changelog(ctx: typer.Context, changelog: str | None, changel @app.command("publish", help="Publish node to registry") @tracking.track_command("publish") def publish( - ctx: typer.Context, token: str | None = typer.Option(None, "--token", help="Personal Access Token for publishing", hide_input=True), changelog: str | None = typer.Option( None, "--changelog", - envvar="COMFY_NODE_CHANGELOG", - help="Changelog text for this version, shown in the registry's Updates section.", + help="Changelog text for this version, shown in the registry's Updates section " + "(env var: COMFY_NODE_CHANGELOG).", ), changelog_file: str | None = typer.Option( None, @@ -1077,7 +1077,7 @@ def publish( """ Publish a node with optional validation. """ - changelog_text = resolve_publish_changelog(ctx, changelog, changelog_file) + changelog_text = resolve_publish_changelog(changelog, changelog_file) config = validate_node_for_publishing() From f7f6b8f66bc9f429665cf97b342f54857653b717 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Fri, 12 Jun 2026 08:20:51 +0000 Subject: [PATCH 4/5] fix(tracking): redact token and changelog values from command telemetry --- comfy_cli/tracking.py | 4 +++- tests/comfy_cli/test_tracking.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/comfy_cli/tracking.py b/comfy_cli/tracking.py index 484e54e5..a42d3b9e 100644 --- a/comfy_cli/tracking.py +++ b/comfy_cli/tracking.py @@ -40,7 +40,9 @@ # Kwargs whose values must never reach tracking system. # The key is kept (with a redacted marker) so we can still see whether the option was supplied. -SENSITIVE_TRACKING_KEYS = frozenset({"api_key"}) +# `token` is the registry publisher PAT; `changelog` is bulky free text (up to a whole +# GitHub release body) with no analytics value beyond its presence. +SENSITIVE_TRACKING_KEYS = frozenset({"api_key", "token", "changelog"}) # Generate a unique tracing ID per command. config_manager = ConfigManager() diff --git a/tests/comfy_cli/test_tracking.py b/tests/comfy_cli/test_tracking.py index 44ec4b7a..557da54e 100644 --- a/tests/comfy_cli/test_tracking.py +++ b/tests/comfy_cli/test_tracking.py @@ -122,6 +122,22 @@ def some_cmd(workflow, api_key=None): _, _, properties = _last_track_call(tracking_module.provider) assert properties["api_key"] is None + def test_publish_token_and_changelog_values_are_redacted(self, tracking_module): + tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") + + @tracking_module.track_command("publish") + def publish(token=None, changelog=None, changelog_file=None): + return None + + publish(token="pat-supersecret", changelog="## 1.0\n- fix things", changelog_file=None) + + _, _, properties = _last_track_call(tracking_module.provider) + assert properties["token"] == "" + assert properties["changelog"] == "" + assert properties["changelog_file"] is None + assert "pat-supersecret" not in str(properties) + assert "fix things" not in str(properties) + class TestInitTrackingRoundTrip: """End-to-end: init_tracking() writes the string "False"/"True", and track_event honors it. From 3f41ffc874b9ec16123ff9339165463059061d2c Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Fri, 12 Jun 2026 08:20:51 +0000 Subject: [PATCH 5/5] fix(node): require --token when reading changelog from stdin --- comfy_cli/command/custom_nodes/command.py | 7 +++++++ tests/comfy_cli/command/nodes/test_publish.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/comfy_cli/command/custom_nodes/command.py b/comfy_cli/command/custom_nodes/command.py index 49410b28..971d99af 100644 --- a/comfy_cli/command/custom_nodes/command.py +++ b/comfy_cli/command/custom_nodes/command.py @@ -1077,6 +1077,13 @@ def publish( """ Publish a node with optional validation. """ + if changelog_file == "-" and not token: + print( + "[red]Error: reading the changelog from stdin requires --token " + "(the API-key prompt cannot read from stdin).[/red]" + ) + raise typer.Exit(code=1) + changelog_text = resolve_publish_changelog(changelog, changelog_file) config = validate_node_for_publishing() diff --git a/tests/comfy_cli/command/nodes/test_publish.py b/tests/comfy_cli/command/nodes/test_publish.py index b178f69b..42905a0e 100644 --- a/tests/comfy_cli/command/nodes/test_publish.py +++ b/tests/comfy_cli/command/nodes/test_publish.py @@ -278,6 +278,20 @@ def test_publish_changelog_stdin_strips_bom(): assert mocks.publish.call_args.kwargs["changelog"] == "piped notes" +def test_publish_changelog_stdin_without_token_fails_before_consuming_stdin(): + with publish_flow_mocks() as mocks: + result = runner.invoke( + app, + ["publish", "--changelog-file", "-"], + input="notes\n", + ) + + assert result.exit_code == 1 + assert "requires --token" in flatten(result.stdout) + assert not mocks.extract.called + assert not mocks.publish.called + + def test_publish_changelog_stdin_invalid_utf8_fails(): with publish_flow_mocks() as mocks: result = runner.invoke(