locales/zh/nodeDefs.json gives every public node a Chinese name, description and input and output names, which ComfyUI shows when its language is set to Chinese. Cards and help pages stay English. tests/test_locales.py keeps the entries matched to the node API, and the preflight lets locales/ ship.
342 lines
14 KiB
Python
342 lines
14 KiB
Python
#!/usr/bin/env python
|
|
"""Release preflight checks - no ComfyUI required.
|
|
|
|
Run from anywhere: python scripts/release_preflight.py
|
|
|
|
Checks:
|
|
1. pyproject.toml has no UTF-8 BOM. tomllib rejects a BOM, and both
|
|
ComfyUI-Manager and the Comfy Registry parse the file with it, so a
|
|
BOM breaks installs silently.
|
|
2. AUSBOSS_JS_VERSION in js/shared/index.mjs equals the pyproject
|
|
version, so the stale-browser warning never fires on a fresh install.
|
|
3. The README release badge is the DYNAMIC shields.io kind - it reads
|
|
the version out of pyproject.toml on main at view time, so it can
|
|
never go stale (a hardcoded badge sat at 1.0.0 through two releases).
|
|
The check guards against someone swapping a static badge back in.
|
|
4. The Registry archive holds runtime files only. With git it is listed
|
|
the way comfy-cli packs it - tracked files minus those .comfyignore
|
|
matches - and every shipped path must sit under the runtime allowlist
|
|
(nodes/, js/, example_workflows/, README, LICENSE, pyproject,
|
|
__init__.py, the presets example). No runtime file may be
|
|
dropped, every allowlist entry plus js/docs/ and
|
|
example_workflows/inputs/ must ship, and the archive must stay under
|
|
its size budget. Without git (an installed copy) .comfyignore is tried
|
|
on sample paths instead.
|
|
5. example_workflows/: every UI graph has a matching thumbnail, consistent
|
|
links, setup instructions, and stage groups containing its nodes without
|
|
overlaps (including title bars).
|
|
6. Shipped source makes no network requests: no HTTP or socket client in
|
|
the backend, and no index-based LiteGraph connect call in the frontend,
|
|
which the Registry scan reads as a socket and flags the release for.
|
|
7. CHANGELOG.md has entries under the pyproject version. The publish
|
|
workflow sends them to the Registry as the version's notes
|
|
(scripts/changelog_notes.py); a release without them goes out blank.
|
|
|
|
Exit code 0 = ready to release, 1 = problems printed below.
|
|
"""
|
|
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
from changelog_notes import notes_for
|
|
from registry_contract import git_paths
|
|
from workflow_contract import example_problems
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
errors = []
|
|
|
|
# --- 1. pyproject.toml is BOM-free and carries a version ---------------------
|
|
pyproject_version = None
|
|
try:
|
|
raw = (ROOT / "pyproject.toml").read_bytes()
|
|
except OSError as exc:
|
|
errors.append(f"could not read pyproject.toml: {exc}")
|
|
raw = b""
|
|
|
|
if raw.startswith(b"\xef\xbb\xbf"):
|
|
errors.append("pyproject.toml starts with a UTF-8 BOM; save it without one")
|
|
|
|
match = re.search(rb'^version\s*=\s*"([^"]+)"', raw, re.MULTILINE)
|
|
if match:
|
|
pyproject_version = match.group(1).decode("ascii", "replace")
|
|
else:
|
|
errors.append('pyproject.toml has no version = "..." line')
|
|
|
|
# The Registry refuses an upload whose description is over 1000 bytes
|
|
# ("description is too long"), and it says so only after the version is
|
|
# on main.
|
|
DESCRIPTION_LIMIT = 1000
|
|
match = re.search(rb'^description\s*=\s*"([^"]*)"', raw, re.MULTILINE)
|
|
if match and len(match.group(1)) > DESCRIPTION_LIMIT:
|
|
errors.append(
|
|
f"the pyproject description is {len(match.group(1))} bytes; "
|
|
f"the Registry takes {DESCRIPTION_LIMIT} at most"
|
|
)
|
|
|
|
# --- 2. the frontend version constant matches --------------------------------
|
|
js_version = None
|
|
try:
|
|
shared = (ROOT / "js" / "shared" / "index.mjs").read_text(encoding="utf-8")
|
|
js_match = re.search(r'AUSBOSS_JS_VERSION\s*=\s*"([^"]+)"', shared)
|
|
if js_match:
|
|
js_version = js_match.group(1)
|
|
else:
|
|
errors.append("js/shared/index.mjs does not define AUSBOSS_JS_VERSION")
|
|
except OSError as exc:
|
|
errors.append(f"could not read js/shared/index.mjs: {exc}")
|
|
|
|
if pyproject_version and js_version and pyproject_version != js_version:
|
|
errors.append(
|
|
f"version mismatch: pyproject.toml says {pyproject_version} but "
|
|
f"AUSBOSS_JS_VERSION is {js_version}"
|
|
)
|
|
|
|
# --- 7. the changelog has this version's entries -----------------------------
|
|
if pyproject_version:
|
|
try:
|
|
notes_for(pyproject_version, (ROOT / "CHANGELOG.md").read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
errors.append(f"no release notes for {pyproject_version}: {exc}")
|
|
|
|
# --- 3. the README release badge stays dynamic -------------------------------
|
|
DYNAMIC_BADGE = (
|
|
"img.shields.io/badge/dynamic/toml"
|
|
"?url=https%3A%2F%2Fraw.githubusercontent.com%2Fausboss%2FComfyUI-AusBoss"
|
|
"%2Fmain%2Fpyproject.toml&query=%24.project.version"
|
|
)
|
|
try:
|
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
if DYNAMIC_BADGE not in readme:
|
|
errors.append(
|
|
"README.md release badge is not the dynamic pyproject-reading "
|
|
"shields.io badge; a hardcoded version WILL go stale"
|
|
)
|
|
if re.search(r"badge/release-\d+\.\d+\.\d+-", readme):
|
|
errors.append(
|
|
"README.md carries a hardcoded release-X.Y.Z badge; use the "
|
|
"dynamic badge only"
|
|
)
|
|
except OSError as exc:
|
|
errors.append(f"could not read README.md: {exc}")
|
|
|
|
# --- 4. the Registry archive holds runtime files only ------------------------
|
|
# comfy-cli packs `git ls-files` minus whatever .comfyignore matches, and git
|
|
# answers the second half with the same gitignore rules, so with git this
|
|
# checks the real archive instead of trying the patterns on sample paths.
|
|
import fnmatch
|
|
|
|
ARCHIVE_ALLOWLIST = (
|
|
"__init__.py", "nodes/", "js/", "example_workflows/", "README.md",
|
|
"LICENSE", "pyproject.toml", "ausboss_presets_example.json", "locales/",
|
|
)
|
|
# Besides every allowlist entry these must ship too: the node help pages and
|
|
# the sample media the example workflows load.
|
|
ARCHIVE_MUST_SHIP = ("js/docs/", "example_workflows/inputs/")
|
|
# Bytes before compression. Without the README media the archive is about
|
|
# 5 MB: 2.5 MB of sample inputs, 0.6 MB of example thumbnails, and code,
|
|
# docs and the example workflows.
|
|
ARCHIVE_BUDGET = 12_000_000
|
|
|
|
|
|
def within(path, entry):
|
|
return path.startswith(entry) if entry.endswith("/") else path == entry
|
|
|
|
|
|
def is_runtime(path):
|
|
return any(within(path, entry) for entry in ARCHIVE_ALLOWLIST)
|
|
|
|
|
|
def sample(paths, limit=3):
|
|
shown = ", ".join(paths[:limit])
|
|
return shown if len(paths) <= limit else f"{shown} and {len(paths) - limit} more"
|
|
|
|
|
|
def comfyignore_patterns(text):
|
|
patterns = []
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
patterns.append(line)
|
|
return patterns
|
|
|
|
|
|
def ignored(path, patterns):
|
|
"""gitignore-style match, the subset .comfyignore uses: a trailing slash
|
|
names a directory anywhere in the tree, a leading slash anchors to the
|
|
root, anything else matches a path segment or the full path."""
|
|
parts = path.split("/")
|
|
for pattern in patterns:
|
|
anchored = pattern.startswith("/")
|
|
body = pattern.lstrip("/")
|
|
directory = body.endswith("/")
|
|
body = body.rstrip("/")
|
|
if anchored:
|
|
if directory and parts[0] == body and len(parts) > 1:
|
|
return True
|
|
if not directory and fnmatch.fnmatch(path, body):
|
|
return True
|
|
continue
|
|
if directory:
|
|
if body in parts[:-1]:
|
|
return True
|
|
continue
|
|
if fnmatch.fnmatch(path, body) or any(fnmatch.fnmatch(part, body) for part in parts):
|
|
return True
|
|
return False
|
|
|
|
|
|
# Sample paths for the check without git.
|
|
DEV_ONLY = [
|
|
"tests/test_math_helpers.py", "tests/panel_guards.test.mjs",
|
|
"scripts/validate_nodes.py", "scripts/release_preflight.py",
|
|
".github/workflows/publish_action.yml", "AGENTS.md", "CLAUDE.md",
|
|
".claude/settings.json", "docs/adding_a_node.md",
|
|
"assets/readme/lora-chain-demo.gif", "CHANGELOG.md",
|
|
]
|
|
RUNTIME = [
|
|
"__init__.py", "nodes/node_seed.py", "nodes/_lora_helpers.py",
|
|
"js/seed/index.js", "js/shared/index.mjs", "js/docs/AUSBOSS_NODES_Seed.md",
|
|
"example_workflows/Krea 2 Outpaint (AusBoss).json",
|
|
"example_workflows/inputs/ausboss_pier_sunrise.png",
|
|
"README.md", "LICENSE", "pyproject.toml", "ausboss_presets_example.json",
|
|
]
|
|
|
|
tracked = git_paths(ROOT)
|
|
if tracked is not None:
|
|
comfyignore = ROOT / ".comfyignore"
|
|
if comfyignore.is_file():
|
|
dropped = set(git_paths(ROOT, "--cached", "--ignored", f"--exclude-from={comfyignore}") or ())
|
|
else:
|
|
errors.append(".comfyignore is missing, so every tracked file would ship")
|
|
dropped = set()
|
|
# The packer also skips a tracked file that is gone from the working tree.
|
|
shipped = [path for path in tracked if path not in dropped and (ROOT / path).is_file()]
|
|
|
|
strays = {}
|
|
for path in shipped:
|
|
if not is_runtime(path):
|
|
top, slash, _ = path.partition("/")
|
|
strays.setdefault(top + slash, []).append(path)
|
|
for top, paths in sorted(strays.items()):
|
|
what = top if paths == [top] else f"{top} ({sample(paths)})"
|
|
errors.append(
|
|
f"{what} would ship in the Registry archive but is not a runtime "
|
|
"path; add it to .comfyignore, or to ARCHIVE_ALLOWLIST in "
|
|
"scripts/release_preflight.py if users need it"
|
|
)
|
|
lost = sorted(path for path in dropped if is_runtime(path))
|
|
if lost:
|
|
errors.append(f".comfyignore drops runtime files from the archive: {sample(lost)}")
|
|
for entry in ARCHIVE_ALLOWLIST + ARCHIVE_MUST_SHIP:
|
|
if not any(within(path, entry) for path in shipped):
|
|
errors.append(f"the Registry archive has no {entry}, which must ship")
|
|
size = sum((ROOT / path).stat().st_size for path in shipped)
|
|
if size > ARCHIVE_BUDGET:
|
|
errors.append(
|
|
f"the Registry archive is {size / 1e6:.1f} MB, over its "
|
|
f"{ARCHIVE_BUDGET / 1e6:.0f} MB budget; shrink the media it "
|
|
"carries or raise ARCHIVE_BUDGET deliberately"
|
|
)
|
|
archive_summary = (
|
|
f"Registry archive: {len(shipped)} files, {size / 1e6:.1f} MB "
|
|
f"(budget {ARCHIVE_BUDGET / 1e6:.0f} MB)"
|
|
)
|
|
else:
|
|
# No git (an installed copy has no .git), so the archive cannot be
|
|
# listed: read .comfyignore directly and try it on sample paths.
|
|
try:
|
|
patterns = comfyignore_patterns((ROOT / ".comfyignore").read_text(encoding="utf-8"))
|
|
except OSError as exc:
|
|
patterns = None
|
|
errors.append(f"could not read .comfyignore: {exc}")
|
|
if patterns is not None:
|
|
for path in DEV_ONLY:
|
|
if not ignored(path, patterns):
|
|
errors.append(f".comfyignore does not exclude {path}, which must stay out of the archive")
|
|
for path in RUNTIME:
|
|
if ignored(path, patterns):
|
|
errors.append(f".comfyignore would drop runtime path {path} from the archive")
|
|
archive_summary = "Registry archive: git cannot list it here; .comfyignore tried on sample paths only"
|
|
|
|
# --- 5. example workflows parse and pair with thumbnails ---------------------
|
|
import json
|
|
|
|
examples = ROOT / "example_workflows"
|
|
if examples.is_dir():
|
|
workflows = {path.stem: path for path in examples.glob("*.json")}
|
|
for stem, path in sorted(workflows.items()):
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
errors.append(f"example_workflows/{path.name} is not valid JSON: {exc}")
|
|
continue
|
|
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list) or not data["nodes"]:
|
|
errors.append(
|
|
f"example_workflows/{path.name} is not a UI workflow (no nodes list) - "
|
|
"the template browser needs the saved-workflow format, not API JSON"
|
|
)
|
|
if not (examples / f"{stem}.jpg").is_file():
|
|
errors.append(f"example_workflows/{path.name} has no {stem}.jpg - it shows a blank template card")
|
|
for path in sorted(examples.glob("*.jpg")):
|
|
if path.stem not in workflows:
|
|
errors.append(f"example_workflows/{path.name} has no matching {path.stem}.json")
|
|
errors.extend(example_problems(examples))
|
|
|
|
# --- 6. shipped source makes no network requests -----------------------------
|
|
# SECURITY.md promises that no backend code contacts a host. The frontend
|
|
# wires graph links through linkSlots (js/shared/graph_links.mjs) because
|
|
# the Registry scan reads every `.connect(` as a network socket.
|
|
NETWORK_PATTERNS = {
|
|
".py": ("ClientSession", "urllib.request", "urlopen", "http.client",
|
|
"import requests", "import httpx", "import socket", "from socket"),
|
|
".js": (".connect(",),
|
|
".mjs": (".connect(",),
|
|
}
|
|
shipped_source = [ROOT / "__init__.py", *sorted((ROOT / "nodes").rglob("*.py"))]
|
|
shipped_source += sorted(p for p in (ROOT / "js").rglob("*") if p.suffix in (".js", ".mjs"))
|
|
for path in shipped_source:
|
|
if "__pycache__" in path.parts or not path.is_file():
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
errors.append(f"could not read {path.relative_to(ROOT)}: {exc}")
|
|
continue
|
|
for pattern in NETWORK_PATTERNS[path.suffix]:
|
|
if pattern in text:
|
|
errors.append(
|
|
f"{path.relative_to(ROOT).as_posix()} contains {pattern!r}; shipped code "
|
|
"makes no network requests (use linkSlots for graph links)"
|
|
)
|
|
|
|
# The browser may call fetch() in two places only: the cache guard (it asks
|
|
# the pack's own server) and LoRA Loader's click-only Civitai lookup (a fixed
|
|
# civitai.com address with a plain SHA256, then the LoRA's picture from
|
|
# image.civitai.com). Anything else is a new outbound
|
|
# call and needs the same review SECURITY.md describes.
|
|
FETCH_ALLOWED = {"js/cache_guard/index.js", "js/lora_loader/index.js"}
|
|
for path in shipped_source:
|
|
if path.suffix not in (".js", ".mjs") or not path.is_file():
|
|
continue
|
|
relative = path.relative_to(ROOT).as_posix()
|
|
if relative in FETCH_ALLOWED:
|
|
continue
|
|
if re.search(r"(?<![\w.])fetch\(", path.read_text(encoding="utf-8")):
|
|
errors.append(
|
|
f"{relative} calls fetch(); only {sorted(FETCH_ALLOWED)} may. "
|
|
"Use api.fetchApi for the pack's own routes."
|
|
)
|
|
|
|
# --- report ------------------------------------------------------------------
|
|
print(archive_summary)
|
|
for error in errors:
|
|
print(f"ERROR {error}")
|
|
|
|
if errors:
|
|
print(f"\n{len(errors)} problem(s) found.")
|
|
sys.exit(1)
|
|
|
|
print(f"OK - release preflight passed for version {pyproject_version}.")
|