Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions .github/workflows/release-extension.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
name: Build Blender Extension

on:
push:
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
release_tag:
description: "Existing release tag to build (vX.Y.Z)"
required: true
type: string

permissions:
contents: read

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
- name: Verify NodeForge version
env:
TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
run: |
python3 - <<'PY'
import ast, os, pathlib, tomllib
manifest = tomllib.loads(pathlib.Path('blender_manifest.toml').read_text())
data = ast.parse(pathlib.Path('__init__.py').read_text())
version = next(node.value for node in data.body if isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'NODEFORGE_VERSION' for t in node.targets))
declared = '.'.join(map(str, ast.literal_eval(version)))
assert manifest['version'] == declared, 'bl_info / manifest mismatch'
# Pull-request merge refs are not release tags. Tag and manual release
# verification still require the selected tag to match the package version.
tag_name = os.environ['TAG_NAME']
if tag_name.startswith('v'):
assert tag_name == 'v' + declared, 'tag / manifest mismatch'
PY
- name: Download latest library releases
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p staging/package_install
python3 - <<'PY'
import subprocess
from bundled_packages import BUNDLED_PACKAGES

for package_id, repository in BUNDLED_PACKAGES.items():
subprocess.run([
'gh', 'release', 'download', '--repo', repository,
'--pattern', f'{package_id}-v*_nf-v*.zip',
'--dir', 'staging/package_install',
], check=True)
PY
- name: Stage extension contents
run: |
set -euo pipefail
cp ./*.py blender_manifest.toml LICENCE README.md staging/
cp -R blender semantic extensions examples staging/
# Only these code directories are part of the extension (no tests or .git).
test -d staging/blender && test -d staging/extensions && test -d staging/semantic
find staging -name __pycache__ -type d -prune -exec rm -rf {} +
python3 - <<'PY'
from pathlib import Path
import json, tomllib, zipfile
from bundled_packages import BUNDLED_PACKAGES
expected = set(BUNDLED_PACKAGES)
nodeforge_version = tuple(int(part) for part in tomllib.loads(
Path('blender_manifest.toml').read_text())['version'].split('.'))
seen = {}
archives = sorted(Path('staging/package_install').glob('*.zip'))
assert len(archives) == len(expected), archives
for archive in archives:
with zipfile.ZipFile(archive) as z:
manifest_names = [p for p in z.namelist() if p.endswith('/nodeforge_package.json') or p == 'nodeforge_package.json']
assert len(manifest_names) == 1, archive
manifest = json.loads(z.read(manifest_names[0]))
package_id = manifest['id']
minimum = manifest['nodeforge_min_version']
assert package_id in expected and package_id not in seen, package_id
assert archive.name == f"{package_id}-v{manifest['version']}_nf-v{minimum}.zip", archive
assert tuple(int(part) for part in minimum.split('.')) <= nodeforge_version, archive
seen[package_id] = archive.name
assert set(seen) == set(expected), seen
PY
- name: Cache Blender 5.2
id: blender-cache
uses: actions/cache@v4
with:
path: blender-dist
key: blender-${{ runner.os }}-${{ runner.arch }}-5.2.0-linux-x64-v1
- name: Install Blender 5.2
if: steps.blender-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl --fail --location --retry 3 https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz -o blender.tar.xz
mkdir -p blender-dist
tar -xJf blender.tar.xz -C blender-dist --strip-components=1
- name: Validate and build
run: |
set -euo pipefail
BLENDER="$PWD/blender-dist/blender"
"$BLENDER" -b --command extension validate staging
mkdir -p public
"$BLENDER" -b --command extension build --source-dir staging --output-dir public
python3 - <<'PY'
import io
import pathlib
import zipfile
from bundled_packages import BUNDLED_PACKAGES
archives = list(pathlib.Path('public').glob('*.zip'))
assert len(archives) == 1, archives
with zipfile.ZipFile(archives[0]) as z:
files = set(z.namelist())
assert 'blender_manifest.toml' in files
bundled_archives = {
name for name in files
if name.startswith('package_install/') and name.endswith('.zip')
}
staged_archives = {
'package_install/' + archive.name
for archive in pathlib.Path('staging/package_install').glob('*.zip')
}
assert len(staged_archives) == len(BUNDLED_PACKAGES), staged_archives
assert bundled_archives == staged_archives, bundled_archives
for archive_name in bundled_archives:
with zipfile.ZipFile(io.BytesIO(z.read(archive_name))) as bundled:
assert 'nodeforge_package.json' in bundled.namelist(), archive_name
assert not any(n.startswith(('.git/', 'tests/', 'dev/')) for n in files)
PY
"$BLENDER" -b --command extension server-generate --repo-dir public
test -s public/index.json
# Preserve the exact built ZIP and index.json for downstream jobs.
- name: Preserve built release files
uses: actions/upload-artifact@v4
with:
name: nodeforge-release-public
path: public/
if-no-files-found: error
retention-days: 30
# Create this once in build, not in deploy-pages: retrying a failed Pages
# deployment must reuse the same artifact, never re-upload it.
- name: Preserve built Pages artifact
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: actions/upload-pages-artifact@v3
with:
path: public/
retention-days: 30

test-extension:
name: Test built Extension in Blender 5.2
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
- name: Retrieve built release files
uses: actions/download-artifact@v4
with:
name: nodeforge-release-public
path: public/
- name: Cache Blender 5.2
id: blender-cache
uses: actions/cache@v4
with:
path: blender-dist
key: blender-${{ runner.os }}-${{ runner.arch }}-5.2.0-linux-x64-v1
- name: Install Blender 5.2
if: steps.blender-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl --fail --location --retry 3 https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz -o blender.tar.xz
mkdir -p blender-dist
tar -xJf blender.tar.xz -C blender-dist --strip-components=1
- name: Install and test built Extension in Blender 5.2
run: |
set -euo pipefail
python3 -m pip install --target "$PWD/.ci-pytest" pytest
export PYTHONPATH="$PWD/.ci-pytest"
export BLENDER_USER_CONFIG="$RUNNER_TEMP/nodeforge-ci/config"
export BLENDER_USER_SCRIPTS="$RUNNER_TEMP/nodeforge-ci/scripts"
export BLENDER_USER_DATAFILES="$RUNNER_TEMP/nodeforge-ci/datafiles"
ARCHIVE=$(find "$PWD/public" -maxdepth 1 -name '*.zip' -type f -print -quit)
test -n "$ARCHIVE"
"$PWD/blender-dist/blender" -b --factory-startup --python-use-system-env --python-exit-code 1 \
--python tests/blender/verify_extension_release.py -- "$ARCHIVE"

publish-release:
name: Publish immutable GitHub Release
needs: [build, test-extension]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Retrieve verified release files
uses: actions/download-artifact@v4
with:
name: nodeforge-release-public
path: public/
- name: Publish GitHub release asset
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag

deploy-pages:
name: Deploy Blender Extensions repository
needs: [build, publish-release]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
# deploy-pages consumes the already uploaded Pages artifact from build.
# Rerun only this failed job after a Pages outage; never rerun release creation.
- id: deployment
uses: actions/deploy-pages@v4
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ __pycache__/
dev/*
!dev/CHANGELOG.md
!dev/ARCHITECTURE.md
!dev/ARCHITECTURE_LAYERS.md
# User-owned local catalog scripts
local/**/*.nf
local/**/*.nodeforge
Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ NodeForge is a Blender add-on for describing Geometry Nodes logic in a Python-li

