#!/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. .comfyignore keeps development-only paths (tests, scripts, CI, agent instructions, root docs) out of the Registry archive and never swallows a runtime path (nodes/, js/ including js/docs/, the example workflows, README, LICENSE, pyproject). comfy-cli's packer honours the file with gitignore semantics; this check reads it the same way. 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). Exit code 0 = ready to release, 1 = problems printed below. """ import pathlib import re import sys 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') # --- 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}" ) # --- 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. .comfyignore covers the development-only paths ----------------------- import fnmatch 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 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/skills/ausboss-node-brand/SKILL.md", ".agents/skills/ausboss-node-brand/SKILL.md", "docs/adding_a_node.md", "todo.md", "todo", "workflow_ideas.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", ] 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 development-only path {path}") for path in RUNTIME: if ignored(path, patterns): errors.append(f".comfyignore would drop runtime path {path} from the archive") # --- 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)) # --- report ------------------------------------------------------------------ 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}.")