@@ -307,7 +307,12 @@ function haveUv(): boolean {
307307 return ! probe . error && probe . status === 0 ;
308308}
309309
310- async function buildFixture ( ) : Promise < string > {
310+ async function buildFixture (
311+ {
312+ withDependent = false ,
313+ virtualReleased = false ,
314+ } : { withDependent ?: boolean ; virtualReleased ?: boolean } = { } ,
315+ ) : Promise < string > {
311316 const root = mkdtempSync ( join ( tmpdir ( ) , "prepare-release-fixture-" ) ) ;
312317 mkdirSync ( join ( root , "scripts/release" ) , { recursive : true } ) ;
313318 mkdirSync ( join ( root , "fixture-pkg" ) , { recursive : true } ) ;
@@ -343,10 +348,12 @@ async function buildFixture(): Promise<string> {
343348 'requires-python = ">=3.10"' ,
344349 "dependencies = []" ,
345350 "" ,
346- "[build-system]" ,
347- 'requires = ["hatchling"]' ,
348- 'build-backend = "hatchling.build"' ,
349- "" ,
351+ // `package = false` makes this a VIRTUAL project, which changes the source
352+ // form a consumer's lock records for it from `directory` to `virtual`.
353+ // A virtual project has no build backend -- it is not installable.
354+ ...( virtualReleased
355+ ? [ "[tool.uv]" , "package = false" , "" ]
356+ : [ "[build-system]" , 'requires = ["hatchling"]' , 'build-backend = "hatchling.build"' , "" ] ) ,
350357 ] . join ( "\n" ) ,
351358 ) ;
352359
@@ -357,9 +364,59 @@ async function buildFixture(): Promise<string> {
357364 stdio : "ignore" ,
358365 } ) ;
359366 assert . equal ( seed . status , 0 , "fixture `uv lock` seed failed" ) ;
367+
368+ // A second, UNRELEASED package that consumes the released one through a
369+ // `[tool.uv.sources]` path override — the shape integrations/langgraph/python
370+ // has while PNI-274 is open. Its lock embeds the released package's VERSION,
371+ // so bumping the released package alone strands it.
372+ if ( withDependent ) {
373+ mkdirSync ( join ( root , "fixture-dep" ) , { recursive : true } ) ;
374+ writeFileSync (
375+ join ( root , "fixture-dep/pyproject.toml" ) ,
376+ [
377+ "[project]" ,
378+ 'name = "fixture_dep"' ,
379+ 'version = "9.9.9"' ,
380+ 'requires-python = ">=3.10"' ,
381+ 'dependencies = ["fixture_pkg"]' ,
382+ "" ,
383+ "[tool.uv.sources]" ,
384+ 'fixture_pkg = { path = "../fixture-pkg" }' ,
385+ "" ,
386+ "[build-system]" ,
387+ 'requires = ["hatchling"]' ,
388+ 'build-backend = "hatchling.build"' ,
389+ "" ,
390+ ] . join ( "\n" ) ,
391+ ) ;
392+ const depSeed = spawnSync ( "uv" , [ "lock" ] , {
393+ cwd : join ( root , "fixture-dep" ) ,
394+ stdio : "ignore" ,
395+ } ) ;
396+ assert . equal ( depSeed . status , 0 , "dependent fixture `uv lock` seed failed" ) ;
397+ }
398+
360399 return root ;
361400}
362401
402+ // The version some OTHER lock records for a package it pulls in from a local
403+ // directory. uv writes the path source as `directory = "<rel>"`, and the
404+ // embedded version goes stale the moment the released package is bumped.
405+ function pathDepVersion ( lockPath : string , relDir : string ) : string | null {
406+ const blocks = readFileSync ( lockPath , "utf8" ) . split ( "[[package]]" ) ;
407+ for ( const block of blocks ) {
408+ // uv writes `directory`, `editable` or `virtual` depending on the target; the
409+ // embedded version goes stale either way, so accept all three here.
410+ const isPathSource = [ "directory" , "editable" , "virtual" ] . some ( ( form ) =>
411+ block . includes ( `source = { ${ form } = "${ relDir } " }` ) ,
412+ ) ;
413+ if ( ! isPathSource ) continue ;
414+ const match = block . match ( / ^ v e r s i o n = " ( [ ^ " ] + ) " / m) ;
415+ if ( match ) return match [ 1 ] ;
416+ }
417+ return null ;
418+ }
419+
363420function selfEntryVersion ( lockPath : string ) : string | null {
364421 // The locked package is the one whose source is the local directory.
365422 const blocks = readFileSync ( lockPath , "utf8" ) . split ( "[[package]]" ) ;
@@ -445,3 +502,107 @@ test("a Python bump with no uv.lock reports only the manifest", {
445502
446503 rmSync ( root , { recursive : true , force : true } ) ;
447504} ) ;
505+
506+ // A released package can be consumed by another first-party package through a
507+ // `[tool.uv.sources]` path override. That consumer's uv.lock embeds the released
508+ // package's VERSION, so bumping the release alone leaves the consumer's lock
509+ // stale and the `uv lock --check` gate turns the release PR red in a package the
510+ // release did not even touch.
511+ //
512+ // This is the failure behind #2553: release/next bumped ag-ui-protocol
513+ // 0.1.20 -> 0.1.21 in sdks/python, and both the `lockfiles` and
514+ // `langgraph-python` jobs failed on integrations/langgraph/python/uv.lock —
515+ // which pins `ag-ui-protocol 0.1.20` from `directory = "../../../sdks/python"`.
516+ // Nothing was wrong with the PR; the bumper simply never relocked the consumer.
517+ test (
518+ "a Python version bump re-locks packages that path-depend on the bumped one" ,
519+ { timeout : 120_000 , skip : haveUv ( ) ? false : "uv not on PATH" } ,
520+ async ( ) => {
521+ const root = await buildFixture ( { withDependent : true } ) ;
522+ const depLock = join ( root , "fixture-dep/uv.lock" ) ;
523+
524+ assert . equal (
525+ pathDepVersion ( depLock , "../fixture-pkg" ) ,
526+ "0.1.0" ,
527+ "dependent fixture seed lock" ,
528+ ) ;
529+
530+ const result = await runPrepareRelease ( [ "--scope" , "fixture-py" , "--bump" , "minor" ] , {
531+ PREPARE_RELEASE_ROOT : root ,
532+ } ) ;
533+ assert . equal ( result . status , 0 , `stderr: ${ result . stderr } ` ) ;
534+
535+ // The regression: this stayed at 0.1.0, so `uv lock --check` failed here.
536+ assert . equal (
537+ pathDepVersion ( depLock , "../fixture-pkg" ) ,
538+ "0.2.0" ,
539+ "dependent uv.lock not re-locked" ,
540+ ) ;
541+
542+ // And it must be REPORTED, or the workflow never stages it — same failure
543+ // mode as the released package's own lock.
544+ const output = JSON . parse ( result . stdout ) ;
545+ assert . deepEqual (
546+ output . files ,
547+ // `files` is emitted sorted.
548+ [
549+ "fixture-dep/uv.lock" ,
550+ "fixture-pkg/pyproject.toml" ,
551+ "fixture-pkg/uv.lock" ,
552+ ] ,
553+ "dependent uv.lock missing from `files`" ,
554+ ) ;
555+
556+ rmSync ( root , { recursive : true , force : true } ) ;
557+ } ,
558+ ) ;
559+
560+ // uv records a path dependency in three different source forms, and the matcher
561+ // that finds dependents has to know all three or it silently skips one:
562+ //
563+ // source = { directory = "../pkg" } non-editable path source
564+ // source = { editable = "../pkg" } editable path source
565+ // source = { virtual = "../pkg" } target sets `[tool.uv] package = false`
566+ //
567+ // `virtual` is the easy one to miss, because it is the form that does NOT
568+ // correspond to something installable -- but uv still records `version = "..."`
569+ // for it, so it still goes stale on a bump and still fails `uv lock --check`.
570+ // Reported on #2555 review with a reproduction; this is that reproduction as a
571+ // test. Before the fix the matcher covered only directory|editable, so the
572+ // dependent below kept the old version and never reached `files`.
573+ test (
574+ "a Python version bump re-locks a dependent that records the `virtual` path form" ,
575+ { timeout : 120_000 , skip : haveUv ( ) ? false : "uv not on PATH" } ,
576+ async ( ) => {
577+ const root = await buildFixture ( { withDependent : true , virtualReleased : true } ) ;
578+ const depLock = join ( root , "fixture-dep/uv.lock" ) ;
579+
580+ // Guard the fixture itself: if uv ever stops emitting `virtual` here, this
581+ // test would pass for the wrong reason, so assert the form is really present.
582+ assert . match (
583+ readFileSync ( depLock , "utf8" ) ,
584+ / s o u r c e = \{ v i r t u a l = " \. \. \/ f i x t u r e - p k g " \} / ,
585+ "fixture did not produce a `virtual` path source -- test would be vacuous" ,
586+ ) ;
587+ assert . equal ( pathDepVersion ( depLock , "../fixture-pkg" ) , "0.1.0" , "dependent seed lock" ) ;
588+
589+ const result = await runPrepareRelease ( [ "--scope" , "fixture-py" , "--bump" , "minor" ] , {
590+ PREPARE_RELEASE_ROOT : root ,
591+ } ) ;
592+ assert . equal ( result . status , 0 , `stderr: ${ result . stderr } ` ) ;
593+
594+ assert . equal (
595+ pathDepVersion ( depLock , "../fixture-pkg" ) ,
596+ "0.2.0" ,
597+ "dependent uv.lock not re-locked through the `virtual` source form" ,
598+ ) ;
599+
600+ const output = JSON . parse ( result . stdout ) ;
601+ assert . ok (
602+ output . files . includes ( "fixture-dep/uv.lock" ) ,
603+ `dependent uv.lock missing from \`files\`: ${ JSON . stringify ( output . files ) } ` ,
604+ ) ;
605+
606+ rmSync ( root , { recursive : true , force : true } ) ;
607+ } ,
608+ ) ;
0 commit comments