From 90c41b94df268679071b2d02f5f636319e1b979b Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:47:43 +0300 Subject: [PATCH 01/13] feat: add Blender Extensions distribution with bundled packages - Add Blender Extension manifest and release workflow - Bundle Math and L-System libraries for automatic installation - Bootstrap packages through the existing package manager - Support independent package updates without version downgrades - Remove Python execution permissions for Packages, Local and Examples - Make maximum NodeForge version advisory instead of blocking - Support Python imports under Blender Extensions namespace - Improve package installation safety and error handling --- .github/workflows/release-extension.yml | 113 +++++++++++ README.md | 18 +- __init__.py | 4 +- blender/mesh_compute_runtime.py | 31 ++- blender_manifest.toml | 22 +++ dev/CHANGELOG.md | 9 + examples/mesh_compute_erosion/erosion.py | 2 +- extensions/registry.py | 20 ++ local_runtime.py | 31 ++- local_sources.py | 132 ++----------- packages.py | 181 ++++++++++-------- .../benchmark_mesh_compute_erosion_runtime.py | 2 +- .../benchmark_mesh_compute_fingerprint.py | 2 +- .../benchmark_mesh_compute_live_scheduler.py | 2 +- ...hmark_original_erosion_native_vs_direct.py | 4 +- .../compare_mesh_compute_original_erosion.py | 4 +- .../blender/mesh_compute_render_cancel_ui.py | 2 +- tests/blender/mesh_compute_undo_redo_ui.py | 2 +- tests/blender/test_bundle_runtime.py | 2 +- tests/blender/test_examples_mesh_compute.py | 33 ++-- tests/blender/test_extension_api_v2.py | 22 +-- .../blender/test_extension_package_install.py | 55 ++++++ tests/blender/test_library_catalogs.py | 8 +- tests/blender/test_library_functions.py | 8 +- .../test_mesh_compute_foreign_event_filter.py | 8 +- .../test_mesh_compute_modifier_binding.py | 2 +- tests/blender/test_mesh_compute_scheduler.py | 20 +- .../test_mesh_compute_stage_primitives.py | 2 +- tests/blender/test_mesh_compute_ui.py | 52 ++--- tests/blender/test_object_inputs.py | 2 +- .../test_package_resolution_regressions.py | 12 +- .../test_qualified_package_callables.py | 10 +- tests/blender/test_string_inputs.py | 2 +- .../test_third_party_package_lifecycle.py | 8 +- tests/blender/test_unique_function_groups.py | 6 +- tests/unit/test_compiler_local_discovery.py | 2 +- tests/unit/test_examples_directory_index.py | 2 +- tests/unit/test_examples_mesh_compute.py | 104 ++++------ tests/unit/test_extension_bootstrap.py | 4 +- tests/unit/test_extension_packages.py | 6 +- .../unit/test_local_mesh_compute_discovery.py | 90 ++++----- .../unit/test_local_mesh_compute_registry.py | 47 ++--- tests/unit/test_package_inventory.py | 143 +++++++------- tests/unit/test_package_release_bootstrap.py | 179 +++++++++++++++++ .../test_package_resolution_regressions.py | 16 +- tests/unit/test_resolved_environment.py | 4 +- tests/unit/test_ui_package_contract.py | 14 +- ui.py | 116 +++-------- 48 files changed, 859 insertions(+), 701 deletions(-) create mode 100644 .github/workflows/release-extension.yml create mode 100644 blender_manifest.toml create mode 100644 tests/blender/test_extension_package_install.py create mode 100644 tests/unit/test_package_release_bootstrap.py diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml new file mode 100644 index 0000000..900fc0f --- /dev/null +++ b/.github/workflows/release-extension.yml @@ -0,0 +1,113 @@ +name: Build Blender Extension + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Verify NodeForge version + env: + TAG_NAME: ${{ 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' + assert os.environ['TAG_NAME'] == 'v' + declared, 'tag / manifest mismatch' + PY + - name: Download pinned library releases + env: + MATH_URL: ${{ vars.NODEFORGE_MATH_RELEASE_URL }} + LSYSTEM_URL: ${{ vars.NODEFORGE_LSYSTEM_RELEASE_URL }} + run: | + set -euo pipefail + test -n "$MATH_URL" && test -n "$LSYSTEM_URL" || { + echo 'Publish the approved library ZIPs and set their version-specific release URLs first.' >&2 + exit 1 + } + mkdir -p staging/package_install + curl --fail --location --retry 3 "$MATH_URL" -o staging/package_install/nodeforge.math-v2.2_nf-v0.70.2.zip + curl --fail --location --retry 3 "$LSYSTEM_URL" -o staging/package_install/nodeforge.lsystem-v4.0_nf-v0.70.2.zip + (cd staging/package_install && sha256sum --check -) <<'SHA256' + c0985b6c5b5499b517d5f2cc175c031efd3b88c312d0729b601b039ee2adb80e nodeforge.math-v2.2_nf-v0.70.2.zip + 9dcde6ef68352c4ea91fe8f732f0e9fa8a9745f78a7d321c0e3aecfdf6c09e31 nodeforge.lsystem-v4.0_nf-v0.70.2.zip + SHA256 + - 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, zipfile + expected = {'nodeforge.math': '2.2', 'nodeforge.lsystem': '4.0'} + seen = {} + for archive in sorted(Path('staging/package_install').glob('*.zip')): + 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])) + assert (manifest['id'], manifest['version']) == (manifest['id'], expected[manifest['id']]) + assert manifest['id'] not in seen + seen[manifest['id']] = archive.name + assert set(seen) == set(expected), seen + PY + - name: Install Blender 5.2 + run: | + 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 pathlib, zipfile + 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 + assert 'package_install/nodeforge.math-v2.2_nf-v0.70.2.zip' in files + assert 'package_install/nodeforge.lsystem-v4.0_nf-v0.70.2.zip' in files + 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 + - name: Publish GitHub release asset + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag || \ + gh release upload "$TAG_NAME" public/*.zip --clobber + - uses: actions/upload-pages-artifact@v3 + with: + path: public + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 4e4f49b..d2a7618 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,13 @@ NodeForge is a Blender add-on for describing Geometry Nodes logic in a Python-li ## Installation -1. Download the latest `NodeForge--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. +1. In Blender 5.2 or newer, open **Edit → Preferences → Get Extensions**. +2. Add the NodeForge remote extension repository under **Repositories → Add Remote Repository**, using the URL published for the release. The intended GitHub Pages URL is `https://denikryt.github.io/NodeForge/index.json` **once deployed**. +3. Install and enable **NodeForge** from **Get Extensions**. Blender manages later add-on updates through the same repository. + +NodeForge automatically installs the included **NodeForge Math** and **NodeForge L-System** libraries into its existing user **Packages** directory when first enabled. On a later NodeForge update, it only replaces an included package if the shipped version is newer than the installed version; it never downgrades a separately updated package. No separate package installation or Python permission step is needed. Installation works offline once the extension ZIP is downloaded. + +For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. ## Quick start @@ -80,10 +83,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) +**NodeForge Math** and **NodeForge L-System** are installed automatically from the selected versions included in the NodeForge Extension release. These remain ordinary, independently versioned NodeForge Packages, not Blender Extensions. + +You can install or update any library separately through **NodeForge → Packages → Install ZIP**. A package installed at a newer version than the one included in a future NodeForge release is not overwritten by that older included version. Automatic independent GitHub update checks for individual packages are not currently implemented. -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. +If a library declares `nodeforge_max_version` below the installed NodeForge version, Packages displays a compatibility warning but still permits installation and use. A library requiring a **higher minimum** NodeForge version is rejected. Actual incompatible package code may still fail at compile time or runtime. Installed package APIs are imported through the package namespace: diff --git a/__init__.py b/__init__.py index 4839460..387e3c4 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,11 @@ """NodeForge Blender addon package.""" +NODEFORGE_VERSION = (0, 71, 0) + 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.", diff --git a/blender/mesh_compute_runtime.py b/blender/mesh_compute_runtime.py index 84af5e3..0496f30 100644 --- a/blender/mesh_compute_runtime.py +++ b/blender/mesh_compute_runtime.py @@ -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] = {} @@ -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 @@ -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): @@ -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): @@ -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() @@ -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 diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..eef05de --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,22 @@ +schema_version = "1.0.0" +id = "nodeforge" +version = "0.71.0" +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", +] diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index b526777..837f38a 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -483,3 +483,12 @@ lifecycle. - Allow Local and Examples **Add Node Group** for `.nf` scripts without a pre-existing Geometry Nodes editor tree; ordinary scripts still require an editor. - Reuse a single unmodified, default passthrough Geometry Nodes modifier for Mesh Compute; preserve its original group on failure and reject nonempty or additional modifiers. - Show source filenames in Local and Examples lists; qualify duplicate Examples names by their directories. + +## 0.71.0 + +- Distribute NodeForge as a self-hosted Blender Extension with a versioned repository index and packaged Math/L-System ZIPs. +- Install or upgrade included libraries through the existing Packages inventory on activation, without downgrading independently updated versions. +- Remove `Allow executable Python` grants from Packages, Local, Examples and Mesh Compute; retain source ownership checks. +- Treat an exceeded package `nodeforge_max_version` as a visible warning, while keeping `nodeforge_min_version` strict. +- Reject damaged top-level package inventories without overwriting state, and fix install publication/copy-failure cleanup. +- Support captured Extension API imports under Blender's `bl_ext.*` add-on module namespace without global aliases. diff --git a/examples/mesh_compute_erosion/erosion.py b/examples/mesh_compute_erosion/erosion.py index 9aeeffc..bc092ef 100644 --- a/examples/mesh_compute_erosion/erosion.py +++ b/examples/mesh_compute_erosion/erosion.py @@ -7,7 +7,7 @@ """ import numpy as np -from NodeForge import mesh_compute +from NodeForge import mesh_compute # Resolved within the owner snapshot by NodeForge @mesh_compute(reads=("height", "initial_height"), writes=("height", "water", "sediment")) diff --git a/extensions/registry.py b/extensions/registry.py index 46b1862..82ac8f1 100644 --- a/extensions/registry.py +++ b/extensions/registry.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import builtins import importlib import importlib.abc import importlib.util @@ -203,6 +204,22 @@ def capture_owner_code_snapshot_from_sources( return OwnerCodeSnapshot(owner_key, modules, frozenset(), _snapshot_fingerprint(owner_key, names)) +def _snapshot_import(name, globals=None, locals=None, fromlist=(), level=0): + """Resolve historical NodeForge absolute imports within captured owner code only. + + Extension snapshots execute under private synthetic module names. Blender's + extension namespace is not a top-level ``NodeForge`` package, so owner code + imports must resolve against this loader's actual NodeForge implementation. + This neither aliases sys.modules nor changes Python's global import path. + """ + if level == 0 and (name == "NodeForge" or name.startswith("NodeForge.")): + addon = __package__.rsplit(".", 1)[0] + mapped = addon + name[len("NodeForge"):] + module = builtins.__import__(mapped, globals, locals, fromlist or ("__name__",), 0) + return module if fromlist else importlib.import_module(addon) + return builtins.__import__(name, globals, locals, fromlist, level) + + class _SnapshotLoader(importlib.abc.Loader): """Execute captured source bytes for one owner/session logical module.""" @@ -226,6 +243,9 @@ def exec_module(self, module) -> None: module.__package__ = module.__name__ try: code = compile(captured.source, module.__file__, "exec") + environment = dict(vars(builtins)) + environment["__import__"] = _snapshot_import + module.__dict__["__builtins__"] = environment exec(code, module.__dict__) except CompileError: raise diff --git a/local_runtime.py b/local_runtime.py index 3589855..4d11bb8 100644 --- a/local_runtime.py +++ b/local_runtime.py @@ -1,8 +1,8 @@ -"""Adapt selected trusted Local Python files to the existing Extension API v2. +"""Adapt registered Local Python files to the existing Extension API v2. Interface and physical adapter modules are generated from statically captured source. The selected user's implementation is executed only in an explicit implementation -phase under the ordinary source snapshot loader, after a trust permission check. +phase under the ordinary source snapshot loader, after verifying its active source owner. """ from __future__ import annotations @@ -15,19 +15,19 @@ from .extensions.registry import ( ExtensionOwnerSession, capture_owner_code_snapshot_from_sources, ) -from .local_sources import local_python_permission, examples_python_permission +from .local_sources import is_registered_local_python_source from .mesh_compute import MeshComputeDeclaration, parse_mesh_compute_source def local_runtime_owner_key(record) -> tuple[str, ...]: - """Identify a trusted Local Python owner by its exact canonical physical file.""" + """Identify a registered Local Python owner by its exact canonical physical file.""" if record.kind != "runtime" or record.source_path is None: raise CompileError("Internal error: Mesh Compute runtime requires a selected Python source record") if record.namespace == "local": return ("local-runtime", record.source_origin) if record.namespace == "examples": # Package owner and physical source both participate; package reinstall - # must not inherit a previous executable callback's trust/session. + # must not inherit a previous executable callback's owner/session. return ("examples-runtime", record.package_id or "core", record.source_origin) raise CompileError("Executable Mesh Compute source must belong to Local or Examples") @@ -44,7 +44,7 @@ def _interface_source(declaration: MeshComputeDeclaration) -> str: params.append(f"{name}: Annotated[{typ[kind]}, EvaluationMode.{mode}]{suffix}") return ( "from typing import Annotated\n" - "from NodeForge import EvaluationMode, Geometry, Float, Int, Bool\n" + f"from {__package__} import EvaluationMode, Geometry, Float, Int, Bool\n" "EXTENSION_API = 2\n" f"EXTENSIONS = {{{declaration.name!r}: '.backend:{declaration.name}'}}\n" f"def {declaration.name}({', '.join(params)}) -> Geometry:\n ...\n" @@ -67,11 +67,11 @@ def _backend_source(declaration: MeshComputeDeclaration, source_origin: str, nam ) -def runtime_python_permission(namespace: str, source_path: str | Path, package_id: str = "") -> bool: - """Recheck the exact selected runtime owner before every evaluation and capture.""" +def runtime_source_active(namespace: str, source_path: str | Path, package_id: str = "") -> bool: + """Check current ownership of a recorded runtime source before code execution.""" source = Path(source_path).resolve() if namespace == "local": - return local_python_permission(source) + return is_registered_local_python_source(source) if namespace != "examples" or source.suffix != ".py" or not source.is_file(): return False if package_id: @@ -79,24 +79,21 @@ def runtime_python_permission(namespace: str, source_path: str | Path, package_i for package in active_package_records(): if package.manifest.package_id == package_id: root = package.manifest.root_for("examples") - return bool(root is not None and source.is_relative_to(root) - and package.state_record.get("allow_python") is True) + return bool(root is not None and source.is_relative_to(root)) return False from .catalog import catalog_dir root = Path(catalog_dir("examples")).resolve() - return bool(source != root and source.is_relative_to(root) and examples_python_permission(source)) + return source != root and source.is_relative_to(root) def capture_local_runtime_session(record) -> tuple[ExtensionCallableId, ExtensionOwnerSession]: - """Capture one trusted Local source and its v2 adapter without running its code.""" + """Capture one registered Local source and its v2 adapter without running its code.""" owner_key = local_runtime_owner_key(record) from .catalog import _path_key if record.source_origin != _path_key(record.source_path): raise CompileError("Selected Local runtime source has inconsistent canonical identity") - if not runtime_python_permission(record.namespace, record.source_path, record.package_id): - if record.namespace == "local": - raise CompileError(f"Local executable Python is not allowed for {record.source_path}. Enable Allow executable Python on the owning Local folder/file") - raise CompileError(f"Executable Python is not authorized for selected {record.namespace} owner {record.source_path}") + if not runtime_source_active(record.namespace, record.source_path, record.package_id): + raise CompileError(f"Mesh Compute Python source is not an active {record.namespace} owner: {record.source_path}") try: if Path(record.source_path).stat().st_size > 1_048_576: raise CompileError("Local Python source exceeds 1 MiB") diff --git a/local_sources.py b/local_sources.py index f47c9aa..e9f6ff4 100644 --- a/local_sources.py +++ b/local_sources.py @@ -54,7 +54,7 @@ def _paths_overlap(first: Path | str, second: Path | str) -> bool: def _read_local_source_payload() -> dict[str, object]: - """Read and validate the version-one registry and optional Python trust grants.""" + """Read registered Local roots and ignore obsolete permission metadata.""" path = _local_sources_registry_path() if not path.exists(): return {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": []} @@ -64,10 +64,6 @@ def _read_local_source_payload() -> dict[str, object]: raise CompileError(f"Could not read Local source registry: {exc}") from exc if not isinstance(payload, dict) or payload.get("version") != _LOCAL_SOURCES_REGISTRY_VERSION: raise CompileError("Unsupported Local source registry format") - if not isinstance(payload.get("managed_allow_python", False), bool): - raise CompileError("Invalid managed Local Python permission") - if not isinstance(payload.get("allow_examples_python", False), bool): - raise CompileError("Invalid bundled Examples Python permission") roots = payload.get("roots", []) if not isinstance(roots, list): raise CompileError("Invalid Local source registry roots") @@ -76,30 +72,22 @@ def _read_local_source_payload() -> dict[str, object]: if not isinstance(item, dict) or not isinstance(item.get("path"), str): raise CompileError("Invalid Local source registry entry") kind = str(item.get("kind") or "folder") - if kind not in {"folder", "file"} or not isinstance(item.get("allow_python", False), bool): - raise CompileError("Invalid Local source registry entry kind or permission") + if kind not in {"folder", "file"}: + raise CompileError("Invalid Local source registry entry kind") entry: dict[str, object] = {"path": item["path"], "label": str(item.get("label") or ""), "kind": kind} - if "allow_python" in item: - entry["allow_python"] = item["allow_python"] result.append(entry) - return {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": result, - **({"managed_allow_python": payload["managed_allow_python"]} if "managed_allow_python" in payload else {}), - **({"allow_examples_python": payload["allow_examples_python"]} if "allow_examples_python" in payload else {})} + return {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": result} def _read_local_source_registry() -> list[dict[str, str]]: - """Load linked Local roots while preserving optional permission fields.""" + """Load registered Local roots.""" return _read_local_source_payload()["roots"] def _write_local_source_registry(roots: list[dict[str, str]]) -> None: - """Atomically persist linked roots without discarding managed Python permission.""" + """Atomically persist linked roots.""" path = _local_sources_registry_path() - previous = _read_local_source_payload() payload = {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": roots} - for permission in ("managed_allow_python", "allow_examples_python"): - if permission in previous: - payload[permission] = previous[permission] fd, temp_name = tempfile.mkstemp(prefix="local_sources.", suffix=".tmp", dir=str(path.parent)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -236,113 +224,23 @@ def unlink_local_source_file(path: str | Path) -> Path: return _unlink_local_source_registration(path, kind="file") -def local_python_permission(path: Path | str) -> bool: - """Resolve an exact selected .py source to its registered, explicitly trusted root.""" +def is_registered_local_python_source(path: Path | str) -> bool: + """Check that a Python file remains inside a registered Local source owner.""" source = _canonical_path(path) if source.suffix != ".py" or not source.is_file(): return False managed = _canonical_path(_default_local_catalog_dir()) - payload = _read_local_source_payload() - try: - source.relative_to(managed) - except ValueError: - pass - else: - return bool(payload.get("managed_allow_python", False)) - for item in payload["roots"]: + if source.is_relative_to(managed): + return True + for item in _read_local_source_payload()["roots"]: root = _canonical_path(item["path"]) - if item["kind"] == "file": - selected = source == root - else: - try: - source.relative_to(root) - selected = True - except ValueError: - selected = False - if selected: - return bool(item.get("allow_python", False)) + if item["kind"] == "file" and source == root: + return True + if item["kind"] == "folder" and source.is_relative_to(root): + return True return False -def local_python_root_permission(path: Path | str) -> bool: - """Report the persisted executable-Python permission for a registered Local owner. - - The caller must pass the exact managed root, linked folder or linked file; - arbitrary paths beneath a registered directory cannot change its grant. - """ - target = _canonical_path(path) - managed = _canonical_path(_default_local_catalog_dir()) - payload = _read_local_source_payload() - if target == managed: - return bool(payload.get("managed_allow_python", False)) - matches = [item for item in payload["roots"] if _canonical_path(item["path"]) == target] - if len(matches) != 1: - raise CompileError(f"Local Python permission requires a registered root or linked file: {target}") - return bool(matches[0].get("allow_python", False)) - - -def set_local_python_permission(path: Path | str, *, allowed: bool) -> None: - """Store an explicit grant on a canonical registered folder/file or managed root.""" - if not isinstance(allowed, bool): - raise CompileError("Allow executable Python must be a boolean") - target = _canonical_path(path) - managed = _canonical_path(_default_local_catalog_dir()) - payload = _read_local_source_payload() - if target == managed: - payload["managed_allow_python"] = allowed - else: - matches = [item for item in payload["roots"] if _canonical_path(item["path"]) == target] - if len(matches) != 1: - raise CompileError(f"Local Python permission requires a registered root or linked file: {target}") - matches[0]["allow_python"] = allowed - _persist_permission_payload(payload) - - -def _persist_permission_payload(payload: dict[str, object]) -> None: - """Atomically write the validated registry including independent permission grants.""" - path = _local_sources_registry_path() - fd, temporary = tempfile.mkstemp(prefix="local_sources.", suffix=".tmp", dir=str(path.parent)) - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, ensure_ascii=False) - handle.write("\n") - os.replace(temporary, path) - except Exception: - Path(temporary).unlink(missing_ok=True) - raise - - -def _bundled_examples_root() -> Path: - """Resolve the exact trusted installation-owned Examples directory.""" - return _canonical_path(catalog.catalog_dir("examples")) - - -def examples_python_root_permission(root: Path | str) -> bool: - """Read the separate bundled Examples grant only for its exact owning root.""" - if _canonical_path(root) != _bundled_examples_root(): - raise CompileError("Examples Python permission requires the bundled Examples root") - return bool(_read_local_source_payload().get("allow_examples_python", False)) - - -def examples_python_permission(path: Path | str) -> bool: - """Deny any Python file not canonically inside the installed bundled Examples root.""" - source = _canonical_path(path) - if source.suffix != ".py" or not source.is_file(): - return False - if source == _bundled_examples_root() or not source.is_relative_to(_bundled_examples_root()): - return False - return examples_python_root_permission(_bundled_examples_root()) - - -def set_examples_python_permission(root: Path | str, *, allowed: bool) -> None: - """Persist an explicit, independent opt-in for the one bundled Examples root.""" - if not isinstance(allowed, bool) or _canonical_path(root) != _bundled_examples_root(): - raise CompileError("Examples Python permission requires its exact root and a boolean grant") - payload = _read_local_source_payload() - payload["allow_examples_python"] = allowed - _persist_permission_payload(payload) - - def _default_local_catalog_dir() -> Path: """Return the persistent user-owned root for Local catalog sources.""" import bpy diff --git a/packages.py b/packages.py index 3a6a2d9..2da5a73 100644 --- a/packages.py +++ b/packages.py @@ -34,13 +34,9 @@ def _current_nodeforge_version() -> tuple[int, ...]: - """Return the running add-on version used for package compatibility checks.""" - try: - from . import bl_info - - raw = bl_info.get("version", ()) - except Exception: - raw = () + """Return the runtime version without relying on Blender's stripped bl_info.""" + from . import NODEFORGE_VERSION + raw = NODEFORGE_VERSION if not isinstance(raw, (tuple, list)) or not raw: raise PackageError("NodeForge runtime version is unavailable") try: @@ -79,10 +75,19 @@ def _validate_nodeforge_version_bounds(raw: dict[str, Any]) -> None: raise PackageError( f"Package requires NodeForge {raw.get('nodeforge_min_version')} or newer; current version is {'.'.join(str(p) for p in current)}" ) - if max_version is not None and _compare_versions(current, max_version) > 0: - raise PackageError( - f"Package supports NodeForge up to {raw.get('nodeforge_max_version')}; current version is {'.'.join(str(p) for p in current)}" - ) + + +def package_compatibility_warning(manifest: "PackageManifest") -> str: + """Return an advisory for a package targeting an older NodeForge maximum.""" + maximum = manifest.nodeforge_max_version + if maximum is None: + return "" + parsed = _parse_dotted_numeric_version(maximum, "nodeforge_max_version") + current = _current_nodeforge_version() + if _compare_versions(current, parsed) <= 0: + return "" + return (f"Package supports NodeForge up to {maximum}; current version is " + f"{'.'.join(map(str, current))}. Compatibility is not guaranteed.") def _validate_raw_posix_relative_path(value: Any, field: str) -> str: @@ -114,7 +119,7 @@ class PackageManifest: description: str root: Path contents: dict[str, str] - permissions: dict[str, bool] + nodeforge_max_version: str | None = None origin: str = "user" def root_for(self, namespace: str) -> Path | None: @@ -151,7 +156,6 @@ class SystemPackageRecord: root: Path interface_path: Path origin: str - permissions: dict[str, bool] @dataclass(frozen=True) @@ -164,8 +168,6 @@ class PackageDiagnostic: name: str = "" version: str = "" origin: str = "" - python_required: bool = False - python_allowed: bool = False @dataclass(frozen=True) @@ -174,7 +176,6 @@ class ActivePackage: manifest: PackageManifest state_record: dict[str, Any] - python_required: bool def set_packages_dir_for_tests(path: Path | None) -> None: @@ -228,7 +229,7 @@ def load_package_state() -> dict[str, Any]: if data.get("schema_version") != STATE_SCHEMA_VERSION: raise PackageError("Unsupported package state schema_version") if not isinstance(data.get("packages"), dict): - data["packages"] = {} + raise PackageError("Package state packages must be an object") data.pop("seed_sources", None) return data @@ -237,7 +238,12 @@ def save_package_state(state: dict[str, Any]) -> None: """Durably save package state using a same-directory temp file and replace.""" root = packages_dir() root.mkdir(parents=True, exist_ok=True) - payload = json.dumps(state, indent=2, sort_keys=True) + "\n" + cleaned = {**state, "packages": { + key: ({k: v for k, v in record.items() if k != "allow_python"} + if isinstance(record, dict) else record) + for key, record in state["packages"].items() + }} + payload = json.dumps(cleaned, indent=2, sort_keys=True) + "\n" fd, tmp_name = tempfile.mkstemp(prefix="state.", suffix=".tmp", dir=root) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -245,7 +251,11 @@ def save_package_state(state: dict[str, Any]) -> None: handle.flush() os.fsync(handle.fileno()) os.replace(tmp_name, state_path()) - _fsync_directory(root) + try: + _fsync_directory(root) + except OSError: + # State is already published: a durability failure cannot trigger rollback. + pass finally: try: if os.path.exists(tmp_name): @@ -263,7 +273,10 @@ def _fsync_directory(path: Path) -> None: except OSError: return try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError: + pass finally: os.close(fd) @@ -313,13 +326,6 @@ def validate_package_root(root: Path, *, origin: str = "user") -> PackageManifes contents[key] = rel if not contents: raise PackageError("Package manifest must declare at least one supported content root") - permissions_raw = raw.get("permissions") or {} - if not isinstance(permissions_raw, dict): - raise PackageError("Package permissions must be an object") - permissions = {"python": bool(permissions_raw.get("python", False))} - python_required = package_requires_python(root, contents) - if python_required and not permissions["python"]: - raise PackageError("Package contains Python or systems but permissions.python is false") _validate_root_contents(root, contents) return PackageManifest( package_id=package_id, @@ -330,7 +336,7 @@ def validate_package_root(root: Path, *, origin: str = "user") -> PackageManifes description=description, root=root, contents=contents, - permissions=permissions, + nodeforge_max_version=raw.get("nodeforge_max_version"), origin=origin, ) @@ -412,20 +418,6 @@ def _validate_public_name(name: str, context: str) -> None: raise PackageError(f"{context} {name!r} must be a valid public identifier") -def package_requires_python(root: Path, contents: dict[str, str]) -> bool: - """Return whether current declared roots contain Python or systems.""" - if "systems" in contents: - return True - for rel in contents.values(): - content_root = (root / rel).resolve() - if not _is_relative_to(content_root, root.resolve()) or not content_root.exists(): - continue - for path in content_root.rglob("*"): - if path.is_file() and path.suffix == ".py": - return True - return False - - def validate_package_system_declarations(manifest: PackageManifest) -> None: """Validate that every structurally accepted system uses Extension API v2. @@ -473,10 +465,7 @@ def _structural_active_package_from_state(package_id: str, record: Any) -> Activ raise PackageError("Manifest id does not match package state key") if manifest.version != record.get("installed_version"): raise PackageError("Manifest version does not match package state version") - python_required = package_requires_python(manifest.root, manifest.contents) - if python_required and not bool(record.get("allow_python", False)): - raise PackageError("Package requires Python but Python consent is not recorded") - return ActivePackage(manifest, record, python_required) + return ActivePackage(manifest, record) def active_package_manifest_snapshot() -> tuple[PackageManifest, ...]: @@ -505,10 +494,8 @@ def _package_diagnostic_from_state(package_id: str, record: Any, message: str) - """Build UI-facing diagnostics from an invalid state record without activating it.""" path = str(record.get("installed_path", "")) if isinstance(record, dict) else "" origin = str(record.get("origin", "")) if isinstance(record, dict) else "" - python_allowed = bool(record.get("allow_python", False)) if isinstance(record, dict) else False name = package_id version = str(record.get("installed_version", "")) if isinstance(record, dict) else "" - python_required = False if isinstance(record, dict): try: rel = _validate_state_installed_path(package_id, record.get("installed_path")) @@ -516,18 +503,11 @@ def _package_diagnostic_from_state(package_id: str, record: Any, message: str) - manifest = validate_package_root(root, origin=origin or "user") name = manifest.name version = manifest.version - python_required = package_requires_python(manifest.root, manifest.contents) except Exception: pass return PackageDiagnostic( - package_id=package_id, - message=message, - path=path, - name=name, - version=version, - origin=origin, - python_required=python_required, - python_allowed=python_allowed, + package_id=package_id, message=message, path=path, + name=name, version=version, origin=origin, ) @@ -606,7 +586,6 @@ def system_package_records_from_manifests( root=child.resolve(), interface_path=interface_path.resolve(), origin=item.origin, - permissions=item.permissions, ) ) return tuple(records) @@ -617,14 +596,66 @@ def system_package_records() -> list[SystemPackageRecord]: return list(system_package_records_from_manifests(active_package_manifests())) -def install_package_directory(source_dir: Path, *, allow_python: bool, replace: bool = False, origin: str = "user") -> PackageManifest: +def install_included_packages(extension_root: Path) -> tuple[str, ...]: + """Install newer versioned ZIPs shipped with this extension as regular Packages. + + All candidate IDs are validated before mutation, and the installed state + remains the sole version authority. An invalid top-level state aborts the batch. + """ + archive_dir = Path(extension_root) / "package_install" + if not archive_dir.is_dir(): + return () + archives = sorted(archive_dir.glob("*.zip")) + if not archives: + return () + # Fail closed before even extracting one archive into the user inventory. + state = load_package_state() + candidates = [] + errors = [] + seen = set() + for archive in archives: + staging = packages_dir() / ".staging" / uuid.uuid4().hex + staging.mkdir(parents=True, exist_ok=False) + try: + root = _extract_zip_to_staging(archive, staging) + manifest = validate_package_root(root) + if manifest.package_id in seen: + raise PackageError(f"Duplicate included package ID: {manifest.package_id}") + seen.add(manifest.package_id) + candidates.append((archive, manifest.package_id, manifest.version)) + except Exception as exc: + if "Duplicate included package ID" in str(exc): + # Ambiguous release contents must not partially change inventory. + return (str(exc),) + errors.append(f"{archive.name}: {exc}") + finally: + shutil.rmtree(staging, ignore_errors=True) + for archive, package_id, version in candidates: + installed = state["packages"].get(package_id) + origin = "user" + if installed is not None: + try: + active = _active_package_from_state(package_id, installed) + if _compare_versions( + _parse_dotted_numeric_version(active.manifest.version, "version"), + _parse_dotted_numeric_version(version, "version"), + ) >= 0: + continue + origin = str(active.state_record.get("origin") or "user") + except Exception as exc: + errors.append(f"{package_id}: installed package is invalid: {exc}") + continue + try: + install_package_zip(archive, replace=installed is not None, origin=origin) + except Exception as exc: + errors.append(f"{archive.name}: {exc}") + return tuple(errors) + + +def install_package_directory(source_dir: Path, *, replace: bool = False, origin: str = "user") -> PackageManifest: """Install a package after validating the exact copied bytes that may be published.""" source_dir = Path(source_dir).resolve() manifest = validate_package_root(source_dir, origin=origin) - current_python_required = package_requires_python(manifest.root, manifest.contents) - if current_python_required and not allow_python: - raise PackageError("Package requires executable Python; explicit consent is required") - state = load_package_state() if manifest.package_id in state.get("packages", {}) and not replace: raise PackageError(f"Package {manifest.package_id!r} is already installed") @@ -636,7 +667,7 @@ def install_package_directory(source_dir: Path, *, allow_python: bool, replace: # Copy first. V2 declaration Python is normalized only from these exact # candidate bytes, never once from source and again from the installed copy. record = _install_validated_directory( - source_dir, manifest, allow_python=allow_python, origin=origin + source_dir, manifest, origin=origin ) installed_root = (packages_dir() / record["installed_path"]).resolve() installed_manifest = validate_package_root(installed_root, origin=origin) @@ -668,7 +699,7 @@ def install_package_directory(source_dir: Path, *, allow_python: bool, replace: return validate_package_root((packages_dir() / record["installed_path"]).resolve(), origin=origin) -def _install_validated_directory(source_dir: Path, manifest: PackageManifest, *, allow_python: bool, origin: str) -> dict[str, Any]: +def _install_validated_directory(source_dir: Path, manifest: PackageManifest, *, origin: str) -> dict[str, Any]: """Copy one structurally validated package without executing declaration Python.""" token = f"install_{uuid.uuid4().hex[:12]}" target_parent = installed_dir() / manifest.package_id @@ -676,28 +707,29 @@ def _install_validated_directory(source_dir: Path, manifest: PackageManifest, *, target = target_parent / token if target.exists(): raise PackageError("Install target collision") - shutil.copytree(source_dir, target, symlinks=False, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")) - installed_manifest = validate_package_root(target, origin=origin) - if installed_manifest.package_id != manifest.package_id or installed_manifest.version != manifest.version: - raise PackageError("Copied package manifest identity changed during installation") - if package_requires_python(installed_manifest.root, installed_manifest.contents) and not allow_python: - raise PackageError("Package requires executable Python; explicit consent is required") + try: + shutil.copytree(source_dir, target, symlinks=False, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")) + installed_manifest = validate_package_root(target, origin=origin) + if installed_manifest.package_id != manifest.package_id or installed_manifest.version != manifest.version: + raise PackageError("Copied package manifest identity changed during installation") + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise rel = Path("installed") / manifest.package_id / token return { "installed_path": rel.as_posix(), "installed_version": installed_manifest.version, - "allow_python": bool(allow_python), "origin": origin, } -def install_package_zip(zip_path: Path, *, allow_python: bool, replace: bool = False, origin: str = "user") -> PackageManifest: +def install_package_zip(zip_path: Path, *, replace: bool = False, origin: str = "user") -> PackageManifest: """Install a package from a hostile zip archive after safe extraction.""" zip_path = Path(zip_path) staging = packages_dir() / ".staging" / uuid.uuid4().hex staging.mkdir(parents=True, exist_ok=False) try: root = _extract_zip_to_staging(zip_path, staging) - return install_package_directory(root, allow_python=allow_python, replace=replace, origin=origin) + return install_package_directory(root, replace=replace, origin=origin) finally: shutil.rmtree(staging, ignore_errors=True) @@ -995,7 +1027,6 @@ def _is_relative_to(path: Path, root: Path) -> bool: "load_package_state", "save_package_state", "validate_package_root", - "package_requires_python", "validate_package_system_declarations", "active_package_records", "active_package_manifests", diff --git a/tests/blender/benchmark_mesh_compute_erosion_runtime.py b/tests/blender/benchmark_mesh_compute_erosion_runtime.py index ac83021..ecc13da 100644 --- a/tests/blender/benchmark_mesh_compute_erosion_runtime.py +++ b/tests/blender/benchmark_mesh_compute_erosion_runtime.py @@ -39,7 +39,7 @@ def run(side,steps): local_sources._default_local_catalog_dir=lambda: root local_sources._local_sources_registry_path=lambda: Path(tmp)/'registry.json' try: - local_sources.set_local_python_permission(root,allowed=True) + mesh=bpy.data.meshes.new('BenchmarkInput') mesh.from_pydata([(0,0,0),(1,0,0),(0,1,0)],[],[(0,1,2)]) owner=bpy.data.objects.new('BenchmarkObject',mesh) diff --git a/tests/blender/benchmark_mesh_compute_fingerprint.py b/tests/blender/benchmark_mesh_compute_fingerprint.py index 2f2c96c..4e4be08 100644 --- a/tests/blender/benchmark_mesh_compute_fingerprint.py +++ b/tests/blender/benchmark_mesh_compute_fingerprint.py @@ -57,7 +57,7 @@ def grid_with_layers(side): (local / "passthrough.py").write_text('''import numpy as np\nfrom NodeForge import mesh_compute\n@mesh_compute(writes=("height",))\ndef passthrough(mesh):\n coords=np.frombuffer(mesh.positions,dtype=np.float32).reshape((-1,3)).copy()\n return {"positions":coords,"point_attributes":{"height":coords[:,2].copy()}}\n''') local_sources._default_local_catalog_dir = lambda: local local_sources._local_sources_registry_path = lambda: root / "registry.json" - local_sources.set_local_python_permission(local, allowed=True) + source = 'from local import passthrough\ngeo = input_geometry("Geometry")\ngeo = passthrough(geo)\noutput("Geometry", geo)\n' owners = [] for side in SIZES: diff --git a/tests/blender/benchmark_mesh_compute_live_scheduler.py b/tests/blender/benchmark_mesh_compute_live_scheduler.py index 9427cf6..5c6f97e 100644 --- a/tests/blender/benchmark_mesh_compute_live_scheduler.py +++ b/tests/blender/benchmark_mesh_compute_live_scheduler.py @@ -110,7 +110,7 @@ def run(side: int, repeats: int, warmups: int) -> dict: local_sources._default_local_catalog_dir = lambda: local local_sources._local_sources_registry_path = lambda: root / 'registry.json' try: - local_sources.set_local_python_permission(local, allowed=True) + bpy.ops.mesh.primitive_grid_add(x_subdivisions=side - 1, y_subdivisions=side - 1) owner = bpy.context.object if len(owner.data.vertices) != side * side: diff --git a/tests/blender/benchmark_original_erosion_native_vs_direct.py b/tests/blender/benchmark_original_erosion_native_vs_direct.py index 0e155d8..972449c 100644 --- a/tests/blender/benchmark_original_erosion_native_vs_direct.py +++ b/tests/blender/benchmark_original_erosion_native_vs_direct.py @@ -103,7 +103,7 @@ def run(args): with tempfile.TemporaryDirectory(prefix='nf-erosion-direct-vs-gn-') as temp: root = Path(temp) packages.set_packages_dir_for_tests(root / 'packages') - installed = packages.install_package_zip(args.math_package, allow_python=True) + installed = packages.install_package_zip(args.math_package) assert installed.package_id == 'nodeforge.math' local = root / 'Local'; local.mkdir() shutil.copy2(EXAMPLES / 'erosion.py', local / 'erosion.py') @@ -112,7 +112,7 @@ def run(args): local_sources._default_local_catalog_dir = lambda: local local_sources._local_sources_registry_path = lambda: root / 'local_sources.json' try: - local_sources.set_local_python_permission(local, allowed=True) + bpy.data.materials.new('Erosion Terrain') source = edited_source((EXAMPLES / 'erosion_numpy.nf').read_text(), args.side, args.steps, args.preset) reference_source = edited_source(args.reference_nf.read_text(), args.side, args.steps, args.preset) diff --git a/tests/blender/compare_mesh_compute_original_erosion.py b/tests/blender/compare_mesh_compute_original_erosion.py index bb25b14..5652db0 100644 --- a/tests/blender/compare_mesh_compute_original_erosion.py +++ b/tests/blender/compare_mesh_compute_original_erosion.py @@ -154,7 +154,7 @@ def run(args): with tempfile.TemporaryDirectory(prefix='nodeforge-gn-erosion-parity-') as tmp: root = Path(tmp) packages.set_packages_dir_for_tests(root / 'packages') - installed = packages.install_package_zip(args.math_package, allow_python=True) + installed = packages.install_package_zip(args.math_package) assert installed.package_id == 'nodeforge.math' local = root / 'Local' local.mkdir() @@ -164,7 +164,7 @@ def run(args): local_sources._default_local_catalog_dir = lambda: local local_sources._local_sources_registry_path = lambda: root / 'local_sources.json' try: - local_sources.set_local_python_permission(local, allowed=True) + bpy.data.materials.new('Erosion Terrain') source_gn = edited_source(args.reference_nf.read_text(), args.side, args.steps, args.preset) source_numpy = edited_source((EXAMPLES / 'erosion_numpy.nf').read_text(), args.side, args.steps, args.preset) diff --git a/tests/blender/mesh_compute_render_cancel_ui.py b/tests/blender/mesh_compute_render_cancel_ui.py index 01b2a5f..35d19cd 100644 --- a/tests/blender/mesh_compute_render_cancel_ui.py +++ b/tests/blender/mesh_compute_render_cancel_ui.py @@ -106,7 +106,7 @@ def _start_render(): root.mkdir() local_sources._default_local_catalog_dir = lambda: root local_sources._local_sources_registry_path = lambda: base / 'registry.json' - local_sources.set_local_python_permission(root, allowed=True) + (root / 'shift.py').write_text( 'from NodeForge import mesh_compute\n' '@mesh_compute(writes=("height",))\n' diff --git a/tests/blender/mesh_compute_undo_redo_ui.py b/tests/blender/mesh_compute_undo_redo_ui.py index 9918ebc..76e1988 100644 --- a/tests/blender/mesh_compute_undo_redo_ui.py +++ b/tests/blender/mesh_compute_undo_redo_ui.py @@ -56,7 +56,7 @@ def _run_history_check() -> dict: local_root.mkdir() local_sources._default_local_catalog_dir = lambda: local_root local_sources._local_sources_registry_path = lambda: base / 'registry.json' - local_sources.set_local_python_permission(local_root, allowed=True) + (local_root / 'shift.py').write_text( 'from NodeForge import mesh_compute\n' '@mesh_compute(writes=("height",))\n' diff --git a/tests/blender/test_bundle_runtime.py b/tests/blender/test_bundle_runtime.py index 2b247b3..b84ffaa 100644 --- a/tests/blender/test_bundle_runtime.py +++ b/tests/blender/test_bundle_runtime.py @@ -172,7 +172,7 @@ def test_bundle_packaged_library_function_round_trip(): ) packages.set_packages_dir_for_tests(tmp / "inventory") try: - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) group = compile_group(''' from packages import bundle as bundle_pkg state = bundle(value=2.0) diff --git a/tests/blender/test_examples_mesh_compute.py b/tests/blender/test_examples_mesh_compute.py index e241ea7..bf84534 100644 --- a/tests/blender/test_examples_mesh_compute.py +++ b/tests/blender/test_examples_mesh_compute.py @@ -65,32 +65,25 @@ def _select_source(nf): return props -def test_selected_examples_compiles_modifier_and_revocation_fails_closed( +def test_selected_examples_compiles_modifier_and_removed_owner_fails_closed( selected_examples, registered_ui, mesh_object): - """Only consented Python executes, and revocation invalidates active evaluation.""" + """Examples compile without grants and invalidate when the source disappears.""" + from NodeForge.local_runtime import runtime_source_active bundled, nf = selected_examples _select_mesh(mesh_object) _select_source(nf) - # Examples selection is independent of the main Blender Text Script. - original_groups = {group.as_pointer() for group in bpy.data.node_groups} - assert not local_sources.examples_python_root_permission(bundled) - with pytest.raises(RuntimeError, match='not authorized'): - bpy.ops.nodeforge.create_function_group(namespace="examples") - assert not mesh_object.modifiers - assert {group.as_pointer() for group in bpy.data.node_groups} == original_groups - - assert bpy.ops.nodeforge.set_examples_python_permission(allowed=True) == {'FINISHED'} - assert bpy.ops.nodeforge.create_function_group(namespace="examples") == {'FINISHED'} + assert bpy.ops.nodeforge.create_function_group(namespace='examples') == {'FINISHED'} assert len(mesh_object.modifiers) == 1 binding = runtime.get_modifier_binding(mesh_object) - assert binding.prepared.source_namespace == 'examples' - assert binding.prepared.source_package_id == '' assert binding.status == 'READY' assert _evaluated_positions(mesh_object)[0] == (2., 3., 4.) - assert bpy.ops.nodeforge.set_examples_python_permission(allowed=False) == {'FINISHED'} - with pytest.raises(PermissionError, match='revoked'): + source = nf.with_name('shift.py') + assert runtime_source_active('examples', source) + source.unlink() + assert not runtime_source_active('examples', source) + with pytest.raises(ValueError, match='owner'): runtime.evaluate_modifier_binding(mesh_object) - assert binding.trust_revoked + assert binding.status == 'ERROR' def test_examples_modifier_poll_and_stale_source_failure( @@ -101,7 +94,7 @@ def test_examples_modifier_poll_and_stale_source_failure( _select_source(nf) user_modifier = mesh_object.modifiers.new('User', 'BEVEL') mesh_object.modifiers.remove(user_modifier) - local_sources.set_examples_python_permission(bundled, allowed=True) + nf.unlink() before = {group.as_pointer() for group in bpy.data.node_groups} with pytest.raises(RuntimeError, match='unavailable'): @@ -115,7 +108,7 @@ def test_examples_python_cannot_be_published_as_regular_library_group(selected_e from NodeForge.compiler import create_examples_source_group from NodeForge.errors import CompileError bundled, nf = selected_examples - local_sources.set_examples_python_permission(bundled, allowed=True) + with pytest.raises(CompileError, match='unavailable'): create_examples_source_group(str(nf.with_name('shift.py'))) @@ -128,7 +121,7 @@ def test_examples_add_node_group_reuses_blender_empty_modifier( bundled, nf = selected_examples _select_mesh(mesh_object) _select_source(nf) - local_sources.set_examples_python_permission(bundled, allowed=True) + modifier, original_group = _add_empty_gn_modifier(mesh_object) pointer = modifier.as_pointer() try: diff --git a/tests/blender/test_extension_api_v2.py b/tests/blender/test_extension_api_v2.py index 40847a8..160dd28 100644 --- a/tests/blender/test_extension_api_v2.py +++ b/tests/blender/test_extension_api_v2.py @@ -335,7 +335,7 @@ def test_v2_system_dispatch_is_lazy_and_uses_public_backend_context(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert not hasattr(builtins, "_nodeforge_extension_v2_blender_impl_hits") group = compile_group( @@ -360,7 +360,7 @@ def test_nested_owner_local_imports_work_through_install_and_physical_dispatch() with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_nested_import_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) group = compile_group( 'from packages import nested\n' 'value = input_float("Value", default=3.0)\n' @@ -380,7 +380,7 @@ def test_rotation_extension_result_can_cross_another_extension_and_group_output( with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_rotation_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) group = compile_group( 'from packages import rotation as rotpkg\n' 'value = input_vector("Euler")\n' @@ -404,7 +404,7 @@ def test_direct_wrapper_cannot_return_an_input_socket_as_runtime_result(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_extension_package(source, bad_result=True) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) before = set(bpy.data.node_groups) with pytest.raises(CompileError, match="output sockets"): compile_group( @@ -422,7 +422,7 @@ def test_invalid_extension_result_rolls_back_generated_resources(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_extension_package(source, bad_result=True, create_resource=True) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) before_meshes = {mesh.as_pointer() for mesh in bpy.data.meshes} before_groups = {group.as_pointer() for group in bpy.data.node_groups} @@ -446,7 +446,7 @@ def test_semantic_state_reconstructs_exact_records_and_runtime_leaf(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_semantic_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) group = compile_group( 'from packages import semantic\n' 'value = input_int("Value", default=3)\n' @@ -472,7 +472,7 @@ def test_semantic_backend_result_failure_rolls_back_generated_resources(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_semantic_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) before_meshes = {mesh.as_pointer() for mesh in bpy.data.meshes} before_groups = {group.as_pointer() for group in bpy.data.node_groups} before_objects = {obj.as_pointer() for obj in bpy.data.objects} @@ -504,7 +504,7 @@ def test_persistent_semantic_assignment_reconstructs_runtime_leaf_later(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_semantic_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) group = compile_group( 'from packages import semantic\n' 'value = input_int("Value", default=3)\n' @@ -530,7 +530,7 @@ def test_discarded_runtime_extension_argument_creates_no_backend_or_resource(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_semantic_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) before_meshes = {mesh.as_pointer() for mesh in bpy.data.meshes} group = compile_group( 'from packages import semantic\n' @@ -553,7 +553,7 @@ def test_persistent_semantic_backend_failure_still_rolls_back_resources(): with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "package" _write_semantic_extension_package(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) before_meshes = {mesh.as_pointer() for mesh in bpy.data.meshes} before_groups = {group.as_pointer() for group in bpy.data.node_groups} before_objects = {obj.as_pointer() for obj in bpy.data.objects} @@ -580,7 +580,7 @@ def test_nodeforge_math_v2_reference_package(): package_path = os.environ.get("NODEFORGE_MATH_PACKAGE") if not package_path: pytest.skip("NODEFORGE_MATH_PACKAGE is required for the reference-package integration test") - packages.install_package_directory(Path(package_path), allow_python=True) + packages.install_package_directory(Path(package_path)) sine_group = compile_group( 'from packages import math\n' diff --git a/tests/blender/test_extension_package_install.py b/tests/blender/test_extension_package_install.py new file mode 100644 index 0000000..60d6355 --- /dev/null +++ b/tests/blender/test_extension_package_install.py @@ -0,0 +1,55 @@ +"""Smoke-test an installed Blender Extension and its independently versioned Packages.""" + +from __future__ import annotations + +import importlib +import os +from pathlib import Path + +import bpy +import pytest + + +def _installed_extension(): + """Find the Blender-managed installation, or skip regular legacy test sessions.""" + module_name = os.environ.get("NODEFORGE_EXTENSION_MODULE", "bl_ext.user_default.nodeforge") + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + pytest.skip(f"NodeForge Blender Extension is not installed under {module_name}") + return module + + +def test_real_extension_provides_installed_packages_and_compiles_examples(): + """Verify release ZIP bootstrap, owner imports and live Geometry Nodes lowering.""" + extension = _installed_extension() + from importlib import import_module + + packages = import_module(extension.__name__ + ".packages") + compiler = import_module(extension.__name__ + ".compiler") + assert extension.__name__.startswith("bl_ext.") + expected = {"nodeforge.math": ("2.2", "noise_terrain.nf"), + "nodeforge.lsystem": ("4.0", "koch_curve.nf")} + active = {item.manifest.package_id: item.manifest for item in packages.active_package_records()} + assert set(expected).issubset(active) + for package_id, (version, example_name) in expected.items(): + manifest = active[package_id] + assert manifest.version == version + assert manifest.root.is_dir() + script = (manifest.root / "examples" / example_name).read_text(encoding="utf-8") + group = compiler.create_expression_group(script, name="NFSuite_" + package_id) + try: + assert group.bl_idname == "GeometryNodeTree" + assert len(group.nodes) > 1 + finally: + bpy.data.node_groups.remove(group) + + +def test_extension_packages_do_not_require_user_consent(): + """Existing package ZIPs are active without a separate Python grant.""" + extension = _installed_extension() + packages = importlib.import_module(extension.__name__ + ".packages") + records = packages.active_package_records() + assert {r.manifest.package_id for r in records} >= {"nodeforge.math", "nodeforge.lsystem"} + assert all("allow_python" not in r.state_record for r in records) + assert all(packages.package_compatibility_warning(r.manifest) for r in records) diff --git a/tests/blender/test_library_catalogs.py b/tests/blender/test_library_catalogs.py index c1bc1f5..c4e83ca 100644 --- a/tests/blender/test_library_catalogs.py +++ b/tests/blender/test_library_catalogs.py @@ -574,7 +574,7 @@ def test_library_reload_allows_same_package_upgrade_and_rejects_package_takeover try: v1 = tmp / "v1" _make_reload_package(v1, package_id="vendor.reload", version="1.0.0", value=1.0) - packages.install_package_directory(v1, allow_python=False) + packages.install_package_directory(v1) group = compiler.create_library_catalog_group("functions", "reload_value") from NodeForge.compiler_identities import library_function_id from NodeForge.function_instances import ( @@ -594,7 +594,7 @@ def test_library_reload_allows_same_package_upgrade_and_rejects_package_takeover v2 = tmp / "v2" _make_reload_package(v2, package_id="vendor.reload", version="2.0.0", value=2.0) - packages.install_package_directory(v2, allow_python=False, replace=True) + packages.install_package_directory(v2, replace=True) compiler.update_library_catalog_group(group, "functions", "reload_value") check(group.as_pointer() == pointer, "same-package reload replaced the root datablock") check(group.get("nodeforge_package_id") == "vendor.reload", "same-package reload changed package identity") @@ -604,7 +604,7 @@ def test_library_reload_allows_same_package_upgrade_and_rejects_package_takeover packages.uninstall_package("vendor.reload") other = tmp / "other" _make_reload_package(other, package_id="vendor.other", version="1.0.0", value=3.0) - packages.install_package_directory(other, allow_python=False) + packages.install_package_directory(other) before_source = group.get("nodeforge_library_source") try: compiler.update_library_catalog_group(group, "functions", "reload_value") @@ -653,7 +653,7 @@ def test_native_only_library_entry_is_not_reloadable(): }), encoding="utf-8", ) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) group = bpy.data.node_groups.new("NFTest_native_only_reload", "GeometryNodeTree") group["nodeforge_library_namespace"] = "functions" group["nodeforge_library_name"] = "reload_native" diff --git a/tests/blender/test_library_functions.py b/tests/blender/test_library_functions.py index 698093c..8f46e3f 100644 --- a/tests/blender/test_library_functions.py +++ b/tests/blender/test_library_functions.py @@ -46,7 +46,7 @@ def test_packaged_library_functions_and_helper_scoping(): _write_package_manifest(root, 'vendor.library_probe') (functions / 'library_flat_probe.nf').write_text('value = input_float("Value", default=2.0)\noutput("Value", value)\n', encoding='utf-8') (functions / 'library_vector_probe.nf').write_text('value = input_vector("Value", default=vector(1, 2, 3))\noutput("Value", value)\n', encoding='utf-8') - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) try: check(has_package_function('library_flat_probe'), 'package flat .nf function was not discovered') names = package_function_names() @@ -65,7 +65,7 @@ def test_packaged_library_functions_and_helper_scoping(): _write_package_manifest(root, 'vendor.private_probe') (functions / '_library_private_probe.nf').write_text('value = input_float("Value", default=2.0)\noutput("Value", value)\n', encoding='utf-8') try: - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) except packages.PackageError: pass else: @@ -87,7 +87,7 @@ def test_same_named_package_functions_materialize_by_canonical_owner(): f'value = input_float("Value", default=0.0)\noutput("Value", value + {delta})\n', encoding="utf-8", ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) roots.append(package_id) try: group = compile_group( @@ -132,7 +132,7 @@ def test_package_function_can_share_core_callable_name_without_shadowing_core(): 'value = input_float("Value", default=0.0)\noutput("Value", value + 1.0)\n', encoding="utf-8", ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) try: group = compile_group( "from packages import geometry_tools\n" diff --git a/tests/blender/test_mesh_compute_foreign_event_filter.py b/tests/blender/test_mesh_compute_foreign_event_filter.py index d4f3a7a..65a833a 100644 --- a/tests/blender/test_mesh_compute_foreign_event_filter.py +++ b/tests/blender/test_mesh_compute_foreign_event_filter.py @@ -92,19 +92,19 @@ def test_scalar_and_geometry_updates_never_skip(configured_local, mesh_object, m assert _evaluated_positions(mesh_object)[0] == (9., 7., 8.) -def test_foreign_event_does_not_hide_permission_revocation(configured_local, mesh_object, monkeypatch): - """Trust revocation is outside depsgraph, so a foreign event must invalidate output.""" +def test_foreign_event_does_not_hide_source_owner_removal(configured_local, mesh_object, monkeypatch): + """Owner availability is outside depsgraph; a foreign event must invalidate output.""" binding = _init(mesh_object) foreign, data = _foreign_object() try: bpy.context.view_layer.update() decisions = _measured_gate(monkeypatch) old = binding.last_published - local_sources.set_local_python_permission(configured_local, allowed=False) + (configured_local / 'shift.py').unlink() foreign.update_tag(refresh={'OBJECT'}) bpy.context.view_layer.update() assert decisions and False in decisions - assert binding.status == 'MISSING_RUNTIME' + assert binding.status == 'ERROR' assert binding.last_published is not old assert len(binding.prepared.result_proxy.data.vertices) == 0 finally: diff --git a/tests/blender/test_mesh_compute_modifier_binding.py b/tests/blender/test_mesh_compute_modifier_binding.py index 1cb1a60..521b153 100644 --- a/tests/blender/test_mesh_compute_modifier_binding.py +++ b/tests/blender/test_mesh_compute_modifier_binding.py @@ -21,7 +21,7 @@ def configured_local(tmp_path, monkeypatch): managed.mkdir() monkeypatch.setattr(local_sources, "_default_local_catalog_dir", lambda: managed) monkeypatch.setattr(local_sources, "_local_sources_registry_path", lambda: tmp_path / "registry.json") - local_sources.set_local_python_permission(managed, allowed=True) + (managed / "shift.py").write_text( 'from NodeForge import mesh_compute\n' '@mesh_compute(writes=("height",))\n' diff --git a/tests/blender/test_mesh_compute_scheduler.py b/tests/blender/test_mesh_compute_scheduler.py index 2e351b6..38ccc10 100644 --- a/tests/blender/test_mesh_compute_scheduler.py +++ b/tests/blender/test_mesh_compute_scheduler.py @@ -115,29 +115,19 @@ def test_frame_handler_ignores_private_source_scene(configured_local, mesh_objec assert _evaluated_positions(mesh_object)[0] == (2., 3., 4.) -def test_revoked_python_grant_invalidates_cached_stage_without_reexecution(configured_local, mesh_object): - """Permission revocation fails closed even if the numerical fingerprint hits.""" - from NodeForge import local_sources - +def test_removed_python_source_invalidates_cached_stage_without_reexecution(configured_local, mesh_object): + """A removed owner invalidates runtime even when numerical inputs are unchanged.""" create_mesh_compute_modifier(SOURCE, mesh_object) binding = runtime.get_modifier_binding(mesh_object) invoked = _kernel_counter(binding) bpy.context.view_layer.update() assert len(invoked) == 1 and binding.status == 'READY' - assert binding.prepared.source_origin is not None - local_sources.set_local_python_permission(configured_local, allowed=False) + (configured_local / 'shift.py').unlink() mesh_object.update_tag(refresh={'OBJECT'}) bpy.context.view_layer.update() assert len(invoked) == 1 - assert binding.status == 'MISSING_RUNTIME' - invalid = binding.prepared.result_proxy.data - assert len(invalid.vertices) == 0 - local_sources.set_local_python_permission(configured_local, allowed=True) - mesh_object.update_tag(refresh={'OBJECT'}) - bpy.context.view_layer.update() - assert binding.status == 'MISSING_RUNTIME' - assert binding.prepared.result_proxy.data is invalid - assert len(invoked) == 1 # re-grant requires a trusted rebind, not an old callback + assert binding.status == 'ERROR' + assert len(binding.prepared.result_proxy.data.vertices) == 0 def test_uv_only_change_republishes_without_invoking_kernel(configured_local, mesh_object): diff --git a/tests/blender/test_mesh_compute_stage_primitives.py b/tests/blender/test_mesh_compute_stage_primitives.py index df947be..43186f1 100644 --- a/tests/blender/test_mesh_compute_stage_primitives.py +++ b/tests/blender/test_mesh_compute_stage_primitives.py @@ -381,7 +381,7 @@ def test_regular_group_compile_rejects_unbound_local_python_before_blender_mutat ) monkeypatch.setattr(local_sources, "_default_local_catalog_dir", lambda: root) monkeypatch.setattr(local_sources, "_local_sources_registry_path", lambda: tmp_path / "local_sources.json") - local_sources.set_local_python_permission(root, allowed=True) + source = ('from local import zlift\n' 'geo = input_geometry("Geometry")\n' 'geo = zlift(geo, amount=2.0)\n' diff --git a/tests/blender/test_mesh_compute_ui.py b/tests/blender/test_mesh_compute_ui.py index 1e690e9..7c6126b 100644 --- a/tests/blender/test_mesh_compute_ui.py +++ b/tests/blender/test_mesh_compute_ui.py @@ -49,38 +49,31 @@ def test_unified_compile_operator_evaluates_real_gn(registered_ui, configured_lo bpy.data.texts.remove(text) -def test_unified_compile_operator_denies_untrusted_source(registered_ui, configured_local, mesh_object): - """No group, scene or modifier can be published when Local Python is denied.""" - text = bpy.data.texts.new("MeshComputeUI_blocked.nf") +def test_unified_compile_rejects_removed_local_source(registered_ui, configured_local, mesh_object): + """An unavailable Local source fails before publishing a modifier or GN group.""" + text = bpy.data.texts.new('MeshComputeUI_unlinked.nf') try: text.from_string(SOURCE) bpy.context.scene.gn_script_mvp.text_block = text _select(mesh_object) - local_sources.set_local_python_permission(configured_local, allowed=False) + (configured_local / 'shift.py').unlink() before_groups = {x.as_pointer() for x in bpy.data.node_groups} - before_scenes = {x.as_pointer() for x in bpy.data.scenes} - with pytest.raises(RuntimeError, match='not allowed'): + with pytest.raises(RuntimeError): bpy.ops.gn_script_mvp.compile_expression() assert not mesh_object.modifiers assert {x.as_pointer() for x in bpy.data.node_groups} == before_groups - assert {x.as_pointer() for x in bpy.data.scenes} == before_scenes finally: bpy.data.texts.remove(text) -def test_local_permission_operator_grants_only_registered_root(registered_ui, configured_local, tmp_path): - """UI toggles the durable Local grant and rejects an unregistered directory.""" +def test_local_python_source_uses_registered_owner_not_permission(registered_ui, configured_local, tmp_path): + """The managed owner is usable and the obsolete grant operator is absent.""" source = configured_local / 'shift.py' - assert local_sources.local_python_permission(source) - assert bpy.ops.nodeforge.set_local_python_permission(path=str(configured_local), allowed=False) == {'FINISHED'} - assert not local_sources.local_python_permission(source) - assert bpy.ops.nodeforge.set_local_python_permission(path=str(configured_local), allowed=True) == {'FINISHED'} - assert local_sources.local_python_permission(source) - foreign = tmp_path / 'unregistered' - foreign.mkdir() - with pytest.raises(RuntimeError, match='registered root'): - bpy.ops.nodeforge.set_local_python_permission(path=str(foreign), allowed=True) - assert local_sources.local_python_permission(source) + assert local_sources.is_registered_local_python_source(source) + foreign = tmp_path / 'unregistered.py' + foreign.write_text(source.read_text()) + assert not local_sources.is_registered_local_python_source(foreign) + assert not hasattr(ui, 'NODEFORGE_OT_set_local_python_permission') def test_unified_compile_rejects_occupied_modifier_only_for_runtime( @@ -217,22 +210,19 @@ def test_local_mesh_compute_compiles_selected_nf_without_text_block( props.text_block = old_text -def test_local_mesh_compute_rejects_revoked_python_permission( - registered_ui, configured_local, mesh_object): - """Local browser action must preserve the existing trust gate and rollback.""" - script = configured_local / 'mesh_compute_denied.nf' - script.write_text(SOURCE, encoding='utf-8') +def test_local_mesh_compute_rejects_missing_selected_source(registered_ui, configured_local, mesh_object): + """A missing Local runtime cannot publish a modifier even with no consent gate.""" + script = configured_local / 'mesh_compute_missing.nf' + script.write_text(SOURCE) props = bpy.context.scene.gn_script_mvp _select(mesh_object) _select_local_script(props, configured_local, script) - local_sources.set_local_python_permission(configured_local, allowed=False) - before_groups = {group.as_pointer() for group in bpy.data.node_groups} - before_scenes = {scene.as_pointer() for scene in bpy.data.scenes} - with pytest.raises(RuntimeError, match='not allowed'): - bpy.ops.nodeforge.create_function_group(namespace="local") + (configured_local / 'shift.py').unlink() + before = {group.as_pointer() for group in bpy.data.node_groups} + with pytest.raises(RuntimeError): + bpy.ops.nodeforge.create_function_group(namespace='local') assert not mesh_object.modifiers - assert {group.as_pointer() for group in bpy.data.node_groups} == before_groups - assert {scene.as_pointer() for scene in bpy.data.scenes} == before_scenes + assert {group.as_pointer() for group in bpy.data.node_groups} == before def test_local_action_rejects_python_row_and_occupied_modifier( diff --git a/tests/blender/test_object_inputs.py b/tests/blender/test_object_inputs.py index 79ea812..1960b2b 100644 --- a/tests/blender/test_object_inputs.py +++ b/tests/blender/test_object_inputs.py @@ -193,7 +193,7 @@ def test_installed_library_object_input_and_output_keep_object_behavior(): 'source = input_object("Source")\noutput("Value", source)\n', encoding="utf-8", ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) try: group = compile_group( ''' diff --git a/tests/blender/test_package_resolution_regressions.py b/tests/blender/test_package_resolution_regressions.py index 654ca17..bab34f3 100644 --- a/tests/blender/test_package_resolution_regressions.py +++ b/tests/blender/test_package_resolution_regressions.py @@ -37,7 +37,7 @@ def test_python_example_import_materializes_through_registry(tmp_path): 'def review_example(value: Annotated[Float, EvaluationMode.RUNTIME_ONLY]) -> Float: ...\n' ) (owner / 'backend.py').write_text('def build(context, value):\n return value\n') - packages.install_package_directory(root, allow_python=True) + packages.install_package_directory(root) group = compile_group( 'from examples import review_example\nx = input_float("X")\noutput("Value", review_example(x))\n', 'NFTest_review_python_example', @@ -54,7 +54,7 @@ def test_functions_reload_uses_one_environment_without_live_rediscovery(tmp_path _package(root, 'vendor.review', 'functions') source = root / 'functions' / 'foo.nf' source.write_text('x = input_float("X")\noutput("Value", x + 1)\n') - installed = packages.install_package_directory(root, allow_python=False) + installed = packages.install_package_directory(root) group = compiler.create_package_function_group('vendor.review', 'foo') (installed.root / 'functions' / 'foo.nf').write_text( 'x = input_float("X")\noutput("Value", x + 2)\n' @@ -89,7 +89,7 @@ def test_native_function_materializes_source_calls(tmp_path, style): 'def native_value(value: Annotated[Float, EvaluationMode.RUNTIME_ONLY]) -> Float: ...\n' ) (owner / 'backend.py').write_text('def build(context, value):\n return value\n') - packages.install_package_directory(root, allow_python=True) + packages.install_package_directory(root) source = 'from packages import review\nx=input_float("X")\n' if style == 'local': source += 'def helper(x):\n return review.native_value(x)\noutput(helper(x))\n' @@ -107,13 +107,13 @@ def test_ui_reload_uses_one_snapshot_and_preserves_owner(tmp_path, monkeypatch, root = tmp_path / 'ui-package' _package(root, 'vendor.review', namespace) (root / namespace / 'foo.nf').write_text('x=input_float("X")\noutput("Value", x+1)\n') - installed = packages.install_package_directory(root, allow_python=False) + installed = packages.install_package_directory(root) if namespace == 'functions': group = compiler.create_package_function_group('vendor.review', 'foo') other = tmp_path / 'other-package' _package(other, 'vendor.other', namespace) (other / namespace / 'foo.nf').write_text('output("Value", 99)\n') - packages.install_package_directory(other, allow_python=False) + packages.install_package_directory(other) else: group = compiler.create_library_catalog_group(namespace, 'foo') group.name = 'NFTest_custom_name' @@ -266,7 +266,7 @@ def test_package_source_transitivity_and_native_owner_do_not_rescan_local(tmp_pa 'EXTENSION_API=2\nEXTENSIONS={"native": ".backend:build"}\n' 'def native(x: Annotated[Float, EvaluationMode.RUNTIME_ONLY]) -> Float: ...\n') (owner / 'backend.py').write_text('def build(context, x):\n return x\n') - packages.install_package_directory(root, allow_python=True) + packages.install_package_directory(root) local = tmp_path / 'Local' local.mkdir() (local / 'noise.nf').write_text('x=input_float("X")\noutput("Value", x+1.0)\n') diff --git a/tests/blender/test_qualified_package_callables.py b/tests/blender/test_qualified_package_callables.py index 359baaf..76f39f2 100644 --- a/tests/blender/test_qualified_package_callables.py +++ b/tests/blender/test_qualified_package_callables.py @@ -59,8 +59,8 @@ def test_qualified_duplicate_members_materialize_exact_package_owners(): "vendor.b", {"foo": 'value = input_float("Value")\noutput("Value", value + 2.0)\n'}, ) - packages.install_package_directory(first, allow_python=False) - packages.install_package_directory(second, allow_python=False) + packages.install_package_directory(first) + packages.install_package_directory(second) group = compile_group( "from packages import a, b\n" @@ -97,7 +97,7 @@ def test_core_points_and_owner_qualified_package_points_materialize_independentl "nodeforge.lsystem", {"points": 'value = input_float("Value")\noutput("Value", value + 1.0)\n'}, ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) group = compile_group( "from packages import lsystem\n" "count = input_int('Count', default=2)\n" @@ -133,7 +133,7 @@ def test_qualified_package_call_in_local_helper_preserves_only_real_runtime_capt "nodeforge.math", {"sin": 'value = input_float("Value")\noutput("Value", value)\n'}, ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) group = compile_group( "from packages import math as m\n" "offset = input_float('Offset')\n" @@ -179,7 +179,7 @@ def test_source_value_collision_rejects_bare_call_but_qualified_call_materialize "nodeforge.math", {"sin": 'value = input_float("Value")\noutput("Value", value)\n'}, ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) expect_compile_error( "from packages import math\n" "sin = input_float('Sin')\n" diff --git a/tests/blender/test_string_inputs.py b/tests/blender/test_string_inputs.py index 099e614..3176013 100644 --- a/tests/blender/test_string_inputs.py +++ b/tests/blender/test_string_inputs.py @@ -172,7 +172,7 @@ def test_packaged_library_function_accepts_runtime_and_constant_string_arguments ) packages.set_packages_dir_for_tests(tmp / "inventory") try: - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) group = compile_group(''' from packages import string as string_pkg source = input_string("Source", default="Weight_A") diff --git a/tests/blender/test_third_party_package_lifecycle.py b/tests/blender/test_third_party_package_lifecycle.py index ad5dbfa..1f35288 100644 --- a/tests/blender/test_third_party_package_lifecycle.py +++ b/tests/blender/test_third_party_package_lifecycle.py @@ -42,7 +42,7 @@ def test_directory_zip_replace_uninstall_and_reinstall_lifecycle(): try: source_v1 = tmp / "source_v1" _make_package(source_v1, version="1.0.0", value=1.0) - installed = packages.install_package_directory(source_v1, allow_python=False) + installed = packages.install_package_directory(source_v1) assert installed.package_id == "vendor.lifecycle" assert has_package_function("vendor_value") compile_group( @@ -57,7 +57,7 @@ def test_directory_zip_replace_uninstall_and_reinstall_lifecycle(): for path in source_v2.rglob("*"): if path.is_file(): zf.write(path, Path("vendor.lifecycle") / path.relative_to(source_v2)) - replaced = packages.install_package_zip(archive, allow_python=False, replace=True) + replaced = packages.install_package_zip(archive, replace=True) assert replaced.version == "2.0.0" assert packages.load_package_state()["packages"]["vendor.lifecycle"]["installed_version"] == "2.0.0" @@ -68,7 +68,7 @@ def test_directory_zip_replace_uninstall_and_reinstall_lifecycle(): "NFTest_vendor_lifecycle_uninstalled", ) - packages.install_package_directory(source_v1, allow_python=False) + packages.install_package_directory(source_v1) assert has_package_function("vendor_value") finally: packages.set_packages_dir_for_tests(None) @@ -83,7 +83,7 @@ def test_invalid_package_record_can_be_removed_without_deleting_other_files(): try: source = tmp / "source" _make_package(source) - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) protected = tmp / "protected" protected.mkdir() diff --git a/tests/blender/test_unique_function_groups.py b/tests/blender/test_unique_function_groups.py index e375ffd..e22af4f 100644 --- a/tests/blender/test_unique_function_groups.py +++ b/tests/blender/test_unique_function_groups.py @@ -240,7 +240,7 @@ def test_imported_unique_function_preserves_shared_and_occurrence_identity(): 'value = input_float("Value")\noutput("Value", value + 1.0)\n', encoding="utf-8", ) - packages.install_package_directory(root, allow_python=False) + packages.install_package_directory(root) try: source = """from packages import unique_identity shared = import_unique_probe(1.0) @@ -316,7 +316,7 @@ def imported_parent_right(x): v2 = tmp / "v2" _write_imported_dependency_package(v1, version="1.0.0", offset=1.0) _write_imported_dependency_package(v2, version="2.0.0", offset=2.0) - packages.install_package_directory(v1, allow_python=False) + packages.install_package_directory(v1) try: root = compile_group(source, "NFTest_imported_dependency_two_parents") left = _local_helpers("imported_parent_left")[0] @@ -330,7 +330,7 @@ def imported_parent_right(x): left["manual_probe"] = "must-reset" right["manual_probe"] = "must-reset" - packages.install_package_directory(v2, allow_python=False, replace=True) + packages.install_package_directory(v2, replace=True) compiler.update_expression_group(root, source) left_after = _local_helpers("imported_parent_left")[0] diff --git a/tests/unit/test_compiler_local_discovery.py b/tests/unit/test_compiler_local_discovery.py index 51e2489..c165d33 100644 --- a/tests/unit/test_compiler_local_discovery.py +++ b/tests/unit/test_compiler_local_discovery.py @@ -85,7 +85,7 @@ def install(tmp_path, functions, *, extension=False): 'contents': contents, 'permissions': {'python': extension}, 'nodeforge_min_version': '0.66.0', 'nodeforge_max_version': None, })) - return packages.install_package_directory(root, allow_python=extension) + return packages.install_package_directory(root) def prepare(text, view, session=None): diff --git a/tests/unit/test_examples_directory_index.py b/tests/unit/test_examples_directory_index.py index adfd06c..7b1bd5b 100644 --- a/tests/unit/test_examples_directory_index.py +++ b/tests/unit/test_examples_directory_index.py @@ -219,7 +219,7 @@ def install_examples_package(tmp_path, package_id, entries, *, python=False): 'contents': {'examples': 'examples'}, 'permissions': {'python': python}, 'nodeforge_min_version': '0.68.0', 'nodeforge_max_version': None, }), encoding='utf8') - return packages.install_package_directory(root, allow_python=python) + return packages.install_package_directory(root) def test_real_installed_package_nested_examples_imports_and_collisions(tmp_path, isolated_packages): diff --git a/tests/unit/test_examples_mesh_compute.py b/tests/unit/test_examples_mesh_compute.py index 359dafb..c9fbdec 100644 --- a/tests/unit/test_examples_mesh_compute.py +++ b/tests/unit/test_examples_mesh_compute.py @@ -43,26 +43,18 @@ def root_code(namespace='examples.terrain'): return f'from {namespace} import erosion\ngeo = input_geometry("Geometry")\noutput(erosion(geo))\n' -def test_ast_only_discovery_and_explicit_bundled_grant(example_root): - """Scan is inert; one granted runtime creates an exact v2 registry overlay.""" +def test_ast_only_discovery_executes_no_unselected_python(example_root): + """Selected Examples Python sources need no grant and are not executed during discovery.""" marker = example_root / 'executed' source = write_python(example_root, marker=marker) ordinary = example_root / 'terrain' / 'other.py' ordinary.write_text(f'open({str(marker)!r}, "w").write("BAD")\n', encoding='utf8') - (example_root / 'terrain' / '__init__.py').write_text('', encoding='utf8') env = environment_resolution._make_compilation_environment() records = env.catalog('examples').records() - assert [(r.name, r.kind) for r in records] == [('erosion', 'runtime')] - assert not marker.exists() - with pytest.raises(CompileError, match='not authorized'): - _prepare(root_code(), env) - assert not marker.exists() - local_sources.set_examples_python_permission(example_root, allowed=True) - selected = environment_resolution._make_compilation_environment() - group = _prepare(root_code(), selected) + assert [(entry.name, entry.kind) for entry in records] == [('erosion', 'runtime')] + group = _prepare(root_code(), env) target = ExtensionCallableId(('examples-runtime', 'core', catalog._path_key(source)), 'erosion') - assert selected.extension_registry.contains(target) - assert group.extension_dependencies == ((target.owner, selected.extension_registry.owner_fingerprint(target.owner)),) + assert env.extension_registry.contains(target) assert not marker.exists() @@ -79,39 +71,23 @@ def test_nested_examples_dsl_and_python_share_one_directory_snapshot(example_roo assert examples.select_import('examples.terrain', 'offset').source_path == script.resolve() _prepare('from examples import terrain\noutput(offset(1.0))\n', view) - local_sources.set_examples_python_permission(example_root, allowed=True) _prepare(root_code(), environment_resolution._make_compilation_environment()) -def test_permission_is_separate_default_denied_and_requires_exact_root(example_root, tmp_path): - """Persisted bundled opt-in does not inherit Local permissions or accept child roots.""" - root = example_root - path = write_python(root) - assert not local_sources.examples_python_permission(path) - assert not local_sources.examples_python_root_permission(root) - with pytest.raises(CompileError, match='exact root|bundled Examples root'): - local_sources.set_examples_python_permission(root / 'terrain', allowed=True) - with pytest.raises(CompileError, match='exact root|bundled Examples root'): - local_sources.examples_python_root_permission(tmp_path) - with pytest.raises(CompileError, match='boolean'): - local_sources.set_examples_python_permission(root, allowed='yes') - local_sources.set_examples_python_permission(root, allowed=True) - assert local_sources.examples_python_permission(path) - payload = json.loads((tmp_path / 'trust.json').read_text()) - assert payload['allow_examples_python'] is True - assert payload.get('managed_allow_python', False) is False - local_sources.set_examples_python_permission(root, allowed=False) - assert not local_sources.examples_python_permission(path) - payload['allow_examples_python'] = 'true' - (tmp_path / 'trust.json').write_text(json.dumps(payload)) - with pytest.raises(CompileError, match='Invalid bundled Examples'): - local_sources.examples_python_root_permission(root) +def test_examples_owner_requires_real_core_root(example_root, tmp_path): + """A runtime source outside the active Examples root is never authorized.""" + from NodeForge.local_runtime import runtime_source_active + path = write_python(example_root) + foreign = write_python(tmp_path / 'outside') + assert runtime_source_active('examples', path) + assert not runtime_source_active('examples', foreign) + assert not runtime_source_active('examples', path, 'vendor.other') def test_directory_alias_is_not_executable_and_nonroot_import_fails(example_root): """Only explicit from examples. import foo at root is executable.""" write_python(example_root) - local_sources.set_examples_python_permission(example_root, allowed=True) + with pytest.raises(CompileError, match='explicit from examples'): _prepare('from examples import terrain as t\ngeo = input_geometry("Geometry")\n' 'output(t.erosion(geo))\n', environment_resolution._make_compilation_environment()) @@ -138,10 +114,10 @@ def test_python_symlinks_and_non_declarations_do_not_enter_catalog(example_root, assert names == {'erosion'} -def test_grant_revocation_and_source_edit_use_fresh_root_snapshots(example_root): - """Permission is verified per attempt and code fingerprint changes on edit.""" +def test_source_edit_and_removal_use_fresh_root_snapshots(example_root): + """A source edit changes its fingerprint and deletion invalidates owner membership.""" + from NodeForge.local_runtime import runtime_source_active file = write_python(example_root) - local_sources.set_examples_python_permission(example_root, allowed=True) first = environment_resolution._make_compilation_environment() _prepare(root_code(), first) owner = ('examples-runtime', 'core', catalog._path_key(file)) @@ -150,9 +126,8 @@ def test_grant_revocation_and_source_edit_use_fresh_root_snapshots(example_root) second = environment_resolution._make_compilation_environment() _prepare(root_code(), second) assert second.extension_registry.owner_fingerprint(owner) != fingerprint - local_sources.set_examples_python_permission(example_root, allowed=False) - with pytest.raises(CompileError, match='not authorized'): - _prepare(root_code(), environment_resolution._make_compilation_environment()) + file.unlink() + assert not runtime_source_active('examples', file) def test_top_level_python_is_explicit_only_not_legacy_wildcard(example_root): @@ -163,24 +138,22 @@ def test_top_level_python_is_explicit_only_not_legacy_wildcard(example_root): selected = view.catalog('examples') assert selected.names() == frozenset({'noise'}) assert {record.kind for record in selected.records()} == {'runtime', 'script'} - local_sources.set_examples_python_permission(example_root, allowed=True) + _prepare(root_code('examples'), environment_resolution._make_compilation_environment()) with pytest.raises(CompileError, match='Unknown|not importable|not defined|Unsupported'): _prepare('from examples import *\ngeo = input_geometry("Geometry")\noutput(erosion(geo))\n', environment_resolution._make_compilation_environment()) -def test_permission_is_rechecked_for_compiled_examples_owner(example_root): - """The same permission entry used on capture is consulted for live stages.""" - from NodeForge.local_runtime import runtime_python_permission +def test_examples_owner_rechecked_for_live_stages(example_root): + """Source availability is checked without any stored permission flags.""" + from NodeForge.local_runtime import runtime_source_active source = write_python(example_root) - assert not runtime_python_permission('examples', source) - local_sources.set_examples_python_permission(example_root, allowed=True) - assert runtime_python_permission('examples', source) - assert not runtime_python_permission('local', source) - assert not runtime_python_permission('examples', source, 'vendor.other') - local_sources.set_examples_python_permission(example_root, allowed=False) - assert not runtime_python_permission('examples', source) + assert runtime_source_active('examples', source) + assert not runtime_source_active('local', source) + assert not runtime_source_active('examples', source, 'vendor.other') + source.unlink() + assert not runtime_source_active('examples', source) @pytest.fixture @@ -200,22 +173,17 @@ def install_example_python(tmp_path, *, allow): 'contents': {'examples': 'examples'}, 'permissions': {'python': True}, 'nodeforge_min_version': '0.68.0', 'nodeforge_max_version': None, }), encoding='utf8') - return packages.install_package_directory(root, allow_python=allow) + return packages.install_package_directory(root) -def test_package_runtime_requires_real_installed_python_grant(tmp_path, isolated_package_root, example_root): - """Package runtime does not borrow consent from separately granted bundled Examples.""" - local_sources.set_examples_python_permission(example_root, allowed=True) - with pytest.raises(CompileError, match='Python|permission|consent'): - install_example_python(tmp_path, allow=False) - installed = install_example_python(tmp_path, allow=True) +def test_package_runtime_requires_active_install_owner(tmp_path, isolated_package_root, example_root): + """Package Examples remain callable while installed, not after package removal.""" + from NodeForge.local_runtime import runtime_source_active + installed = install_example_python(tmp_path, allow=False) view = environment_resolution._make_compilation_environment() record = next(r for r in view.catalog('examples').records() if r.name == 'erosion' and r.package_id == installed.package_id) assert local_runtime_owner_key(record)[1] == 'vendor.erosion' - _prepare(root_code(), view) - state = packages.load_package_state() - state['packages']['vendor.erosion']['allow_python'] = False - packages.save_package_state(state) - with pytest.raises(CompileError, match='Unknown|not authorized'): - _prepare(root_code(), environment_resolution._make_compilation_environment()) + assert runtime_source_active('examples', record.source_path, installed.package_id) + packages.uninstall_package(installed.package_id) + assert not runtime_source_active('examples', record.source_path, installed.package_id) diff --git a/tests/unit/test_extension_bootstrap.py b/tests/unit/test_extension_bootstrap.py index 64ef0e5..dc0e83c 100644 --- a/tests/unit/test_extension_bootstrap.py +++ b/tests/unit/test_extension_bootstrap.py @@ -104,7 +104,7 @@ def test_invalid_system_suppresses_deferred_broken_library_owner(tmp_path): _write_manifest(source, "vendor.atomic") _write_system(source) _write_library(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) installed = _installed_root("vendor.atomic") (installed / "systems" / "demo" / "interface.py").write_text( @@ -128,7 +128,7 @@ def test_library_snapshot_failure_suppresses_owner_qualified_package_namespace(t _write_manifest(source, "vendor.replay") _write_system(source) _write_library(source) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) installed = _installed_root("vendor.replay") library_owner = installed / "functions" / "lib_value" diff --git a/tests/unit/test_extension_packages.py b/tests/unit/test_extension_packages.py index 82b52bf..92f18aa 100644 --- a/tests/unit/test_extension_packages.py +++ b/tests/unit/test_extension_packages.py @@ -88,7 +88,7 @@ def foo(value: Annotated[Float, EvaluationMode.RUNTIME_ONLY]) -> Float: ... ) (owner / "operations.py").write_text("def build(context, value): return value\n", encoding="utf-8") - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) try: assert getattr(builtins, marker) == 1 @@ -123,12 +123,12 @@ def test_cross_package_function_and_v2_system_member_names_are_legal(tmp_path): first = tmp_path / "first" _manifest(first, "vendor.system", systems=True) _system(first, "taken") - packages.install_package_directory(first, allow_python=True) + packages.install_package_directory(first) second = tmp_path / "second" _manifest(second, "vendor.function", functions=True) (second / "functions").mkdir(parents=True, exist_ok=True) (second / "functions" / "taken.nf").write_text("output(1.0)\n", encoding="utf-8") - packages.install_package_directory(second, allow_python=True) + packages.install_package_directory(second) assert {"vendor.system", "vendor.function"} <= set(packages.load_package_state()["packages"]) diff --git a/tests/unit/test_local_mesh_compute_discovery.py b/tests/unit/test_local_mesh_compute_discovery.py index 73a7087..bab5eeb 100644 --- a/tests/unit/test_local_mesh_compute_discovery.py +++ b/tests/unit/test_local_mesh_compute_discovery.py @@ -68,59 +68,39 @@ def test_python_symlink_escape_is_rejected(local_storage, tmp_path): catalog.discover_local_sources((managed,)) -def test_permission_is_default_deny_managed_and_external(local_storage, tmp_path): - """Grants apply to one registered canonical root, not other linked roots.""" - managed, registry = local_storage - local = _python(managed / 'erosion.py') - external = tmp_path / 'External' - linked = _python(external / 'erosion.py') - another = tmp_path / 'Another' - unlinked = _python(another / 'erosion.py') - local_sources.link_local_source_folder(str(external)) - assert not local_sources.local_python_permission(local) - assert not local_sources.local_python_permission(linked) - assert not local_sources.local_python_permission(unlinked) - local_sources.set_local_python_permission(managed, allowed=True) - assert local_sources.local_python_permission(local) - assert not local_sources.local_python_permission(linked) - local_sources.set_local_python_permission(external, allowed=True) - assert local_sources.local_python_permission(linked) - assert not local_sources.local_python_permission(unlinked) - payload = json.loads(registry.read_text()) - assert payload['managed_allow_python'] is True - assert payload['roots'][0]['allow_python'] is True - local_sources.set_local_python_permission(external, allowed=False) - assert not local_sources.local_python_permission(linked) - assert local_sources.local_python_permission(local) - - -def test_permission_survives_link_registry_mutation(local_storage, tmp_path): - """Link, relabel and unlink paths do not silently lose other execution grants.""" - managed, registry = local_storage - local_sources.set_local_python_permission(managed, allowed=True) - root_a = tmp_path / 'A' - root_b = tmp_path / 'B' - root_a.mkdir() - root_b.mkdir() - local_sources.link_local_source_folder(str(root_a)) - local_sources.set_local_python_permission(root_a, allowed=True) - local_sources.link_local_source_folder(str(root_b)) - local_sources.unlink_local_source_folder(root_b) - payload = json.loads(registry.read_text()) - assert payload['managed_allow_python'] is True - assert payload['roots'][0]['allow_python'] is True - assert len(payload['roots']) == 1 - - -def test_permission_rejects_unregistered_root_and_malformed_payload(local_storage, tmp_path): - """Unapproved roots and invalid configuration never acquire implicit trust.""" +def test_local_source_membership_follows_registered_roots(local_storage, tmp_path): + """A managed Python source is active, an external source needs a registered owner.""" + managed, _registry = local_storage + inside = _python(managed / 'erosion.py') + external = _python(tmp_path / 'outside' / 'erosion.py') + assert local_sources.is_registered_local_python_source(inside) + assert not local_sources.is_registered_local_python_source(external) + local_sources.link_local_source_folder(str(external.parent)) + assert local_sources.is_registered_local_python_source(external) + local_sources.unlink_local_source_folder(external.parent) + assert not local_sources.is_registered_local_python_source(external) + + +def test_registry_writes_drop_obsolete_permission_fields(local_storage, tmp_path): + """Legacy grants are ignored and never reintroduced on ordinary root mutations.""" + _managed, registry = local_storage + root = tmp_path / 'linked' + root.mkdir() + registry.write_text(json.dumps({'version': 1, 'roots': [], 'managed_allow_python': False, + 'allow_examples_python': True})) + local_sources.link_local_source_folder(str(root)) + data = json.loads(registry.read_text()) + assert data['roots'][0]['path'] == str(root) + assert 'managed_allow_python' not in data + assert 'allow_examples_python' not in data + + +def test_invalid_registry_rejected_without_permission_gate(local_storage, tmp_path): + """Owner paths still require valid registry shape; obsolete grants are ignored.""" managed, registry = local_storage - other = tmp_path / 'unregistered' - other.mkdir() - with pytest.raises(CompileError, match='registered'): - local_sources.set_local_python_permission(other, allowed=True) - with pytest.raises(CompileError, match='boolean'): - local_sources.set_local_python_permission(managed, allowed=1) - registry.write_text(json.dumps({'version': 1, 'roots': [], 'managed_allow_python': 'true'})) - with pytest.raises(CompileError, match='permission'): - local_sources.local_python_permission(_python(managed / 'erosion.py')) + path = _python(managed / 'erosion.py') + registry.write_text(json.dumps({'version': 1, 'roots': [], 'managed_allow_python': 'old'})) + assert local_sources.is_registered_local_python_source(path) + registry.write_text(json.dumps({'version': 1, 'roots': 'wrong'})) + with pytest.raises(CompileError, match='roots'): + local_sources.local_source_roots() diff --git a/tests/unit/test_local_mesh_compute_registry.py b/tests/unit/test_local_mesh_compute_registry.py index c0158da..fe6f4b3 100644 --- a/tests/unit/test_local_mesh_compute_registry.py +++ b/tests/unit/test_local_mesh_compute_registry.py @@ -65,7 +65,7 @@ def test_explicit_import_creates_canonical_extension_ir_without_user_execution(l """Selected source creates one immutable owner overlay before semantic lowering.""" marker = library / 'executed' source_file = _write(library, marker=marker) - local_sources.set_local_python_permission(library, allowed=True) + view = environment_resolution._make_compilation_environment() source = ('from local import erosion as erosion_alias\n' 'geo = input_geometry("Geometry")\n' @@ -85,7 +85,7 @@ def test_explicit_import_creates_canonical_extension_ir_without_user_execution(l def test_captured_backend_invokes_exact_source_only_during_trusted_phase(library): """Owner implementation is lazily loaded and forwards to the shared backend method.""" file = _write(library) - local_sources.set_local_python_permission(library, allowed=True) + view = environment_resolution._make_compilation_environment() group = _prepare('from local import erosion\nsource_geo = input_geometry("Geometry")\ngeo = erosion(source_geo)\noutput("Geometry", geo)', view) target = ExtensionCallableId(('local-runtime', catalog._path_key(file)), 'erosion') @@ -105,21 +105,21 @@ def compute(geometry, **kwargs): assert seen[0][1]['kernel'].__name__ == 'erosion' -def test_untrusted_import_fails_at_selection_without_running_source(library): - """A default-denied root never invokes user code via extension normalization.""" +def test_managed_runtime_available_without_grant_or_execution(library): + """A managed root exposes callbacks without executing their bodies at selection.""" marker = library / 'executed' _write(library, marker=marker) view = environment_resolution._make_compilation_environment() - with pytest.raises(CompileError, match='Allow executable Python'): - _prepare('from local import erosion\nsource_geo = input_geometry("Geometry")\ngeo = erosion(source_geo)\noutput("Geometry", geo)', view) + _prepare('from local import erosion\ngeo = input_geometry("Geometry")\n' + 'output(erosion(geo))', view) + assert view.extension_registry._families assert not marker.exists() - assert not view.extension_registry._families def test_star_import_excludes_python_and_inherited_explicit_import_is_rejected(library): """Legacy wildcard Local calls keep `.nf` behavior without leaking Python exports.""" _write(library) - local_sources.set_local_python_permission(library, allowed=True) + view = environment_resolution._make_compilation_environment() _prepare('from local import *\noutput(1.0)', view) assert not view.extension_registry._families @@ -134,7 +134,7 @@ def test_selected_owner_snapshots_do_not_import_other_python_sources(library): _write(library) marker = library / 'other-executed' _write(library, name='other', marker=marker) - local_sources.set_local_python_permission(library, allowed=True) + env = environment_resolution._make_compilation_environment() _prepare('from local import erosion\nsource_geo = input_geometry("Geometry")\noutput(erosion(source_geo))', env) assert len(env.extension_registry._sessions) == 1 @@ -145,7 +145,7 @@ def test_selected_owner_snapshots_do_not_import_other_python_sources(library): def test_recompile_captures_modified_source_without_reusing_implementation(library): """Separate root attempts preserve canonical owner ID but vary source fingerprint.""" file = _write(library) - local_sources.set_local_python_permission(library, allowed=True) + before = environment_resolution._make_compilation_environment() _prepare('from local import erosion\nsource_geo = input_geometry("Geometry")\noutput(erosion(source_geo))', before) owner = ('local-runtime', catalog._path_key(file)) @@ -162,7 +162,7 @@ def test_qualified_local_python_import_uses_same_canonical_identity(library): directory = library / 'terrain' directory.mkdir() file = _write(directory) - local_sources.set_local_python_permission(library, allowed=True) + view = environment_resolution._make_compilation_environment() group = _prepare('from local.terrain import erosion as terrain_erosion\n' 'geo = input_geometry("Geometry")\n' @@ -177,29 +177,22 @@ def test_member_call_through_directory_alias_rejects_runtime_without_source_lowe directory = library / 'terrain' directory.mkdir() _write(directory) - local_sources.set_local_python_permission(library, allowed=True) + with pytest.raises(CompileError, match='explicit from local'): _prepare('from local import terrain as t\ngeo = input_geometry("Geometry")\n' 'output(t.erosion(geo))', environment_resolution._make_compilation_environment()) -def test_individual_linked_python_file_requires_its_own_grant(library, tmp_path): - """Legacy individually linked files remain independent of folder permissions.""" +def test_individually_linked_file_requires_registration(library, tmp_path): + """External files are callable only while their exact registration exists.""" linked = _write(tmp_path) - # Legacy file registrations are supported as a persisted v1 registry record. local_sources._write_local_source_registry([ {'path': str(linked), 'label': 'External', 'kind': 'file'}, ]) view = environment_resolution._make_compilation_environment() - with pytest.raises(CompileError, match='Allow executable Python'): - _prepare('from local import erosion\ngeo = input_geometry("Geometry")\n' - 'output(erosion(geo))', view) - local_sources.set_local_python_permission(linked, allowed=True) - trusted = environment_resolution._make_compilation_environment() - group = _prepare('from local import erosion\ngeo = input_geometry("Geometry")\n' - 'output(erosion(geo))', trusted) - assert group.extension_dependencies[0][0] == ('local-runtime', catalog._path_key(linked)) - local_sources.set_local_python_permission(linked, allowed=False) - with pytest.raises(CompileError, match='Allow executable Python'): - _prepare('from local import erosion\ngeo = input_geometry("Geometry")\n' - 'output(erosion(geo))', environment_resolution._make_compilation_environment()) + _prepare('from local import erosion\ngeo = input_geometry("Geometry")\n' + 'output(erosion(geo))', view) + from NodeForge.local_runtime import runtime_source_active + assert runtime_source_active('local', linked) + local_sources.unlink_local_source_file(linked) + assert not runtime_source_active('local', linked) diff --git a/tests/unit/test_package_inventory.py b/tests/unit/test_package_inventory.py index 4b9f244..fa44aae 100644 --- a/tests/unit/test_package_inventory.py +++ b/tests/unit/test_package_inventory.py @@ -180,7 +180,7 @@ def test_explicitly_installed_packages_use_unified_inventory(package_inventory, (source / "functions" / "identity.nf").write_text('value = input_float("Value")\noutput("Value", value)\n', encoding="utf-8") (source / "examples" / "demo.nf").write_text('output("Value", 1)\n', encoding="utf-8") _write_manifest(source, package_id="vendor.inventory", contents={"functions": "functions", "examples": "examples"}) - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) manifests = {m.package_id: m for m in packages.active_package_manifests()} assert set(manifests) == {"vendor.inventory"} @@ -209,7 +209,7 @@ def test_manifest_derived_roots_match_live_wrappers_without_loading_state( contents={"functions": "functions", "examples": "examples", "systems": "systems"}, python=True, ) - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) manifests = tuple(packages.active_package_manifests()) expected_functions = tuple(packages.library_roots("functions")) @@ -238,7 +238,7 @@ def test_system_resolution_uses_v2_interface_without_v1_handler_dispatch(package python=True, ) _write_v2_system(system_root, "resolved_marker") - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) records = tuple(packages.system_package_records()) assert len(records) == 1 @@ -251,13 +251,13 @@ def test_uninstall_validates_state_pointer_before_deleting_files(package_invento (source_a / "functions").mkdir(parents=True) (source_a / "functions" / "a.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source_a, package_id="vendor.a") - packages.install_package_directory(source_a, allow_python=False) + packages.install_package_directory(source_a) source_b = tmp_path / "source_b" (source_b / "functions").mkdir(parents=True) (source_b / "functions" / "b.nf").write_text("output(value=2)\n", encoding="utf-8") _write_manifest(source_b, package_id="vendor.b") - packages.install_package_directory(source_b, allow_python=False) + packages.install_package_directory(source_b) state = packages.load_package_state() b_path = state["packages"]["vendor.b"]["installed_path"] @@ -278,13 +278,13 @@ def test_replace_skips_cleanup_when_old_pointer_is_invalid(package_inventory, tm (source_old / "functions").mkdir(parents=True) (source_old / "functions" / "old.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source_old, package_id="vendor.replace") - packages.install_package_directory(source_old, allow_python=False) + packages.install_package_directory(source_old) source_b = tmp_path / "source_b" (source_b / "functions").mkdir(parents=True) (source_b / "functions" / "b.nf").write_text("output(value=2)\n", encoding="utf-8") _write_manifest(source_b, package_id="vendor.b") - packages.install_package_directory(source_b, allow_python=False) + packages.install_package_directory(source_b) state = packages.load_package_state() b_path = state["packages"]["vendor.b"]["installed_path"] @@ -297,7 +297,7 @@ def test_replace_skips_cleanup_when_old_pointer_is_invalid(package_inventory, tm (replacement / "functions" / "new.nf").write_text("output(value=3)\n", encoding="utf-8") _write_manifest(replacement, package_id="vendor.replace", version="2.0.0") - packages.install_package_directory(replacement, allow_python=False, replace=True) + packages.install_package_directory(replacement, replace=True) after = packages.load_package_state() assert after["packages"]["vendor.replace"]["installed_version"] == "2.0.0" @@ -320,11 +320,11 @@ def test_install_rechecks_duplicate_package_id_at_final_commit(package_inventory real_install = packages._install_validated_directory concurrent_record = {} - def install_and_interleave(source_dir, manifest, *, allow_python, origin): - record = real_install(source_dir, manifest, allow_python=allow_python, origin=origin) + def install_and_interleave(source_dir, manifest, *, origin): + record = real_install(source_dir, manifest, origin=origin) if manifest.package_id == "vendor.race" and not concurrent_record: other_manifest = packages.validate_package_root(concurrent, origin="user") - other_record = real_install(concurrent, other_manifest, allow_python=False, origin="user") + other_record = real_install(concurrent, other_manifest, origin="user") state = packages.load_package_state() state.setdefault("packages", {})["vendor.race"] = other_record packages.save_package_state(state) @@ -334,7 +334,7 @@ def install_and_interleave(source_dir, manifest, *, allow_python, origin): monkeypatch.setattr(packages, "_install_validated_directory", install_and_interleave) with pytest.raises(packages.PackageError, match="already installed"): - packages.install_package_directory(candidate, allow_python=False) + packages.install_package_directory(candidate) state_record = packages.load_package_state()["packages"]["vendor.race"] assert state_record["installed_path"] == concurrent_record["installed_path"] @@ -364,7 +364,7 @@ def test_current_python_requirement_is_recomputed_on_load(package_inventory, tmp (source / "functions").mkdir(parents=True) (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source) - manifest = packages.install_package_directory(source, allow_python=False) + manifest = packages.install_package_directory(source) active_root = next(m.root for m in packages.active_package_manifests() if m.package_id == manifest.package_id) (active_root / "functions" / "demo" ).mkdir() (active_root / "functions" / "demo" / "function.py").write_text("X = 1\n", encoding="utf-8") @@ -381,12 +381,12 @@ def test_manifest_version_bounds_are_enforced_before_commit(package_inventory, t _write_manifest(source, package_id="vendor.future", nodeforge_min_version="999.0.0") with pytest.raises(packages.PackageError, match="requires NodeForge"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) assert "vendor.future" not in packages.load_package_state()["packages"] -def test_manifest_expired_max_version_is_rejected_for_zip_install(package_inventory, tmp_path): +def test_expired_max_version_is_advisory_for_zip_install(package_inventory, tmp_path): source = tmp_path / "expired" / "vendor.expired" (source / "functions").mkdir(parents=True) (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") @@ -397,10 +397,10 @@ def test_manifest_expired_max_version_is_rejected_for_zip_install(package_invent if path.is_file(): zf.write(path, path.relative_to(source.parent).as_posix()) - with pytest.raises(packages.PackageError, match="supports NodeForge"): - packages.install_package_zip(archive, allow_python=False) - - assert "vendor.expired" not in packages.load_package_state()["packages"] + manifest = packages.install_package_zip(archive) + assert manifest.package_id == 'vendor.expired' + assert 'Compatibility is not guaranteed' in packages.package_compatibility_warning(manifest) + assert 'vendor.expired' in packages.load_package_state()['packages'] def test_malformed_manifest_versions_are_rejected(package_inventory, tmp_path): @@ -410,7 +410,7 @@ def test_malformed_manifest_versions_are_rejected(package_inventory, tmp_path): _write_manifest(source, package_id="vendor.badversion", version="1.beta.0") with pytest.raises(packages.PackageError, match="version"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) def test_nonmath_package_materialization_does_not_reuse_uninstalled_group(package_inventory, tmp_path, monkeypatch): @@ -447,7 +447,7 @@ def make_source(root: Path, package_id: str): make_source(source_a, "vendor.a") make_source(source_b, "vendor.b") - packages.install_package_directory(source_a, allow_python=False) + packages.install_package_directory(source_a) compiled = [] @@ -482,7 +482,7 @@ def compile_group(source, group_name, existing_group=None, backend_builtins=None assert compiled[-1][1] is group_a packages.uninstall_package("vendor.a") - packages.install_package_directory(source_b, allow_python=False) + packages.install_package_directory(source_b) group_b = library_groups.materialize_library_entry_group("examples", "demo", _FakeGroupBackend(compile_group)) @@ -522,7 +522,7 @@ def get(self, name, default=None): (source / "functions").mkdir(parents=True) (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id="vendor.contract", contents={"functions": "functions"}) - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) calls = [] @@ -627,7 +627,7 @@ def test_zip_rejects_multiple_manifest_candidates(package_inventory, tmp_path): zf.writestr("vendor.other/functions/other.nf", "output(value=2)\n") with pytest.raises(packages.PackageError, match="exactly one"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) assert "vendor.root" not in packages.load_package_state()["packages"] @@ -639,7 +639,7 @@ def test_zip_rejects_deep_manifest_candidate(package_inventory, tmp_path): zf.writestr("a/b/functions/demo.nf", "output(value=1)\n") with pytest.raises(packages.PackageError, match="root or one top-level directory"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) def test_package_id_regex_matches_manifest_contract(package_inventory, tmp_path): @@ -647,7 +647,7 @@ def test_package_id_regex_matches_manifest_contract(package_inventory, tmp_path) (valid / "functions").mkdir(parents=True) (valid / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(valid, package_id="vendor.3d", import_name="pkg3d") - manifest = packages.install_package_directory(valid, allow_python=False) + manifest = packages.install_package_directory(valid) assert manifest.package_id == "vendor.3d" invalid = tmp_path / "invalid_underscore" @@ -655,7 +655,7 @@ def test_package_id_regex_matches_manifest_contract(package_inventory, tmp_path) (invalid / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(invalid, package_id="ven_dor.foo") with pytest.raises(packages.PackageError, match="Package id"): - packages.install_package_directory(invalid, allow_python=False) + packages.install_package_directory(invalid) def test_package_id_allows_underscore_after_dot(package_inventory, tmp_path): @@ -664,7 +664,7 @@ def test_package_id_allows_underscore_after_dot(package_inventory, tmp_path): (source / "functions").mkdir(parents=True) (source / "functions" / f"demo_{index}.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id=package_id, import_name=f"tools_{index}") - manifest = packages.install_package_directory(source, allow_python=False) + manifest = packages.install_package_directory(source) assert manifest.package_id == package_id @@ -674,13 +674,13 @@ def test_package_import_name_explicit_and_default_are_manifest_owned(package_inv (explicit / "functions").mkdir(parents=True) (explicit / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(explicit, package_id="vendor.explicit", import_name="tools") - assert packages.install_package_directory(explicit, allow_python=False).import_name == "tools" + assert packages.install_package_directory(explicit).import_name == "tools" defaulted = tmp_path / "default_import" (defaulted / "functions").mkdir(parents=True) (defaulted / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(defaulted, package_id="vendor.defaulted") - assert packages.install_package_directory(defaulted, allow_python=False).import_name == "defaulted" + assert packages.install_package_directory(defaulted).import_name == "defaulted" @pytest.mark.parametrize("import_name", ["class", "_private", "not-valid"]) @@ -691,7 +691,7 @@ def test_package_import_name_rejects_non_public_source_identifiers(package_inven (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id=f"vendor.bad{len(import_name)}", import_name=import_name) with pytest.raises(packages.PackageError, match="import_name"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) def test_package_import_names_are_unique_across_active_owners(package_inventory, tmp_path): @@ -702,10 +702,10 @@ def test_package_import_names_are_unique_across_active_owners(package_inventory, (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id=package_id, import_name="shared") if package_id.endswith("first"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) else: with pytest.raises(packages.PackageError, match=r"Package import name 'shared'.*vendor\.first"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) def test_package_replace_rechecks_import_name_collision_before_commit(package_inventory, tmp_path): @@ -714,20 +714,20 @@ def test_package_replace_rechecks_import_name_collision_before_commit(package_in (first / "functions").mkdir(parents=True) (first / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(first, package_id="vendor.first", import_name="shared") - packages.install_package_directory(first, allow_python=False) + packages.install_package_directory(first) second = tmp_path / "second" (second / "functions").mkdir(parents=True) (second / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(second, package_id="vendor.second", import_name="second", version="1.0.0") - packages.install_package_directory(second, allow_python=False) + packages.install_package_directory(second) replacement = tmp_path / "second_v2" (replacement / "functions").mkdir(parents=True) (replacement / "functions" / "demo.nf").write_text("output(value=2)\n", encoding="utf-8") _write_manifest(replacement, package_id="vendor.second", import_name="shared", version="2.0.0") with pytest.raises(packages.PackageError, match=r"Package import name 'shared'.*vendor\.first"): - packages.install_package_directory(replacement, allow_python=False, replace=True) + packages.install_package_directory(replacement, replace=True) assert packages.load_package_state()["packages"]["vendor.second"]["installed_version"] == "1.0.0" def test_zip_rejects_duplicate_casefold_destinations(package_inventory, tmp_path): @@ -738,7 +738,7 @@ def test_zip_rejects_duplicate_casefold_destinations(package_inventory, tmp_path zf.writestr("vendor.demo/functions/foo.nf", "output(value=1)\n") with pytest.raises(packages.PackageError, match="Duplicate zip destination"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) @@ -756,7 +756,7 @@ def test_zip_rejects_unsafe_directory_entries(package_inventory, tmp_path): zf.writestr("vendor.safe/functions/demo.nf", "output(value=1)\n") with pytest.raises(packages.PackageError, match="Unsafe zip member path"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) def test_zip_rejects_symlink_and_special_directory_entries(package_inventory, tmp_path): @@ -770,7 +770,7 @@ def test_zip_rejects_symlink_and_special_directory_entries(package_inventory, tm zf.writestr("vendor.safe/functions/demo.nf", "output(value=1)\n") with pytest.raises(packages.PackageError, match="Unsupported special file"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) @@ -802,7 +802,7 @@ def test_zip_rejects_dot_and_empty_segments_in_member_paths(package_inventory, t zf.writestr(function_name, "output(value=1)\n") with pytest.raises(packages.PackageError, match="Unsafe zip member path"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) assert package_id not in packages.load_package_state()["packages"] @@ -822,7 +822,7 @@ def test_manifest_rejects_dot_and_empty_segments_in_content_roots(package_invent _write_manifest(source, package_id=f"vendor.badpath{index}", contents=contents, python="systems" in contents) with pytest.raises(packages.PackageError, match=expected_field): - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert f"vendor.badpath{index}" not in packages.load_package_state()["packages"] @@ -835,7 +835,7 @@ def test_directory_install_rejects_internal_duplicate_function_public_names(pack _write_manifest(source, package_id="vendor.dupefunc") with pytest.raises(packages.PackageError, match="Duplicate functions library entry"): - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) assert "vendor.dupefunc" not in packages.load_package_state()["packages"] @@ -853,7 +853,7 @@ def test_zip_install_rejects_internal_duplicate_example_public_names(package_inv zf.write(path, path.relative_to(source.parent).as_posix()) with pytest.raises(packages.PackageError, match="Duplicate examples library entry"): - packages.install_package_zip(archive, allow_python=False) + packages.install_package_zip(archive) assert "vendor.dupeexamples" not in packages.load_package_state()["packages"] @@ -871,7 +871,7 @@ def test_install_rejects_v1_system_owner_without_executing_module(package_invent ) with pytest.raises(packages.PackageError, match="unsupported Extension API v1 system.py"): - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert not sentinel.exists() @@ -885,7 +885,7 @@ def test_install_rejects_v1_system_before_state_commit(package_inventory, tmp_pa ) with pytest.raises(packages.PackageError, match="unsupported Extension API v1 system.py"): - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert "vendor.bad" not in packages.load_package_state()["packages"] @@ -897,7 +897,7 @@ def test_install_allows_package_export_colliding_with_core_builtin(package_inven _write_manifest(source, package_id="vendor.corecollision", contents={"systems": "systems"}, python=True) _write_v2_system(source / "systems" / "test", "cube") - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) manifest = next(item for item in packages.active_package_manifests() if item.package_id == "vendor.corecollision") inventory = packages._normalize_package_callable_inventory(manifest, normalize_native_libraries=True) @@ -910,13 +910,13 @@ def test_replace_allows_new_export_colliding_with_core_builtin(package_inventory (original / "systems" / "test").mkdir(parents=True) _write_manifest(original, package_id="vendor.replace", contents={"systems": "systems"}, python=True) _write_v2_system(original / "systems" / "test", "replace_marker") - packages.install_package_directory(original, allow_python=True) + packages.install_package_directory(original) replacement = tmp_path / "replacement" (replacement / "systems" / "test").mkdir(parents=True) _write_manifest(replacement, package_id="vendor.replace", contents={"systems": "systems"}, python=True, version="2.0.0") _write_v2_system(replacement / "systems" / "test", "cube") - packages.install_package_directory(replacement, allow_python=True, replace=True) + packages.install_package_directory(replacement, replace=True) state_record = packages.load_package_state()["packages"]["vendor.replace"] assert state_record["installed_version"] == "2.0.0" @@ -931,13 +931,13 @@ def test_cross_package_function_and_extension_names_may_overlap(package_inventor (owner / "systems" / "test").mkdir(parents=True) _write_manifest(owner, package_id="vendor.constructorowner", contents={"systems": "systems"}, python=True) _write_v2_system(owner / "systems" / "test", "active_marker") - packages.install_package_directory(owner, allow_python=True) + packages.install_package_directory(owner) source = tmp_path / "function_constructor_collision" (source / "functions").mkdir(parents=True) (source / "functions" / "active_marker.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id="vendor.funcconstructor", contents={"functions": "functions"}) - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) assert {"vendor.constructorowner", "vendor.funcconstructor"} <= set(packages.load_package_state()["packages"]) @@ -948,13 +948,13 @@ def test_cross_package_extension_and_function_names_may_overlap_reverse_order(pa (owner / "functions").mkdir(parents=True) (owner / "functions" / "active_function.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(owner, package_id="vendor.functionowner", contents={"functions": "functions"}) - packages.install_package_directory(owner, allow_python=False) + packages.install_package_directory(owner) source = tmp_path / "constructor_function_collision" (source / "systems" / "test").mkdir(parents=True) _write_manifest(source, package_id="vendor.constructorfunc", contents={"systems": "systems"}, python=True) _write_v2_system(source / "systems" / "test", "active_function") - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert {"vendor.functionowner", "vendor.constructorfunc"} <= set(packages.load_package_state()["packages"]) @@ -973,7 +973,7 @@ def test_install_rejects_same_package_function_and_constructor_name(package_inve _write_v2_system(source / "systems" / "test", "marker") with pytest.raises(packages.PackageError, match="function and constructor"): - packages.install_package_directory(source, allow_python=True) + packages.install_package_directory(source) assert "vendor.samecollision" not in packages.load_package_state()["packages"] @@ -984,19 +984,19 @@ def test_replace_allows_cross_owner_member_overlap(package_inventory, tmp_path): (original / "functions").mkdir(parents=True) (original / "functions" / "original.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(original, package_id="vendor.crossreplace", contents={"functions": "functions"}) - packages.install_package_directory(original, allow_python=False) + packages.install_package_directory(original) owner = tmp_path / "active_cross_constructor" (owner / "systems" / "test").mkdir(parents=True) _write_manifest(owner, package_id="vendor.crossowner", contents={"systems": "systems"}, python=True) _write_v2_system(owner / "systems" / "test", "cross_marker") - packages.install_package_directory(owner, allow_python=True) + packages.install_package_directory(owner) replacement = tmp_path / "replacement_cross_kind" (replacement / "functions").mkdir(parents=True) (replacement / "functions" / "cross_marker.nf").write_text("output(value=2)\n", encoding="utf-8") _write_manifest(replacement, package_id="vendor.crossreplace", contents={"functions": "functions"}, version="2.0.0") - packages.install_package_directory(replacement, allow_python=False, replace=True) + packages.install_package_directory(replacement, replace=True) state_record = packages.load_package_state()["packages"]["vendor.crossreplace"] assert state_record["installed_version"] == "2.0.0" @@ -1039,37 +1039,26 @@ def test_state_uses_origin_not_provenance(package_inventory, tmp_path): (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id="vendor.origin") - packages.install_package_directory(source, allow_python=False, origin="user") + packages.install_package_directory(source, origin="user") record = packages.load_package_state()["packages"]["vendor.origin"] assert record["origin"] == "user" assert "provenance" not in record -def test_invalid_package_diagnostic_exposes_python_consent_fields(package_inventory, tmp_path): +def test_python_package_remains_active_with_legacy_false_grant(package_inventory, tmp_path): + """Ignored permission metadata cannot turn an installed package into a diagnostic.""" source = tmp_path / "source" (source / "functions" / "demo").mkdir(parents=True) - (source / "functions" / "demo" / "source.nf").write_text("output(value=1)\n", encoding="utf-8") - (source / "functions" / "demo" / "function.py").write_text("X = 1\n", encoding="utf-8") + (source / "functions" / "demo" / "source.nf").write_text("output(value=1)\n") + (source / "functions" / "demo" / "function.py").write_text("X = 1\n") _write_manifest(source, package_id="vendor.pyblocked", contents={"functions": "functions"}, python=True) - manifest = packages.install_package_directory(source, allow_python=True) + manifest = packages.install_package_directory(source) state = packages.load_package_state() state["packages"][manifest.package_id]["allow_python"] = False - packages.save_package_state(state) - - active_ids = {m.package_id for m in packages.active_package_manifests()} - diagnostics = packages.active_package_records(include_invalid=True) - diag = next(item for item in diagnostics if isinstance(item, packages.PackageDiagnostic) and item.package_id == "vendor.pyblocked") - - assert "vendor.pyblocked" not in active_ids - assert diag.python_required is True - assert diag.python_allowed is False - assert "Python" in diag.message - assert diag.name == "vendor.pyblocked" - assert diag.version == "1.0.0" - - - + packages.state_path().write_text(json.dumps(state)) + assert "vendor.pyblocked" in {m.package_id for m in packages.active_package_manifests()} + assert all(not isinstance(item, packages.PackageDiagnostic) for item in packages.active_package_records(include_invalid=True)) def test_inventory_does_not_auto_install_packages(tmp_path): @@ -1084,7 +1073,7 @@ def test_uninstall_removes_invalid_record_without_deleting_untrusted_target(pack (source / "functions").mkdir(parents=True) (source / "functions" / "demo.nf").write_text("output(value=1)\n", encoding="utf-8") _write_manifest(source, package_id="vendor.invalid") - packages.install_package_directory(source, allow_python=False) + packages.install_package_directory(source) other = tmp_path / "must_survive" other.mkdir() diff --git a/tests/unit/test_package_release_bootstrap.py b/tests/unit/test_package_release_bootstrap.py new file mode 100644 index 0000000..469397e --- /dev/null +++ b/tests/unit/test_package_release_bootstrap.py @@ -0,0 +1,179 @@ +"""Regression coverage for offline release Packages in the existing inventory.""" + +from __future__ import annotations + +import json +import shutil +import zipfile +from pathlib import Path + +import pytest + +from NodeForge import packages + + +@pytest.fixture(autouse=True) +def inventory(tmp_path): + """Isolate user state from every test.""" + packages.set_packages_dir_for_tests(tmp_path / "data") + yield + packages.set_packages_dir_for_tests(None) + + +def archive(directory: Path, package_id: str, version: str, *, maximum="0.70.0", minimum="0.50.0") -> Path: + """Create a valid minimal ZIP with a distinct source identifier.""" + directory.mkdir(parents=True, exist_ok=True) + output = directory / f"{package_id}-{version}.zip" + manifest = { + "schema_version": 1, "id": package_id, "name": package_id, + "version": version, "contents": {"functions": "functions"}, + "nodeforge_min_version": minimum, "nodeforge_max_version": maximum, + "permissions": {"python": False}, # Obsolete metadata must be ignored. + } + with zipfile.ZipFile(output, "w") as z: + z.writestr(f"{package_id}/nodeforge_package.json", json.dumps(manifest)) + z.writestr(f"{package_id}/functions/entry.nf", "output(1.0)\n") + return output + + +def release(tmp_path, *archives): + """Prepare an extension directory with ordinary package installer ZIPs.""" + directory = tmp_path / "extension" / "package_install" + directory.mkdir(parents=True, exist_ok=True) + for path in archives: + shutil.copy2(path, directory / path.name) + return directory.parent + + +def test_fresh_install_is_idempotent_without_python_consent(tmp_path): + """Startup and re-enable use the same package inventory, no second model.""" + a = archive(tmp_path / "a", "vendor.sample", "1.0.0") + root = release(tmp_path, a) + assert packages.install_included_packages(root) == () + first = packages.load_package_state() + current = packages.active_package_records()[0] + assert current.manifest.package_id == "vendor.sample" + assert current.manifest.version == "1.0.0" + assert "allow_python" not in current.state_record + assert packages.package_compatibility_warning(current.manifest) + assert packages.install_included_packages(root) == () + assert packages.load_package_state() == first + + +def test_version_upgrade_and_no_downgrade(tmp_path): + """Embedded packages cannot replace an equal or newer independent install.""" + one = archive(tmp_path / "a", "vendor.sample", "1.0.0") + two = archive(tmp_path / "b", "vendor.sample", "2.0.0") + current_root = release(tmp_path, one) + assert not packages.install_included_packages(current_root) + packages.install_package_zip(two, replace=True) + old = packages.load_package_state() + assert packages.install_included_packages(current_root) == () + assert packages.load_package_state() == old + # Upgrading from a lower version through the embedded archive works. + packages.uninstall_package("vendor.sample") + packages.install_package_zip(one) + updated = release(tmp_path, two) + # Both ZIPs coexist in the staging test fixture: make the release unambiguous. + for f in (updated / "package_install").glob('*.zip'): + if f.name != two.name: + f.unlink() + assert packages.install_included_packages(updated) == () + assert packages.active_package_records()[0].manifest.version == '2.0.0' + + +def test_malformed_inventory_never_overwritten(tmp_path): + """Bootstrap and ordinary installs preserve the bytes of corrupt top-level state.""" + candidate = archive(tmp_path / "a", "vendor.sample", "1.0.0") + root = release(tmp_path, candidate) + state = packages.state_path() + for payload in ({"schema_version": 1}, {"schema_version": 1, "packages": []}, + {"schema_version": 1, "packages": None}): + state.write_text(json.dumps(payload), encoding="utf-8") + before = state.read_bytes() + with pytest.raises(packages.PackageError, match="packages must be an object"): + packages.install_included_packages(root) + with pytest.raises(packages.PackageError, match="packages must be an object"): + packages.install_package_zip(candidate) + assert state.read_bytes() == before + assert not list(packages.installed_dir().glob('**/install_*')) + + +def test_individual_corrupt_package_does_not_get_replaced(tmp_path): + """A malformed installed record is not interpreted as an absent package.""" + candidate = archive(tmp_path / 'a', 'vendor.sample', '2.0.0') + root = release(tmp_path, candidate) + packages.state_path().write_text(json.dumps({"schema_version": 1, "packages": { + "vendor.sample": {"installed_path": "../../escape", "installed_version": "1.0.0"} + }}), encoding='utf8') + errors = packages.install_included_packages(root) + assert errors and 'invalid' in errors[0] + assert packages.load_package_state()['packages']['vendor.sample']['installed_version'] == '1.0.0' + + +def test_duplicate_ids_do_not_change_state(tmp_path): + """Conflicting release inputs are rejected before any installed record changes.""" + a = archive(tmp_path / 'a', 'vendor.sample', '1.0.0') + b = archive(tmp_path / 'b', 'vendor.sample', '2.0.0') + root = release(tmp_path, a, b) + assert 'Duplicate included package ID' in packages.install_included_packages(root)[0] + assert packages.load_package_state()['packages'] == {} + + +def test_invalid_max_is_advisory_but_min_is_strict(tmp_path): + """An exceeded maximum warns, while an unmet minimum rejects installation.""" + older = archive(tmp_path / 'a', 'vendor.sample', '1.0.0', maximum='0.50.0') + assert packages.install_included_packages(release(tmp_path, older)) == () + assert 'Compatibility is not guaranteed' in packages.package_compatibility_warning( + packages.active_package_records()[0].manifest) + newer = archive(tmp_path / 'b', 'vendor.newer', '1.0.0', minimum='99.0.0') + errors = packages.install_included_packages(release(tmp_path, newer)) + assert errors and 'requires NodeForge' in errors[0] + assert all(r.manifest.package_id != 'vendor.newer' for r in packages.active_package_records()) + + +def test_post_publish_fsync_error_retains_installed_directory(tmp_path, monkeypatch): + """After the state publication point, a sync failure cannot roll back the payload.""" + candidate = archive(tmp_path / 'a', 'vendor.sample', '1.0.0') + def fail_sync(_path): + """Simulate a failed directory durability operation after os.replace.""" + raise OSError('injected fsync failure') + monkeypatch.setattr(packages, '_fsync_directory', fail_sync) + packages.install_package_zip(candidate) + assert packages.active_package_records()[0].manifest.version == '1.0.0' + record = packages.load_package_state()['packages']['vendor.sample'] + assert (packages.packages_dir() / record['installed_path']).is_dir() + + +def test_copy_failure_removes_partial_directory(tmp_path, monkeypatch): + """A failed directory copy leaves no orphan, and the old state is unchanged.""" + candidate = archive(tmp_path / 'a', 'vendor.sample', '1.0.0') + with zipfile.ZipFile(candidate) as z: + z.extractall(tmp_path / 'extracted') + root = tmp_path / 'extracted' / 'vendor.sample' + native_copy = packages.shutil.copytree + def fail_copy(src, dst, **kwargs): + """Leave partial output and raise at the copy boundary.""" + Path(dst).mkdir(parents=True, exist_ok=True) + (Path(dst) / 'partial.py').write_text('broken') + raise OSError('injected copy failure') + monkeypatch.setattr(packages.shutil, 'copytree', fail_copy) + with pytest.raises(OSError, match='copy failure'): + packages.install_package_directory(root) + assert packages.load_package_state()['packages'] == {} + assert not list(packages.installed_dir().glob('**/install_*')) + monkeypatch.setattr(packages.shutil, 'copytree', native_copy) + + +def test_legacy_fields_ignored_and_dropped_on_write(tmp_path): + """Old package grants never change package activity or survive normal writes.""" + a = archive(tmp_path / 'a', 'vendor.sample', '1.0.0') + packages.install_package_zip(a) + state = packages.load_package_state() + state['packages']['vendor.sample']['allow_python'] = False + state['seed_sources'] = ['old'] + packages.state_path().write_text(json.dumps(state)) + assert packages.active_package_records()[0].manifest.package_id == 'vendor.sample' + packages.save_package_state(packages.load_package_state()) + assert 'allow_python' not in packages.state_path().read_text() + assert 'seed_sources' not in packages.state_path().read_text() diff --git a/tests/unit/test_package_resolution_regressions.py b/tests/unit/test_package_resolution_regressions.py index 34ba014..4cd8d96 100644 --- a/tests/unit/test_package_resolution_regressions.py +++ b/tests/unit/test_package_resolution_regressions.py @@ -67,7 +67,7 @@ def test_broken_callable_inventory_keeps_manifest_import_reservation(tmp_path, r 'def system_value(value: Annotated[Float, EvaluationMode.RUNTIME_ONLY]) -> Float: ...\n' ) (owner / 'backend.py').write_text('def build(context, value):\n return value\n') - installed = packages.install_package_directory(root, allow_python=True) + installed = packages.install_package_directory(root) # Invalid declarations leave the manifest structurally active. (installed.root / 'systems' / 'demo' / 'interface.py').write_text( 'EXTENSION_API = 2\nEXTENSIONS = {}\n' @@ -77,12 +77,12 @@ def test_broken_callable_inventory_keeps_manifest_import_reservation(tmp_path, r assert environment.package_by_id('vendor.old') is None candidate = write_package(tmp_path / 'new', 'vendor.old' if replacement else 'vendor.new') if replacement: - packages.install_package_directory(candidate, allow_python=False, replace=True) + packages.install_package_directory(candidate, replace=True) assert resolve_environment().package_by_import_name('shared').package_id == 'vendor.old' else: before = packages.load_package_state() with pytest.raises(packages.PackageError, match='already owned by vendor.old'): - packages.install_package_directory(candidate, allow_python=False) + packages.install_package_directory(candidate) assert packages.load_package_state() == before assert resolve_environment().package_by_id('vendor.new') is None @@ -112,7 +112,7 @@ def compile_example(environment): def test_python_example_import_uses_captured_registry_and_is_not_package_export(tmp_path): root = write_package(tmp_path / 'source', 'vendor.examples', examples=True) write_example(root) - installed = packages.install_package_directory(root, allow_python=True) + installed = packages.install_package_directory(root) environment = resolve_environment() record = environment.catalog('examples').find('review_example') callable_id = ExtensionCallableId(library_owner_key(record), record.name) @@ -129,7 +129,7 @@ def test_python_example_import_uses_captured_registry_and_is_not_package_export( def test_invalid_python_example_is_deferred_catalog_failure(tmp_path, corruption): root = write_package(tmp_path / 'source', 'vendor.examples', examples=True) write_example(root) - installed = packages.install_package_directory(root, allow_python=True) + installed = packages.install_package_directory(root) owner = installed.root / 'examples' / 'review_example' if corruption == 'syntax': (owner / 'interface.py').write_text('not valid python!\n') @@ -221,7 +221,7 @@ def test_example_catalog_failure_does_not_publish_partial_sessions(tmp_path): root = write_package(tmp_path / 'source', 'vendor.examples', examples=True) write_example(root, 'a_good') write_example(root, 'z_bad') - installed = packages.install_package_directory(root, allow_python=True) + installed = packages.install_package_directory(root) path = installed.root / 'examples' / 'z_bad' / 'interface.py' path.write_text(path.read_text().replace('z_bad', 'wrong_name')) environment = resolve_environment() @@ -236,7 +236,7 @@ def test_example_catalog_failure_does_not_publish_partial_sessions(tmp_path): def test_examples_are_not_exposed_as_package_members(tmp_path): root = write_package(tmp_path / 'source', 'vendor.examples', examples=True) write_example(root) - packages.install_package_directory(root, allow_python=True) + packages.install_package_directory(root) environment = resolve_environment() with pytest.raises(CompileError, match="has no callable member 'review_example'"): analyze_group_source( @@ -294,7 +294,7 @@ def test_native_function_publishes_exact_callable_id(tmp_path): manifest = json.loads(path.read_text()) manifest['contents'] = {'functions': 'functions'} path.write_text(json.dumps(manifest)) - packages.install_package_directory(root, allow_python=True) + packages.install_package_directory(root) environment = resolve_environment() export = environment.package_by_id('vendor.native').find('native_value') callable_id = ExtensionCallableId(library_owner_key(export.record), 'native_value') diff --git a/tests/unit/test_resolved_environment.py b/tests/unit/test_resolved_environment.py index 6777d44..05d2ea3 100644 --- a/tests/unit/test_resolved_environment.py +++ b/tests/unit/test_resolved_environment.py @@ -176,7 +176,6 @@ def test_resolve_environment_reads_manifest_snapshot_once_and_builds_owner_names description="", root=Path("/package"), contents={}, - permissions={}, ) calls = {"snapshot": 0} @@ -217,8 +216,7 @@ def snapshot(): description="", root=Path("/package"), contents={}, - permissions={}, - ),) + ),) from NodeForge import catalog, local_sources diff --git a/tests/unit/test_ui_package_contract.py b/tests/unit/test_ui_package_contract.py index fb432cd..a4fca66 100644 --- a/tests/unit/test_ui_package_contract.py +++ b/tests/unit/test_ui_package_contract.py @@ -20,20 +20,18 @@ def test_package_item_exposes_short_name_and_diagnostic_fields(): "origin: StringProperty", "status: StringProperty", "path: StringProperty", - "python_required: BoolProperty", - "python_allowed: BoolProperty", + "compatibility_warning: StringProperty", "invalid_reason: StringProperty", ): assert field in source -def test_package_refresh_populates_invalid_reason_and_python_flags(): +def test_package_refresh_populates_invalid_reason_and_advisory_warning(): source = _ui_source() - - assert "item.python_required = record.python_required" in source - assert "item.python_allowed = record.python_allowed" in source - assert "item.invalid_reason = record.message" in source - assert "item.status = \"python blocked\"" in source + assert 'item.invalid_reason = record.message' in source + assert 'item.compatibility_warning = packages.package_compatibility_warning(manifest)' in source + assert 'item.status = "installed"' in source + assert 'Allow executable Python' not in source diff --git a/ui.py b/ui.py index bbe6aad..8c88b99 100644 --- a/ui.py +++ b/ui.py @@ -23,10 +23,6 @@ ) from .local_sources import ( local_browser_records, - local_python_root_permission, - set_local_python_permission, - examples_python_root_permission, - set_examples_python_permission, local_source_roots, link_local_source_folder, unlink_local_source_folder, @@ -94,7 +90,7 @@ def execute(self, context): class NODEFORGE_AddonPreferences(AddonPreferences): """Preferences shown under Edit > Preferences > Add-ons > NodeForge.""" - bl_idname = __package__.split(".")[0] if __package__ else "NodeForge" + bl_idname = __package__ or "NodeForge" def draw(self, context): layout = self.layout @@ -127,9 +123,8 @@ class NODEFORGE_PackageItem(PropertyGroup): origin: StringProperty(name="Origin", default="") status: StringProperty(name="Status", default="active") path: StringProperty(name="Path", default="") - python_required: BoolProperty(name="Python Required", default=False) - python_allowed: BoolProperty(name="Python Allowed", default=False) invalid_reason: StringProperty(name="Invalid Reason", default="") + compatibility_warning: StringProperty(name="Compatibility Warning", default="") class NODEFORGE_UL_function_library(UIList): @@ -174,7 +169,7 @@ def draw_item(self, context, layout, data, item, icon, active_data, active_propn label = item.id or item.package_id or item.name if item.version: label = f"{label} {item.version}" - icon_name = 'ERROR' if item.invalid_reason else 'PACKAGE' + icon_name = 'ERROR' if item.invalid_reason else 'INFO' if item.compatibility_warning else 'PACKAGE' row.label(text=label, icon=icon_name) if item.origin: row.label(text=item.origin) @@ -197,10 +192,9 @@ def _refresh_package_items(props): item.name = record.name or record.package_id item.version = record.version item.origin = record.origin - item.status = "python blocked" if record.python_required and not record.python_allowed else "invalid" + item.status = "invalid" item.path = record.path - item.python_required = record.python_required - item.python_allowed = record.python_allowed + item.compatibility_warning = "" item.invalid_reason = record.message continue manifest = record.manifest @@ -211,9 +205,8 @@ def _refresh_package_items(props): item.origin = str(record.state_record.get("origin") or manifest.origin) item.status = "installed" item.path = str(manifest.root) - item.python_required = record.python_required - item.python_allowed = bool(record.state_record.get("allow_python", False)) item.invalid_reason = "" + item.compatibility_warning = packages.package_compatibility_warning(manifest) props.package_index = 0 if old_id: for index, item in enumerate(props.package_items): @@ -335,7 +328,6 @@ class GNSCRIPT_MVP_Properties(PropertyGroup): package_items: CollectionProperty(type=NODEFORGE_PackageItem) package_index: IntProperty(name="Package", default=0) package_archive_path: StringProperty(name="Package Archive", default="", subtype="FILE_PATH") - package_allow_python: BoolProperty(name="Allow executable Python", default=False) local_browser_path: StringProperty(name="Local Browser Path", default="", options={'HIDDEN'}) local_script_name: StringProperty(name="Script Name", default="") local_overwrite: BoolProperty(name="Overwrite", default=False) @@ -436,51 +428,6 @@ def execute(self, context): return {'FINISHED'} -class NODEFORGE_OT_set_local_python_permission(Operator): - """Persist a grant for one registered Local root or individually linked file.""" - - bl_idname = "nodeforge.set_local_python_permission" - bl_label = "Local Executable Python Permission" - bl_description = "Allow or deny executable Python within the selected Local source root" - bl_options = {'REGISTER'} - - path: StringProperty(name="Registered Local root", default="", options={'HIDDEN'}) - allowed: BoolProperty(name="Allow executable Python", default=False, options={'HIDDEN'}) - - def execute(self, context): - """Delegate ownership validation and durable trust persistence to Local sources.""" - try: - set_local_python_permission(self.path, allowed=self.allowed) - except Exception as exc: - self.report({'ERROR'}, str(exc)) - return {'CANCELLED'} - self.report({'INFO'}, "Executable Local Python enabled" if self.allowed - else "Executable Local Python disabled") - return {'FINISHED'} - - -class NODEFORGE_OT_set_examples_python_permission(Operator): - """Grant or revoke executable Python only for the bundled Examples root.""" - - bl_idname = "nodeforge.set_examples_python_permission" - bl_label = "Examples Executable Python Permission" - bl_description = "Allow or deny Python callbacks shipped with NodeForge Examples" - bl_options = {'REGISTER'} - - allowed: BoolProperty(name="Allow bundled Examples Python", default=False, options={'HIDDEN'}) - - def execute(self, context): - from .catalog import catalog_dir - try: - set_examples_python_permission(catalog_dir('examples'), allowed=self.allowed) - except Exception as exc: - self.report({'ERROR'}, str(exc)) - return {'CANCELLED'} - self.report({'INFO'}, "Bundled Examples Python enabled" if self.allowed - else "Bundled Examples Python disabled") - return {'FINISHED'} - - class GNSCRIPT_MVP_OT_update_selected_group(Operator): """Class `GNSCRIPT_MVP_OT_update_selected_group` used by the NodeForge addon.""" bl_idname = "gn_script_mvp.update_selected_group" @@ -651,7 +598,6 @@ class NODEFORGE_OT_import_package(Operator): bl_options = {'REGISTER'} filepath: StringProperty(name="Package Archive", subtype="FILE_PATH", default="") - allow_python: BoolProperty(name="Allow executable Python", default=False) replace: BoolProperty(name="Replace existing package", default=False) def invoke(self, context, event): @@ -659,7 +605,6 @@ def invoke(self, context, event): props = getattr(context.scene, "gn_script_mvp", None) if props is not None: self.filepath = props.package_archive_path - self.allow_python = props.package_allow_python context.window_manager.fileselect_add(self) return {'RUNNING_MODAL'} @@ -667,10 +612,9 @@ def execute(self, context): """Install the selected package archive and refresh package/catalog UI.""" props = getattr(context.scene, "gn_script_mvp", None) try: - manifest = packages.install_package_zip(self.filepath, allow_python=self.allow_python, replace=self.replace) + manifest = packages.install_package_zip(self.filepath, replace=self.replace) if props is not None: props.package_archive_path = self.filepath - props.package_allow_python = self.allow_python _refresh_package_items(props) _refresh_catalog_items(props, "functions") _refresh_catalog_items(props, "examples") @@ -1218,19 +1162,6 @@ def draw(self, context): row.operator(NODEFORGE_OT_save_to_local.bl_idname, text="Save", icon='FILE_TICK') row.operator(NODEFORGE_OT_import_local.bl_idname, text="Add Folder...", icon='NEWFOLDER') _draw_library_catalog_panel(layout, context, "local", "local_items", "local_index", rows=7) - selected = _selected_catalog_item(props, "local") - root = Path(selected.root_path) if selected is not None and selected.root_path else ensure_local_catalog_dir() - try: - allowed = local_python_root_permission(root) - except Exception: - allowed = None - if allowed is not None: - row = layout.row(align=True) - row.label(text=f"Executable Python: {'Allowed' if allowed else 'Blocked'}", icon='CONSOLE') - op = row.operator(NODEFORGE_OT_set_local_python_permission.bl_idname, - text="Revoke" if allowed else "Allow", icon='CHECKMARK' if allowed else 'UNLOCKED') - op.path = str(root) - op.allowed = not allowed @@ -1267,17 +1198,6 @@ def poll(cls, context): return GNSCRIPT_MVP_PT_panel.poll(context) def draw(self, context): - from .catalog import catalog_dir - root = catalog_dir('examples') - try: - allowed = examples_python_root_permission(root) - except Exception as exc: - self.layout.label(text=f"Examples permission unavailable: {exc}", icon='ERROR') - else: - op = self.layout.operator(NODEFORGE_OT_set_examples_python_permission.bl_idname, - text="Executable Python: Allowed" if allowed else "Executable Python: Denied", - icon='UNLOCKED' if allowed else 'LOCKED') - op.allowed = not allowed _draw_library_catalog_panel(self.layout, context, "examples", "example_items", "example_index", rows=3) @@ -1303,12 +1223,15 @@ def draw(self, context): if props is None: layout.label(text="NodeForge properties unavailable", icon='ERROR') return - layout.prop(props, "package_allow_python", text="Allow executable Python") row = layout.row(align=True) row.operator(NODEFORGE_OT_refresh_packages.bl_idname, text="Refresh", icon='FILE_REFRESH') row.operator(NODEFORGE_OT_import_package.bl_idname, text="Import", icon='IMPORT') layout.template_list(NODEFORGE_UL_packages.__name__, "", props, "package_items", props, "package_index", rows=3) + if _PACKAGE_BOOTSTRAP_ERRORS: + layout.label(text="Package initialization failed; see console", icon='ERROR') selected = _selected_package_item(props) + if selected is not None and selected.compatibility_warning: + layout.label(text=selected.compatibility_warning, icon='INFO') row = layout.row(align=True) op = row.operator(NODEFORGE_OT_uninstall_package.bl_idname, text="Uninstall", icon='TRASH') op.package_id = selected.package_id if selected is not None else "" @@ -1334,8 +1257,6 @@ def menu_func(self, context): NODEFORGE_UL_packages, GNSCRIPT_MVP_OT_compile_expression, NODEFORGE_OT_reload_mesh_compute_modifier, - NODEFORGE_OT_set_local_python_permission, - NODEFORGE_OT_set_examples_python_permission, GNSCRIPT_MVP_OT_update_selected_group, NODEFORGE_OT_reload_selected_library_group, GNSCRIPT_MVP_OT_load_selected_group_source, @@ -1360,8 +1281,11 @@ def menu_func(self, context): NODEFORGE_PT_library_examples, ) +_PACKAGE_BOOTSTRAP_ERRORS: tuple[str, ...] = () + + def register(): - """Function `register` used by the NodeForge addon.""" + """Register UI and install included ordinary Packages on activation.""" generated_resources.cleanup_restart_orphans_deferred() from .blender.mesh_compute_runtime import register_runtime_load_handler register_runtime_load_handler() @@ -1369,6 +1293,16 @@ def register(): bpy.utils.register_class(cls) bpy.types.Scene.gn_script_mvp = PointerProperty(type=GNSCRIPT_MVP_Properties) bpy.types.NODE_MT_add.append(menu_func) + global _PACKAGE_BOOTSTRAP_ERRORS + try: + _PACKAGE_BOOTSTRAP_ERRORS = packages.install_included_packages(Path(__file__).resolve().parent) + except Exception as exc: + _PACKAGE_BOOTSTRAP_ERRORS = (str(exc),) + for message in _PACKAGE_BOOTSTRAP_ERRORS: + print(f"NodeForge package initialization: {message}") + props = getattr(getattr(bpy.context, "scene", None), "gn_script_mvp", None) + if props is not None and not _PACKAGE_BOOTSTRAP_ERRORS: + _refresh_package_items(props) def unregister(): """Unregister NodeForge UI and operators without mutating generated node-group data.""" From 38a4bece9f64cd6824ab04289cb5b214637337e2 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:49:33 +0300 Subject: [PATCH 02/13] feat(packages): add optional repository URL metadata - Add repository_url to package manifests - Validate repository URLs using HTTP(S) schemes - Preserve backward compatibility with existing manifests - Keep repository metadata independent of UI and package updates --- README.md | 2 ++ __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 ++++ packages.py | 20 ++++++++++++++++ tests/unit/test_package_inventory.py | 34 ++++++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2a7618..badc80a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ NodeForge scripts can call reusable functions and can be extended through instal You can install or update any library separately through **NodeForge → Packages → Install ZIP**. A package installed at a newer version than the one included in a future NodeForge release is not overwritten by that older included version. Automatic independent GitHub update checks for individual packages are not currently implemented. +Package manifests (`nodeforge_package.json`) may include an optional `repository_url` with an HTTP(S) project repository link. NodeForge retains this metadata without displaying it or using it to download updates. + If a library declares `nodeforge_max_version` below the installed NodeForge version, Packages displays a compatibility warning but still permits installation and use. A library requiring a **higher minimum** NodeForge version is rejected. Actual incompatible package code may still fail at compile time or runtime. Installed package APIs are imported through the package namespace: diff --git a/__init__.py b/__init__.py index 387e3c4..5721f9e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 71, 0) +NODEFORGE_VERSION = (0, 72, 0) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index eef05de..c7a3b5e 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.71.0" +version = "0.72.0" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 837f38a..b8d8227 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -492,3 +492,8 @@ lifecycle. - Treat an exceeded package `nodeforge_max_version` as a visible warning, while keeping `nodeforge_min_version` strict. - Reject damaged top-level package inventories without overwriting state, and fix install publication/copy-failure cleanup. - Support captured Extension API imports under Blender's `bl_ext.*` add-on module namespace without global aliases. + +## 0.72.0 + +- Accept optional `repository_url` metadata in package manifests; retain the URL without adding UI or automatic package downloads. +- Keep the existing NodeForge Extension `website` link in `blender_manifest.toml`. diff --git a/packages.py b/packages.py index 2da5a73..d06dbed 100644 --- a/packages.py +++ b/packages.py @@ -21,6 +21,7 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any, Iterable +from urllib.parse import urlsplit from .errors import CompileError @@ -107,6 +108,23 @@ class PackageError(CompileError): """Raised when package metadata, state, or files are invalid.""" +def _optional_repository_url(raw: dict[str, Any]) -> str | None: + """Validate optional package repository metadata without accessing the URL.""" + value = raw.get("repository_url") + if value is None: + return None + if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): + raise PackageError("Package manifest repository_url must be an HTTP(S) URL") + try: + parsed = urlsplit(value) + valid = parsed.scheme in {"http", "https"} and bool(parsed.hostname) + except ValueError: + valid = False + if not valid: + raise PackageError("Package manifest repository_url must be an HTTP(S) URL") + return value + + @dataclass(frozen=True) class PackageManifest: """Validated package manifest and owning package root.""" @@ -120,6 +138,7 @@ class PackageManifest: root: Path contents: dict[str, str] nodeforge_max_version: str | None = None + repository_url: str | None = None origin: str = "user" def root_for(self, namespace: str) -> Path | None: @@ -337,6 +356,7 @@ def validate_package_root(root: Path, *, origin: str = "user") -> PackageManifes root=root, contents=contents, nodeforge_max_version=raw.get("nodeforge_max_version"), + repository_url=_optional_repository_url(raw), origin=origin, ) diff --git a/tests/unit/test_package_inventory.py b/tests/unit/test_package_inventory.py index fa44aae..7d605fc 100644 --- a/tests/unit/test_package_inventory.py +++ b/tests/unit/test_package_inventory.py @@ -173,6 +173,40 @@ def _write_v2_system(system_root: Path, public_name: str) -> None: encoding="utf-8", ) +@pytest.mark.parametrize("repository_url", [None, "https://github.com/denikryt/nodeforge.math"]) +def test_optional_repository_url_is_preserved_on_install_and_reload(package_inventory, tmp_path, repository_url): + """Repository metadata is optional and does not affect ordinary package activation.""" + source = tmp_path / "vendor.metadata" + (source / "functions").mkdir(parents=True) + _write_manifest(source, package_id="vendor.metadata") + manifest_path = source / "nodeforge_package.json" + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + if repository_url is not None: + raw["repository_url"] = repository_url + manifest_path.write_text(json.dumps(raw), encoding="utf-8") + + assert packages.validate_package_root(source).repository_url == repository_url + packages.install_package_directory(source) + installed = next(m for m in packages.active_package_manifests() if m.package_id == "vendor.metadata") + assert installed.repository_url == repository_url + assert "repository_url" not in packages.load_package_state()["packages"]["vendor.metadata"] + + +@pytest.mark.parametrize("repository_url", ["", "../repo", "file:///tmp/repo", "javascript:alert(1)", "https://", 123, ["https://github.com"]]) +def test_invalid_repository_url_is_rejected_before_install(package_inventory, tmp_path, repository_url): + """An invalid optional link cannot enter a validated package manifest.""" + source = tmp_path / "vendor.invalid_url" + (source / "functions").mkdir(parents=True) + _write_manifest(source, package_id="vendor.invalid_url") + manifest_path = source / "nodeforge_package.json" + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + raw["repository_url"] = repository_url + manifest_path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(packages.PackageError, match="repository_url"): + packages.install_package_directory(source) + assert packages.load_package_state()["packages"] == {} + + def test_explicitly_installed_packages_use_unified_inventory(package_inventory, tmp_path): source = tmp_path / "vendor.inventory" (source / "functions").mkdir(parents=True) From b308607c6edf4268bf836e1a06d8a82e6aed68b7 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:55:03 +0300 Subject: [PATCH 03/13] fix: refresh library catalogs after package bootstrap - Automatically refresh Packages, Functions and Examples - Defer catalog updates until Blender registration completes - Isolate catalog refresh failures - Add Blender Extension installation and integration tests - Validate bundled libraries and package upgrades before release --- .github/workflows/release-extension.yml | 25 ++++++- README.md | 2 + __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 ++ .../blender/test_extension_package_install.py | 75 +++++++++++++++++++ tests/blender/verify_extension_release.py | 56 ++++++++++++++ ui.py | 27 ++++++- 8 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 tests/blender/verify_extension_release.py diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 900fc0f..13270a5 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -4,6 +4,11 @@ on: push: tags: ["v*"] workflow_dispatch: + inputs: + release_tag: + description: "Existing release tag to build (vX.Y.Z)" + required: true + type: string permissions: contents: write @@ -19,9 +24,11 @@ jobs: 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.ref_name }} + TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | python3 - <<'PY' import ast, os, pathlib, tomllib @@ -98,11 +105,23 @@ jobs: PY "$BLENDER" -b --command extension server-generate --repo-dir public test -s public/index.json + - 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" - name: Publish GitHub release asset - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.ref_name }} + TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag || \ gh release upload "$TAG_NAME" public/*.zip --clobber diff --git a/README.md b/README.md index badc80a..5029654 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ NodeForge automatically installs the included **NodeForge Math** and **NodeForge For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. +For release maintainers: `.github/workflows/release-extension.yml` runs on version tags. For a manual run, supply an existing `vX.Y.Z` tag in the `release_tag` input; the workflow checks out that tag and tests the built ZIP in Blender 5.2 before publication. + ## Quick start 1. Select a mesh object and open the **Geometry Node Editor**. diff --git a/__init__.py b/__init__.py index 5721f9e..055c1c1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 0) +NODEFORGE_VERSION = (0, 72, 1) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index c7a3b5e..6f2a366 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.0" +version = "0.72.1" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index b8d8227..61745a9 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -497,3 +497,8 @@ lifecycle. - Accept optional `repository_url` metadata in package manifests; retain the URL without adding UI or automatic package downloads. - Keep the existing NodeForge Extension `website` link in `blender_manifest.toml`. + +## 0.72.1 + +- Populate Packages, Functions and Examples browser lists after the extension's bundled-package bootstrap, including activation without an active scene context. +- Require Blender 5.2 installation, compiled-library examples and package upgrade checks against the built Extension ZIP before publishing a release. diff --git a/tests/blender/test_extension_package_install.py b/tests/blender/test_extension_package_install.py index 60d6355..6c258ba 100644 --- a/tests/blender/test_extension_package_install.py +++ b/tests/blender/test_extension_package_install.py @@ -53,3 +53,78 @@ def test_extension_packages_do_not_require_user_consent(): assert {r.manifest.package_id for r in records} >= {"nodeforge.math", "nodeforge.lsystem"} assert all("allow_python" not in r.state_record for r in records) assert all(packages.package_compatibility_warning(r.manifest) for r in records) + + +def test_browser_lists_populate_after_extension_activation(): + """Startup refresh exposes newly installed library content without manual Refresh.""" + extension = _installed_extension() + ui = importlib.import_module(extension.__name__ + ".ui") + assert bpy.app.timers.is_registered(ui._refresh_startup_browser_items) + # A background Blender pytest run has no GUI event loop to tick timers. + assert ui._refresh_startup_browser_items() is None + props = bpy.context.scene.gn_script_mvp + assert {item.package_id for item in props.package_items} >= { + "nodeforge.math", "nodeforge.lsystem" + } + assert any(item.name == "noise_terrain" for item in props.example_items) + assert any(item.name == "koch_curve" for item in props.example_items) + assert len(props.function_items) > 0 + + +def test_startup_catalog_error_does_not_block_other_lists(monkeypatch, capsys): + """A failed Functions catalog cannot prevent Examples and Packages refreshing.""" + extension = _installed_extension() + ui = importlib.import_module(extension.__name__ + ".ui") + original = ui._refresh_catalog_items + props = bpy.context.scene.gn_script_mvp + props.example_items.clear() + + def fail_functions(scene_props, namespace): + """Inject a failure in one independent startup catalog.""" + if namespace == "functions": + raise RuntimeError("simulated Functions failure") + return original(scene_props, namespace) + + monkeypatch.setattr(ui, "_refresh_catalog_items", fail_functions) + assert ui._refresh_startup_browser_items() is None + assert "simulated Functions failure" in capsys.readouterr().out + assert any(item.name == "noise_terrain" for item in props.example_items) + assert len(props.package_items) >= 2 + + +def test_z_reenable_upgrades_an_older_included_package(tmp_path): + """Re-enabling the built Extension upgrades an older ordinary installed package.""" + import json + import zipfile + + extension = _installed_extension() + packages = importlib.import_module(extension.__name__ + ".packages") + math_zip = next((Path(extension.__file__).parent / "package_install").glob("nodeforge.math-*.zip")) + old_zip = tmp_path / "math-older.zip" + with zipfile.ZipFile(math_zip) as source, zipfile.ZipFile(old_zip, "w") as old: + for entry in source.infolist(): + payload = source.read(entry.filename) + if entry.filename.endswith("nodeforge_package.json"): + manifest = json.loads(payload) + assert manifest["id"] == "nodeforge.math" + manifest["version"] = "2.1" + payload = json.dumps(manifest).encode("utf-8") + old.writestr(entry, payload) + + packages.install_package_zip(old_zip, replace=True) + current = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} + assert current["nodeforge.math"] == "2.1" + assert current["nodeforge.lsystem"] == "4.0" + + bpy.ops.preferences.addon_disable(module=extension.__name__) + assert extension.__name__ not in bpy.context.preferences.addons + bpy.ops.preferences.addon_enable(module=extension.__name__) + assert extension.__name__ in bpy.context.preferences.addons + packages = importlib.import_module(extension.__name__ + ".packages") + updated = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} + assert updated["nodeforge.math"] == "2.2" + assert updated["nodeforge.lsystem"] == "4.0" + ui = importlib.import_module(extension.__name__ + ".ui") + assert ui._refresh_startup_browser_items() is None + props = bpy.context.scene.gn_script_mvp + assert {row.package_id for row in props.package_items} >= set(updated) diff --git a/tests/blender/verify_extension_release.py b/tests/blender/verify_extension_release.py new file mode 100644 index 0000000..edddc7d --- /dev/null +++ b/tests/blender/verify_extension_release.py @@ -0,0 +1,56 @@ +"""CI entrypoint: install the built Extension ZIP and run Blender release regressions. + +Usage: + blender --background --factory-startup --python-exit-code 1 \\ + --python tests/blender/verify_extension_release.py -- /absolute/path/nodeforge-x.y.z.zip +""" + +from __future__ import annotations + +import importlib +import os +from pathlib import Path +import sys +import tempfile + +import bpy + + +def main() -> None: + """Use an isolated Blender repository and run tests against the installed ZIP.""" + if "--" not in sys.argv: + raise RuntimeError("Pass the built Blender Extension ZIP after --") + archive = Path(sys.argv[sys.argv.index("--") + 1]).resolve() + if not archive.is_file(): + raise FileNotFoundError(archive) + + module_name = "bl_ext.nodeforge_ci.nodeforge" + repo_dir = Path(tempfile.mkdtemp(prefix="nodeforge-extension-ci-")) + bpy.context.preferences.extensions.repos.new( + name="NodeForge CI", module="nodeforge_ci", source="USER", + custom_directory=str(repo_dir), + ) + result = bpy.ops.extensions.package_install_files( + filepath=str(archive), repo="nodeforge_ci", enable_on_install=True, + ) + if result != {"FINISHED"}: + raise AssertionError(f"Extension installation failed: {result}") + extension = importlib.import_module(module_name) + if module_name not in bpy.context.preferences.addons: + raise AssertionError(f"Extension not enabled: {module_name}") + + # The headless Python entrypoint does not process Blender timer callbacks. + # Tests explicitly tick the startup catalog refresh that the UI event loop + # executes after Blender removes its restricted registration context. + os.environ["NODEFORGE_EXTENSION_MODULE"] = module_name + import pytest + + checks = Path(__file__).with_name("test_extension_package_install.py") + result = pytest.main([str(checks), "-q", "-p", "no:cacheprovider"]) + if result != pytest.ExitCode.OK: + raise RuntimeError(f"Blender release integration checks failed: {result}") + print(f"PASS: installed and tested built extension {archive.name} ({extension.__name__})") + + +if __name__ == "__main__": + main() diff --git a/ui.py b/ui.py index 8c88b99..5af4f94 100644 --- a/ui.py +++ b/ui.py @@ -1284,6 +1284,24 @@ def menu_func(self, context): _PACKAGE_BOOTSTRAP_ERRORS: tuple[str, ...] = () +def _refresh_startup_browser_items(): + """Fill Scene-owned browser lists after Blender releases registration restrictions.""" + for scene in bpy.data.scenes: + props = getattr(scene, "gn_script_mvp", None) + if props is None: + continue + for namespace in ("packages", "functions", "examples"): + try: + if namespace == "packages": + _refresh_package_items(props) + else: + _refresh_catalog_items(props, namespace) + except Exception as exc: + # One broken catalog cannot prevent other lists from loading. + print(f"NodeForge {namespace} catalog initialization: {exc}") + return None # One-shot timer; every activation schedules a fresh refresh. + + def register(): """Register UI and install included ordinary Packages on activation.""" generated_resources.cleanup_restart_orphans_deferred() @@ -1300,12 +1318,15 @@ def register(): _PACKAGE_BOOTSTRAP_ERRORS = (str(exc),) for message in _PACKAGE_BOOTSTRAP_ERRORS: print(f"NodeForge package initialization: {message}") - props = getattr(getattr(bpy.context, "scene", None), "gn_script_mvp", None) - if props is not None and not _PACKAGE_BOOTSTRAP_ERRORS: - _refresh_package_items(props) + # Blender restricts bpy.data while add-ons register. Populate the Scene + # collections on the next event-loop tick instead of touching bpy.data here. + if not bpy.app.timers.is_registered(_refresh_startup_browser_items): + bpy.app.timers.register(_refresh_startup_browser_items, first_interval=0.0) def unregister(): """Unregister NodeForge UI and operators without mutating generated node-group data.""" + if bpy.app.timers.is_registered(_refresh_startup_browser_items): + bpy.app.timers.unregister(_refresh_startup_browser_items) from .blender.mesh_compute_runtime import ( shutdown_runtime_bindings, unregister_runtime_load_handler, ) From cb33e4fcc1f836123cbf2ec042f4dcf76b74fca5 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:56:00 +0300 Subject: [PATCH 04/13] fix(ci): prevent overwriting published extension releases - Remove GitHub Release asset clobbering - Reject duplicate release publication - Restrict publication to release tag events - Keep manual workflow runs for validation only - Add release immutability regression tests --- .github/workflows/release-extension.yml | 11 ++- README.md | 2 +- __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 ++ .../unit/test_release_workflow_publication.py | 81 +++++++++++++++++++ 6 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_release_workflow_publication.py diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 13270a5..7252d59 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -117,16 +117,19 @@ jobs: 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" + # Manual dispatch is verification-only: a published release must never be replaced. - name: Publish GitHub release asset - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + TAG_NAME: ${{ github.ref_name }} run: | - gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag || \ - gh release upload "$TAG_NAME" public/*.zip --clobber + set -euo pipefail + gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag - uses: actions/upload-pages-artifact@v3 + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') with: path: public - id: deployment + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 5029654..ba21ab1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ NodeForge automatically installs the included **NodeForge Math** and **NodeForge For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. -For release maintainers: `.github/workflows/release-extension.yml` runs on version tags. For a manual run, supply an existing `vX.Y.Z` tag in the `release_tag` input; the workflow checks out that tag and tests the built ZIP in Blender 5.2 before publication. +For release maintainers: `.github/workflows/release-extension.yml` publishes a version only on its first tag-triggered run; publication fails if the GitHub Release already exists. A manual `workflow_dispatch` with an existing `vX.Y.Z` tag rebuilds and tests the Extension in Blender 5.2 without updating GitHub Releases or GitHub Pages. ## Quick start diff --git a/__init__.py b/__init__.py index 055c1c1..c1f3e64 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 1) +NODEFORGE_VERSION = (0, 72, 2) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index 6f2a366..41a0cd3 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.1" +version = "0.72.2" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 61745a9..ade3c2f 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -502,3 +502,8 @@ lifecycle. - Populate Packages, Functions and Examples browser lists after the extension's bundled-package bootstrap, including activation without an active scene context. - Require Blender 5.2 installation, compiled-library examples and package upgrade checks against the built Extension ZIP before publishing a release. + +## 0.72.2 + +- Publish GitHub Release and GitHub Pages artifacts only on tag-triggered runs; never overwrite an existing release ZIP. +- Make manual release workflow dispatch verification-only: rebuild and test the Extension without changing published artifacts. diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py new file mode 100644 index 0000000..ad62c8b --- /dev/null +++ b/tests/unit/test_release_workflow_publication.py @@ -0,0 +1,81 @@ +"""Release workflow invariants: published extension artifacts are immutable.""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import subprocess + +import pytest + +pytestmark = pytest.mark.unit + +WORKFLOW = Path(__file__).resolve().parents[2] / ".github/workflows/release-extension.yml" +TAG_PUSH_ONLY = "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" + + +def _step(name_or_action: str) -> str: + """Extract one workflow step without depending on a YAML parser in test runners.""" + content = WORKFLOW.read_text(encoding="utf-8") + marker = f" - {name_or_action}" + assert content.count(marker) == 1 + start = content.index(marker) + following = re.search(r"(?m)^ - (?:name:|uses:|id:)", content[start + len(marker):]) + end = start + len(marker) + following.start() if following else len(content) + return content[start:end] + + +def test_manual_dispatch_runs_release_verification_but_not_publication(): + """Manual retries execute built-ZIP checks without changing releases or Pages.""" + content = WORKFLOW.read_text(encoding="utf-8") + assert 'workflow_dispatch:' in content + assert 'inputs.release_tag' in content + assert _step('name: Install and test built Extension in Blender 5.2') + for name in ( + 'name: Publish GitHub release asset', + 'uses: actions/upload-pages-artifact@v3', + 'id: deployment', + ): + assert f'if: {TAG_PUSH_ONLY}' in _step(name) + + +def test_release_cannot_overwrite_existing_asset(): + """Release publishing uses create-only semantics with no clobber fallback.""" + step = _step('name: Publish GitHub release asset') + script = step.split(' run: |\n', 1)[1] + assert 'set -euo pipefail' in script + assert 'gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag' in script + assert 'gh release upload' not in script + assert '--clobber' not in script + assert '||' not in script + + +def test_release_failure_stops_without_attempting_upload(tmp_path): + """An existing release rejection leaves existing assets untouched.""" + section = _step('name: Publish GitHub release asset') + raw_script = section.split(' run: |\n', 1)[1] + script = '\n'.join(line[10:] for line in raw_script.splitlines() if line.strip()) + + bin_dir = tmp_path / 'bin' + bin_dir.mkdir() + gh = bin_dir / 'gh' + gh.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$GH_CALL_LOG"\nexit "$MOCK_GH_EXIT"\n') + gh.chmod(0o755) + public_dir = tmp_path / 'public' + public_dir.mkdir() + (public_dir / 'nodeforge-test.zip').write_bytes(b'archive') + + log_path = tmp_path / 'calls.txt' + env = dict(os.environ, PATH=f'{bin_dir}:{os.environ.get("PATH", "")}', + GH_CALL_LOG=str(log_path), TAG_NAME='v0.0.0', MOCK_GH_EXIT='23') + failed = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, capture_output=True) + assert failed.returncode == 23 + assert log_path.read_text().splitlines() == [ + 'release create v0.0.0 public/nodeforge-test.zip --generate-notes --verify-tag' + ] + + env['MOCK_GH_EXIT'] = '0' + successful = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, capture_output=True) + assert successful.returncode == 0 + assert len(log_path.read_text().splitlines()) == 2 From 1246fc20b6e311bd2cc11ed47df5ab1f7785b628 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:56:39 +0300 Subject: [PATCH 05/13] fix(ci): separate release publication from Pages deployment - Split workflow into build, publish-release and deploy-pages jobs - Reuse verified build artifacts across publication stages - Allow independent GitHub Pages deployment retries - Preserve immutable GitHub Release assets - Add deployment workflow regression tests --- .github/workflows/release-extension.yml | 56 ++++++-- README.md | 2 +- __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 + .../unit/test_release_workflow_publication.py | 127 ++++++++++++------ 6 files changed, 142 insertions(+), 52 deletions(-) diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 7252d59..1f64ccc 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -11,9 +11,7 @@ on: type: string permissions: - contents: write - pages: write - id-token: write + contents: read concurrency: group: pages @@ -117,19 +115,57 @@ jobs: 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" - # Manual dispatch is verification-only: a published release must never be replaced. - - name: Publish GitHub release asset + # Preserve the exact ZIP and index.json validated above for downstream jobs. + - name: Preserve verified 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 verified 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 + + publish-release: + name: Publish immutable GitHub Release + needs: build + 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 }} TAG_NAME: ${{ github.ref_name }} run: | set -euo pipefail gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag - - uses: actions/upload-pages-artifact@v3 - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - with: - path: public + + 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 - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index ba21ab1..71ed9dd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ NodeForge automatically installs the included **NodeForge Math** and **NodeForge For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. -For release maintainers: `.github/workflows/release-extension.yml` publishes a version only on its first tag-triggered run; publication fails if the GitHub Release already exists. A manual `workflow_dispatch` with an existing `vX.Y.Z` tag rebuilds and tests the Extension in Blender 5.2 without updating GitHub Releases or GitHub Pages. +For release maintainers: `.github/workflows/release-extension.yml` builds and tests one Extension ZIP, then publishes the immutable GitHub Release and deploys its repository index to GitHub Pages in separate jobs. If the Pages deployment fails after the release is published, **rerun only the failed `deploy-pages` job** in GitHub Actions; it reuses the verified Pages artifact from `build` without recreating the release. An existing release cannot be overwritten. Manual `workflow_dispatch` with an existing `vX.Y.Z` tag runs verification only, without publishing GitHub Releases or GitHub Pages. ## Quick start diff --git a/__init__.py b/__init__.py index c1f3e64..989f897 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 2) +NODEFORGE_VERSION = (0, 72, 3) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index 41a0cd3..d754a78 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.2" +version = "0.72.3" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index ade3c2f..e535060 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -507,3 +507,8 @@ lifecycle. - Publish GitHub Release and GitHub Pages artifacts only on tag-triggered runs; never overwrite an existing release ZIP. - Make manual release workflow dispatch verification-only: rebuild and test the Extension without changing published artifacts. + +## 0.72.3 + +- Separate Blender Extension release build, immutable GitHub Release publication and GitHub Pages deployment into recoverable GitHub Actions jobs. +- Preserve the verified release and Pages artifacts during build so a failed Pages deployment can be retried independently without recreating or overwriting the GitHub Release. diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index ad62c8b..776dcda 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -1,4 +1,4 @@ -"""Release workflow invariants: published extension artifacts are immutable.""" +"""Release workflow invariants: immutable artifacts and independently retryable Pages.""" from __future__ import annotations @@ -15,67 +15,116 @@ TAG_PUSH_ONLY = "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" -def _step(name_or_action: str) -> str: - """Extract one workflow step without depending on a YAML parser in test runners.""" - content = WORKFLOW.read_text(encoding="utf-8") - marker = f" - {name_or_action}" - assert content.count(marker) == 1 - start = content.index(marker) - following = re.search(r"(?m)^ - (?:name:|uses:|id:)", content[start + len(marker):]) - end = start + len(marker) + following.start() if following else len(content) +def _job(name: str) -> str: + """Extract one job by its top-level YAML indentation, with no YAML dependency.""" + content = WORKFLOW.read_text(encoding="utf-8").split("\njobs:\n", 1)[1] + start_marker = f" {name}:\n" + assert content.count(start_marker) == 1 + start = content.index(start_marker) + next_job = re.search(r"(?m)^ [a-z][\w-]*:\s*$", content[start + len(start_marker):]) + end = start + len(start_marker) + next_job.start() if next_job else len(content) return content[start:end] -def test_manual_dispatch_runs_release_verification_but_not_publication(): - """Manual retries execute built-ZIP checks without changing releases or Pages.""" +def _step(job: str, name_or_action: str) -> str: + """Extract a single workflow step within the specified job.""" + marker = f" - {name_or_action}" + assert job.count(marker) == 1 + start = job.index(marker) + following = re.search(r"(?m)^ - (?:name:|uses:|id:)", job[start + len(marker):]) + end = start + len(marker) + following.start() if following else len(job) + return job[start:end] + + +def test_manual_dispatch_runs_verification_without_publication(): + """Manual runs verify the built ZIP but skip both publishing jobs.""" content = WORKFLOW.read_text(encoding="utf-8") - assert 'workflow_dispatch:' in content - assert 'inputs.release_tag' in content - assert _step('name: Install and test built Extension in Blender 5.2') - for name in ( - 'name: Publish GitHub release asset', - 'uses: actions/upload-pages-artifact@v3', - 'id: deployment', - ): - assert f'if: {TAG_PUSH_ONLY}' in _step(name) + assert "workflow_dispatch:" in content + assert "inputs.release_tag" in content + assert _step(_job("build"), "name: Install and test built Extension in Blender 5.2") + for name in ("publish-release", "deploy-pages"): + assert f"if: {TAG_PUSH_ONLY}" in _job(name) + assert "gh release create" not in _job("build") + assert "actions/deploy-pages" not in _job("build") + + +def test_publishing_jobs_depend_on_one_verified_build(): + """The release and Pages deployment reuse the validated build, not rebuilt ZIPs.""" + build = _job("build") + release = _job("publish-release") + pages = _job("deploy-pages") + + assert "needs: build" in release + assert "needs: [build, publish-release]" in pages + assert "contents: write" in release + assert "pages: write" in pages and "id-token: write" in pages + assert "name: github-pages" in pages + + upload = _step(build, "name: Preserve verified release files") + download = _step(release, "name: Retrieve verified release files") + assert "uses: actions/upload-artifact@v4" in upload + assert "uses: actions/download-artifact@v4" in download + for step in (upload, download): + assert "name: nodeforge-release-public" in step + assert "path: public/" in step + assert build.index("name: Install and test built Extension in Blender 5.2") < build.index("name: Preserve verified release files") + assert "gh release create" in _step(release, "name: Publish GitHub release asset") + assert "blender-dist" not in release + assert "blender-dist" not in pages + + +def test_pages_retry_reuses_existing_pages_artifact(): + """Deploy retries never re-upload an artifact or retry immutable release creation.""" + build = _job("build") + pages = _job("deploy-pages") + prepared = _step(build, "name: Preserve verified Pages artifact") + assert f"if: {TAG_PUSH_ONLY}" in prepared + assert "uses: actions/upload-pages-artifact@v3" in prepared + assert "path: public/" in prepared + assert "retention-days: 30" in prepared + assert "id: deployment" in pages and "uses: actions/deploy-pages@v4" in pages + assert "upload-pages-artifact" not in pages + assert "upload-artifact" not in pages + assert "gh release" not in pages + assert "server-generate" not in pages def test_release_cannot_overwrite_existing_asset(): """Release publishing uses create-only semantics with no clobber fallback.""" - step = _step('name: Publish GitHub release asset') - script = step.split(' run: |\n', 1)[1] - assert 'set -euo pipefail' in script + step = _step(_job("publish-release"), "name: Publish GitHub release asset") + script = step.split(" run: |\n", 1)[1] + assert "set -euo pipefail" in script assert 'gh release create "$TAG_NAME" public/*.zip --generate-notes --verify-tag' in script - assert 'gh release upload' not in script - assert '--clobber' not in script - assert '||' not in script + assert "gh release upload" not in script + assert "--clobber" not in script + assert "||" not in script def test_release_failure_stops_without_attempting_upload(tmp_path): """An existing release rejection leaves existing assets untouched.""" - section = _step('name: Publish GitHub release asset') - raw_script = section.split(' run: |\n', 1)[1] - script = '\n'.join(line[10:] for line in raw_script.splitlines() if line.strip()) + section = _step(_job("publish-release"), "name: Publish GitHub release asset") + raw_script = section.split(" run: |\n", 1)[1] + script = "\n".join(line[10:] for line in raw_script.splitlines() if line.strip()) - bin_dir = tmp_path / 'bin' + bin_dir = tmp_path / "bin" bin_dir.mkdir() - gh = bin_dir / 'gh' + gh = bin_dir / "gh" gh.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$GH_CALL_LOG"\nexit "$MOCK_GH_EXIT"\n') gh.chmod(0o755) - public_dir = tmp_path / 'public' + public_dir = tmp_path / "public" public_dir.mkdir() - (public_dir / 'nodeforge-test.zip').write_bytes(b'archive') + (public_dir / "nodeforge-test.zip").write_bytes(b"archive") - log_path = tmp_path / 'calls.txt' + log_path = tmp_path / "calls.txt" env = dict(os.environ, PATH=f'{bin_dir}:{os.environ.get("PATH", "")}', - GH_CALL_LOG=str(log_path), TAG_NAME='v0.0.0', MOCK_GH_EXIT='23') - failed = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, capture_output=True) + GH_CALL_LOG=str(log_path), TAG_NAME="v0.0.0", MOCK_GH_EXIT="23") + failed = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) assert failed.returncode == 23 assert log_path.read_text().splitlines() == [ - 'release create v0.0.0 public/nodeforge-test.zip --generate-notes --verify-tag' + "release create v0.0.0 public/nodeforge-test.zip --generate-notes --verify-tag" ] - env['MOCK_GH_EXIT'] = '0' - successful = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, capture_output=True) + env["MOCK_GH_EXIT"] = "0" + successful = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) assert successful.returncode == 0 assert len(log_path.read_text().splitlines()) == 2 From eb3c7b14bf9f0557f2b50bc9dc635514b5187d98 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:57:39 +0300 Subject: [PATCH 06/13] fix(ci): specify GitHub repository for release publication - Set GH_REPO explicitly in publish-release job - Support GitHub CLI execution without checkout - Add regression tests for repository resolution --- .github/workflows/release-extension.yml | 1 + __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 4 +++ .../unit/test_release_workflow_publication.py | 33 +++++++++++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 1f64ccc..f69bef5 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -148,6 +148,7 @@ jobs: - name: Publish GitHub release asset env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} TAG_NAME: ${{ github.ref_name }} run: | set -euo pipefail diff --git a/__init__.py b/__init__.py index 989f897..c69c8c0 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 3) +NODEFORGE_VERSION = (0, 72, 4) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index d754a78..e3a1b83 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.3" +version = "0.72.4" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index e535060..953436e 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -512,3 +512,7 @@ lifecycle. - Separate Blender Extension release build, immutable GitHub Release publication and GitHub Pages deployment into recoverable GitHub Actions jobs. - Preserve the verified release and Pages artifacts during build so a failed Pages deployment can be retried independently without recreating or overwriting the GitHub Release. + +## 0.72.4 + +- Set the GitHub CLI target repository explicitly in the standalone release-publishing job, allowing immutable release publication on a runner without a Git checkout. diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index 776dcda..4000fab 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -128,3 +128,36 @@ def test_release_failure_stops_without_attempting_upload(tmp_path): successful = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) assert successful.returncode == 0 assert len(log_path.read_text().splitlines()) == 2 + + +def test_release_targets_repository_without_checkout(tmp_path): + """GH_REPO identifies the publication target on a fresh runner without .git.""" + job = _job("publish-release") + section = _step(job, "name: Publish GitHub release asset") + assert "actions/checkout" not in job + assert "GH_REPO: ${{ github.repository }}" in section + script = "\n".join( + line[10:] for line in section.split(" run: |\n", 1)[1].splitlines() + if line.strip() + ) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text( + '#!/bin/sh\n' + 'test "${GH_REPO-}" = "denikryt/NodeForge" || exit 42\n' + 'test "$1" = "release" && test "$2" = "create"\n' + ) + gh.chmod(0o755) + public = tmp_path / "public" + public.mkdir() + (public / "nodeforge-test.zip").write_bytes(b"archive") + env = dict(os.environ, PATH=f'{bin_dir}:{os.environ.get("PATH", "")}', TAG_NAME="v0.0.0") + env.pop("GH_REPO", None) + missing = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) + assert missing.returncode == 42 + + env["GH_REPO"] = "denikryt/NodeForge" + configured = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) + assert configured.returncode == 0 From 91835036535861689ef4b08d35d2758b4121a0a6 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:58:31 +0300 Subject: [PATCH 07/13] refactor: simplify Local registry and package bootstrap - Remove redundant Local registry payload representation - Consolidate registry loading and validation - Handle duplicate package IDs without exception message matching - Preserve staging cleanup and registry compatibility - Add regression tests for both simplified paths --- __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 +++ local_sources.py | 17 ++++------ packages.py | 6 ++-- .../unit/test_local_mesh_compute_registry.py | 32 +++++++++++++++++++ tests/unit/test_package_release_bootstrap.py | 3 ++ 7 files changed, 50 insertions(+), 17 deletions(-) diff --git a/__init__.py b/__init__.py index c69c8c0..0dda9e0 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 4) +NODEFORGE_VERSION = (0, 72, 5) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index e3a1b83..4c54e10 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.4" +version = "0.72.5" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 953436e..5486aea 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -516,3 +516,8 @@ lifecycle. ## 0.72.4 - Set the GitHub CLI target repository explicitly in the standalone release-publishing job, allowing immutable release publication on a runner without a Git checkout. + +## 0.72.5 + +- Simplified Local registry reading to return validated source roots directly while continuing to ignore obsolete permission metadata. +- Reject duplicate bundled Package IDs directly during prevalidation, preserving inventory and temporary directory cleanup without exception-message matching. diff --git a/local_sources.py b/local_sources.py index e9f6ff4..86251a7 100644 --- a/local_sources.py +++ b/local_sources.py @@ -53,11 +53,11 @@ def _paths_overlap(first: Path | str, second: Path | str) -> bool: return False -def _read_local_source_payload() -> dict[str, object]: +def _read_local_source_registry() -> list[dict[str, str]]: """Read registered Local roots and ignore obsolete permission metadata.""" path = _local_sources_registry_path() if not path.exists(): - return {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": []} + return [] try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError) as exc: @@ -67,21 +67,16 @@ def _read_local_source_payload() -> dict[str, object]: roots = payload.get("roots", []) if not isinstance(roots, list): raise CompileError("Invalid Local source registry roots") - result: list[dict[str, object]] = [] + result: list[dict[str, str]] = [] for item in roots: if not isinstance(item, dict) or not isinstance(item.get("path"), str): raise CompileError("Invalid Local source registry entry") kind = str(item.get("kind") or "folder") if kind not in {"folder", "file"}: raise CompileError("Invalid Local source registry entry kind") - entry: dict[str, object] = {"path": item["path"], "label": str(item.get("label") or ""), "kind": kind} + entry = {"path": item["path"], "label": str(item.get("label") or ""), "kind": kind} result.append(entry) - return {"version": _LOCAL_SOURCES_REGISTRY_VERSION, "roots": result} - - -def _read_local_source_registry() -> list[dict[str, str]]: - """Load registered Local roots.""" - return _read_local_source_payload()["roots"] + return result def _write_local_source_registry(roots: list[dict[str, str]]) -> None: @@ -232,7 +227,7 @@ def is_registered_local_python_source(path: Path | str) -> bool: managed = _canonical_path(_default_local_catalog_dir()) if source.is_relative_to(managed): return True - for item in _read_local_source_payload()["roots"]: + for item in _read_local_source_registry(): root = _canonical_path(item["path"]) if item["kind"] == "file" and source == root: return True diff --git a/packages.py b/packages.py index d06dbed..42bb603 100644 --- a/packages.py +++ b/packages.py @@ -640,13 +640,11 @@ def install_included_packages(extension_root: Path) -> tuple[str, ...]: root = _extract_zip_to_staging(archive, staging) manifest = validate_package_root(root) if manifest.package_id in seen: - raise PackageError(f"Duplicate included package ID: {manifest.package_id}") + # Ambiguous release contents must not partially change inventory. + return (f"Duplicate included package ID: {manifest.package_id}",) seen.add(manifest.package_id) candidates.append((archive, manifest.package_id, manifest.version)) except Exception as exc: - if "Duplicate included package ID" in str(exc): - # Ambiguous release contents must not partially change inventory. - return (str(exc),) errors.append(f"{archive.name}: {exc}") finally: shutil.rmtree(staging, ignore_errors=True) diff --git a/tests/unit/test_local_mesh_compute_registry.py b/tests/unit/test_local_mesh_compute_registry.py index fe6f4b3..71ad722 100644 --- a/tests/unit/test_local_mesh_compute_registry.py +++ b/tests/unit/test_local_mesh_compute_registry.py @@ -1,6 +1,7 @@ """Trusted Local runtime bridging into the existing immutable Extension API registry.""" import dataclasses +import json from pathlib import Path from types import SimpleNamespace @@ -196,3 +197,34 @@ def test_individually_linked_file_requires_registration(library, tmp_path): assert runtime_source_active('local', linked) local_sources.unlink_local_source_file(linked) assert not runtime_source_active('local', linked) + + +def test_local_registry_ignores_legacy_python_permissions(library, tmp_path): + """Legacy consent fields never affect registered Local source membership.""" + linked = _write(tmp_path) + registry = local_sources._local_sources_registry_path() + registry.write_text(json.dumps({ + "version": 1, + "managed_allow_python": False, + "roots": [{"path": str(linked), "label": "Legacy", "kind": "file", + "allow_python": False}], + }), encoding="utf-8") + assert local_sources._read_local_source_registry() == [ + {"path": str(linked), "label": "Legacy", "kind": "file"}, + ] + assert local_sources.is_registered_local_python_source(linked) + assert registry.read_text(encoding="utf-8").find("allow_python") != -1 + + +def test_local_registry_rejects_corrupt_records(library): + """Streamlined registry reads still fail closed on malformed roots.""" + registry = local_sources._local_sources_registry_path() + for payload, message in ( + ({"version": 999, "roots": []}, "Unsupported Local source registry format"), + ({"version": 1, "roots": "bad"}, "Invalid Local source registry roots"), + ({"version": 1, "roots": [{"path": "/tmp", "kind": "invalid"}]}, + "Invalid Local source registry entry kind"), + ): + registry.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(CompileError, match=message): + local_sources._read_local_source_registry() diff --git a/tests/unit/test_package_release_bootstrap.py b/tests/unit/test_package_release_bootstrap.py index 469397e..722b6b2 100644 --- a/tests/unit/test_package_release_bootstrap.py +++ b/tests/unit/test_package_release_bootstrap.py @@ -118,6 +118,9 @@ def test_duplicate_ids_do_not_change_state(tmp_path): root = release(tmp_path, a, b) assert 'Duplicate included package ID' in packages.install_included_packages(root)[0] assert packages.load_package_state()['packages'] == {} + # A return inside try must still execute the staging cleanup in finally. + assert not list((packages.packages_dir() / '.staging').glob('*')) + assert not list(packages.installed_dir().glob('**/install_*')) def test_invalid_max_is_advisory_but_min_is_strict(tmp_path): From c0cc525a25189895d5a0b0cadda3a2705eb3bc86 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 03:59:16 +0300 Subject: [PATCH 08/13] refactor(packages): simplify directory fsync error handling - Remove redundant nested exception handling - Handle fsync failures at the state publication boundary - Preserve file descriptor cleanup - Maintain existing package transaction guarantees - Add regression tests for fsync success and failure --- __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 4 +++ packages.py | 7 ++-- tests/unit/test_package_release_bootstrap.py | 37 ++++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/__init__.py b/__init__.py index 0dda9e0..0885df7 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 5) +NODEFORGE_VERSION = (0, 72, 6) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index 4c54e10..93f188d 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.5" +version = "0.72.6" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 5486aea..4c39b90 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -521,3 +521,7 @@ lifecycle. - Simplified Local registry reading to return validated source roots directly while continuing to ignore obsolete permission metadata. - Reject duplicate bundled Package IDs directly during prevalidation, preserving inventory and temporary directory cleanup without exception-message matching. + +## 0.72.6 + +- Simplified package-state directory synchronization to handle post-publication fsync errors only in `save_package_state()`, while keeping descriptor cleanup unconditional. diff --git a/packages.py b/packages.py index 42bb603..95ef793 100644 --- a/packages.py +++ b/packages.py @@ -284,7 +284,7 @@ def save_package_state(state: dict[str, Any]) -> None: def _fsync_directory(path: Path) -> None: - """Best-effort directory fsync for crash-durable state replacement.""" + """Attempt directory fsync; the state writer handles durability errors.""" if not hasattr(os, "O_DIRECTORY"): return try: @@ -292,10 +292,7 @@ def _fsync_directory(path: Path) -> None: except OSError: return try: - try: - os.fsync(fd) - except OSError: - pass + os.fsync(fd) finally: os.close(fd) diff --git a/tests/unit/test_package_release_bootstrap.py b/tests/unit/test_package_release_bootstrap.py index 722b6b2..3dfa090 100644 --- a/tests/unit/test_package_release_bootstrap.py +++ b/tests/unit/test_package_release_bootstrap.py @@ -180,3 +180,40 @@ def test_legacy_fields_ignored_and_dropped_on_write(tmp_path): packages.save_package_state(packages.load_package_state()) assert 'allow_python' not in packages.state_path().read_text() assert 'seed_sources' not in packages.state_path().read_text() + + +@pytest.mark.skipif(not hasattr(packages.os, "O_DIRECTORY"), reason="No O_DIRECTORY support") +@pytest.mark.parametrize("fails", [False, True]) +def test_directory_fsync_always_closes_descriptor_and_reports_sync_error(tmp_path, monkeypatch, fails): + """Directory sync reports actual fsync failures to the state writer and closes its fd.""" + seen = [] + fake_fd = 123456 + + def open_dir(path, flags): + """Represent successful directory opening without using a real descriptor.""" + assert Path(path) == tmp_path + assert flags & packages.os.O_DIRECTORY + seen.append("open") + return fake_fd + + def sync_dir(fd): + """Record the sync call and optionally inject a durability error.""" + assert fd == fake_fd + seen.append("fsync") + if fails: + raise OSError("injected directory fsync failure") + + def close_dir(fd): + """Record that the directory descriptor is released in both paths.""" + assert fd == fake_fd + seen.append("close") + + monkeypatch.setattr(packages.os, "open", open_dir) + monkeypatch.setattr(packages.os, "fsync", sync_dir) + monkeypatch.setattr(packages.os, "close", close_dir) + if fails: + with pytest.raises(OSError, match="injected directory fsync failure"): + packages._fsync_directory(tmp_path) + else: + packages._fsync_directory(tmp_path) + assert seen == ["open", "fsync", "close"] From a255fc5e980f8abf17efa3d00ead662f12760e81 Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 19:34:04 +0300 Subject: [PATCH 09/13] refactor(ci): separate extension build and integration tests - Move Blender integration tests into a dedicated test-extension job - Pass the built Extension ZIP between jobs through artifacts - Cache Blender 5.2 in build and test jobs - Require successful integration tests before release publication - Preserve independent GitHub Pages deployment retries - Bump NodeForge version to 0.72.7 --- .github/workflows/release-extension.yml | 67 ++++++++++++++----- README.md | 4 +- __init__.py | 2 +- blender_manifest.toml | 2 +- dev/CHANGELOG.md | 5 ++ .../unit/test_release_workflow_publication.py | 39 +++++++++-- 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index f69bef5..743a0af 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -78,8 +78,16 @@ jobs: seen[manifest['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 @@ -103,20 +111,8 @@ jobs: PY "$BLENDER" -b --command extension server-generate --repo-dir public test -s public/index.json - - 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" - # Preserve the exact ZIP and index.json validated above for downstream jobs. - - name: Preserve verified release files + # 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 @@ -125,16 +121,55 @@ jobs: 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 verified Pages artifact + - 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 + needs: [build, test-extension] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-24.04 permissions: diff --git a/README.md b/README.md index 71ed9dd..2381387 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ NodeForge automatically installs the included **NodeForge Math** and **NodeForge For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. -For release maintainers: `.github/workflows/release-extension.yml` builds and tests one Extension ZIP, then publishes the immutable GitHub Release and deploys its repository index to GitHub Pages in separate jobs. If the Pages deployment fails after the release is published, **rerun only the failed `deploy-pages` job** in GitHub Actions; it reuses the verified Pages artifact from `build` without recreating the release. An existing release cannot be overwritten. Manual `workflow_dispatch` with an existing `vX.Y.Z` tag runs verification only, without publishing GitHub Releases or GitHub Pages. +For release maintainers: `.github/workflows/release-extension.yml` runs `build`, `test-extension`, `publish-release` and `deploy-pages` in sequence. `build` saves the Extension ZIP and repository index; `test-extension` downloads and tests that ZIP. Both jobs cache Blender 5.2, downloading it when the cache is unavailable. Release publication requires successful integration tests. If testing fails, rerun `test-extension` with its dependent jobs to reuse the saved build. If Pages deployment fails after release publication, rerun only `deploy-pages` to reuse the saved Pages artifact. Artifacts are retained for 30 days, subject to repository retention limits. An existing release cannot be overwritten. Manual `workflow_dispatch` with an existing `vX.Y.Z` tag runs build and verification only. + +Set the public library ZIP URLs under **Settings → Secrets and variables → Actions → Variables** as `NODEFORGE_MATH_RELEASE_URL` and `NODEFORGE_LSYSTEM_RELEASE_URL`. Each URL must point to the exact approved ZIP matching the pinned SHA-256 checksum in the workflow. Configure GitHub Pages to use **GitHub Actions** as its build and deployment source. ## Quick start diff --git a/__init__.py b/__init__.py index 0885df7..fc695ff 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 6) +NODEFORGE_VERSION = (0, 72, 7) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index 93f188d..cc6ef37 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.6" +version = "0.72.7" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 4c39b90..44a3d5b 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -525,3 +525,8 @@ lifecycle. ## 0.72.6 - Simplified package-state directory synchronization to handle post-publication fsync errors only in `save_package_state()`, while keeping descriptor cleanup unconditional. + +## 0.72.7 + +- Separate Extension build and Blender integration tests into independently retryable jobs, with Blender 5.2 cached for both jobs. +- Require integration tests to pass before publishing the saved release ZIP and deploying the saved Pages artifact. diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index 4000fab..bacf33c 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -41,7 +41,7 @@ def test_manual_dispatch_runs_verification_without_publication(): content = WORKFLOW.read_text(encoding="utf-8") assert "workflow_dispatch:" in content assert "inputs.release_tag" in content - assert _step(_job("build"), "name: Install and test built Extension in Blender 5.2") + assert _step(_job("test-extension"), "name: Install and test built Extension in Blender 5.2") for name in ("publish-release", "deploy-pages"): assert f"if: {TAG_PUSH_ONLY}" in _job(name) assert "gh release create" not in _job("build") @@ -54,20 +54,32 @@ def test_publishing_jobs_depend_on_one_verified_build(): release = _job("publish-release") pages = _job("deploy-pages") - assert "needs: build" in release + assert "needs: [build, test-extension]" in release + assert "needs: build" in _job("test-extension") assert "needs: [build, publish-release]" in pages assert "contents: write" in release assert "pages: write" in pages and "id-token: write" in pages assert "name: github-pages" in pages - upload = _step(build, "name: Preserve verified release files") + upload = _step(build, "name: Preserve built release files") download = _step(release, "name: Retrieve verified release files") assert "uses: actions/upload-artifact@v4" in upload assert "uses: actions/download-artifact@v4" in download for step in (upload, download): assert "name: nodeforge-release-public" in step assert "path: public/" in step - assert build.index("name: Install and test built Extension in Blender 5.2") < build.index("name: Preserve verified release files") + test = _job("test-extension") + retrieve = _step(test, "name: Retrieve built release files") + assert "uses: actions/download-artifact@v4" in retrieve + assert "name: nodeforge-release-public" in retrieve + assert "path: public/" in retrieve + assert "verify_extension_release.py" in test + assert "verify_extension_release.py" not in build + assert "extension build" not in test + assert "upload-artifact" not in test + assert "upload-pages-artifact" not in test + assert "inputs.release_tag || github.ref" in test + assert test.index("name: Retrieve built release files") < test.index("name: Install and test built Extension") assert "gh release create" in _step(release, "name: Publish GitHub release asset") assert "blender-dist" not in release assert "blender-dist" not in pages @@ -77,7 +89,7 @@ def test_pages_retry_reuses_existing_pages_artifact(): """Deploy retries never re-upload an artifact or retry immutable release creation.""" build = _job("build") pages = _job("deploy-pages") - prepared = _step(build, "name: Preserve verified Pages artifact") + prepared = _step(build, "name: Preserve built Pages artifact") assert f"if: {TAG_PUSH_ONLY}" in prepared assert "uses: actions/upload-pages-artifact@v3" in prepared assert "path: public/" in prepared @@ -161,3 +173,20 @@ def test_release_targets_repository_without_checkout(tmp_path): env["GH_REPO"] = "denikryt/NodeForge" configured = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True) assert configured.returncode == 0 + + +@pytest.mark.parametrize("name", ["build", "test-extension"]) +def test_blender_cache_has_pinned_key_and_download_fallback(name): + """Both fresh runners use the same exact Blender cache and a miss fallback.""" + job = _job(name) + cache = _step(job, "name: Cache Blender 5.2") + install = _step(job, "name: Install Blender 5.2") + assert "uses: actions/cache@v4" in cache + assert "path: blender-dist" in cache + assert "key: blender-${{ runner.os }}-${{ runner.arch }}-5.2.0-linux-x64-v1" in cache + assert "restore-keys:" not in cache + assert "if: steps.blender-cache.outputs.cache-hit != 'true'" in install + assert "set -euo pipefail" in install + assert "Blender5.2/blender-5.2.0-linux-x64.tar.xz" in install + assert "tar -xJf blender.tar.xz -C blender-dist --strip-components=1" in install + assert job.index("name: Cache Blender 5.2") < job.index("name: Install Blender 5.2") From 020abe98011d93082c1cadf65ddc168da6d4c92c Mon Sep 17 00:00:00 2001 From: denikryt Date: Tue, 20 Oct 2026 19:58:41 +0300 Subject: [PATCH 10/13] README update --- README.md | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2381387..c69aed5 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,15 @@ NodeForge is a Blender add-on for describing Geometry Nodes logic in a Python-li ## Installation -1. In Blender 5.2 or newer, open **Edit → Preferences → Get Extensions**. -2. Add the NodeForge remote extension repository under **Repositories → Add Remote Repository**, using the URL published for the release. The intended GitHub Pages URL is `https://denikryt.github.io/NodeForge/index.json` **once deployed**. -3. Install and enable **NodeForge** from **Get Extensions**. Blender manages later add-on updates through the same repository. +NodeForge currently targets Blender 5.2. -NodeForge automatically installs the included **NodeForge Math** and **NodeForge L-System** libraries into its existing user **Packages** directory when first enabled. On a later NodeForge update, it only replaces an included package if the shipped version is newer than the installed version; it never downgrades a separately updated package. No separate package installation or Python permission step is needed. Installation works offline once the extension ZIP is downloaded. +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**. -For users of the legacy ZIP add-on, disable the old NodeForge add-on before enabling the new Extension to avoid duplicate registrations. User Packages and Local files remain in their existing Blender user-data directories. +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. -For release maintainers: `.github/workflows/release-extension.yml` runs `build`, `test-extension`, `publish-release` and `deploy-pages` in sequence. `build` saves the Extension ZIP and repository index; `test-extension` downloads and tests that ZIP. Both jobs cache Blender 5.2, downloading it when the cache is unavailable. Release publication requires successful integration tests. If testing fails, rerun `test-extension` with its dependent jobs to reuse the saved build. If Pages deployment fails after release publication, rerun only `deploy-pages` to reuse the saved Pages artifact. Artifacts are retained for 30 days, subject to repository retention limits. An existing release cannot be overwritten. Manual `workflow_dispatch` with an existing `vX.Y.Z` tag runs build and verification only. - -Set the public library ZIP URLs under **Settings → Secrets and variables → Actions → Variables** as `NODEFORGE_MATH_RELEASE_URL` and `NODEFORGE_LSYSTEM_RELEASE_URL`. Each URL must point to the exact approved ZIP matching the pinned SHA-256 checksum in the workflow. Configure GitHub Pages to use **GitHub Actions** as its build and deployment source. +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 @@ -87,13 +85,9 @@ NodeForge scripts can call reusable functions and can be extended through instal ## Additional libraries -**NodeForge Math** and **NodeForge L-System** are installed automatically from the selected versions included in the NodeForge Extension release. These remain ordinary, independently versioned NodeForge Packages, not Blender Extensions. - -You can install or update any library separately through **NodeForge → Packages → Install ZIP**. A package installed at a newer version than the one included in a future NodeForge release is not overwritten by that older included version. Automatic independent GitHub update checks for individual packages are not currently implemented. - -Package manifests (`nodeforge_package.json`) may include an optional `repository_url` with an HTTP(S) project repository link. NodeForge retains this metadata without displaying it or using it to download updates. +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. -If a library declares `nodeforge_max_version` below the installed NodeForge version, Packages displays a compatibility warning but still permits installation and use. A library requiring a **higher minimum** NodeForge version is rejected. Actual incompatible package code may still fail at compile time or runtime. +Install or update other libraries from **NodeForge → Packages → Install ZIP**. Installed package APIs are imported through the package namespace: From cfcf259099a3396a599a059743248040e798954b Mon Sep 17 00:00:00 2001 From: denikryt Date: Wed, 21 Oct 2026 00:31:52 +0300 Subject: [PATCH 11/13] tests cleanup --- .gitignore | 1 - tests/COVERAGE_MAP.md | 1 - .../benchmark_mesh_compute_erosion_runtime.py | 93 ------------------- .../blender/test_extension_package_install.py | 36 +++---- tests/blender/test_library_catalogs.py | 4 +- .../test_mesh_compute_erosion_example.py | 66 ------------- tests/unit/test_architecture_layers_doc.py | 25 ----- 7 files changed, 22 insertions(+), 204 deletions(-) delete mode 100644 tests/blender/benchmark_mesh_compute_erosion_runtime.py delete mode 100644 tests/blender/test_mesh_compute_erosion_example.py delete mode 100644 tests/unit/test_architecture_layers_doc.py diff --git a/.gitignore b/.gitignore index 4f20a21..c0c7e26 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/tests/COVERAGE_MAP.md b/tests/COVERAGE_MAP.md index 31d3307..2358586 100644 --- a/tests/COVERAGE_MAP.md +++ b/tests/COVERAGE_MAP.md @@ -241,7 +241,6 @@ Package-defined semantic values may persist in source-variable/body state throug - `tests/unit/test_module_boundaries.py`: production import-graph reachability, module-scope acyclicity, `semantic/` and `extensions/` isolation from Blender realization, one-way catalog/Local ownership, and relative-import integrity. The tests enforce dependency directions rather than an exhaustive internal module inventory. - `tests/unit/test_module_layout_contract.py`: a small filesystem contract for public/cross-phase anchors, the three internal owner packages, and retired mixed ownership namespaces. Internal implementation filenames are intentionally not snapshotted. -- `tests/unit/test_architecture_layers_doc.py`: developer architecture reference names the same three internal owner packages and Local/catalog/import-graph boundaries enforced by executable architecture tests. ## Local directory namespaces and canonical source ownership diff --git a/tests/blender/benchmark_mesh_compute_erosion_runtime.py b/tests/blender/benchmark_mesh_compute_erosion_runtime.py deleted file mode 100644 index ecc13da..0000000 --- a/tests/blender/benchmark_mesh_compute_erosion_runtime.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Opt-in benchmark: actual Local NumPy erosion through NodeForge's live GN modifier. - -Run one configuration per fresh Blender process, for example: - blender -b --factory-startup --python tests/blender/benchmark_mesh_compute_erosion_runtime.py -- \ - --side 96 --steps 100 --output /tmp/erosion-96-100.json - -Includes Local compilation, source prefix, callback, numerical/publication cache, -and full evaluated GN consumer output. Uses the minimal source terrain without -external nodeforge.math, but the Python hydraulic+thermal equations are those -ported from hydraulic_grid_shape_cpu_fast.nf. -""" - -from argparse import ArgumentParser -from pathlib import Path -from time import perf_counter -from array import array -import json -import shutil -import sys -import tempfile - -import bpy -import numpy as np - -sys.path.insert(0,str(Path(__file__).resolve().parents[3])) -from NodeForge import compiler,local_sources -from NodeForge.blender import mesh_compute_runtime as runtime - -EXAMPLES=Path(__file__).resolve().parents[2]/'examples/mesh_compute_erosion' - - -def run(side,steps): - """Compile and evaluate a real private root, checking output and cache hit.""" - with tempfile.TemporaryDirectory(prefix='nodeforge-erosion-bench-') as tmp: - root=Path(tmp)/'Local';root.mkdir() - shutil.copy2(EXAMPLES/'erosion.py',root/'erosion.py') - old_catalog=local_sources._default_local_catalog_dir - old_registry=local_sources._local_sources_registry_path - local_sources._default_local_catalog_dir=lambda: root - local_sources._local_sources_registry_path=lambda: Path(tmp)/'registry.json' - try: - - mesh=bpy.data.meshes.new('BenchmarkInput') - mesh.from_pydata([(0,0,0),(1,0,0),(0,1,0)],[],[(0,1,2)]) - owner=bpy.data.objects.new('BenchmarkObject',mesh) - bpy.context.scene.collection.objects.link(owner) - source=(EXAMPLES/'erosion_numpy_smoke.nf').read_text() - source=source.replace('default=8',f'default={side}').replace('default=2',f'default={steps}') - t0=perf_counter();compiler.create_mesh_compute_modifier(source,owner);t1=perf_counter() - published=runtime.evaluate_modifier_binding(owner);t2=perf_counter() - assert published is not None - binding=runtime.get_modifier_binding(owner) - assert binding is not None and binding.status=='READY' - bpy.context.view_layer.update() - dg=bpy.context.evaluated_depsgraph_get();evalobj=owner.evaluated_get(dg) - final=evalobj.to_mesh(preserve_all_data_layers=True,depsgraph=dg) - try: - if len(final.vertices)!=side*side:raise AssertionError('output vertex count mismatch') - for attr in ('height','water','sediment'): - layer=final.attributes[attr];values=array('f',[0])*len(final.vertices) - layer.data.foreach_get('value',values) - if not np.isfinite(np.frombuffer(values,dtype=np.float32)).all(): - raise AssertionError(f'nonfinite {attr}') - finally:evalobj.to_mesh_clear() - t3=perf_counter() - old_group=binding.group.as_pointer();old_result=binding.prepared.result_proxy.data.as_pointer() - runtime.evaluate_modifier_binding(owner,skip_unchanged=True) - t4=perf_counter() - if binding.group.as_pointer()!=old_group or binding.prepared.result_proxy.data.as_pointer()!=old_result: - raise AssertionError('unchanged input caused unintended group/Mesh republication') - return {'side':side,'steps':steps,'compile_ms':round(1000*(t1-t0),3), - 'first_runtime_ms':round(1000*(t2-t1),3), - 'consumer_snapshot_ms':round(1000*(t3-t2),3), - 'unchanged_cache_check_ms':round(1000*(t4-t3),3), - 'vertices':side*side,'status':binding.status} - finally: - local_sources._default_local_catalog_dir=old_catalog - local_sources._local_sources_registry_path=old_registry - - -def main(): - """Parse one configuration and persist its runtime measurement.""" - parser=ArgumentParser();parser.add_argument('--side',type=int,default=96) - parser.add_argument('--steps',type=int,default=100) - parser.add_argument('--output',type=Path) - args=parser.parse_args(sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else []) - if args.side<3 or args.steps<0:parser.error('side >= 3, steps >= 0') - result=run(args.side,args.steps) - print('EROSION_RUNTIME',json.dumps(result),flush=True) - if args.output:args.output.write_text(json.dumps(result,indent=2)) - - -if __name__=='__main__':main() diff --git a/tests/blender/test_extension_package_install.py b/tests/blender/test_extension_package_install.py index 6c258ba..7de95ce 100644 --- a/tests/blender/test_extension_package_install.py +++ b/tests/blender/test_extension_package_install.py @@ -28,13 +28,12 @@ def test_real_extension_provides_installed_packages_and_compiles_examples(): packages = import_module(extension.__name__ + ".packages") compiler = import_module(extension.__name__ + ".compiler") assert extension.__name__.startswith("bl_ext.") - expected = {"nodeforge.math": ("2.2", "noise_terrain.nf"), - "nodeforge.lsystem": ("4.0", "koch_curve.nf")} + examples = {"nodeforge.math": "noise_terrain.nf", + "nodeforge.lsystem": "koch_curve.nf"} active = {item.manifest.package_id: item.manifest for item in packages.active_package_records()} - assert set(expected).issubset(active) - for package_id, (version, example_name) in expected.items(): + assert set(examples).issubset(active) + for package_id, example_name in examples.items(): manifest = active[package_id] - assert manifest.version == version assert manifest.root.is_dir() script = (manifest.root / "examples" / example_name).read_text(encoding="utf-8") group = compiler.create_expression_group(script, name="NFSuite_" + package_id) @@ -52,15 +51,14 @@ def test_extension_packages_do_not_require_user_consent(): records = packages.active_package_records() assert {r.manifest.package_id for r in records} >= {"nodeforge.math", "nodeforge.lsystem"} assert all("allow_python" not in r.state_record for r in records) - assert all(packages.package_compatibility_warning(r.manifest) for r in records) -def test_browser_lists_populate_after_extension_activation(): - """Startup refresh exposes newly installed library content without manual Refresh.""" +def test_browser_lists_populate_when_startup_refresh_runs(): + """The startup refresh callback populates the catalogs in background Blender.""" extension = _installed_extension() ui = importlib.import_module(extension.__name__ + ".ui") - assert bpy.app.timers.is_registered(ui._refresh_startup_browser_items) - # A background Blender pytest run has no GUI event loop to tick timers. + # Tests may already have consumed the one-shot timer. Blender's headless + # pytest runner cannot guarantee when a UI event-loop callback is scheduled. assert ui._refresh_startup_browser_items() is None props = bpy.context.scene.gn_script_mvp assert {item.package_id for item in props.package_items} >= { @@ -99,22 +97,28 @@ def test_z_reenable_upgrades_an_older_included_package(tmp_path): extension = _installed_extension() packages = importlib.import_module(extension.__name__ + ".packages") - math_zip = next((Path(extension.__file__).parent / "package_install").glob("nodeforge.math-*.zip")) + included_math = sorted((Path(extension.__file__).parent / "package_install").glob("nodeforge.math-*.zip")) + if not included_math: + pytest.skip("Extension distribution does not bundle a Math package ZIP") + math_zip = included_math[0] old_zip = tmp_path / "math-older.zip" + active_before = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} with zipfile.ZipFile(math_zip) as source, zipfile.ZipFile(old_zip, "w") as old: for entry in source.infolist(): payload = source.read(entry.filename) if entry.filename.endswith("nodeforge_package.json"): manifest = json.loads(payload) assert manifest["id"] == "nodeforge.math" - manifest["version"] = "2.1" + included_version = manifest["version"] + # Version is test input for the upgrade flow, not a pinned release. + manifest["version"] = "0.0.1" payload = json.dumps(manifest).encode("utf-8") old.writestr(entry, payload) packages.install_package_zip(old_zip, replace=True) current = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} - assert current["nodeforge.math"] == "2.1" - assert current["nodeforge.lsystem"] == "4.0" + assert current["nodeforge.math"] == "0.0.1" + assert current["nodeforge.lsystem"] == active_before["nodeforge.lsystem"] bpy.ops.preferences.addon_disable(module=extension.__name__) assert extension.__name__ not in bpy.context.preferences.addons @@ -122,8 +126,8 @@ def test_z_reenable_upgrades_an_older_included_package(tmp_path): assert extension.__name__ in bpy.context.preferences.addons packages = importlib.import_module(extension.__name__ + ".packages") updated = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} - assert updated["nodeforge.math"] == "2.2" - assert updated["nodeforge.lsystem"] == "4.0" + assert updated["nodeforge.math"] == included_version + assert updated["nodeforge.lsystem"] == active_before["nodeforge.lsystem"] ui = importlib.import_module(extension.__name__ + ".ui") assert ui._refresh_startup_browser_items() is None props = bpy.context.scene.gn_script_mvp diff --git a/tests/blender/test_library_catalogs.py b/tests/blender/test_library_catalogs.py index c4e83ca..4bff03c 100644 --- a/tests/blender/test_library_catalogs.py +++ b/tests/blender/test_library_catalogs.py @@ -136,7 +136,7 @@ def test_prepared_root_resolves_once_for_nested_local_dependency(monkeypatch): local = local_sources.ensure_local_catalog_dir() leaf = local / "direct_environment_leaf.nf" parent = local / "direct_environment_parent.nf" - real_resolve = compiler.resolve_environment + real_resolve = compiler._make_compilation_environment resolve_calls = [] def counted_resolve(): @@ -152,7 +152,7 @@ def counted_resolve(): 'y = direct_environment_leaf(x)\n' 'output("y", y)\n', ) - monkeypatch.setattr(compiler, "resolve_environment", counted_resolve) + monkeypatch.setattr(compiler, "_make_compilation_environment", counted_resolve) group = compiler.create_expression_group( "from local import direct_environment_parent\n" "value = direct_environment_parent(2.0)\n" diff --git a/tests/blender/test_mesh_compute_erosion_example.py b/tests/blender/test_mesh_compute_erosion_example.py deleted file mode 100644 index 6287435..0000000 --- a/tests/blender/test_mesh_compute_erosion_example.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Real Blender 5.2 GN-prefix → Local NumPy erosion → native GN suffix tests.""" - -from array import array -from pathlib import Path -import shutil - -import bpy -import numpy as np -import pytest - -from NodeForge.compiler import create_mesh_compute_modifier -from NodeForge.blender import mesh_compute_runtime as runtime -from NodeForge.tests.blender.test_mesh_compute_modifier_binding import configured_local, mesh_object - -pytestmark = pytest.mark.blender -EXAMPLES = Path(__file__).resolve().parents[2] / 'examples/mesh_compute_erosion' - - -def _evaluate_layers(owner): - """Read only the evaluated output Mesh, never the source or proxy ID.""" - bpy.context.view_layer.update() - depsgraph=bpy.context.evaluated_depsgraph_get() - evaluated=owner.evaluated_get(depsgraph) - mesh=evaluated.to_mesh(preserve_all_data_layers=True,depsgraph=depsgraph) - try: - count=len(mesh.vertices) - output={} - for name in ('height','water','sediment','initial_height'): - values=array('f',[0])*count - mesh.attributes[name].data.foreach_get('value',values) - output[name]=np.frombuffer(values,dtype=np.float32).copy() - positions=array('f',[0])*(3*count) - mesh.attributes['position'].data.foreach_get('vector',positions) - output['xyz']=np.frombuffer(positions,dtype=np.float32).reshape(-1,3).copy() - return output - finally: - evaluated.to_mesh_clear() - - -def test_numpy_erosion_example_compiles_from_local_to_real_gn(configured_local,mesh_object): - """Actual selected Local Python executes, and GN suffix applies height to Z.""" - shutil.copy2(EXAMPLES/'erosion.py',configured_local/'erosion.py') - source=(EXAMPLES/'erosion_numpy_smoke.nf').read_text() - group=create_mesh_compute_modifier(source,mesh_object) - result=runtime.evaluate_modifier_binding(mesh_object) - assert result is not None - assert runtime.get_modifier_binding(mesh_object).status=='READY' - values=_evaluate_layers(mesh_object) - assert values['height'].shape==(64,) - assert np.all(np.isfinite(values['height'])) - np.testing.assert_allclose(values['xyz'][:,2],values['height'],atol=1e-5,rtol=0) - np.testing.assert_allclose(values['initial_height'],2.+.07*values['xyz'][:,0]+.11*values['xyz'][:,1],atol=1e-5,rtol=0) - assert np.any(values['water']>0.) - assert np.all(values['sediment']>=0.) - - -def test_erosion_steps_zero_refreshes_named_outputs(configured_local,mesh_object): - """Zero-step update keeps original height and clears simulation arrays.""" - shutil.copy2(EXAMPLES/'erosion.py',configured_local/'erosion.py') - source=(EXAMPLES/'erosion_numpy_smoke.nf').read_text().replace('default=2','default=0') - create_mesh_compute_modifier(source,mesh_object) - runtime.evaluate_modifier_binding(mesh_object) - result=_evaluate_layers(mesh_object) - np.testing.assert_allclose(result['height'],result['initial_height'],atol=1e-6,rtol=0) - assert not np.any(result['water']) - assert not np.any(result['sediment']) diff --git a/tests/unit/test_architecture_layers_doc.py b/tests/unit/test_architecture_layers_doc.py deleted file mode 100644 index 693c876..0000000 --- a/tests/unit/test_architecture_layers_doc.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Keep the developer architecture note aligned with executable ownership boundaries.""" - -from pathlib import Path - -PACKAGE_ROOT = Path(__file__).resolve().parents[2] -DOC = PACKAGE_ROOT / "dev" / "ARCHITECTURE_LAYERS.md" - - -def test_architecture_layers_doc_names_current_owner_packages_and_boundaries(): - """The architecture note documents the dependency boundaries enforced by tests.""" - text = DOC.read_text(encoding="utf-8") - assert "# Architecture Layers" in text - assert "`semantic/` is Blender-independent" in text - assert "`extensions/` owns the internal Extension API v2" in text - assert "`blender/` owns physical realization" in text - assert "`catalog.py` never imports `local_sources.py`" in text - assert "production module-scope import graph must remain acyclic" in text - assert "Internal filenames are not architectural contracts" in text - - -def test_architecture_layers_doc_does_not_present_retired_namespaces_as_current_owners(): - """Retired mixed namespaces are not described as current implementation owners.""" - text = DOC.read_text(encoding="utf-8") - assert "builtins/registry.py" not in text - assert "systems/registry.py" not in text From eb9d25000b7673f115979ca7ec2b529896335aff Mon Sep 17 00:00:00 2001 From: denikryt Date: Wed, 21 Oct 2026 03:45:58 +0300 Subject: [PATCH 12/13] ci: validate extension releases on pull requests --- .github/workflows/release-extension.yml | 8 +++++++- tests/unit/test_release_workflow_publication.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 743a0af..acbf2a8 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -3,6 +3,8 @@ name: Build Blender Extension on: push: tags: ["v*"] + pull_request: + branches: [main] workflow_dispatch: inputs: release_tag: @@ -36,7 +38,11 @@ jobs: 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' - assert os.environ['TAG_NAME'] == 'v' + declared, 'tag / 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 pinned library releases env: diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index bacf33c..dc0cd36 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -48,6 +48,16 @@ def test_manual_dispatch_runs_verification_without_publication(): assert "actions/deploy-pages" not in _job("build") +def test_pull_requests_build_and_test_without_publication(): + """Pull requests targeting main validate the merged Extension without publishing it.""" + content = WORKFLOW.read_text(encoding="utf-8") + assert "pull_request:\n branches: [main]" in content + assert _step(_job("test-extension"), "name: Install and test built Extension in Blender 5.2") + assert "if tag_name.startswith('v'):" in _step(_job("build"), "name: Verify NodeForge version") + for name in ("publish-release", "deploy-pages"): + assert f"if: {TAG_PUSH_ONLY}" in _job(name) + + def test_publishing_jobs_depend_on_one_verified_build(): """The release and Pages deployment reuse the validated build, not rebuilt ZIPs.""" build = _job("build") From eb37e880ddf13a23b9d32486dd0938834d0451b8 Mon Sep 17 00:00:00 2001 From: denikryt Date: Wed, 21 Oct 2026 18:00:28 +0300 Subject: [PATCH 13/13] ci: bundle latest library releases via central registry --- .github/workflows/release-extension.yml | 66 ++++++++++++------- README.md | 4 ++ bundled_packages.py | 6 ++ dev/CHANGELOG.md | 2 + .../blender/test_extension_package_install.py | 53 +++++++++------ .../unit/test_release_workflow_publication.py | 16 +++++ 6 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 bundled_packages.py diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index acbf2a8..ba67c81 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -44,23 +44,23 @@ jobs: if tag_name.startswith('v'): assert tag_name == 'v' + declared, 'tag / manifest mismatch' PY - - name: Download pinned library releases + - name: Download latest library releases env: - MATH_URL: ${{ vars.NODEFORGE_MATH_RELEASE_URL }} - LSYSTEM_URL: ${{ vars.NODEFORGE_LSYSTEM_RELEASE_URL }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - test -n "$MATH_URL" && test -n "$LSYSTEM_URL" || { - echo 'Publish the approved library ZIPs and set their version-specific release URLs first.' >&2 - exit 1 - } mkdir -p staging/package_install - curl --fail --location --retry 3 "$MATH_URL" -o staging/package_install/nodeforge.math-v2.2_nf-v0.70.2.zip - curl --fail --location --retry 3 "$LSYSTEM_URL" -o staging/package_install/nodeforge.lsystem-v4.0_nf-v0.70.2.zip - (cd staging/package_install && sha256sum --check -) <<'SHA256' - c0985b6c5b5499b517d5f2cc175c031efd3b88c312d0729b601b039ee2adb80e nodeforge.math-v2.2_nf-v0.70.2.zip - 9dcde6ef68352c4ea91fe8f732f0e9fa8a9745f78a7d321c0e3aecfdf6c09e31 nodeforge.lsystem-v4.0_nf-v0.70.2.zip - SHA256 + 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 @@ -71,17 +71,25 @@ jobs: find staging -name __pycache__ -type d -prune -exec rm -rf {} + python3 - <<'PY' from pathlib import Path - import json, zipfile - expected = {'nodeforge.math': '2.2', 'nodeforge.lsystem': '4.0'} + 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 = {} - for archive in sorted(Path('staging/package_install').glob('*.zip')): + 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])) - assert (manifest['id'], manifest['version']) == (manifest['id'], expected[manifest['id']]) - assert manifest['id'] not in seen - seen[manifest['id']] = archive.name + 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 @@ -105,14 +113,28 @@ jobs: mkdir -p public "$BLENDER" -b --command extension build --source-dir staging --output-dir public python3 - <<'PY' - import pathlib, zipfile + 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 - assert 'package_install/nodeforge.math-v2.2_nf-v0.70.2.zip' in files - assert 'package_install/nodeforge.lsystem-v4.0_nf-v0.70.2.zip' 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 diff --git a/README.md b/README.md index c69aed5..ffce4fb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ NodeForge currently targets Blender 5.2. 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 @@ -87,6 +89,8 @@ NodeForge scripts can call reusable functions and can be extended through instal 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 `-v_nf-v.zip` in that repository. The package manifest supplies the version and minimum compatible NodeForge version. + Install or update other libraries from **NodeForge → Packages → Install ZIP**. Installed package APIs are imported through the package namespace: diff --git a/bundled_packages.py b/bundled_packages.py new file mode 100644 index 0000000..1afe948 --- /dev/null +++ b/bundled_packages.py @@ -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", +} diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index 44a3d5b..bdf3009 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -530,3 +530,5 @@ lifecycle. - Separate Extension build and Blender integration tests into independently retryable jobs, with Blender 5.2 cached for both jobs. - Require integration tests to pass before publishing the saved release ZIP and deploying the saved Pages artifact. +- Bundle the latest published Math and L-System package ZIPs at release build time, using each package's minimum NodeForge version for compatibility checks. +- Centralize bundled-library IDs and Release repositories in one registry shared by the Extension workflow and tests. diff --git a/tests/blender/test_extension_package_install.py b/tests/blender/test_extension_package_install.py index 7de95ce..0fbaf1c 100644 --- a/tests/blender/test_extension_package_install.py +++ b/tests/blender/test_extension_package_install.py @@ -9,6 +9,11 @@ import bpy import pytest +SMOKE_EXAMPLES = { + "nodeforge.math": "noise_terrain.nf", + "nodeforge.lsystem": "koch_curve.nf", +} + def _installed_extension(): """Find the Blender-managed installation, or skip regular legacy test sessions.""" @@ -26,10 +31,11 @@ def test_real_extension_provides_installed_packages_and_compiles_examples(): from importlib import import_module packages = import_module(extension.__name__ + ".packages") + bundled = import_module(extension.__name__ + ".bundled_packages") compiler = import_module(extension.__name__ + ".compiler") assert extension.__name__.startswith("bl_ext.") - examples = {"nodeforge.math": "noise_terrain.nf", - "nodeforge.lsystem": "koch_curve.nf"} + examples = SMOKE_EXAMPLES + assert set(examples) == set(bundled.BUNDLED_PACKAGES) active = {item.manifest.package_id: item.manifest for item in packages.active_package_records()} assert set(examples).issubset(active) for package_id, example_name in examples.items(): @@ -48,8 +54,9 @@ def test_extension_packages_do_not_require_user_consent(): """Existing package ZIPs are active without a separate Python grant.""" extension = _installed_extension() packages = importlib.import_module(extension.__name__ + ".packages") + bundled = importlib.import_module(extension.__name__ + ".bundled_packages") records = packages.active_package_records() - assert {r.manifest.package_id for r in records} >= {"nodeforge.math", "nodeforge.lsystem"} + assert {r.manifest.package_id for r in records} >= set(bundled.BUNDLED_PACKAGES) assert all("allow_python" not in r.state_record for r in records) @@ -57,15 +64,17 @@ def test_browser_lists_populate_when_startup_refresh_runs(): """The startup refresh callback populates the catalogs in background Blender.""" extension = _installed_extension() ui = importlib.import_module(extension.__name__ + ".ui") + bundled = importlib.import_module(extension.__name__ + ".bundled_packages") # Tests may already have consumed the one-shot timer. Blender's headless # pytest runner cannot guarantee when a UI event-loop callback is scheduled. assert ui._refresh_startup_browser_items() is None props = bpy.context.scene.gn_script_mvp - assert {item.package_id for item in props.package_items} >= { - "nodeforge.math", "nodeforge.lsystem" - } - assert any(item.name == "noise_terrain" for item in props.example_items) - assert any(item.name == "koch_curve" for item in props.example_items) + assert {item.package_id for item in props.package_items} >= set(bundled.BUNDLED_PACKAGES) + for example_name in SMOKE_EXAMPLES.values(): + assert any( + item.name == Path(example_name).stem + for item in props.example_items + ) assert len(props.function_items) > 0 @@ -73,6 +82,7 @@ def test_startup_catalog_error_does_not_block_other_lists(monkeypatch, capsys): """A failed Functions catalog cannot prevent Examples and Packages refreshing.""" extension = _installed_extension() ui = importlib.import_module(extension.__name__ + ".ui") + bundled = importlib.import_module(extension.__name__ + ".bundled_packages") original = ui._refresh_catalog_items props = bpy.context.scene.gn_script_mvp props.example_items.clear() @@ -86,8 +96,9 @@ def fail_functions(scene_props, namespace): monkeypatch.setattr(ui, "_refresh_catalog_items", fail_functions) assert ui._refresh_startup_browser_items() is None assert "simulated Functions failure" in capsys.readouterr().out - assert any(item.name == "noise_terrain" for item in props.example_items) - assert len(props.package_items) >= 2 + example_names = {Path(example).stem for example in SMOKE_EXAMPLES.values()} + assert {item.name for item in props.example_items} >= example_names + assert len(props.package_items) >= len(bundled.BUNDLED_PACKAGES) def test_z_reenable_upgrades_an_older_included_package(tmp_path): @@ -97,18 +108,18 @@ def test_z_reenable_upgrades_an_older_included_package(tmp_path): extension = _installed_extension() packages = importlib.import_module(extension.__name__ + ".packages") - included_math = sorted((Path(extension.__file__).parent / "package_install").glob("nodeforge.math-*.zip")) - if not included_math: - pytest.skip("Extension distribution does not bundle a Math package ZIP") - math_zip = included_math[0] - old_zip = tmp_path / "math-older.zip" + bundled = importlib.import_module(extension.__name__ + ".bundled_packages") + package_id = next(iter(bundled.BUNDLED_PACKAGES)) + included = sorted((Path(extension.__file__).parent / "package_install").glob(f"{package_id}-*.zip")) + assert len(included) == 1 + old_zip = tmp_path / "older-package.zip" active_before = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} - with zipfile.ZipFile(math_zip) as source, zipfile.ZipFile(old_zip, "w") as old: + with zipfile.ZipFile(included[0]) as source, zipfile.ZipFile(old_zip, "w") as old: for entry in source.infolist(): payload = source.read(entry.filename) if entry.filename.endswith("nodeforge_package.json"): manifest = json.loads(payload) - assert manifest["id"] == "nodeforge.math" + assert manifest["id"] == package_id included_version = manifest["version"] # Version is test input for the upgrade flow, not a pinned release. manifest["version"] = "0.0.1" @@ -117,8 +128,8 @@ def test_z_reenable_upgrades_an_older_included_package(tmp_path): packages.install_package_zip(old_zip, replace=True) current = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} - assert current["nodeforge.math"] == "0.0.1" - assert current["nodeforge.lsystem"] == active_before["nodeforge.lsystem"] + assert current[package_id] == "0.0.1" + assert all(current[other] == version for other, version in active_before.items() if other != package_id) bpy.ops.preferences.addon_disable(module=extension.__name__) assert extension.__name__ not in bpy.context.preferences.addons @@ -126,8 +137,8 @@ def test_z_reenable_upgrades_an_older_included_package(tmp_path): assert extension.__name__ in bpy.context.preferences.addons packages = importlib.import_module(extension.__name__ + ".packages") updated = {r.manifest.package_id: r.manifest.version for r in packages.active_package_records()} - assert updated["nodeforge.math"] == included_version - assert updated["nodeforge.lsystem"] == active_before["nodeforge.lsystem"] + assert updated[package_id] == included_version + assert all(updated[other] == version for other, version in active_before.items() if other != package_id) ui = importlib.import_module(extension.__name__ + ".ui") assert ui._refresh_startup_browser_items() is None props = bpy.context.scene.gn_script_mvp diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index dc0cd36..6c1d9ca 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -58,6 +58,22 @@ def test_pull_requests_build_and_test_without_publication(): assert f"if: {TAG_PUSH_ONLY}" in _job(name) +def test_library_release_assets_are_downloaded_from_latest_releases(): + """The build uses the bundled-package registry for release assets and validation.""" + download = _step(_job("build"), "name: Download latest library releases") + assert "from bundled_packages import BUNDLED_PACKAGES" in download + assert "for package_id, repository in BUNDLED_PACKAGES.items():" in download + assert "'--repo', repository" in download + assert "f'{package_id}-v*_nf-v*.zip'" in download + stage = _step(_job("build"), "name: Stage extension contents") + assert "expected = set(BUNDLED_PACKAGES)" in stage + assert "manifest['nodeforge_min_version']" in stage + assert "minimum.split('.')" in stage + assert "assert set(seen) == set(expected)" in stage + build = _step(_job("build"), "name: Validate and build") + assert "len(staged_archives) == len(BUNDLED_PACKAGES)" in build + + def test_publishing_jobs_depend_on_one_verified_build(): """The release and Pages deployment reuse the validated build, not rebuilt ZIPs.""" build = _job("build")