Skip to content

Commit a318b16

Browse files
TMHSDigitalclaude
andcommitted
feat: the extension template ships two-LTS CI and a register round-trip
The extension-runtime skill tells add-on authors to gate CI on extension build, and this repo has a Blender resolver and a register/unregister check, but the template shipped neither. It now carries .github/workflows/blender.yml (4.5 LTS and 5.2 LTS: newest point release, sha256-verified and cached, extension build, then the round-trip) and tests/check_register.py, a generic round-trip that finds the add-on's own classes and ID properties, so it keeps working as the add-on grows (exit 3 register, exit 4 names what survived unregister). The manifest's [build] section keeps tests/ and .github/ out of the package. GitHub never runs a workflow under templates/, so blender-smoke runs its build and register steps against the template, with a falsifier, and tests/test_template_ci.py pins the workflow's series, steps, verified download and action SHAs. Live-run proven on Blender 5.2.1 LTS and 4.5.14 LTS: round-trip exit 0 (3 classes, 1 property), --skip-unregister exit 4, extension validate 0, and the built zip holds only blender_manifest.toml, __init__.py and README.md. Closes #531 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRvikhJZSVGBFM91Dv Signed-off-by: TM Hospitality Strategies <TMHospitalityStrategies@gmail.com>
1 parent cb3488e commit a318b16

9 files changed

