Skip to content

Commit 4d26f08

Browse files
authored
fix(native): break engine_version mismatch loop in CI hot-swap flows (#1074)
* fix(native): break engine_version mismatch loop in CI-hot-swap flows The Rust orchestrator writes `build_meta.engine_version = CARGO_PKG_VERSION` (the binary's value) and `check_version_mismatch` compares against it on the next build. The JS post-processing was overwriting that with the platform package.json version (`getNativePackageVersion()`), which drifts from the binary in CI flows that hot-swap a freshly-built `.node` over the published binary without also updating the platform package's `package.json`. The mismatch promoted every incremental rebuild to a full rebuild, producing the ~2s 1-file-rebuild floor reported in #1066. - Plumb the binary's `engineVersion()` through `getActiveEngine` as a new `binaryVersion` field, kept distinct from the package.json-preferred display `version`. - `PipelineContext.nativeBinaryVersion` carries it; `setBuildMeta` after the orchestrator and `persistBuildMetadata` in finalize now write it instead of `engineVersion`. `checkEngineSchemaMismatch` compares against it for symmetry with what Rust reads. - `scripts/ci-install-native.mjs` now also rewrites the platform package's `package.json` `version` to match the just-built binary, sourcing the value from `NATIVE_BUILD_VERSION` (preferred) or the in-tree `Cargo.toml` (correct for flows that build without a version bump). - `publish.yml`'s pre-publish-benchmark job — the only site where Cargo.toml is bumped before the build artifact ships — sets `NATIVE_BUILD_VERSION` from `compute-version.outputs.version`. Other call sites (CI test/parity, publish preflight) build from current source without a bump, so the Cargo.toml fallback resolves correctly. Refs #1066. * fix(native): align tryNativeOrchestrator write path with read fallback (#1074) When ctx.nativeBinaryVersion is null (native addon lacks engineVersion()), tryNativeOrchestrator was writing codegraph_version = ctx.engineVersion (platform package.json), while checkEngineSchemaMismatch and persistBuildMetadata both fall back to CODEGRAPH_VERSION (JS package). The asymmetry could re-introduce a perpetual full-rebuild loop on older addons whenever those two strings diverged — exactly the bug #1066 fixed.
1 parent 2082fb3 commit 4d26f08

6 files changed

Lines changed: 127 additions & 31 deletions

File tree

.github/workflows/publish.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ jobs:
284284
run: npm install
285285

286286
- name: Install native addon over published binary
287+
# Pass NATIVE_BUILD_VERSION so the script also rewrites the platform
288+
# package.json's version to match the binary's CARGO_PKG_VERSION
289+
# (build-native bumps Cargo.toml to this same value before building).
290+
# Without this, the JS-side getNativePackageVersion() returns the
291+
# last-published version while the binary reports the bumped version,
292+
# and the Rust orchestrator's check_version_mismatch forces every
293+
# incremental rebuild back through the full pipeline (#1066).
294+
env:
295+
NATIVE_BUILD_VERSION: ${{ needs.compute-version.outputs.version }}
287296
run: node scripts/ci-install-native.mjs
288297

289298
# Build dist/ so benchmarks load the same compiled JS that ships to npm.

scripts/ci-install-native.mjs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
* Used by the CI `test` and `parity` jobs so they exercise the native engine
88
* built from the PR's Rust source rather than the last-published binary,
99
* which lags behind PR changes and causes false parity failures.
10+
*
11+
* Also rewrites the platform package's `package.json` `version` field to
12+
* match the just-built binary's `CARGO_PKG_VERSION`. Without this step the
13+
* JS-side `getNativePackageVersion()` returns the published version while
14+
* the binary reports the bumped version, and the Rust orchestrator's
15+
* check_version_mismatch then forces every incremental rebuild back through
16+
* the full pipeline (~2s floor in #1066).
17+
*
18+
* The version is read from `NATIVE_BUILD_VERSION` if set (use this when the
19+
* artifact was built from a workflow that bumped Cargo.toml — e.g.
20+
* publish.yml's build-native job sets it from compute-version output),
21+
* falling back to `crates/codegraph-core/Cargo.toml` for flows that build
22+
* locally without a version bump.
1023
*/
1124

1225
import fs from 'node:fs';
@@ -59,8 +72,52 @@ if (built.length > 1) {
5972

6073
const src = built[0];
6174
const pkg = resolvePackage();
62-
const dest = path.join('node_modules', pkg, 'codegraph-core.node');
75+
const destDir = path.join('node_modules', pkg);
76+
const dest = path.join(destDir, 'codegraph-core.node');
6377

64-
fs.mkdirSync(path.dirname(dest), { recursive: true });
78+
fs.mkdirSync(destDir, { recursive: true });
6579
fs.copyFileSync(src, dest);
6680
console.log(`[ci-install-native] copied ${src} -> ${dest}`);
81+
82+
// Resolve the binary's CARGO_PKG_VERSION. We can't read it from the .node
83+
// directly, so we accept it via env var (preferred) or fall back to the
84+
// Cargo.toml on disk — which is correct for flows that build the artifact
85+
// in the same checkout (no version bump between Cargo read and build).
86+
function resolveBinaryVersion() {
87+
const envVersion = process.env.NATIVE_BUILD_VERSION?.trim();
88+
if (envVersion) return envVersion;
89+
const cargoPath = path.join('crates', 'codegraph-core', 'Cargo.toml');
90+
try {
91+
const cargoToml = fs.readFileSync(cargoPath, 'utf8');
92+
// Match the first `version = "X.Y.Z"` after the [package] header so we
93+
// don't accidentally pick up a dependency's version pin.
94+
const pkgSection = cargoToml.split(/^\[/m)[1] ?? cargoToml;
95+
const m = pkgSection.match(/version\s*=\s*"([^"]+)"/);
96+
return m?.[1] ?? null;
97+
} catch (e) {
98+
console.warn(`[ci-install-native] failed to read ${cargoPath}: ${e.message}`);
99+
return null;
100+
}
101+
}
102+
103+
const binaryVersion = resolveBinaryVersion();
104+
const pkgJsonPath = path.join(destDir, 'package.json');
105+
if (binaryVersion && fs.existsSync(pkgJsonPath)) {
106+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
107+
const prev = pkgJson.version;
108+
if (prev !== binaryVersion) {
109+
pkgJson.version = binaryVersion;
110+
fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`);
111+
console.log(
112+
`[ci-install-native] updated ${pkgJsonPath} version: ${prev} -> ${binaryVersion}`,
113+
);
114+
} else {
115+
console.log(
116+
`[ci-install-native] ${pkgJsonPath} version already ${binaryVersion} — no rewrite needed`,
117+
);
118+
}
119+
} else if (!binaryVersion) {
120+
console.warn(
121+
'[ci-install-native] could not resolve binary version (NATIVE_BUILD_VERSION unset and Cargo.toml unreadable) — leaving platform package.json untouched',
122+
);
123+
}

src/domain/graph/builder/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export class PipelineContext {
2828
engineOpts!: EngineOpts;
2929
engineName!: 'native' | 'wasm';
3030
engineVersion!: string | null;
31+
/**
32+
* The version reported by the native binary itself (CARGO_PKG_VERSION at
33+
* build time), as opposed to `engineVersion` which prefers the platform
34+
* package.json. The Rust orchestrator's check_version_mismatch compares
35+
* `build_meta.engine_version` against CARGO_PKG_VERSION, so build_meta
36+
* writes must use this value to avoid a perpetual full-rebuild loop when
37+
* the binary and platform package.json drift apart (e.g., CI hot-swap
38+
* via ci-install-native.mjs — #1066).
39+
*/
40+
nativeBinaryVersion!: string | null;
3141
aliases!: PathAliases;
3242
incremental!: boolean;
3343
forceFullRebuild: boolean = false;

src/domain/graph/builder/pipeline.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,14 @@ function initializeEngine(ctx: PipelineContext): void {
7676
suspendJsDb: undefined,
7777
resumeJsDb: undefined,
7878
};
79-
const { name: engineName, version: engineVersion } = getActiveEngine(ctx.engineOpts);
79+
const {
80+
name: engineName,
81+
version: engineVersion,
82+
binaryVersion: nativeBinaryVersion,
83+
} = getActiveEngine(ctx.engineOpts);
8084
ctx.engineName = engineName as 'native' | 'wasm';
8185
ctx.engineVersion = engineVersion;
86+
ctx.nativeBinaryVersion = nativeBinaryVersion;
8287
info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
8388
}
8489

@@ -105,13 +110,15 @@ function checkEngineSchemaMismatch(ctx: PipelineContext): void {
105110
);
106111
ctx.forceFullRebuild = true;
107112
}
108-
// When the native engine is active, the Rust addon's version (ctx.engineVersion)
109-
// is written into codegraph_version by setBuildMeta after a native orchestrator
110-
// build. The check must compare against the same version, otherwise JS and Rust
111-
// fight over which version to record — causing every incremental build to be
112-
// promoted to a full rebuild when npm and crate versions diverge.
113+
// When the native engine is active, the Rust orchestrator writes
114+
// build_meta.codegraph_version = CARGO_PKG_VERSION (the binary's own value).
115+
// Compare against the same value here so a CI hot-swap that leaves the
116+
// platform package.json behind doesn't trigger a perpetual full-rebuild
117+
// loop on every incremental (#1066).
113118
const effectiveVersion =
114-
ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION;
119+
ctx.engineName === 'native' && ctx.nativeBinaryVersion
120+
? ctx.nativeBinaryVersion
121+
: CODEGRAPH_VERSION;
115122
const prevVersion = meta('codegraph_version');
116123
if (prevVersion && prevVersion !== effectiveVersion) {
117124
info(
@@ -665,16 +672,24 @@ async function tryNativeOrchestrator(
665672
const p = result.phases;
666673

667674
// Sync build_meta so JS-side version/engine checks work on next build.
668-
// Use the Rust addon version as codegraph_version when the native
669-
// orchestrator performed the build — the Rust side's check_version_mismatch
670-
// compares this value against CARGO_PKG_VERSION. Writing the JS
671-
// CODEGRAPH_VERSION here would create a permanent mismatch whenever the
672-
// npm package version diverges from the Rust crate version, forcing every
673-
// subsequent native build to be a full rebuild (no incremental).
675+
// Use the binary's CARGO_PKG_VERSION (ctx.nativeBinaryVersion), not the
676+
// platform package.json version (ctx.engineVersion). The Rust side's
677+
// check_version_mismatch compares against CARGO_PKG_VERSION; writing
678+
// the package.json value would create a permanent mismatch whenever
679+
// the binary and platform package.json diverge — e.g., CI hot-swap
680+
// via ci-install-native.mjs (#1066) — forcing every subsequent build
681+
// to be a full rebuild.
682+
//
683+
// When the native addon doesn't expose engineVersion() (older addon),
684+
// fall back to CODEGRAPH_VERSION — same fallback used by both
685+
// checkEngineSchemaMismatch (read path) and persistBuildMetadata
686+
// (the JS-pipeline write path in finalize.ts). Using ctx.engineVersion
687+
// here would re-introduce the asymmetry this PR fixes for that case.
688+
const nativeVersionForMeta = ctx.nativeBinaryVersion || CODEGRAPH_VERSION;
674689
setBuildMeta(ctx.db, {
675690
engine: ctx.engineName,
676-
engine_version: ctx.engineVersion || '',
677-
codegraph_version: ctx.engineVersion || CODEGRAPH_VERSION,
691+
engine_version: nativeVersionForMeta,
692+
codegraph_version: nativeVersionForMeta,
678693
schema_version: String(ctx.schemaVersion),
679694
built_at: new Date().toISOString(),
680695
});

src/domain/graph/builder/stages/finalize.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,16 @@ function persistBuildMetadata(
8282
): void {
8383
const useNativeDb = ctx.engineName === 'native' && !!ctx.nativeDb;
8484
if (!ctx.isFullBuild && ctx.allSymbols.size <= 3) return;
85-
// When the native engine is active, persist the Rust addon version so that
86-
// checkEngineSchemaMismatch compares against the same value on the next build.
87-
// Writing CODEGRAPH_VERSION (the npm package version) here would create a
88-
// permanent mismatch whenever npm and crate versions diverge, forcing every
89-
// subsequent build to be a full rebuild.
85+
// When the native engine is active, persist the binary's CARGO_PKG_VERSION
86+
// (ctx.nativeBinaryVersion). The Rust orchestrator's check_version_mismatch
87+
// compares against that exact value, so writing the platform package.json
88+
// version (ctx.engineVersion) — which can drift from the binary in CI
89+
// hot-swap flows (#1066) — would force every subsequent native build to
90+
// be a full rebuild.
9091
const codeVersionToWrite =
91-
ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION;
92+
ctx.engineName === 'native' && ctx.nativeBinaryVersion
93+
? ctx.nativeBinaryVersion
94+
: CODEGRAPH_VERSION;
9295
// Persist the repo root so downstream commands (e.g. `codegraph embed`)
9396
// can resolve relative file paths regardless of the invoking cwd.
9497
// Use realpathSync (symlink-resolving) to match the Rust engine's

src/domain/parser.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,23 +1197,25 @@ export async function parseFilesAuto(
11971197
export function getActiveEngine(opts: ParseEngineOpts = {}): {
11981198
name: 'native' | 'wasm';
11991199
version: string | null;
1200+
binaryVersion: string | null;
12001201
} {
12011202
const { name, native } = resolveEngine(opts);
1202-
let version: string | null = native
1203-
? typeof native.engineVersion === 'function'
1204-
? native.engineVersion()
1205-
: null
1206-
: null;
1207-
// Prefer platform package.json version over binary-embedded version
1208-
// to handle stale binaries that weren't recompiled during a release
1203+
const binaryVersion: string | null =
1204+
native && typeof native.engineVersion === 'function' ? native.engineVersion() : null;
1205+
// The display version prefers the platform package.json so the "Using native
1206+
// engine (vX)" log matches the npm release the user installed. The Rust
1207+
// orchestrator's check_version_mismatch compares against CARGO_PKG_VERSION
1208+
// (the binary's own value), so build_meta writes must use `binaryVersion`,
1209+
// not this display value — see pipeline.ts and finalize.ts (#1066).
1210+
let version: string | null = binaryVersion;
12091211
if (native) {
12101212
try {
12111213
version = getNativePackageVersion() ?? version;
12121214
} catch (e: unknown) {
12131215
debug(`getNativePackageVersion failed: ${(e as Error).message}`);
12141216
}
12151217
}
1216-
return { name, version };
1218+
return { name, version, binaryVersion };
12171219
}
12181220

12191221
/**

0 commit comments

Comments
 (0)