diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml new file mode 100644 index 0000000000..3dc7e92343 --- /dev/null +++ b/.github/workflows/publish-pypi.yaml @@ -0,0 +1,62 @@ +# NOTE: This filename is hardcoded in our trusted publishing setup on pypi.org and test.pypi.org; +# do not change this filename without updating it in both places as well. +name: Publish to PyPI +on: + workflow_dispatch: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - run: uv sync + + - run: uv run python scripts/generate_wheels.py + + - uses: actions/upload-artifact@v7 + with: + name: wheels + path: dist/ + + test-publish: + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: pypi-test + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: wheels + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + publish: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + environment: pypi-release + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: wheels + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true diff --git a/make/buf/all.mk b/make/buf/all.mk index ad99cc3560..c30b848c24 100644 --- a/make/buf/all.mk +++ b/make/buf/all.mk @@ -199,3 +199,4 @@ postupgrade:: checkandupdateprecommithooks updatebufversion: $(SED_I) -E "s/BUF_VERSION \?=.*/BUF_VERSION ?= v${RELEASE_BUF_VERSION}/" "make/go/dep_buf.mk" $(SED_I) -E "s/\# https\:\/\/github.com\/bufbuild\/buf\/releases.*/\# https\:\/\/github.com\/bufbuild\/buf\/releases $(shell date "+%Y%m%d") checked $(shell date "+%Y%m%d")/" "make/go/dep_buf.mk" + $(SED_I) -E "s/^version = \".*\"/version = \"${RELEASE_BUF_VERSION}\"/" "packaging/python/pyproject.toml" diff --git a/packaging/python/.gitignore b/packaging/python/.gitignore new file mode 100644 index 0000000000..f1c6f33013 --- /dev/null +++ b/packaging/python/.gitignore @@ -0,0 +1,4 @@ +.venv +.ruff_cache +dist +uv.lock diff --git a/packaging/python/LICENSE b/packaging/python/LICENSE new file mode 120000 index 0000000000..30cff7403d --- /dev/null +++ b/packaging/python/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packaging/python/README.md b/packaging/python/README.md new file mode 120000 index 0000000000..fe84005413 --- /dev/null +++ b/packaging/python/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/packaging/python/buf_bin/__init__.py b/packaging/python/buf_bin/__init__.py new file mode 100644 index 0000000000..592c0de6e4 --- /dev/null +++ b/packaging/python/buf_bin/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2020-2026 Buf Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# buf-bin: Buf CLI distributed via PyPI. +# The buf binary is installed directly as a script on PATH. diff --git a/packaging/python/out/bin/.gitkeep b/packaging/python/out/bin/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packaging/python/pyproject.toml b/packaging/python/pyproject.toml new file mode 100644 index 0000000000..a35668d6b5 --- /dev/null +++ b/packaging/python/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "buf-bin" +version = "1.72.0" +description = "Buf CLI distributed via PyPI" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE"] +keywords = [ + "buf", + "bufbuild", + "protobuf", + "protoc", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Compilers", +] + +[project.urls] +Changelog = "https://github.com/bufbuild/buf/blob/main/CHANGELOG.md" +Documentation = "https://buf.build/docs/cli/" +Homepage = "https://buf.build/docs/cli/" +Issue = "https://github.com/bufbuild/buf/issues" +Repository = "https://github.com/bufbuild/buf" + +[dependency-groups] +dev = ["wheel"] + +[build-system] +requires = ["uv_build>=0.11.0,<0.12.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "buf_bin" +module-root = "" +data = { scripts = "out/bin" } diff --git a/packaging/python/scripts/generate_wheels.py b/packaging/python/scripts/generate_wheels.py new file mode 100644 index 0000000000..e98e2f0daa --- /dev/null +++ b/packaging/python/scripts/generate_wheels.py @@ -0,0 +1,126 @@ +# Copyright 2020-2026 Buf Technologies, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import shutil +import subprocess +import sys +import urllib.request +from importlib.metadata import version as pkg_version +from pathlib import Path + +GITHUB_RELEASES_BASE = "https://github.com/bufbuild/buf/releases/download" + +# Maps buf platform suffix to Python wheel platform tag. +# buf binary names on GitHub releases follow the pattern: +# buf-{PLATFORM} or buf-{PLATFORM}.exe +# Check https://go.dev/wiki/MinimumRequirements#operating-systems for +# minimum OS versions, especially macOS. +PLATFORMS = [ + ("Darwin-arm64", "macosx_11_0_arm64"), + ("Darwin-x86_64", "macosx_11_0_x86_64"), + ("Linux-aarch64", "manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64"), + ("Linux-armv7", "manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l"), + ("Linux-ppc64le", "manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le"), + ("Linux-riscv64", "manylinux_2_17_riscv64.musllinux_1_2_riscv64"), + ("Linux-s390x", "manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x"), + ("Linux-x86_64", "manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64"), + ("Windows-arm64", "win_arm64"), + ("Windows-x86_64", "win_amd64"), +] + + +def fetch_checksums(version: str) -> dict[str, str]: + url = f"{GITHUB_RELEASES_BASE}/v{version}/sha256.txt" + with urllib.request.urlopen(url) as response: + content = response.read().decode() + result = {} + for line in content.splitlines(): + if " " in line: + sha256, filename = line.split(" ", 1) + result[filename] = sha256 + return result + + +def verify_checksum(path: Path, expected: str) -> None: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + msg = f"checksum mismatch for {path.name}: expected {expected}, got {actual}" + raise ValueError(msg) + + +def download(url: str, dest: Path) -> None: + with urllib.request.urlopen(url) as response, dest.open("wb") as f: + shutil.copyfileobj(response, f) + + +def main() -> None: + base_dir = Path(__file__).parent.parent + + version = pkg_version("buf-bin") + + print(f"Generating wheels for buf v{version}") + checksums = fetch_checksums(version) + + bin_dir = base_dir / "out" / "bin" + + for buf_platform, wheel_platform in PLATFORMS: + print(f"\nBuilding wheel for {buf_platform} ({wheel_platform})") + + shutil.rmtree(bin_dir, ignore_errors=True) + bin_dir.mkdir(parents=True) + + try: + ext = ".exe" if buf_platform.startswith("Windows") else "" + filename = f"buf-{buf_platform}{ext}" + dest = bin_dir / f"buf{ext}" + print(f" Downloading {GITHUB_RELEASES_BASE}/v{version}/{filename}") + download(f"{GITHUB_RELEASES_BASE}/v{version}/{filename}", dest) + verify_checksum(dest, checksums[filename]) + if not ext: + dest.chmod(0o755) + + subprocess.run( + ["uv", "build", "--wheel"], + check=True, + cwd=base_dir, + ) + + dist_dir = base_dir / "dist" + built_wheel = next(dist_dir.glob("*-py3-none-any.whl")) + + subprocess.run( + [ + sys.executable, + "-m", + "wheel", + "tags", + "--remove", + "--platform-tag", + wheel_platform, + str(built_wheel), + ], + check=True, + ) + finally: + shutil.rmtree(bin_dir, ignore_errors=True) + + bin_dir.mkdir(parents=True) + (bin_dir / ".gitkeep").touch() + + print("\nDone. Wheels written to dist/") + + +if __name__ == "__main__": + main()