Skip to content

Commit 0dbb94b

Browse files
TMHSDigitalclaude
andauthored
ci: smoke the shipped templates, enforce catalog completeness, parse frontmatter, cache and verify Blender (#314)
- #291: smoke runs templates/headless-batch-script-template/script.py in place (copy deleted); extension template gets `extension validate` plus a register/unregister lifecycle row in the catalog (operator, panel, Scene pointer, operator behaviour) - #292: Validate fails when an example/showcase dir has no catalog row or a row points at a missing dir; run_catalog runs every row and lists all failures (--fail-fast is opt-in); an empty catalog is red; unit tests added - #293: skill/rule frontmatter parsed as YAML (first block only); rules must carry a boolean alwaysApply and non-empty globs - #294: Blender Smoke skips docs-only PRs, caches the extracted Blender by resolved version, verifies the published sha256 before extracting, drops the duplicate harness unit-test run Closes #291, closes #292, closes #293, closes #294 Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 5185e66 commit 0dbb94b

10 files changed

Lines changed: 353 additions & 204 deletions

‎.github/workflows/blender-smoke.yml‎

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ on:
3333
pull_request:
3434
branches: [main]
3535
types: [opened, synchronize, reopened, labeled]
36+
# Docs-only PRs do not touch anything Blender runs. A PR that also changes
37+
# code is not ignored: paths-ignore skips only when every file matches.
38+
paths-ignore:
39+
- "**.md"
40+
- "docs/**"
41+
- "assets/**"
3642

3743
permissions:
3844
contents: read
@@ -87,11 +93,6 @@ jobs:
8793
steps:
8894
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
8995

90-
- name: Harness protocol unit tests
91-
run: |
92-
set -euo pipefail
93-
python3 tests/smoke/test_harness.py -v
94-
9596
- name: Init smoke status log
9697
run: |
9798
set -euo pipefail
@@ -109,7 +110,8 @@ jobs:
109110
xvfb libgl1 libegl1 libxrender1 libxxf86vm1 libxfixes3 libxi6 \
110111
libxkbcommon0 libsm6 libice6
111112
112-
- name: Resolve and download Blender ${{ matrix.series }}
113+
- name: Resolve Blender ${{ matrix.series }}
114+
id: resolve
113115
run: |
114116
set -euo pipefail
115117
series="${{ matrix.series }}"
@@ -123,14 +125,46 @@ jobs:
123125
echo "::error::Could not resolve a linux-x64 build for Blender ${series} at $base"
124126
exit 1
125127
fi
126-
url="${base}${file}"
127-
echo "Downloading $url"
128+
version="${file#blender-}"
129+
version="${version%-linux-x64.tar.xz}"
130+
echo "base=$base" >> "$GITHUB_OUTPUT"
131+
echo "file=$file" >> "$GITHUB_OUTPUT"
132+
echo "version=$version" >> "$GITHUB_OUTPUT"
133+
echo "Resolved Blender $version"
134+
135+
- name: Restore Blender ${{ steps.resolve.outputs.version }} from cache
136+
id: bl-cache
137+
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
138+
with:
139+
path: ${{ runner.temp }}/bl
140+
key: blender-${{ steps.resolve.outputs.version }}-linux-x64
141+
142+
- name: Download Blender and verify its published sha256
143+
if: steps.bl-cache.outputs.cache-hit != 'true'
144+
run: |
145+
set -euo pipefail
146+
base="${{ steps.resolve.outputs.base }}"
147+
file="${{ steps.resolve.outputs.file }}"
148+
version="${{ steps.resolve.outputs.version }}"
128149
mkdir -p "$RUNNER_TEMP/bl"
129-
curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "$url"
150+
echo "Downloading ${base}${file}"
151+
curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "${base}${file}"
152+
curl -fsSL --retry 3 -o "$RUNNER_TEMP/bl.sha256" "${base}blender-${version}.sha256"
153+
expected=$(grep -E " ${file}$" "$RUNNER_TEMP/bl.sha256" | awk '{print $1}')
154+
if [ -z "$expected" ]; then
155+
echo "::error::no published sha256 for ${file}"
156+
exit 1
157+
fi
158+
echo "${expected} $RUNNER_TEMP/bl.tar.xz" | sha256sum -c - \
159+
|| { echo "::error::sha256 mismatch for ${file}; refusing to extract"; exit 1; }
130160
tar -xf "$RUNNER_TEMP/bl.tar.xz" -C "$RUNNER_TEMP/bl"
161+
162+
- name: Locate Blender binary
163+
run: |
164+
set -euo pipefail
131165
bl=$(find "$RUNNER_TEMP/bl" -maxdepth 2 -type f -name blender | head -1)
132166
if [ -z "$bl" ]; then
133-
echo "::error::blender binary not found after extraction"
167+
echo "::error::blender binary not found"
134168
exit 1
135169
fi
136170
echo "BLENDER=$bl" >> "$GITHUB_ENV"
@@ -158,7 +192,7 @@ jobs:
158192
run: |
159193
set -euo pipefail
160194
xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \
161-
--python tests/smoke/tmpl_gltf.py -- \
195+
--python templates/headless-batch-script-template/script.py -- \
162196
--output "$RUNNER_TEMP/out/out.glb" --apply-modifier SUBSURF
163197
test -s "$RUNNER_TEMP/out/out.glb" || { echo "::error::glTF output missing/empty"; exit 1; }
164198
head -c4 "$RUNNER_TEMP/out/out.glb" | grep -q "glTF" || { echo "::error::not a glTF binary"; exit 1; }
@@ -167,12 +201,17 @@ jobs:
167201
run: |
168202
set +e
169203
xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \
170-
--python tests/smoke/tmpl_gltf.py -- --output "$RUNNER_TEMP/out/none.glb"
204+
--python templates/headless-batch-script-template/script.py -- --output "$RUNNER_TEMP/out/none.glb"
171205
code=$?
172206
set -e
173207
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; }
174208
echo "no-mesh exit code = $code (correct)"
175209
210+
- name: Extension template passes Blender's own manifest validator
211+
run: |
212+
set -euo pipefail
213+
"$BLENDER" --command extension validate templates/extension-addon-template
214+
176215
- name: Build pipeline template fixture GLB
177216
run: |
178217
set -euo pipefail

