Skip to content

Commit ee7be71

Browse files
committed
ci: add Blender smoke-test workflow running examples in real Blender
py_compile cannot catch API-level regressions (EEVEE-id inversion, slotted-actions boundary, driver TypeError, dead SDF link) -- all passed py_compile and only surfaced by running in real Blender. Adds .github/workflows/blender-smoke.yml (workflow_dispatch + weekly schedule + pull_request on main) matrixed over the current stable (5.1.x) and active LTS (4.5.x), resolving the latest point release from download.blender.org and failing loudly if a build can't be fetched. A self-contained driver (tests/smoke/run_smoke.py) executes the headline examples and asserts CONTENT (driver evaluates; SDF GridToMesh yields >0 verts; EEVEE id assigns + non-black render; slotted branch per version; save_pre arg is a filepath; foreach roundtrip; SUBSURF eval>base) and exits non-zero naming the first failing example -- including on any unhandled exception, since Blender otherwise exits 0 on a traceback. Both headless templates run as subprocesses (glTF exit 0 + valid .glb; no-mesh exit 2; render exit 0). Verified locally on 4.5.10 LTS and 5.1.1 green, and proven to fail (exit 1) when F1's id_type fix is reverted. No shipped content changed; counts unchanged (12/6/2/17). Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com>
1 parent de6250f commit ee7be71

5 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
name: Blender Smoke Test
2+
3+
# Executes the snippets' and skills' headline examples inside REAL Blender, headless,
4+
# on the current stable (5.1.x) and the active LTS (4.5.x), and fails on any error or
5+
# empty-output assertion. py_compile (in validate.yml) cannot catch API-level regressions
6+
# like the EEVEE-id inversion, the slotted-actions boundary, the driver TypeError, or the
7+
# dead SDF link -- this gate runs the code so those surface in CI, not in users' files.
8+
9+
on:
10+
workflow_dispatch: {}
11+
schedule:
12+
- cron: "0 7 * * 1" # weekly, Monday 07:00 UTC
13+
pull_request:
14+
branches: [main]
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: blender-smoke-${{ github.ref }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
smoke:
25+
name: Blender ${{ matrix.series }} smoke
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 30
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
include:
32+
- series: "5.1" # current stable
33+
- series: "4.5" # active LTS
34+
steps:
35+
- uses: actions/checkout@v6
36+
37+
- name: Resolve and download Blender ${{ matrix.series }}
38+
run: |
39+
set -euo pipefail
40+
series="${{ matrix.series }}"
41+
base="https://download.blender.org/release/Blender${series}/"
42+
echo "Listing $base"
43+
# pick the highest point release for this series (linux x64 portable)
44+
file=$(curl -fsSL "$base" \
45+
| grep -oE "blender-${series}\.[0-9]+-linux-x64\.tar\.xz" \
46+
| sort -V | uniq | tail -1)
47+
if [ -z "$file" ]; then
48+
echo "::error::Could not resolve a linux-x64 build for Blender ${series} at $base"
49+
exit 1
50+
fi
51+
url="${base}${file}"
52+
echo "Downloading $url"
53+
mkdir -p "$RUNNER_TEMP/bl"
54+
curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "$url"
55+
tar -xf "$RUNNER_TEMP/bl.tar.xz" -C "$RUNNER_TEMP/bl"
56+
bl=$(find "$RUNNER_TEMP/bl" -maxdepth 2 -type f -name blender | head -1)
57+
if [ -z "$bl" ]; then
58+
echo "::error::blender binary not found after extraction"
59+
exit 1
60+
fi
61+
echo "BLENDER=$bl" >> "$GITHUB_ENV"
62+
63+
- name: Print Blender version
64+
run: |
65+
set -euo pipefail
66+
"$BLENDER" --version | head -1
67+
# series guard: confirm we actually got the matrix series
68+
"$BLENDER" --version | head -1 | grep -q "Blender ${{ matrix.series }}\." \
69+
|| { echo "::error::version does not match series ${{ matrix.series }}"; exit 1; }
70+
71+
- name: Run in-Blender smoke driver
72+
run: |
73+
set -euo pipefail
74+
mkdir -p "$RUNNER_TEMP/out"
75+
"$BLENDER" --background --python tests/smoke/run_smoke.py -- "$RUNNER_TEMP/out"
76+
77+
- name: Build template input scene
78+
run: |
79+
set -euo pipefail
80+
"$BLENDER" --background --python tests/smoke/make_input.py -- "$RUNNER_TEMP/out/input.blend"
81+
82+
- name: Headless glTF template runs (exit 0, .glb produced)
83+
run: |
84+
set -euo pipefail
85+
"$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \
86+
--python tests/smoke/tmpl_gltf.py -- \
87+
--output "$RUNNER_TEMP/out/out.glb" --apply-modifier SUBSURF
88+
test -s "$RUNNER_TEMP/out/out.glb" || { echo "::error::glTF output missing/empty"; exit 1; }
89+
head -c4 "$RUNNER_TEMP/out/out.glb" | grep -q "glTF" || { echo "::error::not a glTF binary"; exit 1; }
90+
91+
- name: Headless template no-mesh path returns exit 2
92+
run: |
93+
set +e
94+
"$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \
95+
--python tests/smoke/tmpl_gltf.py -- --output "$RUNNER_TEMP/out/none.glb"
96+
code=$?
97+
set -e
98+
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; }
99+
echo "no-mesh exit code = $code (correct)"
100+
101+
- name: Headless render template runs (exit 0, PNG produced)
102+
run: |
103+
set -euo pipefail
104+
# Cycles (CPU) so this is reliable on GPU-less runners; the EEVEE-id regression
105+
# itself is gated in run_smoke.py via engine assignment.
106+
"$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \
107+
--python tests/smoke/tmpl_render.py -- \
108+
--output "$RUNNER_TEMP/out/render.png" --engine CYCLES
109+
test -s "$RUNNER_TEMP/out/render.png" || { echo "::error::render PNG missing/empty"; exit 1; }

