Skip to content

Commit cde99da

Browse files
committed
feat: rolling releases on every merge to main
Versions come from git tags via hatch-vcs. The release workflow runs CI, computes the next version from Conventional Commit subjects, publishes to PyPI with trusted publishing, then tags and creates a GitHub release. CI no longer runs separately on pushes to main.
1 parent 1ccb32f commit cde99da

9 files changed

Lines changed: 191 additions & 35 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
name: CI
22

33
on:
4-
push:
5-
branches: [main]
64
pull_request:
75
workflow_dispatch:
6+
workflow_call: # the release workflow runs CI before publishing
87

98
permissions:
109
contents: read

‎.github/workflows/release.yml‎

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,99 @@
11
name: Release
22

3+
# Rolling releases: every merge to main that changes the package is tested, versioned from its
4+
# commits, tagged, published to PyPI and announced as a GitHub release.
5+
36
on:
4-
release:
5-
types: [published]
7+
push:
8+
branches: [main]
9+
workflow_dispatch:
10+
inputs:
11+
force:
12+
description: "Release even if no package files changed"
13+
type: boolean
14+
default: false
615

716
permissions:
817
contents: read
918

19+
concurrency:
20+
group: release
21+
cancel-in-progress: false
22+
1023
jobs:
24+
ci:
25+
uses: ./.github/workflows/ci.yml
26+
27+
version:
28+
needs: ci
29+
runs-on: ubuntu-latest
30+
outputs:
31+
version: ${{ steps.next.outputs.version }}
32+
steps:
33+
- uses: actions/checkout@v5
34+
with:
35+
fetch-depth: 0
36+
persist-credentials: false
37+
- id: next
38+
run: |
39+
version=$(python3 scripts/next_version.py ${{ inputs.force && '--force' || '' }})
40+
echo "version=$version" >> "$GITHUB_OUTPUT"
41+
if [ -n "$version" ]; then echo "Next release: v$version"; else echo "No package changes; nothing to release."; fi
42+
43+
build:
44+
needs: version
45+
if: needs.version.outputs.version != ''
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v5
49+
with:
50+
persist-credentials: false
51+
- uses: astral-sh/setup-uv@v6
52+
- name: Build
53+
env:
54+
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.version }}
55+
run: uv build
56+
- run: uvx twine check --strict dist/*
57+
- uses: actions/upload-artifact@v4
58+
with:
59+
name: dist
60+
path: dist/
61+
1162
publish:
12-
name: Publish to PyPI
63+
needs: [version, build]
1364
runs-on: ubuntu-latest
1465
environment:
1566
name: pypi
16-
url: https://pypi.org/project/opencode-python-sdk/
67+
url: https://pypi.org/project/opencode-python-sdk/${{ needs.version.outputs.version }}/
1768
permissions:
18-
id-token: write # trusted publishing
69+
id-token: write # trusted publishing, no stored token
1970
attestations: write
2071
steps:
21-
- uses: actions/checkout@v5
22-
- uses: astral-sh/setup-uv@v6
23-
- name: Check the tag matches the package version
24-
run: |
25-
version=$(uv run --no-project python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
26-
test "v$version" = "${GITHUB_REF_NAME}" || { echo "Tag ${GITHUB_REF_NAME} does not match version $version"; exit 1; }
27-
- run: uv build
72+
- uses: actions/download-artifact@v4
73+
with:
74+
name: dist
75+
path: dist/
2876
- uses: pypa/gh-action-pypi-publish@release/v1
2977
with:
3078
attestations: true
79+
80+
github-release:
81+
needs: [version, publish]
82+
runs-on: ubuntu-latest
83+
permissions:
84+
contents: write
85+
steps:
86+
- uses: actions/download-artifact@v4
87+
with:
88+
name: dist
89+
path: dist/
90+
- name: Tag and create release
91+
env:
92+
GH_TOKEN: ${{ github.token }}
93+
VERSION: ${{ needs.version.outputs.version }}
94+
run: |
95+
gh release create "v$VERSION" dist/* \
96+
--repo "$GITHUB_REPOSITORY" \
97+
--target "$GITHUB_SHA" \
98+
--title "v$VERSION" \
99+
--generate-notes

‎CHANGELOG.md‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
11
# Changelog
22

3-
All notable changes to this project are documented here. The format follows
4-
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5-
[semantic versioning](https://semver.org/).
3+
Releases are published automatically on every merge to `main`. Release notes for each version are on the
4+
[GitHub releases page](https://github.com/shrijayan/opencode-python-sdk/releases).
65

7-
## [0.1.0] - Unreleased
6+
## 0.1.0
87

9-
First release, generated from OpenCode 2.1.2.
10-
11-
### Added
12-
13-
- `Opencode` and `AsyncOpencode` clients covering all 142 operations of the OpenCode 2.1 server API.
14-
- Typed pydantic models for every schema and 96 event types, with forward-compatible enums and union fallbacks.
15-
- Typed exceptions for every error the server declares, plus status-based base classes.
16-
- Cursor pagination, server-sent event streams, retries with backoff, and per-request options.
17-
- `OpencodeServer` and `AsyncOpencodeServer` to run `opencode serve` as a child process.
18-
- Contract extractor and code generator, with CI checks that generated code is current.
8+
First release, generated from OpenCode 2.1.2: sync and async clients for all 142 operations, typed models and
9+
events, typed errors, pagination, event streams, retries, and the `OpencodeServer` launcher.

‎CONTRIBUTING.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,28 @@ cd ../..
5353
uv run python -m codegen
5454
```
5555

56-
Then review the diff of `api.md`, update `API_VERSION` notes in the README version table, and add a changelog
57-
entry.
56+
Then review the diff of `api.md` and update the README version table if the OpenCode minor version changed.
5857

5958
## Changing generated code
6059

6160
Change the generator in `codegen/`, run `uv run python -m codegen`, and commit both the generator change and the
6261
regenerated files. Never edit generated files directly; CI will reject the change.
62+
63+
## Releases
64+
65+
Releases are automatic. Every merge to `main` that changes `src/`, `spec/` or `pyproject.toml` runs CI, picks the
66+
next version from the merged commit subjects, publishes to PyPI and creates a GitHub release with generated notes.
67+
Docs, tests and CI changes alone do not release.
68+
69+
Use [Conventional Commits](https://www.conventionalcommits.org/) subjects; with rebase merging, every commit lands
70+
on `main` as written:
71+
72+
| Subject | Bump |
73+
| --- | --- |
74+
| `fix: handle empty cursor` | patch |
75+
| `feat: add session.run helper` | minor |
76+
| `feat!: rename Opencode.server` or a `BREAKING CHANGE:` footer | major (minor while below 1.0) |
77+
| anything else (`chore:`, `refactor:`, ...) | patch |
78+
79+
The version comes from git tags (`hatch-vcs`); there is no version number to edit. To release without package
80+
changes, run the Release workflow manually with **force**.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,8 @@ retry; credentials are never logged.
259259
| 0.1.x | 2.1.x |
260260

261261
`opencode.API_VERSION` is the server version the client was generated from. The package follows semantic
262-
versioning; until 1.0, minor versions may contain breaking changes, noted in [CHANGELOG.md](CHANGELOG.md).
262+
versioning and releases automatically on every merge to `main`; until 1.0, minor versions may contain breaking
263+
changes. See the [releases page](https://github.com/shrijayan/opencode-python-sdk/releases).
263264

264265
## How it is built
265266

‎pyproject.toml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "opencode-python-sdk"
3-
version = "0.1.0"
3+
dynamic = ["version"]
44
description = "Typed Python client for the OpenCode 2.x server API"
55
readme = "README.md"
66
license = "MIT"
@@ -51,9 +51,14 @@ dev = [
5151
]
5252

5353
[build-system]
54-
requires = ["hatchling>=1.27"]
54+
requires = ["hatchling>=1.27", "hatch-vcs>=0.5"]
5555
build-backend = "hatchling.build"
5656

57+
[tool.hatch.version]
58+
# The version comes from the latest `vX.Y.Z` git tag, which the release workflow creates on every merge to main.
59+
source = "vcs"
60+
raw-options = { local_scheme = "no-local-version", fallback_version = "0.0.0" }
61+
5762
[tool.hatch.build.targets.wheel]
5863
packages = ["src/opencode"]
5964

@@ -112,6 +117,7 @@ ignore = [
112117
"src/opencode/resources/**" = ["TC"]
113118
"src/opencode/_errors.py" = ["TC"]
114119
"codegen/**" = ["T20"]
120+
"scripts/**" = ["T20"]
115121
"examples/**" = ["T20"]
116122
"tests/**" = ["E501", "TC"]
117123

‎scripts/next_version.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Prints the next release version, or nothing when there is nothing to release.
2+
3+
Every merge to main that changes the package is released (rolling releases). The bump follows
4+
Conventional Commits in the commit subjects since the last ``vX.Y.Z`` tag:
5+
6+
- ``feat!:`` / ``fix!:`` or a ``BREAKING CHANGE:`` footer: major (minor while below 1.0)
7+
- ``feat:``: minor
8+
- anything else: patch
9+
10+
Commits that only touch docs, tests or CI do not trigger a release. With no tag yet, the first
11+
release is 0.1.0.
12+
13+
Usage: python scripts/next_version.py [--force]
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import re
19+
import subprocess
20+
import sys
21+
22+
RELEASE_PATHS = ("src/", "pyproject.toml", "spec/")
23+
BREAKING = re.compile(r"^[a-z]+(\([^)]*\))?!:", re.IGNORECASE)
24+
FEATURE = re.compile(r"^feat(\([^)]*\))?:", re.IGNORECASE)
25+
26+
27+
def git(*args: str) -> str:
28+
return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip()
29+
30+
31+
def latest_tag() -> str | None:
32+
tags = git("tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname").splitlines()
33+
return tags[0] if tags else None
34+
35+
36+
def main() -> int:
37+
force = "--force" in sys.argv
38+
tag = latest_tag()
39+
if tag is None:
40+
print("0.1.0")
41+
return 0
42+
43+
revision = f"{tag}..HEAD"
44+
changed = git("diff", "--name-only", revision).splitlines()
45+
if not force and not any(path.startswith(RELEASE_PATHS) for path in changed):
46+
return 0 # nothing user-facing changed
47+
48+
subjects = git("log", "--format=%s", revision).splitlines()
49+
bodies = git("log", "--format=%b", revision)
50+
major, minor, patch = (int(part) for part in tag.removeprefix("v").split("."))
51+
52+
if any(BREAKING.match(subject) for subject in subjects) or "BREAKING CHANGE:" in bodies:
53+
if major == 0:
54+
minor, patch = minor + 1, 0
55+
else:
56+
major, minor, patch = major + 1, 0, 0
57+
elif any(FEATURE.match(subject) for subject in subjects):
58+
minor, patch = minor + 1, 0
59+
else:
60+
patch += 1
61+
print(f"{major}.{minor}.{patch}")
62+
return 0
63+
64+
65+
if __name__ == "__main__":
66+
raise SystemExit(main())

‎src/opencode/_version.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
__version__ = "0.1.0"
1+
from __future__ import annotations
2+
3+
from importlib.metadata import PackageNotFoundError, version
4+
5+
try:
6+
__version__ = version("opencode-python-sdk")
7+
except PackageNotFoundError: # running from a source tree that is not installed
8+
__version__ = "0.0.0"

‎uv.lock‎

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)