From 80f2d3bfa17cddd7ea2129eb9ef0b129d88cec0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 12:43:41 -0700 Subject: [PATCH 1/6] ci: add PyPI publish workflow with trusted publishing --- .github/update_versions.py | 128 +++++++++++ .github/workflows/publish.yml | 353 +++++++++++++++++++++++++++++ .github/workflows/release-gate.yml | 38 ++++ 3 files changed, 519 insertions(+) create mode 100644 .github/update_versions.py create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release-gate.yml diff --git a/.github/update_versions.py b/.github/update_versions.py new file mode 100644 index 00000000..8730e205 --- /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(bump_type: str) -> None: + new_versions = {} + for pypi_name, version_path in PACKAGES.items(): + vf = pathlib.Path(version_path) + if vf.exists(): + cur = read_version(vf) + new = bump_version(cur, bump_type) + print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") + write_new_version(vf, new) + new_versions[pypi_name] = new + + if "livekit-protocol" in new_versions: + update_api_protocol_dependency(new_versions["livekit-protocol"]) + + +def do_prerelease(prerelease_type: str) -> None: + new_versions = {} + for pypi_name, version_path in PACKAGES.items(): + vf = pathlib.Path(version_path) + if vf.exists(): + cur = read_version(vf) + new = bump_prerelease(cur, prerelease_type) + print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") + write_new_version(vf, new) + new_versions[pypi_name] = new + + if "livekit-protocol" in new_versions: + update_api_protocol_dependency(new_versions["livekit-protocol"]) + + +@click.command("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(pre: str, bump_type: str) -> None: + if pre == "none": + do_bump(bump_type) + else: + do_prerelease(pre) + + +if __name__ == "__main__": + bump() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..7608fc6c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,353 @@ +name: Publish to PyPI + +on: + # Step 1: Dispatch creates a version bump PR + workflow_dispatch: + inputs: + 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 + +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 + run: | + key=$(echo "${{ inputs.version }}" | awk '{print $1}') + branch="${{ inputs.branch }}" + if [ "$branch" != "main" ]; then + case "$key" in + *-rc|next-rc) ;; # allowed + *) echo "::error::Only RC releases are allowed from non-main branches (got '$key' on '$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 versions + run: | + key=$(echo "${{ inputs.version }}" | awk '{print $1}') + + case "$key" in + patch|minor|major) + python .github/update_versions.py bump --bump-type "$key" + ;; + patch-rc|minor-rc|major-rc) + bump="${key%-rc}" + python .github/update_versions.py bump --bump-type "$bump" + python .github/update_versions.py bump --pre rc + ;; + next-rc) + python .github/update_versions.py bump --pre rc + ;; + promote) + python .github/update_versions.py bump --bump-type release + ;; + esac + + - name: Read new version + id: version + run: | + version=$(python -c " + import re, pathlib + m = re.search(r'__version__\s*=\s*[\"'\''](.*?)[\"'\'']', pathlib.Path('livekit-rtc/livekit/rtc/version.py').read_text()) + print(m.group(1)) + ") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "New version: $version" + + - name: Close existing release PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Close any open PRs from release/* branches + gh pr list --state open --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("release/v")) | .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 }} + run: | + version="${{ steps.version.outputs.version }}" + branch="release/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 "v${version}" + git push --force origin "$branch" + + gh pr create \ + --base "${{ inputs.branch }}" \ + --head "$branch" \ + --title "v${version}" \ + --body "Merging this PR will publish all packages as **v${version}** to PyPI." \ + --label "release" + + # ── Step 2: Publish on merge ────────────────────────────────── + build-rtc: + name: Build RTC wheels (${{ matrix.archs }}) + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v') + 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 + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v') + 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 + + build-api: + name: Build API + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v') + 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 + + build-protocol: + name: Build Protocol + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v') + 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 + + tag: + name: Tag release + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create git tag + run: | + version="${{ github.event.pull_request.head.ref }}" + version="${version#release/}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$version" + git push origin "$version" + + publish-rtc: + name: Publish livekit (RTC) + needs: [build-rtc, build-rtc-sdist] + 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 + + publish-api: + name: Publish livekit-api + needs: [build-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 + + publish-protocol: + name: Publish livekit-protocol + needs: [build-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: + name: Build docs (${{ matrix.package_name }}) + needs: [publish-rtc, publish-api, publish-protocol] + strategy: + matrix: + include: + - package_dir: livekit-rtc + package_name: livekit.rtc + - package_dir: livekit-api + package_name: livekit.api + - package_dir: livekit-protocol + package_name: livekit.protocol + uses: ./.github/workflows/build-docs.yml + with: + package_dir: ${{ matrix.package_dir }} + package_name: ${{ matrix.package_name }} + secrets: inherit diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..a5ff5a99 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,38 @@ +name: Release gate + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "**/version.py" + pull_request_review: + types: [submitted, dismissed] + +permissions: + pull-requests: read + +jobs: + release-gate: + name: Release gate + if: startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + steps: + - name: Verify PR was created by GitHub Actions + run: | + author="${{ github.event.pull_request.user.login }}" + if [ "$author" != "github-actions[bot]" ]; then + echo "::error::Release PRs must be created by the publish workflow, not by '$author'" + exit 1 + fi + + - name: Require at least 2 approvals + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + approvals=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews \ + --jq '[.[] | select(.state == "APPROVED")] | length') + echo "Approvals: $approvals" + if [ "$approvals" -lt 2 ]; then + echo "::error::Release PRs require at least 2 approvals (got $approvals)" + exit 1 + fi From 189abf84080d9c80c46577835b378528b8711522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 13:02:38 -0700 Subject: [PATCH 2/6] ci: support per-package releases in publish workflow --- .github/update_versions.py | 62 ++++---- .github/workflows/publish.yml | 266 ++++++++++++++++++++-------------- 2 files changed, 192 insertions(+), 136 deletions(-) diff --git a/.github/update_versions.py b/.github/update_versions.py index 8730e205..a5ff0c6b 100644 --- a/.github/update_versions.py +++ b/.github/update_versions.py @@ -74,37 +74,37 @@ def update_api_protocol_dependency(new_protocol_version: str) -> None: print(f"Updated livekit-api dependency on livekit-protocol to >={new_protocol_version}") -def do_bump(bump_type: str) -> None: - new_versions = {} - for pypi_name, version_path in PACKAGES.items(): - vf = pathlib.Path(version_path) - if vf.exists(): - cur = read_version(vf) - new = bump_version(cur, bump_type) - print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") - write_new_version(vf, new) - new_versions[pypi_name] = new - - if "livekit-protocol" in new_versions: - update_api_protocol_dependency(new_versions["livekit-protocol"]) - - -def do_prerelease(prerelease_type: str) -> None: - new_versions = {} - for pypi_name, version_path in PACKAGES.items(): - vf = pathlib.Path(version_path) - if vf.exists(): - cur = read_version(vf) - new = bump_prerelease(cur, prerelease_type) - print(f"{pypi_name}: {_esc(31)}{cur}{_esc(0)} -> {_esc(32)}{new}{_esc(0)}") - write_new_version(vf, new) - new_versions[pypi_name] = new - - if "livekit-protocol" in new_versions: - update_api_protocol_dependency(new_versions["livekit-protocol"]) +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"]), @@ -117,11 +117,11 @@ def do_prerelease(prerelease_type: str) -> None: default="patch", help="Type of version bump.", ) -def bump(pre: str, bump_type: str) -> None: +def bump(package: str, pre: str, bump_type: str) -> None: if pre == "none": - do_bump(bump_type) + do_bump(package, bump_type) else: - do_prerelease(pre) + do_prerelease(package, pre) if __name__ == "__main__": diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7608fc6c..3e285bbd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,14 @@ 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 @@ -32,6 +40,12 @@ permissions: 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: @@ -63,45 +77,50 @@ jobs: - name: Install dependencies run: pip install click packaging - - name: Bump versions + - name: Bump version run: | key=$(echo "${{ inputs.version }}" | awk '{print $1}') + pkg="${{ inputs.package }}" case "$key" in patch|minor|major) - python .github/update_versions.py bump --bump-type "$key" + 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 --bump-type "$bump" - python .github/update_versions.py bump --pre 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 --pre rc + python .github/update_versions.py bump --package "$pkg" --pre rc ;; promote) - python .github/update_versions.py bump --bump-type release + python .github/update_versions.py bump --package "$pkg" --bump-type release ;; esac - name: Read new version id: version run: | + pkg="${{ inputs.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('livekit-rtc/livekit/rtc/version.py').read_text()) + 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 "New version: $version" + echo "tag_prefix=$tag_prefix" >> "$GITHUB_OUTPUT" + echo "Package: $pkg, New version: $version, Tag: ${tag_prefix}-v${version}" - - name: Close existing release PRs + - name: Close existing release PRs for this package env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Close any open PRs from release/* branches + prefix="release/${{ steps.version.outputs.tag_prefix }}-v" gh pr list --state open --json number,headRefName \ - --jq '.[] | select(.headRefName | startswith("release/v")) | .number' | while read -r pr; do + --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 @@ -112,29 +131,73 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version="${{ steps.version.outputs.version }}" - branch="release/v${version}" + tag_prefix="${{ steps.version.outputs.tag_prefix }}" + 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 "v${version}" + git commit -m "${tag_prefix}-v${version}" git push --force origin "$branch" gh pr create \ --base "${{ inputs.branch }}" \ --head "$branch" \ - --title "v${version}" \ - --body "Merging this PR will publish all packages as **v${version}** to PyPI." \ + --title "${{ inputs.package }} v${version}" \ + --body "Merging this PR will publish **${{ inputs.package }}** v${version} to PyPI." \ --label "release" # ── Step 2: Publish on merge ────────────────────────────────── - build-rtc: - name: Build RTC wheels (${{ matrix.archs }}) + detect: + name: Detect package if: | github.event_name == 'pull_request' && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/v') + && 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 + run: | + branch="${{ github.event.pull_request.head.ref }}" + # branch is like release/rtc-v1.2.0, release/api-v1.1.1, release/protocol-v1.1.5 + ref="${branch#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 + + - name: Create git tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "${{ needs.detect.outputs.tag }}" + git push origin "${{ needs.detect.outputs.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 @@ -173,10 +236,8 @@ jobs: build-rtc-sdist: name: Build RTC sdist - if: | - github.event_name == 'pull_request' - && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/v') + needs: detect + if: needs.detect.outputs.package == 'livekit' runs-on: ubuntu-latest defaults: run: @@ -196,12 +257,33 @@ jobs: 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 - if: | - github.event_name == 'pull_request' - && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/v') + needs: detect + if: needs.detect.outputs.package == 'livekit-api' runs-on: ubuntu-latest defaults: run: @@ -225,12 +307,32 @@ jobs: 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 - if: | - github.event_name == 'pull_request' - && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/v') + needs: detect + if: needs.detect.outputs.package == 'livekit-protocol' runs-on: ubuntu-latest defaults: run: @@ -254,69 +356,10 @@ jobs: livekit-protocol/dist/*.whl livekit-protocol/dist/*.tar.gz - tag: - name: Tag release - if: | - github.event_name == 'pull_request' - && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/v') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Create git tag - run: | - version="${{ github.event.pull_request.head.ref }}" - version="${version#release/}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "$version" - git push origin "$version" - - publish-rtc: - name: Publish livekit (RTC) - needs: [build-rtc, build-rtc-sdist] - 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 - - publish-api: - name: Publish livekit-api - needs: [build-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 - publish-protocol: name: Publish livekit-protocol - needs: [build-protocol] + needs: [detect, build-protocol] + if: needs.detect.outputs.package == 'livekit-protocol' runs-on: ubuntu-latest environment: pypi permissions: @@ -334,20 +377,33 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - docs: - name: Build docs (${{ matrix.package_name }}) - needs: [publish-rtc, publish-api, publish-protocol] - strategy: - matrix: - include: - - package_dir: livekit-rtc - package_name: livekit.rtc - - package_dir: livekit-api - package_name: livekit.api - - package_dir: livekit-protocol - package_name: livekit.protocol + # ── 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: ${{ matrix.package_dir }} - package_name: ${{ matrix.package_name }} + package_dir: livekit-protocol + package_name: livekit.protocol secrets: inherit From c178bb3f6e6fb0ec7175fd39708790fff06b2ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 13:10:45 -0700 Subject: [PATCH 3/6] ci: fix script injection, unique approvals, scope PR closing per-package --- .github/workflows/publish.yml | 53 +++++++++++++++++++----------- .github/workflows/release-gate.yml | 11 ++++--- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3e285bbd..cb06aa4c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,13 +59,15 @@ jobs: submodules: true - name: Guard non-main branches + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_BRANCH: ${{ inputs.branch }} run: | - key=$(echo "${{ inputs.version }}" | awk '{print $1}') - branch="${{ inputs.branch }}" - if [ "$branch" != "main" ]; then + 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 '$branch')"; exit 1 ;; + *) echo "::error::Only RC releases are allowed from non-main branches (got '$key' on '$INPUT_BRANCH')"; exit 1 ;; esac fi @@ -78,9 +80,12 @@ jobs: run: pip install click packaging - name: Bump version + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PACKAGE: ${{ inputs.package }} run: | - key=$(echo "${{ inputs.version }}" | awk '{print $1}') - pkg="${{ inputs.package }}" + key=$(echo "$INPUT_VERSION" | awk '{print $1}') + pkg="$INPUT_PACKAGE" case "$key" in patch|minor|major) @@ -101,12 +106,14 @@ jobs: - name: Read new version id: version + env: + INPUT_PACKAGE: ${{ inputs.package }} run: | - pkg="${{ inputs.package }}" + 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()) + 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]') @@ -117,8 +124,9 @@ jobs: - name: Close existing release PRs for this package env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_PREFIX: ${{ steps.version.outputs.tag_prefix }} run: | - prefix="release/${{ steps.version.outputs.tag_prefix }}-v" + 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" @@ -129,23 +137,25 @@ jobs: - 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: | - version="${{ steps.version.outputs.version }}" - tag_prefix="${{ steps.version.outputs.tag_prefix }}" - branch="release/${tag_prefix}-v${version}" + 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 commit -m "${TAG_PREFIX}-v${VERSION}" git push --force origin "$branch" gh pr create \ - --base "${{ inputs.branch }}" \ + --base "$INPUT_BRANCH" \ --head "$branch" \ - --title "${{ inputs.package }} v${version}" \ - --body "Merging this PR will publish **${{ inputs.package }}** v${version} to PyPI." \ + --title "${INPUT_PACKAGE} v${VERSION}" \ + --body "Merging this PR will publish **${INPUT_PACKAGE}** v${VERSION} to PyPI." \ --label "release" # ── Step 2: Publish on merge ────────────────────────────────── @@ -162,10 +172,11 @@ jobs: steps: - name: Parse release branch id: detect + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - branch="${{ github.event.pull_request.head.ref }}" # branch is like release/rtc-v1.2.0, release/api-v1.1.1, release/protocol-v1.1.5 - ref="${branch#release/}" + ref="${HEAD_REF#release/}" prefix="${ref%%-v*}" case "$prefix" in @@ -187,11 +198,13 @@ jobs: - uses: actions/checkout@v4 - 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 "${{ needs.detect.outputs.tag }}" - git push origin "${{ needs.detect.outputs.tag }}" + git tag "$TAG" + git push origin "$TAG" # ── RTC builds (multi-platform) ────────────────────────────── build-rtc: diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index a5ff5a99..ea0ab34d 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -14,14 +14,15 @@ permissions: jobs: release-gate: name: Release gate - if: startsWith(github.event.pull_request.head.ref, 'release/v') + if: startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest steps: - name: Verify PR was created by GitHub Actions + env: + PR_AUTHOR: ${{ github.event.pull_request.user.login }} run: | - author="${{ github.event.pull_request.user.login }}" - if [ "$author" != "github-actions[bot]" ]; then - echo "::error::Release PRs must be created by the publish workflow, not by '$author'" + 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 @@ -30,7 +31,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | approvals=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews \ - --jq '[.[] | select(.state == "APPROVED")] | length') + --jq '[.[] | select(.state == "APPROVED") | .user.login] | unique | length') echo "Approvals: $approvals" if [ "$approvals" -lt 2 ]; then echo "::error::Release PRs require at least 2 approvals (got $approvals)" From 883f27d265275a47c4d171fecdbd995a0f6497e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 13:27:16 -0700 Subject: [PATCH 4/6] ci: improve release gate approval counting and remove paths filter --- .github/workflows/release-gate.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index ea0ab34d..7ed81085 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -3,8 +3,6 @@ name: Release gate on: pull_request: types: [opened, synchronize, reopened] - paths: - - "**/version.py" pull_request_review: types: [submitted, dismissed] @@ -14,24 +12,28 @@ permissions: jobs: release-gate: name: Release gate - if: startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest steps: - - name: Verify PR was created by GitHub Actions + - 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 - - name: Require at least 2 approvals - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - approvals=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews \ - --jq '[.[] | select(.state == "APPROVED") | .user.login] | unique | length') + 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)" From 277af7e1a9c33afb87eb1ffef1d4253cd5cdf5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 13:30:19 -0700 Subject: [PATCH 5/6] ci: fix script injection in build-docs workflow --- .github/workflows/build-docs.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) 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 From 1c8692e78e8bda52c1d4d5d28ea00dc654ac8baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 8 Apr 2026 13:34:08 -0700 Subject: [PATCH 6/6] ci: tag the actual merge commit, not the test merge ref --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cb06aa4c..4c5744b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -196,6 +196,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Create git tag env: