From c6a166df3f530a4d8a2a66b5a4f46c51d3d73fb5 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:44:28 -0400 Subject: [PATCH] fix(ci): one requirements file and one command for every local check A contributor could not reproduce Validate without reading the YAML: test deps were pinned inline (`pip install PyYAML==6.0.2`, `numpy==2.2.6 Pillow==11.2.1`), invisible to Dependabot, and three checks existed only as heredoc Python inside validate.yml. Pillow 11.2.1 also predates the 11.3.0 fix for CVE-2025-48379. - requirements-dev.txt pins PyYAML, numpy and Pillow 11.3.0 (plus the site build's Jinja2); Validate installs from it and Dependabot watches it. - tests/run_all.py runs every tests/check_*.py and test_*.py, the smoke harness tests, the generator --check modes, the landing build the link check needs, the gallery-drift check, and the inline validate.yml heredoc checks, extracted from the workflow at run time so there is still one copy of each. CONTRIBUTING documents it. - .gitattributes keeps *.sh LF so Windows checkouts run under WSL bash. Closes #364 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + .github/dependabot.yml | 11 ++++ .github/workflows/validate.yml | 4 +- CONTRIBUTING.md | 12 ++++ requirements-dev.txt | 8 +++ tests/run_all.py | 101 +++++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 tests/run_all.py diff --git a/.gitattributes b/.gitattributes index 03fb8302..d36770d4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,8 @@ *.html text eol=lf *.css text eol=lf *.py text eol=lf +# Shell scripts run under bash in CI and Git Bash/WSL locally; CRLF breaks WSL. +*.sh text eol=lf # Binary. Gallery stills and any future .blend fixtures. *.webp binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2bee01dd..26d874d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,3 +22,14 @@ updates: site-build: patterns: - "*" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + target-branch: "main" + commit-message: + prefix: "chore(deps)" + groups: + dev-tools: + patterns: + - "*" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 01817e33..137cd742 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -38,7 +38,7 @@ jobs: - name: Validate skill and rule frontmatter (parsed YAML, first block only) run: | - pip install PyYAML==6.0.2 + pip install -r requirements-dev.txt python3 tests/check_frontmatter.py - name: Validate snippet Python syntax @@ -428,5 +428,5 @@ jobs: - name: Hero-drift tool exit-code tests run: | - pip install numpy==2.2.6 Pillow==11.2.1 + pip install -r requirements-dev.txt python3 tests/test_measure_hero_drift.py -v diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8047f2a..8d50851d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,18 @@ Thanks for helping improve this repository. This document describes how to set u git checkout -b your-feature-name ``` +4. **Run the checks** before you open a pull request. One command runs every + Validate check that does not need Blender (the frontmatter, catalog, + exit-code, gallery, link and packaging gates, plus the tooling tests): + + ```bash + pip install -r requirements-dev.txt + python tests/run_all.py # or: python tests/run_all.py -k catalog + ``` + + Blender smoke runs in CI on 5.2 LTS and 4.5 LTS; to run an example locally, + see `tests/smoke/run_example.py`. + ## Repository Structure This repo is a content collection (skills, rules, snippets, templates, examples, and showcase pieces) for Blender Python development. There is no runtime and no MCP server. Headless checks run through `tests/smoke/run_example.py`; CI validates frontmatter, syntax, and aggregate counts. diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..895471b1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +# Everything the Validate workflow's Python checks need, pinned. One file so a +# fresh clone can run them all (`python tests/run_all.py`) and Dependabot can +# propose bumps (#364). Blender itself is not a pip package; see CLAUDE.md +# "Blender Runtime Discovery" for the smoke tests. +-r scripts/site/requirements.txt +PyYAML==6.0.2 +numpy==2.2.6 +Pillow==11.3.0 diff --git a/tests/run_all.py b/tests/run_all.py new file mode 100644 index 00000000..01b17d59 --- /dev/null +++ b/tests/run_all.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Run every Validate check locally in one command (#364). No Blender needed. + + pip install -r requirements-dev.txt + python tests/run_all.py # all checks, summary at the end + python tests/run_all.py -k gallery # only checks whose name contains "gallery" + +It runs every tests/check_*.py and tests/test_*.py, the smoke-harness tests, +the generator --check modes, and the Python heredoc checks that live inline in +.github/workflows/validate.yml (extracted from the workflow at run time, so +there is one copy of each). It does not run Blender, `gh`, or the site build. +Exit 0 only if everything passed. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def inline_checks() -> list[tuple[str, str]]: + """(name, python source) for each `python3 << 'PYEOF'` step in validate.yml.""" + import yaml + + wf = yaml.safe_load((ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8")) + out = [] + for job in wf["jobs"].values(): + for step in job.get("steps", []): + run = step.get("run") or "" + if "<< 'PYEOF'" in run: + body = run.split("<< 'PYEOF'\n", 1)[1].rsplit("PYEOF", 1)[0] + out.append((f"validate.yml: {step.get('name', '?')}", textwrap.dedent(body))) + return out + + +GALLERY_DRIFT = """ +import subprocess, sys +subprocess.run([sys.executable, "scripts/build_gallery.py"], check=True, capture_output=True) +diff = subprocess.run(["git", "status", "--porcelain", "docs/gallery"], capture_output=True, text=True).stdout +if diff.strip(): + sys.exit("docs/gallery/ is stale; commit the regenerated pages: " + diff) +""" + +# check_site_links reads the built landing page, which is not committed. +SITE_BUILD = """ +import subprocess, sys +subprocess.run([sys.executable, "scripts/site/build_site.py", "--repo-root", ".", "--out", "docs"], + check=True) +""" + + +def commands() -> list[tuple[str, list[str] | str]]: + py = sys.executable + cmds: list[tuple[str, list[str] | str]] = [ + ("build landing page (for check_site_links)", SITE_BUILD), + ("committed gallery matches its generator", GALLERY_DRIFT), + ] + for path in sorted((ROOT / "tests").glob("check_*.py")): + cmds.append((path.name, [py, str(path)])) + for path in [*sorted((ROOT / "tests").glob("test_*.py")), ROOT / "tests" / "smoke" / "test_harness.py"]: + cmds.append((path.name, [py, str(path)])) + cmds.append(("build_claude_rules --check", [py, str(ROOT / "scripts" / "build_claude_rules.py"), "--check"])) + cmds.append(("build_plugin_dist --check", [py, str(ROOT / "scripts" / "build_plugin_dist.py"), "--check"])) + cmds += inline_checks() + return cmds + + +def run(name: str, cmd: list[str] | str) -> bool: + if isinstance(cmd, str): # inline heredoc source + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as fh: + fh.write(cmd) + argv = [sys.executable, fh.name] + else: + argv = cmd + proc = subprocess.run(argv, cwd=ROOT, capture_output=True, text=True, + encoding="utf-8", errors="replace") + ok = proc.returncode == 0 + print(f"{'PASS' if ok else 'FAIL'} {name}", flush=True) + if not ok: + tail = (proc.stdout + proc.stderr).strip().splitlines()[-15:] + print("\n".join(" " + line for line in tail), flush=True) + return ok + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("-k", default="", help="only run checks whose name contains this") + args = ap.parse_args(argv) + todo = [(n, c) for n, c in commands() if args.k.lower() in n.lower()] + failed = [n for n, c in todo if not run(n, c)] + print(f"\n{len(todo) - len(failed)} passed, {len(failed)} failed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())