From f9f95f13a7a1c936ec8e26a7bc0be9dd596da087 Mon Sep 17 00:00:00 2001 From: Mark Fogle Date: Tue, 4 Aug 2026 23:36:01 +0000 Subject: [PATCH] fix(release): re-lock uv-managed Python packages after a version bump uv.lock carries an entry for the package it locks -- the one whose source is { editable = "." } -- so editing pyproject.toml alone leaves that entry one version stale. prepare-release.ts did exactly that, so every release shipped a stale lock: four consecutive aws-strands releases are each a one-line, one-file commit, and ag_ui_adk drifted five releases deep before anyone noticed. #2313 repaired the accumulated drift; this stops it recurring. Runs `uv lock`, which is what the tooling is for. Note it may also flush latent metadata corrections unrelated to the bump: it rewrites the whole file once it has any reason to, and what it writes reflects the package metadata in uv's cache at that moment. Measured -- the same uv 0.9.24 binary added an exceptiongroup dependency marker on Aug 4 that it had not added on Jul 30 from an identical starting state, because the cache had refreshed from PyPI in between. Those are corrections rather than corruption (exceptiongroup genuinely needs typing-extensions only below 3.13), and the companion `uv lock --check` CI gate (#2315) keeps them from accumulating: with locks continuously current, a release bump has nothing extra to flush and its diff stays to the version line. Packages with no uv.lock (poetry-managed, or unlocked) are skipped; a missing uv is fatal rather than skipped, since silently publishing a stale lock is the failure this exists to prevent. uv's stdout is discarded so the script's JSON summary stays parseable, with stderr passed through for diagnostics. Testability: repoRoot was pinned to the script's own location, so a non-dry-run would have edited the real repo and --dry-run (which never writes) was the only safe mode -- leaving the write path uncovered. It now honours PREPARE_RELEASE_ROOT, so a test can point config, package files and lockfiles at a throwaway tree. The added test builds a dependency-free fixture package (so `uv lock` needs no network), seeds its lock with a real uv run rather than hand-writing one, bumps it through the actual script, and asserts pyproject.toml and uv.lock both land on the new version. Commenting out the re-lock makes it fail with "uv.lock self-entry not re-locked", so it guards the regression rather than the implementation. The test skips itself when uv is absent, so test-release-scripts.yml gains the uv install -- otherwise it would pass vacuously in CI and give false confidence. Rejected: a main-guard refactor to make the script importable. A guard whose condition silently mismatched would stop main() running at all -- a quiet no-op in release tooling, worse than the bug being fixed. The env override achieves the same testability with no effect on normal invocation. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/prepare-release.yml | 8 ++ .github/workflows/test-release-scripts.yml | 7 ++ scripts/release/prepare-release.test.ts | 118 ++++++++++++++++++++- scripts/release/prepare-release.ts | 56 +++++++++- 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index cec10226e0..968d1991e4 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -132,6 +132,14 @@ jobs: with: python-version: "3.12" + # prepare-release.ts re-locks any uv-managed Python package it bumps, so uv + # must be on PATH or the bump aborts rather than shipping a stale lock. + # Same pinned action and floor as unit-python-sdk.yml. + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: ">=0.8.0" + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/test-release-scripts.yml b/.github/workflows/test-release-scripts.yml index ab4cd102ef..7c0e981c0c 100644 --- a/.github/workflows/test-release-scripts.yml +++ b/.github/workflows/test-release-scripts.yml @@ -39,6 +39,13 @@ jobs: with: node-version: "22" + # The uv.lock re-lock test skips itself when uv is absent, so without this + # it would pass vacuously here and give false confidence. + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: ">=0.8.0" + - name: Install dependencies # --ignore-scripts: we only need tsx + node; skip postinstall builds. # --frozen-lockfile: hermetic, matches release workflow discipline. diff --git a/scripts/release/prepare-release.test.ts b/scripts/release/prepare-release.test.ts index 9fb78f939f..1b7c92df73 100644 --- a/scripts/release/prepare-release.test.ts +++ b/scripts/release/prepare-release.test.ts @@ -1,7 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; const SCRIPT = join(process.cwd(), "scripts/release/prepare-release.ts"); @@ -31,10 +38,11 @@ function bumpMinor(version: string): string { async function runPrepareRelease( args: string[], + extraEnv: Record = {}, ): Promise<{ status: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn("node", ["--import", "tsx", SCRIPT, ...args], { - env: process.env, + env: { ...process.env, ...extraEnv }, }); let stdout = ""; let stderr = ""; @@ -127,3 +135,107 @@ test( ); }, ); + +// The write path was previously untestable: repoRoot was pinned to the script's +// own location, so a non-dry-run would have edited the real repo, leaving +// --dry-run (which never writes) as the only safe mode. PREPARE_RELEASE_ROOT +// redirects config, package files and lockfiles at a throwaway tree, so the +// uv.lock re-lock can be exercised for real. +// +// Guards the drift behind #2313/#2314: bumping pyproject.toml alone left every +// released package's uv.lock self-entry a version stale. +function haveUv(): boolean { + const probe = spawnSync("uv", ["--version"], { stdio: "ignore" }); + return !probe.error && probe.status === 0; +} + +async function buildFixture(): Promise { + const root = mkdtempSync(join(tmpdir(), "prepare-release-fixture-")); + mkdirSync(join(root, "scripts/release"), { recursive: true }); + mkdirSync(join(root, "fixture-pkg"), { recursive: true }); + + writeFileSync( + join(root, "scripts/release/release.config.json"), + JSON.stringify({ + prereleaseTag: "alpha", + scopes: { + "fixture-py": { + description: "Fixture package (Python, uv)", + sharedVersion: false, + packages: [ + { + name: "fixture_pkg", + path: "fixture-pkg", + ecosystem: "python", + buildSystem: "uv", + }, + ], + }, + }, + }), + ); + + // No dependencies, so `uv lock` needs no network and resolves instantly. + writeFileSync( + join(root, "fixture-pkg/pyproject.toml"), + [ + "[project]", + 'name = "fixture_pkg"', + 'version = "0.1.0"', + 'requires-python = ">=3.10"', + "dependencies = []", + "", + "[build-system]", + 'requires = ["hatchling"]', + 'build-backend = "hatchling.build"', + "", + ].join("\n"), + ); + + // Seed a real lock rather than hand-writing one, so the self-entry is + // whatever this uv actually emits. + const seed = spawnSync("uv", ["lock"], { + cwd: join(root, "fixture-pkg"), + stdio: "ignore", + }); + assert.equal(seed.status, 0, "fixture `uv lock` seed failed"); + return root; +} + +function selfEntryVersion(lockPath: string): string | null { + // The locked package is the one whose source is the local directory. + const blocks = readFileSync(lockPath, "utf8").split("[[package]]"); + for (const block of blocks) { + if (!block.includes('source = { editable = "." }')) continue; + const match = block.match(/^version = "([^"]+)"/m); + if (match) return match[1]; + } + return null; +} + +test( + "a Python version bump re-locks uv.lock's self-entry", + { timeout: 120_000, skip: haveUv() ? false : "uv not on PATH" }, + async () => { + const root = await buildFixture(); + const pyproject = join(root, "fixture-pkg/pyproject.toml"); + const lock = join(root, "fixture-pkg/uv.lock"); + + assert.equal(selfEntryVersion(lock), "0.1.0", "fixture seed lock"); + + const result = await runPrepareRelease(["--scope", "fixture-py", "--bump", "minor"], { + PREPARE_RELEASE_ROOT: root, + }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + + // stdout must stay parseable — uv's own output is discarded for this reason. + const output = JSON.parse(result.stdout); + assert.equal(output.packages[0].newVersion, "0.2.0"); + + assert.match(readFileSync(pyproject, "utf8"), /^version = "0\.2\.0"$/m); + // The regression: this stayed at 0.1.0 before the fix. + assert.equal(selfEntryVersion(lock), "0.2.0", "uv.lock self-entry not re-locked"); + + rmSync(root, { recursive: true, force: true }); + }, +); diff --git a/scripts/release/prepare-release.ts b/scripts/release/prepare-release.ts index 0dd531f08d..fd3626ebc9 100644 --- a/scripts/release/prepare-release.ts +++ b/scripts/release/prepare-release.ts @@ -18,6 +18,7 @@ * { "scope": "...", "packages": [{ "name", "oldVersion", "newVersion", "file", "path" }] } */ +import { execFileSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; @@ -328,6 +329,53 @@ function writePyVersion(pyprojectPath: string, newVersion: string): void { fs.writeFileSync(pyprojectPath, lines.join('\n'), "utf-8"); } +/** + * Re-lock a uv-managed Python package after its version has been bumped. + * + * uv.lock carries an entry for the package it locks -- the one whose ``source`` + * is ``{ editable = "." }`` -- so editing pyproject.toml alone leaves that entry + * one version stale. Every release did exactly that, so every release shipped a + * stale lock: four consecutive aws-strands releases are each a one-line, + * one-file commit, and ag_ui_adk drifted five releases deep before anyone + * noticed. #2313 repaired the accumulated drift; this stops it recurring. + * + * ``uv lock`` may also flush latent metadata corrections that have nothing to do + * with the bump -- it rewrites the whole file once it has any reason to, and what + * it writes reflects the package metadata in uv's cache at that moment. Observed: + * the same uv binary added an ``exceptiongroup`` dependency marker on Aug 4 that + * it had not added on Jul 30, because the cache had refreshed from PyPI in + * between. Those are corrections rather than corruption, and the companion + * ``uv lock --check`` CI gate is what keeps them from piling up: with locks kept + * continuously current, a release bump has nothing extra to flush and its diff + * stays to the version line. + * + * Packages with no uv.lock (poetry-managed, or unlocked) are skipped. A missing + * ``uv`` is fatal rather than skipped -- silently shipping a stale lock is the + * exact failure this exists to prevent. + */ +function relockPythonPackage(pyprojectPath: string): void { + const pkgDir = path.dirname(pyprojectPath); + if (!fs.existsSync(path.join(pkgDir, "uv.lock"))) return; + + try { + // stdout belongs to this script's JSON summary -- discard uv's so the + // summary stays parseable, and pass its stderr through for diagnostics. + execFileSync("uv", ["lock"], { + cwd: pkgDir, + stdio: ["ignore", "ignore", "inherit"], + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error( + `uv is required to re-lock ${pkgDir} after a version bump, but was not ` + + `found on PATH. Install uv (https://docs.astral.sh/uv/) -- without it ` + + `the release would publish a uv.lock pinning the previous version.`, + ); + } + throw error; + } +} + function readDotnetVersion(propsPath: string): string { const content = fs.readFileSync(propsPath, "utf-8"); const match = content.match(/]*)?>([^<]+)<\/VersionPrefix>/); @@ -390,6 +438,7 @@ function writeVersionFile(filePath: string, ecosystem: PackageConfig["ecosystem" writeDotnetVersion(filePath, newVersion); } else { writePyVersion(filePath, newVersion); + relockPythonPackage(filePath); } } @@ -419,7 +468,12 @@ function computeNewVersion( function main(): void { const args = parseArgs(); - const repoRoot = path.resolve(__dirname, "../.."); + // Normally the repo this script ships in. Overridable so tests can point the + // whole thing -- config, package files, lockfiles -- at a throwaway fixture + // tree, since the write path cannot otherwise be exercised without editing + // the real repo. + const repoRoot = + process.env.PREPARE_RELEASE_ROOT ?? path.resolve(__dirname, "../.."); const configPath = path.join(repoRoot, "scripts/release/release.config.json"); const config: ReleaseConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));