From a40409ec5332638ef190a7c7d112be5236c86ee1 Mon Sep 17 00:00:00 2001 From: Faisal Memon Date: Sun, 31 May 2026 05:29:55 -0700 Subject: [PATCH] Automate release bumps from the current branch (#831) * Automate dependent chart release bumps Signed-off-by: Faisal Memon * Use ruamel for chart dependency parsing Signed-off-by: Faisal Memon * Simplify dependent chart graph helper Signed-off-by: Faisal Memon * Add current-branch mode to release automation Signed-off-by: Faisal Memon * Polish chart dependency helper Signed-off-by: Faisal Memon --------- Signed-off-by: Faisal Memon --- release-chart.sh | 287 ++++++++++++++++++++++++++++++++++++----- scripts/chart-graph.py | 185 ++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 32 deletions(-) create mode 100644 scripts/chart-graph.py diff --git a/release-chart.sh b/release-chart.sh index bbb0714..1698ea1 100755 --- a/release-chart.sh +++ b/release-chart.sh @@ -6,23 +6,27 @@ ## ## Usage example(s): ## -## ./__PROG__ --chart spire --new-version 0.16.0 -## ./__PROG__ --chart spire-crds --new-version 0.3.0 +## ./__PROG__ --chart spire --bump patch +## ./__PROG__ --chart spire-crds --bump minor ## ## Options: ## --help Show this help message ## --chart The chart to release -## --new-version The new version number +## --bump The semantic version bump type: major, minor, or patch +## --from-current-branch Apply the release bump on the current branch instead of recreating a bump branch from main ## --dry-run Will not actually submit the PR ## ## Prerequisites: ## - gsed (MacOS) ## - git +## - helm ## - GitHub CLI (gh) +## - npm (if readme-generator is not already installed) +## - yq ## ## Commands ## -## ./__PROG__ --chart «chart» --current-version «current-version» --new-version «new-version» [--dry-run] +## ./__PROG__ --chart «chart» --bump «major|minor|patch» [--from-current-branch] [--dry-run] me=$(basename "$0") function usage { @@ -39,10 +43,22 @@ function print_error_and_exit { exit 1 } +function require_command { + command -v "$1" >/dev/null 2>&1 || { + print_error_and_exit "$2" + } +} + function unreleased_changes_other_charts { + local chart latest_tag changes + for chart in "$@" ; do - latest_tag="$(git --no-pager tag --list "${chart}-[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1)" - changes="$(git --no-pager log "${latest_tag}..HEAD" --pretty=format:'* %h %s' "charts/${chart}")" + latest_tag="$(latest_chart_tag "${chart}")" + if [ -n "${latest_tag}" ] ; then + changes="$(git --no-pager log "${latest_tag}..HEAD" --pretty=format:'* %h %s' -- "charts/${chart}")" + else + changes="$(git --no-pager log --pretty=format:'* %h %s' -- "charts/${chart}")" + fi if [ -n "${changes}" ] ; then echo "### Unreleased changes ${chart}" echo @@ -51,12 +67,125 @@ function unreleased_changes_other_charts { echo Please ensure you bump above charts as well before merging main into the release branch. echo echo '```shell' - echo ./release-chart.sh --chart "${chart}" --new-version ……… + echo ./release-chart.sh --chart "${chart}" --bump patch echo '```' fi done } +function latest_chart_tag { + local chart_name=$1 + + git --no-pager tag --list "${chart_name}-[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1 +} + +function bump_version { + local current_version=$1 + local bump_type=$2 + local major minor patch + + IFS=. read -r major minor patch <<< "${current_version}" + + if [[ -z "${major}" || -z "${minor}" || -z "${patch}" ]]; then + print_error_and_exit "invalid semantic version '${current_version}'" + fi + + case "${bump_type}" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + *) + print_error_and_exit "invalid bump type '${bump_type}'" + ;; + esac + + echo "${major}.${minor}.${patch}" +} + +function update_dependency_version { + local chart_yaml=$1 + local dependency_chart=$2 + local dependency_version=$3 + + DEPENDENCY_CHART="${dependency_chart}" DEPENDENCY_VERSION="${dependency_version}" \ + yq e 'with(.dependencies[]? | select(.name == strenv(DEPENDENCY_CHART)); .version = strenv(DEPENDENCY_VERSION))' -i "${chart_yaml}" +} + +function update_chart_version { + local chart_name=$1 + local dependency_version=$2 + local chart_yaml="charts/${chart_name}/Chart.yaml" + + TARGET_VERSION="${dependency_version}" \ + yq e '.version = strenv(TARGET_VERSION)' -i "${chart_yaml}" +} + +function ensure_readme_generator { + local readme_generator_version="2.6.0" + local readme_generator_exe="readme-generator" + + if ! hash "${readme_generator_exe}" 2>/dev/null; then + echo >&2 "${readme_generator_exe} not installed. Installing..." + require_command npm "npm is required to install ${readme_generator_exe}. Please install npm and rerun the script." + npm install -g "@bitnami/readme-generator-for-helm@${readme_generator_version}" + fi +} + +function refresh_chart_docs { + local chart_dir + + ensure_readme_generator + + for chart_dir in "$@" ; do + if [ -f "${chart_dir}/values.yaml" ] && [ -f "${chart_dir}/README.md" ] ; then + echo >&2 "Generating Chart documentation for ${chart_dir}…" + readme-generator --values="${chart_dir}/values.yaml" --readme="${chart_dir}/README.md" + fi + done +} + +function chart_has_remote_dependencies { + local chart_yaml=$1 + local remote_count + + remote_count="$(yq e '[.dependencies[]? | select(((.repository // "") | test("^file://")) | not)] | length' "${chart_yaml}")" + [ "${remote_count}" -gt 0 ] +} + +function refresh_chart_dependencies { + local chart_dir + local refreshed_repos='' + + for chart_dir in "$@" ; do + if chart_has_remote_dependencies "${chart_dir}/Chart.yaml" && [ -z "${refreshed_repos}" ] ; then + helm repo update + refreshed_repos='true' + fi + helm dependency update --skip-refresh "${chart_dir}" + done +} + +function collect_dependent_charts { + local root_chart=$1 + + python3 scripts/chart-graph.py --chart "${root_chart}" --output names +} + +function get_chart_version { + local chart_name=$1 + + yq e '.version' "charts/${chart_name}/Chart.yaml" +} + while (("$#")); do case "$1" in --help) @@ -67,10 +196,14 @@ while (("$#")); do chart=$2 shift 2 ;; - --new-version) - new_version=$2 + --bump) + bump_type=$2 shift 2 ;; + --from-current-branch) + from_current_branch='true' + shift 1 + ;; --dry-run) dry_run='-w' shift 1 @@ -82,14 +215,13 @@ while (("$#")); do esac done -command -v gh >/dev/null 2>&1 || { - print_error_and_exit 'the GitHub cli (gh) is required to run this script' -} +require_command gh 'the GitHub cli (gh) is required to run this script' +require_command helm 'helm is required to run this script' +require_command yq 'yq is required to run this script' +require_command python3 'python3 is required to run this script' if [[ $OSTYPE == "darwin"* ]]; then - command -v gsed >/dev/null 2>&1 || { - print_error_and_exit 'gsed is required to run this script' - } + require_command gsed 'gsed is required to run this script' SED='gsed' else SED='sed' @@ -100,28 +232,105 @@ if [ -z "$chart" ]; then print_error_and_exit 'chart option is missing' fi -if [ -z "$new_version" ]; then +if [ -z "$bump_type" ]; then usage - print_error_and_exit 'new-version option is missing' + print_error_and_exit 'bump option is missing' fi if [ ! -f "charts/${chart}/Chart.yaml" ] ; then print_error_and_exit "no chart named '${chart}' in charts folder" fi -branch_name="bump-${chart}-version" +if [ -n "${from_current_branch}" ] ; then + branch_name="$(git branch --show-current)" + if [ -z "${branch_name}" ] ; then + print_error_and_exit 'unable to determine current branch; please checkout a branch before using --from-current-branch' + fi +else + branch_name="bump-${chart}-version" -git fetch --tags -git checkout main -git pull -git checkout --track -B "${branch_name}" main + git fetch --tags + git checkout main + git pull + git checkout --track -B "${branch_name}" main +fi current_version="$(grep '^version:' "charts/${chart}/Chart.yaml" | awk '{print $2}')" -commits_since_previous_release="$(git log "${chart}-${current_version}..HEAD" --pretty=format:'* %h %s' "charts/${chart}")" -"${SED}" -i "s/version: ${current_version}/version: ${new_version}/" "charts/${chart}/Chart.yaml" +new_version="$(bump_version "${current_version}" "${bump_type}")" +release_base_tag="$(latest_chart_tag "${chart}")" +if [ -n "${release_base_tag}" ] ; then + commits_since_previous_release="$(git log "${release_base_tag}..HEAD" --pretty=format:'* %h %s' -- "charts/${chart}")" +else + commits_since_previous_release="$(git log --pretty=format:'* %h %s' -- "charts/${chart}")" +fi +update_chart_version "${chart}" "${new_version}" "${SED}" -i "s/${current_version}/${new_version}/g" "charts/${chart}/README.md" -git add "charts/${chart}/"{Chart.yaml,README.md} -git commit -m "Bump ${chart} Helm Chart version from ${current_version} to ${new_version}" \ + +updated_dependency_charts=() +updated_chart_versions=() +release_charts=("${chart}") +while IFS= read -r dependent_chart ; do + if [ -z "${dependent_chart}" ] ; then + continue + fi + + chart_yaml="charts/${dependent_chart}/Chart.yaml" + if [ ! -f "${chart_yaml}" ] ; then + print_error_and_exit "dependent chart '${dependent_chart}' does not have a Chart.yaml at ${chart_yaml}" + fi + + dependent_current_version="$(grep '^version:' "${chart_yaml}" | awk '{print $2}')" + dependent_new_version="$(bump_version "${dependent_current_version}" "${bump_type}")" + update_chart_version "${dependent_chart}" "${dependent_new_version}" + update_dependency_version "${chart_yaml}" "${chart}" "${new_version}" + updated_dependency_charts+=("charts/${dependent_chart}") + updated_chart_versions+=("${dependent_chart}:${dependent_current_version}:${dependent_new_version}") + release_charts+=("${dependent_chart}") +done < <(collect_dependent_charts "${chart}") + +unique_dependency_charts=() +while IFS= read -r chart_dir ; do + if [ -n "${chart_dir}" ] ; then + unique_dependency_charts+=("${chart_dir}") + fi +done < <(printf '%s\n' "${updated_dependency_charts[@]}" | sort -u) + +unique_release_charts=() +while IFS= read -r chart_name ; do + if [ -n "${chart_name}" ] ; then + unique_release_charts+=("${chart_name}") + fi +done < <(printf '%s\n' "${release_charts[@]}" | sort -u) + +for chart_name in "${unique_release_charts[@]}" ; do + chart_version="$(get_chart_version "${chart_name}")" + for chart_dir in "${unique_dependency_charts[@]}" ; do + update_dependency_version "${chart_dir}/Chart.yaml" "${chart_name}" "${chart_version}" + done +done + +refresh_chart_dependencies "${unique_dependency_charts[@]}" + +refresh_chart_docs "charts/${chart}" "${unique_dependency_charts[@]}" + +if [ -n "${dry_run}" ] && [ -n "${from_current_branch}" ] ; then + echo >&2 + echo >&2 "Dry run completed on the current branch (${branch_name})." + echo >&2 "Inspect the working tree diff before deciding what to keep." + exit 0 +fi + +git add release-chart.sh "charts/${chart}/"{Chart.yaml,README.md} +for chart_dir in "${unique_dependency_charts[@]}" ; do + git add "${chart_dir}/Chart.yaml" + if [ -f "${chart_dir}/Chart.lock" ] ; then + git add "${chart_dir}/Chart.lock" + fi + if [ -f "${chart_dir}/README.md" ] ; then + git add "${chart_dir}/README.md" + fi +done +git commit -m "Bump ${chart} and dependent Helm Chart versions (${bump_type})" \ -m "${commits_since_previous_release}" \ -s git push -u origin --force-with-lease @@ -154,6 +363,14 @@ $(unreleased_changes_other_charts "${other_charts[@]}") > git push > \`\`\` +## Release set + +- ${chart}: ${current_version} -> ${new_version} +$(for version_update in "${updated_chart_versions[@]}" ; do + IFS=: read -r dependent_chart dependent_current_version dependent_new_version <<< "${version_update}" + echo "- ${dependent_chart}: ${dependent_current_version} -> ${dependent_new_version}" +done) + ## Changes in this release ${commits_since_previous_release} @@ -161,11 +378,15 @@ EOF if [ -n "${dry_run}" ] ; then echo >&2 - echo >&2 "If you choose not to submit the PR please run following commands to cleanup the branch:" - echo >&2 - echo >&2 " git checkout main" - echo >&2 " git push origin :${branch_name}" - echo >&2 " git branch -D ${branch_name}" + if [ -n "${from_current_branch}" ] ; then + echo >&2 "Dry run completed on the current branch (${branch_name}). Inspect the branch diff before deciding what to keep." + else + echo >&2 "If you choose not to submit the PR please run following commands to cleanup the branch:" + echo >&2 + echo >&2 " git checkout main" + echo >&2 " git push origin :${branch_name}" + echo >&2 " git branch -D ${branch_name}" + fi echo >&2 echo >&2 'If you choose to submit the PR, please run following:' echo >&2 @@ -174,4 +395,6 @@ if [ -n "${dry_run}" ] ; then fi gh pr merge --auto -r -d -git checkout main +if [ -z "${from_current_branch}" ] ; then + git checkout main +fi diff --git a/scripts/chart-graph.py b/scripts/chart-graph.py new file mode 100644 index 0000000..281dc2e --- /dev/null +++ b/scripts/chart-graph.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from pathlib import Path + +from ruamel.yaml import YAML + + +yaml = YAML(typ="safe") + + +@dataclass(frozen=True) +class Dependency: + name: str + repository: str + + +@dataclass(frozen=True) +class Chart: + name: str + path: Path + dependencies: tuple[Dependency, ...] + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments for the root chart lookup.""" + parser = argparse.ArgumentParser( + description="Print charts that depend on a given root chart." + ) + parser.add_argument( + "--chart", + required=True, + help="Chart name whose dependent chart closure should be printed.", + ) + parser.add_argument( + "--charts-root", + default="charts", + help="Path to the charts root directory (default: charts)", + ) + parser.add_argument( + "--output", + choices=("names", "print-graph"), + default="names", + help="Output format (default: names).", + ) + return parser.parse_args() + + +def main() -> int: + """Execute the dependent-chart lookup and print the selected output format.""" + args = parse_args() + charts_root = Path(args.charts_root).resolve() + charts = discover_charts(charts_root) + + if args.chart not in charts: + print(f"Unknown chart: {args.chart}", file=sys.stderr) + return 1 + + reverse_dependencies = build_reverse_dependencies(charts) + dependents = find_dependents(args.chart, reverse_dependencies) + + if args.output == "print-graph": + print(f"Dependents of {args.chart}:") + if not dependents: + print(" (none)") + else: + for dependent in dependents: + relpath = charts[dependent].path.relative_to(charts_root.parent) + print(f" {dependent} [{relpath}]") + else: + for dependent in dependents: + print(dependent) + + return 0 + + +def parse_chart_yaml(chart_yaml: Path) -> Chart: + """Read one Chart.yaml file into the lightweight Chart structure.""" + with chart_yaml.open() as fp: + data = yaml.load(fp) + + if not isinstance(data, dict) or "name" not in data: + raise ValueError(f"Could not find chart name in {chart_yaml}") + + dependencies: list[Dependency] = [] + for dependency in data.get("dependencies", []) or []: + if not isinstance(dependency, dict) or "name" not in dependency: + continue + dependencies.append( + Dependency( + name=str(dependency["name"]), + repository=str(dependency.get("repository", "")), + ) + ) + + return Chart( + name=str(data["name"]), + path=chart_yaml.parent.resolve(), + dependencies=tuple(dependencies), + ) + + +def discover_charts(charts_root: Path) -> dict[str, Chart]: + """Discover every chart under the charts root and index them by chart name.""" + charts: dict[str, Chart] = {} + for chart_yaml in sorted(charts_root.rglob("Chart.yaml")): + chart = parse_chart_yaml(chart_yaml) + if chart.name in charts: + raise ValueError(f"Duplicate chart name detected: {chart.name}") + charts[chart.name] = chart + return charts + + +def resolve_local_dependency( + source_chart: Chart, dependency: Dependency, charts: dict[str, Chart] +) -> str | None: + """Resolve a file:// dependency reference back to a known local chart name.""" + if not dependency.repository.startswith("file://"): + return None + + dependency_path = ( + source_chart.path / dependency.repository.removeprefix("file://") + ).resolve() + chart_yaml = dependency_path / "Chart.yaml" + if not chart_yaml.exists(): + return None + + for chart_name, chart in charts.items(): + if chart.path == dependency_path: + return chart_name + return None + + +def build_reverse_dependencies(charts: dict[str, Chart]) -> dict[str, set[str]]: + """Build a reverse dependency index for walking from a chart to its dependents.""" + reverse_dependencies: dict[str, set[str]] = { + chart_name: set() for chart_name in charts + } + + for chart_name, chart in charts.items(): + for dependency in chart.dependencies: + dependency_name = resolve_local_dependency(chart, dependency, charts) + if dependency_name is not None: + reverse_dependencies[dependency_name].add(chart_name) + + return reverse_dependencies + + +def find_dependents(root_chart: str, reverse_dependencies: dict[str, set[str]]) -> list[str]: + """Traverse the reverse dependency graph and fail fast on reachable cycles.""" + dependents: list[str] = [] + visited: set[str] = set() + on_stack: set[str] = {root_chart} + stack: list[tuple[str, list[str]]] = [ + (root_chart, sorted(reverse_dependencies[root_chart])) + ] + + while stack: + current, children = stack[-1] + if not children: + on_stack.remove(current) + stack.pop() + continue + + child = children.pop(0) + if child in on_stack: + cycle = " -> ".join([item[0] for item in stack] + [child]) + raise ValueError(f"Dependency cycle detected: {cycle}") + if child in visited: + continue + + visited.add(child) + dependents.append(child) + on_stack.add(child) + stack.append((child, sorted(reverse_dependencies[child]))) + + return dependents + + +if __name__ == "__main__": + sys.exit(main())