diff --git a/.github/update_versions.py b/.github/update_versions.py new file mode 100644 index 00000000..a5ff0c6b --- /dev/null +++ b/.github/update_versions.py @@ -0,0 +1,128 @@ +import pathlib +import re +import click +from packaging.version import Version + +PACKAGES = { + "livekit": "livekit-rtc/livekit/rtc/version.py", + "livekit-api": "livekit-api/livekit/api/version.py", + "livekit-protocol": "livekit-protocol/livekit/protocol/version.py", +} + + +def _esc(*codes: int) -> str: + return "\033[" + ";".join(str(c) for c in codes) + "m" + + +def read_version(f: pathlib.Path) -> str: + text = f.read_text() + m = re.search(r'__version__\s*=\s*[\'"]([^\'"]+)[\'"]', text) + if not m: + raise ValueError(f"could not find __version__ in {f}") + return m.group(1) + + +def write_new_version(f: pathlib.Path, new_version: str) -> None: + text = f.read_text() + new_text = re.sub( + r'__version__\s*=\s*[\'"][^\'"]*[\'"]', + f'__version__ = "{new_version}"', + text, + count=1, + ) + f.write_text(new_text) + + +def bump_version(cur: str, bump_type: str) -> str: + v = Version(cur) + if bump_type == "release": + return v.base_version + if bump_type == "patch": + return f"{v.major}.{v.minor}.{v.micro + 1}" + if bump_type == "minor": + return f"{v.major}.{v.minor + 1}.0" + if bump_type == "major": + return f"{v.major + 1}.0.0" + raise ValueError(f"unknown bump type: {bump_type}") + + +def bump_prerelease(cur: str, bump_type: str) -> str: + v = Version(cur) + base = v.base_version + if bump_type == "rc": + if v.pre and v.pre[0] == "rc": + next_rc = v.pre[1] + 1 + else: + next_rc = 1 + return f"{base}.rc{next_rc}" + raise ValueError(f"unknown prerelease bump type: {bump_type}") + + +def update_api_protocol_dependency(new_protocol_version: str) -> None: + """Update livekit-api's dependency on livekit-protocol.""" + pyproject = pathlib.Path("livekit-api/pyproject.toml") + if not pyproject.exists(): + return + old_text = pyproject.read_text() + new_text = re.sub( + r'"livekit-protocol>=[\w.\-]+,', + f'"livekit-protocol>={new_protocol_version},', + old_text, + ) + if new_text != old_text: + pyproject.write_text(new_text) + print(f"Updated livekit-api dependency on livekit-protocol to >={new_protocol_version}") + + +def do_bump(package: str, bump_type: str) -> None: + version_path = PACKAGES[package] + vf = pathlib.Path(version_path) + cur = read_version(vf) + new = bump_version(cur, bump_type) + print(f"{package}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") + write_new_version(vf, new) + + if package == "livekit-protocol": + update_api_protocol_dependency(new) + + +def do_prerelease(package: str, prerelease_type: str) -> None: + version_path = PACKAGES[package] + vf = pathlib.Path(version_path) + cur = read_version(vf) + new = bump_prerelease(cur, prerelease_type) + print(f"{package}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") + write_new_version(vf, new) + + if package == "livekit-protocol": + update_api_protocol_dependency(new) + + +@click.command("bump") +@click.option( + "--package", + type=click.Choice(list(PACKAGES.keys())), + required=True, + help="Package to bump.", +) +@click.option( + "--pre", + type=click.Choice(["rc", "none"]), + default="none", + help="Pre-release type.", +) +@click.option( + "--bump-type", + type=click.Choice(["patch", "minor", "major", "release"]), + default="patch", + help="Type of version bump.", +) +def bump(package: str, pre: str, bump_type: str) -> None: + if pre == "none": + do_bump(package, bump_type) + else: + do_prerelease(package, pre) + + +if __name__ == "__main__": + bump() diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 9f1ab20c..40ddc14a 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -1,11 +1,3 @@ -# 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: Build Docs on: @@ -50,24 +42,31 @@ jobs: submodules: recursive - name: Install Package to Document - run: python -m pip install ${{ inputs.package_dir }}/ + env: + PACKAGE_DIR: ${{ inputs.package_dir }} + run: python -m pip install "$PACKAGE_DIR/" - name: Download ffi + env: + PACKAGE_NAME: ${{ inputs.package_name }} run: | - if [[ '${{ inputs.package_name }}' = 'livekit.rtc' ]]; then + if [[ "$PACKAGE_NAME" = 'livekit.rtc' ]]; then pip install requests - python livekit-rtc/rust-sdks/download_ffi.py --output $(python -m site --user-site)/livekit/rtc/resources + python livekit-rtc/rust-sdks/download_ffi.py --output "$(python -m site --user-site)/livekit/rtc/resources" fi - name: Install pdoc run: pip install --upgrade pdoc - name: Build Docs - run: python -m pdoc ${{ inputs.package_name }} --docformat=google --output-dir docs + env: + PACKAGE_NAME: ${{ inputs.package_name }} + run: python -m pdoc "$PACKAGE_NAME" --docformat=google --output-dir docs - name: S3 Upload - run: aws s3 cp docs/ s3://livekit-docs/${{ inputs.package_dir }} --recursive env: + PACKAGE_DIR: ${{ inputs.package_dir }} AWS_ACCESS_KEY_ID: ${{ secrets.DOCS_DEPLOY_AWS_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DOCS_DEPLOY_AWS_API_SECRET }} AWS_DEFAULT_REGION: "us-east-1" + run: aws s3 cp docs/ "s3://livekit-docs/$PACKAGE_DIR" --recursive diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..4c5744b1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,424 @@ +name: Publish to PyPI + +on: + # Step 1: Dispatch creates a version bump PR + workflow_dispatch: + inputs: + package: + description: "Package to publish" + type: choice + required: true + options: + - livekit + - livekit-api + - livekit-protocol + version: + description: "What to publish" + type: choice + required: true + options: + - "patch (1.5.1 → 1.5.2)" + - "minor (1.5.1 → 1.6.0)" + - "major (1.5.1 → 2.0.0)" + - "patch-rc (1.5.1 → 1.5.2.rc1)" + - "minor-rc (1.5.1 → 1.6.0.rc1)" + - "major-rc (1.5.1 → 2.0.0.rc1)" + - "next-rc (.rc1 → .rc2)" + - "promote (1.6.0.rc2 → 1.6.0)" + branch: + description: "Branch to publish from (default: main)" + type: string + required: false + default: "main" + + # Step 2: Merging the release PR triggers build + publish + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + id-token: write + +env: + # Map PyPI package names to tag prefixes + # livekit -> rtc, livekit-api -> api, livekit-protocol -> protocol + TAG_PREFIX_MAP: '{"livekit":"rtc","livekit-api":"api","livekit-protocol":"protocol"}' + VERSION_FILE_MAP: '{"livekit":"livekit-rtc/livekit/rtc/version.py","livekit-api":"livekit-api/livekit/api/version.py","livekit-protocol":"livekit-protocol/livekit/protocol/version.py"}' + +jobs: + # ── Step 1: Create a version bump PR ────────────────────────── + bump: + name: Create release PR + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + submodules: true + + - name: Guard non-main branches + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_BRANCH: ${{ inputs.branch }} + run: | + key=$(echo "$INPUT_VERSION" | awk '{print $1}') + if [ "$INPUT_BRANCH" != "main" ]; then + case "$key" in + *-rc|next-rc) ;; # allowed + *) echo "::error::Only RC releases are allowed from non-main branches (got '$key' on '$INPUT_BRANCH')"; exit 1 ;; + esac + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: pip install click packaging + + - name: Bump version + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PACKAGE: ${{ inputs.package }} + run: | + key=$(echo "$INPUT_VERSION" | awk '{print $1}') + pkg="$INPUT_PACKAGE" + + case "$key" in + patch|minor|major) + python .github/update_versions.py bump --package "$pkg" --bump-type "$key" + ;; + patch-rc|minor-rc|major-rc) + bump="${key%-rc}" + python .github/update_versions.py bump --package "$pkg" --bump-type "$bump" + python .github/update_versions.py bump --package "$pkg" --pre rc + ;; + next-rc) + python .github/update_versions.py bump --package "$pkg" --pre rc + ;; + promote) + python .github/update_versions.py bump --package "$pkg" --bump-type release + ;; + esac + + - name: Read new version + id: version + env: + INPUT_PACKAGE: ${{ inputs.package }} + run: | + pkg="$INPUT_PACKAGE" + version_file=$(echo '${{ env.VERSION_FILE_MAP }}' | jq -r --arg pkg "$pkg" '.[$pkg]') + version=$(python -c " + import re, pathlib + m = re.search(r'__version__\s*=\s*[\"'\''](.*?)[\"'\'']', pathlib.Path('${version_file}').read_text()) + print(m.group(1)) + ") + tag_prefix=$(echo '${{ env.TAG_PREFIX_MAP }}' | jq -r --arg pkg "$pkg" '.[$pkg]') + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag_prefix=$tag_prefix" >> "$GITHUB_OUTPUT" + echo "Package: $pkg, New version: $version, Tag: ${tag_prefix}-v${version}" + + - name: Close existing release PRs for this package + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_PREFIX: ${{ steps.version.outputs.tag_prefix }} + run: | + prefix="release/${TAG_PREFIX}-v" + gh pr list --state open --json number,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"$prefix\")) | .number" | while read -r pr; do + echo "Superseding release PR #$pr" + gh pr comment "$pr" --body "Superseded by a new release." + gh pr close "$pr" --delete-branch || true + done + + - name: Create release PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + TAG_PREFIX: ${{ steps.version.outputs.tag_prefix }} + INPUT_BRANCH: ${{ inputs.branch }} + INPUT_PACKAGE: ${{ inputs.package }} + run: | + branch="release/${TAG_PREFIX}-v${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + git add -A + git commit -m "${TAG_PREFIX}-v${VERSION}" + git push --force origin "$branch" + + gh pr create \ + --base "$INPUT_BRANCH" \ + --head "$branch" \ + --title "${INPUT_PACKAGE} v${VERSION}" \ + --body "Merging this PR will publish **${INPUT_PACKAGE}** v${VERSION} to PyPI." \ + --label "release" + + # ── Step 2: Publish on merge ────────────────────────────────── + detect: + name: Detect package + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + outputs: + package: ${{ steps.detect.outputs.package }} + tag: ${{ steps.detect.outputs.tag }} + steps: + - name: Parse release branch + id: detect + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + # branch is like release/rtc-v1.2.0, release/api-v1.1.1, release/protocol-v1.1.5 + ref="${HEAD_REF#release/}" + prefix="${ref%%-v*}" + + case "$prefix" in + rtc) package="livekit" ;; + api) package="livekit-api" ;; + protocol) package="livekit-protocol" ;; + *) echo "::error::Unknown release prefix: $prefix"; exit 1 ;; + esac + + echo "package=$package" >> "$GITHUB_OUTPUT" + echo "tag=$ref" >> "$GITHUB_OUTPUT" + echo "Releasing $package (tag: $ref)" + + tag: + name: Tag release + needs: detect + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Create git tag + env: + TAG: ${{ needs.detect.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + # ── RTC builds (multi-platform) ────────────────────────────── + build-rtc: + name: Build RTC wheels (${{ matrix.archs }}) + needs: detect + if: needs.detect.outputs.package == 'livekit' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + archs: x86_64 + - os: namespace-profile-default-arm64 + archs: aarch64 + - os: windows-latest + archs: AMD64 + - os: macos-latest + archs: x86_64 arm64 + defaults: + run: + working-directory: ./livekit-rtc + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + id: setup-python + with: + python-version: "3.11" + + - name: Build wheels + run: pipx run --python '${{ steps.setup-python.outputs.python-path }}' cibuildwheel==3.3.1 --output-dir dist + env: + CIBW_ARCHS: ${{ matrix.archs }} + + - uses: actions/upload-artifact@v4 + with: + name: dist-rtc-${{ matrix.os }} + path: livekit-rtc/dist/*.whl + + build-rtc-sdist: + name: Build RTC sdist + needs: detect + if: needs.detect.outputs.package == 'livekit' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./livekit-rtc + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Build sdist + run: | + pip3 install build + python3 -m build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: dist-rtc-sdist + path: livekit-rtc/dist/*.tar.gz + + publish-rtc: + name: Publish livekit (RTC) + needs: [detect, build-rtc, build-rtc-sdist] + if: needs.detect.outputs.package == 'livekit' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + pattern: dist-rtc-* + path: dist + merge-multiple: true + + - name: List distributions + run: ls -la dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # ── API build ──────────────────────────────────────────────── + build-api: + name: Build API + needs: detect + if: needs.detect.outputs.package == 'livekit-api' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./livekit-api + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v4 + + - name: Build wheel & sdist + run: | + pip3 install build wheel + python3 -m build --wheel --sdist + + - uses: actions/upload-artifact@v4 + with: + name: dist-api + path: | + livekit-api/dist/*.whl + livekit-api/dist/*.tar.gz + + publish-api: + name: Publish livekit-api + needs: [detect, build-api] + if: needs.detect.outputs.package == 'livekit-api' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: dist-api + path: dist/ + + - name: List distributions + run: ls -la dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # ── Protocol build ─────────────────────────────────────────── + build-protocol: + name: Build Protocol + needs: detect + if: needs.detect.outputs.package == 'livekit-protocol' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./livekit-protocol + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v4 + + - name: Build wheel & sdist + run: | + pip3 install build wheel + python3 -m build --wheel --sdist + + - uses: actions/upload-artifact@v4 + with: + name: dist-protocol + path: | + livekit-protocol/dist/*.whl + livekit-protocol/dist/*.tar.gz + + publish-protocol: + name: Publish livekit-protocol + needs: [detect, build-protocol] + if: needs.detect.outputs.package == 'livekit-protocol' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: dist-protocol + path: dist/ + + - name: List distributions + run: ls -la dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # ── Docs ───────────────────────────────────────────────────── + docs-rtc: + name: Build RTC docs + needs: [detect, publish-rtc] + if: needs.detect.outputs.package == 'livekit' + uses: ./.github/workflows/build-docs.yml + with: + package_dir: livekit-rtc + package_name: livekit.rtc + secrets: inherit + + docs-api: + name: Build API docs + needs: [detect, publish-api] + if: needs.detect.outputs.package == 'livekit-api' + uses: ./.github/workflows/build-docs.yml + with: + package_dir: livekit-api + package_name: livekit.api + secrets: inherit + + docs-protocol: + name: Build Protocol docs + needs: [detect, publish-protocol] + if: needs.detect.outputs.package == 'livekit-protocol' + uses: ./.github/workflows/build-docs.yml + with: + package_dir: livekit-protocol + package_name: livekit.protocol + secrets: inherit diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..7ed81085 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,41 @@ +name: Release gate + +on: + pull_request: + types: [opened, synchronize, reopened] + pull_request_review: + types: [submitted, dismissed] + +permissions: + pull-requests: read + +jobs: + release-gate: + name: Release gate + runs-on: ubuntu-latest + steps: + - name: Check release PR requirements + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + if [[ "$HEAD_REF" != release/* ]]; then + echo "Not a release PR, skipping" + exit 0 + fi + + if [ "$PR_AUTHOR" != "github-actions[bot]" ]; then + echo "::error::Release PRs must be created by the publish workflow, not by '$PR_AUTHOR'" + exit 1 + fi + + approvals=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \ + --jq '[group_by(.user.login)[] | sort_by(.submitted_at) | last | select(.state == "APPROVED") | .user.login] | length') + echo "Approvals: $approvals" + if [ "$approvals" -lt 2 ]; then + echo "::error::Release PRs require at least 2 approvals (got $approvals)" + exit 1 + fi