From cec5121fb1d5e52101d15cab2ae4aca7ebb60bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:38:17 +0000 Subject: [PATCH 1/3] ci: run linting without building native code (#414) The lint job set up a full native toolchain (JDK 17, Android SDK + NDK, x86_64-linux-android Rust target) and ran two native bootstraps purely to get generated TypeScript types for type-checking. Resolve both TODOs: - Add `ferric build --dts-only`, which generates a crate's `.d.ts` and JS entrypoint via a plain host `cargo build` (napi-rs typedef codegen), without cross-compiling any Android/Apple binaries. The library basename is derived from `cargo metadata`'s cdylib target instead of from built artifact paths, so no platform build is needed to compute it. Wire this up as `ferric-example`'s new `build:types` script. - Use `weak-node-api`'s existing `prebuild:prepare` script (header copy + C++/TS declaration codegen) instead of `bootstrap` (which also runs the native CMake build). It already required nothing beyond clang-format. With both native builds no longer needed for typing, the lint job drops the JDK 17, Android SDK, and `rustup target add` steps entirely. Verified locally (Node 24, cargo present, no Android/Apple SDK): fresh `pnpm install && pnpm run build`, then `pnpm --filter weak-node-api run prebuild:prepare`, `pnpm --filter @react-native-node-api/ferric-example run build:types`, `pnpm run lint`, `pnpm run prettier:check`, `pnpm run depcheck` and `pnpm run publint` all pass end-to-end with no native toolchain present, reproducing the new lint job's steps. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --- .changeset/wet-carrots-relax.md | 5 +++ .github/workflows/check.yml | 24 +++++-------- packages/ferric-example/package.json | 3 +- packages/ferric/src/build.ts | 53 +++++++++++++++++++++++++++- packages/ferric/src/cargo.ts | 30 ++++++++++++++++ 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 .changeset/wet-carrots-relax.md diff --git a/.changeset/wet-carrots-relax.md b/.changeset/wet-carrots-relax.md new file mode 100644 index 00000000..eb7090f2 --- /dev/null +++ b/.changeset/wet-carrots-relax.md @@ -0,0 +1,5 @@ +--- +"ferric-cli": patch +--- + +Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3b6500f5..d92c6b37 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -59,24 +59,16 @@ jobs: uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ${{ github.job }}-${{ runner.os }} - # Set up JDK and Android SDK only because we need weak-node-api, to build ferric-example and to run the linting - # TODO: Remove this once we have a way to run linting without building the native code - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: "17" - distribution: "temurin" - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - with: - packages: tools platform-tools ndk;${{ env.NDK_VERSION }} - - run: rustup target add x86_64-linux-android - run: pnpm install - run: pnpm run build - # Bootstrap weak-node-api and ferric-example to get types - # TODO: Solve this by adding an option to ferric to build only types or by committing the types into the repo as a fixture for an "init" command - - run: pnpm --filter weak-node-api run bootstrap - - run: pnpm --filter @react-native-node-api/ferric-example run bootstrap + # Generate the TypeScript/C++ declarations that other packages' type-checking + # depends on, without building any native binaries: weak-node-api's + # "prebuild:prepare" only copies headers and runs codegen (needs clang-format, + # set up above, but no JDK/Android SDK/NDK), and ferric's "--dts-only" flag + # runs a plain host `cargo build` to emit ferric-example's .d.ts instead of + # cross-compiling Android/Apple binaries. See #414. + - run: pnpm --filter weak-node-api run prebuild:prepare + - run: pnpm --filter @react-native-node-api/ferric-example run build:types - run: pnpm run lint env: DEBUG: eslint:eslint diff --git a/packages/ferric-example/package.json b/packages/ferric-example/package.json index 28735907..3e9fd232 100644 --- a/packages/ferric-example/package.json +++ b/packages/ferric-example/package.json @@ -19,7 +19,8 @@ ], "scripts": { "build": "ferric build", - "bootstrap": "node --run build" + "bootstrap": "node --run build", + "build:types": "ferric build --dts-only" }, "dependencies": { "react-native-node-api": "workspace:*" diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index 3e5236ac..a2e72b41 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -25,7 +25,7 @@ import { determineLibraryBasename, } from "react-native-node-api"; -import { ensureCargo, build } from "./cargo.js"; +import { ensureCargo, build, determineCargoLibraryName } from "./cargo.js"; import { ALL_TARGETS, ANDROID_TARGETS, @@ -104,6 +104,10 @@ const xcframeworkExtensionOption = new Option( "--xcframework-extension", "Don't rename the xcframework to .apple.node", ).default(false); +const dtsOnlyOption = new Option( + "--dts-only", + "Only generate the TypeScript declarations, skipping the native build entirely (no Android/Apple toolchain needed)", +).default(false); const outputPathOption = new Option( "--output ", @@ -153,6 +157,7 @@ export const buildCommand = new Command("build") .addOption(appleBundleIdentifierOption) .addOption(concurrencyOption) .addOption(verboseOption) + .addOption(dtsOnlyOption) .action( wrapAction( async ({ @@ -167,7 +172,53 @@ export const buildCommand = new Command("build") appleBundleIdentifier, concurrency, verbose, + dtsOnly, }) => { + if (dtsOnly) { + assertFixable( + targetArg.length === 0 && !apple && !android && !clean, + "The --dts-only flag cannot be combined with --target, --apple, --android or --clean", + { + instructions: + "Drop --dts-only to build native binaries, or remove the other flags to only generate TypeScript declarations", + }, + ); + ensureCargo(); + const libraryName = determineCargoLibraryName(process.cwd()); + const declarationsFilename = `${libraryName}.d.ts`; + const declarationsPath = path.join(outputPath, declarationsFilename); + await oraPromise( + generateTypeScriptDeclarations({ + outputFilename: declarationsFilename, + createPath: process.cwd(), + outputPath, + }), + { + text: "Generating TypeScript declarations", + successText: `Generated TypeScript declarations ${prettyPath( + declarationsPath, + )}`, + failText: (error) => + `Failed to generate TypeScript declarations: ${error.message}`, + }, + ); + const entrypointPath = path.join(outputPath, `${libraryName}.js`); + await oraPromise( + generateEntrypoint({ + libraryName, + outputPath: entrypointPath, + }), + { + text: `Generating entrypoint`, + successText: `Generated entrypoint into ${prettyPath( + entrypointPath, + )}`, + failText: (error) => + `Failed to generate entrypoint: ${error.message}`, + }, + ); + return; + } if (clean) { await oraPromise( () => spawn("cargo", ["clean"], { outputMode: "buffered" }), diff --git a/packages/ferric/src/cargo.ts b/packages/ferric/src/cargo.ts index fc4fb2ac..31c8eaa3 100644 --- a/packages/ferric/src/cargo.ts +++ b/packages/ferric/src/cargo.ts @@ -93,6 +93,36 @@ export function ensureCargo() { } } +type CargoMetadata = { + packages: { targets: { name: string; kind: string[] }[] }[]; +}; + +/** + * Determine the name of the crate's "cdylib" target, without building anything, + * by asking cargo for its metadata. This matches the basename a full build would + * produce (e.g. "ferric_example" for a crate named "ferric-example"), since cargo + * normalizes the crate name (dashes to underscores) for the compiled artifact. + */ +export function determineCargoLibraryName(cwd: string): string { + const output = cp.execFileSync( + "cargo", + ["metadata", "--no-deps", "--format-version", "1"], + { cwd, encoding: "utf-8" }, + ); + const { packages } = JSON.parse(output) as CargoMetadata; + const cdylibNames = packages + .flatMap((pkg) => pkg.targets) + .filter((target) => target.kind.includes("cdylib")) + .map((target) => target.name); + const candidates = new Set(cdylibNames); + assert( + candidates.size === 1, + `Expected exactly one cdylib target, got: ${[...candidates].join(", ")}`, + ); + const [name] = candidates; + return name; +} + type BuildOptions = { configuration: "debug" | "release"; verbose: boolean; From 63f12e874a5697fe811dc9cbb6d685fd5d25614e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:08:37 +0000 Subject: [PATCH 2/3] ci: commit ferric-example's declarations as a fixture instead of building them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kraenhansen suspected generateTypeScriptDeclarations doesn't actually skip a native build. Confirmed: napi-rs's `napi build` has no typegen-only mode — it always runs a real `cargo build`, and --dts-only leaves a fully populated ~123MB target/ directory (including a compiled libferric_example.so) behind. "Skipping the native build entirely" was wrong; only Android/Apple cross-compilation was actually skipped, and the lint job stayed coupled to the host Rust toolchain's health exactly as #414 wanted to avoid. Switch to the issue's other suggested option: commit ferric_example.d.ts and ferric_example.js as a checked-in fixture (no longer gitignored), and drop the ferric-example build:types step from the lint job entirely — it no longer needs to regenerate anything. --dts-only stays, now documented accurately, as the way to regenerate the fixture by hand after changing packages/ferric-example/src/lib.rs. To catch drift, the two CI jobs that already do a real `ferric build` (Android and Apple triplets) now `git diff --exit-code` the two committed files right after building. Both are label-gated rather than running on every PR, so this doesn't fully close the gap — flagged in the PR thread. Also excludes the two fixture files from Prettier: they're left in napi-rs's own output formatting so regenerating them reproduces the committed bytes exactly, and the new drift check doesn't false-positive on formatting alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --- .changeset/wet-carrots-relax.md | 2 +- .github/workflows/check.yml | 16 ++++++++++++---- .prettierignore | 7 +++++++ packages/ferric-example/.gitignore | 9 ++++++--- packages/ferric-example/ferric_example.d.ts | 11 +++++++++++ packages/ferric-example/ferric_example.js | 13 +++++++++++++ packages/ferric/src/build.ts | 2 +- 7 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 packages/ferric-example/ferric_example.d.ts create mode 100644 packages/ferric-example/ferric_example.js diff --git a/.changeset/wet-carrots-relax.md b/.changeset/wet-carrots-relax.md index eb7090f2..6050c6cb 100644 --- a/.changeset/wet-carrots-relax.md +++ b/.changeset/wet-carrots-relax.md @@ -2,4 +2,4 @@ "ferric-cli": patch --- -Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries. +Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries. It still runs a real host `cargo build` (napi-rs has no lighter typegen-only mode), so it's meant for regenerating a checked-in declarations fixture rather than for environments without a Rust toolchain. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d92c6b37..cb093ba7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -64,11 +64,13 @@ jobs: # Generate the TypeScript/C++ declarations that other packages' type-checking # depends on, without building any native binaries: weak-node-api's # "prebuild:prepare" only copies headers and runs codegen (needs clang-format, - # set up above, but no JDK/Android SDK/NDK), and ferric's "--dts-only" flag - # runs a plain host `cargo build` to emit ferric-example's .d.ts instead of - # cross-compiling Android/Apple binaries. See #414. + # set up above, but no JDK/Android SDK/NDK). ferric-example's declarations are + # committed as a fixture instead (see packages/ferric-example/.gitignore) — + # napi-rs's dts generation has no way to run without a real `cargo build` + # (confirmed: it leaves a populated target/ directory), so unlike + # weak-node-api's codegen it can't be reproduced here without reintroducing a + # native build into the fastest-feedback job. See #414. - run: pnpm --filter weak-node-api run prebuild:prepare - - run: pnpm --filter @react-native-node-api/ferric-example run build:types - run: pnpm run lint env: DEBUG: eslint:eslint @@ -342,6 +344,9 @@ jobs: - name: Build ferric-example for all architectures run: pnpm run build --android working-directory: packages/ferric-example + - name: Verify committed ferric-example TypeScript declarations are up to date + run: git diff --exit-code -- ferric_example.d.ts ferric_example.js + working-directory: packages/ferric-example - name: Run tests (Android) timeout-minutes: 75 uses: reactivecircus/android-emulator-runner@v2 @@ -409,6 +414,9 @@ jobs: # Build Ferric example for all Apple architectures - run: pnpm exec ferric --apple working-directory: packages/ferric-example + - name: Verify committed ferric-example TypeScript declarations are up to date + run: git diff --exit-code -- ferric_example.d.ts ferric_example.js + working-directory: packages/ferric-example - name: Inspect the structure of the prebuilt binary run: | lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-output.txt diff --git a/.prettierignore b/.prettierignore index 23bef2b3..c8aaf613 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,10 @@ packages/node-addon-examples/examples packages/node-tests/node packages/node-tests/tests packages/node-tests/*.generated.js + +# Committed napi-rs codegen fixture (see packages/ferric-example/.gitignore) — left +# in napi-rs's own output formatting so `pnpm run build:types` reproduces it exactly +# and the CI drift check (see .github/workflows/check.yml) doesn't false-positive on +# formatting alone. +packages/ferric-example/ferric_example.d.ts +packages/ferric-example/ferric_example.js diff --git a/packages/ferric-example/.gitignore b/packages/ferric-example/.gitignore index f1d36c32..ad684f13 100644 --- a/packages/ferric-example/.gitignore +++ b/packages/ferric-example/.gitignore @@ -4,6 +4,9 @@ /*.apple.node/ /*.android.node/ -# Generated files -/ferric_example.d.ts -/ferric_example.js +# ferric_example.d.ts and ferric_example.js are intentionally NOT ignored: they're +# committed as a fixture so `pnpm run lint` (which apps/test-app's App.tsx needs for +# its `typeof import("@react-native-node-api/ferric-example")`) doesn't have to run a +# native `cargo build` to get them. See #414. Regenerate with `pnpm run build:types` +# after changing packages/ferric-example/src/lib.rs, and let the "Test app (Android)" +# / "Test ferric Apple triplets" jobs' drift check catch anyone who forgets. diff --git a/packages/ferric-example/ferric_example.d.ts b/packages/ferric-example/ferric_example.d.ts new file mode 100644 index 00000000..3b38df96 --- /dev/null +++ b/packages/ferric-example/ferric_example.d.ts @@ -0,0 +1,11 @@ +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ +/* eslint-disable */ +export declare function sum(a: number, b: number): number diff --git a/packages/ferric-example/ferric_example.js b/packages/ferric-example/ferric_example.js new file mode 100644 index 00000000..69fff2e3 --- /dev/null +++ b/packages/ferric-example/ferric_example.js @@ -0,0 +1,13 @@ +/* eslint-disable */ + +/** + * This file was generated by + * ╭─────────────────────────╮ + * │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│ + * │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│ + * │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│ + * ╰─────────────────────────╯ + * Powered by napi.rs + */ + +module.exports = require('./ferric_example.node'); diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index a2e72b41..50e32b94 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -106,7 +106,7 @@ const xcframeworkExtensionOption = new Option( ).default(false); const dtsOnlyOption = new Option( "--dts-only", - "Only generate the TypeScript declarations, skipping the native build entirely (no Android/Apple toolchain needed)", + "Only generate the TypeScript declarations and entrypoint, skipping Android/Apple cross-compilation. Still runs a real `cargo build` for the host target (napi-rs has no lighter typegen-only mode), so this is not a no-op — it's meant for regenerating a checked-in declarations fixture, not for toolchain-free environments.", ).default(false); const outputPathOption = new Option( From af465da45fd71051fd69186c5d0f62a1cdd20cb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:19:34 +0000 Subject: [PATCH 3/3] ci: drop the explanatory comment from ferric-example/.gitignore Per review feedback on #435. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --- packages/ferric-example/.gitignore | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/ferric-example/.gitignore b/packages/ferric-example/.gitignore index ad684f13..a225cf72 100644 --- a/packages/ferric-example/.gitignore +++ b/packages/ferric-example/.gitignore @@ -3,10 +3,3 @@ /*.xcframework/ /*.apple.node/ /*.android.node/ - -# ferric_example.d.ts and ferric_example.js are intentionally NOT ignored: they're -# committed as a fixture so `pnpm run lint` (which apps/test-app's App.tsx needs for -# its `typeof import("@react-native-node-api/ferric-example")`) doesn't have to run a -# native `cargo build` to get them. See #414. Regenerate with `pnpm run build:types` -# after changing packages/ferric-example/src/lib.rs, and let the "Test app (Android)" -# / "Test ferric Apple triplets" jobs' drift check catch anyone who forgets.