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
62 changes: 61 additions & 1 deletion comfy_cli/command/custom_nodes/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,14 +1023,69 @@ def validate():
# print("[green]✓ All validation checks passed successfully[/green]")


def resolve_publish_changelog(changelog: str | None, changelog_file: str | None) -> str:
"""
Resolve the changelog text from --changelog, --changelog-file, or COMFY_NODE_CHANGELOG.

`--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:
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 os.environ.get("COMFY_NODE_CHANGELOG", "").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(
token: str | None = typer.Option(None, "--token", help="Personal Access Token for publishing", hide_input=True),
changelog: str | None = typer.Option(
None,
"--changelog",
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,
"--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.
"""
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()

# Prompt for API Key
Expand All @@ -1043,7 +1098,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
Expand Down
9 changes: 7 additions & 2 deletions comfy_cli/registry/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion comfy_cli/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
260 changes: 260 additions & 0 deletions tests/comfy_cli/command/nodes/test_publish.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import contextlib
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from typer.testing import CliRunner
Expand Down Expand Up @@ -204,3 +206,261 @@ 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_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(
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)
Loading
Loading