Files
ausboss 475d6e19ea Offline checks read pyproject as TOML and catch more hidden mappings
Review fixes for the pack's offline checks.

- The startup version reader and the checks read pyproject.toml as TOML
  instead of a regex.
- validate_nodes and registry_contract catch more ways a node mapping
  or the module list could be hidden from registry scanners.
- release_preflight flags network-client imports and spaced or
  qualified fetch calls in shipped code.
- workflow_contract verifies saved widget values against the real
  widget order, covers subgraphs and validates model download info.
- Krea 2 Inpaint Masked example: two Note nodes' named widget values
  now match their positional values, which the new check requires, and
  the file ends with a newline. The graph itself is unchanged.

Tests: tests/test_check_regressions.py, with one adjusted case in
tests/test_workflow_contract.py.

No inputs, outputs or mapping keys change. No version change.
2026-11-08 19:33:20 -06:00

359 lines
15 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 repository-hosted README badge matches pyproject.toml and links to
the latest GitHub Release. Its image needs no external badge service.
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 ast
import pathlib
import re
import sys
import tomllib
from changelog_notes import notes_for
from registry_contract import git_paths
from release_badge import BADGE_PATH, badge_svg
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")
try:
project = tomllib.loads(raw.decode("utf-8")).get("project", {})
except (ValueError, UnicodeError) as exc:
errors.append(f"could not parse pyproject.toml: {exc}")
project = {}
version = project.get("version")
if isinstance(version, str) and version.strip():
pyproject_version = version
else:
errors.append("pyproject.toml needs a nonempty project.version string")
# 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
description = project.get("description")
if not isinstance(description, str) or not description.strip():
errors.append("pyproject.toml needs a nonempty project.description string")
elif len(description.encode("utf-8")) > DESCRIPTION_LIMIT:
errors.append(
f"the pyproject description is {len(description.encode('utf-8'))} 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 matches the version -------------------------
try:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
if f'src="{BADGE_PATH}"' not in readme:
errors.append(f"README.md release badge must use {BADGE_PATH}")
if 'href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"' not in readme:
errors.append("README.md must link to the latest GitHub Release")
if pyproject_version:
badge = (ROOT / BADGE_PATH).read_text(encoding="utf-8")
if badge != badge_svg(pyproject_version):
errors.append("release badge is stale; run python scripts/release_badge.py")
except (OSError, ValueError) as exc:
errors.append(f"could not validate the README release badge: {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(",),
}
NETWORK_IMPORTS = {"requests", "httpx", "socket", "urllib.request", "http.client", "aiohttp.ClientSession"}
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
if path.suffix == ".py":
try:
tree = ast.parse(text)
except SyntaxError as exc:
errors.append(f"{path.relative_to(ROOT)} cannot be parsed: {exc}")
continue
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and not node.level:
imports.extend(f"{node.module}.{alias.name}" for alias in node.names)
for imported in imports:
if any(imported == name or imported.startswith(name + ".") for name in NETWORK_IMPORTS):
errors.append(f"{path.relative_to(ROOT)} imports {imported}; shipped code makes no network requests")
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\s*(?:\?\.\s*)?\(", 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}.")