Skip to content

build(bazel): model PyO3/napi cdylibs and packaging handoff (#7) - #422

Merged
DecisionNerd merged 3 commits into
mainfrom
build/7-bindings-cdylib-packaging
Aug 6, 2026
Merged

DecisionNerd merged 3 commits into
mainfrom
build/7-bindings-cdylib-packaging

Conversation

@DecisionNerd

@DecisionNerd DecisionNerd commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Model PyO3 and napi-rs cdylibs under rules_rust (rust_shared_library) plus the CLI lib/build.rs link dependency required to compile them.
  • Add packaging handoff (//:python_wheel_smoke, //:node_package_smoke) that assembles CI smoke packages from Bazel-built natives without invoking maturin build / napi build / cargo recompile.
  • Update migration ledger + bootstrap docs; wire Blacksmith Bazel Bootstrap to build binding cdylibs and run packaging unit tests.

Test plan

  • bazelisk build //crates/graphforge-cli:graphforge_cli
  • bazelisk build //:binding_cdylibs //:python_wheel_smoke //:node_package_smoke
  • python3 scripts/ci/test-assemble-bazel-binding-packages.py
  • scripts/ci/test-classify-changes.sh
  • python3 scripts/ci/cargo-bazel-drift-check.py
  • cargo check -p graphforge-cli --lib
  • Blacksmith Bazel Bootstrap + CI Gate green on exact head SHA

Fixes #7

Made with Cursor


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Added Bazel support for building Python and Node.js native bindings.
    • Added Bazel targets to package Python wheels and Node.js archives.
    • Added public build and smoke-test targets for binding packages.
    • Added support for bundling project skills in Bazel builds.
  • Bug Fixes

    • Improved build-script handling for Bazel-provided project metadata.
  • Tests

    • Added coverage for package creation, embedded native artifacts, validation, and change classification.

Note

Add Bazel targets for PyO3/napi cdylibs and packaging handoff scripts

  • Adds gf_rust_shared_library and gf_cargo_build_script macros in gf_rust.bzl for consistent cdylib and build-script definitions across crates.
  • Adds Bazel rust_shared_library targets for the Python (PyO3) and Node (napi) binding crates, with platform-specific linker flags for macOS dynamic lookup on the Python side.
  • Introduces assemble_bazel_binding_packages.py, which assembles a Python wheel and Node package zip from Bazel-built cdylibs without recompiling; rejects any invocation containing recompile-like arguments (maturin, napi, cargo).
  • Wires python_wheel_smoke and node_package_smoke genrules in tools/bazel/bindings/BUILD.bazel to produce smoke packages and evidence JSON from Bazel outputs.
  • Updates crates/graphforge-cli/build.rs to support a GRAPHFORGE_PROJECT_SKILLS_MANIFEST env var for Bazel runfiles and copies skill files into OUT_DIR for embedding.

Macroscope summarized f83559a.

Wire rust_shared_library targets for Python and Node bindings, map the CLI
lib as a link dependency, and assemble CI smoke packages from Bazel natives
without maturin/napi recompile.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added core Core source code changes documentation Improvements or additions to documentation ci-cd CI/CD configuration changes tooling Developer tooling and automation labels Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds Bazel targets for PyO3 and napi-rs cdylibs, updates project-skills embedding for Bazel build scripts, introduces Bazel-only Python and Node package assembly, and adds smoke and classification tests.

Changes

Bazel binding build graph

Layer / File(s) Summary
Rust Bazel targets
tools/bazel/gf_rust.bzl, crates/graphforge-cli/BUILD.bazel, crates/graphforge-bindings-node/BUILD.bazel, crates/graphforge-bindings-py/BUILD.bazel
Adds Cargo build-script and Rust shared-library macros. Defines CLI, Python, and Node binding targets with their dependencies and package sources.
Project-skills Bazel embedding
project-skills/BUILD.bazel, crates/graphforge-cli/build.rs
Adds a public project-skills bundle. The CLI build script resolves the Bazel manifest, copies files into OUT_DIR, and generates relative embedded paths.
Package assembly tool
scripts/ci/BUILD.bazel, scripts/ci/assemble_bazel_binding_packages.py
Adds Bazel-only Python wheel and Node package assembly with validation, native artifact hashing, platform naming, metadata, evidence, and recompilation checks.
Smoke validation and public wiring
tools/bazel/bindings/BUILD.bazel, BUILD.bazel, MODULE.bazel, scripts/ci/classify-changes.sh, scripts/ci/test-assemble-bazel-binding-packages.py, scripts/ci/test-classify-changes.sh
Adds binding build and package smoke targets, root aliases, module documentation, packaging tests, and Bazel change-classification cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels: testing

Sequence Diagram(s)

sequenceDiagram
  participant Bazel
  participant BindingCdylibs
  participant PackageAssembler
  participant SmokeArtifacts
  Bazel->>BindingCdylibs: Build Python and Node cdylibs
  BindingCdylibs->>PackageAssembler: Pass native libraries and package sources
  PackageAssembler->>SmokeArtifacts: Create wheel and Node archive
  PackageAssembler->>SmokeArtifacts: Write native evidence
Loading
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description explains the main changes and test plan, but it omits most required template sections and leaves exact-head CI validation pending. Add the required template sections, mark applicable change and checklist items, document performance and breaking-change status, and report final CI results when available.
Linked Issues check ❓ Inconclusive The core Bazel cdylib and packaging criteria are supported, but workflow provenance and exact-head CI requirements cannot be verified because .github/workflows/test.yml was excluded. Review .github/workflows/test.yml or provide evidence that provenance requirements and exact-head Bazel Bootstrap and CI Gate checks are satisfied.
✅ Passed checks (2 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The reviewed changes support Bazel cdylib modeling, packaging handoff, tests, and build integration for issue #7 without introducing unrelated mobile bindings.
Title check ✅ Passed The title clearly identifies the main change: Bazel modeling for PyO3 and napi-rs cdylibs and packaging handoff.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch build/7-bindings-cdylib-packaging

Comment @coderabbitai help to get the list of available commands.

@blacksmith-sh

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
scripts/ci/assemble_bazel_binding_packages.py (2)

34-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate _die as NoReturn.

_die always raises SystemExit, but the annotation says -> None. Type checkers then treat line 77 in _napi_platform_tag and line 85 in _read_version as paths that fall through and return None, which conflicts with their declared -> str. Change the annotation so callers narrow correctly.

♻️ Proposed annotation fix
-from pathlib import Path
+from pathlib import Path
+from typing import NoReturn
-def _die(message: str, code: int = 2) -> None:
+def _die(message: str, code: int = 2) -> NoReturn:
     print(f"assemble_bazel_binding_packages: {message}", file=sys.stderr)
     raise SystemExit(code)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/assemble_bazel_binding_packages.py` around lines 34 - 36, Update
the return annotation of _die to NoReturn and import NoReturn from the
appropriate typing module, so type checkers recognize every call as
non-returning and preserve the declared str return paths in _napi_platform_tag
and _read_version.

61-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider deriving the platform tag from the Bazel target platform.

_napi_platform_tag reads the host platform through platform.system() and platform.machine(). The wheel tag selection at lines 126-137 uses the same host values. Inside a Bazel action, the host is the executor, not necessarily the target platform of the rust_shared_library. Under remote execution or cross-compilation, the addon name and the wheel tag can disagree with the cdylib bytes that were actually packaged. Nothing in the script cross-checks the tag against native.

The PR objectives defer the cross-platform matrix to #6, and the comment at lines 124-125 records the scope. So this is acceptable for the smoke path. When #6 lands, pass the target triple in as an explicit flag instead of detecting it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/assemble_bazel_binding_packages.py` around lines 61 - 77, Keep the
current host-platform detection in _napi_platform_tag and the wheel tag
selection unchanged for this smoke-path scope. Do not add Bazel native-platform
derivation or cross-compilation handling; when cross-platform support is
implemented in `#6`, replace host detection with an explicit target-triple flag.
scripts/ci/test-assemble-bazel-binding-packages.py (2)

51-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Add coverage through the Bazel genrule surface.

These tests call assemble_python and assemble_node directly. They prove the Python functions behave correctly. They do not exercise the genrule shell in tools/bazel/bindings/BUILD.bazel, which selects the native artifact at lines 26 and 50, derives PKG_ROOT at lines 28 and 52, and passes --write-evidence. Every defect in that shell layer stays invisible to this suite.

The build_test at tools/bazel/bindings/BUILD.bazel lines 7-13 covers only the cdylib targets, not the packaging genrules. Add a Bazel test that builds //:python_wheel_smoke and //:node_package_smoke and asserts the resulting artifacts, including the evidence JSON once it is a declared output.

As per coding guidelines: "Logical plans and wrapper tests are not sufficient end-to-end proof; validate behavior through the real execution surface when required."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/test-assemble-bazel-binding-packages.py` around lines 51 - 93, Add
a Bazel-level test targeting the packaging genrules `//:python_wheel_smoke` and
`//:node_package_smoke`, rather than only invoking `assemble_python` and
`assemble_node` directly. Declare the generated evidence JSON as an output where
necessary, then inspect both produced archives and verify their native payloads,
required package files, and `recompiled` evidence through the actual genrule
execution path.

Source: Coding guidelines


23-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the no-recompile guard tests.

These two tests do not pin down the central safety property of this PR.

test_main_refuses_recompile_looking_argv asserts only that the exit code is 2. argparse also exits with code 2 for unrecognized arguments. The trailing maturin build tokens at lines 45-46 are unrecognized positionals. The test therefore passes even if _assert_no_recompile_args is removed from main entirely. Assert the stderr message instead, so the test distinguishes the guard from argparse.

test_forbidden_recompile_pattern_catches_tool_invocations asserts only positive matches. A regex that degraded to match everything would still pass. Add a negative case.

💚 Proposed assertions
     def test_forbidden_recompile_pattern_catches_tool_invocations(self) -> None:
         for token in (
             "maturin build --release",
             "maturin develop -m x",
             "napi build --platform",
             "cargo build -p graphforge-bindings-py",
             "cargo rustc -p graphforge-bindings-node",
         ):
             self.assertIsNotNone(FORBIDDEN_RECOMPILE.search(token), token)
+        for token in (
+            "--language python --native x.so --package-root . --out out.whl",
+            "maturin upload dist/*",
+            "cargo metadata --format-version 1",
+        ):
+            self.assertIsNone(FORBIDDEN_RECOMPILE.search(token), token)
 
     def test_main_refuses_recompile_looking_argv(self) -> None:
-        with self.assertRaises(SystemExit) as raised:
+        with self.assertRaises(SystemExit) as raised, contextlib.redirect_stderr(io.StringIO()) as err:
             main(
                 [
                     "--language",
                     "python",
                     "--native",
                     "x.so",
                     "--package-root",
                     ".",
                     "--out",
                     "out.whl",
                     "maturin",
                     "build",
                 ]
             )
         self.assertEqual(raised.exception.code, 2)
+        self.assertIn("silent native recompile", err.getvalue())

Add the supporting imports:

+import contextlib
+import io
 import json

As per coding guidelines: "Keep real evidence: do not lie, skip tests, weaken assertions, or claim green checks without running the relevant checks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/test-assemble-bazel-binding-packages.py` around lines 23 - 49,
Strengthen test_forbidden_recompile_pattern_catches_tool_invocations with
representative safe-token negative cases asserting FORBIDDEN_RECOMPILE does not
match, while retaining the existing positive cases. In
test_main_refuses_recompile_looking_argv, assert the captured stderr contains
the guard’s specific recompile-rejection message in addition to exit code 2,
distinguishing _assert_no_recompile_args from argparse failures; add only the
supporting imports needed to capture stderr.

Source: Coding guidelines

tools/bazel/bindings/BUILD.bazel (1)

26-29: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Harden the native-artifact selection and the interpreter choice.

Two points on this command:

  1. Line 26 filters $(locations ...) by extension and takes head -n1. If the rust_shared_library target reports more than one matching file, the genrule silently packages the first one. Assert that exactly one file matches, so an unexpected artifact set fails the build instead of producing a package built from an arbitrary choice.
  2. Line 29 runs the system python3 from PATH. The action result then depends on the executor's interpreter. The comment in scripts/ci/BUILD.bazel line 14 records that this avoids rules_python, so the tradeoff is deliberate. Note that the same interpreter decides whether tomllib is available for the _read_version fix proposed on scripts/ci/assemble_bazel_binding_packages.py.

The node genrule at line 50 uses the same head -n1 pattern.

♻️ Proposed strict selection
-NATIVE=$$(echo $(locations //crates/graphforge-bindings-py:graphforge_bindings_py) | tr ' ' '\\n' | grep -E '\\.(so|dylib|dll)$$' | head -n1)
+NATIVE_MATCHES=$$(echo $(locations //crates/graphforge-bindings-py:graphforge_bindings_py) | tr ' ' '\\n' | grep -E '\\.(so|dylib|dll)$$')
+if [ "$$(printf '%s\\n' "$$NATIVE_MATCHES" | wc -l)" -ne 1 ]; then
+  echo "expected exactly one python cdylib, got: $$NATIVE_MATCHES" >&2
+  exit 1
+fi
+NATIVE="$$NATIVE_MATCHES"

As per coding guidelines: "Fix root causes; never hide failures with skips, retries, sleeps, blanket ignores, fallback behavior, or weakened assertions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/bazel/bindings/BUILD.bazel` around lines 26 - 29, Harden native
artifact selection in both the Python and node genrules by validating that the
extension-filtered locations contain exactly one file before assigning it, and
fail on zero or multiple matches instead of using head -n1. Update the
interpreter invocation for assemble_bazel_binding_packages_py to use a declared,
deterministic Python executable rather than PATH-based python3, while preserving
the required tomllib support and the intentional rules_python tradeoff.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/ci/assemble_bazel_binding_packages.py`:
- Around line 123-137: Update the wheel-tag selection block in the assembly flow
to avoid retaining the initial "py3-none-any" value when no supported host
branch matches. After the platform checks, fail through the existing _die
mechanism, matching _napi_platform_tag’s unsupported-host behavior, while
preserving the platform-specific tags for recognized Linux, Darwin, and Windows
hosts.
- Around line 165-178: Update the RECORD generation in the wheel assembly loop
to encode each SHA-256 raw digest with unpadded URL-safe Base64, replacing
hashlib.sha256(data).hexdigest() with the required digest conversion before
building record_lines. Also replace the record_body list concatenation with list
unpacking using [*record_lines, ...] to satisfy RUF005.

In `@tools/bazel/bindings/BUILD.bazel`:
- Around line 23-34: Declare both evidence JSON files as outputs of their
respective genrules: add python-bazel-native-evidence.json to python_wheel_smoke
at tools/bazel/bindings/BUILD.bazel lines 23-34, and
node-bazel-native-evidence.json to node_package_smoke at lines 47-58. Keep the
existing --write-evidence paths unchanged so Bazel tracks and preserves each
generated artifact.

---

Nitpick comments:
In `@scripts/ci/assemble_bazel_binding_packages.py`:
- Around line 34-36: Update the return annotation of _die to NoReturn and import
NoReturn from the appropriate typing module, so type checkers recognize every
call as non-returning and preserve the declared str return paths in
_napi_platform_tag and _read_version.
- Around line 61-77: Keep the current host-platform detection in
_napi_platform_tag and the wheel tag selection unchanged for this smoke-path
scope. Do not add Bazel native-platform derivation or cross-compilation
handling; when cross-platform support is implemented in `#6`, replace host
detection with an explicit target-triple flag.

In `@scripts/ci/test-assemble-bazel-binding-packages.py`:
- Around line 51-93: Add a Bazel-level test targeting the packaging genrules
`//:python_wheel_smoke` and `//:node_package_smoke`, rather than only invoking
`assemble_python` and `assemble_node` directly. Declare the generated evidence
JSON as an output where necessary, then inspect both produced archives and
verify their native payloads, required package files, and `recompiled` evidence
through the actual genrule execution path.
- Around line 23-49: Strengthen
test_forbidden_recompile_pattern_catches_tool_invocations with representative
safe-token negative cases asserting FORBIDDEN_RECOMPILE does not match, while
retaining the existing positive cases. In
test_main_refuses_recompile_looking_argv, assert the captured stderr contains
the guard’s specific recompile-rejection message in addition to exit code 2,
distinguishing _assert_no_recompile_args from argparse failures; add only the
supporting imports needed to capture stderr.

In `@tools/bazel/bindings/BUILD.bazel`:
- Around line 26-29: Harden native artifact selection in both the Python and
node genrules by validating that the extension-filtered locations contain
exactly one file before assigning it, and fail on zero or multiple matches
instead of using head -n1. Update the interpreter invocation for
assemble_bazel_binding_packages_py to use a declared, deterministic Python
executable rather than PATH-based python3, while preserving the required tomllib
support and the intentional rules_python tradeoff.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 97cbd255-caa0-4010-920c-be868fd22319

📥 Commits

Reviewing files that changed from the base of the PR and between b8c2178 and 62f64e7.

⛔ Files ignored due to path filters (3)
  • .github/workflows/test.yml is excluded by !**/.github/**
  • docs/development/bazel-bootstrap.md is excluded by !**/*.md, !**/docs/**
  • docs/development/bazel-migration-ledger.md is excluded by !**/*.md, !**/docs/**
📒 Files selected for processing (14)
  • BUILD.bazel
  • MODULE.bazel
  • crates/graphforge-bindings-node/BUILD.bazel
  • crates/graphforge-bindings-py/BUILD.bazel
  • crates/graphforge-cli/BUILD.bazel
  • crates/graphforge-cli/build.rs
  • project-skills/BUILD.bazel
  • scripts/ci/BUILD.bazel
  • scripts/ci/assemble_bazel_binding_packages.py
  • scripts/ci/classify-changes.sh
  • scripts/ci/test-assemble-bazel-binding-packages.py
  • scripts/ci/test-classify-changes.sh
  • tools/bazel/bindings/BUILD.bazel
  • tools/bazel/gf_rust.bzl

Comment thread scripts/ci/assemble_bazel_binding_packages.py Outdated
Comment thread scripts/ci/assemble_bazel_binding_packages.py
Comment thread tools/bazel/bindings/BUILD.bazel Outdated
DecisionNerd and others added 2 commits August 5, 2026 21:59
Move the skill filegroup to the workspace root and satisfy ruff on the
packaging handoff scripts so Python binding parity stays byte-identical.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fail closed on unsupported wheel hosts, encode RECORD digests per PEP 427,
and declare packaging evidence JSON as Bazel genrule outputs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-cd CI/CD configuration changes core Core source code changes documentation Improvements or additions to documentation tooling Developer tooling and automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bazel: model PyO3 and napi-rs cdylibs plus packaging handoff

1 participant