Adds a changed-charts script to detect charts needing release (#892)

* Adds a changed-charts script to detect charts needing release

Shows charts where a release needs to be made, and in what order.

Example:
$ ./scripts/changed-charts.py
spire-lib
spire-identity-exchange
spire
spire-nested

Signed-off-by: Kevin Fox <[email protected]>

* Add deps that will change

Signed-off-by: Kevin Fox <[email protected]>

---------

Signed-off-by: Kevin Fox <[email protected]>
Co-authored-by: Faisal Memon <[email protected]>
This commit is contained in:
kfox1111
2026-07-30 12:17:45 -07:00
committed by GitHub
co-authored by Faisal Memon
parent c0bee5ee15
commit 2d92b81ba4
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Detect top-level charts needing release and order them dependencies-first.
A companion to chart-graph.py / release-chart.sh. Given the repo as-is, this
reports which top-level charts under the charts root need releasing and prints
them so that a chart always appears after the charts it depends on. The first
entries are charts with no local dependencies.
Change detection is per-chart and uses git release tags of the form
``<chart>-<X.Y.Z>`` (the same scheme as release-chart.sh's latest_chart_tag):
* no matching tag -> NEW
* commits touching charts/<chart> since tag -> CHANGED
* otherwise -> UNCHANGED
By default the output is the full release blast radius: every chart that
changed itself or is new, *plus* every chart that (transitively) depends on one
of those -- because releasing a base chart cascades version bumps to its
dependents (as release-chart.sh does). Those pulled-in charts are labelled
DEPENDENT. Pass --direct-only for just the charts with their own changes.
Notes:
* Requires local tags to be present/current. Pass --fetch-tags (or run
``git fetch --tags`` beforehand) if your local tags may be stale.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from ruamel.yaml import YAML
yaml = YAML(typ="safe")
NEW = "NEW"
CHANGED = "CHANGED"
DEPENDENT = "DEPENDENT"
UNCHANGED = "UNCHANGED"
@dataclass(frozen=True)
class Chart:
name: str
path: Path
# Names of other top-level charts this chart depends on (via file:// deps).
dependencies: tuple[str, ...]
def parse_args() -> argparse.Namespace:
"""Parse CLI arguments for the changed-chart lookup."""
parser = argparse.ArgumentParser(
description="Print top-level charts needing release, dependencies first."
)
parser.add_argument(
"--charts-root",
default="charts",
help="Path to the charts root directory (default: charts)",
)
parser.add_argument(
"--output",
choices=("names", "detail"),
default="names",
help="Output format (default: names).",
)
parser.add_argument(
"--direct-only",
action="store_true",
help="Only charts with their own changes/newness; skip the dependents expansion.",
)
parser.add_argument(
"--no-status",
action="store_true",
help="In --output detail, omit the status column.",
)
parser.add_argument(
"--all",
action="store_true",
help="Include UNCHANGED charts too (full topo-ordered inventory).",
)
parser.add_argument(
"--fetch-tags",
action="store_true",
help="Run 'git fetch --tags' before detecting changes.",
)
return parser.parse_args()
def main() -> int:
"""Detect changed/new top-level charts and print them dependencies-first."""
args = parse_args()
charts_root = Path(args.charts_root).resolve()
if not charts_root.is_dir():
print(f"Charts root not found: {charts_root}", file=sys.stderr)
return 1
if args.fetch_tags:
run_git(["fetch", "--tags"], charts_root)
charts = discover_charts(charts_root)
if not charts:
print(f"No charts found under {charts_root}", file=sys.stderr)
return 1
dependents = build_dependents(charts)
ordered = topological_order(charts, dependents)
status = {
name: chart_status(charts[name], charts_root) for name in ordered
}
# Seed with charts that changed themselves / are new, then (unless
# --direct-only) expand to every chart that transitively depends on the
# seed -- the full set a release will cascade-bump.
seed = {name for name, state in status.items() if state in (NEW, CHANGED)}
release_set = seed if args.direct_only else dependents_closure(seed, dependents)
for name in ordered:
if args.all:
pass
elif name not in release_set:
continue
label = classify(name, status[name], release_set, charts)
if args.output == "detail":
deps = ", ".join(charts[name].dependencies) or "-"
if args.no_status:
print(f"{name}\tdeps: {deps}")
else:
print(f"{name}\t{label}\tdeps: {deps}")
else:
print(name)
return 0
def classify(
name: str, status: str, release_set: set[str], charts: dict[str, Chart]
) -> str:
"""Label a chart by why it is being released (own change vs. dependency)."""
if status in (NEW, CHANGED):
return status
if name in release_set:
via = [dep for dep in charts[name].dependencies if dep in release_set]
return f"{DEPENDENT} (via {', '.join(via)})" if via else DEPENDENT
return status
def dependents_closure(
seed: set[str], dependents: dict[str, set[str]]
) -> set[str]:
"""Expand a seed set to include every chart that transitively depends on it."""
closure = set(seed)
stack = list(seed)
while stack:
current = stack.pop()
for dependent in dependents.get(current, ()):
if dependent not in closure:
closure.add(dependent)
stack.append(dependent)
return closure
def parse_dependency_owners(
chart_yaml: Path, chart_dir: Path, charts_root: Path
) -> tuple[str, ...]:
"""Resolve a chart's file:// dependencies to owning top-level chart names."""
with chart_yaml.open() as fp:
data = yaml.load(fp)
if not isinstance(data, dict):
raise ValueError(f"Could not parse chart at {chart_yaml}")
self_name = chart_dir.name
owners: list[str] = []
for dependency in data.get("dependencies", []) or []:
if not isinstance(dependency, dict):
continue
repository = str(dependency.get("repository", ""))
if not repository.startswith("file://"):
continue
target = (chart_dir / repository.removeprefix("file://")).resolve()
owner = top_level_owner(target, charts_root)
if owner is not None and owner != self_name and owner not in owners:
owners.append(owner)
return tuple(owners)
def top_level_owner(target: Path, charts_root: Path) -> str | None:
"""Map a resolved path to the top-level chart directory that contains it."""
try:
relative = target.relative_to(charts_root)
except ValueError:
return None
if not relative.parts:
return None
return relative.parts[0]
def discover_charts(charts_root: Path) -> dict[str, Chart]:
"""Discover every top-level chart (charts/*/) and index it by directory name."""
charts: dict[str, Chart] = {}
for chart_dir in sorted(p for p in charts_root.iterdir() if p.is_dir()):
chart_yaml = chart_dir / "Chart.yaml"
if not chart_yaml.is_file():
continue
name = chart_dir.name
dependencies = parse_dependency_owners(chart_yaml, chart_dir, charts_root)
charts[name] = Chart(
name=name, path=chart_dir.resolve(), dependencies=dependencies
)
return charts
def build_dependents(charts: dict[str, Chart]) -> dict[str, set[str]]:
"""Build the reverse map {chart -> set of charts that depend on it}."""
dependents: dict[str, set[str]] = {name: set() for name in charts}
for name, chart in charts.items():
for dep in chart.dependencies:
if dep in charts:
dependents[dep].add(name)
return dependents
def topological_order(
charts: dict[str, Chart], dependents: dict[str, set[str]]
) -> list[str]:
"""Order charts so each appears after its local dependencies (base-first)."""
remaining_deps = {
name: {dep for dep in chart.dependencies if dep in charts}
for name, chart in charts.items()
}
ready = sorted(name for name, deps in remaining_deps.items() if not deps)
order: list[str] = []
while ready:
current = ready.pop(0)
order.append(current)
for dependent in sorted(dependents[current]):
remaining_deps[dependent].discard(current)
if not remaining_deps[dependent]:
ready.append(dependent)
ready.sort()
if len(order) != len(charts):
cyclic = sorted(set(charts) - set(order))
raise ValueError(f"Dependency cycle detected among: {', '.join(cyclic)}")
return order
def chart_status(chart: Chart, charts_root: Path) -> str:
"""Classify a chart as NEW, CHANGED, or UNCHANGED against its latest tag."""
tag = latest_chart_tag(chart.name, charts_root)
if tag is None:
return NEW
# git resolves the pathspec relative to run_git's cwd (charts_root), so
# scope it to the chart directory name rather than a repo-root path.
chart_relpath = chart.path.relative_to(charts_root)
log = run_git(
["log", "--oneline", f"{tag}..HEAD", "--", str(chart_relpath)],
charts_root,
)
return CHANGED if log.strip() else UNCHANGED
def latest_chart_tag(chart_name: str, charts_root: Path) -> str | None:
"""Return the highest-versioned <chart>-X.Y.Z tag, or None if untagged."""
tags = run_git(
["tag", "--list", f"{chart_name}-[0-9]*.[0-9]*.[0-9]*"], charts_root
).split()
if not tags:
return None
return sorted(tags, key=_version_key)[-1]
def _version_key(tag: str) -> tuple[int, ...]:
"""Sort key that orders <chart>-X.Y.Z tags by numeric version."""
version = tag.rsplit("-", 1)[-1]
parts: list[int] = []
for component in version.split("."):
number = "".join(ch for ch in component if ch.isdigit())
parts.append(int(number) if number else 0)
return tuple(parts)
def run_git(git_args: list[str], cwd: Path) -> str:
"""Run a git command in the repo containing the charts root, return stdout."""
result = subprocess.run(
["git", *git_args],
cwd=cwd,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"git {' '.join(git_args)} failed: {result.stderr.strip()}"
)
return result.stdout
if __name__ == "__main__":
try:
sys.exit(main())
except (ValueError, RuntimeError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)