Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0f5416624 | |||
| 1c74fcc81f | |||
| 8cb530954d |
@@ -6,3 +6,5 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.git/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
|
.gitea/
|
||||||
|
tools/
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here.
|
All notable changes to this project are documented here.
|
||||||
|
|
||||||
|
## 1.0.3 - 2026-08-26
|
||||||
|
|
||||||
|
- Route Registry publication through the Manager installation gate and keep
|
||||||
|
the API key out of process arguments and environment variables.
|
||||||
|
|
||||||
|
## 1.0.2 - 2026-08-26
|
||||||
|
|
||||||
|
- Add a credential-free ComfyUI Manager clone gate so Registry releases fail
|
||||||
|
when their declared public repository is missing, inaccessible, stale, or
|
||||||
|
inconsistent with the release commit and version.
|
||||||
|
|
||||||
|
## 1.0.1 - 2026-08-26
|
||||||
|
|
||||||
|
- Correct the public source repository and installation URL.
|
||||||
|
|
||||||
## 1.0.0 - 2026-08-26
|
## 1.0.0 - 2026-08-26
|
||||||
|
|
||||||
- Publish the timeline editor as a standalone ComfyUI node pack.
|
- Publish the timeline editor as a standalone ComfyUI node pack.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ ComfyUI. For a manual installation:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ComfyUI/custom_nodes
|
cd ComfyUI/custom_nodes
|
||||||
git clone https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor.git
|
git clone https://git.hoppingmadgames.com/hmg-comfy/ComfyUI_ETK_LTXV_Timeline_Editor.git
|
||||||
```
|
```
|
||||||
|
|
||||||
No model downloads or additional Python packages are required beyond a current
|
No model downloads or additional Python packages are required beyond a current
|
||||||
@@ -48,6 +48,29 @@ node --check web/etk_ltxv_timeline_image_editor.js
|
|||||||
find web/ltxv_timeline -name '*.js' -print0 | xargs -0 -n1 node --check
|
find web/ltxv_timeline -name '*.js' -print0 | xargs -0 -n1 node --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every Registry release tag must also pass the Manager installation gate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/verify_manager_install.py --source .
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate reads the public repository from `pyproject.toml`, disables ambient
|
||||||
|
Git credentials, performs Manager's recursive clone into a clean
|
||||||
|
`custom_nodes` directory, verifies the release commit and version tag, and
|
||||||
|
compiles every shipped Python and JavaScript source from the clone.
|
||||||
|
|
||||||
|
Registry publishing is supported only through the gated release command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m tools.publish_registry_release \
|
||||||
|
--changelog "Describe this release"
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs the Manager installation gate and Registry validation before invoking
|
||||||
|
publication. The API key is read interactively and sent to `comfy` over
|
||||||
|
standard input; it is never placed in process arguments or environment
|
||||||
|
variables. Direct `comfy node publish` calls bypass the required release gate.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GNU General Public License v3.0 or later. See [LICENSE](LICENSE).
|
GNU General Public License v3.0 or later. See [LICENSE](LICENSE).
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "etk-ltxv-timeline-editor"
|
name = "etk-ltxv-timeline-editor"
|
||||||
version = "1.0.0"
|
version = "1.0.3"
|
||||||
description = "A visual timeline editor for LTXV image guides, prompts, strengths, and frame positions in ComfyUI."
|
description = "A visual timeline editor for LTXV image guides, prompts, strengths, and frame positions in ComfyUI."
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -12,11 +12,14 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor"
|
Repository = "https://git.hoppingmadgames.com/hmg-comfy/ComfyUI_ETK_LTXV_Timeline_Editor"
|
||||||
Documentation = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor/src/branch/main/README.md"
|
Documentation = "https://git.hoppingmadgames.com/hmg-comfy/ComfyUI_ETK_LTXV_Timeline_Editor/src/branch/main/README.md"
|
||||||
"Bug Tracker" = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor/issues"
|
"Bug Tracker" = "https://git.hoppingmadgames.com/hmg-comfy/ComfyUI_ETK_LTXV_Timeline_Editor/issues"
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
PublisherId = "hmg"
|
PublisherId = "hmg"
|
||||||
DisplayName = "ETK LTXV Timeline Image Editor"
|
DisplayName = "ETK LTXV Timeline Image Editor"
|
||||||
requires-comfyui = ">=0.33.0"
|
requires-comfyui = ">=0.33.0"
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tools/__init__.py" = ["N999"]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools import publish_registry_release
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_stops_before_registry_when_manager_gate_fails(monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fail_gate(source):
|
||||||
|
raise RuntimeError("public clone failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(publish_registry_release, "verify_manager_install", fail_gate)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
publish_registry_release.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *args, **kwargs: calls.append((args, kwargs)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="public clone failed"):
|
||||||
|
publish_registry_release.publish_release(
|
||||||
|
tmp_path,
|
||||||
|
comfy="comfy",
|
||||||
|
token="private-token",
|
||||||
|
changelog="release",
|
||||||
|
)
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_keeps_token_out_of_arguments_and_environment(monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
publish_registry_release,
|
||||||
|
"verify_manager_install",
|
||||||
|
lambda source: ("1.0.3", "https://example.test/owner/repo", "abc123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_run(command, **kwargs):
|
||||||
|
calls.append((command, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(publish_registry_release.subprocess, "run", record_run)
|
||||||
|
publish_registry_release.publish_release(
|
||||||
|
Path(tmp_path),
|
||||||
|
comfy="comfy",
|
||||||
|
token="private-token",
|
||||||
|
changelog="release notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [command for command, _ in calls] == [
|
||||||
|
["comfy", "node", "validate"],
|
||||||
|
["comfy", "node", "publish"],
|
||||||
|
]
|
||||||
|
publish_kwargs = calls[1][1]
|
||||||
|
assert publish_kwargs["input"] == "private-token\n"
|
||||||
|
assert "private-token" not in repr(calls[1][0])
|
||||||
|
assert "private-token" not in repr(publish_kwargs["env"])
|
||||||
|
assert publish_kwargs["env"]["COMFY_NODE_CHANGELOG"] == "release notes"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[1] / "tools" / "verify_manager_install.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("verify_manager_install", SCRIPT)
|
||||||
|
release_gate = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
SPEC.loader.exec_module(release_gate)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_clone_command_matches_manager_install_shape(tmp_path):
|
||||||
|
destination = tmp_path / "custom_nodes" / "etk-ltxv-timeline-editor"
|
||||||
|
assert release_gate.manager_clone_command("https://example.test/owner/repo", destination) == [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"-v",
|
||||||
|
"--recursive",
|
||||||
|
"--progress",
|
||||||
|
"--",
|
||||||
|
"https://example.test/owner/repo",
|
||||||
|
str(destination),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_environment_removes_ambient_git_credentials(tmp_path):
|
||||||
|
env = release_gate.anonymous_git_environment(
|
||||||
|
{
|
||||||
|
"PATH": "/usr/bin",
|
||||||
|
"GIT_ASKPASS": "credential-helper",
|
||||||
|
"GIT_CONFIG_GLOBAL": "/secret/config",
|
||||||
|
"GIT_SSH_COMMAND": "ssh -i /secret/key",
|
||||||
|
},
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
assert env["GIT_ASKPASS"] == "/bin/false"
|
||||||
|
assert env["GIT_CONFIG_GLOBAL"] == "/dev/null"
|
||||||
|
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
||||||
|
assert env["HOME"] == str(tmp_path)
|
||||||
|
assert "GIT_SSH_COMMAND" not in env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"ssh://git@example.test/owner/repo",
|
||||||
|
"https://user:password@example.test/owner/repo",
|
||||||
|
"https://example.test/owner/repo?token=secret",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_public_repository_url_rejects_nonanonymous_forms(url):
|
||||||
|
with pytest.raises(release_gate.VerificationError):
|
||||||
|
release_gate.validate_public_repository_url(url)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Release tooling for the standalone timeline node pack."""
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish only after the public Manager installation path is verified."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tools.verify_manager_install import verify_manager_install
|
||||||
|
|
||||||
|
|
||||||
|
def publish_release(
|
||||||
|
source: Path,
|
||||||
|
*,
|
||||||
|
comfy: str,
|
||||||
|
token: str,
|
||||||
|
changelog: str,
|
||||||
|
) -> None:
|
||||||
|
verify_manager_install(source)
|
||||||
|
subprocess.run([comfy, "node", "validate"], cwd=source, check=True)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["COMFY_NODE_CHANGELOG"] = changelog
|
||||||
|
subprocess.run(
|
||||||
|
[comfy, "node", "publish"],
|
||||||
|
cwd=source,
|
||||||
|
env=env,
|
||||||
|
input=f"{token}\n",
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).resolve().parents[1],
|
||||||
|
help="tagged release repository",
|
||||||
|
)
|
||||||
|
parser.add_argument("--changelog", required=True)
|
||||||
|
parser.add_argument("--comfy", default=shutil.which("comfy") or "comfy")
|
||||||
|
args = parser.parse_args()
|
||||||
|
token = getpass.getpass("Comfy Registry API key: ").strip()
|
||||||
|
if not token:
|
||||||
|
print("REGISTRY RELEASE FAILED: API key is empty", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
publish_release(
|
||||||
|
args.source.resolve(),
|
||||||
|
comfy=args.comfy,
|
||||||
|
token=token,
|
||||||
|
changelog=args.changelog,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.CalledProcessError, RuntimeError) as exc:
|
||||||
|
print(f"REGISTRY RELEASE FAILED: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("REGISTRY RELEASE PASSED THE MANAGER INSTALL GATE AND WAS UPLOADED")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+192
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the public repository exactly as ComfyUI Manager installs it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationError(RuntimeError):
|
||||||
|
"""Raised when release metadata cannot produce a clean Manager install."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], *, cwd: Path, env: dict[str, str]) -> str:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def load_release_metadata(source: Path) -> tuple[str, str]:
|
||||||
|
with (source / "pyproject.toml").open("rb") as handle:
|
||||||
|
metadata = tomllib.load(handle)
|
||||||
|
|
||||||
|
project = metadata.get("project", {})
|
||||||
|
version = str(project.get("version", "")).strip()
|
||||||
|
repository = str(project.get("urls", {}).get("Repository", "")).strip()
|
||||||
|
if not version:
|
||||||
|
raise VerificationError("pyproject.toml is missing project.version")
|
||||||
|
validate_public_repository_url(repository)
|
||||||
|
return version, repository.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_public_repository_url(repository: str) -> None:
|
||||||
|
parsed = urlsplit(repository)
|
||||||
|
if parsed.scheme != "https" or not parsed.netloc or not parsed.path.strip("/"):
|
||||||
|
raise VerificationError(
|
||||||
|
"project.urls.Repository must be a complete public HTTPS repository URL"
|
||||||
|
)
|
||||||
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise VerificationError(
|
||||||
|
"project.urls.Repository must not contain credentials, query parameters, or a fragment"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def manager_clone_command(repository: str, destination: Path) -> list[str]:
|
||||||
|
return [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"-v",
|
||||||
|
"--recursive",
|
||||||
|
"--progress",
|
||||||
|
"--",
|
||||||
|
repository,
|
||||||
|
str(destination),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def anonymous_git_environment(base: dict[str, str], home: Path) -> dict[str, str]:
|
||||||
|
env = dict(base)
|
||||||
|
for key in (
|
||||||
|
"GIT_ASKPASS",
|
||||||
|
"GIT_CONFIG_GLOBAL",
|
||||||
|
"GIT_CONFIG_PARAMETERS",
|
||||||
|
"GIT_SSH",
|
||||||
|
"GIT_SSH_COMMAND",
|
||||||
|
"SSH_ASKPASS",
|
||||||
|
):
|
||||||
|
env.pop(key, None)
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"GIT_ASKPASS": "/bin/false",
|
||||||
|
"GIT_CONFIG_GLOBAL": "/dev/null",
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"HOME": str(home),
|
||||||
|
"SSH_ASKPASS": "/bin/false",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def verify_source_state(source: Path, version: str, env: dict[str, str]) -> str:
|
||||||
|
status = run(
|
||||||
|
["git", "status", "--porcelain", "--untracked-files=all"],
|
||||||
|
cwd=source,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
raise VerificationError("release source worktree is not clean")
|
||||||
|
head = run(["git", "rev-parse", "HEAD"], cwd=source, env=env)
|
||||||
|
tag_commit = run(
|
||||||
|
["git", "rev-list", "-n", "1", f"v{version}"], cwd=source, env=env
|
||||||
|
)
|
||||||
|
if tag_commit != head:
|
||||||
|
raise VerificationError(f"tag v{version} does not resolve to source HEAD {head}")
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
|
def verify_cloned_sources(clone: Path, scratch: Path, env: dict[str, str]) -> None:
|
||||||
|
required = (
|
||||||
|
"__init__.py",
|
||||||
|
"LICENSE",
|
||||||
|
"README.md",
|
||||||
|
"pyproject.toml",
|
||||||
|
"timeline/editor.py",
|
||||||
|
"web/etk_ltxv_timeline_image_editor.js",
|
||||||
|
)
|
||||||
|
missing = [relative for relative in required if not (clone / relative).is_file()]
|
||||||
|
if missing:
|
||||||
|
raise VerificationError(f"public clone is missing release files: {missing}")
|
||||||
|
|
||||||
|
python_files = sorted(str(path) for path in clone.rglob("*.py"))
|
||||||
|
compile_env = dict(env)
|
||||||
|
compile_env["PYTHONPYCACHEPREFIX"] = str(scratch / "pycache")
|
||||||
|
run([sys.executable, "-m", "py_compile", *python_files], cwd=clone, env=compile_env)
|
||||||
|
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
raise VerificationError("node is required to syntax-check shipped JavaScript")
|
||||||
|
for path in sorted(clone.rglob("*.js")):
|
||||||
|
run([node, "--check", str(path)], cwd=clone, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_manager_install(source: Path) -> tuple[str, str, str]:
|
||||||
|
source = source.resolve()
|
||||||
|
version, repository = load_release_metadata(source)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="etk-manager-release-") as temporary:
|
||||||
|
scratch = Path(temporary)
|
||||||
|
home = scratch / "anonymous-home"
|
||||||
|
home.mkdir()
|
||||||
|
env = anonymous_git_environment(os.environ, home)
|
||||||
|
source_head = verify_source_state(source, version, env)
|
||||||
|
|
||||||
|
destination = scratch / "custom_nodes" / "etk-ltxv-timeline-editor"
|
||||||
|
destination.parent.mkdir()
|
||||||
|
run(manager_clone_command(repository, destination), cwd=scratch, env=env)
|
||||||
|
|
||||||
|
clone_head = run(["git", "rev-parse", "HEAD"], cwd=destination, env=env)
|
||||||
|
if clone_head != source_head:
|
||||||
|
raise VerificationError(
|
||||||
|
f"public clone HEAD {clone_head} does not match release source {source_head}"
|
||||||
|
)
|
||||||
|
clone_version, clone_repository = load_release_metadata(destination)
|
||||||
|
if (clone_version, clone_repository) != (version, repository):
|
||||||
|
raise VerificationError("public clone release metadata differs from source")
|
||||||
|
clone_tag = run(
|
||||||
|
["git", "rev-list", "-n", "1", f"v{version}"],
|
||||||
|
cwd=destination,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if clone_tag != clone_head:
|
||||||
|
raise VerificationError(
|
||||||
|
f"public clone tag v{version} does not resolve to clone HEAD {clone_head}"
|
||||||
|
)
|
||||||
|
verify_cloned_sources(destination, scratch, env)
|
||||||
|
return version, repository, source_head
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).resolve().parents[1],
|
||||||
|
help="release repository to verify",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
version, repository, commit = verify_manager_install(args.source)
|
||||||
|
except (OSError, subprocess.CalledProcessError, VerificationError) as exc:
|
||||||
|
print(f"MANAGER INSTALL VERIFICATION FAILED: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"MANAGER INSTALL VERIFICATION PASSED: {repository} v{version} {commit}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user