Automate release bumps from the current branch (#831)

* Automate dependent chart release bumps

Signed-off-by: Faisal Memon <[email protected]>

* Use ruamel for chart dependency parsing

Signed-off-by: Faisal Memon <[email protected]>

* Simplify dependent chart graph helper

Signed-off-by: Faisal Memon <[email protected]>

* Add current-branch mode to release automation

Signed-off-by: Faisal Memon <[email protected]>

* Polish chart dependency helper

Signed-off-by: Faisal Memon <[email protected]>

---------

Signed-off-by: Faisal Memon <[email protected]>
This commit is contained in:
Faisal Memon
2026-05-31 05:29:55 -07:00
committed by GitHub
parent ab3164d48e
commit a40409ec53
2 changed files with 440 additions and 32 deletions
+250 -27
View File
@@ -6,23 +6,27 @@
## ##
## Usage example(s): ## Usage example(s):
## ##
## ./__PROG__ --chart spire --new-version 0.16.0 ## ./__PROG__ --chart spire --bump patch
## ./__PROG__ --chart spire-crds --new-version 0.3.0 ## ./__PROG__ --chart spire-crds --bump minor
## ##
## Options: ## Options:
## --help Show this help message ## --help Show this help message
## --chart The chart to release ## --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 ## --dry-run Will not actually submit the PR
## ##
## Prerequisites: ## Prerequisites:
## - gsed (MacOS) ## - gsed (MacOS)
## - git ## - git
## - helm
## - GitHub CLI (gh) ## - GitHub CLI (gh)
## - npm (if readme-generator is not already installed)
## - yq
## ##
## Commands ## 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") me=$(basename "$0")
function usage { function usage {
@@ -39,10 +43,22 @@ function print_error_and_exit {
exit 1 exit 1
} }
function require_command {
command -v "$1" >/dev/null 2>&1 || {
print_error_and_exit "$2"
}
}
function unreleased_changes_other_charts { function unreleased_changes_other_charts {
local chart latest_tag changes
for chart in "$@" ; do for chart in "$@" ; do
latest_tag="$(git --no-pager tag --list "${chart}-[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1)" latest_tag="$(latest_chart_tag "${chart}")"
changes="$(git --no-pager log "${latest_tag}..HEAD" --pretty=format:'* %h %s' "charts/${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 if [ -n "${changes}" ] ; then
echo "### Unreleased changes ${chart}" echo "### Unreleased changes ${chart}"
echo 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 Please ensure you bump above charts as well before merging main into the release branch.
echo echo
echo '```shell' echo '```shell'
echo ./release-chart.sh --chart "${chart}" --new-version ……… echo ./release-chart.sh --chart "${chart}" --bump patch
echo '```' echo '```'
fi fi
done 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 while (("$#")); do
case "$1" in case "$1" in
--help) --help)
@@ -67,10 +196,14 @@ while (("$#")); do
chart=$2 chart=$2
shift 2 shift 2
;; ;;
--new-version) --bump)
new_version=$2 bump_type=$2
shift 2 shift 2
;; ;;
--from-current-branch)
from_current_branch='true'
shift 1
;;
--dry-run) --dry-run)
dry_run='-w' dry_run='-w'
shift 1 shift 1
@@ -82,14 +215,13 @@ while (("$#")); do
esac esac
done done
command -v gh >/dev/null 2>&1 || { require_command gh 'the GitHub cli (gh) is required to run this script'
print_error_and_exit '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 if [[ $OSTYPE == "darwin"* ]]; then
command -v gsed >/dev/null 2>&1 || { require_command gsed 'gsed is required to run this script'
print_error_and_exit 'gsed is required to run this script'
}
SED='gsed' SED='gsed'
else else
SED='sed' SED='sed'
@@ -100,28 +232,105 @@ if [ -z "$chart" ]; then
print_error_and_exit 'chart option is missing' print_error_and_exit 'chart option is missing'
fi fi
if [ -z "$new_version" ]; then if [ -z "$bump_type" ]; then
usage usage
print_error_and_exit 'new-version option is missing' print_error_and_exit 'bump option is missing'
fi fi
if [ ! -f "charts/${chart}/Chart.yaml" ] ; then if [ ! -f "charts/${chart}/Chart.yaml" ] ; then
print_error_and_exit "no chart named '${chart}' in charts folder" print_error_and_exit "no chart named '${chart}' in charts folder"
fi 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 fetch --tags
git checkout main git checkout main
git pull git pull
git checkout --track -B "${branch_name}" main git checkout --track -B "${branch_name}" main
fi
current_version="$(grep '^version:' "charts/${chart}/Chart.yaml" | awk '{print $2}')" 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}")" new_version="$(bump_version "${current_version}" "${bump_type}")"
"${SED}" -i "s/version: ${current_version}/version: ${new_version}/" "charts/${chart}/Chart.yaml" 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" "${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}" \ -m "${commits_since_previous_release}" \
-s -s
git push -u origin --force-with-lease git push -u origin --force-with-lease
@@ -154,6 +363,14 @@ $(unreleased_changes_other_charts "${other_charts[@]}")
> git push > 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 ## Changes in this release
${commits_since_previous_release} ${commits_since_previous_release}
@@ -161,11 +378,15 @@ EOF
if [ -n "${dry_run}" ] ; then if [ -n "${dry_run}" ] ; then
echo >&2 echo >&2
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 "If you choose not to submit the PR please run following commands to cleanup the branch:"
echo >&2 echo >&2
echo >&2 " git checkout main" echo >&2 " git checkout main"
echo >&2 " git push origin :${branch_name}" echo >&2 " git push origin :${branch_name}"
echo >&2 " git branch -D ${branch_name}" echo >&2 " git branch -D ${branch_name}"
fi
echo >&2 echo >&2
echo >&2 'If you choose to submit the PR, please run following:' echo >&2 'If you choose to submit the PR, please run following:'
echo >&2 echo >&2
@@ -174,4 +395,6 @@ if [ -n "${dry_run}" ] ; then
fi fi
gh pr merge --auto -r -d gh pr merge --auto -r -d
git checkout main if [ -z "${from_current_branch}" ] ; then
git checkout main
fi
+185
View File
@@ -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())