Skip to content

Commit db970ba

Browse files
authored
Merge pull request #32 from cloudwebrtc/pr-901
ci: changeset-driven Package Release workflow (mirror of livekit#901)
2 parents 1448e0e + fcecc50 commit db970ba

7 files changed

Lines changed: 1137 additions & 411 deletions

File tree

‎.changeset/README.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Changesets
2+
3+
Add a changeset to any PR that should be released. Create a markdown file in
4+
this directory (any name, e.g. `fix-token-parsing.md`):
5+
6+
```md
7+
---
8+
"livekit-api": patch
9+
---
10+
11+
Short description of the change, used in the release notes.
12+
```
13+
14+
List one line per package the change should release:
15+
16+
- **Packages:** `livekit-rtc`, `livekit-api`, `livekit-protocol`
17+
- **Types:**
18+
19+
| Type | Example |
20+
|---|---|
21+
| `patch` | 1.5.1 → 1.5.2 |
22+
| `minor` | 1.5.1 → 1.6.0 |
23+
| `major` | 1.5.1 → 2.0.0 |
24+
| `patch-rc` | 1.5.1 → 1.5.2.rc1 |
25+
| `minor-rc` | 1.5.1 → 1.6.0.rc1 |
26+
| `major-rc` | 1.5.1 → 2.0.0.rc1 |
27+
| `next-rc` | 1.6.0.rc1 → 1.6.0.rc2 |
28+
| `promote` | 1.6.0.rc2 → 1.6.0 |
29+
30+
When several pending changesets touch the same package, the largest bump wins.
31+
Mixing stable and rc types for one package, or combining `promote` with
32+
anything else, is rejected. Only rc types are allowed on branches other than
33+
`main`.
34+
35+
While a package is at an rc (e.g. `1.6.0.rc1`), only `next-rc` and `promote`
36+
are accepted for it, so fixes land in the next rc and the planned `1.6.0` still
37+
ships. Any other type would skip it (`patch` would release `1.6.1`). To move
38+
to a different version line, use the Manual Version Bump workflow below.
39+
40+
## How releases happen
41+
42+
1. Every PR is checked by the **Changeset** workflow; invalid changesets fail
43+
the check.
44+
2. On each push to `main`, **Package Release** applies all pending changesets
45+
and opens (or updates) the `Release: ...` PR with the version bumps and
46+
release notes, then deletes the changesets it used. Changesets used by an
47+
rc release are kept instead (and recorded in `.changeset/pre.json`) until
48+
that package's next stable release, normally `promote`, whose release notes
49+
then list every change since the last stable version.
50+
3. Merging that release PR tags each bumped package, publishes it to PyPI and
51+
creates its GitHub Release.
52+
53+
Changes that don't need a release (docs, CI, tests) don't need a changeset.
54+
55+
## Manual version bump
56+
57+
As a fallback, run the **Manual Version Bump** workflow with a package and
58+
bump type. It opens a `release/<prefix>-v<version>` PR without reading any
59+
changeset; merging it publishes the same way as a changeset release PR.
60+
It doesn't touch `.changeset/`, so changesets kept from rc releases stay until
61+
that package's next changeset stable release.
62+
63+
The two flows don't know about each other: a manual bump doesn't close an open
64+
`Release: ...` changeset PR for the same package (or the other way round), and
65+
both change that package's version, so once one merges the other conflicts.
66+
Close the stale one before merging.

‎.github/changesets.py‎

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""Consume pending .changeset/*.md files and bump package versions.
2+
3+
A changeset looks like:
4+
5+
---
6+
"livekit-rtc": patch
7+
"livekit-api": minor-rc
8+
---
9+
10+
Summary of the change.
11+
12+
Supported bump types: patch, minor, major, patch-rc, minor-rc, major-rc,
13+
next-rc, promote. When several changesets touch the same package, the
14+
largest bump wins (an explicit X-rc wins over next-rc). Mixing stable and
15+
rc bumps, or combining promote with anything else, is an error. While a
16+
package is at an rc, only next-rc and promote are accepted for it.
17+
18+
Changesets used by an rc release are kept and recorded in .changeset/pre.json
19+
(package -> file names) instead of being deleted, so later runs skip them. The
20+
package's next stable release (normally promote) lists them in its release
21+
notes and deletes them.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import json
27+
import os
28+
import pathlib
29+
import re
30+
import subprocess
31+
32+
import click
33+
from packaging.version import Version
34+
from update_versions import PACKAGES, do_bump, do_prerelease, read_version
35+
36+
CHANGESET_DIR = pathlib.Path(".changeset")
37+
# Changesets already released in an rc, kept until the package's stable release
38+
PRE_STATE = CHANGESET_DIR / "pre.json"
39+
40+
# Names accepted in changeset frontmatter -> key in PACKAGES
41+
PACKAGE_ALIASES = {
42+
"livekit": "livekit",
43+
"livekit-rtc": "livekit",
44+
"livekit-api": "livekit-api",
45+
"livekit-protocol": "livekit-protocol",
46+
}
47+
48+
STABLE = {"patch": 1, "minor": 2, "major": 3}
49+
RC = {"patch-rc": 1, "minor-rc": 2, "major-rc": 3}
50+
BUMP_TYPES = set(STABLE) | set(RC) | {"next-rc", "promote"}
51+
52+
53+
def parse_changeset(path: pathlib.Path) -> tuple[dict[str, str], str]:
54+
m = re.match(r"^---\s*\n(.*?)\n?---\s*\n?(.*)$", path.read_text(), re.DOTALL)
55+
if not m:
56+
raise click.ClickException(f"{path}: missing '---' frontmatter")
57+
58+
bumps: dict[str, str] = {}
59+
for line in m.group(1).splitlines():
60+
line = line.strip()
61+
if not line or line.startswith("#"):
62+
continue
63+
lm = re.fullmatch(r'[\'"]?([\w.\-]+)[\'"]?\s*:\s*[\'"]?([\w\-]+)[\'"]?', line)
64+
if not lm:
65+
raise click.ClickException(f"{path}: cannot parse line {line!r}")
66+
name, kind = lm.groups()
67+
if name not in PACKAGE_ALIASES:
68+
raise click.ClickException(
69+
f"{path}: unknown package {name!r} (expected one of {sorted(PACKAGE_ALIASES)})"
70+
)
71+
if kind not in BUMP_TYPES:
72+
raise click.ClickException(
73+
f"{path}: unknown bump type {kind!r} (expected one of {sorted(BUMP_TYPES)})"
74+
)
75+
bumps[PACKAGE_ALIASES[name]] = kind
76+
return bumps, m.group(2).strip()
77+
78+
79+
def resolve(package: str, kinds: set[str]) -> str:
80+
if "promote" in kinds:
81+
if len(kinds) > 1:
82+
raise click.ClickException(
83+
f"{package}: 'promote' cannot be combined with {sorted(kinds - {'promote'})}"
84+
)
85+
return "promote"
86+
87+
stable = kinds & STABLE.keys()
88+
if stable and kinds - stable:
89+
raise click.ClickException(f"{package}: cannot mix stable and rc bumps ({sorted(kinds)})")
90+
if stable:
91+
return max(stable, key=STABLE.__getitem__)
92+
93+
explicit_rc = kinds & RC.keys()
94+
if explicit_rc:
95+
return max(explicit_rc, key=RC.__getitem__)
96+
return "next-rc"
97+
98+
99+
def validate(package: str, kind: str) -> None:
100+
cur = Version(read_version(pathlib.Path(PACKAGES[package])))
101+
if kind == "next-rc" and not (cur.pre and cur.pre[0] == "rc"):
102+
raise click.ClickException(f"{package}: next-rc requires an rc version (got {cur})")
103+
if kind == "promote" and not cur.is_prerelease:
104+
raise click.ClickException(f"{package}: promote requires a pre-release (got {cur})")
105+
# Any other bump would move past the rc's planned stable version, e.g.
106+
# patch on 1.4.0.rc1 releases 1.4.1 and 1.4.0 never ships.
107+
if cur.is_prerelease and kind not in ("next-rc", "promote"):
108+
raise click.ClickException(
109+
f"{package}: {kind} is not allowed while at pre-release {cur}; "
110+
"use next-rc or promote (or the Manual Version Bump workflow)"
111+
)
112+
113+
114+
def apply(package: str, kind: str) -> None:
115+
if kind in STABLE:
116+
do_bump(package, kind)
117+
elif kind in RC:
118+
do_bump(package, kind.removesuffix("-rc"))
119+
do_prerelease(package, "rc")
120+
elif kind == "next-rc":
121+
do_prerelease(package, "rc")
122+
elif kind == "promote":
123+
do_bump(package, "release")
124+
125+
126+
def changeset_origin(path: pathlib.Path, repo: str | None) -> str:
127+
"""Return " (#123 by @user)" for the PR that added this changeset, or ""."""
128+
if not repo:
129+
return ""
130+
try:
131+
sha = subprocess.run(
132+
["git", "log", "--diff-filter=A", "--format=%H", "-1", "--", str(path)],
133+
capture_output=True,
134+
text=True,
135+
check=True,
136+
).stdout.strip()
137+
if not sha:
138+
return ""
139+
pr = subprocess.run(
140+
[
141+
"gh",
142+
"api",
143+
f"repos/{repo}/commits/{sha}/pulls",
144+
"--jq",
145+
'.[0] // empty | "\\(.number) \\(.user.login)"',
146+
],
147+
capture_output=True,
148+
text=True,
149+
check=True,
150+
).stdout.split()
151+
except (OSError, subprocess.CalledProcessError) as e:
152+
click.echo(f"warning: cannot resolve PR for {path}: {e}", err=True)
153+
return ""
154+
if len(pr) == 2:
155+
return f" (#{pr[0]} by @{pr[1]})"
156+
return f" ({sha[:7]})"
157+
158+
159+
def kept_entries(names: list[str], package: str, repo: str | None) -> list[str]:
160+
"""Release-note entries for this package from changesets kept since its rc releases."""
161+
entries = []
162+
for name in names:
163+
path = CHANGESET_DIR / name
164+
bumps, summary = parse_changeset(path)
165+
if summary and package in bumps:
166+
entries.append(f"`{bumps[package]}` {summary}{changeset_origin(path, repo)}")
167+
return entries
168+
169+
170+
@click.command()
171+
@click.option(
172+
"--rc-only",
173+
is_flag=True,
174+
default=False,
175+
help="Reject stable bumps (used for non-main branches).",
176+
)
177+
@click.option(
178+
"--notes",
179+
type=click.Path(dir_okay=False, path_type=pathlib.Path),
180+
default=None,
181+
help="Write release notes (markdown) to this file.",
182+
)
183+
@click.option(
184+
"--repo",
185+
envvar="GITHUB_REPOSITORY",
186+
default=None,
187+
help="owner/name used to look up the PR and author of each changeset (needs gh).",
188+
)
189+
@click.option(
190+
"--check",
191+
is_flag=True,
192+
default=False,
193+
help="Only validate the pending changesets; don't change any file.",
194+
)
195+
def main(rc_only: bool, notes: pathlib.Path | None, repo: str | None, check: bool) -> None:
196+
pre: dict[str, list[str]] = json.loads(PRE_STATE.read_text()) if PRE_STATE.exists() else {}
197+
kept = {name for names in pre.values() for name in names}
198+
# A kept changeset still owes its entry to the stable release notes.
199+
missing = sorted(name for name in kept if not (CHANGESET_DIR / name).exists())
200+
if missing:
201+
raise click.ClickException(
202+
f"changesets kept from rc releases are missing: {missing} "
203+
f"(restore them, or remove them from {PRE_STATE} on purpose)"
204+
)
205+
# Parse them now too, so a broken kept changeset fails the PR check
206+
# instead of the stable release.
207+
for name in sorted(kept):
208+
parse_changeset(CHANGESET_DIR / name)
209+
files = sorted(
210+
p
211+
for p in CHANGESET_DIR.glob("*.md")
212+
if p.name.lower() != "readme.md" and p.name not in kept
213+
)
214+
215+
kinds: dict[str, set[str]] = {}
216+
summaries: dict[str, list[str]] = {}
217+
packages_of: dict[str, set[str]] = {}
218+
for path in files:
219+
bumps, summary = parse_changeset(path)
220+
packages_of[path.name] = set(bumps)
221+
if summary and not check:
222+
summary += changeset_origin(path, repo)
223+
for package, kind in bumps.items():
224+
kinds.setdefault(package, set()).add(kind)
225+
if summary:
226+
# Each entry shows the bump type its changeset asked for.
227+
summaries.setdefault(package, []).append(f"`{kind}` {summary}")
228+
229+
resolved = {package: resolve(package, k) for package, k in kinds.items()}
230+
if rc_only:
231+
stable = {p: k for p, k in resolved.items() if k in STABLE or k == "promote"}
232+
if stable:
233+
raise click.ClickException(
234+
f"only rc releases are allowed from non-main branches (got {stable})"
235+
)
236+
# Validate everything before touching any version file.
237+
for package, kind in resolved.items():
238+
validate(package, kind)
239+
240+
if check:
241+
click.echo(f"{len(files)} changeset(s) valid ({len(kept)} kept from rc releases).")
242+
for package in PACKAGES:
243+
if package in resolved:
244+
cur = read_version(pathlib.Path(PACKAGES[package]))
245+
click.echo(f" {package}: {cur} ({resolved[package]})")
246+
return
247+
248+
lines: list[str] = []
249+
released: list[str] = []
250+
for package in PACKAGES: # stable order
251+
if package not in resolved:
252+
continue
253+
kind = resolved[package]
254+
apply(package, kind)
255+
version = read_version(pathlib.Path(PACKAGES[package]))
256+
released.append(f"{package} v{version}")
257+
lines.append(f"## {package} v{version} ({kind})\n")
258+
entries = summaries.get(package, [])
259+
if kind in RC or kind == "next-rc":
260+
# Keep this rc's changesets for the stable release notes.
261+
pre.setdefault(package, []).extend(
262+
p.name for p in files if package in packages_of[p.name]
263+
)
264+
else:
265+
# A stable release (normally promote) also lists the changesets
266+
# kept since this package's rc releases.
267+
entries = kept_entries(pre.pop(package, []), package, repo) + entries
268+
for summary in entries:
269+
lines.append("- " + summary.replace("\n", "\n "))
270+
lines.append("")
271+
272+
# Changesets are consumed once applied (including ones listing no package),
273+
# except those still kept for a package's stable release.
274+
still_kept = {name for names in pre.values() for name in names}
275+
for name in {p.name for p in files} | kept:
276+
if name not in still_kept:
277+
(CHANGESET_DIR / name).unlink(missing_ok=True)
278+
if pre:
279+
PRE_STATE.write_text(json.dumps(pre, indent=2, sort_keys=True) + "\n")
280+
else:
281+
PRE_STATE.unlink(missing_ok=True)
282+
283+
if notes is not None:
284+
notes.write_text("\n".join(lines))
285+
286+
title = ", ".join(released)
287+
print(f"Release: {title or '(nothing to release)'}")
288+
if gh_output := os.environ.get("GITHUB_OUTPUT"):
289+
with open(gh_output, "a") as f:
290+
f.write(f"title={title}\n")
291+
292+
293+
if __name__ == "__main__":
294+
main()

0 commit comments

Comments
 (0)