Skip to content

Commit c73d30c

Browse files
kraenhansenclaude
andcommitted
ci: run linting without building native code (#435)
* 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 * ci: commit ferric-example's declarations as a fixture instead of building them 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm * ci: drop the explanatory comment from ferric-example/.gitignore Per review feedback on #435. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaK9eAAF5G8wj6UT8VekAm --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43c2b8f commit c73d30c

9 files changed

Lines changed: 136 additions & 22 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. 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.

.github/workflows/check.yml

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,24 +59,18 @@ 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). ferric-example's declarations are
68+
# committed as a fixture instead (see packages/ferric-example/.gitignore) —
69+
# napi-rs's dts generation has no way to run without a real `cargo build`
70+
# (confirmed: it leaves a populated target/ directory), so unlike
71+
# weak-node-api's codegen it can't be reproduced here without reintroducing a
72+
# native build into the fastest-feedback job. See #414.
73+
- run: pnpm --filter weak-node-api run prebuild:prepare
8074
- run: pnpm run lint
8175
env:
8276
DEBUG: eslint:eslint
@@ -406,6 +400,9 @@ jobs:
406400
- name: Build ferric-example for all architectures
407401
run: pnpm run build --android
408402
working-directory: packages/ferric-example
403+
- name: Verify committed ferric-example TypeScript declarations are up to date
404+
run: git diff --exit-code -- ferric_example.d.ts ferric_example.js
405+
working-directory: packages/ferric-example
409406
- name: Run tests (Android)
410407
timeout-minutes: 75
411408
uses: reactivecircus/android-emulator-runner@v2
@@ -473,6 +470,9 @@ jobs:
473470
# Build Ferric example for all Apple architectures
474471
- run: pnpm exec ferric --apple
475472
working-directory: packages/ferric-example
473+
- name: Verify committed ferric-example TypeScript declarations are up to date
474+
run: git diff --exit-code -- ferric_example.d.ts ferric_example.js
475+
working-directory: packages/ferric-example
476476
- name: Inspect the structure of the prebuilt binary
477477
run: |
478478
lipo -info ferric_example.apple.node/*/libferric_example.framework/libferric_example > lipo-output.txt

.prettierignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,10 @@ packages/node-addon-examples/examples
1414
packages/node-tests/node
1515
packages/node-tests/tests
1616
packages/node-tests/*.generated.js
17+
18+
# Committed napi-rs codegen fixture (see packages/ferric-example/.gitignore) — left
19+
# in napi-rs's own output formatting so `pnpm run build:types` reproduces it exactly
20+
# and the CI drift check (see .github/workflows/check.yml) doesn't false-positive on
21+
# formatting alone.
22+
packages/ferric-example/ferric_example.d.ts
23+
packages/ferric-example/ferric_example.js

packages/ferric-example/.gitignore

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@
33
/*.xcframework/
44
/*.apple.node/
55
/*.android.node/
6-
7-
# Generated files
8-
/ferric_example.d.ts
9-
/ferric_example.js
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* This file was generated by
3+
* ╭─────────────────────────╮
4+
* │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│
5+
* │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│
6+
* │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│
7+
* ╰─────────────────────────╯
8+
* Powered by napi.rs
9+
*/
10+
/* eslint-disable */
11+
export declare function sum(a: number, b: number): number
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* eslint-disable */
2+
3+
/**
4+
* This file was generated by
5+
* ╭─────────────────────────╮
6+
* │░█▀▀░█▀▀░█▀▄░█▀▄░▀█▀░█▀▀░│
7+
* │░█▀▀░█▀▀░█▀▄░█▀▄░░█░░█░░░│
8+
* │░▀░░░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░│
9+
* ╰─────────────────────────╯
10+
* Powered by napi.rs
11+
*/
12+
13+
module.exports = require('./ferric_example.node');

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 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.",
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)