Files
servers/scripts/prepare-python-release.mjs
cliffhallandClaude Opus 5.5 6a1d753914 fix: stamp on a license change; keep the Python release PR on v2/main and its body current
- A license file ships in both distributions, so a change to one now counts
  as a shipped change.
- prepare-python-release.yml refuses a dispatch from any ref but v2/main,
  since the dispatch ref becomes the PR's base.
- A same-day rerun rewrites the open PR's body to match the force-updated
  branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-10-01 09:45:14 -04:00

218 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
// Stamp today's CalVer date onto each Python server that changed since its
// last version bump (#4472).
//
// The Python servers stay on CalVer (`2026.8.1`) while the TypeScript ones move
// to semver under changesets: pip and uv have no dist-tags and always install
// the numerically highest version, so the `2026.x` line on PyPI has to keep
// climbing. What changes is WHEN the version is written. The old pipeline
// (`scripts/release.py`) stamped it inside the release run and pushed the
// result as a tag only, so `v2/main` never held the version that shipped. Now
// the version in `pyproject.toml` is the source of truth, it changes only in a
// reviewed PR, and `release.yml` publishes whatever version it finds that the
// registry does not have yet. This script is what writes that PR's content;
// `.github/workflows/prepare-python-release.yml` runs it and opens the PR.
//
// For each `src/*/pyproject.toml`:
//
// 1. Find the last commit that changed its `version` line.
// 2. If a file that ships in the package changed since then, set the version
// to today's date and refresh `uv.lock`, which records the project's own
// version and is installed `--locked` in CI.
//
// "Ships in the package" is read off the path: Python sources, the `py.typed`
// marker, Markdown (the README is the sdist's long description), a license
// file, and `pyproject.toml` itself (a dependency change). A test-only change does not
// count: nothing a user installs is different. Over-stamping would be harmless
// anyway, since the registry guard in `release.yml` decides what publishes;
// under-stamping is the failure that matters, because a changed package whose
// version did not move is silently not released.
//
// It is JavaScript rather than the Python it replaces so that it sits under the
// same format, lint and `node --test` checks as every other script here, with
// no second toolchain for the gate to cover. Only `uv lock` needs `uv`.
//
// stdout carries one `name: old -> new` line per stamped package, which the
// workflow uses as the PR body; everything else goes to stderr.
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
/**
* The CalVer version for a date: `YEAR.MONTH.DAY`, no zero padding, in UTC so
* the result does not depend on the runner's timezone.
*
* @param {Date} date
* @returns {string}
*/
export function calver(date) {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}`;
}
/**
* Does a change to this path change what the package ships?
*
* @param {string} file a path relative to the package directory, POSIX-style
* @returns {boolean}
*/
export function shipsInPackage(file) {
const parts = file.split("/");
if (parts.some((p) => p === "tests" || p === "test")) return false;
const name = parts.at(-1) ?? "";
return (
name === "pyproject.toml" ||
/\.(?:py|md|typed)$/.test(name) ||
// Hatchling puts a license file in both distributions.
/^(?:LICEN[CS]E|COPYING|NOTICE)(?:\.|$)/i.test(name)
);
}
/**
* The `[project]` table's `name` and `version`, read without a TOML parser:
* both are plain one-line strings in every server here, and a manifest where
* they are not fails loudly rather than being guessed at.
*
* @param {string} toml the text of a pyproject.toml
* @returns {{ name: string, version: string }}
*/
export function readProject(toml) {
const table = projectTable(toml);
const field = (key) => {
const m = new RegExp(`^${key}\\s*=\\s*"([^"]*)"\\s*(?:#.*)?$`, "m").exec(
table.text,
);
if (!m)
throw new Error(`pyproject.toml: no \`${key} = "…"\` line in [project]`);
return m[1];
};
return { name: field("name"), version: field("version") };
}
/**
* `toml` with the `[project]` version replaced, everything else byte-identical.
*
* @param {string} toml the text of a pyproject.toml
* @param {string} version
* @returns {string}
*/
export function stampVersion(toml, version) {
const table = projectTable(toml);
const line = /^version\s*=\s*"[^"]*"/m;
if (!line.test(table.text))
throw new Error('pyproject.toml: no `version = "…"` line in [project]');
return (
toml.slice(0, table.start) +
table.text.replace(line, `version = "${version}"`) +
toml.slice(table.end)
);
}
/** The span of the `[project]` table: from its header to the next table. */
function projectTable(toml) {
const header = /^\[project\]\s*$/m.exec(toml);
if (!header) throw new Error("pyproject.toml: no [project] table");
const start = header.index + header[0].length;
const next = /^\[/m.exec(toml.slice(start));
const end = next ? start + next.index : toml.length;
return { start, end, text: toml.slice(start, end) };
}
function run(command, args, cwd) {
const res = spawnSync(command, args, { cwd, encoding: "utf8" });
if (res.error) throw res.error;
if (res.status !== 0)
throw new Error(
`${command} ${args.join(" ")} exited ${res.status} in ${cwd}: ${res.stderr.trim()}`,
);
return res.stdout.trim();
}
/** The real git and uv; `main` takes a replacement as a test seam. */
export const TOOLS = {
/** The last commit that changed the version line, or "" when there is none. */
lastVersionBump: (dir) =>
run(
"git",
[
"log",
"-1",
"--format=%H",
"-G",
"^version[[:space:]]*=",
"--",
"pyproject.toml",
],
dir,
),
/** Paths under `dir` that differ from `commit`, relative to `dir`. */
changedSince: (dir, commit) =>
run("git", ["diff", "--name-only", "--relative", commit, "--", "."], dir)
.split("\n")
.filter(Boolean),
/** Bring `uv.lock` in line with the stamped pyproject.toml. */
lock: (dir) => {
run("uv", ["lock"], dir);
},
};
/**
* @param {object} [options]
* @param {string} [options.root] the repository root
* @param {Date} [options.today]
* @param {typeof TOOLS} [options.tools]
* @returns {number} the exit code
*/
export function main({
root = repoRoot,
today = new Date(),
tools = TOOLS,
} = {}) {
const src = path.join(root, "src");
const packages = readdirSync(src, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => path.join(src, e.name))
.filter((dir) => existsSync(path.join(dir, "pyproject.toml")))
.sort();
// A moved `src/` would otherwise read as "nothing to stamp" and the release
// would ship no Python package without anyone being told.
if (packages.length === 0) {
console.error(`prepare-python-release: no pyproject.toml under ${src}`);
return 1;
}
const version = calver(today);
for (const dir of packages) {
const manifest = path.join(dir, "pyproject.toml");
const toml = readFileSync(manifest, "utf8");
const { name, version: current } = readProject(toml);
if (current === version) {
console.error(`${name}: already at ${version}, skipping`);
continue;
}
// No bump commit at all means the history is shallow or the package is
// new. Stamp it: the registry guard makes a needless stamp harmless.
const since = tools.lastVersionBump(dir);
if (since && !tools.changedSince(dir, since).some(shipsInPackage)) {
console.error(`${name}: no shipped change since ${current}, skipping`);
continue;
}
writeFileSync(manifest, stampVersion(toml, version));
tools.lock(dir);
console.error(`${name}: ${current} -> ${version}`);
console.log(`${name}: ${current} -> ${version}`);
}
return 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(main());