From 6d7d0bacceeeb07a9a55e974f2e58b5840c2eb22 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 6 May 2026 01:44:55 -0600 Subject: [PATCH 1/2] fix(native): break engine_version mismatch loop in CI-hot-swap flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/publish.yml | 9 +++ scripts/ci-install-native.mjs | 61 ++++++++++++++++++++- src/domain/graph/builder/context.ts | 10 ++++ src/domain/graph/builder/pipeline.ts | 39 ++++++++----- src/domain/graph/builder/stages/finalize.ts | 15 +++-- src/domain/parser.ts | 18 +++--- 6 files changed, 121 insertions(+), 31 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b5081524..fa50ce505 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -284,6 +284,15 @@ jobs: run: npm install - name: Install native addon over published binary + # Pass NATIVE_BUILD_VERSION so the script also rewrites the platform + # package.json's version to match the binary's CARGO_PKG_VERSION + # (build-native bumps Cargo.toml to this same value before building). + # Without this, the JS-side getNativePackageVersion() returns the + # last-published version while the binary reports the bumped version, + # and the Rust orchestrator's check_version_mismatch forces every + # incremental rebuild back through the full pipeline (#1066). + env: + NATIVE_BUILD_VERSION: ${{ needs.compute-version.outputs.version }} run: node scripts/ci-install-native.mjs # Build dist/ so benchmarks load the same compiled JS that ships to npm. diff --git a/scripts/ci-install-native.mjs b/scripts/ci-install-native.mjs index 3606c9cbc..69040f002 100644 --- a/scripts/ci-install-native.mjs +++ b/scripts/ci-install-native.mjs @@ -7,6 +7,19 @@ * Used by the CI `test` and `parity` jobs so they exercise the native engine * built from the PR's Rust source rather than the last-published binary, * which lags behind PR changes and causes false parity failures. + * + * Also rewrites the platform package's `package.json` `version` field to + * match the just-built binary's `CARGO_PKG_VERSION`. Without this step the + * JS-side `getNativePackageVersion()` returns the published version while + * the binary reports the bumped version, and the Rust orchestrator's + * check_version_mismatch then forces every incremental rebuild back through + * the full pipeline (~2s floor in #1066). + * + * The version is read from `NATIVE_BUILD_VERSION` if set (use this when the + * artifact was built from a workflow that bumped Cargo.toml — e.g. + * publish.yml's build-native job sets it from compute-version output), + * falling back to `crates/codegraph-core/Cargo.toml` for flows that build + * locally without a version bump. */ import fs from 'node:fs'; @@ -59,8 +72,52 @@ if (built.length > 1) { const src = built[0]; const pkg = resolvePackage(); -const dest = path.join('node_modules', pkg, 'codegraph-core.node'); +const destDir = path.join('node_modules', pkg); +const dest = path.join(destDir, 'codegraph-core.node'); -fs.mkdirSync(path.dirname(dest), { recursive: true }); +fs.mkdirSync(destDir, { recursive: true }); fs.copyFileSync(src, dest); console.log(`[ci-install-native] copied ${src} -> ${dest}`); + +// Resolve the binary's CARGO_PKG_VERSION. We can't read it from the .node +// directly, so we accept it via env var (preferred) or fall back to the +// Cargo.toml on disk — which is correct for flows that build the artifact +// in the same checkout (no version bump between Cargo read and build). +function resolveBinaryVersion() { + const envVersion = process.env.NATIVE_BUILD_VERSION?.trim(); + if (envVersion) return envVersion; + const cargoPath = path.join('crates', 'codegraph-core', 'Cargo.toml'); + try { + const cargoToml = fs.readFileSync(cargoPath, 'utf8'); + // Match the first `version = "X.Y.Z"` after the [package] header so we + // don't accidentally pick up a dependency's version pin. + const pkgSection = cargoToml.split(/^\[/m)[1] ?? cargoToml; + const m = pkgSection.match(/version\s*=\s*"([^"]+)"/); + return m?.[1] ?? null; + } catch (e) { + console.warn(`[ci-install-native] failed to read ${cargoPath}: ${e.message}`); + return null; + } +} + +const binaryVersion = resolveBinaryVersion(); +const pkgJsonPath = path.join(destDir, 'package.json'); +if (binaryVersion && fs.existsSync(pkgJsonPath)) { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const prev = pkgJson.version; + if (prev !== binaryVersion) { + pkgJson.version = binaryVersion; + fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`); + console.log( + `[ci-install-native] updated ${pkgJsonPath} version: ${prev} -> ${binaryVersion}`, + ); + } else { + console.log( + `[ci-install-native] ${pkgJsonPath} version already ${binaryVersion} — no rewrite needed`, + ); + } +} else if (!binaryVersion) { + console.warn( + '[ci-install-native] could not resolve binary version (NATIVE_BUILD_VERSION unset and Cargo.toml unreadable) — leaving platform package.json untouched', + ); +} diff --git a/src/domain/graph/builder/context.ts b/src/domain/graph/builder/context.ts index 330aa0e35..432866958 100644 --- a/src/domain/graph/builder/context.ts +++ b/src/domain/graph/builder/context.ts @@ -28,6 +28,16 @@ export class PipelineContext { engineOpts!: EngineOpts; engineName!: 'native' | 'wasm'; engineVersion!: string | null; + /** + * The version reported by the native binary itself (CARGO_PKG_VERSION at + * build time), as opposed to `engineVersion` which prefers the platform + * package.json. The Rust orchestrator's check_version_mismatch compares + * `build_meta.engine_version` against CARGO_PKG_VERSION, so build_meta + * writes must use this value to avoid a perpetual full-rebuild loop when + * the binary and platform package.json drift apart (e.g., CI hot-swap + * via ci-install-native.mjs — #1066). + */ + nativeBinaryVersion!: string | null; aliases!: PathAliases; incremental!: boolean; forceFullRebuild: boolean = false; diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index 6c37e3d10..440f4d6ae 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -76,9 +76,14 @@ function initializeEngine(ctx: PipelineContext): void { suspendJsDb: undefined, resumeJsDb: undefined, }; - const { name: engineName, version: engineVersion } = getActiveEngine(ctx.engineOpts); + const { + name: engineName, + version: engineVersion, + binaryVersion: nativeBinaryVersion, + } = getActiveEngine(ctx.engineOpts); ctx.engineName = engineName as 'native' | 'wasm'; ctx.engineVersion = engineVersion; + ctx.nativeBinaryVersion = nativeBinaryVersion; info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`); } @@ -105,13 +110,15 @@ function checkEngineSchemaMismatch(ctx: PipelineContext): void { ); ctx.forceFullRebuild = true; } - // When the native engine is active, the Rust addon's version (ctx.engineVersion) - // is written into codegraph_version by setBuildMeta after a native orchestrator - // build. The check must compare against the same version, otherwise JS and Rust - // fight over which version to record — causing every incremental build to be - // promoted to a full rebuild when npm and crate versions diverge. + // When the native engine is active, the Rust orchestrator writes + // build_meta.codegraph_version = CARGO_PKG_VERSION (the binary's own value). + // Compare against the same value here so a CI hot-swap that leaves the + // platform package.json behind doesn't trigger a perpetual full-rebuild + // loop on every incremental (#1066). const effectiveVersion = - ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION; + ctx.engineName === 'native' && ctx.nativeBinaryVersion + ? ctx.nativeBinaryVersion + : CODEGRAPH_VERSION; const prevVersion = meta('codegraph_version'); if (prevVersion && prevVersion !== effectiveVersion) { info( @@ -665,16 +672,18 @@ async function tryNativeOrchestrator( const p = result.phases; // Sync build_meta so JS-side version/engine checks work on next build. - // Use the Rust addon version as codegraph_version when the native - // orchestrator performed the build — the Rust side's check_version_mismatch - // compares this value against CARGO_PKG_VERSION. Writing the JS - // CODEGRAPH_VERSION here would create a permanent mismatch whenever the - // npm package version diverges from the Rust crate version, forcing every - // subsequent native build to be a full rebuild (no incremental). + // Use the binary's CARGO_PKG_VERSION (ctx.nativeBinaryVersion), not the + // platform package.json version (ctx.engineVersion). The Rust side's + // check_version_mismatch compares against CARGO_PKG_VERSION; writing + // the package.json value would create a permanent mismatch whenever + // the binary and platform package.json diverge — e.g., CI hot-swap + // via ci-install-native.mjs (#1066) — forcing every subsequent build + // to be a full rebuild. + const nativeVersionForMeta = ctx.nativeBinaryVersion || ctx.engineVersion || ''; setBuildMeta(ctx.db, { engine: ctx.engineName, - engine_version: ctx.engineVersion || '', - codegraph_version: ctx.engineVersion || CODEGRAPH_VERSION, + engine_version: nativeVersionForMeta, + codegraph_version: nativeVersionForMeta || CODEGRAPH_VERSION, schema_version: String(ctx.schemaVersion), built_at: new Date().toISOString(), }); diff --git a/src/domain/graph/builder/stages/finalize.ts b/src/domain/graph/builder/stages/finalize.ts index 74f1dfbbc..d59fe016a 100644 --- a/src/domain/graph/builder/stages/finalize.ts +++ b/src/domain/graph/builder/stages/finalize.ts @@ -82,13 +82,16 @@ function persistBuildMetadata( ): void { const useNativeDb = ctx.engineName === 'native' && !!ctx.nativeDb; if (!ctx.isFullBuild && ctx.allSymbols.size <= 3) return; - // When the native engine is active, persist the Rust addon version so that - // checkEngineSchemaMismatch compares against the same value on the next build. - // Writing CODEGRAPH_VERSION (the npm package version) here would create a - // permanent mismatch whenever npm and crate versions diverge, forcing every - // subsequent build to be a full rebuild. + // When the native engine is active, persist the binary's CARGO_PKG_VERSION + // (ctx.nativeBinaryVersion). The Rust orchestrator's check_version_mismatch + // compares against that exact value, so writing the platform package.json + // version (ctx.engineVersion) — which can drift from the binary in CI + // hot-swap flows (#1066) — would force every subsequent native build to + // be a full rebuild. const codeVersionToWrite = - ctx.engineName === 'native' && ctx.engineVersion ? ctx.engineVersion : CODEGRAPH_VERSION; + ctx.engineName === 'native' && ctx.nativeBinaryVersion + ? ctx.nativeBinaryVersion + : CODEGRAPH_VERSION; // Persist the repo root so downstream commands (e.g. `codegraph embed`) // can resolve relative file paths regardless of the invoking cwd. // Use realpathSync (symlink-resolving) to match the Rust engine's diff --git a/src/domain/parser.ts b/src/domain/parser.ts index fa97e9c06..f1c7dd809 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -1197,15 +1197,17 @@ export async function parseFilesAuto( export function getActiveEngine(opts: ParseEngineOpts = {}): { name: 'native' | 'wasm'; version: string | null; + binaryVersion: string | null; } { const { name, native } = resolveEngine(opts); - let version: string | null = native - ? typeof native.engineVersion === 'function' - ? native.engineVersion() - : null - : null; - // Prefer platform package.json version over binary-embedded version - // to handle stale binaries that weren't recompiled during a release + const binaryVersion: string | null = + native && typeof native.engineVersion === 'function' ? native.engineVersion() : null; + // The display version prefers the platform package.json so the "Using native + // engine (vX)" log matches the npm release the user installed. The Rust + // orchestrator's check_version_mismatch compares against CARGO_PKG_VERSION + // (the binary's own value), so build_meta writes must use `binaryVersion`, + // not this display value — see pipeline.ts and finalize.ts (#1066). + let version: string | null = binaryVersion; if (native) { try { version = getNativePackageVersion() ?? version; @@ -1213,7 +1215,7 @@ export function getActiveEngine(opts: ParseEngineOpts = {}): { debug(`getNativePackageVersion failed: ${(e as Error).message}`); } } - return { name, version }; + return { name, version, binaryVersion }; } /** From 637c233472f595335d1b0e176c897f6814b4a10c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 6 May 2026 12:21:38 -0600 Subject: [PATCH 2/2] fix(native): align tryNativeOrchestrator write path with read fallback (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/domain/graph/builder/pipeline.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/graph/builder/pipeline.ts b/src/domain/graph/builder/pipeline.ts index 440f4d6ae..55096e060 100644 --- a/src/domain/graph/builder/pipeline.ts +++ b/src/domain/graph/builder/pipeline.ts @@ -679,11 +679,17 @@ async function tryNativeOrchestrator( // the binary and platform package.json diverge — e.g., CI hot-swap // via ci-install-native.mjs (#1066) — forcing every subsequent build // to be a full rebuild. - const nativeVersionForMeta = ctx.nativeBinaryVersion || ctx.engineVersion || ''; + // + // When the native addon doesn't expose engineVersion() (older addon), + // fall back to CODEGRAPH_VERSION — same fallback used by both + // checkEngineSchemaMismatch (read path) and persistBuildMetadata + // (the JS-pipeline write path in finalize.ts). Using ctx.engineVersion + // here would re-introduce the asymmetry this PR fixes for that case. + const nativeVersionForMeta = ctx.nativeBinaryVersion || CODEGRAPH_VERSION; setBuildMeta(ctx.db, { engine: ctx.engineName, engine_version: nativeVersionForMeta, - codegraph_version: nativeVersionForMeta || CODEGRAPH_VERSION, + codegraph_version: nativeVersionForMeta, schema_version: String(ctx.schemaVersion), built_at: new Date().toISOString(), });