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. diff --git a/.github/changesets.py b/.github/changesets.py new file mode 100644 index 00000000..5b6baa51 --- /dev/null +++ b/.github/changesets.py @@ -0,0 +1,230 @@ +"""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 subprocess + +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 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})") + 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") + + +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", + 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.", +) +@click.option( + "--repo", + envvar="GITHUB_REPOSITORY", + default=None, + help="owner/name used to look up the PR and author of each changeset (needs gh).", +) +@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 and not check: + summary += changeset_origin(path, repo) + for package, kind in bumps.items(): + kinds.setdefault(package, set()).add(kind) + if 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: + 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(): + 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] = [] + 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/changeset-reminder.yml b/.github/workflows/changeset-reminder.yml new file mode 100644 index 00000000..508fea72 --- /dev/null +++ b/.github/workflows/changeset-reminder.yml @@ -0,0 +1,81 @@ +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, 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 + + 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 new file mode 100644 index 00000000..b7eb99fa --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,49 @@ +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 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] + +permissions: + contents: read + +jobs: + validate: + name: Validate changesets + # renovate/* PRs are dependency/security updates and never carry changesets + 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 + # 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" diff --git a/.github/workflows/publish.yml b/.github/workflows/package-release.yml similarity index 50% rename from .github/workflows/publish.yml rename to .github/workflows/package-release.yml index 872fc06e..a7b150a9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/package-release.yml @@ -1,32 +1,17 @@ -name: Publish to PyPI +name: Package Release on: - # Step 1: Dispatch creates a version bump PR + # 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: - 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 +26,22 @@ 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 == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + concurrency: + group: package-release-${{ inputs.branch || github.ref_name }} + cancel-in-progress: true + env: + BASE_BRANCH: ${{ inputs.branch || github.ref_name }} 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 }} + # 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 @@ -73,133 +51,180 @@ 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 }} - 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 + - name: Apply changesets + id: changesets 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 // empty') + 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 + # 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: - 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 + name: Tag & create GitHub releases 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 + + - 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 } + # 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" + fi + + args=() + 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" \ + "${args[@]}" + 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 +264,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: @@ -261,11 +286,12 @@ jobs: publish-rtc: name: Publish livekit (RTC) - needs: [detect, build-rtc, build-rtc-sdist] - if: needs.detect.outputs.package == 'livekit' + 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 @@ -281,11 +307,22 @@ 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 needs: detect - if: needs.detect.outputs.package == 'livekit-api' + if: needs.detect.outputs.api == 'true' runs-on: ubuntu-latest defaults: run: @@ -311,11 +348,12 @@ jobs: publish-api: name: Publish livekit-api - needs: [detect, build-api] - if: needs.detect.outputs.package == 'livekit-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 @@ -330,11 +368,22 @@ 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 needs: detect - if: needs.detect.outputs.package == 'livekit-protocol' + if: needs.detect.outputs.protocol == 'true' runs-on: ubuntu-latest defaults: run: @@ -360,11 +409,12 @@ jobs: publish-protocol: name: Publish livekit-protocol - needs: [detect, build-protocol] - if: needs.detect.outputs.package == 'livekit-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 @@ -379,11 +429,22 @@ 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 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 +454,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 +464,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