diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79b1dbb..a52b745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ name: CI on: - push: - branches: [main] pull_request: workflow_dispatch: + workflow_call: # the release workflow runs CI before publishing permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46fca9a..c6e8bcc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,99 @@ name: Release +# Rolling releases: every merge to main that changes the package is tested, versioned from its +# commits, tagged, published to PyPI and announced as a GitHub release. + on: - release: - types: [published] + push: + branches: [main] + workflow_dispatch: + inputs: + force: + description: "Release even if no package files changed" + type: boolean + default: false permissions: contents: read +concurrency: + group: release + cancel-in-progress: false + jobs: + ci: + uses: ./.github/workflows/ci.yml + + version: + needs: ci + runs-on: ubuntu-latest + outputs: + version: ${{ steps.next.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + - id: next + run: | + version=$(python3 scripts/next_version.py ${{ inputs.force && '--force' || '' }}) + echo "version=$version" >> "$GITHUB_OUTPUT" + if [ -n "$version" ]; then echo "Next release: v$version"; else echo "No package changes; nothing to release."; fi + + build: + needs: version + if: needs.version.outputs.version != '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + - name: Build + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.version }} + run: uv build + - run: uvx twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + publish: - name: Publish to PyPI + needs: [version, build] runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/opencode-python-sdk/ + url: https://pypi.org/project/opencode-python-sdk/${{ needs.version.outputs.version }}/ permissions: - id-token: write # trusted publishing + id-token: write # trusted publishing, no stored token attestations: write steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 - - name: Check the tag matches the package version - run: | - version=$(uv run --no-project python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - test "v$version" = "${GITHUB_REF_NAME}" || { echo "Tag ${GITHUB_REF_NAME} does not match version $version"; exit 1; } - - run: uv build + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 with: attestations: true + + github-release: + needs: [version, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Tag and create release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.version.outputs.version }} + run: | + gh release create "v$VERSION" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" \ + --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 545d8f1..2c6b7d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,9 @@ # Changelog -All notable changes to this project are documented here. The format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses -[semantic versioning](https://semver.org/). +Releases are published automatically on every merge to `main`. Release notes for each version are on the +[GitHub releases page](https://github.com/shrijayan/opencode-python-sdk/releases). -## [0.1.0] - Unreleased +## 0.1.0 -First release, generated from OpenCode 2.1.2. - -### Added - -- `Opencode` and `AsyncOpencode` clients covering all 142 operations of the OpenCode 2.1 server API. -- Typed pydantic models for every schema and 96 event types, with forward-compatible enums and union fallbacks. -- Typed exceptions for every error the server declares, plus status-based base classes. -- Cursor pagination, server-sent event streams, retries with backoff, and per-request options. -- `OpencodeServer` and `AsyncOpencodeServer` to run `opencode serve` as a child process. -- Contract extractor and code generator, with CI checks that generated code is current. +First release, generated from OpenCode 2.1.2: sync and async clients for all 142 operations, typed models and +events, typed errors, pagination, event streams, retries, and the `OpencodeServer` launcher. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c29f9b..974294e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,10 +53,28 @@ cd ../.. uv run python -m codegen ``` -Then review the diff of `api.md`, update `API_VERSION` notes in the README version table, and add a changelog -entry. +Then review the diff of `api.md` and update the README version table if the OpenCode minor version changed. ## Changing generated code Change the generator in `codegen/`, run `uv run python -m codegen`, and commit both the generator change and the regenerated files. Never edit generated files directly; CI will reject the change. + +## Releases + +Releases are automatic. Every merge to `main` that changes `src/`, `spec/` or `pyproject.toml` runs CI, picks the +next version from the merged commit subjects, publishes to PyPI and creates a GitHub release with generated notes. +Docs, tests and CI changes alone do not release. + +Use [Conventional Commits](https://www.conventionalcommits.org/) subjects; with rebase merging, every commit lands +on `main` as written: + +| Subject | Bump | +| --- | --- | +| `fix: handle empty cursor` | patch | +| `feat: add session.run helper` | minor | +| `feat!: rename Opencode.server` or a `BREAKING CHANGE:` footer | major (minor while below 1.0) | +| anything else (`chore:`, `refactor:`, ...) | patch | + +The version comes from git tags (`hatch-vcs`); there is no version number to edit. To release without package +changes, run the Release workflow manually with **force**. diff --git a/README.md b/README.md index 9d23791..cf8c783 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,8 @@ retry; credentials are never logged. | 0.1.x | 2.1.x | `opencode.API_VERSION` is the server version the client was generated from. The package follows semantic -versioning; until 1.0, minor versions may contain breaking changes, noted in [CHANGELOG.md](CHANGELOG.md). +versioning and releases automatically on every merge to `main`; until 1.0, minor versions may contain breaking +changes. See the [releases page](https://github.com/shrijayan/opencode-python-sdk/releases). ## How it is built diff --git a/pyproject.toml b/pyproject.toml index 2611ba3..a0c6f09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opencode-python-sdk" -version = "0.1.0" +dynamic = ["version"] description = "Typed Python client for the OpenCode 2.x server API" readme = "README.md" license = "MIT" @@ -51,9 +51,14 @@ dev = [ ] [build-system] -requires = ["hatchling>=1.27"] +requires = ["hatchling>=1.27", "hatch-vcs>=0.5"] build-backend = "hatchling.build" +[tool.hatch.version] +# The version comes from the latest `vX.Y.Z` git tag, which the release workflow creates on every merge to main. +source = "vcs" +raw-options = { local_scheme = "no-local-version", fallback_version = "0.0.0" } + [tool.hatch.build.targets.wheel] packages = ["src/opencode"] @@ -112,6 +117,7 @@ ignore = [ "src/opencode/resources/**" = ["TC"] "src/opencode/_errors.py" = ["TC"] "codegen/**" = ["T20"] +"scripts/**" = ["T20"] "examples/**" = ["T20"] "tests/**" = ["E501", "TC"] diff --git a/scripts/next_version.py b/scripts/next_version.py new file mode 100644 index 0000000..5aa7dd7 --- /dev/null +++ b/scripts/next_version.py @@ -0,0 +1,66 @@ +"""Prints the next release version, or nothing when there is nothing to release. + +Every merge to main that changes the package is released (rolling releases). The bump follows +Conventional Commits in the commit subjects since the last ``vX.Y.Z`` tag: + +- ``feat!:`` / ``fix!:`` or a ``BREAKING CHANGE:`` footer: major (minor while below 1.0) +- ``feat:``: minor +- anything else: patch + +Commits that only touch docs, tests or CI do not trigger a release. With no tag yet, the first +release is 0.1.0. + +Usage: python scripts/next_version.py [--force] +""" + +from __future__ import annotations + +import re +import subprocess +import sys + +RELEASE_PATHS = ("src/", "pyproject.toml", "spec/") +BREAKING = re.compile(r"^[a-z]+(\([^)]*\))?!:", re.IGNORECASE) +FEATURE = re.compile(r"^feat(\([^)]*\))?:", re.IGNORECASE) + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() + + +def latest_tag() -> str | None: + tags = git("tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname").splitlines() + return tags[0] if tags else None + + +def main() -> int: + force = "--force" in sys.argv + tag = latest_tag() + if tag is None: + print("0.1.0") + return 0 + + revision = f"{tag}..HEAD" + changed = git("diff", "--name-only", revision).splitlines() + if not force and not any(path.startswith(RELEASE_PATHS) for path in changed): + return 0 # nothing user-facing changed + + subjects = git("log", "--format=%s", revision).splitlines() + bodies = git("log", "--format=%b", revision) + major, minor, patch = (int(part) for part in tag.removeprefix("v").split(".")) + + if any(BREAKING.match(subject) for subject in subjects) or "BREAKING CHANGE:" in bodies: + if major == 0: + minor, patch = minor + 1, 0 + else: + major, minor, patch = major + 1, 0, 0 + elif any(FEATURE.match(subject) for subject in subjects): + minor, patch = minor + 1, 0 + else: + patch += 1 + print(f"{major}.{minor}.{patch}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/opencode/_version.py b/src/opencode/_version.py index 3dc1f76..75a5d03 100644 --- a/src/opencode/_version.py +++ b/src/opencode/_version.py @@ -1 +1,8 @@ -__version__ = "0.1.0" +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("opencode-python-sdk") +except PackageNotFoundError: # running from a source tree that is not installed + __version__ = "0.0.0" diff --git a/uv.lock b/uv.lock index ce96ab8..cf8f27a 100644 --- a/uv.lock +++ b/uv.lock @@ -577,7 +577,6 @@ wheels = [ [[package]] name = "opencode-python-sdk" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "anyio" },