tests/smoke/make_input.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import bpy, bmesh, sys
2+
out = sys.argv[sys.argv.index("--")+1:][0]
3+
bpy.ops.wm.read_factory_settings(use_empty=True)
4+
for name, loc in [("Cube",(0,0,0)), ("Sphere",(3,0,0))]:
5+
me = bpy.data.meshes.new(name); bm = bmesh.new()
6+
if name == "Cube": bmesh.ops.create_cube(bm, size=2.0)
7+
else: bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=8, radius=1.0)
8+
bm.to_mesh(me); bm.free()
9+
o = bpy.data.objects.new(name, me); o.location = loc
10+
bpy.context.collection.objects.link(o)
11+
# camera + sun + emissive world so a render is non-black
12+
cam_d = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", cam_d)
13+
bpy.context.collection.objects.link(cam); cam.location=(6,-6,6); cam.rotation_euler=(1.1,0,0.78)
14+
bpy.context.scene.camera = cam
15+
sd = bpy.data.lights.new("sun",'SUN'); s=bpy.data.objects.new("sun",sd); s.location=(0,0,8); bpy.context.collection.objects.link(s)
16+
bpy.context.scene.world = bpy.data.worlds.new("W")
17+
bpy.context.scene.world.use_nodes = True
18+
bpy.context.scene.world.node_tree.nodes["Background"].inputs[0].default_value = (0.3,0.4,0.6,1.0)
19+
bpy.ops.wm.save_as_mainfile(filepath=out)
20+
print(f"saved input {out} with 2 meshes")
21+
# also an empty blend for the exit-code-2 path
22+
empty = out.replace("input.blend","empty.blend")
23+
bpy.ops.wm.read_factory_settings(use_empty=True)
24+
bpy.ops.wm.save_as_mainfile(filepath=empty)
25+
print(f"saved empty {empty}")

