Repository navigation
docs(readme): write down the Blender support policy #818
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate: | |
| name: Validate structure and frontmatter | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" # pinned: the runner image's python3 moves (#495) | |
| - name: Check referenced paths exist | |
| run: | | |
| echo "Checking referenced paths..." | |
| test -d skills || { echo "ERROR: skills/ directory missing"; exit 1; } | |
| test -d rules || { echo "ERROR: rules/ directory missing"; exit 1; } | |
| test -d templates || { echo "ERROR: templates/ directory missing"; exit 1; } | |
| test -d snippets || { echo "ERROR: snippets/ directory missing"; exit 1; } | |
| test -f VERSION || { echo "ERROR: VERSION file missing"; exit 1; } | |
| test -f LICENSE || { echo "ERROR: LICENSE file missing"; exit 1; } | |
| echo "All referenced paths exist." | |
| - name: Validate VERSION file | |
| run: | | |
| version=$(cat VERSION | tr -d '[:space:]') | |
| if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| echo "ERROR: VERSION '$version' is not valid semver" | |
| exit 1 | |
| fi | |
| echo "VERSION valid: $version" | |
| - name: Validate skill and rule frontmatter (parsed YAML, first block only) | |
| run: | | |
| pip install -r requirements-dev.txt | |
| python3 tests/check_frontmatter.py | |
| - name: Validate snippet Python syntax | |
| run: | | |
| echo "Checking snippet Python syntax..." | |
| errors=0 | |
| for snippet in snippets/*.py; do | |
| if [ ! -f "$snippet" ]; then continue; fi | |
| if ! python3 -m py_compile "$snippet" 2>/dev/null; then | |
| echo "ERROR: $snippet has invalid Python syntax" | |
| errors=$((errors + 1)) | |
| fi | |
| done | |
| if [ "$errors" -gt 0 ]; then | |
| echo "$errors snippet syntax error(s) found." | |
| exit 1 | |
| fi | |
| echo "All snippets have valid Python syntax." | |
| - name: Check import-scale and unevaluated-export anti-patterns | |
| run: python3 tests/check_import_export_rules.py | |
| - name: Check the smoke catalog covers every example and showcase piece | |
| run: python3 tests/check_smoke_catalog.py | |
| - name: Check product exit codes are in README tables | |
| run: python3 tests/check_exit_code_readme.py | |
| - name: Check EEVEE engine-id mappings | |
| run: python3 tests/check_engine_id.py | |
| - name: Every gallery still is gated by gallery_framing | |
| run: python3 tests/check_render_gates.py | |
| - name: Showcase witness callouts match the README budgets | |
| run: python3 tests/check_witnesses.py | |
| - name: Showcase helper drift (warn-only report) | |
| run: python3 tests/check_helper_drift.py | |
| - name: Examples and showcase follow the repo's own rules | |
| run: python3 tests/check_example_rules.py | |
| - name: Check asset_sheet.py can render every showcase piece and reference | |
| run: python3 tests/check_asset_sheet_select.py | |
| - name: Check falsifiers declare the budget they target | |
| run: python3 tests/check_falsifier_targets.py | |
| - name: Check committed gallery matches its generator | |
| # docs/gallery/ is committed, but pages.yml regenerates it at deploy, so a | |
| # stale commit ships fine and drifts silently. Regenerate and require no diff. | |
| run: | | |
| python3 scripts/build_gallery.py | |
| drift=$(git status --porcelain -- docs/gallery) | |
| if [ -n "$drift" ]; then | |
| echo "::error::docs/gallery/ is stale. Run 'python scripts/build_gallery.py' and commit the result." | |
| echo "$drift" | |
| git --no-pager diff --stat -- docs/gallery | |
| exit 1 | |
| fi | |
| echo "docs/gallery/ matches scripts/build_gallery.py output." | |
| - name: Check gallery hero and preview sizes | |
| run: python3 tests/check_gallery_images.py | |
| - name: Validate template Python syntax | |
| run: | | |
| echo "Checking template Python syntax..." | |
| errors=0 | |
| while IFS= read -r -d '' py; do | |
| if ! python3 -m py_compile "$py" 2>/dev/null; then | |
| echo "ERROR: $py has invalid Python syntax" | |
| errors=$((errors + 1)) | |
| fi | |
| done < <(find templates -name '*.py' -print0) | |
| if [ "$errors" -gt 0 ]; then | |
| echo "$errors template syntax error(s) found." | |
| exit 1 | |
| fi | |
| echo "All template Python files have valid syntax." | |
| - name: Count components | |
| run: | | |
| skill_count=$(ls -d skills/*/SKILL.md 2>/dev/null | wc -l) | |
| rule_count=$(ls rules/*.mdc 2>/dev/null | wc -l) | |
| template_count=$(find templates -mindepth 1 -maxdepth 1 -type d | wc -l) | |
| snippet_count=$(ls snippets/*.py 2>/dev/null | wc -l) | |
| echo "Skills: $skill_count" | |
| echo "Rules: $rule_count" | |
| echo "Templates: $template_count" | |
| echo "Snippets: $snippet_count" | |
| validate-site: | |
| name: Build landing page and check site links | |
| # The landing page is built only at deploy (docs/index.html is untracked), | |
| # so a template error or a dead link otherwise surfaces in production. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| cache-dependency-path: scripts/site/requirements.txt | |
| - run: pip install -r scripts/site/requirements.txt | |
| - name: Build landing page into docs/ | |
| run: python scripts/site/build_site.py --repo-root . --out docs | |
| - name: Stage the public site exactly as pages.yml does | |
| run: python scripts/site/stage_public.py --src docs --out _site | |
| - name: Check every internal link, anchor and image alt (on the staged tree) | |
| run: python tests/check_site_links.py --root _site | |
| validate-manifest: | |
| name: Validate plugin manifest | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" # pinned: the runner image's python3 moves (#495) | |
| - name: Check plugin.json matches filesystem and VERSION | |
| run: | | |
| python3 << 'PYEOF' | |
| import glob | |
| import json | |
| import os | |
| import re | |
| import sys | |
| errors = [] | |
| manifest = json.load(open('.cursor-plugin/plugin.json')) | |
| version = open('VERSION').read().strip() | |
| if manifest.get('version') != version: | |
| errors.append( | |
| f"plugin.json version '{manifest.get('version')}' != VERSION '{version}'" | |
| ) | |
| # Cursor's plugin schema (github.com/cursor/plugins schemas/ | |
| # plugin.schema.json) sets additionalProperties: false, so any other | |
| # key makes the manifest invalid for Cursor. The old snippets / | |
| # templates / examples / showcase inventory keys did exactly that (#347). | |
| allowed = {'name', 'displayName', 'description', 'version', | |
| 'minClientVersions', 'author', 'publisher', 'homepage', | |
| 'repository', 'license', 'logo', 'keywords', 'category', | |
| 'tags', 'commands', 'agents', 'skills', 'rules', 'hooks', | |
| 'variables', 'mcpServers'} | |
| for key in sorted(set(manifest) - allowed): | |
| errors.append(f'plugin.json: {key!r} is not in the Cursor plugin schema') | |
| if not re.fullmatch(r'[a-z0-9]([a-z0-9.-]*[a-z0-9])?', manifest.get('name', '')): | |
| errors.append('plugin.json: name must be kebab-case') | |
| # skills / rules: every listed path exists and every one on disk is listed. | |
| expected = { | |
| 'skills': sorted(glob.glob('skills/*/SKILL.md')), | |
| 'rules': sorted(glob.glob('rules/*.mdc')), | |
| } | |
| for key, paths in expected.items(): | |
| listed = {p.replace('\\', '/') for p in manifest.get(key, [])} | |
| for path in listed: | |
| if not os.path.exists(path): | |
| errors.append(f'{key}: manifest lists missing path {path}') | |
| for path in paths: | |
| if path.replace('\\', '/') not in listed: | |
| errors.append(f'{key}: {path} on disk but not in manifest') | |
| # The marketplace that lets Cursor import this repo as a plugin. | |
| market = json.load(open('.cursor-plugin/marketplace.json')) | |
| for key in sorted(set(market) - {'name', 'owner', 'metadata', 'plugins'}): | |
| errors.append(f'marketplace.json: {key!r} is not in the Cursor marketplace schema') | |
| entries = market.get('plugins') or [] | |
| live = json.load(open('live-session/.cursor-plugin/plugin.json')) | |
| if [e.get('name') for e in entries] != [manifest.get('name'), live.get('name')]: | |
| errors.append('marketplace.json: must list this plugin, then the opt-in live-session plugin') | |
| for e in entries: | |
| for key in sorted(set(e) - {'name', 'source', 'description', 'minClientVersions'}): | |
| errors.append(f'marketplace.json plugin entry: {key!r} not allowed') | |
| if len(entries) > 1 and entries[1].get('source') != './live-session': | |
| errors.append('marketplace.json: the live-session entry source must be ./live-session') | |
| # The opt-in live-session plugin (#483): same schema, same VERSION. | |
| for key in sorted(set(live) - allowed): | |
| errors.append(f'live-session plugin.json: {key!r} is not in the Cursor plugin schema') | |
| if live.get('version') != version: | |
| errors.append(f"live-session plugin.json version {live.get('version')!r} != VERSION {version!r}") | |
| live_skills = sorted(p.replace('\\', '/').removeprefix('live-session/') | |
| for p in glob.glob('live-session/skills/*/SKILL.md')) | |
| if sorted(live.get('skills', [])) != live_skills: | |
| errors.append(f"live-session plugin.json skills {live.get('skills')} != on disk {live_skills}") | |
| # Every showcase piece names a category the gallery build knows. | |
| sys.path.insert(0, 'scripts') | |
| from build_gallery import CATEGORIES | |
| for piece in json.load(open('showcase/gallery.json', encoding='utf-8'))['pieces']: | |
| if piece.get('category') not in CATEGORIES: | |
| errors.append( | |
| f"showcase/gallery.json: {piece['name']} category " | |
| f"{piece.get('category')!r} not in {sorted(CATEGORIES)}" | |
| ) | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| counts = {k: len(manifest.get(k, [])) for k in expected} | |
| print(f'Manifest verified at v{version}: {counts}') | |
| PYEOF | |
| - name: Check both plugin manifests share description, homepage, license, keywords and logo | |
| run: python3 tests/check_manifest_meta.py | |
| validate-claude-packaging: | |
| name: Validate Claude Code packaging | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" # pinned: the runner image's python3 moves (#495) | |
| - name: Check .claude-plugin manifests match VERSION and the skills on disk | |
| run: | | |
| python3 << 'PYEOF' | |
| import glob | |
| import json | |
| import os | |
| import sys | |
| errors = [] | |
| version = open('VERSION').read().strip() | |
| plugin = json.load(open('.claude-plugin/plugin.json', encoding='utf-8')) | |
| market = json.load(open('.claude-plugin/marketplace.json', encoding='utf-8')) | |
| if plugin.get('version') != version: | |
| errors.append(f"claude plugin.json version {plugin.get('version')!r} != VERSION {version!r}") | |
| if plugin.get('name') != 'blender-developer-tools': | |
| errors.append('claude plugin.json name must be blender-developer-tools') | |
| live = json.load(open('live-session/.claude-plugin/plugin.json', encoding='utf-8')) | |
| entries = market.get('plugins', []) | |
| if [e.get('name') for e in entries] != [plugin.get('name'), live.get('name')]: | |
| errors.append('marketplace.json must list the core plugin, then blender-live-session') | |
| else: | |
| want_sources = ( | |
| {'source': 'github', 'repo': 'TMHSDigital/Blender-Developer-Tools', 'ref': 'plugin-dist'}, | |
| {'source': 'git-subdir', 'url': 'TMHSDigital/Blender-Developer-Tools', | |
| 'path': 'live-session', 'ref': 'plugin-dist'}, | |
| ) | |
| for entry, want in zip(entries, want_sources): | |
| if entry.get('version') != version: | |
| errors.append(f"marketplace.json {entry['name']} version {entry.get('version')!r} != VERSION {version!r}") | |
| if entry.get('source') != want: | |
| errors.append(f"marketplace.json {entry['name']} source must be {want} (the plugin-dist branch)") | |
| if live.get('version') != version: | |
| errors.append(f"live-session claude plugin.json version {live.get('version')!r} != VERSION {version!r}") | |
| # Claude Code discovers skills/<name>/SKILL.md plus every extra | |
| # directory in plugin.json "skills" (the generated blender-rules | |
| # skill); each SKILL.md needs name + description. | |
| extra = plugin.get('skills', []) | |
| skill_dirs = ['skills'] | |
| for d in extra: | |
| if not (isinstance(d, str) and d.startswith('./') and os.path.isdir(d)): | |
| errors.append(f'plugin.json skills entry {d!r} must be an existing ./ directory') | |
| else: | |
| skill_dirs.append(d) | |
| if './claude/skills/' not in extra: | |
| errors.append('plugin.json skills must include ./claude/skills/ (the rules skill)') | |
| for path in sorted(p for d in skill_dirs for p in glob.glob(os.path.join(d, '*', 'SKILL.md'))): | |
| parts = open(path, encoding='utf-8').read().replace('\r\n', '\n').split('---') | |
| front = parts[1] if len(parts) > 2 else '' | |
| for key in ('name:', 'description:'): | |
| if key not in front: | |
| errors.append(f'{path}: frontmatter missing {key}') | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| print('Claude Code packaging verified') | |
| PYEOF | |
| - name: Check every workflow action is pinned to a full commit SHA | |
| run: | | |
| bad=$(grep -rnE '^\s*(-\s+)?uses:\s+[^./ ]' .github/workflows \ | |
| | grep -vE 'uses:\s+\S+@[0-9a-f]{40}(\s|$)' || true) | |
| if [ -n "$bad" ]; then | |
| echo "$bad" | |
| echo "::error::pin these actions to a full commit SHA with a trailing '# vX.Y' comment (#306)" | |
| exit 1 | |
| fi | |
| # Composite actions that check out another repo's code at an input ref | |
| # must get a SHA too, or the pin above stops at the first hop (#363). | |
| refs=$(grep -rnE '^\s*meta-repo-ref:' .github/workflows \ | |
| | grep -vE 'meta-repo-ref:\s+[0-9a-f]{40}(\s|$)' || true) | |
| if [ -n "$refs" ]; then | |
| echo "$refs" | |
| echo "::error::pass meta-repo-ref as a full commit SHA, not a tag (#363)" | |
| exit 1 | |
| fi | |
| - name: Check CLAUDE.md carries no personal plugin routing block | |
| run: | | |
| if grep -nE 'context-mode|MANDATORY routing rules|ctx_(execute|batch_execute|search|fetch_and_index)' CLAUDE.md; then | |
| echo "::error::CLAUDE.md contains a personal plugin routing block (see #288)" | |
| exit 1 | |
| fi | |
| - name: Check claude/blender-rules.md and the rules skill are regenerated from rules/*.mdc | |
| run: python3 scripts/build_claude_rules.py --check | |
| - name: Check examples/index.json and each skill's Runnable examples section are regenerated | |
| run: python3 scripts/build_examples_index.py --check | |
| - name: Check the plugin-dist build assembles, carries every skill, and stays slim | |
| run: python3 scripts/build_plugin_dist.py --check | |
| - name: Check skill file references resolve outside a checkout | |
| run: python3 tests/check_skill_refs.py | |
| validate-counts: | |
| name: Validate content counts | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" # pinned: the runner image's python3 moves (#495) | |
| - name: Check content counts match the repo | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| python3 << 'PYEOF' | |
| import os | |
| import sys | |
| errors = [] | |
| skill_count = len([ | |
| d for d in os.listdir('skills') | |
| if os.path.isdir(os.path.join('skills', d)) | |
| and os.path.exists(os.path.join('skills', d, 'SKILL.md')) | |
| ]) | |
| rule_count = len([ | |
| f for f in os.listdir('rules') | |
| if f.endswith('.mdc') | |
| ]) | |
| template_count = len([ | |
| d for d in os.listdir('templates') | |
| if os.path.isdir(os.path.join('templates', d)) | |
| ]) | |
| snippet_count = len([ | |
| f for f in os.listdir('snippets') | |
| if f.endswith('.py') | |
| ]) | |
| example_count = len([ | |
| d for d in os.listdir('examples') | |
| if os.path.isdir(os.path.join('examples', d)) | |
| and os.path.exists(os.path.join('examples', d, 'README.md')) | |
| ]) | |
| showcase_count = 0 | |
| if os.path.isdir('showcase'): | |
| showcase_count = len([ | |
| d for d in os.listdir('showcase') | |
| if os.path.isdir(os.path.join('showcase', d)) | |
| and os.path.exists(os.path.join('showcase', d, 'README.md')) | |
| ]) | |
| # Every stated copy of every count, across README, CLAUDE.md, AGENTS.md, | |
| # CONTRIBUTING.md and docs/new-example-prompt.md, plus the snippet cap. | |
| import subprocess | |
| if subprocess.run([sys.executable, 'tests/check_counts.py']).returncode: | |
| errors.append('tests/check_counts.py found stale counts (see above)') | |
| # The GitHub About text drifts silently otherwise. Warn only: it is | |
| # repo metadata edited by hand and must not block content PRs. | |
| try: | |
| about = subprocess.run( | |
| ['gh', 'repo', 'view', '--json', 'description', '--jq', '.description'], | |
| capture_output=True, text=True, timeout=60, check=True, | |
| ).stdout | |
| for needle in (f'{example_count} examples', f'{showcase_count} showcase'): | |
| if needle not in about: | |
| print(f'::warning::GitHub repo description is stale (missing "{needle}"): {about.strip()}') | |
| except (subprocess.SubprocessError, OSError) as exc: | |
| print(f'::warning::could not read the GitHub repo description: {exc}') | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} templates, {snippet_count} snippets, {example_count} examples, {showcase_count} showcase pieces') | |
| PYEOF | |
| validate-harness: | |
| name: Validate smoke harness protocol | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" # pinned: the runner image's python3 moves (#495) | |
| - name: Install pinned dev requirements (the release-gate mirror test reads YAML) | |
| run: pip install -r requirements-dev.txt | |
| - name: Harness unit tests | |
| run: python3 tests/smoke/test_harness.py -v | |
| - name: Release gate fails closed (stubbed gh) | |
| run: python3 tests/test_release_gate.py -v | |
| - name: Release bump detection (bump-kind.sh) | |
| run: python3 tests/test_bump_kind.py -v | |
| - name: Release notes generator | |
| run: python3 tests/test_release_notes.py -v | |
| - name: Plugin-dist --out refuses destructive targets | |
| run: python3 tests/test_build_plugin_dist.py -v | |
| - name: Plugin-content change detector (release cadence) | |
| run: python3 tests/test_plugin_content_changed.py -v | |
| - name: Exit-code gate resolves fail() helpers and tuple returns | |
| run: python3 tests/test_exit_code_readme.py -v | |
| - name: Showcase falsifier sweep shards cover every piece once | |
| run: python3 tests/test_falsifier_shard.py -v | |
| - name: Hero-drift tool exit-code tests | |
| run: python3 tests/test_measure_hero_drift.py -v |