diff --git a/.github/workflows/binding-release-candidate.yml b/.github/workflows/binding-release-candidate.yml index c2e55f6b5..f5a7c55ef 100644 --- a/.github/workflows/binding-release-candidate.yml +++ b/.github/workflows/binding-release-candidate.yml @@ -49,9 +49,11 @@ jobs: fail-fast: false matrix: include: - - { os: blacksmith-4vcpu-ubuntu-2404, target: python-ubuntu, sticky_target: "true" } - - { os: blacksmith-12vcpu-macos-15, target: python-macos, sticky_target: "false" } - - { os: blacksmith-8vcpu-windows-2025, target: python-windows, sticky_target: "false" } + # Linux: Bazel cdylib + assembler (no maturin recompile). + - { os: blacksmith-4vcpu-ubuntu-2404, target: python-ubuntu, native_builder: bazel, sticky_target: "false", wheel_tag: cp310-abi3-manylinux_2_17_x86_64 } + # Host maturin retained until macOS/Windows Binding RC Bazel cutover. + - { os: blacksmith-12vcpu-macos-15, target: python-macos, native_builder: maturin, sticky_target: "false" } + - { os: blacksmith-8vcpu-windows-2025, target: python-windows, native_builder: maturin, sticky_target: "false" } env: CARGO_INCREMENTAL: 0 CARGO_TARGET_DIR: ${{ github.workspace }}/target @@ -72,15 +74,17 @@ jobs: python-version: "3.13" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Mount shared Linux Cargo build disk if: matrix.sticky_target == 'true' uses: useblacksmith/stickydisk@35ba2e331a80056a42af053ee54968511de2b7c7 # v1.5.0 with: - # Python Ubuntu and Linux Node targets share compatible release products. + # Retained for maturin lanes that still compile into workspace target/. key: ${{ github.repository }}-binding-rc-linux-rust-1.96.0-${{ hashFiles('Cargo.lock') }}-release-target-v1 path: target - name: Cache Cargo registry + if: matrix.native_builder == 'maturin' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | @@ -88,7 +92,30 @@ jobs: ~/.cargo/git key: ${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }} + - name: Install Bazelisk + if: matrix.native_builder == 'bazel' + shell: bash + run: | + set -euo pipefail + sudo curl -fsSL \ + "https://github.com/bazelbuild/bazelisk/releases/download/v1.26.0/bazelisk-linux-amd64" \ + -o /usr/local/bin/bazelisk + sudo chmod +x /usr/local/bin/bazelisk + sudo ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel + bazelisk version + + - name: Build native abi3 wheel + if: matrix.native_builder == 'bazel' + shell: bash + run: | + set -euo pipefail + mkdir -p dist + python3 scripts/ci/binding_rc_bazel_native.py python \ + --out dist/graphforge-bazel.whl \ + --wheel-tag "${{ matrix.wheel_tag }}" + - name: Build native abi3 wheel + if: matrix.native_builder == 'maturin' uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: command: build @@ -99,7 +126,7 @@ jobs: # manylinux maturin writes into the mounted target as root; reclaim it # before the native contracts can launch Cargo on the host runner. - name: Reclaim sticky-disk ownership after maturin - if: matrix.sticky_target == 'true' + if: matrix.native_builder == 'maturin' && matrix.sticky_target == 'true' shell: bash run: | test "$CARGO_TARGET_DIR" = "$GITHUB_WORKSPACE/target" @@ -112,6 +139,7 @@ jobs: # launch Cargo after that wrapper has left PATH, especially on containerized # Linux runners, so validate the inherited command before those contracts. - name: Prepare Rust compiler wrapper for native contracts + if: matrix.native_builder == 'maturin' shell: bash run: | python3 scripts/ci/prepare-rustc-wrapper.py \ @@ -119,6 +147,7 @@ jobs: --contract python-native-contracts - name: Verify writable Cargo target for native contracts + if: matrix.native_builder == 'maturin' shell: bash run: | test "$CARGO_TARGET_DIR" = "$GITHUB_WORKSPACE/target" @@ -223,6 +252,7 @@ jobs: execution_mode: native cross_args: "" node_arch: x64 + native_builder: napi sticky_target: "false" - os: blacksmith-12vcpu-macos-15 target: aarch64-apple-darwin @@ -230,6 +260,7 @@ jobs: execution_mode: native cross_args: "" node_arch: arm64 + native_builder: napi sticky_target: "false" - os: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu @@ -237,7 +268,9 @@ jobs: execution_mode: native cross_args: "" node_arch: x64 - sticky_target: "true" + native_builder: bazel + platform_tag: linux-x64-gnu + sticky_target: "false" - os: blacksmith-4vcpu-ubuntu-2404 target: aarch64-unknown-linux-gnu report_target: node-aarch64-unknown-linux-gnu @@ -245,6 +278,7 @@ jobs: cross_args: --use-napi-cross arm_cflags: -D__ARM_ARCH=8 node_arch: x64 + native_builder: napi sticky_target: "true" - os: blacksmith-8vcpu-windows-2025 target: x86_64-pc-windows-msvc @@ -252,6 +286,7 @@ jobs: execution_mode: native cross_args: "" node_arch: x64 + native_builder: napi sticky_target: "false" timeout_minutes: 90 env: @@ -276,6 +311,7 @@ jobs: - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master as of 2026-08-05 + if: matrix.native_builder == 'napi' with: toolchain: "1.96.0" targets: ${{ matrix.target }} @@ -284,11 +320,12 @@ jobs: if: matrix.sticky_target == 'true' uses: useblacksmith/stickydisk@35ba2e331a80056a42af053ee54968511de2b7c7 # v1.5.0 with: - # Python Ubuntu and Linux Node targets share compatible release products. + # Retained for napi/cross lanes that still compile into workspace target/. key: ${{ github.repository }}-binding-rc-linux-rust-1.96.0-${{ hashFiles('Cargo.lock') }}-release-target-v1 path: target - name: Cache Cargo registry + if: matrix.native_builder == 'napi' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | @@ -314,7 +351,29 @@ jobs: exit 1 fi + - name: Install Bazelisk + if: matrix.native_builder == 'bazel' + shell: bash + run: | + set -euo pipefail + sudo curl -fsSL \ + "https://github.com/bazelbuild/bazelisk/releases/download/v1.26.0/bazelisk-linux-amd64" \ + -o /usr/local/bin/bazelisk + sudo chmod +x /usr/local/bin/bazelisk + sudo ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel + bazelisk version + - name: Build declared publish target + if: matrix.native_builder == 'bazel' + shell: bash + run: | + set -euo pipefail + python3 scripts/ci/binding_rc_bazel_native.py node \ + --out-dir crates/graphforge-bindings-node \ + --platform-tag "${{ matrix.platform_tag }}" + + - name: Build declared publish target + if: matrix.native_builder == 'napi' shell: bash env: CFLAGS_aarch64_unknown_linux_gnu: ${{ matrix.arm_cflags || '' }} @@ -488,6 +547,7 @@ jobs: python-version: "3.13" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" @@ -548,10 +608,14 @@ jobs: cp candidate/release-artifacts/node-addons/*.node \ crates/graphforge-bindings-node/artifacts/ pushd crates/graphforge-bindings-node - # index.js / index.d.ts are gitignored NAPI-RS outputs. Generate them - # here before packing the main package; matrix jobs already retained - # the tested native addons that napi artifacts will distribute. - pnpm exec napi build --platform --release --target x86_64-unknown-linux-gnu + # index.js / index.d.ts are gitignored. Emit loaders from a retained + # Linux addon via the Bazel assembler — no second native recompile. + linux_addon="$(ls candidate/release-artifacts/node-addons/graphforge.linux-x64-gnu.node | head -n1)" + test -f "$linux_addon" + python3 "$GITHUB_WORKSPACE/scripts/ci/binding_rc_bazel_native.py" emit-node-loaders \ + --addon "$linux_addon" \ + --out-dir . \ + --platform-tag linux-x64-gnu test -f index.js test -f index.d.ts pnpm exec napi create-npm-dirs diff --git a/docs/development/bazel-migration-cutover.md b/docs/development/bazel-migration-cutover.md index 8aa735594..b2dc7d73a 100644 --- a/docs/development/bazel-migration-cutover.md +++ b/docs/development/bazel-migration-cutover.md @@ -17,9 +17,9 @@ Companion artifacts: | Required check name | Exactly **`CI Gate`** (unchanged) | | Authoritative Rust compile/test | `Bazel Bootstrap` → `bazelisk test //:ci_rust_tests` (+ libs/CLI/resources/bindings builds) | | Retired | Cargo `rust-test` workspace job; PR job-isolated Cargo `target/` sticky disks | -| Retained Cargo diagnostics | `Rust Quality` (fmt/clippy); Windows `graphforge-storage` lock unit tests; PR maturin/napi binding assembly (no sticky); Binding RC / fuzz / M1 sticky packaging lanes | +| Retained Cargo diagnostics | `Rust Quality` (fmt/clippy); Windows `graphforge-storage` lock unit tests; PR maturin/napi binding assembly (no sticky); Binding RC macOS/Windows/cross napi + fuzz / M1 sticky packaging lanes | | Path-classified skips | Remain neutral via `require-gates.sh` (`success` or `skipped`) | -| Dual-build parity | Diagnostic under `Bazel Bootstrap` for **one release cycle** | +| Dual-build parity | Diagnostic under non-required `Bazel Diagnostics` for **one release cycle** | Do **not** set `--remote_cache` in-repo. Blacksmith injects repository Bazel caching. @@ -67,9 +67,16 @@ python3 scripts/ci/cargo-bazel-parity-check.py \ ### Binding RC / publish sticky disks -Not retired by #4. Linux Binding RC and M1 release-load sticky `target/` volumes -remain for packaging/publish-track lanes (`RT-maturin-assemble` / `RT-napi-assemble` -handoff). Fuzz retains its sticky disk as a justified retained tool. +Linux Binding RC **host** lanes (Python Ubuntu + Node `x86_64-unknown-linux-gnu`) +consume Bazel `//:binding_cdylibs` via +`scripts/ci/binding_rc_bazel_native.py` + +`assemble_bazel_binding_packages.py` — no maturin/napi native recompile and no +Cargo `target/` sticky mount on those lanes. Remaining Binding RC platforms +(macOS/Windows Python maturin; macOS/Windows Node napi; Linux aarch64 +napi-cross) and M1 release-load sticky `target/` volumes stay until follow-on +cutover. Fuzz retains its sticky disk as a justified retained tool. +`release_candidate` emits gitignored `index.js` / `index.d.ts` from a retained +Linux addon (`emit-node-loaders`) instead of `napi build` recompile. ## Acceptance mapping diff --git a/scripts/ci/binding_rc_bazel_native.py b/scripts/ci/binding_rc_bazel_native.py new file mode 100755 index 000000000..7c4073c93 --- /dev/null +++ b/scripts/ci/binding_rc_bazel_native.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Build Binding RC natives with Bazel and assemble packages (no maturin/napi recompile). + +Used by binding-release-candidate.yml for hermetic Linux (and later host) lanes. +Never shells out to ``maturin build``, ``napi build``, or ``cargo build``. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +ASSEMBLE = ROOT / "scripts" / "ci" / "assemble_bazel_binding_packages.py" + +FORBIDDEN = ("maturin build", "napi build", "cargo build", "cargo rustc") + + +def _die(message: str, code: int = 2) -> None: + print(f"binding_rc_bazel_native: {message}", file=sys.stderr) + raise SystemExit(code) + + +def _run(cmd: list[str]) -> None: + print("+", " ".join(cmd), flush=True) + subprocess.run(cmd, cwd=ROOT, check=True) + + +def _find_native(bazel_target: str, patterns: tuple[str, ...]) -> Path: + query = subprocess.check_output( + ["bazelisk", "cquery", bazel_target, "--output=files"], + cwd=ROOT, + text=True, + ) + candidates: list[Path] = [] + for line in query.splitlines(): + path = Path(line.strip()) + if not path.is_absolute(): + path = ROOT / path + if path.is_file() and path.name.endswith(patterns): + candidates.append(path) + if not candidates: + # Fallback: walk bazel-bin for the target's package. + pkg = bazel_target.split(":", maxsplit=1)[0].removeprefix("//") + base = ROOT / "bazel-bin" / pkg + if base.is_dir(): + for path in base.rglob("*"): + if path.is_file() and path.name.endswith(patterns): + candidates.append(path) + if not candidates: + _die(f"no native library found for {bazel_target} (patterns={patterns})") + candidates.sort(key=lambda p: (len(p.parts), str(p))) + return candidates[0] + + +def build_python(*, out: Path, wheel_tag: str | None) -> dict[str, str]: + target = "//crates/graphforge-bindings-py:graphforge_bindings_py" + _run(["bazelisk", "build", target]) + native = _find_native(target, (".so", ".dylib", ".dll", ".pyd")) + out.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + sys.executable, + str(ASSEMBLE), + "--language", + "python", + "--native", + str(native), + "--package-root", + str(ROOT / "crates" / "graphforge-bindings-py"), + "--out", + str(out), + ] + if wheel_tag: + cmd.extend(["--wheel-tag", wheel_tag]) + evidence_path = out.with_suffix(out.suffix + ".evidence.json") + cmd.extend(["--write-evidence", str(evidence_path)]) + _run(cmd) + return json.loads(evidence_path.read_text(encoding="utf-8")) + + +def build_node(*, out_dir: Path, platform_tag: str | None) -> dict[str, str]: + target = "//crates/graphforge-bindings-node:graphforge_bindings_node" + _run(["bazelisk", "build", target]) + native = _find_native(target, (".so", ".dylib", ".dll", ".node")) + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="gf-binding-rc-node-") as tmp: + staging = Path(tmp) / "package" + cmd = [ + sys.executable, + str(ASSEMBLE), + "--language", + "node", + "--native", + str(native), + "--package-root", + str(ROOT / "crates" / "graphforge-bindings-node"), + "--out", + str(staging), + ] + if platform_tag: + cmd.extend(["--platform-tag", platform_tag]) + evidence_path = Path(tmp) / "evidence.json" + cmd.extend(["--write-evidence", str(evidence_path)]) + _run(cmd) + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + for name in (evidence["addon"], "index.js", "index.d.ts"): + src = staging / name + if not src.is_file(): + _die(f"assembler did not produce {name}") + shutil.copy2(src, out_dir / name) + shutil.copy2(evidence_path, out_dir / "bazel-native-evidence.json") + return evidence + + +def emit_node_loaders(*, addon: Path, out_dir: Path, platform_tag: str | None) -> None: + """Emit index.js / index.d.ts from a retained addon without recompiling.""" + if not addon.is_file(): + _die(f"addon not found: {addon}") + out_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="gf-emit-loaders-") as tmp: + staging = Path(tmp) / "package" + cmd = [ + sys.executable, + str(ASSEMBLE), + "--language", + "node", + "--native", + str(addon), + "--package-root", + str(ROOT / "crates" / "graphforge-bindings-node"), + "--out", + str(staging), + ] + if platform_tag: + cmd.extend(["--platform-tag", platform_tag]) + _run(cmd) + for name in ("index.js", "index.d.ts"): + shutil.copy2(staging / name, out_dir / name) + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + joined = " ".join(argv).lower() + for token in FORBIDDEN: + if token in joined: + _die(f"refusing argv that looks like a silent native recompile ({token})") + + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + py = sub.add_parser("python", help="Bazel-build + assemble an abi3 wheel") + py.add_argument("--out", type=Path, required=True) + py.add_argument("--wheel-tag", default=None) + + node = sub.add_parser("node", help="Bazel-build + stage .node + loaders") + node.add_argument("--out-dir", type=Path, required=True) + node.add_argument("--platform-tag", default=None) + + emit = sub.add_parser("emit-node-loaders", help="Emit index.js/d.ts from a retained addon") + emit.add_argument("--addon", type=Path, required=True) + emit.add_argument("--out-dir", type=Path, required=True) + emit.add_argument("--platform-tag", default=None) + + args = parser.parse_args(argv) + if not shutil.which("bazelisk"): + _die("bazelisk is required on PATH; see docs/development/bazel.md") + + if args.command == "python": + evidence = build_python(out=args.out.resolve(), wheel_tag=args.wheel_tag) + elif args.command == "node": + evidence = build_node(out_dir=args.out_dir.resolve(), platform_tag=args.platform_tag) + else: + emit_node_loaders( + addon=args.addon.resolve(), + out_dir=args.out_dir.resolve(), + platform_tag=args.platform_tag, + ) + evidence = {"language": "node", "recompiled": "false", "loaders": "emitted"} + print(json.dumps(evidence, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test-binding-release-candidate.py b/scripts/ci/test-binding-release-candidate.py index 7306e5fbc..523f772a6 100644 --- a/scripts/ci/test-binding-release-candidate.py +++ b/scripts/ci/test-binding-release-candidate.py @@ -148,6 +148,9 @@ def validate_python_evidence_policy(workflow_text: str) -> None: python_job.count("PYTHON_RC_EVIDENCE_DIR: ${{ runner.temp }}/graphforge-python-rc-evidence") == 4 ) + assert "binding_rc_bazel_native.py python" in python_job + assert "native_builder: bazel" in python_job + assert "native_builder: maturin" in python_job _, maturin_found, post_maturin = python_job.partition("uses: PyO3/maturin-action@") assert maturin_found, "missing maturin build marker" assert ( @@ -263,13 +266,17 @@ def validate_windows_node_cold_start_policy(workflow_text: str) -> None: ) assert "path: target" not in workflow_step(node_job, "name: Cache Cargo registry") assert "key: ${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }}" in node_job + assert "binding_rc_bazel_native.py node" in node_job + assert "native_builder: bazel" in node_job + assert "native_builder: napi" in node_job assert node_job.index("uses: dtolnay/rust-toolchain@") < node_job.index( - "Build declared publish target" + "pnpm --filter @curatelabs/graphforge exec napi build --platform --release" ) assert_active_lines( node_job, "pnpm --filter @curatelabs/graphforge exec napi build --platform --release", + "python3 scripts/ci/binding_rc_bazel_native.py node \\", "pnpm --filter @curatelabs/graphforge test:smoke", "tests/non-cypher-release-parity.test.mjs \\", "tests/async-errors.test.mjs", @@ -412,9 +419,12 @@ def main() -> None: ("pnpm --filter @curatelabs/graphforge test:smoke", "sleep 1"), ("pnpm --filter @curatelabs/graphforge test:smoke", "retry native-smoke"), ( - "- name: Build declared publish target\n shell: bash", - "- name: Build declared publish target\n" - " if: false # disabled\n shell: bash", + "pnpm --filter @curatelabs/graphforge exec napi build --platform --release", + "false # disabled napi build", + ), + ( + "binding_rc_bazel_native.py node", + "false # disabled bazel node build", ), ("tests/non-cypher-release-parity.test.mjs", "tests/skipped-parity.test.mjs"), ("cmp built-addon.sha256 tested-addon.sha256", "true"), @@ -516,6 +526,7 @@ def main() -> None: " python:\n", " node:\n", "uses: PyO3/maturin-action@", + "binding_rc_bazel_native.py python", "Prepare writable Python RC evidence directory", "Clean-install and execute native contract", "Write target evidence", @@ -533,19 +544,21 @@ def main() -> None: < rc_workflow_text.index(target_step) < rc_workflow_text.index(native_step) ) - post_maturin_python = rc_workflow_text.split("uses: PyO3/maturin-action@", 1)[1].split( - " node:", 1 - )[0] + python_job_body = rc_workflow_text.split(" python:", 1)[1].split(" node:", 1)[0] assert "CARGO_TARGET_DIR: ${{ github.workspace }}/target" in rc_workflow_text - assert 'test "$CARGO_TARGET_DIR" = "$GITHUB_WORKSPACE/target"' in post_maturin_python - assert "cargo_target_state=unwritable" in post_maturin_python - assert "cargo_target_state=ready" in post_maturin_python - assert "chmod" not in post_maturin_python - assert post_maturin_python.count('sudo chown -R "$(id -u):$(id -g)" "$CARGO_TARGET_DIR"') == 1 - assert "continue-on-error" not in post_maturin_python - assert "|| true" not in post_maturin_python - assert "retry" not in post_maturin_python.lower() - assert "if: false" not in post_maturin_python + assert 'test "$CARGO_TARGET_DIR" = "$GITHUB_WORKSPACE/target"' in python_job_body + assert "cargo_target_state=unwritable" in python_job_body + assert "cargo_target_state=ready" in python_job_body + # Bazelisk install uses chmod +x; forbid chmod on the Cargo sticky reclaim path. + maturin_lane = python_job_body.split("uses: PyO3/maturin-action@", 1)[1] + assert "chmod" not in maturin_lane + assert python_job_body.count('sudo chown -R "$(id -u):$(id -g)" "$CARGO_TARGET_DIR"') == 1 + assert "continue-on-error" not in python_job_body + assert "|| true" not in python_job_body + assert "retry" not in python_job_body.lower() + assert "if: false" not in python_job_body + assert "if: matrix.native_builder == 'maturin'" in python_job_body + assert "if: matrix.native_builder == 'bazel'" in python_job_body strict_add_node_text = STRICT_ADD_NODE.read_text() cargo_invocation = strict_add_node_text.split('"cargo",', 1)[1].split("check=True,", 1)[0] assert "env=" not in cargo_invocation @@ -561,6 +574,9 @@ def main() -> None: assert "project_generation::tests::" not in python_job assert "Clean-install and execute native contract" in python_job assert "uses: PyO3/maturin-action@" in python_job + assert "binding_rc_bazel_native.py python" in python_job + assert "native_builder: bazel" in python_job + assert "native_builder: maturin" in python_job test_workflow_text = (ROOT / ".github/workflows/test.yml").read_text() windows_locks_job = required_section( test_workflow_text, @@ -604,6 +620,9 @@ def main() -> None: assert 'sccache: "true"' not in rc_workflow_text assert "path: target\n key: ${{ runner.os }}-cargo-registry" not in rc_workflow_text assert "Reclaim sticky-disk ownership after maturin" in rc_workflow_text + assert ( + "matrix.native_builder == 'maturin' && matrix.sticky_target == 'true'" in rc_workflow_text + ) package_validation_step = rc_workflow_text.split("- name: Validate cross-built package", 1)[ 1 ].split("- name: Write target evidence", 1)[0] @@ -625,8 +644,10 @@ def main() -> None: assert "scripts/ci/prepare-napi-packages.py" in release_candidate_job assert ( "pnpm exec napi build --platform --release --target x86_64-unknown-linux-gnu" - in release_candidate_job - ), "assemble must generate gitignored index.js/index.d.ts before packing the main package" + not in release_candidate_job + ), "assemble must not recompile natives; emit loaders from retained addon" + assert "binding_rc_bazel_native.py" in release_candidate_job + assert "emit-node-loaders" in release_candidate_job assert "test -f index.js" in release_candidate_job assert "test -f index.d.ts" in release_candidate_job assert "package/index.js" in release_candidate_job