Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/test-release-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 115 additions & 3 deletions scripts/release/prepare-release.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -31,10 +38,11 @@ function bumpMinor(version: string): string {

async function runPrepareRelease(
args: string[],
extraEnv: Record<string, string> = {},
): 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 = "";
Expand Down Expand Up @@ -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<string> {
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 });
},
);
56 changes: 55 additions & 1 deletion scripts/release/prepare-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(?:\s+[^>]*)?>([^<]+)<\/VersionPrefix>/);
Expand Down Expand Up @@ -390,6 +438,7 @@ function writeVersionFile(filePath: string, ecosystem: PackageConfig["ecosystem"
writeDotnetVersion(filePath, newVersion);
} else {
writePyVersion(filePath, newVersion);
relockPythonPackage(filePath);
}
}

Expand Down Expand Up @@ -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"));
Expand Down
Loading