diff --git a/.github/scripts/changeset_detect.py b/.github/scripts/changeset_detect.py new file mode 100755 index 000000000..8b8659279 --- /dev/null +++ b/.github/scripts/changeset_detect.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Detect which knope-managed packages a PR affects and validate changeset coverage. + +A changed crate requires a version bump not just for itself, but for every crate +that (transitively) depends on it. This computes that closure from `cargo metadata` +and compares it against the bumps already declared in the PR's changeset files. + +Inputs (environment variables): + CHANGED_FILES newline-separated list of files changed by the PR + CHANGESET_FILES newline-separated list of .changeset/*.md files in the PR diff + PR_TITLE PR title, used for the prefilled changeset description (optional) + PR_NUMBER PR number (optional) + PR_AUTHOR PR author login (optional) + +Output (JSON on stdout): + { + "error": str, # non-empty if cargo metadata failed + "direct": [pkg, ...], # packages whose own files changed + "downstream": [pkg, ...], # transitive dependents of `direct` + "required": [pkg, ...], # direct + downstream (full closure) + "present": [[pkg, bump], ...], # bumps already in the changeset + "missing": [pkg, ...], # required packages not yet covered + "changeset_content": str, # a changeset prefilled with `missing` + } +""" + +import json +import os +import re +import subprocess +import sys + +BUMP_ORDER = {"patch": 0, "minor": 1, "major": 2} + + +def emit(data): + """Write the result as JSON and exit successfully.""" + json.dump(data, sys.stdout) + sys.exit(0) + + +def read_lines(env_var): + return [line.strip() for line in os.environ.get(env_var, "").strip().split("\n") if line.strip()] + + +def load_knope_packages(): + """Return the set of package names managed by knope (from knope.toml).""" + with open("knope.toml") as f: + return set(re.findall(r"^\[packages\.([^\]]+)\]", f.read(), re.MULTILINE)) + + +def parse_present_bumps(changeset_files, knope_packages): + """Parse the highest bump declared per package across the PR's changeset files. + + Changeset front matter looks like: "livekit-api": patch + """ + present = {} + for filepath in changeset_files: + try: + with open(filepath) as f: + content = f.read() + except OSError: + continue + match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + if not match: + continue + for line in match.group(1).strip().split("\n"): + line = line.strip() + if ":" not in line: + continue + pkg, bump = line.split(":", 1) + pkg = pkg.strip().strip('"').strip("'").strip() + bump = bump.strip().strip('"').strip("'").strip() + if bump in BUMP_ORDER and pkg in knope_packages: + if pkg not in present or BUMP_ORDER[bump] > BUMP_ORDER[present[pkg]]: + present[pkg] = bump + return present + + +def build_reverse_dep_graph(meta, knope_packages): + """Map each knope package to the set of knope packages that directly depend on it.""" + reverse = {name: set() for name in knope_packages} + for pkg in meta["packages"]: + if pkg["name"] not in knope_packages: + continue + # dependencies includes both normal and build deps + for dep in pkg.get("dependencies", []): + if dep["name"] in knope_packages: + reverse[dep["name"]].add(pkg["name"]) + return reverse + + +def transitive_downstream(pkg, reverse): + """All packages that (transitively) depend on `pkg`.""" + visited = set() + stack = list(reverse.get(pkg, set())) + while stack: + node = stack.pop() + if node not in visited: + visited.add(node) + stack.extend(reverse.get(node, set()) - visited) + return visited + + +def match_changed_packages(changed_files, pkg_to_dir): + """Map changed files to knope packages (longest directory prefix wins).""" + sorted_pkgs = sorted(pkg_to_dir.items(), key=lambda x: len(x[1]), reverse=True) + direct = set() + for f in changed_files: + for pkg_name, pkg_dir in sorted_pkgs: + if f.startswith(pkg_dir + "/"): + direct.add(pkg_name) + break + return direct + + +def build_changeset_content(missing, pr_title=None, pr_number=None, pr_author=None): + """Build a changeset that fills in `patch` bumps for the missing packages.""" + pr_title = pr_title if pr_title is not None else os.environ.get("PR_TITLE", "Description of your change") + pr_number = pr_number if pr_number is not None else os.environ.get("PR_NUMBER", "") + pr_author = pr_author if pr_author is not None else os.environ.get("PR_AUTHOR", "") + lines = ["---"] + for pkg in missing: + lines.append(f'"{pkg}": patch') + lines.extend(["---", "", f"{pr_title} - #{pr_number} (@{pr_author})"]) + return "\n".join(lines) + + +def package_directories(meta, knope_packages): + """Map each knope package name to its workspace-relative directory.""" + workspace_root = meta["workspace_root"] + pkg_to_dir = {} + for pkg in meta["packages"]: + if pkg["name"] in knope_packages: + manifest_dir = os.path.dirname(pkg["manifest_path"]) + pkg_to_dir[pkg["name"]] = os.path.relpath(manifest_dir, workspace_root) + return pkg_to_dir + + +def detect(meta, knope_packages, changed_files, present): + """Compute affected packages and changeset coverage. Pure — no I/O or cargo. + + `present` maps already-declared package -> bump. Returns the result dict + (without the "error" key, which only the cargo-metadata fetch can set). + """ + pkg_to_dir = package_directories(meta, knope_packages) + reverse = build_reverse_dep_graph(meta, knope_packages) + + direct_affected = match_changed_packages(changed_files, pkg_to_dir) + + # Expand with downstream dependents + all_affected = set(direct_affected) + downstream_only = set() + for pkg in direct_affected: + for dep in transitive_downstream(pkg, reverse): + all_affected.add(dep) + if dep not in direct_affected: + downstream_only.add(dep) + + missing = sorted(all_affected - set(present.keys())) + + return { + "error": "", + "direct": sorted(direct_affected), + "downstream": sorted(downstream_only), + "required": sorted(all_affected), + "present": sorted(present.items()), + "missing": missing, + "changeset_content": build_changeset_content(missing), + } + + +def load_cargo_metadata(): + """Fetch workspace metadata (--no-deps avoids network access).""" + return json.loads(subprocess.check_output( + ["cargo", "metadata", "--format-version", "1", "--no-deps"], + text=True, stderr=subprocess.DEVNULL, + )) + + +def main(): + changed_files = read_lines("CHANGED_FILES") + changeset_files = read_lines("CHANGESET_FILES") + + knope_packages = load_knope_packages() + present = parse_present_bumps(changeset_files, knope_packages) + + try: + meta = load_cargo_metadata() + except Exception as e: # noqa: BLE001 - report any failure back to the workflow + emit({ + "error": str(e), + "direct": [], "downstream": [], "required": [], + "present": sorted(present.items()), "missing": [], + "changeset_content": "", + }) + return # emit calls sys.exit, but guard against falling through + + emit(detect(meta, knope_packages, changed_files, present)) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_changeset_detect.py b/.github/scripts/test_changeset_detect.py new file mode 100644 index 000000000..49e6e7b59 --- /dev/null +++ b/.github/scripts/test_changeset_detect.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for changeset_detect.py. + +Run with: python3 -m unittest discover -s .github/scripts -p 'test_*.py' + or: python3 .github/scripts/test_changeset_detect.py + +Uses only the standard library and a synthetic workspace, so it never invokes +cargo or reads the real workspace. +""" + +import os +import tempfile +import unittest + +import changeset_detect as cd + + +def make_meta(packages, deps, workspace_root="/ws"): + """Build a fake `cargo metadata` document. + + packages: {name -> relative dir} + deps: {name -> [dependency names]} + """ + return { + "workspace_root": workspace_root, + "packages": [ + { + "name": name, + "manifest_path": f"{workspace_root}/{rel}/Cargo.toml", + "dependencies": [{"name": d} for d in deps.get(name, [])], + } + for name, rel in packages.items() + ], + } + + +# A small synthetic workspace: +# a-sys (leaf) +# b -> depends on a-sys +# c -> depends on b (so c transitively depends on a-sys) +# d -> independent +PACKAGES = {"a-sys": "a-sys", "b": "b", "c": "c", "d": "d"} +DEPS = {"b": ["a-sys"], "c": ["b"], "d": []} +KNOPE = set(PACKAGES) +META = make_meta(PACKAGES, DEPS) + + +class TestGraph(unittest.TestCase): + def test_reverse_dep_graph(self): + reverse = cd.build_reverse_dep_graph(META, KNOPE) + self.assertEqual(reverse["a-sys"], {"b"}) + self.assertEqual(reverse["b"], {"c"}) + self.assertEqual(reverse["c"], set()) + self.assertEqual(reverse["d"], set()) + + def test_reverse_dep_graph_ignores_non_knope_deps(self): + meta = make_meta({"b": "b"}, {"b": ["serde", "tokio"]}) + reverse = cd.build_reverse_dep_graph(meta, {"b"}) + self.assertEqual(reverse["b"], set()) + + def test_transitive_downstream(self): + reverse = cd.build_reverse_dep_graph(META, KNOPE) + self.assertEqual(cd.transitive_downstream("a-sys", reverse), {"b", "c"}) + self.assertEqual(cd.transitive_downstream("b", reverse), {"c"}) + self.assertEqual(cd.transitive_downstream("c", reverse), set()) + self.assertEqual(cd.transitive_downstream("d", reverse), set()) + + def test_transitive_downstream_handles_cycle(self): + # x <-> y depend on each other; closure must terminate + meta = make_meta({"x": "x", "y": "y"}, {"x": ["y"], "y": ["x"]}) + reverse = cd.build_reverse_dep_graph(meta, {"x", "y"}) + self.assertEqual(cd.transitive_downstream("x", reverse), {"x", "y"}) + + +class TestMatchChangedPackages(unittest.TestCase): + def setUp(self): + self.pkg_to_dir = cd.package_directories(META, KNOPE) + + def test_matches_by_directory_prefix(self): + self.assertEqual( + cd.match_changed_packages(["b/src/lib.rs"], self.pkg_to_dir), {"b"} + ) + + def test_no_match_for_unversioned_paths(self): + self.assertEqual( + cd.match_changed_packages(["README.md", "docs/x.md"], self.pkg_to_dir), set() + ) + + def test_multiple_packages(self): + self.assertEqual( + cd.match_changed_packages(["a-sys/x.rs", "d/y.rs"], self.pkg_to_dir), + {"a-sys", "d"}, + ) + + def test_longest_prefix_wins_for_nested_packages(self): + # A nested package must win over its parent-directory package. + meta = make_meta({"outer": "pkg", "inner": "pkg/inner"}, {}) + pkg_to_dir = cd.package_directories(meta, {"outer", "inner"}) + self.assertEqual( + cd.match_changed_packages(["pkg/inner/src/lib.rs"], pkg_to_dir), {"inner"} + ) + self.assertEqual( + cd.match_changed_packages(["pkg/src/lib.rs"], pkg_to_dir), {"outer"} + ) + + def test_prefix_requires_directory_boundary(self): + # "a-sys-extra/x" must not match package dir "a-sys". + meta = make_meta({"a-sys": "a-sys"}, {}) + pkg_to_dir = cd.package_directories(meta, {"a-sys"}) + self.assertEqual( + cd.match_changed_packages(["a-sys-extra/x.rs"], pkg_to_dir), set() + ) + + +class TestParsePresentBumps(unittest.TestCase): + def _write(self, tmp, name, body): + path = os.path.join(tmp, name) + with open(path, "w") as f: + f.write(body) + return path + + def test_parses_quoted_names(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "a.md", '---\n"a-sys": patch\n"b": minor\n---\n\ndesc\n') + self.assertEqual( + cd.parse_present_bumps([p], KNOPE), {"a-sys": "patch", "b": "minor"} + ) + + def test_ignores_unknown_packages(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "a.md", '---\n"not-a-package": patch\n"b": patch\n---\n\nd\n') + self.assertEqual(cd.parse_present_bumps([p], KNOPE), {"b": "patch"}) + + def test_highest_bump_wins_across_files(self): + with tempfile.TemporaryDirectory() as tmp: + p1 = self._write(tmp, "a.md", '---\n"b": patch\n---\n\nd\n') + p2 = self._write(tmp, "z.md", '---\n"b": major\n---\n\nd\n') + self.assertEqual(cd.parse_present_bumps([p1, p2], KNOPE), {"b": "major"}) + # order independent + self.assertEqual(cd.parse_present_bumps([p2, p1], KNOPE), {"b": "major"}) + + def test_ignores_invalid_bump_values(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "a.md", '---\n"b": bogus\n---\n\nd\n') + self.assertEqual(cd.parse_present_bumps([p], KNOPE), {}) + + def test_missing_front_matter(self): + with tempfile.TemporaryDirectory() as tmp: + p = self._write(tmp, "a.md", "just a description, no front matter\n") + self.assertEqual(cd.parse_present_bumps([p], KNOPE), {}) + + def test_missing_file_is_skipped(self): + self.assertEqual(cd.parse_present_bumps(["/nonexistent/x.md"], KNOPE), {}) + + +class TestDetect(unittest.TestCase): + def test_no_changeset_flags_full_closure(self): + r = cd.detect(META, KNOPE, ["a-sys/src/lib.rs"], {}) + self.assertEqual(r["direct"], ["a-sys"]) + self.assertEqual(r["downstream"], ["b", "c"]) + self.assertEqual(r["required"], ["a-sys", "b", "c"]) + self.assertEqual(r["missing"], ["a-sys", "b", "c"]) + self.assertEqual(r["error"], "") + + def test_incomplete_changeset_flags_downstream(self): + r = cd.detect(META, KNOPE, ["a-sys/src/lib.rs"], {"a-sys": "patch"}) + self.assertEqual(r["required"], ["a-sys", "b", "c"]) + self.assertEqual(r["present"], [("a-sys", "patch")]) + self.assertEqual(r["missing"], ["b", "c"]) + + def test_complete_changeset_has_no_missing(self): + present = {"a-sys": "patch", "b": "patch", "c": "minor"} + r = cd.detect(META, KNOPE, ["a-sys/src/lib.rs"], present) + self.assertEqual(r["missing"], []) + + def test_unversioned_change_requires_nothing(self): + r = cd.detect(META, KNOPE, ["README.md", ".github/x.yml"], {}) + self.assertEqual(r["required"], []) + self.assertEqual(r["missing"], []) + + def test_extra_bumps_do_not_cause_missing(self): + # Changeset bumps more than required — still complete. + present = {"a-sys": "patch", "b": "patch", "c": "patch", "d": "patch"} + r = cd.detect(META, KNOPE, ["a-sys/src/lib.rs"], present) + self.assertEqual(r["missing"], []) + + def test_changeset_content_prefills_missing(self): + r = cd.detect(META, KNOPE, ["a-sys/src/lib.rs"], {"a-sys": "patch"}) + # missing == [b, c]; the prefilled changeset must bump exactly those + self.assertIn('"b": patch', r["changeset_content"]) + self.assertIn('"c": patch', r["changeset_content"]) + self.assertNotIn('"a-sys"', r["changeset_content"]) + + +class TestBuildChangesetContent(unittest.TestCase): + def test_deterministic_with_explicit_metadata(self): + content = cd.build_changeset_content( + ["b", "c"], pr_title="My change", pr_number="42", pr_author="alice" + ) + self.assertEqual( + content, + '---\n"b": patch\n"c": patch\n---\n\nMy change - #42 (@alice)', + ) + + def test_empty_missing_produces_empty_front_matter(self): + content = cd.build_changeset_content([], pr_title="t", pr_number="1", pr_author="a") + self.assertEqual(content, "---\n---\n\nt - #1 (@a)") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index f6bfd3c23..d5a26b755 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -30,6 +30,9 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Test detection script + run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' + - name: Check for changeset env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -76,162 +79,17 @@ jobs: # --- Get changed files --- CHANGED_FILES=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename') - # --- Check for changeset in diff --- + # --- Identify changeset files present in the diff --- CHANGESET_FILES=$(echo "$CHANGED_FILES" | grep '^\.changeset/.*\.md$' || true) - if [ -n "$CHANGESET_FILES" ]; then - echo "Changeset found in PR diff" - - # Parse changeset files to extract version bumps - export CHANGESET_FILES - BUMPS=$(python3 << 'PYEOF' - import json, os, re, sys - - BUMP_ORDER = {"patch": 0, "minor": 1, "major": 2} - bumps = {} - - for filepath in os.environ["CHANGESET_FILES"].strip().split("\n"): - filepath = filepath.strip() - if not filepath: - continue - try: - with open(filepath) as f: - content = f.read() - match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) - if not match: - continue - for line in match.group(1).strip().split("\n"): - line = line.strip() - if ":" not in line: - continue - pkg, bump = line.split(":", 1) - pkg, bump = pkg.strip(), bump.strip() - if bump in BUMP_ORDER: - if pkg not in bumps or BUMP_ORDER[bump] > BUMP_ORDER[bumps[pkg]]: - bumps[pkg] = bump - except Exception: - continue - - json.dump(sorted(bumps.items()), sys.stdout) - PYEOF - ) - - # Build summary comment with version bump table - TABLE_ROWS=$(echo "$BUMPS" | jq -r '.[] | "| `\(.[0])` | `\(.[1])` |"') - - COMMENT_BODY=$( - echo "$COMMENT_MARKER" - echo "### Changeset" - echo "" - if [ -n "$TABLE_ROWS" ]; then - echo "The following package versions will be affected by this PR:" - echo "" - echo "| Package | Bump |" - echo "|---------|------|" - echo "$TABLE_ROWS" - else - echo "Changeset found but no version bumps declared." - fi - ) - - upsert_comment "$COMMENT_BODY" || true - exit 0 - fi - - echo "No changeset found - detecting affected packages..." - - # --- Detect affected packages via cargo metadata + knope.toml --- - export CHANGED_FILES - DETECTION=$(python3 << 'PYEOF' - import json, os, re, subprocess, sys - - changed_files = [f for f in os.environ["CHANGED_FILES"].strip().split("\n") if f] - - # Parse knope-managed package names from knope.toml - with open("knope.toml") as f: - knope_packages = set(re.findall(r"^\[packages\.([^\]]+)\]", f.read(), re.MULTILINE)) - - # Get workspace metadata (--no-deps avoids network access) - try: - meta = json.loads(subprocess.check_output( - ["cargo", "metadata", "--format-version", "1", "--no-deps"], - text=True, stderr=subprocess.DEVNULL - )) - except Exception as e: - json.dump({"error": str(e), "direct": [], "downstream": [], "changeset_content": ""}, sys.stdout) - sys.exit(0) - - workspace_root = meta["workspace_root"] - - # Map knope-managed package name -> relative directory - pkg_to_dir = {} - for pkg in meta["packages"]: - if pkg["name"] in knope_packages: - manifest_dir = os.path.dirname(pkg["manifest_path"]) - pkg_to_dir[pkg["name"]] = os.path.relpath(manifest_dir, workspace_root) - - # Build direct dependency graph between knope-managed packages - # (packages[].dependencies includes both normal and build deps) - direct_deps = {name: set() for name in knope_packages} - for pkg in meta["packages"]: - if pkg["name"] not in knope_packages: - continue - for dep in pkg.get("dependencies", []): - if dep["name"] in knope_packages: - direct_deps[pkg["name"]].add(dep["name"]) - - # Reverse the graph: for each package, who depends on it (downstream) - reverse = {name: set() for name in knope_packages} - for pkg, deps in direct_deps.items(): - for dep in deps: - reverse[dep].add(pkg) - - # Compute transitive downstream closure - def transitive_downstream(pkg): - visited = set() - stack = list(reverse.get(pkg, set())) - while stack: - node = stack.pop() - if node not in visited: - visited.add(node) - stack.extend(reverse.get(node, set()) - visited) - return visited - - # Match changed files to packages (longest directory prefix wins) - sorted_pkgs = sorted(pkg_to_dir.items(), key=lambda x: len(x[1]), reverse=True) - direct_affected = set() - for f in changed_files: - for pkg_name, pkg_dir in sorted_pkgs: - if f.startswith(pkg_dir + "/"): - direct_affected.add(pkg_name) - break - - # Expand with downstream dependencies - all_affected = set(direct_affected) - downstream_only = set() - for pkg in direct_affected: - for dep in transitive_downstream(pkg): - all_affected.add(dep) - if dep not in direct_affected: - downstream_only.add(dep) - - # Build changeset content - lines = ["---"] - for pkg in sorted(all_affected): - lines.append(f"{pkg}: patch") - pr_title = os.environ.get("PR_TITLE", "Description of your change") - pr_number = os.environ.get("PR_NUMBER", "") - pr_author = os.environ.get("PR_AUTHOR", "") - description = f"{pr_title} - #{pr_number} (@{pr_author})" - lines.extend(["---", "", description]) - - json.dump({ - "direct": sorted(direct_affected), - "downstream": sorted(downstream_only), - "changeset_content": "\n".join(lines), - }, sys.stdout) - PYEOF - ) + # --- Detect affected packages AND validate existing changeset coverage --- + # + # A changed crate requires a version bump not just for itself, but for + # every crate that (transitively) depends on it. The changeset must + # cover the full closure; a changeset that bumps only a subset is + # incomplete and fails the check. See .github/scripts/changeset_detect.py. + export CHANGED_FILES CHANGESET_FILES + DETECTION=$(python3 .github/scripts/changeset_detect.py) # Handle cargo metadata errors gracefully ERROR=$(echo "$DETECTION" | jq -r '.error // empty') @@ -239,48 +97,77 @@ jobs: echo "::warning::Failed to detect packages via cargo metadata: $ERROR" fi - # --- If no packages affected, pass --- - NUM_DIRECT=$(echo "$DETECTION" | jq '.direct | length') - if [ "$NUM_DIRECT" = "0" ]; then + # --- If no versioned packages are affected, no changeset is required --- + NUM_REQUIRED=$(echo "$DETECTION" | jq '.required | length') + if [ "$NUM_REQUIRED" = "0" ]; then echo "No versioned packages affected - changeset not required" delete_comment exit 0 fi - # --- Build changeset file content and URL --- + # --- If every affected package is covered, the changeset is complete --- + NUM_MISSING=$(echo "$DETECTION" | jq '.missing | length') + if [ "$NUM_MISSING" = "0" ]; then + echo "Changeset covers all affected packages" + + TABLE_ROWS=$(echo "$DETECTION" | jq -r '.present[] | "| `\(.[0])` | `\(.[1])` |"') + COMMENT_BODY=$( + echo "$COMMENT_MARKER" + echo "### Changeset ✓" + echo "" + echo "This PR includes a changeset covering all affected packages:" + echo "" + echo "| Package | Bump |" + echo "|---------|------|" + echo "$TABLE_ROWS" + ) + upsert_comment "$COMMENT_BODY" || true + exit 0 + fi + + # --- Some affected packages are missing a bump: build comment and fail --- CHANGESET_CONTENT=$(echo "$DETECTION" | jq -r '.changeset_content') - # --- Generate file name slug from PR title --- + # Generate a file-name slug from the PR title SLUG=$(echo "$PR_TITLE" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '_' | sed 's/^_//; s/_$//') SLUG="${SLUG:0:60}" [ -z "$SLUG" ] && SLUG="pr_${PR_NUMBER}" - # --- Build GitHub create-file URL --- + # Build a GitHub create-file URL that pre-populates the missing bumps ENCODED_CONTENT=$(printf '%s' "$CHANGESET_CONTENT" | jq -sRr @uri) CREATE_URL="https://github.com/${REPO}/new/${PR_BRANCH}?filename=.changeset/${SLUG}.md&value=${ENCODED_CONTENT}" - # --- Build comment body --- - DIRECT_LIST=$(echo "$DETECTION" | jq -r '.direct[] | "- `\(.)`"') - DOWNSTREAM_LIST=$(echo "$DETECTION" | jq -r '.downstream[] | "- `\(.)`"') + MISSING_LIST=$(echo "$DETECTION" | jq -r '.missing[] | "- `\(.)`"') + NUM_PRESENT=$(echo "$DETECTION" | jq '.present | length') + + if [ "$NUM_PRESENT" = "0" ]; then + HEADING="### No changeset found" + INTRO="This PR modifies versioned packages but doesn't include a changeset. The following packages require a version bump:" + else + HEADING="### Changeset incomplete" + PRESENT_LIST=$(echo "$DETECTION" | jq -r '.present[] | "- `\(.[0])` (`\(.[1])`)"') + INTRO="This PR's changeset is missing version bumps for packages that are affected by the change. The following packages still require a bump:" + fi COMMENT_BODY=$( echo "$COMMENT_MARKER" - echo "### No changeset found" + echo "$HEADING" echo "" - echo "This PR modifies the following packages but doesn't include a changeset:" + echo "$INTRO" echo "" - echo "**Directly changed:**" - echo "$DIRECT_LIST" - if [ -n "$DOWNSTREAM_LIST" ]; then + echo "$MISSING_LIST" + echo "" + if [ "$NUM_PRESENT" != "0" ]; then + echo "Already covered:" + echo "" + echo "$PRESENT_LIST" echo "" - echo "**Downstream dependencies (also need a version bump):**" - echo "$DOWNSTREAM_LIST" fi + echo "A package must be bumped when its own files change, **and** whenever a package it depends on is bumped (so downstream consumers get a matching release)." echo "" - echo "[**Click here to create a changeset**](${CREATE_URL})" + echo "[**Click here to create a changeset for the missing packages**](${CREATE_URL})" echo "" - echo "The link pre-populates a changeset file with \`patch\` bumps for all affected packages." - echo "Edit the description and bump types as needed before committing." + echo "The link pre-populates a changeset file with \`patch\` bumps for the missing packages. You can also add them to your existing changeset. Edit the bump types as needed before committing." echo "" echo "If this change doesn't require a version bump, add the \`internal\` label to this PR." ) @@ -288,5 +175,6 @@ jobs: # --- Post or update comment --- upsert_comment "$COMMENT_BODY" || echo "::warning::Could not post PR comment (this can happen for fork PRs with limited permissions)" - echo "::error::No changeset found. Add a changeset or apply the 'internal' label." + MISSING_CSV=$(echo "$DETECTION" | jq -r '.missing | join(", ")') + echo "::error::Changeset is missing version bumps for: ${MISSING_CSV}. Add them or apply the 'internal' label." exit 1 diff --git a/.gitignore b/.gitignore index 456c62ecb..328df4d65 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ soxr-sys/test-output.wav .DS_Store .env .cursor +__pycache__