tests/smoke/run_smoke.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""In-Blender smoke test for the BDT example patterns.
2+
3+
Run: blender --background --python tests/smoke/run_smoke.py -- <outdir>
4+
5+
Executes the snippets' / skills' headline examples and asserts CONTENT, not just
6+
"no exception". Exits non-zero on the FIRST failed assertion, naming the example.
7+
Self-contained: example code is copied here, NOT imported from skills/ or snippets/,
8+
so the test catches drift in the shipped content rather than masking it.
9+
"""
10+
import bpy, sys, os, tempfile
11+
12+
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
13+
OUT = ARGS[0] if ARGS else tempfile.mkdtemp()
14+
os.makedirs(OUT, exist_ok=True)
15+
V = bpy.app.version
16+
print(f"=== smoke on Blender {V[0]}.{V[1]}.{V[2]} -> {OUT} ===")
17+
18+
def require(example, cond, evidence):
19+
"""Pass-or-die assertion. On failure print which example failed and exit 1."""
20+
status = "ok" if cond else "FAIL"
21+
print(f"[{status}] {example}: {evidence}")
22+
if not cond:
23+
print(f"SMOKE FAILED at example '{example}' on Blender {V[0]}.{V[1]}.{V[2]}")
24+
sys.exit(1)
25+
26+
def reset():
27+
bpy.ops.wm.read_factory_settings(use_empty=True)
28+
29+
# ---- helpers copied from fixed content ----
30+
def get_eevee_engine_id():
31+
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
32+
33+
def get_channelbag_for_slot(action, slot):
34+
if bpy.app.version >= (5, 0, 0):
35+
from bpy_extras.anim_utils import action_ensure_channelbag_for_slot
36+
return action_ensure_channelbag_for_slot(action, slot)
37+
layer = action.layers[0] if action.layers else action.layers.new("Layer")
38+
strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME')
39+
return strip.channelbag(slot, ensure=True)
40+
41+
# ---------- mesh: foreach roundtrip + SUBSURF eval > base ----------
42+
def smoke_mesh():
43+
import bmesh
44+
reset()
45+
me = bpy.data.meshes.new("m"); me.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)], [], [(0,1,2,3)]); me.update()
46+
obj = bpy.data.objects.new("M", me); bpy.context.collection.objects.link(obj)
47+
base = len(me.vertices)
48+
n = len(me.vertices); src = [float(i) for i in range(n*3)]
49+
me.vertices.foreach_set("co", src); dst = [0.0]*(n*3); me.vertices.foreach_get("co", dst)
50+
require("mesh-foreach-roundtrip", all(abs(a-b) < 1e-5 for a,b in zip(src,dst)), "foreach_set/get arrays equal")
51+
obj.modifiers.new("ss", 'SUBSURF').levels = 2
52+
dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh()
53+
evc = len(m.vertices); ev.to_mesh_clear()
54+
require("mesh-subsurf-eval", evc > base, f"eval vcount {evc} > base {base}")
55+
56+
# ---------- F1: driver_namespace example (skill, fixed) ----------
57+
def smoke_driver():
58+
reset()
59+
me = bpy.data.meshes.new("d"); me.from_pydata([(0,0,0)],[],[])
60+
obj = bpy.data.objects.new("D", me); bpy.context.collection.objects.link(obj)
61+
def smooth_step(t):
62+
t = max(0.0, min(1.0, t)); return t*t*(3.0-2.0*t)
63+
bpy.app.driver_namespace['smooth_step'] = smooth_step
64+
fcurve = obj.driver_add("location", 2); fcurve.driver.type = 'SCRIPTED'
65+
var = fcurve.driver.variables.new(); var.name = 't'; var.type = 'SINGLE_PROP'
66+
var.targets[0].id_type = 'SCENE' # the F1 fix under test
67+
var.targets[0].id = bpy.context.scene
68+
var.targets[0].data_path = 'frame_current'
69+
fcurve.driver.expression = 'smooth_step((t - 1.0) / 100.0) * 5.0'
70+
vals = []
71+
for f in (1, 50, 100):
72+
bpy.context.scene.frame_set(f); dg = bpy.context.evaluated_depsgraph_get()
73+
vals.append(round(obj.evaluated_get(dg).location.z, 4))
74+
require("F1-driver-namespace", vals[0] < vals[1] < vals[2], f"location.z across frames = {vals} (strictly increasing)")
75+
76+
# ---------- F2: SDF remesh via GridToMesh (skill, fixed) ----------
77+
def smoke_sdf():
78+
import bmesh
79+
reset()
80+
me = bpy.data.meshes.new("c"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free()
81+
obj = bpy.data.objects.new("C", me); bpy.context.collection.objects.link(obj)
82+
tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree')
83+
tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry')
84+
tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
85+
gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput')
86+
m2s = tree.nodes.new('GeometryNodeMeshToSDFGrid'); g2m = tree.nodes.new('GeometryNodeGridToMesh')
87+
m2s.inputs["Voxel Size"].default_value = 0.1
88+
g2m.inputs["Threshold"].default_value = 0.0
89+
tree.links.new(gi.outputs["Geometry"], m2s.inputs["Mesh"])
90+
link = tree.links.new(m2s.outputs["SDF Grid"], g2m.inputs["Grid"]) # the F2 fix under test
91+
tree.links.new(g2m.outputs["Mesh"], go.inputs["Geometry"])
92+
require("F2-sdf-link-valid", link.is_valid, "SDF Grid -> Grid link is_valid")
93+
obj.modifiers.new("gn", 'NODES').node_group = tree
94+
dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh()
95+
n = len(m.vertices); ev.to_mesh_clear()
96+
require("F2-sdf-remesh", n > 0, f"GridToMesh remesh produced {n} vertices (>0)")
97+
98+
# ---------- EEVEE: id assignment + non-black render ----------
99+
def smoke_eevee():
100+
import bmesh
101+
reset()
102+
me = bpy.data.meshes.new("p"); bm = bmesh.new(); bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=10.0); bm.to_mesh(me); bm.free()
103+
obj = bpy.data.objects.new("P", me); bpy.context.collection.objects.link(obj)
104+
mat = bpy.data.materials.new("M"); mat.use_nodes = True; nt = mat.node_tree; nt.nodes.clear()
105+
emis = nt.nodes.new('ShaderNodeEmission'); emis.inputs['Color'].default_value = (0.9,0.4,0.1,1.0); emis.inputs['Strength'].default_value = 5.0
106+
out = nt.nodes.new('ShaderNodeOutputMaterial'); nt.links.new(emis.outputs['Emission'], out.inputs['Surface'])
107+
obj.data.materials.append(mat)
108+
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); bpy.context.collection.objects.link(cam)
109+
cam.location = (0,0,10); bpy.context.scene.camera = cam
110+
sc = bpy.context.scene
111+
# the EEVEE-id regression guard: assigning the version-correct id must succeed
112+
eid = get_eevee_engine_id()
113+
try:
114+
sc.render.engine = eid
115+
except Exception as e:
116+
require("eevee-id-assign", False, f"assigning '{eid}' raised {type(e).__name__}: {e}")
117+
require("eevee-id-assign", sc.render.engine == eid, f"engine id '{eid}' valid on this build")
118+
sc.render.resolution_x = 16; sc.render.resolution_y = 16; sc.render.image_settings.file_format = 'PNG'
119+
png = os.path.join(OUT, f"smoke_render_{V[0]}{V[1]}.png"); sc.render.filepath = png
120+
# try EEVEE render; if the headless runner has no GPU and it raises, fall back to Cycles (CPU)
121+
used = sc.render.engine
122+
try:
123+
try: sc.eevee.taa_render_samples = 4
124+
except Exception: pass
125+
bpy.ops.render.render(write_still=True)
126+
if not os.path.exists(png): raise RuntimeError("no PNG written")
127+
except Exception as e:
128+
print(f" (EEVEE render unavailable headless: {type(e).__name__}; falling back to CYCLES)")
129+
sc.render.engine = 'CYCLES'; used = 'CYCLES'
130+
try: sc.cycles.samples = 4
131+
except Exception: pass
132+
bpy.ops.render.render(write_still=True)
133+
require("eevee-render-file", os.path.exists(png) and os.path.getsize(png) > 0, f"render PNG written by {used}")
134+
img = bpy.data.images.load(png); px = list(img.pixels); mx = max(px) if px else 0.0
135+
require("eevee-render-nonblack", mx > 0.05, f"max pixel {round(mx,3)} > 0.05 (engine used: {used})")
136+
137+
# ---------- slotted actions: correct branch + legacy behaviour ----------
138+
def smoke_slotted():
139+
reset()
140+
me = bpy.data.meshes.new("a"); me.from_pydata([(0,0,0)],[],[])
141+
obj = bpy.data.objects.new("A", me); bpy.context.collection.objects.link(obj)
142+
obj.location = (0,0,0.0); obj.keyframe_insert("location", frame=1)
143+
obj.location = (0,0,5.0); obj.keyframe_insert("location", frame=24)
144+
bpy.context.scene.frame_set(1); z1 = round(obj.location.z,3)
145+
bpy.context.scene.frame_set(24); z24 = round(obj.location.z,3)
146+
require("slotted-keyframe-drives", abs(z24-z1) > 1.0, f"sampled z f1={z1} f24={z24} differ")
147+
o2 = bpy.data.objects.new("A2", bpy.data.meshes.new("a2")); bpy.context.collection.objects.link(o2)
148+
o2.animation_data_create(); act = bpy.data.actions.new("Act"); o2.animation_data.action = act
149+
slot = o2.animation_data.action_slot
150+
if slot is None:
151+
slot = act.slots.new(id_type='OBJECT', name=o2.name); o2.animation_data.action_slot = slot
152+
cbag = get_channelbag_for_slot(act, slot)
153+
require("slotted-channelbag", type(cbag).__name__ == "ActionChannelbag", f"channelbag = {type(cbag).__name__}")
154+
act3 = bpy.data.actions.new("Act3")
155+
try:
156+
act3.fcurves.new("location", index=0); legacy = 'WORKS'
157+
except AttributeError:
158+
legacy = 'AttributeError'
159+
expected = 'AttributeError' if bpy.app.version >= (5,0,0) else 'WORKS'
160+
require("slotted-legacy-branch", legacy == expected, f"legacy action.fcurves = {legacy} (expected {expected} for this version)")
161+
162+
# ---------- save_pre handler arg is a filepath string ----------
163+
def smoke_save_pre():
164+
reset()
165+
captured = {}
166+
def on_save_pre(arg, *a): captured['t'] = type(arg).__name__
167+
bpy.app.handlers.save_pre.append(on_save_pre)
168+
p = os.path.join(OUT, "smoke_sp.blend"); bpy.ops.wm.save_as_mainfile(filepath=p)
169+
bpy.app.handlers.save_pre.remove(on_save_pre)
170+
require("save_pre-arg-type", captured.get('t') == 'str', f"save_pre arg type = {captured.get('t')} (filepath string, not Scene)")
171+
172+
# Blender runs --python scripts but exits 0 even on an uncaught exception, so wrap every
173+
# example: a raised exception is a failure just like a failed content assertion.
174+
for fn in (smoke_mesh, smoke_driver, smoke_sdf, smoke_eevee, smoke_slotted, smoke_save_pre):
175+
try:
176+
fn()
177+
except SystemExit:
178+
raise
179+
except Exception as e:
180+
import traceback; traceback.print_exc()
181+
print(f"SMOKE FAILED: unhandled {type(e).__name__} in '{fn.__name__}' on "
182+
f"Blender {V[0]}.{V[1]}.{V[2]}: {e}")
183+
sys.exit(1)
184+
185+
print(f"=== ALL SMOKE CHECKS PASSED on Blender {V[0]}.{V[1]}.{V[2]} ===")

0 commit comments

Comments
 (0)