## Installation

1. Download the latest `NodeForge-<version>-blender.zip` from the [**GitHub release**](https://github.com/denikryt/NodeForge/releases) section.
2. In Blender, open **Edit → Preferences → Add-ons**.
3. Open the Add-ons menu, choose **Install from Disk...**, and select the downloaded ZIP file.
4. Enable **NodeForge** in the add-on list.
NodeForge currently targets Blender 5.2.

1. Download the latest NodeForge Extension ZIP from [GitHub Releases](https://github.com/denikryt/NodeForge/releases/latest).
2. In Blender, open **Edit → Preferences → Get Extensions** and choose **Install from Disk...**.
3. Select the ZIP and enable **NodeForge**.

Once the [NodeForge extension repository](https://denikryt.github.io/NodeForge/index.json) is available, you can add it under **Repositories → Add Remote Repository** in **Get Extensions** to install NodeForge and receive updates through Blender.

The release ZIP includes the latest published NodeForge Math and L-System packages available when that ZIP is built. You can update either package separately through **NodeForge → Packages → Install ZIP**.

If you used the earlier ZIP add-on, disable it before enabling the Extension. Your Packages and Local files remain in Blender's user-data directories.

## Quick start

Expand Down Expand Up @@ -80,10 +87,11 @@ NodeForge scripts can call reusable functions and can be extended through instal

## Additional libraries

- **NodeForge Math** adds math operations, reusable functions, and examples. Download the package ZIP from the [NodeForge release section](https://github.com/denikryt/NodeForge/releases/latest), or browse its [source repository](https://github.com/denikryt/nodeforge.math).
- **NodeForge L-System** adds tools for procedural L-system generation. [Download](https://www.patreon.com/nachitima/posts/nodeforge-l-v2-0-169730745)
The Extension includes **NodeForge Math** for math operations and **NodeForge L-System** for procedural L-system generation. Both libraries are installed automatically when you enable NodeForge.

The bundled-library registry is [`bundled_packages.py`](bundled_packages.py). It maps each package ID to its GitHub repository. To include another library in future Extension ZIPs, add an entry and publish a Release ZIP named `<package-id>-v<package-version>_nf-v<minimum-NodeForge-version>.zip` in that repository. The package manifest supplies the version and minimum compatible NodeForge version.

Install a library ZIP from the **Packages** section of the NodeForge tab in the Geometry Nodes Editor. Enable **Allow executable Python** when the package requires Python support.
Install or update other libraries from **NodeForge → Packages → Install ZIP**.

Installed package APIs are imported through the package namespace:

Expand Down
4 changes: 3 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""NodeForge Blender addon package."""

NODEFORGE_VERSION = (0, 72, 7)

bl_info = {
"name": "NodeForge",
"author": "nachitima",
"version": (0, 70, 2),
"version": NODEFORGE_VERSION,
"blender": (5, 2, 0),
"location": "Geometry Nodes Editor > Sidebar > NodeForge; Add Menu > Script > Compile Group",
"description": "Compile a Python-like DSL into Geometry Nodes node groups. Catalog library: reusable functions, bundled examples, and user-owned local DSL scripts.",
Expand Down
31 changes: 13 additions & 18 deletions blender/mesh_compute_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ class RuntimeModifierBinding:
numerical_result: MeshComputeResult | None = None
last_published: object | None = None
publication_key: str | None = None
trust_revoked: bool = False


_ACTIVE_BINDINGS: dict[int, RuntimeModifierBinding] = {}
Expand Down Expand Up @@ -438,7 +437,7 @@ def _can_skip_foreign_depsgraph_updates(scene) -> bool:
full validation. This is an optimization, never a cache authority.
"""
import bpy
from ..local_runtime import runtime_python_permission
from ..local_runtime import runtime_source_active

if scene is None or bpy.context.scene is not scene:
return False
Expand All @@ -451,13 +450,12 @@ def _can_skip_foreign_depsgraph_updates(scene) -> bool:

relevant = set()
for binding in tuple(_ACTIVE_BINDINGS.values()):
# Trust is stored outside Blender's depsgraph. Revocation must always
# reach evaluate_modifier_binding's fail-closed output invalidation.
if (binding.trust_revoked or binding.status != 'READY' or
# Source-owner removal is invisible to the depsgraph and must invalidate outputs.
if (binding.status != 'READY' or
(binding.prepared.source_origin is not None and
not runtime_python_permission(binding.prepared.source_namespace,
binding.prepared.source_origin,
binding.prepared.source_package_id))):
not runtime_source_active(binding.prepared.source_namespace,
binding.prepared.source_origin,
binding.prepared.source_package_id))):
return False
owner = binding.owner
if not any(item.as_pointer() == owner.as_pointer() for item in scene.objects):
Expand Down Expand Up @@ -532,7 +530,7 @@ def _handle_scene_update(scene) -> None:
_SCHEDULER_RUNNING = True
try:
for binding in tuple(_ACTIVE_BINDINGS.values()):
if binding.trust_revoked or _modifier_identity(binding.modifier) in _GUARDED_MODIFIERS:
if _modifier_identity(binding.modifier) in _GUARDED_MODIFIERS:
continue
try:
if not any(obj.as_pointer() == binding.owner.as_pointer() for obj in scene.objects):
Expand Down Expand Up @@ -880,15 +878,12 @@ def evaluate_modifier_binding(owner, modifier=None, *, source_scene=None, skip_u
new_mesh = None
previous_published = binding.last_published
try:
if binding.trust_revoked:
raise PermissionError("Mesh Compute requires Reload after a Local Python permission revocation")
if binding.prepared.source_origin is not None:
from ..local_runtime import runtime_python_permission
if not runtime_python_permission(binding.prepared.source_namespace,
binding.prepared.source_origin,
binding.prepared.source_package_id):
binding.trust_revoked = True
raise PermissionError("Mesh Compute executable Python permission was revoked")
from ..local_runtime import runtime_source_active
if not runtime_source_active(binding.prepared.source_namespace,
binding.prepared.source_origin,
binding.prepared.source_package_id):
raise ValueError("Mesh Compute source owner is no longer active")
if any(other.as_pointer() != owner.as_pointer() and any(
mod.type == "NODES" and mod.node_group is not None and
mod.node_group.as_pointer() == binding.group.as_pointer()
Expand Down Expand Up @@ -956,7 +951,7 @@ def evaluate_modifier_binding(owner, modifier=None, *, source_scene=None, skip_u
RuntimeWarning, stacklevel=2)
return new_mesh
except BaseException as exc:
binding.status = "MISSING_RUNTIME" if isinstance(exc, PermissionError) else "ERROR"
binding.status = "ERROR"
binding.last_error = str(exc)
binding.publication_key = None
# Fail closed even if a candidate Mesh was already installed. The
Expand Down
22 changes: 22 additions & 0 deletions blender_manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
schema_version = "1.0.0"
id = "nodeforge"
version = "0.72.7"
name = "NodeForge"
tagline = "Compile a procedural DSL into Blender Geometry Nodes"
maintainer = "nachitima"
type = "add-on"
website = "https://github.com/denikryt/NodeForge"
tags = ["Node"]
blender_version_min = "5.2.0"
license = ["SPDX:GPL-3.0-or-later"]

[permissions]
files = "Install NodeForge libraries in Blender's user data directory"

[build]
paths_exclude_pattern = [
".git/", ".github/", ".pytest_cache/", ".venv/", "__pycache__/",
"tests/", "dev/", "plans/", "local/", "media/", "*.pyc",
"*.pyo", "*.blend[1-9]", "AGENTS.md", "*.patch", "/*.zip",
"*.log", "*.tmp", "*.tar", "*.xz",
]
6 changes: 6 additions & 0 deletions bundled_packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Release-owned registry of packages included in the NodeForge Extension ZIP."""

BUNDLED_PACKAGES = {
"nodeforge.math": "denikryt/nodeforge.math",
"nodeforge.lsystem": "denikryt/nodeforge.lsystem",
}
Loading
Loading