Skip to content

Commit edc2516

Browse files
authored
Release notes generation script (#1280)
* initial script Assisted-by: Cursor:composer-2.5 Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> * automatic prev tag resolution, arg aliases Assisted-by: Cursor:composer-2.5 Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> * wire notes generation into the patch bump action Assisted-by: Cursor:composer-2.5 Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> * add --attribution arg Assisted-by: Cursor:composer-2.5 Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> * address review comments, more arg aliases Assisted-by: Cursor:composer-2.5 Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> * retrigger action Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com> --------- Signed-off-by: Mózes László Máté <laszlo.mozes@nokia.com>
1 parent c5d1162 commit edc2516

2 files changed

Lines changed: 297 additions & 5 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env bash
2+
3+
# Copyright 2026 The kpt Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
# Generate GitHub-style release notes scoped to a single folder.
18+
#
19+
# Calls the GitHub Release Notes API, then keeps only bullets whose linked PR
20+
# also appears in git history for the folder (e.g. functions/go/set-namespace/).
21+
#
22+
# Prerequisites: bash 3.2+, gh (authenticated), git.
23+
#
24+
# Example:
25+
# scripts/generate-folder-release-notes.sh \
26+
# --function upsert-resource \
27+
# --new-tag v0.2.4
28+
29+
set -euo pipefail
30+
31+
GITHUB_REPOSITORY=${GITHUB_REPOSITORY:-"kptdev/krm-functions-catalog"}
32+
33+
usage() {
34+
cat <<'EOF'
35+
Usage: generate-folder-release-notes.sh [options]
36+
37+
Generate release notes for changes under functions/go/NAME/ by filtering the
38+
output of the GitHub Release Notes API.
39+
40+
Options:
41+
--function NAME Function name under functions/go/ (required)
42+
--previous-tag VERSION Previous release version (e.g. v0.2.3); default: latest
43+
semver tag for the function
44+
--new-tag VERSION New release version (required, e.g. v0.2.4)
45+
--ref REF Commitish for the new release (default: HEAD)
46+
--attribution Prepend a note that notes were auto-generated (uses gh user login)
47+
-o, --output FILE Write notes to FILE instead of stdout
48+
-h, --help Show this help
49+
50+
EOF
51+
}
52+
53+
fail() {
54+
echo "generate-folder-release-notes.sh: $*" >&2
55+
exit 1
56+
}
57+
58+
missing_value() {
59+
fail "missing value for $1"
60+
}
61+
62+
normalize_version() {
63+
local version="$1"
64+
if [[ "$version" =~ ^v ]]; then
65+
printf '%s' "$version"
66+
else
67+
printf 'v%s' "$version"
68+
fi
69+
}
70+
71+
function_tag() {
72+
local version
73+
version="$(normalize_version "$1")"
74+
printf 'functions/go/%s/%s' "$function_name" "$version"
75+
}
76+
77+
# Latest strict SemVer long tag for the function.
78+
latest_function_tag() {
79+
git tag -l "functions/go/${function_name}/v*" --sort=v:refname |
80+
grep -E -- '/v[0-9]+\.[0-9]+\.[0-9]+$' |
81+
tail -n 1 || true
82+
}
83+
84+
function_name=""
85+
previous_version=""
86+
new_version=""
87+
ref="HEAD"
88+
add_attribution=0
89+
output_file=""
90+
91+
while [[ $# -gt 0 ]]; do
92+
case "$1" in
93+
--function|--func)
94+
[[ $# -ge 2 ]] || missing_value "$1"
95+
function_name="$2"
96+
shift 2
97+
;;
98+
--previous-tag|--prev-tag|--prev)
99+
[[ $# -ge 2 ]] || missing_value "$1"
100+
previous_version="$2"
101+
shift 2
102+
;;
103+
--new-tag|--new|--next-tag|--next)
104+
[[ $# -ge 2 ]] || missing_value "$1"
105+
new_version="$2"
106+
shift 2
107+
;;
108+
--ref|--target|--target-ref)
109+
[[ $# -ge 2 ]] || missing_value "$1"
110+
ref="$2"
111+
shift 2
112+
;;
113+
--attribution)
114+
add_attribution=1
115+
shift
116+
;;
117+
-o | --output)
118+
[[ $# -ge 2 ]] || missing_value "$1"
119+
output_file="$2"
120+
shift 2
121+
;;
122+
-h | --help)
123+
usage
124+
exit 0
125+
;;
126+
*)
127+
fail "unknown argument: $1 (try --help)"
128+
;;
129+
esac
130+
done
131+
132+
if [[ -z "$function_name" ]]; then
133+
fail "--function is required (try --help)"
134+
fi
135+
if [[ -z "$new_version" ]]; then
136+
fail "--new-tag is required"
137+
fi
138+
139+
if [[ -z "$previous_version" ]]; then
140+
prev_long="$(latest_function_tag)"
141+
if [[ -z "$prev_long" ]]; then
142+
fail "no prior semver tag functions/go/${function_name}/vMAJOR.MINOR.PATCH; pass --previous-tag explicitly"
143+
fi
144+
previous_version="${prev_long##*/}"
145+
fi
146+
147+
previous_tag="$(function_tag "$previous_version")"
148+
new_tag="$(function_tag "$new_version")"
149+
folder_path="functions/go/${function_name}"
150+
151+
extract_pr_number() {
152+
local line="$1"
153+
if [[ "$line" =~ pull/([0-9]+) ]]; then
154+
printf '%s' "${BASH_REMATCH[1]}"
155+
fi
156+
}
157+
158+
extract_pr_number_from_subject() {
159+
local subject="$1"
160+
if [[ "$subject" =~ \(#([0-9]+)\)$ ]]; then
161+
printf '%s' "${BASH_REMATCH[1]}"
162+
elif [[ "$subject" =~ ^Merge[[:space:]]pull[[:space:]]request[[:space:]]#([0-9]+) ]]; then
163+
printf '%s' "${BASH_REMATCH[1]}"
164+
fi
165+
}
166+
167+
# Discover PRs that touched the folder via git history (newline-separated, sorted).
168+
folder_pr_numbers="$(
169+
git log "${previous_tag}..${ref}" --format='%s' -- "${folder_path}/" |
170+
while IFS= read -r subject; do
171+
[[ -z "$subject" ]] && continue
172+
pr_number="$(extract_pr_number_from_subject "$subject")"
173+
[[ -n "$pr_number" ]] || continue
174+
printf '%s\n' "$pr_number"
175+
done | sort -nu
176+
)"
177+
178+
pr_in_folder() {
179+
local pr="$1"
180+
[[ -n "$folder_pr_numbers" ]] || return 1
181+
printf '%s\n' "$folder_pr_numbers" | grep -Fqx "$pr"
182+
}
183+
184+
filter_bullet_line() {
185+
local line="$1"
186+
local pr_number
187+
188+
pr_number="$(extract_pr_number "$line")"
189+
[[ -n "$pr_number" ]] || return 1
190+
pr_in_folder "$pr_number"
191+
}
192+
193+
parse_and_filter_notes() {
194+
local body="$1"
195+
local section=""
196+
local whats_changed=()
197+
local new_contributors=()
198+
local full_changelog=""
199+
200+
while IFS= read -r line || [[ -n "$line" ]]; do
201+
case "$line" in
202+
"## What's Changed")
203+
section="whats_changed"
204+
continue
205+
;;
206+
"## New Contributors")
207+
section="new_contributors"
208+
continue
209+
;;
210+
"**Full Changelog"*)
211+
full_changelog="$line"
212+
section=""
213+
continue
214+
;;
215+
esac
216+
217+
if [[ "$line" == "* "* ]]; then
218+
if filter_bullet_line "$line"; then
219+
case "$section" in
220+
whats_changed) whats_changed+=("$line") ;;
221+
new_contributors) new_contributors+=("$line") ;;
222+
esac
223+
fi
224+
fi
225+
done <<< "$body"
226+
227+
printf '%s\n' "## What's Changed"
228+
if [[ ${#whats_changed[@]} -eq 0 ]]; then
229+
printf "* No changes under \`%s/\` in this range.\n" "$folder_path"
230+
else
231+
printf '%s\n' "${whats_changed[@]}"
232+
fi
233+
234+
if [[ ${#new_contributors[@]} -gt 0 ]]; then
235+
printf '\n%s\n' "## New Contributors"
236+
printf '%s\n' "${new_contributors[@]}"
237+
fi
238+
239+
if [[ -n "$full_changelog" ]]; then
240+
printf '\n%s\n' "$full_changelog"
241+
fi
242+
}
243+
244+
gh_user_login() {
245+
gh api user --jq .login 2>/dev/null || true
246+
}
247+
248+
prepend_attribution() {
249+
local notes="$1"
250+
local login
251+
252+
login="$(gh_user_login)"
253+
if [[ -n "$login" ]]; then
254+
printf "> **Note:** These release notes were auto-generated by @%s and include only changes under \`%s/\`.\n\n%s\n" \
255+
"$login" "$folder_path" "$notes"
256+
else
257+
printf "> **Note:** These release notes were auto-generated and include only changes under \`%s/\`.\n\n%s\n" \
258+
"$folder_path" "$notes"
259+
fi
260+
}
261+
262+
notes_body="$(
263+
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
264+
-f tag_name="$new_tag" \
265+
-f previous_tag_name="$previous_tag" \
266+
-f target_commitish="$ref" \
267+
--jq .body
268+
)"
269+
270+
filtered_notes="$(parse_and_filter_notes "$notes_body")"
271+
272+
if [[ "$add_attribution" -eq 1 ]]; then
273+
filtered_notes="$(prepend_attribution "$filtered_notes")"
274+
fi
275+
276+
if [[ -n "$output_file" ]]; then
277+
printf '%s\n' "$filtered_notes" > "$output_file"
278+
else
279+
printf '%s\n' "$filtered_notes"
280+
fi

scripts/manual-patch-release.sh

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616

1717
# Manual patch release for functions/go KRM functions (used by GitHub Actions and runnable locally).
1818
#
19-
# Creates a GitHub Release (and the semver tag on the server at GITHUB_SHA) via `gh`. Image build,
20-
# short tag creation, and appending the container image to release notes are left to workflows
19+
# Creates a GitHub Release (and the semver tag on the server at GITHUB_SHA) via `gh`.
20+
# Release notes are scoped to functions/go/<name>/ via generate-folder-release-notes.sh.
21+
# Image build, short tag creation, and appending the container image to release notes are left to workflows
2122
# that run on tag push (e.g. after-tag-with-version.yaml, release.yaml). Use a GitHub App
2223
# installation access token or PAT for GH_TOKEN in CI so those workflows are triggered;
2324
# GITHUB_TOKEN-created tag/release events do not start them.
@@ -147,7 +148,8 @@ for fn in "${functions[@]}"; do
147148
echo "Previous: ${prev_long} -> Next: ${long_tag}"
148149

149150
if [[ "$dry_run" == "true" ]]; then
150-
echo "(dry_run) would run: gh release create \"${long_tag}\" --repo \"${repo}\" --target \"${sha}\" --title \"${release_title}\" --generate-notes --notes-start-tag \"${prev_long}\""
151+
echo "(dry_run) would run: GITHUB_REPOSITORY=\"${repo}\" \"${scripts_dir}/generate-folder-release-notes.sh\" --function \"${fn}\" --previous-tag \"${ver}\" --new-tag \"${next_ver}\" --ref \"${sha}\" --attribution -o <notes-file>"
152+
echo "(dry_run) would run: gh release create \"${long_tag}\" --repo \"${repo}\" --target \"${sha}\" --title \"${release_title}\" --notes-file <notes-file>"
151153
continue
152154
fi
153155

@@ -156,10 +158,20 @@ for fn in "${functions[@]}"; do
156158
exit 1
157159
fi
158160

161+
notes_file="$(mktemp)"
162+
GITHUB_REPOSITORY="${repo}" "${scripts_dir}/generate-folder-release-notes.sh" \
163+
--function "${fn}" \
164+
--previous-tag "${ver}" \
165+
--new-tag "${next_ver}" \
166+
--ref "${sha}" \
167+
--attribution \
168+
-o "${notes_file}"
169+
159170
gh release create "${long_tag}" \
160171
--repo "${repo}" \
161172
--target "${sha}" \
162173
--title "${release_title}" \
163-
--generate-notes \
164-
--notes-start-tag "${prev_long}"
174+
--notes-file "${notes_file}"
175+
176+
rm -f "${notes_file}"
165177
done

0 commit comments

Comments
 (0)