Skip to content

Commit cec5121

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm
1 parent 1966cb3 commit cec5121

5 files changed

Lines changed: 97 additions & 18 deletions

File tree

.changeset/wet-carrots-relax.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ferric-cli": patch
3+
---
4+
5+
Add `--dts-only` flag to `ferric build`, generating just the TypeScript declaration file and JS entrypoint without cross-compiling any Android/Apple binaries.

.github/workflows/check.yml

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,24 +59,16 @@ jobs:
5959
uses: hendrikmuhs/ccache-action@v1.2.23
6060
with:
6161
key: ${{ github.job }}-${{ runner.os }}
62-
# Set up JDK and Android SDK only because we need weak-node-api, to build ferric-example and to run the linting
63-
# TODO: Remove this once we have a way to run linting without building the native code
64-
- name: Set up JDK 17
65-
uses: actions/setup-java@v5
66-
with:
67-
java-version: "17"
68-
distribution: "temurin"
69-
- name: Setup Android SDK
70-
uses: android-actions/setup-android@v4
71-
with:
72-
packages: tools platform-tools ndk;${{ env.NDK_VERSION }}
73-
- run: rustup target add x86_64-linux-android
7462
- run: pnpm install
7563
- run: pnpm run build
76-
# Bootstrap weak-node-api and ferric-example to get types
77-
# 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
78-
- run: pnpm --filter weak-node-api run bootstrap
79-
- run: pnpm --filter @react-native-node-api/ferric-example run bootstrap
64+
# Generate the TypeScript/C++ declarations that other packages' type-checking
65+
# depends on, without building any native binaries: weak-node-api's
66+
# "prebuild:prepare" only copies headers and runs codegen (needs clang-format,
67+
# set up above, but no JDK/Android SDK/NDK), and ferric's "--dts-only" flag
68+
# runs a plain host `cargo build` to emit ferric-example's .d.ts instead of
69+
# cross-compiling Android/Apple binaries. See #414.
70+
- run: pnpm --filter weak-node-api run prebuild:prepare
71+
- run: pnpm --filter @react-native-node-api/ferric-example run build:types
8072
- run: pnpm run lint
8173
env:
8274
DEBUG: eslint:eslint

packages/ferric-example/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
],
2020
"scripts": {
2121
"build": "ferric build",
22-
"bootstrap": "node --run build"
22+
"bootstrap": "node --run build",
23+
"build:types": "ferric build --dts-only"
2324
},
2425
"dependencies": {
2526
"react-native-node-api": "workspace:*"

packages/ferric/src/build.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
determineLibraryBasename,
2626
} from "react-native-node-api";
2727

28-
import { ensureCargo, build } from "./cargo.js";
28+
import { ensureCargo, build, determineCargoLibraryName } from "./cargo.js";
2929
import {
3030
ALL_TARGETS,
3131
ANDROID_TARGETS,
@@ -104,6 +104,10 @@ const xcframeworkExtensionOption = new Option(
104104
"--xcframework-extension",
105105
"Don't rename the xcframework to .apple.node",
106106
).default(false);
107+
const dtsOnlyOption = new Option(
108+
"--dts-only",
109+
"Only generate the TypeScript declarations, skipping the native build entirely (no Android/Apple toolchain needed)",
110+
).default(false);
107111

108112
const outputPathOption = new Option(
109113
"--output <path>",
@@ -153,6 +157,7 @@ export const buildCommand = new Command("build")
153157
.addOption(appleBundleIdentifierOption)
154158
.addOption(concurrencyOption)
155159
.addOption(verboseOption)
160+
.addOption(dtsOnlyOption)
156161
.action(
157162
wrapAction(
158163
async ({
@@ -167,7 +172,53 @@ export const buildCommand = new Command("build")
167172
appleBundleIdentifier,
168173
concurrency,
169174
verbose,
175+
dtsOnly,
170176
}) => {
177+
if (dtsOnly) {
178+
assertFixable(
179+
targetArg.length === 0 && !apple && !android && !clean,
180+
"The --dts-only flag cannot be combined with --target, --apple, --android or --clean",
181+
{
182+
instructions:
183+
"Drop --dts-only to build native binaries, or remove the other flags to only generate TypeScript declarations",
184+
},
185+
);
186+
ensureCargo();
187+
const libraryName = determineCargoLibraryName(process.cwd());
188+
const declarationsFilename = `${libraryName}.d.ts`;
189+
const declarationsPath = path.join(outputPath, declarationsFilename);
190+
await oraPromise(
191+
generateTypeScriptDeclarations({
192+
outputFilename: declarationsFilename,
193+
createPath: process.cwd(),
194+
outputPath,
195+
}),
196+
{
197+
text: "Generating TypeScript declarations",
198+
successText: `Generated TypeScript declarations ${prettyPath(
199+
declarationsPath,
200+
)}`,
201+
failText: (error) =>
202+
`Failed to generate TypeScript declarations: ${error.message}`,
203+
},
204+
);
205+
const entrypointPath = path.join(outputPath, `${libraryName}.js`);
206+
await oraPromise(
207+
generateEntrypoint({
208+
libraryName,
209+
outputPath: entrypointPath,
210+
}),
211+
{
212+
text: `Generating entrypoint`,
213+
successText: `Generated entrypoint into ${prettyPath(
214+
entrypointPath,
215+
)}`,
216+
failText: (error) =>
217+
`Failed to generate entrypoint: ${error.message}`,
218+
},
219+
);
220+
return;
221+
}
171222
if (clean) {
172223
await oraPromise(
173224
() => spawn("cargo", ["clean"], { outputMode: "buffered" }),

packages/ferric/src/cargo.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ export function ensureCargo() {
9393
}
9494
}
9595

96+
type CargoMetadata = {
97+
packages: { targets: { name: string; kind: string[] }[] }[];
98+
};
99+
100+
/**
101+
* Determine the name of the crate's "cdylib" target, without building anything,
102+
* by asking cargo for its metadata. This matches the basename a full build would
103+
* produce (e.g. "ferric_example" for a crate named "ferric-example"), since cargo
104+
* normalizes the crate name (dashes to underscores) for the compiled artifact.
105+
*/
106+
export function determineCargoLibraryName(cwd: string): string {
107+
const output = cp.execFileSync(
108+
"cargo",
109+
["metadata", "--no-deps", "--format-version", "1"],
110+
{ cwd, encoding: "utf-8" },
111+
);
112+
const { packages } = JSON.parse(output) as CargoMetadata;
113+
const cdylibNames = packages
114+
.flatMap((pkg) => pkg.targets)
115+
.filter((target) => target.kind.includes("cdylib"))
116+
.map((target) => target.name);
117+
const candidates = new Set(cdylibNames);
118+
assert(
119+
candidates.size === 1,
120+
`Expected exactly one cdylib target, got: ${[...candidates].join(", ")}`,
121+
);
122+
const [name] = candidates;
123+
return name;
124+
}
125+
96126
type BuildOptions = {
97127
configuration: "debug" | "release";
98128
verbose: boolean;

0 commit comments

Comments
 (0)