diff --git a/.comfyignore b/.comfyignore index f004b37..d7c5790 100644 --- a/.comfyignore +++ b/.comfyignore @@ -6,3 +6,5 @@ __pycache__/ .ruff_cache/ .git/ .github/ +.gitea/ +tools/ diff --git a/.gitea/workflows/release-install.yml b/.gitea/workflows/release-install.yml new file mode 100644 index 0000000..12a2f42 --- /dev/null +++ b/.gitea/workflows/release-install.yml @@ -0,0 +1,25 @@ +name: Verify Manager release install + +on: + push: + tags: + - "v*" + +jobs: + verify-manager-install: + runs-on: ubuntu-latest + steps: + - name: Check out release tag + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Clone and validate the declared public repository + run: python tools/verify_manager_install.py --source . diff --git a/CHANGELOG.md b/CHANGELOG.md index fd800ad..d0aa46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented here. +## 1.0.2 - 2026-08-26 + +- Add a credential-free ComfyUI Manager clone gate and tag-triggered workflow + 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. diff --git a/README.md b/README.md index ec7128f..9a8b845 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ node --check web/etk_ltxv_timeline_image_editor.js 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. The Gitea +tag workflow runs the same command before a release is considered installable. + ## License GNU General Public License v3.0 or later. See [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index a7138f3..b3733cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "etk-ltxv-timeline-editor" -version = "1.0.1" +version = "1.0.2" description = "A visual timeline editor for LTXV image guides, prompts, strengths, and frame positions in ComfyUI." license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/tests/test_release_gate.py b/tests/test_release_gate.py new file mode 100644 index 0000000..dcf9eeb --- /dev/null +++ b/tests/test_release_gate.py @@ -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) diff --git a/tools/verify_manager_install.py b/tools/verify_manager_install.py new file mode 100755 index 0000000..b75f10e --- /dev/null +++ b/tools/verify_manager_install.py @@ -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())