Skip to content

Commit 8e3e1ad

Browse files
authored
Merge pull request #2555 from ag-ui-protocol/mme/fix-release-relock-path-dependents
fix(release): re-lock packages that path-depend on a bumped Python package
2 parents a5f8fbc + c91e76a commit 8e3e1ad

2 files changed

Lines changed: 250 additions & 15 deletions

File tree

scripts/release/prepare-release.test.ts

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/^version = "([^"]+)"/m);
415+
if (match) return match[1];
416+
}
417+
return null;
418+
}
419+
363420
function 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+
/source = \{ virtual = "\.\.\/fixture-pkg" \}/,
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+
);

scripts/release/prepare-release.ts

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -383,11 +383,24 @@ function writePyVersion(pyprojectPath: string, newVersion: string): void {
383383
* crew-ai 0.3.0 (#2366) and aws-strands 0.2.5 (#2374) both needed a hand-pushed
384384
* "sync uv.lock" commit before their release PRs could go green.
385385
*/
386-
function relockPythonPackage(pyprojectPath: string): string | null {
386+
function relockPythonPackage(repoRoot: string, pyprojectPath: string): string[] {
387387
const pkgDir = path.dirname(pyprojectPath);
388388
const lockPath = path.join(pkgDir, "uv.lock");
389-
if (!fs.existsSync(lockPath)) return null;
390389

390+
const rewritten: string[] = [];
391+
if (fs.existsSync(lockPath)) {
392+
runUvLock(pkgDir);
393+
rewritten.push(lockPath);
394+
}
395+
for (const dependentLock of findPathDependentLocks(repoRoot, pkgDir)) {
396+
runUvLock(path.dirname(dependentLock));
397+
rewritten.push(dependentLock);
398+
}
399+
return rewritten;
400+
}
401+
402+
/** `uv lock` in one directory, with the missing-uv case spelled out. */
403+
function runUvLock(pkgDir: string): void {
391404
try {
392405
// stdout belongs to this script's JSON summary -- discard uv's so the
393406
// summary stays parseable, and pass its stderr through for diagnostics.
@@ -405,8 +418,68 @@ function relockPythonPackage(pyprojectPath: string): string | null {
405418
}
406419
throw error;
407420
}
421+
}
408422

409-
return lockPath;
423+
/**
424+
* Every OTHER first-party uv.lock that pulls ``pkgDir`` in from the filesystem.
425+
*
426+
* A ``[tool.uv.sources]`` path override makes a consumer's lock carry the
427+
* released package's VERSION, not just its name:
428+
*
429+
* [[package]]
430+
* name = "ag-ui-protocol"
431+
* version = "0.1.20"
432+
* source = { directory = "../../../sdks/python" }
433+
*
434+
* So bumping the released package strands every such consumer, and the
435+
* ``uv lock --check`` gate then fails in a package the release never touched.
436+
* That is #2553: release/next bumped ag-ui-protocol 0.1.20 -> 0.1.21 and both
437+
* the ``lockfiles`` and ``langgraph-python`` jobs went red on
438+
* integrations/langgraph/python/uv.lock.
439+
*
440+
* Scope matches the gate this exists to satisfy (see the ``lockfiles`` job in
441+
* unit-python-sdk.yml): first-party locks only, ``examples/`` excluded. Example
442+
* locks are deliberately left alone -- they are not gated, several are stale
443+
* today, and dojo-e2e relocks them non-frozen anyway, so touching them here
444+
* would drag unrelated dependency churn into every release PR.
445+
*
446+
* Matching is on the resolved directory rather than the literal string, because
447+
* the same package is reached by a different relative path from each consumer.
448+
*/
449+
function findPathDependentLocks(repoRoot: string, pkgDir: string): string[] {
450+
const SKIP = new Set(["node_modules", ".venv", ".git", "examples"]);
451+
const found: string[] = [];
452+
453+
const walk = (dir: string): void => {
454+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
455+
if (entry.isDirectory()) {
456+
if (SKIP.has(entry.name)) continue;
457+
walk(path.join(dir, entry.name));
458+
} else if (entry.name === "uv.lock" && dir !== pkgDir) {
459+
found.push(path.join(dir, entry.name));
460+
}
461+
}
462+
};
463+
walk(repoRoot);
464+
465+
return found.filter((lockPath) => {
466+
const lockDir = path.dirname(lockPath);
467+
// uv has three directory-based source forms, and all three embed the
468+
// dependency's version, so all three go stale on a bump:
469+
// directory -- a non-editable path source
470+
// editable -- an editable path source
471+
// virtual -- a path source whose target sets `[tool.uv] package = false`
472+
// `virtual` is easy to miss because it is the one that does not correspond to
473+
// something installable, but uv still records `version = "..."` for it and
474+
// still fails `uv lock --check` when that version moves.
475+
const sources = fs
476+
.readFileSync(lockPath, "utf-8")
477+
.matchAll(/^source = \{ (?:directory|editable|virtual) = "([^"]+)" \}$/gm);
478+
for (const [, rel] of sources) {
479+
if (path.resolve(lockDir, rel) === pkgDir) return true;
480+
}
481+
return false;
482+
});
410483
}
411484

412485
function readDotnetVersion(propsPath: string): string {
@@ -617,6 +690,7 @@ function readVersion(repoRoot: string, pkg: PackageConfig, versionSource?: strin
617690

618691
/** Returns the absolute path of every file written (Maven fans out to modules). */
619692
function writeVersionFile(
693+
repoRoot: string,
620694
filePath: string,
621695
ecosystem: PackageConfig["ecosystem"],
622696
newVersion: string
@@ -629,10 +703,10 @@ function writeVersionFile(
629703
return writeMavenVersion(filePath, newVersion);
630704
} else {
631705
writePyVersion(filePath, newVersion);
632-
// The re-locked uv.lock is a second modified file and must be reported, or
633-
// the release workflow never stages it -- see relockPythonPackage.
634-
const lockPath = relockPythonPackage(filePath);
635-
if (lockPath) return [filePath, lockPath];
706+
// Each re-locked uv.lock -- this package's own, plus any consumer that
707+
// path-depends on it -- is a further modified file and must be reported, or
708+
// the release workflow never stages it. See relockPythonPackage.
709+
return [filePath, ...relockPythonPackage(repoRoot, filePath)];
636710
}
637711
return [filePath];
638712
}
@@ -644,7 +718,7 @@ function writeVersion(
644718
versionSource?: string
645719
): string[] {
646720
const filePath = getVersionFilePath(repoRoot, pkg, versionSource);
647-
return writeVersionFile(filePath, pkg.ecosystem, newVersion);
721+
return writeVersionFile(repoRoot, filePath, pkg.ecosystem, newVersion);
648722
}
649723

650724
function computeNewVersion(
@@ -719,7 +793,7 @@ function main(): void {
719793
console.error(`[${args.scope}] Shared version: ${currentVersion} -> ${newVersion}`);
720794

721795
if (versionSourceEcosystem === "dotnet" && args.bump !== "prerelease" && !args.dryRun) {
722-
recordWritten(writeVersionFile(versionSourcePath, versionSourceEcosystem, newVersion));
796+
recordWritten(writeVersionFile(repoRoot, versionSourcePath, versionSourceEcosystem, newVersion));
723797
const written = readVersionFile(versionSourcePath, versionSourceEcosystem);
724798
if (written !== newVersion) {
725799
console.error(`ERROR: Verification failed for ${scopeConfig.versionSource}: expected ${newVersion}, got ${written}`);
@@ -732,7 +806,7 @@ function main(): void {
732806
// -p:VersionSuffix and the props file stays put), the pom is the only place
733807
// a Maven version exists.
734808
if (versionSourceEcosystem === "maven" && !args.dryRun) {
735-
recordWritten(writeVersionFile(versionSourcePath, versionSourceEcosystem, newVersion));
809+
recordWritten(writeVersionFile(repoRoot, versionSourcePath, versionSourceEcosystem, newVersion));
736810
const written = readVersionFile(versionSourcePath, versionSourceEcosystem);
737811
if (written !== newVersion) {
738812
console.error(`ERROR: Verification failed for ${scopeConfig.versionSource}: expected ${newVersion}, got ${written}`);

0 commit comments

Comments
 (0)