‎.github/workflows/validate.yml‎

Lines changed: 6 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -36,82 +36,10 @@ jobs:
3636
fi
3737
echo "VERSION valid: $version"
3838
39-
- name: Validate skill frontmatter
39+
- name: Validate skill and rule frontmatter (parsed YAML, first block only)
4040
run: |
41-
echo "Checking skill YAML frontmatter..."
42-
errors=0
43-
for skill in skills/*/SKILL.md; do
44-
if [ ! -f "$skill" ]; then continue; fi
45-
if ! head -1 "$skill" | grep -q "^---$"; then
46-
echo "ERROR: $skill missing YAML frontmatter opening ---"
47-
errors=$((errors + 1))
48-
continue
49-
fi
50-
frontmatter=$(sed -n '/^---$/,/^---$/p' "$skill" | sed '1d;$d')
51-
if ! echo "$frontmatter" | grep -q "^name:"; then
52-
echo "ERROR: $skill missing 'name' in frontmatter"
53-
errors=$((errors + 1))
54-
fi
55-
if ! echo "$frontmatter" | grep -q "^description:"; then
56-
echo "ERROR: $skill missing 'description' in frontmatter"
57-
errors=$((errors + 1))
58-
fi
59-
if ! echo "$frontmatter" | grep -q "^standards-version:"; then
60-
echo "ERROR: $skill missing 'standards-version' in frontmatter"
61-
errors=$((errors + 1))
62-
fi
63-
done
64-
if [ "$errors" -gt 0 ]; then
65-
echo "$errors frontmatter error(s) found."
66-
exit 1
67-
fi
68-
echo "All skill frontmatter valid."
69-
70-
- name: Check skill name matches directory
71-
run: |
72-
echo "Checking skill names match directory names..."
73-
errors=0
74-
for skill in skills/*/SKILL.md; do
75-
if [ ! -f "$skill" ]; then continue; fi
76-
dir_name=$(echo "$skill" | sed 's|skills/\(.*\)/SKILL.md|\1|')
77-
fm_name=$(sed -n '/^---$/,/^---$/p' "$skill" | sed '1d;$d' | grep "^name:" | sed 's/^name: *//')
78-
if [ "$dir_name" != "$fm_name" ]; then
79-
echo "ERROR: $skill has name '$fm_name' but directory is '$dir_name'"
80-
errors=$((errors + 1))
81-
fi
82-
done
83-
if [ "$errors" -gt 0 ]; then
84-
echo "$errors name mismatch(es) found."
85-
exit 1
86-
fi
87-
echo "All skill names match their directories."
88-
89-
- name: Validate rule frontmatter
90-
run: |
91-
echo "Checking rule YAML frontmatter..."
92-
errors=0
93-
for rule in rules/*.mdc; do
94-
if [ ! -f "$rule" ]; then continue; fi
95-
if ! head -1 "$rule" | grep -q "^---$"; then
96-
echo "ERROR: $rule missing YAML frontmatter opening ---"
97-
errors=$((errors + 1))
98-
continue
99-
fi
100-
frontmatter=$(sed -n '/^---$/,/^---$/p' "$rule" | sed '1d;$d')
101-
if ! echo "$frontmatter" | grep -q "^description:"; then
102-
echo "ERROR: $rule missing 'description' in frontmatter"
103-
errors=$((errors + 1))
104-
fi
105-
if ! echo "$frontmatter" | grep -q "^standards-version:"; then
106-
echo "ERROR: $rule missing 'standards-version' in frontmatter"
107-
errors=$((errors + 1))
108-
fi
109-
done
110-
if [ "$errors" -gt 0 ]; then
111-
echo "$errors frontmatter error(s) found."
112-
exit 1
113-
fi
114-
echo "All rule frontmatter valid."
41+
pip install PyYAML==6.0.2
42+
python3 tests/check_frontmatter.py
11543
11644
- name: Validate snippet Python syntax
11745
run: |
@@ -133,6 +61,9 @@ jobs:
13361
- name: Check import-scale and unevaluated-export anti-patterns
13462
run: python3 tests/check_import_export_rules.py
13563

64+
- name: Check the smoke catalog covers every example and showcase piece
65+
run: python3 tests/check_smoke_catalog.py
66+
13667
- name: Check product exit codes are in README tables
13768
run: python3 tests/check_exit_code_readme.py
13869

‎tests/check_frontmatter.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
"""Validate skill and rule frontmatter by parsing the leading YAML block.
3+
4+
Only the first ``---`` block is read (a Markdown horizontal rule later in the
5+
body cannot satisfy a missing key). Skills need name (matching the directory),
6+
description and standards-version. Rules need description, standards-version, a
7+
boolean alwaysApply, and non-empty globs given as a string or a list of strings.
8+
9+
python tests/check_frontmatter.py [--root PATH]
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
from pathlib import Path
17+
18+
import yaml
19+
20+
21+
def front(path: Path) -> tuple[dict | None, str]:
22+
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
23+
if not text.startswith("---\n"):
24+
return None, "missing opening ---"
25+
parts = text.split("---\n", 2)
26+
if len(parts) < 3:
27+
return None, "frontmatter is not closed by ---"
28+
try:
29+
data = yaml.safe_load(parts[1])
30+
except yaml.YAMLError as exc:
31+
return None, f"invalid YAML: {exc}"
32+
if not isinstance(data, dict):
33+
return None, "frontmatter is not a mapping"
34+
return data, ""
35+
36+
37+
def need_str(data: dict, key: str, label: str, errors: list[str]) -> None:
38+
if not isinstance(data.get(key), str) or not data[key].strip():
39+
errors.append(f"{label}: '{key}' must be a non-empty string")
40+
41+
42+
def check(root: Path) -> list[str]:
43+
errors: list[str] = []
44+
for path in sorted((root / "skills").glob("*/SKILL.md")):
45+
label = path.relative_to(root).as_posix()
46+
data, why = front(path)
47+
if data is None:
48+
errors.append(f"{label}: {why}")
49+
continue
50+
for key in ("name", "description", "standards-version"):
51+
need_str(data, key, label, errors)
52+
if data.get("name") != path.parent.name:
53+
errors.append(f"{label}: name {data.get('name')!r} != directory {path.parent.name!r}")
54+
for path in sorted((root / "rules").glob("*.mdc")):
55+
label = path.relative_to(root).as_posix()
56+
data, why = front(path)
57+
if data is None:
58+
errors.append(f"{label}: {why}")
59+
continue
60+
for key in ("description", "standards-version"):
61+
need_str(data, key, label, errors)
62+
if not isinstance(data.get("alwaysApply"), bool):
63+
errors.append(f"{label}: 'alwaysApply' must be a boolean (got {data.get('alwaysApply')!r})")
64+
globs = data.get("globs")
65+
ok = (isinstance(globs, str) and globs.strip()) or (
66+
isinstance(globs, list) and globs and all(isinstance(g, str) and g.strip() for g in globs)
67+
)
68+
if not ok:
69+
errors.append(f"{label}: 'globs' must be a non-empty string or list of strings")
70+
return errors
71+
72+
73+
def main() -> int:
74+
ap = argparse.ArgumentParser()
75+
ap.add_argument("--root", default=str(Path(__file__).resolve().parent.parent))
76+
errors = check(Path(ap.parse_args().root))
77+
for e in errors:
78+
print(f"::error::{e}", file=sys.stderr)
79+
if errors:
80+
return 1
81+
print("skill and rule frontmatter valid")
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
sys.exit(main())

