diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 8248f84e0..89a5e2ad4 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,38 +1,53 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: release: types: [published] - workflow_dispatch: permissions: contents: read +concurrency: + group: pypi-${{ github.event.release.tag_name }} + cancel-in-progress: false + jobs: - release: + publish: + name: Publish attached release artifacts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Check out the release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.release.tag_name }} - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - - name: Set up Python - run: uv python install 3.10 - - name: Install the project - run: uv sync --all-extras - - name: Build the project - run: uv build - - name: Build and publish Python package - run: uv publish + python-version: "3.11" + - name: Download tested release artifacts + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + mkdir -p dist + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern '*.whl' \ + --pattern '*.tar.gz' \ + --dir dist + - name: Validate tag, metadata, and distributions + env: + TAG: ${{ github.event.release.tag_name }} + run: | + VERSION="${TAG#v}" + test "$TAG" = "v$VERSION" + python scripts/prepare_release.py \ + --expected-version "$VERSION" \ + --output /tmp/release-notes.md + uvx --from twine==6.2.0 twine check dist/* + - name: Publish exact release artifacts to PyPI env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv publish dist/* diff --git a/.github/workflows/scheduled-release.yml b/.github/workflows/scheduled-release.yml index f01f99f6a..40fa6354c 100644 --- a/.github/workflows/scheduled-release.yml +++ b/.github/workflows/scheduled-release.yml @@ -1,268 +1,199 @@ -name: Scheduled Release +name: Release Readiness on: schedule: - # Every 2 weeks on Monday at 9 AM UTC - - cron: '0 9 * * 1/2' - workflow_dispatch: # Allow manual trigger + - cron: "0 9 * * 1" + pull_request: + paths: + - .github/workflows/python-publish.yml + - .github/workflows/scheduled-release.yml + - CHANGELOG.md + - pyproject.toml + - scripts/prepare_release.py + - tests/test_prepare_release.py + - uv.lock + workflow_dispatch: inputs: - skip_tests: - description: 'Skip LLM tests (use for testing workflow)' - required: false - default: false - type: boolean - dry_run: - description: 'Dry run - dont push changes or create release' - required: false + expected_version: + description: Exact version already declared in pyproject.toml + required: true + type: string + publish: + description: Publish the tested artifacts as a GitHub release + required: true default: false type: boolean +permissions: + contents: read + +concurrency: + group: release-readiness-${{ github.ref }} + cancel-in-progress: false + jobs: - test-and-release: + prepare: + name: Prepare exact release artifacts + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + release_needed: ${{ steps.metadata.outputs.release_needed }} + tag: ${{ steps.metadata.outputs.tag }} + version: ${{ steps.metadata.outputs.version }} + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + python-version: "3.11" + - name: Set up Python + run: uv python install 3.11 + - name: Install locked dependencies + run: uv sync --frozen --all-extras + + - name: Validate release metadata + id: metadata + env: + EXPECTED_VERSION: ${{ inputs.expected_version }} + run: | + args=(--output release-notes.md) + if [ -n "$EXPECTED_VERSION" ]; then + args+=(--expected-version "$EXPECTED_VERSION") + fi + VERSION=$(uv run --frozen python scripts/prepare_release.py "${args[@]}") + TAG="v$VERSION" + + if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then + TAG_SHA=$(git rev-list -n 1 "$TAG") + if [ "$TAG_SHA" != "$GITHUB_SHA" ]; then + echo "::error::Tag $TAG already points to $TAG_SHA; source needs a new version." + exit 1 + fi + RELEASE_NEEDED=false + else + RELEASE_NEEDED=true + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "release_needed=$RELEASE_NEEDED" >> "$GITHUB_OUTPUT" + + - name: Run release checks + env: + INSTRUCTOR_ENV: CI + run: | + uv run --frozen ruff check instructor examples tests scripts/prepare_release.py + uv run --frozen ruff format --check instructor examples tests scripts/prepare_release.py + uv run --frozen ty check --error-on-warning instructor/ + uv run --frozen ty check --config-file ty-tests.toml --error-on-warning tests + uv run --frozen pytest tests/ --asyncio-mode=auto -n auto \ + --ignore=tests/coverage \ + --ignore=tests/test_batch_processor_coverage.py \ + -k 'not test_core_providers and not test_openai and not test_anthropic and not test_gemini and not test_genai and not test_writer and not test_vertexai and not docs' + + - name: Build and validate distributions + run: | + uv build + uvx --from twine==6.2.0 twine check dist/* + + - name: Upload exact release candidate + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: instructor-${{ steps.metadata.outputs.version }}-${{ github.sha }} + path: | + dist/* + release-notes.md + if-no-files-found: error + retention-days: 14 + + - name: Summarize readiness + run: | + { + echo "## Release readiness" + echo "- Version: ${{ steps.metadata.outputs.version }}" + echo "- Tag: ${{ steps.metadata.outputs.tag }}" + echo "- Commit: $GITHUB_SHA" + echo "- Release needed: ${{ steps.metadata.outputs.release_needed }}" + echo "- Publish requested: ${{ inputs.publish || false }}" + } >> "$GITHUB_STEP_SUMMARY" + + smoke: + name: Wheel smoke test (Python ${{ matrix.python-version }}) + needs: prepare runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.13"] + steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup UV - uses: astral-sh/setup-uv@v3 - - - name: Install dependencies - run: | - uv sync --all-extras --dev - - - name: Run linting - run: | - uv run ruff check instructor examples tests - - - name: Run type checking - run: | - uv run ty check --error-on-warning --output-format github instructor/ - uv run ty check --config-file ty-tests.toml --error-on-warning --output-format github tests - - - name: Run core tests (no LLM) - run: | - uv run pytest tests/ -k "not openai and not llm and not anthropic and not gemini and not cohere and not mistral and not groq and not vertexai and not xai and not cerebras and not fireworks and not writer and not bedrock and not perplexity and not genai" --tb=short -v --maxfail=10 - - # Optional: Run LLM tests if you have API keys in secrets - - name: Run LLM tests - if: github.event.inputs.skip_tests != 'true' - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} - GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} - MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} - run: | - echo "Running basic LLM tests if API keys are available..." - # Run a subset of LLM tests to verify basic functionality - if [ ! -z "$OPENAI_API_KEY" ]; then - echo "Testing OpenAI integration..." - uv run pytest tests/llm/test_openai/test_basics.py --tb=short -v --maxfail=1 || echo "OpenAI tests failed" - fi - if [ ! -z "$ANTHROPIC_API_KEY" ]; then - echo "Testing Anthropic integration..." - uv run pytest tests/llm/test_anthropic/test_basics.py --tb=short -v --maxfail=1 || echo "Anthropic tests failed" - fi - echo "LLM tests completed (non-blocking)" - - - name: Check for changes since last release - id: changes - run: | - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$LAST_TAG" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "last_tag=none" >> $GITHUB_OUTPUT - echo "change_count=initial" >> $GITHUB_OUTPUT - else - CHANGES=$(git rev-list $LAST_TAG..HEAD --count) - echo "has_changes=$([[ $CHANGES -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT - echo "change_count=$CHANGES" >> $GITHUB_OUTPUT - echo "last_tag=$LAST_TAG" >> $GITHUB_OUTPUT - fi - - echo "Last tag: $LAST_TAG" - echo "Changes since last tag: $(git rev-list $LAST_TAG..HEAD --count 2>/dev/null || echo 'N/A')" - - # Only proceed with release if tests passed AND there are changes - - name: Get current version - if: steps.changes.outputs.has_changes == 'true' - id: current_version - run: | - VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Current version: $VERSION" - - - name: Determine version bump type - if: steps.changes.outputs.has_changes == 'true' - id: version_type - run: | - # Check commit messages since last tag to determine bump type - LAST_TAG="${{ steps.changes.outputs.last_tag }}" - if [ "$LAST_TAG" = "none" ]; then - COMMITS=$(git log --oneline HEAD~20..HEAD) - else - COMMITS=$(git log --oneline $LAST_TAG..HEAD) - fi - - echo "Recent commits:" - echo "$COMMITS" - - # Look for breaking changes or major features - if echo "$COMMITS" | grep -qE "(BREAKING|feat!|fix!)"; then - echo "bump_type=minor" >> $GITHUB_OUTPUT - echo "Detected breaking changes - using minor bump" - elif echo "$COMMITS" | grep -qE "feat:"; then - echo "bump_type=minor" >> $GITHUB_OUTPUT - echo "Detected new features - using minor bump" - else - echo "bump_type=patch" >> $GITHUB_OUTPUT - echo "Using patch bump for bug fixes and chores" - fi - - - name: Bump version - if: steps.changes.outputs.has_changes == 'true' - id: bump_version - run: | - CURRENT="${{ steps.current_version.outputs.version }}" - BUMP_TYPE="${{ steps.version_type.outputs.bump_type }}" - - IFS='.' read -r major minor patch <<< "$CURRENT" - - case $BUMP_TYPE in - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - esac - - NEW_VERSION="$major.$minor.$patch" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumping from $CURRENT to $NEW_VERSION ($BUMP_TYPE)" - - # Update pyproject.toml - sed -i "s/version = \"$CURRENT\"/version = \"$NEW_VERSION\"/" pyproject.toml - - - name: Update lockfile - if: steps.changes.outputs.has_changes == 'true' - run: | - uv lock - - # Run tests again after version bump to make sure nothing broke - - name: Final test run - if: steps.changes.outputs.has_changes == 'true' - run: | - uv sync - uv run pytest tests/ -k "not openai and not llm and not anthropic and not gemini and not cohere and not mistral and not groq and not vertexai and not xai and not cerebras and not fireworks and not writer and not bedrock and not perplexity and not genai" --tb=short --maxfail=5 - - - name: Generate changelog - if: steps.changes.outputs.has_changes == 'true' - id: changelog - run: | - LAST_TAG="${{ steps.changes.outputs.last_tag }}" - NEW_VERSION="${{ steps.bump_version.outputs.new_version }}" - - if [ "$LAST_TAG" = "none" ]; then - CHANGELOG=$(git log --oneline HEAD~30..HEAD --pretty=format:"- %s" | head -20) - else - CHANGELOG=$(git log --oneline $LAST_TAG..HEAD --pretty=format:"- %s") - fi - - # Save changelog to file for GitHub release - cat > CHANGELOG.md << EOF - ## ๐Ÿš€ What's Changed - - $CHANGELOG - - ## ๐Ÿ”— Links - **Full Changelog**: https://github.com/${{ github.repository }}/compare/$LAST_TAG...v$NEW_VERSION - - --- - ๐Ÿค– *This release was automatically generated every 2 weeks* - EOF - - echo "changelog_file=CHANGELOG.md" >> $GITHUB_OUTPUT - - - name: Create release commit - if: steps.changes.outputs.has_changes == 'true' - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add pyproject.toml uv.lock - git commit -m "chore: automated release v${{ steps.bump_version.outputs.new_version }} + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - name: Download exact release candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: instructor-${{ needs.prepare.outputs.version }}-${{ github.sha }} + - name: Install and exercise wheel + env: + EXPECTED_VERSION: ${{ needs.prepare.outputs.version }} + run: | + uv venv --python "${{ matrix.python-version }}" .smoke + WHEEL=$(find dist -name '*.whl' -print -quit) + test -n "$WHEEL" + uv pip install --python .smoke/bin/python "$WHEEL" + .smoke/bin/python - <<'PY' + import os + from importlib.metadata import version + + import instructor + from pydantic import BaseModel - ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) + from instructor.v2.core.schema import generate_openai_schema - Co-Authored-By: GitHub Action " - git tag "v${{ steps.bump_version.outputs.new_version }}" - - - name: Push changes - if: steps.changes.outputs.has_changes == 'true' && github.event.inputs.dry_run != 'true' - run: | - git push origin main - git push origin "v${{ steps.bump_version.outputs.new_version }}" - - - name: Create GitHub Release - if: steps.changes.outputs.has_changes == 'true' && github.event.inputs.dry_run != 'true' - uses: ncipollo/release-action@v1 - with: - tag: "v${{ steps.bump_version.outputs.new_version }}" - name: "๐Ÿš€ Release v${{ steps.bump_version.outputs.new_version }}" - bodyFile: "CHANGELOG.md" - draft: false - prerelease: false - - - name: Dry run summary - if: steps.changes.outputs.has_changes == 'true' && github.event.inputs.dry_run == 'true' - run: | - echo "๐Ÿงช DRY RUN MODE - No changes pushed" - echo "Would have released: v${{ steps.bump_version.outputs.new_version }}" - cat CHANGELOG.md - - # Optional: Publish to PyPI (uncomment if you want automatic PyPI releases) - # - name: Build and publish to PyPI - # if: steps.changes.outputs.has_changes == 'true' && secrets.PYPI_TOKEN != '' - # env: - # PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - # run: | - # uv build - # uv publish --token $PYPI_TOKEN - - # Summary outputs - - name: Summary - if: always() - run: | - echo "## ๐Ÿ“Š Scheduled Release Summary" >> $GITHUB_STEP_SUMMARY - echo "- **Branch**: ${{ github.ref }}" >> $GITHUB_STEP_SUMMARY - echo "- **Has Changes**: ${{ steps.changes.outputs.has_changes }}" >> $GITHUB_STEP_SUMMARY - echo "- **Change Count**: ${{ steps.changes.outputs.change_count }}" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then - echo "- **Version**: ${{ steps.current_version.outputs.version }} โ†’ ${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Bump Type**: ${{ steps.version_type.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY - echo "- **Status**: โœ… Released" >> $GITHUB_STEP_SUMMARY - else - echo "- **Status**: โญ๏ธ Skipped (no changes)" >> $GITHUB_STEP_SUMMARY - fi - - - name: Notify on failure - if: failure() - run: | - echo "โŒ Scheduled release failed - check the logs above" - echo "Common issues:" - echo "- Tests failed" - echo "- Linting issues" - echo "- Type checking errors" - echo "- Git push permissions" + class Result(BaseModel): + value: int + + expected = os.environ["EXPECTED_VERSION"] + assert version("instructor") == expected + assert instructor.__version__ == expected + schema = generate_openai_schema(Result) + assert schema["parameters"]["required"] == ["value"] + assert Result.model_validate({"value": 7}).value == 7 + PY + + publish: + name: Publish approved GitHub release + if: >- + github.event_name == 'workflow_dispatch' && inputs.publish && + needs.prepare.outputs.release_needed == 'true' + needs: [prepare, smoke] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Download exact release candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: instructor-${{ needs.prepare.outputs.version }}-${{ github.sha }} + - name: Create GitHub release from tested artifacts + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + gh release create "$TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "Instructor $TAG" \ + --notes-file release-notes.md \ + --latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4906462..bc0b0087f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [1.15.5] - 2026-08-02 + ### Fixed - **Retry usage accounting**: Accumulate nested and newly added numeric usage fields across OpenAI and Anthropic retries, including prediction, cache-write, cache-creation, and server-tool counters, without treating boolean metadata as billable usage. ([#2493](https://github.com/567-labs/instructor/issues/2493), [#2500](https://github.com/567-labs/instructor/pull/2500)) - **OpenAI Responses reask**: Add a fallback correction message when a `RESPONSES_TOOLS` response contains no tool calls (e.g. reasoning-only output), so retries carry validation feedback instead of resending the identical request. ([#2498](https://github.com/567-labs/instructor/pull/2498)) @@ -34,17 +36,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Templating**: Use populated `contents` when `messages` is empty, avoid mutating nested caller input, and preserve uncopyable metadata during template expansion. - **Anthropic system messages**: Reject invalid new system-message values even when no existing system message is present. - **Python 3.9**: Include the required type-evaluation backport in minimal installs, keep overload metadata available, and avoid runtime evaluation of unsupported union syntax in the core response path and offline tests. - -### Tests / CI -- **Coverage and test quality**: Run the complete offline suite on Python 3.9-3.13, enforce fork-safe statement and branch coverage plus supported-version type checks in pull-request CI, add strict resource and thread warning checks, and provide a manual retry-mutation workflow. Consolidate typed response, stream, and SDK fixtures; remove duplicate tests and unreachable provider paths; and replace coverage-only stubs with meaningful edge-case and transport-backed provider checks. - -## [1.15.5] - 2026-06-28 - -### Fixed - **v2 imports**: Defer OpenAI SDK imports from core v2 modules until an OpenAI-specific path actually needs them, reducing import side effects for non-OpenAI usage. ([#2390](https://github.com/567-labs/instructor/pull/2390)) - **v2 response models**: Treat `list[A | B]` PEP 604 unions of Pydantic models as iterable response models, matching `list[Union[A, B]]` schema behavior. ([#2377](https://github.com/567-labs/instructor/pull/2377)) - **OpenAI Responses API**: Align `RESPONSES_TOOLS` `text.format` with the forced tool schema and add targeted retry guidance when tool calls return empty `{}` arguments. ([#2300](https://github.com/567-labs/instructor/issues/2300), [#2304](https://github.com/567-labs/instructor/pull/2304)) +### Tests / CI +- **Coverage and test quality**: Run the complete offline suite on Python 3.9-3.13, enforce fork-safe statement and branch coverage plus supported-version type checks in pull-request CI, add strict resource and thread warning checks, and provide a manual retry-mutation workflow. Consolidate typed response, stream, and SDK fixtures; remove duplicate tests and unreachable provider paths; and replace coverage-only stubs with meaningful edge-case and transport-backed provider checks. +- **Release safety**: Validate the declared source, lockfile, changelog, tag, and built artifacts before any publication step; require an explicit version confirmation and publish opt-in; and publish the exact tested assets instead of rebuilding from a moving branch. + --- ## [1.15.4] - 2026-06-27 @@ -271,3 +270,6 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed - Pydantic v2 deprecation warnings resolved by migrating from class `Config` to `ConfigDict` ([#1782](https://github.com/567-labs/instructor/pull/1782)) + +[Unreleased]: https://github.com/567-labs/instructor/compare/v1.15.5...HEAD +[1.15.5]: https://github.com/567-labs/instructor/compare/v1.15.4...v1.15.5 diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 000000000..8cfdba62f --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Validate release metadata and extract notes for the declared version.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.9/3.10 compatibility + import tomli as tomllib # type: ignore[no-redef] + + +REPOSITORY = "567-labs/instructor" + + +def _load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as file: + return tomllib.load(file) + + +def _project_version(root: Path) -> str: + data = _load_toml(root / "pyproject.toml") + return str(data["project"]["version"]) + + +def _lock_version(root: Path) -> str: + data = _load_toml(root / "uv.lock") + matches = [ + package + for package in data.get("package", []) + if package.get("name") == "instructor" + ] + if len(matches) != 1: + raise ValueError("uv.lock must contain exactly one instructor package") + return str(matches[0]["version"]) + + +def _release_notes(changelog: str, version: str) -> str: + unreleased = list(re.finditer(r"^## \[Unreleased\]$", changelog, re.MULTILINE)) + if len(unreleased) != 1: + raise ValueError("CHANGELOG.md must contain exactly one [Unreleased] section") + + heading_pattern = re.compile( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}$", + re.MULTILINE, + ) + headings = list(heading_pattern.finditer(changelog)) + if len(headings) != 1: + raise ValueError( + f"CHANGELOG.md must contain exactly one dated [{version}] section" + ) + + heading = headings[0] + if unreleased[0].start() > heading.start(): + raise ValueError("[Unreleased] must appear before the current release section") + + next_heading = re.search(r"^## \[", changelog[heading.end() :], re.MULTILINE) + end = heading.end() + next_heading.start() if next_heading else len(changelog) + notes = changelog[heading.end() : end].strip() + notes = re.sub(r"\n---\s*$", "", notes).strip() + if not notes or "### " not in notes: + raise ValueError(f"CHANGELOG.md [{version}] release notes are empty") + + comparison = re.compile( + rf"^\[{re.escape(version)}\]: " + rf"https://github\.com/{re.escape(REPOSITORY)}/compare/" + rf"v\d+\.\d+\.\d+\.\.\.v{re.escape(version)}$", + re.MULTILINE, + ) + if not comparison.search(changelog): + raise ValueError(f"CHANGELOG.md is missing the [{version}] comparison link") + + return notes + "\n" + + +def prepare_release( + root: Path, + output: Path, + expected_version: str | None = None, +) -> str: + """Validate version metadata and write the matching changelog section.""" + version = _project_version(root) + lock_version = _lock_version(root) + if lock_version != version: + raise ValueError( + f"version mismatch: pyproject.toml={version}, uv.lock={lock_version}" + ) + if expected_version and expected_version != version: + raise ValueError( + f"expected version {expected_version}, but source declares {version}" + ) + + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + notes = _release_notes(changelog, version) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(notes, encoding="utf-8") + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path, default=Path("dist/release-notes.md")) + parser.add_argument("--expected-version") + args = parser.parse_args() + + try: + version = prepare_release( + args.project_root.resolve(), + args.output, + expected_version=args.expected_version, + ) + except (KeyError, OSError, ValueError) as exc: + parser.error(str(exc)) + print(version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py new file mode 100644 index 000000000..f026e0065 --- /dev/null +++ b/tests/test_prepare_release.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "prepare_release.py" + + +def _write_release_files(root: Path, changelog: str) -> None: + (root / "pyproject.toml").write_text( + '[project]\nname = "instructor"\nversion = "1.2.3"\n', encoding="utf-8" + ) + (root / "uv.lock").write_text( + 'version = 1\n[[package]]\nname = "instructor"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + (root / "CHANGELOG.md").write_text(changelog, encoding="utf-8") + + +def _run( + root: Path, expected_version: str = "1.2.3" +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--project-root", + str(root), + "--output", + str(root / "release-notes.md"), + "--expected-version", + expected_version, + ], + check=False, + capture_output=True, + text=True, + ) + + +def _changelog() -> str: + return """# Changelog + +## [Unreleased] + +## [1.2.3] - 2026-08-02 + +### Fixed +- Correct retry accounting. + +--- + +## [1.2.2] - 2026-07-01 + +### Fixed +- Previous fix. + +[Unreleased]: https://github.com/567-labs/instructor/compare/v1.2.3...HEAD +[1.2.3]: https://github.com/567-labs/instructor/compare/v1.2.2...v1.2.3 +""" + + +def test_prepare_release_validates_and_extracts_notes(tmp_path: Path) -> None: + _write_release_files(tmp_path, _changelog()) + + result = _run(tmp_path) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "1.2.3" + assert (tmp_path / "release-notes.md").read_text(encoding="utf-8") == ( + "### Fixed\n- Correct retry accounting.\n" + ) + + +def test_prepare_release_rejects_expected_version_mismatch(tmp_path: Path) -> None: + _write_release_files(tmp_path, _changelog()) + + result = _run(tmp_path, expected_version="1.2.4") + + assert result.returncode == 2 + assert "expected version 1.2.4, but source declares 1.2.3" in result.stderr + + +def test_prepare_release_rejects_duplicate_release_sections(tmp_path: Path) -> None: + changelog = _changelog().replace( + "## [1.2.2] - 2026-07-01", "## [1.2.3] - 2026-07-01" + ) + _write_release_files(tmp_path, changelog) + + result = _run(tmp_path) + + assert result.returncode == 2 + assert "exactly one dated [1.2.3] section" in result.stderr + + +def test_prepare_release_rejects_lockfile_version_drift(tmp_path: Path) -> None: + _write_release_files(tmp_path, _changelog()) + (tmp_path / "uv.lock").write_text( + 'version = 1\n[[package]]\nname = "instructor"\nversion = "1.2.2"\n', + encoding="utf-8", + ) + + result = _run(tmp_path) + + assert result.returncode == 2 + assert "pyproject.toml=1.2.3, uv.lock=1.2.2" in result.stderr + + +def test_prepare_release_requires_comparison_link(tmp_path: Path) -> None: + changelog = _changelog().replace( + "[1.2.3]: https://github.com/567-labs/instructor/compare/v1.2.2...v1.2.3", + "", + ) + _write_release_files(tmp_path, changelog) + + result = _run(tmp_path) + + assert result.returncode == 2 + assert "missing the [1.2.3] comparison link" in result.stderr