Repository navigation
444 lines (377 loc) · 18.7 KB
/
Copy pathvalidate.yml
File metadata and controls
444 lines (377 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
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