Skip to content

chore(deps): Bump markupsafe from 3.0.3 to 3.0.4 in /scripts/site in the site-build group #801

chore(deps): Bump markupsafe from 3.0.3 to 3.0.4 in /scripts/site in the site-build group

chore(deps): Bump markupsafe from 3.0.3 to 3.0.4 in /scripts/site in the site-build group #801

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 -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
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
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- 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 []
if [e.get('name') for e in entries] != [manifest.get('name')]:
errors.append('marketplace.json: must list exactly this plugin by name')
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')
# 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
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 entries[0].get('source') != {'source': 'github', 'repo': 'TMHSDigital/Blender-Developer-Tools', 'ref': 'plugin-dist'}:
errors.append('marketplace.json plugin source must be the plugin-dist branch '
'(github TMHSDigital/Blender-Developer-Tools, ref plugin-dist)')
# 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
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- 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
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- 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: Hero-drift tool exit-code tests
run: |
pip install -r requirements-dev.txt
python3 tests/test_measure_hero_drift.py -v