Lines changed: 367 additions & 1 deletion

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,39 @@ jobs:
261261
set -euo pipefail
262262
"$BLENDER" --command extension validate templates/extension-addon-template
263263
264+
# The template ships its own CI (.github/workflows/blender.yml), which
265+
# GitHub never runs from inside templates/. Run its two steps here (#531).
266+
- name: Extension template CI - build excludes tests/ and .github/
267+
if: matrix.shard == 0
268+
run: |
269+
set -euo pipefail
270+
"$BLENDER" --command extension build \
271+
--source-dir templates/extension-addon-template --output-dir "$RUNNER_TEMP/extbuild"
272+
zip=$(ls "$RUNNER_TEMP"/extbuild/*.zip | head -1)
273+
names=$(python3 -c "import sys, zipfile; print('\n'.join(zipfile.ZipFile(sys.argv[1]).namelist()))" "$zip")
274+
echo "$names"
275+
echo "$names" | grep -qx "__init__.py" || { echo "::error::__init__.py missing from the package"; exit 1; }
276+
if echo "$names" | grep -qE "^(tests|\.github)/"; then
277+
echo "::error::CI-only files were packed into the extension"; exit 1
278+
fi
279+
280+
- name: Extension template CI - register round-trip
281+
if: matrix.shard == 0
282+
run: |
283+
set -euo pipefail
284+
"$BLENDER" --background --factory-startup --python-exit-code 1 \
285+
--python templates/extension-addon-template/tests/check_register.py
286+
287+
- name: Falsifier - template register check catches a skipped unregister (exit 4)
288+
if: matrix.shard == 0
289+
run: |
290+
set +e
291+
"$BLENDER" --background --factory-startup --python-exit-code 1 \
292+
--python templates/extension-addon-template/tests/check_register.py -- --skip-unregister
293+
code=$?
294+
set -e
295+
[ "$code" -eq 4 ] || { echo "::error::skipped unregister exited $code, expected 4"; exit 1; }
296+
264297
- name: Build pipeline template fixture GLB
265298
if: matrix.shard == 0
266299
run: |

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,6 @@ jobs:
448448

449449
- name: Gallery generator unit tests (#528)
450450
run: python3 tests/test_build_gallery.py -v
451+
452+
- name: Extension template CI workflow contract (#531)
453+
run: python3 tests/test_template_ci.py -v

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ matching its `globs` does. Changing a glob changes when the rule fires.
9595
- One Operator, one Panel, one PropertyGroup
9696
- A `PointerProperty` bound to `bpy.types.Scene`
9797
- Symmetric `register()` / `unregister()` with property cleanup before class unregister
98+
- Shipped CI (`.github/workflows/blender.yml`, 4.5 LTS + 5.2 LTS): `extension build` plus `tests/check_register.py`, a generic register/unregister round-trip; blender-smoke runs both against the template, since GitHub never runs a workflow under `templates/`
9899

99100
`templates/headless-batch-script-template/` is a working starter for unattended Blender batch jobs:
100101

‎skills/extension-runtime-and-packaging/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ blender --factory-startup --command extension server-generate --repo-dir ./dist
115115

116116
`server-generate` writes the `index.json` listing a static extension repository needs. Each entry carries the package `id` and an `archive_url` relative to the listing (`./my_addon-0.1.0.zip`). Host `dist/` anywhere that serves files and add it as a remote repository in Blender.
117117

118+
The extension template ships this as a ready workflow: [`templates/extension-addon-template/.github/workflows/blender.yml`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/templates/extension-addon-template/.github/workflows/blender.yml) builds the package on 4.5 LTS and 5.2 LTS and runs a register/unregister round-trip ([`tests/check_register.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/templates/extension-addon-template/tests/check_register.py)).
119+
118120
To smoke-test an installed build without touching a real profile, point `BLENDER_USER_RESOURCES` at a temporary directory, then run `extension install-file -r user_default -e dist/<zip>` and a `--background --python-expr` import of `bl_ext.user_default.<id>`.
119121

120122
## Common AI mistakes
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Two-LTS CI for this add-on: on Blender 4.5 LTS and 5.2 LTS, build the
2+
# extension package with Blender's own builder and run the register/unregister
3+
# round-trip in tests/check_register.py. It resolves the newest point release
4+
# of each series at run time, verifies the download against the sha256
5+
# blender.org publishes, and caches the binary per version.
6+
#
7+
# Copied from Blender Developer Tools (templates/extension-addon-template);
8+
# that repo smoke-tests the same build and register check on every change.
9+
# Keep blender_version_min in blender_manifest.toml at or below the oldest
10+
# series in the matrix.
11+
12+
name: Blender
13+
14+
on:
15+
push:
16+
pull_request:
17+
workflow_dispatch:
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
blender:
24+
name: Blender ${{ matrix.series }}
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 20
27+
strategy:
28+
fail-fast: false
29+
matrix:
30+
series: ["4.5", "5.2"]
31+
steps:
32+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
33+
34+
- name: Install Blender runtime libraries
35+
run: |
36+
set -euo pipefail
37+
sudo apt-get update
38+
sudo apt-get install -y --no-install-recommends \
39+
libgl1 libegl1 libxrender1 libxxf86vm1 libxfixes3 libxi6 \
40+
libxkbcommon0 libsm6 libice6
41+
42+
- name: Resolve the newest Blender ${{ matrix.series }}
43+
id: resolve
44+
env:
45+
SERIES: ${{ matrix.series }}
46+
run: |
47+
set -euo pipefail
48+
base="https://download.blender.org/release/Blender${SERIES}/"
49+
file=$(curl -fsSL "$base" \
50+
| grep -oE "blender-${SERIES}\.[0-9]+-linux-x64\.tar\.xz" \
51+
| sort -V | uniq | tail -1)
52+
[ -n "$file" ] || { echo "::error::no linux-x64 build for Blender ${SERIES}"; exit 1; }
53+
version="${file#blender-}"
54+
version="${version%-linux-x64.tar.xz}"
55+
{
56+
echo "base=$base"
57+
echo "file=$file"
58+
echo "version=$version"
59+
} >> "$GITHUB_OUTPUT"
60+
61+
- name: Restore Blender from cache
62+
id: cache
63+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
64+
with:
65+
path: ${{ runner.temp }}/bl
66+
key: blender-${{ steps.resolve.outputs.version }}-linux-x64
67+
68+
- name: Download Blender and verify its sha256
69+
if: steps.cache.outputs.cache-hit != 'true'
70+
env:
71+
BASE: ${{ steps.resolve.outputs.base }}
72+
FILE: ${{ steps.resolve.outputs.file }}
73+
VERSION: ${{ steps.resolve.outputs.version }}
74+
run: |
75+
set -euo pipefail
76+
mkdir -p "$RUNNER_TEMP/bl"
77+
curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "${BASE}${FILE}"
78+
curl -fsSL --retry 3 -o "$RUNNER_TEMP/bl.sha256" "${BASE}blender-${VERSION}.sha256"
79+
expected=$(grep -E " ${FILE}$" "$RUNNER_TEMP/bl.sha256" | awk '{print $1}')
80+
[ -n "$expected" ] || { echo "::error::no published sha256 for ${FILE}"; exit 1; }
81+
echo "${expected} $RUNNER_TEMP/bl.tar.xz" | sha256sum -c -
82+
tar -xf "$RUNNER_TEMP/bl.tar.xz" -C "$RUNNER_TEMP/bl"
83+
84+
- name: Locate Blender
85+
run: |
86+
set -euo pipefail
87+
bl=$(find "$RUNNER_TEMP/bl" -maxdepth 2 -type f -name blender | head -1)
88+
[ -n "$bl" ] || { echo "::error::blender binary not found"; exit 1; }
89+
"$bl" --version | head -1
90+
echo "BLENDER=$bl" >> "$GITHUB_ENV"
91+
92+
- name: Build the extension package
93+
# `build`, not `validate`: build also checks the files it packs.
94+
run: |
95+
set -euo pipefail
96+
"$BLENDER" --command extension build --source-dir . --output-dir "$RUNNER_TEMP/dist"
97+
ls -l "$RUNNER_TEMP/dist"
98+
99+
- name: Register / unregister round-trip
100+
run: |
101+
"$BLENDER" --background --factory-startup --python-exit-code 1 \
102+
--python tests/check_register.py

‎templates/extension-addon-template/README.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ Targets Blender 4.5 LTS and 5.x.
1111
- `__init__.py` -- a working add-on with one PropertyGroup, one Operator,
1212
one Panel, a Scene-bound PointerProperty, and symmetric register and
1313
unregister.
14+
- `tests/check_register.py` -- a headless register/unregister round-trip
15+
that finds your classes and ID properties itself, so it keeps working as
16+
the add-on grows.
17+
- `.github/workflows/blender.yml` -- CI on Blender 4.5 LTS and 5.2 LTS:
18+
builds the package and runs the round-trip (see Continuous integration).
1419

1520
## Use the template
1621

@@ -60,7 +65,32 @@ blender --command extension build --source-dir .\my_addon --output-dir .\dist
6065
```
6166

6267
The resulting zip is the artifact to upload to the Extensions Platform
63-
or distribute directly.
68+
or distribute directly. The manifest's `[build] paths_exclude_pattern` keeps
69+
`tests/`, `.github/` and earlier builds out of it.
70+
71+
## Continuous integration
72+
73+
Put this directory at the root of your add-on's repository and
74+
`.github/workflows/blender.yml` runs on every push and pull request. For each
75+
of Blender 4.5 LTS and 5.2 LTS it resolves the newest point release, checks
76+
the download against blender.org's published sha256, caches it, then:
77+
78+
1. `blender --command extension build`: build, not just validate, because
79+
build also checks the files it packs;
80+
2. `tests/check_register.py`: register, check every class and ID property
81+
landed, unregister, check every one is gone, then register and unregister
82+
again. Exit 3 means register failed or is not repeatable; exit 4 names what
83+
survived unregister.
84+
85+
Run the round-trip locally the same way:
86+
87+
```bash
88+
blender --background --factory-startup --python-exit-code 1 --python tests/check_register.py
89+
# Falsifier: skip unregister; must exit 4
90+
blender --background --factory-startup --python-exit-code 1 --python tests/check_register.py -- --skip-unregister
91+
```
92+
93+
If you raise `blender_version_min`, drop the matrix series below it.
6494

6595
## Conventions to keep
6696

‎templates/extension-addon-template/blender_manifest.toml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,15 @@ copyright = ["2026 TMHSDigital"]
1717
website = "https://github.com/TMHSDigital/Blender-Developer-Tools"
1818

1919
tags = ["Object"]
20+
21+
# Keep CI-only files out of the package `blender --command extension build`
22+
# writes: the GitHub workflow, the register test and earlier build output.
23+
[build]
24+
paths_exclude_pattern = [
25+
"__pycache__/",
26+
"/.git/",
27+
"/.github/",
28+
"/tests/",
29+
"/dist/",
30+
"/*.zip",
31+
]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Register/unregister round-trip for this add-on, headless. Copy it with the template.
2+
3+
blender --background --factory-startup --python-exit-code 1 \
4+
--python tests/check_register.py [-- --skip-unregister]
5+
6+
Loads the add-on package (the directory above tests/) under the module name
7+
Blender gives an installed extension, then:
8+
9+
1. register(): every bpy.types class the package defines must be registered,
10+
and register() must add something (exit 3 otherwise);
11+
2. unregister(): every one of those classes must be gone, and every property
12+
register() added to an ID type (Scene.my_addon = PointerProperty(...))
13+
must be deleted again (exit 4 names what survived);
14+
3. register() and unregister() a second time: a non-symmetric pair usually
15+
fails here, on the re-register (exit 3).
16+
17+
Generic: it finds the classes and properties by comparing before and after,
18+
so it keeps working as you add operators, panels and property groups.
19+
--skip-unregister is the falsifier: it skips step 2's unregister and must
20+
exit 4. Exit 0 means the lifecycle is symmetric on this Blender.
21+
"""
22+
import importlib.util
23+
import inspect
24+
import os
25+
import sys
26+
import tomllib
27+
28+
import bpy
29+
30+
HERE = os.path.dirname(os.path.abspath(__file__))
31+
PKG_DIR = os.path.dirname(HERE)
32+
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
33+
SKIP_UNREGISTER = "--skip-unregister" in ARGS
34+
BASES = (bpy.types.Operator, bpy.types.Panel, bpy.types.Menu, bpy.types.UIList,
35+
bpy.types.PropertyGroup, bpy.types.AddonPreferences, bpy.types.Header)
36+
ID_TYPES = (bpy.types.Scene, bpy.types.Object, bpy.types.Mesh, bpy.types.Material,
37+
bpy.types.WindowManager, bpy.types.World, bpy.types.Collection)
38+
39+
40+
def fail(msg, code):
41+
print(f"FAIL: {msg}", file=sys.stderr)
42+
return code
43+
44+
45+
def id_props():
46+
return {(t.__name__, p) for t in ID_TYPES for p in t.bl_rna.properties.keys()}
47+
48+
49+
def package_classes(pkg_name):
50+
"""bpy.types subclasses defined in the package or its submodules."""
51+
found = set()
52+
for name, m in list(sys.modules.items()):
53+
if name != pkg_name and not name.startswith(pkg_name + "."):
54+
continue
55+
for _attr, obj in inspect.getmembers(m, inspect.isclass):
56+
if issubclass(obj, BASES) and obj not in BASES and (
57+
obj.__module__ == pkg_name or obj.__module__.startswith(pkg_name + ".")):
58+
found.add(obj)
59+
return found
60+
61+
62+
def load_package():
63+
ident = "addon_under_test"
64+
manifest = os.path.join(PKG_DIR, "blender_manifest.toml")
65+
if os.path.isfile(manifest):
66+
with open(manifest, "rb") as fh:
67+
ident = tomllib.load(fh).get("id", ident)
68+
name = f"bl_ext.user_default.{ident}" # what Blender names an installed extension
69+
for parent in ("bl_ext", "bl_ext.user_default"):
70+
sys.modules.setdefault(parent, type(sys)(parent))
71+
spec = importlib.util.spec_from_file_location(
72+
name, os.path.join(PKG_DIR, "__init__.py"), submodule_search_locations=[PKG_DIR])
73+
module = importlib.util.module_from_spec(spec)
74+
sys.modules[name] = module
75+
spec.loader.exec_module(module)
76+
return module
77+
78+
79+
def main():
80+
bpy.ops.wm.read_factory_settings(use_empty=True)
81+
pkg = load_package()
82+
classes = package_classes(pkg.__name__)
83+
before = id_props()
84+
85+
pkg.register()
86+
added = id_props() - before
87+
missing = sorted(c.__name__ for c in classes if not c.is_registered)
88+
if missing:
89+
return fail(f"classes not registered by register(): {missing}", 3)
90+
if not classes and not added:
91+
return fail("register() registered no class and added no property", 3)
92+
93+
if not SKIP_UNREGISTER:
94+
pkg.unregister()
95+
left = sorted(c.__name__ for c in classes if c.is_registered)
96+
if left:
97+
return fail(f"classes still registered after unregister(): {left}", 4)
98+
stale = sorted(f"{t}.{p}" for t, p in (id_props() & added))
99+
if stale:
100+
return fail(f"properties still bound after unregister(): {stale}", 4)
101+
102+
try:
103+
pkg.register()
104+
pkg.unregister()
105+
except Exception as exc: # noqa: BLE001 -- any failure here is the asymmetry
106+
return fail(f"second register()/unregister() raised {type(exc).__name__}: {exc}", 3)
107+
108+
print(f"OK: {len(classes)} classes and {len(added)} properties register and unregister "
109+
f"symmetrically on Blender {bpy.app.version_string}")
110+
return 0
111+
112+
113+
sys.exit(main())

0 commit comments

Comments
 (0)