From 855c3e80a1e047efeb4799a2cb25ed144a82bcd8 Mon Sep 17 00:00:00 2001 From: Johannes Aubart Date: Wed, 3 Sep 2025 17:56:59 +0200 Subject: [PATCH] improve release note generation --- generate-changelog.sh | 51 +++++++++++++++++-------- release-notes-to-json.sh | 80 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 16 deletions(-) create mode 100755 release-notes-to-json.sh diff --git a/generate-changelog.sh b/generate-changelog.sh index 5912c55..c3f868c 100755 --- a/generate-changelog.sh +++ b/generate-changelog.sh @@ -2,12 +2,15 @@ set -euo pipefail +echo "Generating changelog for version: $VERSION" + # Ensure gh CLI available if ! command -v gh &> /dev/null; then echo "gh CLI is required but not installed." >&2 exit 1 fi +RELEASE_NOTES_TO_JSON_SCRIPT="$(realpath "$(dirname $0)/release-notes-to-json.sh")" cd $(dirname "$0")/../../ LATEST_RELEASE_TAG=$(gh release list --json tagName,isLatest --jq '.[] | select(.isLatest)|.tagName') @@ -15,7 +18,7 @@ if [[ -z "$LATEST_RELEASE_TAG" ]]; then # first release? LATEST_RELEASE_TAG=$(git rev-list --max-parents=0 HEAD) # first commit in the branch. fi -GIT_LOG_OUTPUT=$(git log "$LATEST_RELEASE_TAG"..HEAD --oneline --pretty=format:"%s" main) +GIT_LOG_OUTPUT=$(git log "$LATEST_RELEASE_TAG"..HEAD --oneline --pretty=format:"%s") PR_COMMITS=$(echo "$GIT_LOG_OUTPUT" | grep -oE "#[0-9]+" || true | tr -d '#' | sort -u) CHANGELOG_FILE=./CHANGELOG.md @@ -43,38 +46,54 @@ done for PR_NUMBER in $PR_COMMITS; do PR_JSON=$(gh pr view "$PR_NUMBER" --json number,title,body,url,author) + echo -n "Checking PR $PR_NUMBER" - IS_BOT=$(echo "$PR_JSON" | jq -r '.author.is_bot') + IS_BOT=$(jq -r '.author.is_bot' <<< "$PR_JSON") if [[ "$IS_BOT" == "true" ]]; then + echo " [skipping bot PR"] continue fi - PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') - PR_URL=$(echo "$PR_JSON" | jq -r '.url') - PR_BODY=$(echo "$PR_JSON" | jq -r '.body') + PR_TITLE=$(jq -r '.title' <<< "$PR_JSON") + PR_URL=$(jq -r '.url' <<< "$PR_JSON") + PR_BODY=$(jq -r '.body' <<< "$PR_JSON") + echo " - $PR_TITLE" # Determine type from conventional commit (assumes title like "type(scope): message" or "type: message") TYPE=$(echo "$PR_TITLE" | grep -oE '^[a-z]+' || echo "feat") CLEAN_TITLE=$(echo "$PR_TITLE" | sed -E 's/^[a-z]+(\([^)]+\))?(!)?:[[:space:]]+//') # Extract release note block, this contains the release notes and the release notes headers. - RELEASE_NOTE_BLOCK=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p') + # The last sed call is required to remove the carriage return characters (Github seems to use \r\n for new lines in PR bodies). + RELEASE_NOTE_BLOCK=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p' | sed 's/\r//g') # Extract release notes body - RELEASE_NOTE=$(echo "$RELEASE_NOTE_BLOCK" | sed '1d;$d' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + RELEASE_NOTE_JSON=$("$RELEASE_NOTES_TO_JSON_SCRIPT" <<< "$RELEASE_NOTE_BLOCK") + + # skip PRs without release notes + if [[ "$RELEASE_NOTE_JSON" == "[]" ]]; then + echo " [ignoring PR without release notes]" + continue + fi + + # Format release notes + # Updating NOTE_ENTRY in the loop does not work because it is executed in a subshell, therefore this workaround via echo. + NOTE_ENTRY="$( + jq -rc 'sort_by(.audience, .type) | .[]' <<< "$RELEASE_NOTE_JSON" | while IFS= read -r note; do + NOTE_TYPE=$(jq -r '.type' <<< "$note" | tr '[:lower:]' '[:upper:]') + NOTE_AUDIENCE=$(jq -r '.audience' <<< "$note" | tr '[:lower:]' '[:upper:]') + NOTE_BODY=$(jq -r '.body' <<< "$note") + echo -en "\n - **[$NOTE_AUDIENCE][$NOTE_TYPE]** $NOTE_BODY" + done + )" # Format entry ENTRY="- $CLEAN_TITLE [#${PR_NUMBER}](${PR_URL})" - if [[ -z "$RELEASE_NOTE" || "$RELEASE_NOTE" == "NONE" ]]; then - ENTRY+="." - else - # Extract and format the release note headers. - HEADERS=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p'| head -n 1 | sed 's/^```//') - FORMATED_HEADERS=$(echo "$HEADERS" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/\s\+/ /g' | sed 's/\(\S\+\)/[\1]/g') + # Extract and format the release note headers. + HEADERS=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p'| head -n 1 | sed 's/^```//') + FORMATED_HEADERS=$(echo "$HEADERS" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/\s\+/ /g' | sed 's/\(\S\+\)/[\1]/g') - ENTRY="- ${FORMATED_HEADERS} ${CLEAN_TITLE} [#${PR_NUMBER}](${PR_URL}): ${RELEASE_NOTE}" - fi - ENTRY+="\n" + ENTRY="- ${CLEAN_TITLE} [${PR_NUMBER}](${PR_URL})${NOTE_ENTRY}\n" # Append to appropriate section if [[ -n "${PR_ENTRIES[$TYPE]+x}" ]]; then diff --git a/release-notes-to-json.sh b/release-notes-to-json.sh new file mode 100755 index 0000000..3386721 --- /dev/null +++ b/release-notes-to-json.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# This script reads release notes from STDIN and converts them to JSON format, which is then printed to STDOUT. +# Expected input format: +# +# ``` +# (may span multiple lines) +# ``` +# (the above block may repeat multiple times) +# +# Output: +# [ +# { +# "type": "", +# "audience": "", +# "body": "" +# }, +# ... +# ] +# +# Additional whitespace is ignored, but each release note block must start with ``` followed by two lowercase words, +# and it must end with ``` followed by any amount of whitespace and then a newline or end of input. +# +# Release notes where the body is empty or "NONE" are ignored and not part of the output JSON array. + +set -euo pipefail + +type="" +audience="" +body="" +rnj='[]' + +start_regex='^[[:blank:]]*```[[:blank:]]*([a-z]+)[[:blank:]]+([a-z]+)[[:blank:]]*$' +end_regex='^[[:blank:]]*```[[:blank:]]*$' +empty_regex='^[[:space:]]*$' +line="" + +while IFS= read -r line || [[ -n "$line" ]]; do + # check if we are currently reading a release note body + if [[ -n "$type" ]]; then + # check for end of release note block + if [[ "$line" =~ $end_regex ]]; then + # end of release note block + # add release note to JSON array, unless the body is empty or "NONE" + if [[ ! "$body" =~ $empty_regex ]] && [[ "$body" != "NONE" ]]; then + rnj="$(jq '. + [{"type": $type, "audience": $audience, "body": $body}]' --arg type "$type" --arg audience "$audience" --arg body "$body" <<< "$rnj")" + fi + # reset variables + type="" + audience="" + body="" + else + # more body content + # append line to body + if [[ -n "$body" ]]; then + body="$body\n$line" + else + body="$line" + fi + fi + else + # not currently reading a release note body + # check for start of release note block + if [[ "$line" =~ $start_regex ]]; then + # start of release note block + type="${BASH_REMATCH[1]}" + audience="${BASH_REMATCH[2]}" + body="" + else + # invalid line outside of release note block + if [[ -n "$line" ]]; then + # ignore empty lines, log a warning otherwise + echo "unexpected line in release notes: $line" >&2 + fi + fi + fi +done + +# output JSON array +echo "$rnj"