‎tests/check_smoke_catalog.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env python3
2+
"""Fail when examples/showcase pieces and tests/smoke/catalog.json disagree.
3+
4+
Every examples/<name>/ and showcase/<name>/ directory must be the target of at
5+
least one catalog row (the row's ``script`` path names the directory), and every
6+
row that points into examples/ or showcase/ must name a directory that exists.
7+
An empty catalog fails. Rows for harness code (tests/smoke/...) are allowed.
8+
9+
python tests/check_smoke_catalog.py [--root PATH]
10+
11+
No Blender needed; runs in Validate.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import json
18+
import sys
19+
from pathlib import Path
20+
21+
# Directories that intentionally have no smoke row. Keep empty unless justified.
22+
EXCLUDED: set[str] = set()
23+
24+
25+
def check(root: Path) -> list[str]:
26+
catalog = json.loads((root / "tests/smoke/catalog.json").read_text(encoding="utf-8"))
27+
if not catalog:
28+
return ["tests/smoke/catalog.json is empty"]
29+
30+
covered: set[str] = set()
31+
errors: list[str] = []
32+
for row in catalog:
33+
parts = Path(row["script"]).parts
34+
if len(parts) >= 2 and parts[0] in ("examples", "showcase"):
35+
key = f"{parts[0]}/{parts[1]}"
36+
covered.add(key)
37+
if not (root / key).is_dir():
38+
errors.append(f"catalog row {row['name']!r} points at missing directory {key}")
39+
40+
for top in ("examples", "showcase"):
41+
for d in sorted((root / top).iterdir()):
42+
if not d.is_dir() or d.name == "__pycache__":
43+
continue
44+
key = f"{top}/{d.name}"
45+
if key not in covered and key not in EXCLUDED:
46+
errors.append(f"{key} has no row in tests/smoke/catalog.json (it would never run)")
47+
return errors
48+
49+
50+
def main() -> int:
51+
ap = argparse.ArgumentParser()
52+
ap.add_argument("--root", default=str(Path(__file__).resolve().parent.parent))
53+
root = Path(ap.parse_args().root)
54+
errors = check(root)
55+
for e in errors:
56+
print(f"::error::{e}", file=sys.stderr)
57+
if errors:
58+
return 1
59+
print("smoke catalog covers every example and showcase directory")
60+
return 0
61+
62+
63+
if __name__ == "__main__":
64+
sys.exit(main())

‎tests/smoke/catalog.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
{"name": "lattice-deform", "script": "examples/lattice-deform/lattice_deform.py"},
8080
{"name": "solidify-even-thickness", "script": "examples/solidify-even-thickness/solidify_even_thickness.py"},
8181
{"name": "boolean-exact-volume", "script": "examples/boolean-exact-volume/boolean_exact_volume.py"},
82+
{"name": "extension-template-lifecycle", "script": "tests/smoke/check_extension_template.py"},
8283
{"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"},
8384
{"name": "stone-well", "script": "showcase/stone-well/stone_well.py"},
8485
{"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"},

0 commit comments

Comments
 (0)