diff --git a/.claude/skills/prepare-release/SKILL.md b/.claude/skills/prepare-release/SKILL.md new file mode 100644 index 00000000..e47aac7a --- /dev/null +++ b/.claude/skills/prepare-release/SKILL.md @@ -0,0 +1,134 @@ +--- +name: prepare-release +description: Prepare a wave-level genblaze release — scope version bumps and dependency-floor drift with tools/prepare_release.py, apply them, gate with make pre-release, and open a release PR. Cannot tag or publish. Use before cutting a new CHANGELOG wave (as opposed to /release-check, which gates one already-decided package version). +allowed-tools: Bash(git add:*) Bash(git commit:*) Bash(git checkout:*) Bash(git switch:*) Bash(git push:*) Bash(git status:*) Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(python3:*) Bash(make pre-release:*) Bash(make ts-types:*) Bash(gh pr:*) Read Grep Edit +--- + +# Prepare a release wave — $ARGUMENTS + +**Safety boundary**: the `allowed-tools` list above deliberately has no `git tag`, +`gh release`, `twine upload`, or `make post-release`. This skill cannot tag or publish +anything — without a `git tag` step there is no tag to push, and without `gh release` +or `twine` there is nothing to trigger `.github/workflows/release.yml`. It goes as far +as a merge-ready release PR and stops. Tagging, releasing, and post-release +verification are human commands, emitted (not run) at the end. + +This is a thin driver over [`tools/prepare_release.py`](../../../tools/prepare_release.py), +which does the actual bookkeeping (dynamic package discovery, version bumps, dependency +floor sync, reserved-core-version guard — see its module docstring). For what a wave +release actually IS (versioning policy, the publish pipeline graph, tag-naming +convention) see [RELEASING.md](../../../RELEASING.md) — this skill does not restate +it. For gating one already-decided package version one at a time, use `/release-check` +instead; this skill operates at the wave level across every package at once. + +## Step 1 — Scope + +```bash +python3 tools/prepare_release.py --check +``` + +Read the report: which packages changed since the last tag, what version bumps and +dependency-floor updates it computes, and any warnings (e.g. a connector with no +entry in `libs/meta/pyproject.toml`). Exit 0 means nothing to prepare — stop here. +Exit 1 means proceed to Step 2. Exit 2 is a hard error (bad `--set`/`--bump`, a git +failure, or the reserved-core-version guard tripping) — resolve it before continuing. + +Before doing anything else, confirm: +- You're on `main` (`git status`), up to date with `origin/main`, and the working + tree is clean. +- `git log` shows the commit you're about to release from is the one you intend. + +## Step 2 — Apply, then cut the CHANGELOG + +```bash +python3 tools/prepare_release.py --apply +``` + +This writes every version bump and dependency-floor update computed in Step 1. Pass +`--set =` or `--bump =minor|major` to override the default patch +bump for any package (e.g. `--set core=0.4.0` for a deliberate minor/major, or to +route around the reserved-version guard intentionally). + +Then, by hand (this is not scriptable — it's an editorial decision about wave naming +and prose): + +1. Cut `CHANGELOG.md`'s `[Unreleased]` section to `## [X.Y.Z] - `, pasting in + the `### Released package versions` list the script just emitted. Fold in any + entries that were mis-sectioned under the wrong package heading. +2. Leave `[Unreleased]` empty — `changelog-gate` in `release.yml` fails the release + otherwise. +3. **The tag name is `v` + that heading, exactly** (e.g. heading `## [0.5.0]` → tag + `v0.5.0`) — `validate-version` enforces this and a mismatch is the single most + common reason a release run fails. Do not confuse the wave name with any one + package's version (see RELEASING.md's versioning policy). + +If `libs/spec` changed this wave, `prepare_release.py` will have flagged it — run: + +```bash +make ts-types +``` + +and commit the regenerated `libs/spec/ts/genblaze.d.ts` in the same PR. + +## Step 3 — Gate + +```bash +make pre-release +``` + +This runs the same lint/typecheck/ts-types/pypi-metadata/pin-parity/test/release-smoke +gates the release workflow runs (see RELEASING.md's Pre-release checklist) — a clean +run here is a strong signal `validate-version`, `changelog-gate`, and `release-smoke` +will all be green once tagged. Delegate any single-package specifics (entry-point +sanity, per-connector doc freshness) to `/release-check`. + +**macOS prerequisites** — verify these are already satisfied before running the +gate; this skill has no permission to install anything, and one-time machine setup +(cert installation) should never be automated by a skill: +- An activated venv so `python`/`pip` resolve (`make pre-release` shells out to + `python -m build`, `mypy`, etc.) +- `mypy>=1.8` and `pip install build twine` already done in that venv +- If the PyPI-metadata/pin-parity checks fail with an SSL error, run + `/Applications/Python 3.x/Install Certificates.command` once (a one-time, + machine-level change — do this yourself, don't script it), or set + `export SSL_CERT_FILE=$(python3 -m certifi)` for the session. + +If `make pre-release` can't run locally for environment reasons (missing Node for +`ts-types`, no local PyPI network access, etc.), fall back to the `workflow_dispatch` +dry-run instead of forcing it: push the branch, then from the Actions tab → Release +workflow → "Run workflow" with `dry_run: true`. This exercises the identical +pipeline against TestPyPI without a local toolchain. + +## Step 4 — Open the release PR + +```bash +git switch -c release/ +git add -A +git commit -m "chore(release): bump versions and cut CHANGELOG wave" +git push -u origin release/ +gh pr create --title "chore(release): wave" --body "..." +``` + +Never commit to `main` directly. This is a normal PR — it needs review and a green +CI run like anything else before it merges. + +**Conditional follow-ups** (only if the wave warrants them, not mandatory steps): +- Breaking changes this wave? Write `MIGRATING-.md`. +- Large surface touched? Consider running `/verify-docs` and `/security-review` + before opening the PR. + +## After the PR merges + +Once CI is green on `main`, here are the human commands to run — this skill does +not run them for you: + +```bash +git tag -a v -m "Release " +git push origin v +gh release create v --title "" --notes-from-tag +# after the Release workflow finishes publishing: +make post-release VERSION= +``` + +`` is `libs/meta/pyproject.toml`'s version, which may differ from +the wave name (see RELEASING.md — e.g. wave `0.3.0` shipped umbrella `0.4.0`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 090fb52c..2b9f01b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `tools/prepare_release.py`: deterministic wave-level release-prep engine. + Discovers every package dynamically (no hardcoded version literals or + connector list), bumps versions for what changed since the last tag, and + — independent of whether the affected package itself changed — resyncs + `cli`'s and the `genblaze` umbrella's `genblaze-core`/`genblaze-s3`/ + connector-extra dependency floors to match, forcing a republish when a + pin-only change would otherwise ship without a version bump (the drift + class `tools/check_pin_parity.py` guards against). Refuses to bump core + onto the reserved `raise_on_failure` default-flip version. `--check`/ + `--apply` modes, `--set`/`--bump` overrides. New `.claude/skills/ + prepare-release/SKILL.md` drives it end-to-end through `make pre-release` + and a release PR — it cannot tag or publish (see the skill's safety + boundary). + ## [0.5.0] - 2026-07-16 Correctness and security hardening across the pipeline, provenance, streaming, diff --git a/RELEASING.md b/RELEASING.md index 03086795..f456f15f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -42,10 +42,15 @@ Before cutting a release, verify on `main`: 1. **Versions are bumped.** Every package whose code changed since the last release has its `pyproject.toml` `version` updated. The umbrella - `libs/meta/pyproject.toml` is bumped to reflect this release. + `libs/meta/pyproject.toml` is bumped to reflect this release, along with + its (and `cli`'s) `genblaze-core`/`genblaze-s3`/connector-extra floors. + `python3 tools/prepare_release.py --check`/`--apply` automates this + step deterministically — see `.claude/skills/prepare-release/SKILL.md` + for the end-to-end driver. 2. **CHANGELOG is cut.** The `[Unreleased]` section is empty; a new `## [X.Y.Z] - YYYY-MM-DD` section lists every package version change - under "Released package versions". + under "Released package versions" (the exact text `prepare_release.py` + emits). 3. **TS types are current.** `make ts-types` produces no diff. (CI's `ts-types-check` job already enforces this on every push, but the release workflow re-runs it as a defense in depth.) diff --git a/docs/exec-plans/active/release-prep-automation.md b/docs/exec-plans/active/release-prep-automation.md new file mode 100644 index 00000000..58636758 --- /dev/null +++ b/docs/exec-plans/active/release-prep-automation.md @@ -0,0 +1,81 @@ + +# Wave Release-Prep Automation + +Adds a deterministic engine for the bookkeeping half of cutting a genblaze release +wave (RELEASING.md), plus a thin skill driver over it. Motivated by a concrete +failure mode from this session: a hand-edited release-prep pass bumped +`genblaze-core` but silently missed updating `cli`'s `genblaze-core` dependency +floor to match — invisible until a much later CI run. That's exactly the drift class +`tools/check_pin_parity.py` already guards against for connectors (`genblaze-s3` in +0.3.0, `genblaze-langsmith` + `genblaze-cli` in 0.3.2); this closes the same gap one +step earlier, before a human even has to remember to run the guard. + +## Goals & success criteria + +- No hardcoded package list or version literal anywhere in the new script — package + set comes from fixed structural roots (`libs/core`, `cli`, `libs/meta`, + `libs/spec`) plus a glob over `libs/connectors/*`. +- `cli`'s and the `genblaze` umbrella's `genblaze-core`/`genblaze-s3`/connector-extra + dependency floors are always resynced to the FINAL decided version of the package + they point at, whether or not that package changed this wave — the existing + `test_cli_core_dependency_floor_matches_local_core` / + `test_umbrella_core_dependency_floor_matches_local_core` / + `test_umbrella_s3_dependency_floor_matches_local_s3` / + `test_umbrella_connector_extra_floors_match_local_versions` invariants in + `cli/tests/test_cli.py` stay green regardless of which packages moved. +- Refuses to bump `genblaze-core` onto the version reserved for the + `raise_on_failure` default flip (`_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION` in + `libs/core/genblaze_core/pipeline/pipeline.py`), read from source rather than + duplicated as a literal in the script. +- Idempotent: re-running `--apply` (or hand-bumping a package outside the tool) is a + safe no-op for anything already past what was published at the last tag. +- The driving skill (`.claude/skills/prepare-release/SKILL.md`) cannot tag or + publish — enforced via its `allowed-tools` frontmatter, not prose. + +## Key decisions + +- **Text-level pyproject/package.json surgery, not full TOML re-serialization.** + Targeted regex line rewrites preserve comments and formatting; matches how the + rest of `tools/` (e.g. `validate-version` in `release.yml`) already treats these + files as text, not just data. +- **Floor rewriting preserves the existing upper-bound cap verbatim** (e.g. the + `<0.4` in `genblaze-core>=0.3.6,<0.4`) — only the floor number is substituted, so + the script never hardcodes that cap and stays correct once it eventually moves. +- **"Already bumped" is detected by comparing on-disk version to what was published + at the last tag**, not by tracking script-internal state across runs — this is + what makes re-running safe and also what let a live smoke-test (`--check` against + `main`, last tag `v0.4.0`) correctly report the already-prepped 0.5.0 wave as + clean except for one genuine, previously invisible gap (see Verification). +- **A pin that needs to change forces a version bump on the file that carries it**, + even with no other code change to that package — this is the actual fix for the + motivating bug, not a side effect. +- **New connectors with no existing floor line in `libs/meta/pyproject.toml` are not + auto-wired** (extra + bundle membership is an editorial call) — surfaced as a + warning instead. +- Design was red-teamed via a fork sub-agent before implementation (idempotency + heuristic, reserved-version guard blind spot on `--check`, skill `allowed-tools` + vs. its own prescribed macOS prereqs, over-generalized TOML editor) and again by + a 3-reviewer panel over the finished diff before push. + +## Verification + +- `pytest tools/tests/ -v` — 86 passed (34 new for `prepare_release.py`, no + regressions in `check_pin_parity`/`check_pypi_metadata`/`release_import_smoke` + tests). +- `python3 tools/prepare_release.py --check` against the live repo (last tag + `v0.4.0`) correctly recognized every package the 0.5.0 wave-prep commit + (`afe1dd5`) already bumped as "no action needed," and surfaced exactly one real, + previously invisible gap: `genblaze-nvidia`'s only change since `v0.4.0` is a + README relative-link fix that was never version-bumped, so the broken-link + README is still what's live on PyPI for that package. +- `ruff check` / `ruff format --check` clean on both new files. + +## Follow-ups (not in this change) + +- Whether to fold the `genblaze-nvidia` README gap the smoke test surfaced into a + future patch wave is a release-scheduling decision, not something this PR + resolves. +- Each connector's own `genblaze-core>=X,<0.4` floor (as opposed to `cli`'s and the + umbrella's) is intentionally left wide/unsynced per existing convention (see the + 0.5.0 CHANGELOG: "All other connectors unchanged — their existing floor already + admits 0.3.6") — not a gap, a deliberate scope boundary. diff --git a/tools/prepare_release.py b/tools/prepare_release.py new file mode 100644 index 00000000..39149b83 --- /dev/null +++ b/tools/prepare_release.py @@ -0,0 +1,775 @@ +#!/usr/bin/env python3 +""" +Deterministic wave-level release-prep engine. + +Genblaze cuts a "wave" release across ~15 independently-versioned packages +(see RELEASING.md). Preparing one by hand means a human has to remember, +every time: which packages changed since the last tag, bump each one's own +``pyproject.toml``/``package.json`` version, AND keep ``cli``'s and the +``genblaze`` umbrella's dependency floors on ``genblaze-core``/``genblaze-s3``/ +every connector in lockstep with whatever those packages' versions actually +are. Missing any one of those is invisible until ``make pypi-pin-parity`` +(or worse, the ``pin-parity`` release gate) fails — or, in the case that +motivated this script, a hand-edit silently misses the CLI's core-floor +bump and nobody notices until a much later CI run. + +This script makes that bookkeeping mechanical instead of memorized: + +* Package set is discovered dynamically (``libs/core``, ``cli``, ``libs/meta``, + ``libs/spec`` (npm), and a glob over ``libs/connectors/*``) — no hardcoded + package list or version literal anywhere in this file. +* The reserved ``genblaze-core`` version (the release at which + ``raise_on_failure``'s default flips — see + ``_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION`` in + ``libs/core/genblaze_core/pipeline/pipeline.py``) is extracted from source + at runtime and this script refuses to land core on it. +* Dependency floors (``cli``'s and the umbrella's pins on ``genblaze-core``/ + ``genblaze-s3``/every connector) are recomputed from each package's FINAL + decided version on every run, independent of whether that specific + package changed this wave — a connectors-only wave still republishes the + umbrella with the new floors, closing the exact drift class documented in + ``tools/check_pin_parity.py`` (genblaze-s3 in 0.3.0, genblaze-langsmith + + genblaze-cli in 0.3.2: a pin widened in source without a version bump, + silently kept forever by ``skip-existing``). + +What this script deliberately does NOT do (left to a human, or to the +``prepare-release`` skill that drives it): pick the wave name, write +CHANGELOG prose, tag, or publish. See ``.claude/skills/prepare-release/SKILL.md``. + +Modes +----- +``--check`` Report what would change; exit 0 if nothing is needed, 1 if + prep is needed, 2 on a hard error (bad args, git failure, or + a reserved-version collision). +``--apply`` Do the same computation, then write the changes. Exit 0/2. + +Overrides +--------- +``--set PKG=VERSION`` Force PKG to an exact version instead of the + default patch bump. +``--bump PKG=patch|minor|major`` Force a specific bump kind for PKG. + +PKG is a package key: ``core``, ``cli``, ``meta``, ``spec``, or a connector +directory name (e.g. ``openai``, ``stability-audio``). + +Idempotent by construction: a package is only bumped if its on-disk version +still equals what was published at the last tag (whether or not THIS script +did the bumping) — re-running ``--apply`` after a prior run, or after a +maintainer bumped something by hand, is a safe no-op for that package. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +try: + import tomllib # Python 3.11+ +except ImportError: # pragma: no cover - repo requires 3.11+, see AGENTS.md + print("ERROR: this script needs tomllib (Python 3.11+).", file=sys.stderr) + sys.exit(2) + + +class PrepareReleaseError(Exception): + """Base error for prepare_release failures (bad input, git failure, missing file).""" + + +class ReservedVersionError(PrepareReleaseError): + """Raised when a candidate genblaze-core version collides with the reserved + ``raise_on_failure`` default-flip version.""" + + +# --------------------------------------------------------------------------- +# Package discovery +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PackageInfo: + """One discovered, independently-versioned package.""" + + key: str # e.g. "core", "cli", "meta", "spec", "openai" + rel_dir: str # e.g. "libs/core" — relative to repo root + manifest_path: str # e.g. "libs/core/pyproject.toml" + name: str # published name, e.g. "genblaze-core", "@genblaze/spec" + version: str + kind: str # "pyproject" | "npm" + + +def _load_pyproject(path: Path) -> dict: + with path.open("rb") as f: + return tomllib.load(f) + + +def discover_packages(repo_root: Path) -> dict[str, PackageInfo]: + """Discover every released package. Dynamic — no hardcoded connector list. + + Fixed structural roots (``libs/core``, ``cli``, ``libs/meta``, ``libs/spec``) + are genuine monorepo layout, not a "list of packages that change"; the + connector set comes from a glob so a newly scaffolded connector is picked + up automatically. + """ + packages: dict[str, PackageInfo] = {} + + for key, rel_dir in (("core", "libs/core"), ("cli", "cli"), ("meta", "libs/meta")): + manifest_path = f"{rel_dir}/pyproject.toml" + data = _load_pyproject(repo_root / manifest_path) + project = data["project"] + packages[key] = PackageInfo( + key=key, + rel_dir=rel_dir, + manifest_path=manifest_path, + name=project["name"], + version=project["version"], + kind="pyproject", + ) + + connectors_dir = repo_root / "libs/connectors" + for child in sorted(p for p in connectors_dir.iterdir() if p.is_dir()): + manifest = child / "pyproject.toml" + if not manifest.exists(): + continue + key = child.name + rel_dir = f"libs/connectors/{key}" + data = _load_pyproject(manifest) + project = data["project"] + packages[key] = PackageInfo( + key=key, + rel_dir=rel_dir, + manifest_path=f"{rel_dir}/pyproject.toml", + name=project["name"], + version=project["version"], + kind="pyproject", + ) + + spec_manifest = "libs/spec/package.json" + spec_data = json.loads((repo_root / spec_manifest).read_text(encoding="utf-8")) + packages["spec"] = PackageInfo( + key="spec", + rel_dir="libs/spec", + manifest_path=spec_manifest, + name=spec_data["name"], + version=spec_data["version"], + kind="npm", + ) + + return packages + + +# --------------------------------------------------------------------------- +# Git plumbing — every call takes repo_root explicitly so tests can point at +# a synthetic fixture repo instead of the live one. +# --------------------------------------------------------------------------- + + +def _run_git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], cwd=repo_root, capture_output=True, text=True, check=False + ) + + +def git_last_tag(repo_root: Path) -> str: + """Return the most recent ``v*``-matching tag reachable from HEAD.""" + result = _run_git(repo_root, "describe", "--tags", "--match", "v*", "--abbrev=0") + if result.returncode != 0: + raise PrepareReleaseError( + "no prior release tag found (git describe --tags --match 'v*' " + f"--abbrev=0 failed): {result.stderr.strip()}" + ) + return result.stdout.strip() + + +def git_changed_files(repo_root: Path, last_tag: str) -> list[str]: + """Return paths that differ between ``last_tag`` and HEAD.""" + result = _run_git(repo_root, "diff", "--name-only", f"{last_tag}..HEAD") + if result.returncode != 0: + raise PrepareReleaseError(f"git diff {last_tag}..HEAD failed: {result.stderr.strip()}") + return [line for line in result.stdout.splitlines() if line] + + +def git_show_file(repo_root: Path, tag: str, rel_path: str) -> str | None: + """Return the file contents at ``tag``, or None if it didn't exist there + (a brand-new package added since that tag).""" + result = _run_git(repo_root, "show", f"{tag}:{rel_path}") + if result.returncode != 0: + return None + return result.stdout + + +def map_changed_files_to_keys( + changed_files: list[str], packages: dict[str, PackageInfo] +) -> set[str]: + """Map changed file paths to the package(s) whose directory contains them. + + Package `rel_dir`s are mutually exclusive prefixes (libs/core, libs/meta, + libs/connectors/, libs/spec, cli), so a path matches at most one key. + """ + changed: set[str] = set() + for f in changed_files: + for key, pkg in packages.items(): + prefix = pkg.rel_dir.rstrip("/") + "/" + if f == pkg.rel_dir or f.startswith(prefix): + changed.add(key) + break + return changed + + +def read_published_versions( + repo_root: Path, tag: str, packages: dict[str, PackageInfo] +) -> dict[str, str | None]: + """Return each package's version as published in the manifest at ``tag``, + or None if the package didn't exist yet (brand new this wave).""" + published: dict[str, str | None] = {} + for key, pkg in packages.items(): + raw = git_show_file(repo_root, tag, pkg.manifest_path) + if raw is None: + published[key] = None + elif pkg.kind == "pyproject": + published[key] = tomllib.loads(raw)["project"]["version"] + else: + published[key] = json.loads(raw)["version"] + return published + + +# --------------------------------------------------------------------------- +# Version arithmetic +# --------------------------------------------------------------------------- + +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") +_BUMP_KINDS = ("patch", "minor", "major") + + +def _validate_semver(value: str) -> None: + if not _SEMVER_RE.match(value): + raise PrepareReleaseError(f"not a valid X.Y.Z version: {value!r}") + + +def bump_version(version: str, kind: str) -> str: + """Bump an X.Y.Z version. ``kind`` is one of patch/minor/major.""" + _validate_semver(version) + if kind not in _BUMP_KINDS: + raise PrepareReleaseError(f"unknown bump kind {kind!r}, expected one of {_BUMP_KINDS}") + major, minor, patch = (int(p) for p in version.split(".")) + if kind == "patch": + patch += 1 + elif kind == "minor": + minor += 1 + patch = 0 + else: + major += 1 + minor = 0 + patch = 0 + return f"{major}.{minor}.{patch}" + + +# --------------------------------------------------------------------------- +# Reserved core version guard +# --------------------------------------------------------------------------- + +_RESERVED_VERSION_RE = re.compile(r'_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION\s*=\s*"([^"]+)"') +_PIPELINE_MODULE = "libs/core/genblaze_core/pipeline/pipeline.py" + + +def extract_reserved_core_version(repo_root: Path) -> str: + """Read the reserved genblaze-core version straight from source — never + hardcoded here, so this guard can't drift from the actual sentinel.""" + path = repo_root / _PIPELINE_MODULE + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise PrepareReleaseError( + f"could not read {path} to extract the reserved core version: {exc}" + ) from exc + match = _RESERVED_VERSION_RE.search(text) + if not match: + raise PrepareReleaseError( + f"could not find _RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION in {path} — " + "the reserved-version guard cannot run without it." + ) + return match.group(1) + + +# --------------------------------------------------------------------------- +# pyproject.toml / package.json text surgery — targeted line rewrites that +# preserve comments and formatting, rather than a full TOML re-serialization. +# --------------------------------------------------------------------------- + +_SECTION_RE = re.compile(r"^\[([^\]]+)\]$") +_VERSION_LINE_RE = re.compile(r'^(?P[ \t]*)version\s*=\s*"(?P[^"]*)"(?P.*)$') + + +def set_pyproject_version(text: str, new_version: str) -> str: + """Replace the `version = "..."` line inside `[project]`, leaving every + other line (comments, other sections) untouched.""" + out: list[str] = [] + in_project = False + replaced = False + for line in text.splitlines(keepends=True): + stripped = line.rstrip("\n") + section_match = _SECTION_RE.match(stripped.strip()) + if section_match: + in_project = section_match.group(1) == "project" + if in_project and not replaced: + version_match = _VERSION_LINE_RE.match(stripped) + if version_match: + out.append( + f'{version_match.group("indent")}version = "{new_version}"' + f"{version_match.group('trail')}\n" + ) + replaced = True + continue + out.append(line) + if not replaced: + raise PrepareReleaseError('could not find `version = "..."` in [project] table') + return "".join(out) + + +_PKG_JSON_VERSION_RE = re.compile( + r'^(?P\s*)"version":\s*"(?P[^"]*)"(?P,?)\s*$' +) + + +def set_package_json_version(text: str, new_version: str) -> str: + """Replace the first top-level `"version": "..."` line in a package.json.""" + out: list[str] = [] + replaced = False + for line in text.splitlines(keepends=True): + stripped = line.rstrip("\n") + match = _PKG_JSON_VERSION_RE.match(stripped) if not replaced else None + if match: + out.append( + f'{match.group("indent")}"version": "{new_version}"{match.group("trail")}\n' + ) + replaced = True + continue + out.append(line) + if not replaced: + raise PrepareReleaseError('could not find `"version": "..."` in package.json') + return "".join(out) + + +_DEP_LINE_RE = re.compile( + r'^(?P[ \t]*)"(?P[A-Za-z0-9_.]+(?:-[A-Za-z0-9_.]+)*)' + r'>=(?P[0-9][0-9A-Za-z.]*)(?P[^"]*)"(?P,?[ \t]*)$' +) + + +def rewrite_floors( + text: str, name_to_key: dict[str, str], final_versions: dict[str, str] +) -> tuple[str, list[tuple[str, str, str]]]: + """Rewrite every dependency-array entry whose package name is tracked in + ``name_to_key`` so its floor (the version after ``>=``) matches + ``final_versions``. + + Only the floor number is substituted — the upper-bound cap, any extras, + and markers are preserved verbatim from whatever is already in the file, + so this never hardcodes a version cap. Returns the (possibly) rewritten + text and a list of ``(dep_name, old_pin, new_pin)`` for lines that + actually changed. + """ + changes: list[tuple[str, str, str]] = [] + out_lines: list[str] = [] + for line in text.splitlines(keepends=True): + stripped = line.rstrip("\n") + match = _DEP_LINE_RE.match(stripped) + if not match: + out_lines.append(line) + continue + name = match.group("name") + key = name_to_key.get(name) + if key is None or key not in final_versions: + out_lines.append(line) + continue + old_floor = match.group("floor") + new_floor = final_versions[key] + if old_floor == new_floor: + out_lines.append(line) + continue + rest = match.group("rest") + old_pin = f"{name}>={old_floor}{rest}" + new_pin = f"{name}>={new_floor}{rest}" + out_lines.append(f'{match.group("indent")}"{new_pin}"{match.group("trail")}\n') + changes.append((name, old_pin, new_pin)) + return "".join(out_lines), changes + + +# --------------------------------------------------------------------------- +# Plan +# --------------------------------------------------------------------------- + + +@dataclass +class VersionChange: + key: str + name: str + old: str + new: str + reason: str + + +@dataclass +class FloorChange: + file_key: str # "cli" or "meta" + dep_name: str + old_pin: str + new_pin: str + + +@dataclass +class Plan: + last_tag: str + changed_keys: set[str] + version_changes: dict[str, VersionChange] + floor_changes: list[FloorChange] + warnings: list[str] + released_versions_text: str + final_versions: dict[str, str] + packages: dict[str, PackageInfo] = field(repr=False) + prep_needed: bool + + +_PRIORITY = {"meta": 0, "core": 1, "cli": 2, "s3": 3} + + +def _released_versions_sort_key(key: str) -> tuple[int, str]: + if key == "spec": + return (99, key) + return (_PRIORITY.get(key, 50), key) + + +def render_released_versions( + packages: dict[str, PackageInfo], + published: dict[str, str | None], + changes: dict[str, VersionChange], +) -> str: + """Render the "### Released package versions" list for the human to + paste into the CHANGELOG wave header. Deliberately does not invent + change-summary prose — just the version transitions.""" + lines = ["### Released package versions", ""] + for key in sorted(changes, key=_released_versions_sort_key): + change = changes[key] + pkg = packages[key] + label = f"`{pkg.name}` (umbrella)" if key == "meta" else f"`{pkg.name}`" + old_label = published[key] or "new" + lines.append(f"- {label} {old_label} → **{change.new}**") + if len(lines) == 2: + lines.append("_(nothing changed this wave)_") + return "\n".join(lines) + + +def parse_overrides(pairs: list[str], flag: str) -> dict[str, str]: + """Parse repeated ``PKG=VALUE`` CLI args into a dict, validating shape.""" + result: dict[str, str] = {} + for pair in pairs: + if "=" not in pair: + raise PrepareReleaseError(f"invalid --{flag} value {pair!r}, expected PKG=VALUE") + key, value = pair.split("=", 1) + key, value = key.strip(), value.strip() + if not key or not value: + raise PrepareReleaseError(f"invalid --{flag} value {pair!r}, expected PKG=VALUE") + if flag == "bump" and value not in _BUMP_KINDS: + raise PrepareReleaseError( + f"invalid --bump kind {value!r} for {key!r}, expected one of {_BUMP_KINDS}" + ) + result[key] = value + return result + + +def build_plan( + repo_root: Path, + *, + set_overrides: dict[str, str] | None = None, + bump_overrides: dict[str, str] | None = None, +) -> Plan: + """Compute the full release-prep plan. Raises PrepareReleaseError (or the + ReservedVersionError subclass) on any hard error; never partially applies + anything — this function only computes, `apply_plan` writes.""" + set_overrides = dict(set_overrides or {}) + bump_overrides = dict(bump_overrides or {}) + + packages = discover_packages(repo_root) + unknown = (set(set_overrides) | set(bump_overrides)) - set(packages) + if unknown: + raise PrepareReleaseError( + f"unknown package(s) in --set/--bump: {', '.join(sorted(unknown))} " + f"(known: {', '.join(sorted(packages))})" + ) + for value in set_overrides.values(): + _validate_semver(value) + + last_tag = git_last_tag(repo_root) + changed_files = git_changed_files(repo_root, last_tag) + changed_keys = map_changed_files_to_keys(changed_files, packages) + published = read_published_versions(repo_root, last_tag, packages) + + changes: dict[str, VersionChange] = {} + bumped: set[str] = set() + + def _decide(key: str, candidate: str, reason: str) -> None: + pkg = packages[key] + if candidate != pkg.version: + changes[key] = VersionChange(key, pkg.name, pkg.version, candidate, reason) + bumped.add(key) + + # 1. Explicit --set overrides — highest priority, and locks the package + # from any further auto-bump logic below even if the value equals + # what's already on disk (an explicit no-op override still counts as + # "handled" for this run). + for key, value in set_overrides.items(): + _decide(key, value, "explicit --set override") + + # 2. Explicit --bump overrides. + for key, kind in bump_overrides.items(): + if key in bumped: + continue + _decide(key, bump_version(packages[key].version, kind), f"explicit --bump {kind}") + + # 3. Default patch bump for packages that changed since the last tag and + # haven't already moved past what was published there — this is what + # makes --apply safe to re-run (a package already bumped, whether by + # a prior run of this tool or a hand-edit, is left alone). A package + # with no published version at all (pub is None) is brand new this + # wave — its initial version is whatever the scaffold set, not this + # tool's business to bump. + for key in sorted(changed_keys): + if key in bumped: + continue + pkg = packages[key] + pub = published[key] + if pub is None or pkg.version != pub: + continue # brand new, or already bumped since the tag + _decide(key, bump_version(pkg.version, "patch"), f"code changed since {last_tag}") + + final_versions: dict[str, str] = { + key: (changes[key].new if key in changes else pkg.version) for key, pkg in packages.items() + } + + # 4. Reserved-version guard, checked against the FINAL core version + # regardless of how it got there — a hand-edit that lands core on the + # reserved value can't slip past `--check` just because this tool + # itself decided not to touch it. + reserved = extract_reserved_core_version(repo_root) + if final_versions["core"] == reserved: + raise ReservedVersionError( + f"genblaze-core version {reserved} is reserved (see " + f"_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION in {_PIPELINE_MODULE} — " + "raise_on_failure's default flips there). Refusing to prepare a " + "release that lands core on this version; pick a different " + "version explicitly with --set core= if this is " + "intentional." + ) + + # 5. Floor sync — cli's and the umbrella's genblaze-core/-s3/- + # pins must always match the FINAL version of the package they point + # at, whether or not that package changed this wave. A pin that would + # change without its own version bumping is the exact skip-existing + # trap this tool exists to prevent, so force a default bump too. + name_to_key = {pkg.name: key for key, pkg in packages.items() if pkg.kind == "pyproject"} + floor_changes: list[FloorChange] = [] + for file_key in ("cli", "meta"): + pkg = packages[file_key] + text = (repo_root / pkg.manifest_path).read_text(encoding="utf-8") + _new_text, dep_changes = rewrite_floors(text, name_to_key, final_versions) + if not dep_changes: + continue + floor_changes.extend( + FloorChange(file_key, name, old_pin, new_pin) for name, old_pin, new_pin in dep_changes + ) + if file_key not in bumped: + # A connector referenced in multiple extras (e.g. an individual + # extra plus the video/image/audio/all bundles) produces one + # dep_changes entry per line — dedupe for a readable reason string. + dep_list = ", ".join(dict.fromkeys(name for name, _, _ in dep_changes)) + _decide( + file_key, + bump_version(pkg.version, "patch"), + f"dependency floor updated: {dep_list}", + ) + final_versions[file_key] = changes[file_key].new + + # 6. npm/spec reminder. + warnings: list[str] = [] + if "spec" in changes: + warnings.append( + "libs/spec changed this wave — run `make ts-types` and commit the " + "regenerated libs/spec/ts/genblaze.d.ts before tagging." + ) + + # 7. New/unwired connector warning — a connector never referenced in the + # umbrella's optional-dependencies is missing its extra + bundle + # membership, which is an editorial call this tool won't guess at. + meta_text = (repo_root / packages["meta"].manifest_path).read_text(encoding="utf-8") + for key, pkg in packages.items(): + if key in ("core", "cli", "meta", "spec"): + continue + if pkg.name not in meta_text: + warnings.append( + f"{pkg.name} has no entry anywhere in libs/meta/pyproject.toml — " + "add its extra (and bundle membership) manually." + ) + + released_versions_text = render_released_versions(packages, published, changes) + + return Plan( + last_tag=last_tag, + changed_keys=changed_keys, + version_changes=changes, + floor_changes=floor_changes, + warnings=warnings, + released_versions_text=released_versions_text, + final_versions=final_versions, + packages=packages, + prep_needed=bool(changes) or bool(floor_changes), + ) + + +def apply_plan(repo_root: Path, plan: Plan) -> None: + """Write every version bump and floor update computed in `plan`.""" + packages = plan.packages + + for key, change in plan.version_changes.items(): + pkg = packages[key] + path = repo_root / pkg.manifest_path + text = path.read_text(encoding="utf-8") + if pkg.kind == "pyproject": + text = set_pyproject_version(text, change.new) + else: + text = set_package_json_version(text, change.new) + path.write_text(text, encoding="utf-8") + + name_to_key = {pkg.name: key for key, pkg in packages.items() if pkg.kind == "pyproject"} + for file_key in ("cli", "meta"): + pkg = packages[file_key] + path = repo_root / pkg.manifest_path + text = path.read_text(encoding="utf-8") + new_text, _changes = rewrite_floors(text, name_to_key, plan.final_versions) + if new_text != text: + path.write_text(new_text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +def render_report(plan: Plan) -> str: + lines = [f"Prepare release — changes since {plan.last_tag}", ""] + + if plan.changed_keys: + lines.append(f"Changed packages (git diff since {plan.last_tag}):") + lines.append(" " + ", ".join(sorted(plan.changed_keys))) + else: + lines.append(f"No package directories changed since {plan.last_tag}.") + lines.append("") + + if plan.version_changes: + lines.append("Version bumps:") + for key in sorted(plan.version_changes, key=_released_versions_sort_key): + change = plan.version_changes[key] + lines.append(f" {change.name:<28} {change.old} -> {change.new} ({change.reason})") + else: + lines.append("Version bumps: none needed.") + lines.append("") + + if plan.floor_changes: + lines.append("Dependency floor updates:") + for fc in plan.floor_changes: + manifest = plan.packages[fc.file_key].manifest_path + lines.append(f" {manifest}: {fc.old_pin} -> {fc.new_pin}") + else: + lines.append("Dependency floor updates: none needed.") + lines.append("") + + if plan.warnings: + lines.append("Warnings:") + for warning in plan.warnings: + lines.append(f" - {warning}") + lines.append("") + + lines.append(plan.released_versions_text) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Deterministic wave-level release-prep engine for genblaze." + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--check", + action="store_true", + help="Report what would change. Exit 0 if nothing is needed, 1 if prep is needed.", + ) + mode.add_argument( + "--apply", + action="store_true", + help="Write the computed version bumps and dependency floor updates.", + ) + parser.add_argument( + "--set", + action="append", + default=[], + metavar="PKG=VERSION", + help="Force PKG to an exact version instead of the default patch bump.", + ) + parser.add_argument( + "--bump", + action="append", + default=[], + metavar="PKG=patch|minor|major", + help="Force a specific bump kind for PKG.", + ) + parser.add_argument( + "--repo-root", + type=Path, + default=None, + help="Repository root (default: parent of tools/).", + ) + args = parser.parse_args(argv) + + repo_root = args.repo_root or Path(__file__).resolve().parent.parent + + try: + set_overrides = parse_overrides(args.set, "set") + bump_overrides = parse_overrides(args.bump, "bump") + plan = build_plan(repo_root, set_overrides=set_overrides, bump_overrides=bump_overrides) + except PrepareReleaseError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + print(render_report(plan)) + + if args.check: + return 1 if plan.prep_needed else 0 + + if not plan.prep_needed: + print() + print("Nothing to prepare — every package is already up to date for this wave.") + return 0 + + apply_plan(repo_root, plan) + print() + print( + f"Applied {len(plan.version_changes)} version bump(s) and " + f"{len(plan.floor_changes)} floor update(s)." + ) + print() + print( + "Next: paste the released-versions list above into CHANGELOG.md's new " + "wave heading, then run `make pre-release`. See RELEASING.md." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_prepare_release.py b/tools/tests/test_prepare_release.py new file mode 100644 index 00000000..bb669530 --- /dev/null +++ b/tools/tests/test_prepare_release.py @@ -0,0 +1,536 @@ +"""Tests for tools/prepare_release.py. + +Uses real, throwaway git repositories under ``tmp_path`` rather than mocked +git output. This script's entire value is in real git plumbing +(``describe --match``, ``diff --name-only``, ``show :``) — mocking +it would hide exactly the bugs worth catching. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +_TOOLS_DIR = Path(__file__).resolve().parent.parent +if str(_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(_TOOLS_DIR)) + +import prepare_release as pr # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixture repo builder — a minimal-but-real genblaze-shaped monorepo. +# --------------------------------------------------------------------------- + +_PYPROJECT_TEMPLATE = """\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{name}" +version = "{version}" +description = "test package" +requires-python = ">=3.11" +dependencies = [ +{deps} +] +{extras_block} +""" + + +def _deps_block(deps: list[str]) -> str: + return "\n".join(f' "{d}",' for d in deps) + + +def _write_pyproject( + path: Path, + name: str, + version: str, + deps: list[str], + extras: dict[str, list[str]] | None = None, +) -> None: + extras_block = "" + if extras: + lines = ["[project.optional-dependencies]"] + for extra_name, extra_deps in extras.items(): + lines.append(f"{extra_name} = [") + lines.extend(f' "{d}",' for d in extra_deps) + lines.append("]") + extras_block = "\n".join(lines) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + _PYPROJECT_TEMPLATE.format( + name=name, version=version, deps=_deps_block(deps), extras_block=extras_block + ), + encoding="utf-8", + ) + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ) + + +def _commit(repo: Path, message: str) -> None: + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message, "--no-gpg-sign") + + +def make_fixture_repo( + tmp_path: Path, + *, + core_version: str = "0.1.0", + cli_version: str = "0.1.0", + meta_version: str = "0.1.0", + spec_version: str = "0.1.0", + connectors: dict[str, str] | None = None, + reserved_version: str = "0.9.0", +) -> Path: + """Build a real git repo mirroring the genblaze layout, commit it, and tag + it ``v0.1.0`` (the "last release"). Returns the repo root; callers + make further commits to simulate a wave in progress. + + Every git identity/signing setting is pinned per-repo (not ambient global + config) so this works in a container with no global git config and + doesn't hang if the host has commit signing turned on. + """ + connectors = connectors if connectors is not None else {"openai": "0.1.0", "s3": "0.1.0"} + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.name", "Test Bot") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "commit.gpgsign", "false") + + (repo / "libs/core/genblaze_core/pipeline").mkdir(parents=True) + (repo / "libs/core/genblaze_core/pipeline/pipeline.py").write_text( + f'_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION = "{reserved_version}"\n', + encoding="utf-8", + ) + _write_pyproject( + repo / "libs/core/pyproject.toml", "genblaze-core", core_version, ["pydantic>=2.0"] + ) + + _write_pyproject( + repo / "cli/pyproject.toml", + "genblaze-cli", + cli_version, + [f"genblaze-core>={core_version},<0.4", "click>=8.0"], + ) + + default_extras: dict[str, list[str]] = {} + for name, version in connectors.items(): + pkg_name = f"genblaze-{name}" + _write_pyproject( + repo / f"libs/connectors/{name}/pyproject.toml", + pkg_name, + version, + [f"genblaze-core>={core_version},<0.4"], + ) + default_extras[name] = [f"{pkg_name}>={version},<0.4"] + + _write_pyproject( + repo / "libs/meta/pyproject.toml", + "genblaze", + meta_version, + [f"genblaze-core>={core_version},<0.4"], + extras=default_extras, + ) + + (repo / "libs/spec").mkdir(parents=True, exist_ok=True) + (repo / "libs/spec/package.json").write_text( + f'{{\n "name": "@genblaze/spec",\n "version": "{spec_version}"\n}}\n', + encoding="utf-8", + ) + + _commit(repo, "initial commit") + _git(repo, "tag", "-a", "v0.1.0", "-m", "Release v0.1.0") + return repo + + +def _touch_and_commit(repo: Path, rel_path: str, content: str, message: str) -> None: + path = repo / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + _commit(repo, message) + + +# --------------------------------------------------------------------------- +# Pure unit tests — no git involved. +# --------------------------------------------------------------------------- + + +def test_bump_version_patch(): + assert pr.bump_version("0.3.6", "patch") == "0.3.7" + + +def test_bump_version_minor_resets_patch(): + assert pr.bump_version("0.3.6", "minor") == "0.4.0" + + +def test_bump_version_major_resets_minor_and_patch(): + assert pr.bump_version("1.2.3", "major") == "2.0.0" + + +def test_bump_version_rejects_malformed_input(): + with pytest.raises(pr.PrepareReleaseError): + pr.bump_version("v1.2", "patch") + + +def test_set_pyproject_version_replaces_only_project_version(): + text = ( + '[build-system]\nrequires = ["hatchling"]\n\n' + '[project]\nname = "genblaze-core"\nversion = "0.3.6"\ndescription = "x"\n\n' + '[tool.pytest.ini_options]\nversion = "not-this-one"\n' + ) + new_text = pr.set_pyproject_version(text, "0.3.7") + assert 'version = "0.3.7"' in new_text + assert 'version = "not-this-one"' in new_text # untouched, outside [project] + assert new_text.count('version = "0.3.7"') == 1 + + +def test_set_pyproject_version_missing_raises(): + with pytest.raises(pr.PrepareReleaseError): + pr.set_pyproject_version('[project]\nname = "x"\n', "1.0.0") + + +def test_set_package_json_version_replaces_first_occurrence(): + text = '{\n "name": "@genblaze/spec",\n "version": "0.4.0",\n "other": "version"\n}\n' + new_text = pr.set_package_json_version(text, "0.4.1") + assert '"version": "0.4.1"' in new_text + assert '"other": "version"' in new_text # untouched + + +def test_rewrite_floors_updates_only_tracked_names_preserves_cap(): + text = 'dependencies = [\n "genblaze-core>=0.3.6,<0.4",\n "click>=8.0",\n]\n' + new_text, changes = pr.rewrite_floors(text, {"genblaze-core": "core"}, {"core": "0.3.7"}) + assert '"genblaze-core>=0.3.7,<0.4",' in new_text + assert '"click>=8.0",' in new_text # untracked name untouched + assert changes == [("genblaze-core", "genblaze-core>=0.3.6,<0.4", "genblaze-core>=0.3.7,<0.4")] + + +def test_rewrite_floors_no_op_when_floor_already_matches(): + text = 'dependencies = [\n "genblaze-core>=0.3.7,<0.4",\n]\n' + new_text, changes = pr.rewrite_floors(text, {"genblaze-core": "core"}, {"core": "0.3.7"}) + assert new_text == text + assert changes == [] + + +def test_rewrite_floors_updates_every_occurrence_across_extras(): + """A connector referenced in its own extra AND a bundle must get both + lines updated (the real-world nvidia case: appears in 4 groups).""" + text = ( + "[project.optional-dependencies]\n" + 'openai = [\n "genblaze-openai>=0.1.0,<0.4",\n]\n' + 'all = [\n "genblaze-openai>=0.1.0,<0.4",\n]\n' + ) + new_text, changes = pr.rewrite_floors(text, {"genblaze-openai": "openai"}, {"openai": "0.1.1"}) + assert new_text.count('"genblaze-openai>=0.1.1,<0.4",') == 2 + assert len(changes) == 2 + + +def test_map_changed_files_prefix_matching_does_not_false_positive(): + """`openai` and a hypothetical `openai-realtime` connector must not + cross-match on a bare prefix (no trailing-slash boundary bug).""" + packages = { + "openai": pr.PackageInfo( + "openai", + "libs/connectors/openai", + "libs/connectors/openai/pyproject.toml", + "genblaze-openai", + "0.1.0", + "pyproject", + ), + "openai-realtime": pr.PackageInfo( + "openai-realtime", + "libs/connectors/openai-realtime", + "libs/connectors/openai-realtime/pyproject.toml", + "genblaze-openai-realtime", + "0.1.0", + "pyproject", + ), + } + changed = pr.map_changed_files_to_keys( + ["libs/connectors/openai-realtime/provider.py"], packages + ) + assert changed == {"openai-realtime"} + + +def test_extract_reserved_core_version(tmp_path: Path): + pipeline_dir = tmp_path / "libs/core/genblaze_core/pipeline" + pipeline_dir.mkdir(parents=True) + (pipeline_dir / "pipeline.py").write_text( + '_RAISE_ON_FAILURE_DEFAULT_FLIP_VERSION = "0.4.0"\n', encoding="utf-8" + ) + assert pr.extract_reserved_core_version(tmp_path) == "0.4.0" + + +def test_extract_reserved_core_version_missing_sentinel_raises(tmp_path: Path): + pipeline_dir = tmp_path / "libs/core/genblaze_core/pipeline" + pipeline_dir.mkdir(parents=True) + (pipeline_dir / "pipeline.py").write_text("# nothing here\n", encoding="utf-8") + with pytest.raises(pr.PrepareReleaseError): + pr.extract_reserved_core_version(tmp_path) + + +def test_parse_overrides_set(): + assert pr.parse_overrides(["core=0.4.0"], "set") == {"core": "0.4.0"} + + +def test_parse_overrides_bump_rejects_bad_kind(): + with pytest.raises(pr.PrepareReleaseError): + pr.parse_overrides(["core=sideways"], "bump") + + +def test_parse_overrides_rejects_missing_equals(): + with pytest.raises(pr.PrepareReleaseError): + pr.parse_overrides(["core"], "set") + + +# --------------------------------------------------------------------------- +# Integration tests — real git fixture repos. +# --------------------------------------------------------------------------- + + +def test_default_patch_bump_for_changed_package(tmp_path: Path): + repo = make_fixture_repo(tmp_path, core_version="0.3.6") + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: add a file") + + plan = pr.build_plan(repo) + + assert "core" in plan.changed_keys + assert plan.version_changes["core"].old == "0.3.6" + assert plan.version_changes["core"].new == "0.3.7" + assert "code changed" in plan.version_changes["core"].reason + + +def test_floor_sync_forces_cli_and_meta_bump_when_only_core_changes(tmp_path: Path): + repo = make_fixture_repo( + tmp_path, core_version="0.3.6", cli_version="0.2.0", meta_version="0.5.0" + ) + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + + plan = pr.build_plan(repo) + + # cli's own files never changed... + assert "cli" not in plan.changed_keys + # ...but its core floor must move to match core's new version, forcing a bump. + assert plan.version_changes["cli"].old == "0.2.0" + assert plan.version_changes["cli"].new == "0.2.1" + assert "dependency floor updated" in plan.version_changes["cli"].reason + assert "genblaze-core" in plan.version_changes["cli"].reason + + assert plan.version_changes["meta"].old == "0.5.0" + assert plan.version_changes["meta"].new == "0.5.1" + + cli_floor_change = [fc for fc in plan.floor_changes if fc.file_key == "cli"] + assert len(cli_floor_change) == 1 + assert cli_floor_change[0].new_pin == "genblaze-core>=0.3.7,<0.4" + + +def test_connectors_only_wave_leaves_cli_untouched(tmp_path: Path): + repo = make_fixture_repo( + tmp_path, + core_version="0.3.6", + cli_version="0.2.0", + meta_version="0.5.0", + connectors={"openai": "0.1.0", "s3": "0.2.0"}, + ) + _touch_and_commit( + repo, "libs/connectors/openai/genblaze_openai/newfile.py", "x = 1\n", "openai: bugfix" + ) + + plan = pr.build_plan(repo) + + assert plan.changed_keys == {"openai"} + assert "cli" not in plan.version_changes # regression guard: core never moved + assert "core" not in plan.version_changes + assert plan.version_changes["openai"].new == "0.1.1" + # meta republishes because its openai floor now needs to move. + assert plan.version_changes["meta"].new == "0.5.1" + assert plan.version_changes["meta"].reason.startswith("dependency floor updated") + assert "genblaze-openai" in plan.version_changes["meta"].reason + + +def test_reserved_version_refusal_via_explicit_minor_bump(tmp_path: Path): + repo = make_fixture_repo(tmp_path, core_version="0.3.9", reserved_version="0.4.0") + + with pytest.raises(pr.ReservedVersionError, match="0.4.0"): + pr.build_plan(repo, bump_overrides={"core": "minor"}) + + +def test_reserved_version_refusal_via_explicit_set(tmp_path: Path): + repo = make_fixture_repo(tmp_path, core_version="0.3.6", reserved_version="0.4.0") + + with pytest.raises(pr.ReservedVersionError, match="0.4.0"): + pr.build_plan(repo, set_overrides={"core": "0.4.0"}) + + +def test_reserved_version_guard_catches_hand_edited_core_on_check(tmp_path: Path): + """The guard must fire even when the TOOL isn't the one deciding to bump + core — e.g. a maintainer hand-edits core straight to the reserved + version outside this script entirely. Regression for the blind spot + where `--check` only validated tool-computed candidates.""" + repo = make_fixture_repo(tmp_path, core_version="0.3.9", reserved_version="0.4.0") + # Hand-edit core to the reserved version directly (bypassing the tool) + # and commit it — simulating a maintainer PR that already did this. + text = (repo / "libs/core/pyproject.toml").read_text() + (repo / "libs/core/pyproject.toml").write_text( + pr.set_pyproject_version(text, "0.4.0"), encoding="utf-8" + ) + _commit(repo, "core: hand-bump to 0.4.0 (oops)") + + with pytest.raises(pr.ReservedVersionError, match="0.4.0"): + pr.build_plan(repo) + + +def test_reserved_version_exact_match_only_does_not_block_explicit_skip(tmp_path: Path): + """Explicitly skipping past the reserved version via --set is a + legitimate maintainer call, not the tool's business to block.""" + repo = make_fixture_repo(tmp_path, core_version="0.3.6", reserved_version="0.4.0") + + plan = pr.build_plan(repo, set_overrides={"core": "0.5.0"}) + + assert plan.version_changes["core"].new == "0.5.0" + + +def test_released_versions_list_ordering(tmp_path: Path): + repo = make_fixture_repo( + tmp_path, + core_version="0.3.6", + cli_version="0.2.0", + meta_version="0.5.0", + connectors={"openai": "0.1.0", "zeta": "0.1.0"}, + ) + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + _touch_and_commit( + repo, "libs/connectors/zeta/genblaze_zeta/newfile.py", "x = 1\n", "zeta: bugfix" + ) + + plan = pr.build_plan(repo) + text = plan.released_versions_text + order = [ + key + for key in ("genblaze", "genblaze-core", "genblaze-cli", "genblaze-zeta") + if key in text + ] + idx = {name: text.index(f"`{name}`") for name in order} + assert idx["genblaze"] < idx["genblaze-core"] < idx["genblaze-cli"] < idx["genblaze-zeta"] + assert "(umbrella)" in text + + +def test_spec_change_included_and_ts_types_warning(tmp_path: Path): + repo = make_fixture_repo(tmp_path, spec_version="0.4.0") + _touch_and_commit(repo, "libs/spec/schemas/asset.json", "{}\n", "spec: schema tweak") + + plan = pr.build_plan(repo) + + assert plan.version_changes["spec"].old == "0.4.0" + assert plan.version_changes["spec"].new == "0.4.1" + assert any("make ts-types" in w for w in plan.warnings) + + +def test_check_apply_idempotent_second_run_is_no_op(tmp_path: Path): + repo = make_fixture_repo( + tmp_path, core_version="0.3.6", cli_version="0.2.0", meta_version="0.5.0" + ) + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + + first_plan = pr.build_plan(repo) + assert first_plan.prep_needed + pr.apply_plan(repo, first_plan) + + # Re-run against the SAME commits (no new commit) — on-disk versions have + # moved past what's published at the tag, so nothing should bump again. + second_plan = pr.build_plan(repo) + assert second_plan.prep_needed is False + assert second_plan.version_changes == {} + assert second_plan.floor_changes == [] + + +def test_apply_writes_version_and_floor_to_disk(tmp_path: Path): + repo = make_fixture_repo( + tmp_path, core_version="0.3.6", cli_version="0.2.0", meta_version="0.5.0" + ) + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + + plan = pr.build_plan(repo) + pr.apply_plan(repo, plan) + + core_text = (repo / "libs/core/pyproject.toml").read_text() + cli_text = (repo / "cli/pyproject.toml").read_text() + assert 'version = "0.3.7"' in core_text + assert '"genblaze-core>=0.3.7,<0.4"' in cli_text + assert 'version = "0.2.1"' in cli_text + + +def test_new_connector_with_no_meta_entry_warns_and_is_not_bumped(tmp_path: Path): + repo = make_fixture_repo(tmp_path, connectors={"openai": "0.1.0"}) + # A brand-new connector added after the tag, never wired into meta's extras. + _write_pyproject( + repo / "libs/connectors/newconn/pyproject.toml", + "genblaze-newconn", + "0.1.0", + ["genblaze-core>=0.3.0,<0.4"], + ) + _commit(repo, "add newconn scaffold") + + plan = pr.build_plan(repo) + + assert "newconn" not in plan.version_changes # brand new; nothing to bump + assert any("genblaze-newconn" in w and "libs/meta/pyproject.toml" in w for w in plan.warnings) + + +def test_unknown_package_in_override_raises(tmp_path: Path): + repo = make_fixture_repo(tmp_path) + with pytest.raises(pr.PrepareReleaseError, match="unknown package"): + pr.build_plan(repo, set_overrides={"not-a-real-package": "1.0.0"}) + + +def test_no_tags_raises_clear_error(tmp_path: Path): + repo = tmp_path / "no-tags-repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.name", "Test Bot") + _git(repo, "config", "user.email", "test@example.com") + (repo / "README.md").write_text("hi\n", encoding="utf-8") + _commit(repo, "initial commit, no tag") + + with pytest.raises(pr.PrepareReleaseError, match="no prior release tag"): + pr.git_last_tag(repo) + + +# --------------------------------------------------------------------------- +# CLI (main()) — exit code contract. +# --------------------------------------------------------------------------- + + +def test_main_check_exits_zero_when_clean(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + repo = make_fixture_repo(tmp_path) + exit_code = pr.main(["--check", "--repo-root", str(repo)]) + assert exit_code == 0 + + +def test_main_check_exits_one_when_prep_needed(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + repo = make_fixture_repo(tmp_path, core_version="0.3.6") + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + exit_code = pr.main(["--check", "--repo-root", str(repo)]) + assert exit_code == 1 + + +def test_main_apply_exits_zero_and_writes(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + repo = make_fixture_repo(tmp_path, core_version="0.3.6") + _touch_and_commit(repo, "libs/core/genblaze_core/newfile.py", "x = 1\n", "core: bugfix") + exit_code = pr.main(["--apply", "--repo-root", str(repo)]) + assert exit_code == 0 + assert 'version = "0.3.7"' in (repo / "libs/core/pyproject.toml").read_text() + + +def test_main_returns_two_on_bad_override(tmp_path: Path, capsys: pytest.CaptureFixture[str]): + repo = make_fixture_repo(tmp_path) + exit_code = pr.main(["--check", "--set", "no-such-pkg=1.0.0", "--repo-root", str(repo)]) + assert exit_code == 2