mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 09:15:22 -04:00
3822192a52
Adds a pull-request gate for release-please fan-out caused by regenerated package lockfiles. The check catches bump-worthy PRs that would accidentally create release PRs for managed packages whose only change is `uv.lock`, while still allowing intentional lockfile-only releases through an explicit bypass label.
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""Flag PRs that would create a release PR for a package whose only change is its lockfile.
|
|
|
|
Why this exists:
|
|
release-please scopes a commit to a package by the file *paths* it touches,
|
|
with no notion of "this file is just a lockfile." When a bump-worthy commit
|
|
(e.g. a `feat:` in `libs/deepagents`) also regenerates the `uv.lock` of every
|
|
dependent package, release-please attributes the bump-worthy commit to those
|
|
dependents too — and opens a release PR for each, even though their only
|
|
change is a lockfile. This is the lockfile-churn sibling of the empty-commit
|
|
fan-out that `guard-empty-commit` (in release-please.yml) already blocks. See
|
|
the "Empty commit fan-out" entry in `.github/RELEASING.md` for the related case.
|
|
|
|
What it detects:
|
|
Given a PR's conventional-commit title type and its list of changed files,
|
|
report every release-please-managed package whose changed files inside the
|
|
package path are *exclusively* lockfiles, when the title type is one that
|
|
release-please would cut a release for. The package that legitimately owns
|
|
the change has source edits too, so its lockfile change is never flagged.
|
|
|
|
How it stays faithful to release-please:
|
|
- Package path -> component map is read straight from `release-please-config.json`
|
|
(the same source release-please scopes against) — no second list to drift.
|
|
- The set of "bump-worthy" types is derived from the non-hidden entries in that
|
|
config's `changelog-sections`. This is a deliberately conservative *superset*
|
|
of release-please's actual bump triggers (`feat`/`fix` + breaking): `perf` and
|
|
`revert` are visible sections that may not always bump. We accept the rare
|
|
false positive on a `perf`/`revert`-only lockfile PR to avoid missing a real
|
|
fan-out; such a PR can be cleared with the `allow-lockfile-release` label.
|
|
|
|
This script only *reports* offenders (and always exits 0 on success). The
|
|
blocking decision — failing the check — lives in the workflow that calls it
|
|
(`release_please_scope_check.yml`), not here.
|
|
|
|
Limitations:
|
|
- Breaking changes are detected via the `!` title shorthand only. A
|
|
`BREAKING CHANGE:` footer with no `!` is not inspected (the workflow passes
|
|
the title, not the body). The worst case is an unflagged fan-out a maintainer
|
|
catches at release-please time, so acceptable.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_CONFIG = REPO_ROOT / "release-please-config.json"
|
|
|
|
# Lockfiles that are regenerated by dependency resolution rather than authored
|
|
# directly. A package whose changed files are *only* these is churn, not a change.
|
|
LOCKFILE_NAMES = frozenset({"uv.lock"})
|
|
|
|
# Conventional-commit title: lowercase type, optional (scope), optional `!`.
|
|
_TITLE_RE = re.compile(r"^([a-z]+)(?:\([^)]*\))?(!)?:\s")
|
|
|
|
|
|
def bump_worthy_types(config: dict) -> frozenset[str]:
|
|
"""Return the conventional-commit types that may trigger a release.
|
|
|
|
Derived from the non-hidden entries in the config's `changelog-sections`
|
|
so the check tracks the repo's own configuration instead of a hardcoded
|
|
list. See module docstring for why this is a conservative superset.
|
|
|
|
Args:
|
|
config: Parsed `release-please-config.json`.
|
|
|
|
Returns:
|
|
Set of lowercase commit types considered bump-worthy.
|
|
"""
|
|
sections = config.get("changelog-sections", [])
|
|
return frozenset(
|
|
s["type"] for s in sections if s.get("type") and not s.get("hidden", False)
|
|
)
|
|
|
|
|
|
def parse_title(title: str) -> tuple[str | None, bool]:
|
|
"""Return `(type, is_breaking)` parsed from a conventional-commit title.
|
|
|
|
Args:
|
|
title: The PR title (e.g. `feat(sdk): add thing` or `fix!: oops`).
|
|
|
|
Returns:
|
|
Tuple of the lowercase type (or `None` if the title is not
|
|
conventional-commit-shaped) and whether the `!` breaking marker is set.
|
|
"""
|
|
match = _TITLE_RE.match(title)
|
|
if not match:
|
|
return None, False
|
|
return match.group(1), bool(match.group(2))
|
|
|
|
|
|
def _package_files(changed: list[str], path: str) -> list[str]:
|
|
"""Return the subset of `changed` that lives inside package directory `path`."""
|
|
prefix = f"{path}/"
|
|
return [f for f in changed if f == path or f.startswith(prefix)]
|
|
|
|
|
|
def find_offenders(title: str, changed: list[str], config: dict) -> list[str]:
|
|
"""Return components whose only changed files are lockfiles, if the PR bumps.
|
|
|
|
Args:
|
|
title: The PR title.
|
|
changed: Changed file paths, repo-root-relative (forward slashes).
|
|
config: Parsed `release-please-config.json`.
|
|
|
|
Returns:
|
|
Sorted list of offending component names. Empty when the title type is
|
|
not bump-worthy or no managed package is lockfile-only.
|
|
"""
|
|
ttype, breaking = parse_title(title)
|
|
if not breaking and ttype not in bump_worthy_types(config):
|
|
return []
|
|
|
|
offenders: list[str] = []
|
|
for path, meta in config.get("packages", {}).items():
|
|
pkg_files = _package_files(changed, path)
|
|
if pkg_files and all(Path(f).name in LOCKFILE_NAMES for f in pkg_files):
|
|
offenders.append(meta.get("component", path))
|
|
return sorted(offenders)
|
|
|
|
|
|
def main(title: str, changed: list[str], config_path: Path = DEFAULT_CONFIG) -> int:
|
|
"""Print offending components as a JSON array to stdout.
|
|
|
|
Errors are surfaced loudly (not swallowed) and exit non-zero so a broken
|
|
config or unreadable input fails the CI step visibly rather than silently
|
|
reporting "no offenders."
|
|
|
|
Returns:
|
|
`0` always on successful analysis — offenders are reported on stdout and
|
|
the blocking decision is made by the calling workflow,
|
|
not this script.
|
|
`2` on an internal error (missing/invalid/empty config).
|
|
"""
|
|
try:
|
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
print(
|
|
f"::error::Could not read release-please config {config_path}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
# Fail closed on config drift rather than silently degrading to a no-op gate
|
|
# that passes everything: no packages means nothing is ever scoped, and no
|
|
# non-hidden changelog-sections means no title is ever bump-worthy.
|
|
if not isinstance(config.get("packages"), dict) or not config["packages"]:
|
|
print(
|
|
f"::error::release-please config {config_path} has no 'packages' map",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
if not bump_worthy_types(config):
|
|
print(
|
|
f"::error::release-please config {config_path} has no non-hidden "
|
|
"changelog-sections; cannot determine bump-worthy types",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
offenders = find_offenders(title, changed, config)
|
|
if offenders:
|
|
print(
|
|
f"Lockfile-only release scope for: {', '.join(offenders)}", file=sys.stderr
|
|
)
|
|
print(json.dumps(offenders))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print(
|
|
"usage: check_lockfile_release_scope.py <pr-title> (changed files on stdin)",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(2)
|
|
pr_title = sys.argv[1]
|
|
changed_files = [line.strip() for line in sys.stdin if line.strip()]
|
|
raise SystemExit(main(pr_title, changed_files))
|