From c3a6ebe5a39ea84d880c4ee0eac4dc5db136dcb9 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:17:10 +0800 Subject: [PATCH 01/16] chore: Add .changeset support for package-release. --- .changeset/add-changeset.md | 7 + .github/changesets.py | 173 +++++++++++++ .../{publish.yml => package-release.yml} | 232 ++++++++---------- 3 files changed, 280 insertions(+), 132 deletions(-) create mode 100644 .changeset/add-changeset.md create mode 100644 .github/changesets.py rename .github/workflows/{publish.yml => package-release.yml} (59%) diff --git a/.changeset/add-changeset.md b/.changeset/add-changeset.md new file mode 100644 index 00000000..0a18103f --- /dev/null +++ b/.changeset/add-changeset.md @@ -0,0 +1,7 @@ +--- +"livekit-api": patch +"livekit-protocol": patch +"livekit-rtc": patch +--- + +Add .changeset support for python-sdks diff --git a/.github/changesets.py b/.github/changesets.py new file mode 100644 index 00000000..f0516e02 --- /dev/null +++ b/.github/changesets.py @@ -0,0 +1,173 @@ +"""Consume pending .changeset/*.md files and bump package versions. + +A changeset looks like: + + --- + "livekit-rtc": patch + "livekit-api": minor-rc + --- + + Summary of the change. + +Supported bump types: patch, minor, major, patch-rc, minor-rc, major-rc, +next-rc, promote. When several changesets touch the same package, the +largest bump wins (an explicit X-rc wins over next-rc). Mixing stable and +rc bumps, or combining promote with anything else, is an error. +""" + +from __future__ import annotations + +import os +import pathlib +import re + +import click +from packaging.version import Version +from update_versions import PACKAGES, do_bump, do_prerelease, read_version + +CHANGESET_DIR = pathlib.Path(".changeset") + +# Names accepted in changeset frontmatter -> key in PACKAGES +PACKAGE_ALIASES = { + "livekit": "livekit", + "livekit-rtc": "livekit", + "livekit-api": "livekit-api", + "livekit-protocol": "livekit-protocol", +} + +STABLE = {"patch": 1, "minor": 2, "major": 3} +RC = {"patch-rc": 1, "minor-rc": 2, "major-rc": 3} +BUMP_TYPES = set(STABLE) | set(RC) | {"next-rc", "promote"} + + +def parse_changeset(path: pathlib.Path) -> tuple[dict[str, str], str]: + m = re.match(r"^---\s*\n(.*?)\n?---\s*\n?(.*)$", path.read_text(), re.DOTALL) + if not m: + raise click.ClickException(f"{path}: missing '---' frontmatter") + + bumps: dict[str, str] = {} + for line in m.group(1).splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + lm = re.fullmatch(r'[\'"]?([\w.\-]+)[\'"]?\s*:\s*[\'"]?([\w\-]+)[\'"]?', line) + if not lm: + raise click.ClickException(f"{path}: cannot parse line {line!r}") + name, kind = lm.groups() + if name not in PACKAGE_ALIASES: + raise click.ClickException( + f"{path}: unknown package {name!r} (expected one of {sorted(PACKAGE_ALIASES)})" + ) + if kind not in BUMP_TYPES: + raise click.ClickException( + f"{path}: unknown bump type {kind!r} (expected one of {sorted(BUMP_TYPES)})" + ) + bumps[PACKAGE_ALIASES[name]] = kind + return bumps, m.group(2).strip() + + +def resolve(package: str, kinds: set[str]) -> str: + if "promote" in kinds: + if len(kinds) > 1: + raise click.ClickException( + f"{package}: 'promote' cannot be combined with {sorted(kinds - {'promote'})}" + ) + return "promote" + + stable = kinds & STABLE.keys() + if stable and kinds - stable: + raise click.ClickException(f"{package}: cannot mix stable and rc bumps ({sorted(kinds)})") + if stable: + return max(stable, key=STABLE.__getitem__) + + explicit_rc = kinds & RC.keys() + if explicit_rc: + return max(explicit_rc, key=RC.__getitem__) + return "next-rc" + + +def check(package: str, kind: str) -> None: + cur = Version(read_version(pathlib.Path(PACKAGES[package]))) + if kind == "next-rc" and not (cur.pre and cur.pre[0] == "rc"): + raise click.ClickException(f"{package}: next-rc requires an rc version (got {cur})") + if kind == "promote" and not cur.is_prerelease: + raise click.ClickException(f"{package}: promote requires a pre-release (got {cur})") + + +def apply(package: str, kind: str) -> None: + if kind in STABLE: + do_bump(package, kind) + elif kind in RC: + do_bump(package, kind.removesuffix("-rc")) + do_prerelease(package, "rc") + elif kind == "next-rc": + do_prerelease(package, "rc") + elif kind == "promote": + do_bump(package, "release") + + +@click.command() +@click.option( + "--rc-only", + is_flag=True, + default=False, + help="Reject stable bumps (used for non-main branches).", +) +@click.option( + "--notes", + type=click.Path(dir_okay=False, path_type=pathlib.Path), + default=None, + help="Write release notes (markdown) to this file.", +) +def main(rc_only: bool, notes: pathlib.Path | None) -> None: + files = sorted(p for p in CHANGESET_DIR.glob("*.md") if p.name.lower() != "readme.md") + + kinds: dict[str, set[str]] = {} + summaries: dict[str, list[str]] = {} + for path in files: + bumps, summary = parse_changeset(path) + for package, kind in bumps.items(): + kinds.setdefault(package, set()).add(kind) + if summary: + summaries.setdefault(package, []).append(summary) + + resolved = {package: resolve(package, k) for package, k in kinds.items()} + if rc_only: + stable = {p: k for p, k in resolved.items() if k in STABLE or k == "promote"} + if stable: + raise click.ClickException( + f"only rc releases are allowed from non-main branches (got {stable})" + ) + # Validate everything before touching any version file. + for package, kind in resolved.items(): + check(package, kind) + + lines: list[str] = [] + released: list[str] = [] + for package in PACKAGES: # stable order + if package not in resolved: + continue + apply(package, resolved[package]) + version = read_version(pathlib.Path(PACKAGES[package])) + released.append(f"{package} v{version}") + lines.append(f"## {package} v{version} ({resolved[package]})\n") + for summary in summaries.get(package, []): + lines.append("- " + summary.replace("\n", "\n ")) + lines.append("") + + # Changesets are consumed once applied (including ones listing no package). + for path in files: + path.unlink() + + if notes is not None: + notes.write_text("\n".join(lines)) + + title = ", ".join(released) + print(f"Release: {title or '(nothing to release)'}") + if gh_output := os.environ.get("GITHUB_OUTPUT"): + with open(gh_output, "a") as f: + f.write(f"title={title}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/package-release.yml similarity index 59% rename from .github/workflows/publish.yml rename to .github/workflows/package-release.yml index 872fc06e..2a956afd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/package-release.yml @@ -1,32 +1,12 @@ -name: Publish to PyPI +name: Package Release on: - # Step 1: Dispatch creates a version bump PR + # Step 1: A merged PR (or a manual run) consumes pending .changeset files and + # opens/updates the release PR with the version bumps. 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)" + description: "Branch to release from (default: main)" type: string required: false default: "main" @@ -41,29 +21,23 @@ permissions: id-token: write jobs: - # ── Step 1: Create a version bump PR ────────────────────────── + # ── Step 1: Create / update the release PR from changesets ──── bump: name: Create release PR - if: github.event_name == 'workflow_dispatch' + if: | + github.event_name == 'workflow_dispatch' + || (github.event.pull_request.merged == true + && !startsWith(github.event.pull_request.head.ref, 'release/')) runs-on: ubuntu-latest + concurrency: + group: package-release-${{ inputs.branch || github.event.pull_request.base.ref }} + cancel-in-progress: true + env: + BASE_BRANCH: ${{ inputs.branch || github.event.pull_request.base.ref }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 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 + ref: ${{ env.BASE_BRANCH }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 @@ -73,133 +47,127 @@ jobs: - 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 --package "$pkg" --bump-type "$key" - ;; - patch-rc|minor-rc|major-rc) - bump="${key%-rc}" - python .github/update_versions.py --package "$pkg" --bump-type "$bump" - python .github/update_versions.py --package "$pkg" --pre rc - ;; - next-rc) - python .github/update_versions.py --package "$pkg" --pre rc - ;; - promote) - python .github/update_versions.py --package "$pkg" --bump-type release - ;; - esac - - - name: Read new version - id: version - env: - INPUT_PACKAGE: ${{ inputs.package }} + - name: Apply changesets + id: changesets run: | - eval "$(python .github/update_versions.py --package "$INPUT_PACKAGE" --print-version)" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag_prefix=$tag_prefix" >> "$GITHUB_OUTPUT" - echo "Package: $INPUT_PACKAGE, 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 + # Only RC releases are allowed from non-main branches + args=() + if [ "$BASE_BRANCH" != "main" ]; then + args+=(--rc-only) + fi + python .github/changesets.py --notes "$RUNNER_TEMP/release-notes.md" "${args[@]}" - - name: Create release PR + - name: Create or update release PR + if: steps.changesets.outputs.title != '' 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 }} + TITLE: ${{ steps.changesets.outputs.title }} run: | - branch="release/${TAG_PREFIX}-v${VERSION}" + branch="release/changesets-${BASE_BRANCH//\//-}" 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 "Release: ${TITLE}" 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." \ - + { + echo "Merging this PR will publish the following packages to PyPI:" + echo + cat "$RUNNER_TEMP/release-notes.md" + } > "$RUNNER_TEMP/pr-body.md" + + existing=$(gh pr list --state open --head "$branch" --base "$BASE_BRANCH" --json number --jq '.[0].number') + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "Release: ${TITLE}" --body-file "$RUNNER_TEMP/pr-body.md" + else + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$branch" \ + --title "Release: ${TITLE}" \ + --body-file "$RUNNER_TEMP/pr-body.md" + fi # ── Step 2: Publish on merge ────────────────────────────────── detect: - name: Detect package + name: Detect packages 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 }} + rtc: ${{ steps.detect.outputs.rtc }} + api: ${{ steps.detect.outputs.api }} + protocol: ${{ steps.detect.outputs.protocol }} + tags: ${{ steps.detect.outputs.tags }} steps: - - name: Parse release branch + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.10" + + - name: Install dependencies + run: pip install click packaging + + - name: Detect bumped packages id: detect env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} 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 + # A package is released when the release PR changed its version.py + files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename') + + tags=() + for entry in "rtc:livekit:livekit-rtc/livekit/rtc/version.py" \ + "api:livekit-api:livekit-api/livekit/api/version.py" \ + "protocol:livekit-protocol:livekit-protocol/livekit/protocol/version.py"; do + IFS=: read -r key package path <<< "$entry" + if grep -qxF "$path" <<< "$files"; then + eval "$(python .github/update_versions.py --package "$package" --print-version)" + tags+=("${tag_prefix}-v${version}") + echo "$key=true" >> "$GITHUB_OUTPUT" + echo "Releasing $package v$version" + else + echo "$key=false" >> "$GITHUB_OUTPUT" + fi + done - echo "package=$package" >> "$GITHUB_OUTPUT" - echo "tag=$ref" >> "$GITHUB_OUTPUT" - echo "Releasing $package (tag: $ref)" + echo "tags=${tags[*]}" >> "$GITHUB_OUTPUT" tag: name: Tag release needs: detect + if: needs.detect.outputs.tags != '' runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.pull_request.merge_commit_sha }} - - name: Create git tag + - name: Create git tags env: - TAG: ${{ needs.detect.outputs.tag }} + TAGS: ${{ needs.detect.outputs.tags }} 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" + for tag in $TAGS; do + git tag "$tag" + git push origin "$tag" + done # ── RTC builds (multi-platform) ────────────────────────────── build-rtc: name: Build RTC wheels (${{ matrix.archs }}) needs: detect - if: needs.detect.outputs.package == 'livekit' + if: needs.detect.outputs.rtc == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -239,7 +207,7 @@ jobs: build-rtc-sdist: name: Build RTC sdist needs: detect - if: needs.detect.outputs.package == 'livekit' + if: needs.detect.outputs.rtc == 'true' runs-on: ubuntu-latest defaults: run: @@ -262,7 +230,7 @@ jobs: publish-rtc: name: Publish livekit (RTC) needs: [detect, build-rtc, build-rtc-sdist] - if: needs.detect.outputs.package == 'livekit' + if: needs.detect.outputs.rtc == 'true' runs-on: ubuntu-latest environment: pypi permissions: @@ -285,7 +253,7 @@ jobs: build-api: name: Build API needs: detect - if: needs.detect.outputs.package == 'livekit-api' + if: needs.detect.outputs.api == 'true' runs-on: ubuntu-latest defaults: run: @@ -312,7 +280,7 @@ jobs: publish-api: name: Publish livekit-api needs: [detect, build-api] - if: needs.detect.outputs.package == 'livekit-api' + if: needs.detect.outputs.api == 'true' runs-on: ubuntu-latest environment: pypi permissions: @@ -334,7 +302,7 @@ jobs: build-protocol: name: Build Protocol needs: detect - if: needs.detect.outputs.package == 'livekit-protocol' + if: needs.detect.outputs.protocol == 'true' runs-on: ubuntu-latest defaults: run: @@ -361,7 +329,7 @@ jobs: publish-protocol: name: Publish livekit-protocol needs: [detect, build-protocol] - if: needs.detect.outputs.package == 'livekit-protocol' + if: needs.detect.outputs.protocol == 'true' runs-on: ubuntu-latest environment: pypi permissions: @@ -383,7 +351,7 @@ jobs: docs-rtc: name: Build RTC docs needs: [detect, publish-rtc] - if: needs.detect.outputs.package == 'livekit' + if: needs.detect.outputs.rtc == 'true' uses: ./.github/workflows/build-docs.yml with: package_dir: livekit-rtc @@ -393,7 +361,7 @@ jobs: docs-api: name: Build API docs needs: [detect, publish-api] - if: needs.detect.outputs.package == 'livekit-api' + if: needs.detect.outputs.api == 'true' uses: ./.github/workflows/build-docs.yml with: package_dir: livekit-api @@ -403,7 +371,7 @@ jobs: docs-protocol: name: Build Protocol docs needs: [detect, publish-protocol] - if: needs.detect.outputs.package == 'livekit-protocol' + if: needs.detect.outputs.protocol == 'true' uses: ./.github/workflows/build-docs.yml with: package_dir: livekit-protocol From b7c6d1ee87dee0eb0af251aead553debfde49c45 Mon Sep 17 00:00:00 2001 From: CloudWebRTC Date: Mon, 26 Oct 2026 12:22:27 +0800 Subject: [PATCH 02/16] Update .github/workflows/package-release.yml Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/package-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 2a956afd..ef8de10b 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -78,7 +78,7 @@ jobs: cat "$RUNNER_TEMP/release-notes.md" } > "$RUNNER_TEMP/pr-body.md" - existing=$(gh pr list --state open --head "$branch" --base "$BASE_BRANCH" --json number --jq '.[0].number') + existing=$(gh pr list --state open --head "$branch" --base "$BASE_BRANCH" --json number --jq '.[0].number // empty') if [ -n "$existing" ]; then gh pr edit "$existing" --title "Release: ${TITLE}" --body-file "$RUNNER_TEMP/pr-body.md" else From c514606efd561094b0636e9bf00c26f66faca7c1 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:35:25 +0800 Subject: [PATCH 03/16] ci: create GitHub releases from changeset notes After tagging, create a GitHub Release per released package using its section of the release PR body (generated from the consumed changesets). RC versions are marked as pre-releases. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/package-release.yml | 43 ++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index ef8de10b..fcc68aee 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -143,7 +143,7 @@ jobs: echo "tags=${tags[*]}" >> "$GITHUB_OUTPUT" tag: - name: Tag release + name: Tag & create GitHub releases needs: detect if: needs.detect.outputs.tags != '' runs-on: ubuntu-latest @@ -163,6 +163,47 @@ jobs: git push origin "$tag" done + - name: Create GitHub releases + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAGS: ${{ needs.detect.outputs.tags }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + printf '%s\n' "$PR_BODY" > "$RUNNER_TEMP/pr-body.md" + + for tag in $TAGS; do + prefix="${tag%%-v*}" + version="${tag#*-v}" + case "$prefix" in + rtc) package="livekit" ;; + api) package="livekit-api" ;; + protocol) package="livekit-protocol" ;; + esac + + # Take this package's "## v (...)" section from + # the release PR body (written from the consumed changesets) + notes="$RUNNER_TEMP/notes-$tag.md" + awk -v heading="## $package v$version " ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' "$RUNNER_TEMP/pr-body.md" > "$notes" + if ! grep -q '[^[:space:]]' "$notes"; then + echo "Released from #$PR_NUMBER." > "$notes" + fi + + args=() + if [[ "$version" == *rc* ]]; then + args+=(--prerelease) + fi + gh release create "$tag" \ + --verify-tag \ + --title "$package v$version" \ + --notes-file "$notes" \ + "${args[@]}" + done + # ── RTC builds (multi-platform) ────────────────────────────── build-rtc: name: Build RTC wheels (${{ matrix.archs }}) From de45c877313b369f9a1bed5cc3bea8a89df71bbf Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:42:17 +0800 Subject: [PATCH 04/16] ci: credit PR and contributor in changeset release notes Look up the commit that added each changeset and the PR it belongs to, and append "(#123 by @user)" to its release-notes entry (short SHA when there is no PR). The bump job now fetches full history and passes GH_TOKEN so the lookup works in CI. Co-Authored-By: Claude Opus 5.5 --- .github/changesets.py | 44 ++++++++++++++++++++++++++- .github/workflows/package-release.yml | 4 +++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/changesets.py b/.github/changesets.py index f0516e02..2614f92e 100644 --- a/.github/changesets.py +++ b/.github/changesets.py @@ -20,6 +20,7 @@ import os import pathlib import re +import subprocess import click from packaging.version import Version @@ -106,6 +107,39 @@ def apply(package: str, kind: str) -> None: do_bump(package, "release") +def changeset_origin(path: pathlib.Path, repo: str | None) -> str: + """Return " (#123 by @user)" for the PR that added this changeset, or "".""" + if not repo: + return "" + try: + sha = subprocess.run( + ["git", "log", "--diff-filter=A", "--format=%H", "-1", "--", str(path)], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if not sha: + return "" + pr = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/commits/{sha}/pulls", + "--jq", + '.[0] // empty | "\\(.number) \\(.user.login)"', + ], + capture_output=True, + text=True, + check=True, + ).stdout.split() + except (OSError, subprocess.CalledProcessError) as e: + click.echo(f"warning: cannot resolve PR for {path}: {e}", err=True) + return "" + if len(pr) == 2: + return f" (#{pr[0]} by @{pr[1]})" + return f" ({sha[:7]})" + + @click.command() @click.option( "--rc-only", @@ -119,13 +153,21 @@ def apply(package: str, kind: str) -> None: default=None, help="Write release notes (markdown) to this file.", ) -def main(rc_only: bool, notes: pathlib.Path | None) -> None: +@click.option( + "--repo", + envvar="GITHUB_REPOSITORY", + default=None, + help="owner/name used to look up the PR and author of each changeset (needs gh).", +) +def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None) -> None: files = sorted(p for p in CHANGESET_DIR.glob("*.md") if p.name.lower() != "readme.md") kinds: dict[str, set[str]] = {} summaries: dict[str, list[str]] = {} for path in files: bumps, summary = parse_changeset(path) + if summary: + summary += changeset_origin(path, repo) for package, kind in bumps.items(): kinds.setdefault(package, set()).add(kind) if summary: diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index fcc68aee..964f4c94 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -38,6 +38,8 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ env.BASE_BRANCH }} + # Full history to find the commit (and PR) that added each changeset + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 @@ -49,6 +51,8 @@ jobs: - name: Apply changesets id: changesets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Only RC releases are allowed from non-main branches args=() From fcdf8b54c62e808dc7ada1bf8ff27a62e8535eec Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:47:18 +0800 Subject: [PATCH 05/16] ci: keep GitHub releases as drafts until the PyPI upload succeeds Create releases with --draft in the tag job, and make each one public in its package's publish job right after the PyPI upload. A failed build or upload now leaves only a draft, never a public release for a version that is not on PyPI. Publish jobs now depend on tag and get contents: write for gh release edit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/package-release.yml | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 964f4c94..52a20e3d 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -201,7 +201,10 @@ jobs: if [[ "$version" == *rc* ]]; then args+=(--prerelease) fi + # Created as a draft; each publish job makes its release public + # only after the PyPI upload succeeds. gh release create "$tag" \ + --draft \ --verify-tag \ --title "$package v$version" \ --notes-file "$notes" \ @@ -274,11 +277,12 @@ jobs: publish-rtc: name: Publish livekit (RTC) - needs: [detect, build-rtc, build-rtc-sdist] + needs: [detect, build-rtc, build-rtc-sdist, tag] if: needs.detect.outputs.rtc == 'true' runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: - name: Download build artifacts @@ -294,6 +298,17 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAGS: ${{ needs.detect.outputs.tags }} + run: | + for tag in $TAGS; do + if [[ "$tag" == rtc-v* ]]; then + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false + fi + done + # ── API build ──────────────────────────────────────────────── build-api: name: Build API @@ -324,11 +339,12 @@ jobs: publish-api: name: Publish livekit-api - needs: [detect, build-api] + needs: [detect, build-api, tag] if: needs.detect.outputs.api == 'true' runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: - name: Download build artifact @@ -343,6 +359,17 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAGS: ${{ needs.detect.outputs.tags }} + run: | + for tag in $TAGS; do + if [[ "$tag" == api-v* ]]; then + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false + fi + done + # ── Protocol build ─────────────────────────────────────────── build-protocol: name: Build Protocol @@ -373,11 +400,12 @@ jobs: publish-protocol: name: Publish livekit-protocol - needs: [detect, build-protocol] + needs: [detect, build-protocol, tag] if: needs.detect.outputs.protocol == 'true' runs-on: ubuntu-latest environment: pypi permissions: + contents: write id-token: write steps: - name: Download build artifact @@ -392,6 +420,17 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAGS: ${{ needs.detect.outputs.tags }} + run: | + for tag in $TAGS; do + if [[ "$tag" == protocol-v* ]]; then + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false + fi + done + # ── Docs ───────────────────────────────────────────────────── docs-rtc: name: Build RTC docs From 5dacb87056f55a6677345e91e11b959323cb9392 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:51:29 +0800 Subject: [PATCH 06/16] ci: run changeset bump on push to main so fork PRs work pull_request events from forks get a read-only token even after merge, so the bump job could not push the release branch or open the release PR. Trigger it on push to main (plus workflow_dispatch) instead; release PR detection and publishing stay on pull_request: closed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/package-release.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 52a20e3d..de0e4fb6 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -1,8 +1,13 @@ name: Package Release on: - # Step 1: A merged PR (or a manual run) consumes pending .changeset files and - # opens/updates the release PR with the version bumps. + # Step 1: A push to main (e.g. a merged PR, including from forks) or a manual + # run consumes pending .changeset files and opens/updates the release PR with + # the version bumps. This runs on `push` rather than `pull_request` so it gets + # a write-capable token for fork contributions too. + push: + branches: [main] + workflow_dispatch: inputs: branch: @@ -24,16 +29,13 @@ jobs: # ── Step 1: Create / update the release PR from changesets ──── bump: name: Create release PR - if: | - github.event_name == 'workflow_dispatch' - || (github.event.pull_request.merged == true - && !startsWith(github.event.pull_request.head.ref, 'release/')) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest concurrency: - group: package-release-${{ inputs.branch || github.event.pull_request.base.ref }} + group: package-release-${{ inputs.branch || github.ref_name }} cancel-in-progress: true env: - BASE_BRANCH: ${{ inputs.branch || github.event.pull_request.base.ref }} + BASE_BRANCH: ${{ inputs.branch || github.ref_name }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: From a83396e8ceb5b17521c5b804a443180500630a78 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 12:54:32 +0800 Subject: [PATCH 07/16] ci: only publish from release PRs opened by the bump job detect trusted any merged PR whose branch started with release/. Also require a release/changesets-* branch from this repository, opened by github-actions[bot], so a hand-made release PR cannot trigger a publish. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/package-release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index de0e4fb6..a811cd21 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -98,10 +98,15 @@ jobs: # ── Step 2: Publish on merge ────────────────────────────────── detect: name: Detect packages + # Only release PRs opened by the bump job (same repo, github-actions[bot], + # release/changesets-* branch) may trigger publishing; the branch name alone + # is not trusted. if: | github.event_name == 'pull_request' && github.event.pull_request.merged == true - && startsWith(github.event.pull_request.head.ref, 'release/') + && startsWith(github.event.pull_request.head.ref, 'release/changesets-') + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.user.login == 'github-actions[bot]' runs-on: ubuntu-latest outputs: rtc: ${{ steps.detect.outputs.rtc }} From 52964c695d4c92220cf1ca8129692791c6f5c81c Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:00:15 +0800 Subject: [PATCH 08/16] ci: validate changesets on every PR commit Add a Changeset workflow that runs `changesets.py --check` on each PR push. It checks the PR merge result with the same rules the release uses (format, package names, bump types, conflicts, rc-only on non-main branches) without changing any file. Rename check() to validate() to free the name for the new flag. Co-Authored-By: Claude Opus 5.5 --- .github/changesets.py | 22 ++++++++++++++--- .github/workflows/changeset.yml | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/changeset.yml diff --git a/.github/changesets.py b/.github/changesets.py index 2614f92e..09661680 100644 --- a/.github/changesets.py +++ b/.github/changesets.py @@ -87,7 +87,7 @@ def resolve(package: str, kinds: set[str]) -> str: return "next-rc" -def check(package: str, kind: str) -> None: +def validate(package: str, kind: str) -> None: cur = Version(read_version(pathlib.Path(PACKAGES[package]))) if kind == "next-rc" and not (cur.pre and cur.pre[0] == "rc"): raise click.ClickException(f"{package}: next-rc requires an rc version (got {cur})") @@ -159,14 +159,20 @@ def changeset_origin(path: pathlib.Path, repo: str | None) -> str: default=None, help="owner/name used to look up the PR and author of each changeset (needs gh).", ) -def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None) -> None: +@click.option( + "--check", + is_flag=True, + default=False, + help="Only validate the pending changesets; don't change any file.", +) +def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None, check: bool) -> None: files = sorted(p for p in CHANGESET_DIR.glob("*.md") if p.name.lower() != "readme.md") kinds: dict[str, set[str]] = {} summaries: dict[str, list[str]] = {} for path in files: bumps, summary = parse_changeset(path) - if summary: + if summary and not check: summary += changeset_origin(path, repo) for package, kind in bumps.items(): kinds.setdefault(package, set()).add(kind) @@ -182,7 +188,15 @@ def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None) -> None: ) # Validate everything before touching any version file. for package, kind in resolved.items(): - check(package, kind) + validate(package, kind) + + if check: + click.echo(f"{len(files)} changeset(s) valid.") + for package in PACKAGES: + if package in resolved: + cur = read_version(pathlib.Path(PACKAGES[package])) + click.echo(f" {package}: {cur} ({resolved[package]})") + return lines: list[str] = [] released: list[str] = [] diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml new file mode 100644 index 00000000..02083db3 --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,44 @@ +name: Changeset + +# Validate .changeset files on every commit of every PR, with the same rules +# the Package Release workflow applies when it bumps versions. +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + validate: + name: Validate changesets + runs-on: ubuntu-latest + steps: + # Default ref is the PR merge commit, i.e. the base branch's versions plus + # every changeset that would be pending once this PR merges. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.10" + + - name: Install dependencies + run: pip install click packaging + + - name: Validate changesets + env: + BASE_BRANCH: ${{ github.base_ref }} + run: | + # Only RC releases are allowed from non-main branches + args=() + if [ "$BASE_BRANCH" != "main" ]; then + args+=(--rc-only) + fi + + if ! out=$(python .github/changesets.py --check "${args[@]}" 2>&1); then + echo "$out" + echo "::error title=Invalid changeset::${out#Error: }" + exit 1 + fi + echo "$out" From 18bee1894faae79cfb557081028245fac908c499 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:06:05 +0800 Subject: [PATCH 09/16] ci: remind PR authors to add a changeset Add a remind job (pull_request_target, no checkout of PR code) that comments once with a short format guide when a PR has no .changeset file, and deletes that comment once a changeset is added. Bot PRs and release PRs are skipped. The validate job now runs on pull_request only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/changeset.yml | 65 ++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 02083db3..a4410363 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -1,10 +1,15 @@ name: Changeset # Validate .changeset files on every commit of every PR, with the same rules -# the Package Release workflow applies when it bumps versions. +# the Package Release workflow applies when it bumps versions, and remind the +# author once when a PR has no changeset. on: pull_request: types: [opened, synchronize, reopened] + # Needed for a write token on fork PRs. The remind job never checks out or + # runs PR code; it only reads the PR's file list through the API. + pull_request_target: + types: [opened, synchronize, reopened] permissions: contents: read @@ -12,6 +17,7 @@ permissions: jobs: validate: name: Validate changesets + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: # Default ref is the PR merge commit, i.e. the base branch's versions plus @@ -42,3 +48,60 @@ jobs: exit 1 fi echo "$out" + + remind: + name: Changeset reminder + if: | + github.event_name == 'pull_request_target' + && !startsWith(github.event.pull_request.head.ref, 'release/changesets-') + && !endsWith(github.event.pull_request.user.login, '[bot]') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment when the PR has no changeset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + marker="" + + changesets=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ + --jq '.[] | select(.status != "removed") | .filename' \ + | grep -E '^\.changeset/[^/]+\.md$' | grep -viE '/readme\.md$' || true) + existing=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id") + + if [ -n "$changesets" ]; then + echo "Changeset found:"; echo "$changesets" + # Drop the reminder once a changeset has been added + for id in $existing; do + gh api -X DELETE "repos/$REPO/issues/comments/$id" + done + exit 0 + fi + + if [ -n "$existing" ]; then + echo "Reminder already posted" + exit 0 + fi + + cat > "$RUNNER_TEMP/reminder.md" <<'EOF' + + 👋 This PR has no changeset. If it should be released, add a markdown file under `.changeset/` (any name, e.g. `.changeset/fix-token-parsing.md`): + + ```md + --- + "livekit-rtc": patch + --- + + Short description of the change, used in the release notes. + ``` + + - **Packages:** `livekit-rtc`, `livekit-api`, `livekit-protocol` (one line per package) + - **Types:** `patch`, `minor`, `major`, `patch-rc`, `minor-rc`, `major-rc`, `next-rc`, `promote` + + If this change doesn't need a release (docs, CI, tests), you can ignore this comment. + EOF + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$RUNNER_TEMP/reminder.md" From f676623f802675ad0762f90836f5030861832baa Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:17:04 +0800 Subject: [PATCH 10/16] ci: skip changeset checks for renovate and dependency-only PRs - Skip both the `validate` and `remind` jobs for PRs from `renovate/*` branches (dependency and security updates) - Don't post the missing-changeset reminder when a PR only changes dependency files: `*.lock` (e.g. `uv.lock`, `Cargo.lock`), `package.json` or `pnpm-lock.yaml`, in any directory Co-Authored-By: Claude Opus 5.5 --- .github/workflows/changeset.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index a4410363..f010e366 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -17,7 +17,10 @@ permissions: jobs: validate: name: Validate changesets - if: github.event_name == 'pull_request' + # renovate/* PRs are dependency/security updates and never carry changesets + if: | + github.event_name == 'pull_request' + && !startsWith(github.event.pull_request.head.ref, 'renovate/') runs-on: ubuntu-latest steps: # Default ref is the PR merge commit, i.e. the base branch's versions plus @@ -54,6 +57,7 @@ jobs: if: | github.event_name == 'pull_request_target' && !startsWith(github.event.pull_request.head.ref, 'release/changesets-') + && !startsWith(github.event.pull_request.head.ref, 'renovate/') && !endsWith(github.event.pull_request.user.login, '[bot]') runs-on: ubuntu-latest permissions: @@ -67,8 +71,16 @@ jobs: run: | marker="" - changesets=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ - --jq '.[] | select(.status != "removed") | .filename' \ + files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ + --jq '.[] | "\(.status) \(.filename)"') + + # Dependency-only updates (lockfiles / package.json) don't need a changeset + if [ -z "$(grep -vE '^[a-z]+ (.*/)?([^/]+\.lock|package\.json|pnpm-lock\.yaml)$' <<< "$files")" ]; then + echo "Only dependency files changed, skipping" + exit 0 + fi + + changesets=$(grep -v '^removed ' <<< "$files" | cut -d' ' -f2- \ | grep -E '^\.changeset/[^/]+\.md$' | grep -viE '/readme\.md$' || true) existing=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id") From 1431e29fe5facb9e5373dbb6a0d0bab23411dd3f Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:48:32 +0800 Subject: [PATCH 11/16] ci: move the changeset reminder into its own workflow With validate and remind in one workflow triggered by both pull_request and pull_request_target, every PR got a second, skipped "Validate changesets" check, which could satisfy a required status check even when validation failed. The reminder now lives in changeset-reminder.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/changeset-reminder.yml | 78 +++++++++++++++++++++++ .github/workflows/changeset.yml | 80 ++---------------------- 2 files changed, 83 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/changeset-reminder.yml diff --git a/.github/workflows/changeset-reminder.yml b/.github/workflows/changeset-reminder.yml new file mode 100644 index 00000000..8bd3b7f5 --- /dev/null +++ b/.github/workflows/changeset-reminder.yml @@ -0,0 +1,78 @@ +name: Changeset Reminder + +# Comment once with a short format guide when a PR has no .changeset file, and +# delete that comment once one is added. +on: + # Needed for a write token on fork PRs. This workflow never checks out or + # runs PR code; it only reads the PR's file list through the API. + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + remind: + name: Changeset reminder + if: | + !startsWith(github.event.pull_request.head.ref, 'release/changesets-') + && !startsWith(github.event.pull_request.head.ref, 'renovate/') + && !endsWith(github.event.pull_request.user.login, '[bot]') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment when the PR has no changeset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + marker="" + + files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ + --jq '.[] | "\(.status) \(.filename)"') + + # Dependency-only updates (lockfiles / package.json) don't need a changeset + if [ -z "$(grep -vE '^[a-z]+ (.*/)?([^/]+\.lock|package\.json|pnpm-lock\.yaml)$' <<< "$files")" ]; then + echo "Only dependency files changed, skipping" + exit 0 + fi + + changesets=$(grep -v '^removed ' <<< "$files" | cut -d' ' -f2- \ + | grep -E '^\.changeset/[^/]+\.md$' | grep -viE '/readme\.md$' || true) + existing=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id") + + if [ -n "$changesets" ]; then + echo "Changeset found:"; echo "$changesets" + # Drop the reminder once a changeset has been added + for id in $existing; do + gh api -X DELETE "repos/$REPO/issues/comments/$id" + done + exit 0 + fi + + if [ -n "$existing" ]; then + echo "Reminder already posted" + exit 0 + fi + + cat > "$RUNNER_TEMP/reminder.md" <<'EOF' + + 👋 This PR has no changeset. If it should be released, add a markdown file under `.changeset/` (any name, e.g. `.changeset/fix-token-parsing.md`): + + ```md + --- + "livekit-rtc": patch + --- + + Short description of the change, used in the release notes. + ``` + + - **Packages:** `livekit-rtc`, `livekit-api`, `livekit-protocol` (one line per package) + - **Types:** `patch`, `minor`, `major`, `patch-rc`, `minor-rc`, `major-rc`, `next-rc`, `promote` + + If this change doesn't need a release (docs, CI, tests), you can ignore this comment. + EOF + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$RUNNER_TEMP/reminder.md" diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index f010e366..b7eb99fa 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -1,15 +1,13 @@ name: Changeset # Validate .changeset files on every commit of every PR, with the same rules -# the Package Release workflow applies when it bumps versions, and remind the -# author once when a PR has no changeset. +# the Package Release workflow applies when it bumps versions. The reminder for +# PRs without a changeset lives in changeset-reminder.yml; keeping it separate +# means this workflow never reports a skipped "Validate changesets" check that +# could satisfy a required status check. on: pull_request: types: [opened, synchronize, reopened] - # Needed for a write token on fork PRs. The remind job never checks out or - # runs PR code; it only reads the PR's file list through the API. - pull_request_target: - types: [opened, synchronize, reopened] permissions: contents: read @@ -18,9 +16,7 @@ jobs: validate: name: Validate changesets # renovate/* PRs are dependency/security updates and never carry changesets - if: | - github.event_name == 'pull_request' - && !startsWith(github.event.pull_request.head.ref, 'renovate/') + if: "!startsWith(github.event.pull_request.head.ref, 'renovate/')" runs-on: ubuntu-latest steps: # Default ref is the PR merge commit, i.e. the base branch's versions plus @@ -51,69 +47,3 @@ jobs: exit 1 fi echo "$out" - - remind: - name: Changeset reminder - if: | - github.event_name == 'pull_request_target' - && !startsWith(github.event.pull_request.head.ref, 'release/changesets-') - && !startsWith(github.event.pull_request.head.ref, 'renovate/') - && !endsWith(github.event.pull_request.user.login, '[bot]') - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Comment when the PR has no changeset - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: | - marker="" - - files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ - --jq '.[] | "\(.status) \(.filename)"') - - # Dependency-only updates (lockfiles / package.json) don't need a changeset - if [ -z "$(grep -vE '^[a-z]+ (.*/)?([^/]+\.lock|package\.json|pnpm-lock\.yaml)$' <<< "$files")" ]; then - echo "Only dependency files changed, skipping" - exit 0 - fi - - changesets=$(grep -v '^removed ' <<< "$files" | cut -d' ' -f2- \ - | grep -E '^\.changeset/[^/]+\.md$' | grep -viE '/readme\.md$' || true) - existing=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ - --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id") - - if [ -n "$changesets" ]; then - echo "Changeset found:"; echo "$changesets" - # Drop the reminder once a changeset has been added - for id in $existing; do - gh api -X DELETE "repos/$REPO/issues/comments/$id" - done - exit 0 - fi - - if [ -n "$existing" ]; then - echo "Reminder already posted" - exit 0 - fi - - cat > "$RUNNER_TEMP/reminder.md" <<'EOF' - - 👋 This PR has no changeset. If it should be released, add a markdown file under `.changeset/` (any name, e.g. `.changeset/fix-token-parsing.md`): - - ```md - --- - "livekit-rtc": patch - --- - - Short description of the change, used in the release notes. - ``` - - - **Packages:** `livekit-rtc`, `livekit-api`, `livekit-protocol` (one line per package) - - **Types:** `patch`, `minor`, `major`, `patch-rc`, `minor-rc`, `major-rc`, `next-rc`, `promote` - - If this change doesn't need a release (docs, CI, tests), you can ignore this comment. - EOF - gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$RUNNER_TEMP/reminder.md" From 392fe52bfff6681b7a058ba1d0f4f38563ce3aa5 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:48:46 +0800 Subject: [PATCH 12/16] ci: treat the root pyproject.toml as a dependency-only file The root pyproject.toml is the unpublished workspace config (dev tooling and security constraint floors), so a PR touching only it and uv.lock needs no changeset. Package pyproject.toml files still get the reminder since they declare published runtime dependencies. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/changeset-reminder.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changeset-reminder.yml b/.github/workflows/changeset-reminder.yml index 8bd3b7f5..508fea72 100644 --- a/.github/workflows/changeset-reminder.yml +++ b/.github/workflows/changeset-reminder.yml @@ -33,8 +33,11 @@ jobs: files=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" \ --jq '.[] | "\(.status) \(.filename)"') - # Dependency-only updates (lockfiles / package.json) don't need a changeset - if [ -z "$(grep -vE '^[a-z]+ (.*/)?([^/]+\.lock|package\.json|pnpm-lock\.yaml)$' <<< "$files")" ]; then + # Dependency-only updates (lockfiles / package.json, and the root + # pyproject.toml, which is the unpublished workspace config) don't need + # a changeset. Package pyproject.toml files declare published runtime + # dependencies, so changing them still gets the reminder. + if [ -z "$(grep -vE '^[a-z]+ ((.*/)?([^/]+\.lock|package\.json|pnpm-lock\.yaml)|pyproject\.toml)$' <<< "$files")" ]; then echo "Only dependency files changed, skipping" exit 0 fi From ed9e38d9e97d11f9930ba84afefd68dc9525d578 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:49:02 +0800 Subject: [PATCH 13/16] docs: add .changeset/README.md Documents the changeset format and release flow, and keeps the .changeset/ directory in git once every pending changeset has been consumed by a release (the scripts and workflows ignore README.md). Co-Authored-By: Claude Opus 5.5 --- .changeset/README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .changeset/README.md diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 00000000..3ce1a9cc --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,45 @@ +# Changesets + +Add a changeset to any PR that should be released. Create a markdown file in +this directory (any name, e.g. `fix-token-parsing.md`): + +```md +--- +"livekit-api": patch +--- + +Short description of the change, used in the release notes. +``` + +List one line per package the change should release: + +- **Packages:** `livekit-rtc`, `livekit-api`, `livekit-protocol` +- **Types:** + + | Type | Example | + |---|---| + | `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` | 1.6.0.rc1 → 1.6.0.rc2 | + | `promote` | 1.6.0.rc2 → 1.6.0 | + +When several pending changesets touch the same package, the largest bump wins. +Mixing stable and rc types for one package, or combining `promote` with +anything else, is rejected. Only rc types are allowed on branches other than +`main`. + +## How releases happen + +1. Every PR is checked by the **Changeset** workflow; invalid changesets fail + the check. +2. On each push to `main`, **Package Release** applies all pending changesets + and opens (or updates) the `Release: ...` PR with the version bumps and + release notes, then deletes the changesets it used. +3. Merging that release PR tags each bumped package, publishes it to PyPI and + creates its GitHub Release. + +Changes that don't need a release (docs, CI, tests) don't need a changeset. From 6769d4ca31a72975709a4a61bd986c9f5365a2fb Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:49:21 +0800 Subject: [PATCH 14/16] ci: trim blank lines around GitHub release notes Co-Authored-By: Claude Opus 5.5 --- .github/workflows/package-release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index a811cd21..a7b150a9 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -198,7 +198,9 @@ jobs: awk -v heading="## $package v$version " ' index($0, heading) == 1 { found = 1; next } found && /^## / { exit } - found { print } + # Buffer blank lines so leading/trailing ones are dropped + found && /^[[:space:]]*$/ { if (started) blank = blank "\n"; next } + found { printf "%s%s\n", blank, $0; blank = ""; started = 1 } ' "$RUNNER_TEMP/pr-body.md" > "$notes" if ! grep -q '[^[:space:]]' "$notes"; then echo "Released from #$PR_NUMBER." > "$notes" From 8e83239d35a346966954ae6e8848218d5f5e8584 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 13:51:38 +0800 Subject: [PATCH 15/16] chore: drop the placeholder changeset This PR only changes CI and release tooling, so it shouldn't release any package. .changeset/README.md now keeps the directory in git. Co-Authored-By: Claude Opus 5.5 --- .changeset/add-changeset.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/add-changeset.md diff --git a/.changeset/add-changeset.md b/.changeset/add-changeset.md deleted file mode 100644 index 0a18103f..00000000 --- a/.changeset/add-changeset.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"livekit-api": patch -"livekit-protocol": patch -"livekit-rtc": patch ---- - -Add .changeset support for python-sdks From 9aa7d98fd61b2cace561bbfc5c01b8392d027488 Mon Sep 17 00:00:00 2001 From: cloudwebrtc Date: Mon, 26 Oct 2026 14:06:19 +0800 Subject: [PATCH 16/16] ci: show each changeset's bump type in release notes Prefix every release-notes entry with the bump type its changeset asked for, e.g. "- `minor` Add a shared room helper (#15 by @user)". The type is per package, since one changeset can bump packages differently. Co-Authored-By: Claude Opus 5.5 --- .github/changesets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/changesets.py b/.github/changesets.py index 09661680..5b6baa51 100644 --- a/.github/changesets.py +++ b/.github/changesets.py @@ -177,7 +177,8 @@ def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None, check: boo for package, kind in bumps.items(): kinds.setdefault(package, set()).add(kind) if summary: - summaries.setdefault(package, []).append(summary) + # Each entry shows the bump type its changeset asked for. + summaries.setdefault(package, []).append(f"`{kind}` {summary}") resolved = {package: resolve(package, k) for package, k in kinds.items()} if rc_only: