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"));