Skip to content

build(deps): Bump markupsafe from 3.0.2 to 3.0.3 in /scripts/site (#311) #635

build(deps): Bump markupsafe from 3.0.2 to 3.0.3 in /scripts/site (#311)

build(deps): Bump markupsafe from 3.0.2 to 3.0.3 in /scripts/site (#311) #635

Workflow file for this run

name: Validate
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
validate:
name: Validate structure and frontmatter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- 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 PyYAML==6.0.2
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: 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
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: Check every internal link, anchor and image alt
run: python tests/check_site_links.py
validate-manifest:
name: Validate plugin manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Check plugin.json matches filesystem and VERSION
run: |
python3 << 'PYEOF'
import glob
import json
import os
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}'"
)
# Every manifest path must exist on disk.
for key in ('skills', 'rules', 'snippets', 'templates', 'examples', 'showcase'):
for path in manifest.get(key, []):
if not os.path.exists(path):
errors.append(f'{key}: manifest lists missing path {path}')
# Every content file/dir on disk must be listed in the manifest.
expected = {
'skills': sorted(glob.glob('skills/*/SKILL.md')),
'rules': sorted(glob.glob('rules/*.mdc')),
'snippets': sorted(glob.glob('snippets/*.py')),
'templates': sorted(
d for d in glob.glob('templates/*') if os.path.isdir(d)
),
'examples': sorted(
d for d in glob.glob('examples/*') if os.path.isdir(d)
),
'showcase': sorted(
d for d in glob.glob('showcase/*') if os.path.isdir(d)
),
}
for key, paths in expected.items():
listed = {p.replace('\\', '/') for p in manifest.get(key, [])}
for path in paths:
if path.replace('\\', '/') not in listed:
errors.append(f'{key}: {path} on disk but not in manifest')
# 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
validate-claude-packaging:
name: Validate Claude Code packaging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- 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')
entries = market.get('plugins', [])
if len(entries) != 1 or entries[0].get('name') != plugin.get('name'):
errors.append('marketplace.json must list exactly the one plugin from plugin.json')
elif entries[0].get('version') != version:
errors.append(f"marketplace.json plugin version {entries[0].get('version')!r} != VERSION {version!r}")
elif not os.path.isdir(os.path.join(entries[0].get('source', ''), 'skills')):
errors.append('marketplace.json source must point at a directory containing skills/')
# Claude Code discovers skills/<name>/SKILL.md; each needs name + description.
for path in sorted(glob.glob('skills/*/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
- 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 is regenerated from rules/*.mdc
run: python3 scripts/build_claude_rules.py --check
validate-counts:
name: Validate content counts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Check content counts match README
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'))
])
readme = open('README.md').read()
if f'{skill_count} skills' not in readme:
errors.append(f'README skill count mismatch (expected "{skill_count} skills" substring)')
if f'{rule_count} rules' not in readme:
errors.append(f'README rule count mismatch (expected "{rule_count} rules" substring)')
template_word = 'template' if template_count == 1 else 'templates'
if f'{template_count} {template_word}' not in readme:
errors.append(f'README template count mismatch (expected "{template_count} {template_word}" substring)')
if f'{snippet_count} snippets' not in readme:
errors.append(f'README snippet count mismatch (expected "{snippet_count} snippets" substring)')
if f'{example_count} examples' not in readme:
errors.append(f'README example count mismatch (expected "{example_count} examples" substring)')
showcase_word = 'piece' if showcase_count == 1 else 'pieces'
showcase_needle = f'{showcase_count} showcase {showcase_word}'
if showcase_needle not in readme:
errors.append(
f'README showcase count mismatch (expected "{showcase_needle}" substring)'
)
# Documented snippet length cap (README, CLAUDE.md, CONTRIBUTING.md).
for f in sorted(os.listdir('snippets')):
if f.endswith('.py'):
n = len(open(os.path.join('snippets', f), encoding='utf-8').read().splitlines())
if not 5 <= n <= 75:
errors.append(f'snippets/{f} is {n} lines (documented range is 5 to 75)')
# Secondary docs must not carry stale aggregate counts.
import re
for doc in ('AGENTS.md', 'CONTRIBUTING.md'):
text = open(doc, encoding='utf-8').read()
for kind, actual in (('examples', example_count), ('showcase pieces', showcase_count)):
for m in re.finditer(r'(\d+) ' + kind, text):
if int(m.group(1)) != actual:
errors.append(f'{doc} says "{m.group(0)}" but the repo has {actual}')
# The GitHub About text drifts silently otherwise. Warn only: it is
# repo metadata edited by hand and must not block content PRs.
import subprocess
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} {template_word}, {snippet_count} snippets, {example_count} examples, {showcase_needle}')
PYEOF
validate-harness:
name: Validate smoke harness protocol
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Harness unit tests
run: python3 tests/smoke/test_harness.py -v
- name: Hero-drift tool exit-code tests
run: |
pip install numpy==2.2.6 Pillow==11.2.1
python3 tests/test_measure_hero_drift.py -v