Repository navigation
ci: changeset-driven Package Release workflow #901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cloudwebrtc
wants to merge
18
commits into
main
Choose a base branch
from
chore/add-changeset-support-for-merge-pr
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
c3a6ebe
chore: Add .changeset support for package-release.
cloudwebrtc b7c6d1e
Update .github/workflows/package-release.yml
cloudwebrtc c514606
ci: create GitHub releases from changeset notes
cloudwebrtc de45c87
ci: credit PR and contributor in changeset release notes
cloudwebrtc fcdf8b5
ci: keep GitHub releases as drafts until the PyPI upload succeeds
cloudwebrtc 5dacb87
ci: run changeset bump on push to main so fork PRs work
cloudwebrtc a83396e
ci: only publish from release PRs opened by the bump job
cloudwebrtc 52964c6
ci: validate changesets on every PR commit
cloudwebrtc 18bee18
ci: remind PR authors to add a changeset
cloudwebrtc f676623
ci: skip changeset checks for renovate and dependency-only PRs
cloudwebrtc 1431e29
ci: move the changeset reminder into its own workflow
cloudwebrtc 392fe52
ci: treat the root pyproject.toml as a dependency-only file
cloudwebrtc ed9e38d
docs: add .changeset/README.md
cloudwebrtc 6769d4c
ci: trim blank lines around GitHub release notes
cloudwebrtc 8e83239
chore: drop the placeholder changeset
cloudwebrtc 9aa7d98
ci: show each changeset's bump type in release notes
cloudwebrtc 374376c
ci: keep rc changesets until promote and list them in its release notes
cloudwebrtc 0e5b847
ci: restore manual version bump as manual-version-bump.yml
cloudwebrtc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # 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. Changesets used by an | ||
| rc release are kept instead (and recorded in `.changeset/pre.json`) until | ||
| that package's next stable release, normally `promote`, whose release notes | ||
| then list every change since the last stable version. | ||
| 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. | ||
|
|
||
| ## Manual version bump | ||
|
|
||
| As a fallback, run the **Manual Version Bump** workflow with a package and | ||
| bump type. It opens a `release/<prefix>-v<version>` PR without reading any | ||
| changeset; merging it publishes the same way as a changeset release PR. | ||
| It doesn't touch `.changeset/`, so changesets kept from rc releases stay until | ||
| that package's next changeset stable release. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| """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. | ||
|
|
||
| Changesets used by an rc release are kept and recorded in .changeset/pre.json | ||
| (package -> file names) instead of being deleted, so later runs skip them. The | ||
| package's next stable release (normally promote) lists them in its release | ||
| notes and deletes them. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| 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") | ||
| # Changesets already released in an rc, kept until the package's stable release | ||
| PRE_STATE = CHANGESET_DIR / "pre.json" | ||
|
|
||
| # 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]})" | ||
|
|
||
|
|
||
| def kept_entries(names: list[str], package: str, repo: str | None) -> list[str]: | ||
| """Release-note entries for this package from changesets kept since its rc releases.""" | ||
| entries = [] | ||
| for name in names: | ||
| path = CHANGESET_DIR / name | ||
| if not path.exists(): | ||
| click.echo(f"warning: kept changeset {path} is missing", err=True) | ||
| continue | ||
| bumps, summary = parse_changeset(path) | ||
| if summary and package in bumps: | ||
| entries.append(f"`{bumps[package]}` {summary}{changeset_origin(path, repo)}") | ||
| return entries | ||
|
|
||
|
|
||
| @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: | ||
| pre: dict[str, list[str]] = json.loads(PRE_STATE.read_text()) if PRE_STATE.exists() else {} | ||
| kept = {name for names in pre.values() for name in names} | ||
| files = sorted( | ||
| p | ||
| for p in CHANGESET_DIR.glob("*.md") | ||
| if p.name.lower() != "readme.md" and p.name not in kept | ||
| ) | ||
|
|
||
| kinds: dict[str, set[str]] = {} | ||
| summaries: dict[str, list[str]] = {} | ||
| packages_of: dict[str, set[str]] = {} | ||
| for path in files: | ||
| bumps, summary = parse_changeset(path) | ||
| packages_of[path.name] = set(bumps) | ||
| 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 ({len(kept)} kept from rc releases).") | ||
| 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 | ||
| kind = resolved[package] | ||
| apply(package, kind) | ||
| version = read_version(pathlib.Path(PACKAGES[package])) | ||
| released.append(f"{package} v{version}") | ||
| lines.append(f"## {package} v{version} ({kind})\n") | ||
| entries = summaries.get(package, []) | ||
| if kind in RC or kind == "next-rc": | ||
| # Keep this rc's changesets for the stable release notes. | ||
| pre.setdefault(package, []).extend( | ||
| p.name for p in files if package in packages_of[p.name] | ||
| ) | ||
| else: | ||
| # A stable release (normally promote) also lists the changesets | ||
| # kept since this package's rc releases. | ||
| entries = kept_entries(pre.pop(package, []), package, repo) + entries | ||
| for summary in entries: | ||
| lines.append("- " + summary.replace("\n", "\n ")) | ||
| lines.append("") | ||
|
|
||
| # Changesets are consumed once applied (including ones listing no package), | ||
| # except those still kept for a package's stable release. | ||
| still_kept = {name for names in pre.values() for name in names} | ||
| for name in {p.name for p in files} | kept: | ||
| if name not in still_kept: | ||
| (CHANGESET_DIR / name).unlink(missing_ok=True) | ||
| if pre: | ||
| PRE_STATE.write_text(json.dumps(pre, indent=2, sort_keys=True) + "\n") | ||
| else: | ||
| PRE_STATE.unlink(missing_ok=True) | ||
|
|
||
| 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() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Deleted RC changesets lose stable release notes
If a retained RC changeset is deleted before promotion,
kept_entriesdrops its summary.--checkskips retained files, so validation passes and the stable release permanently omits that change.Learn more
A release candidate retains its changeset file and records its name in
pre.jsonfor later stable release notes. The pending-file filter skips those names during--check, even if a contributor deleted the file. Promotion callskept_entries, which merely warns on a missing file; the stable-release path then removes its tracking entry. The stable release therefore ships without that change in its notes.Example: An RC release records
fix-room.mdunderlivekit-apiinpre.json. A later PR removesfix-room.md; validation passes. Apromotechangeset then releaseslivekit-apiwithout the fix-room summary and deletes its tracking entry.Recommended fix: Validate that every file named in
pre.jsonexists during--checkand before applying version bumps. Reject missing retained files rather than warning and discarding their notes.Was this helpful? React with 👍 or 👎 to provide feedback.