feat(release): write the changelog from change files

This commit is contained in:
peaklabs-dev
2026-11-09 00:29:50 +01:00
parent cd975b49aa
commit b2617e7539
6 changed files with 304 additions and 38 deletions
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -euo pipefail
CHANGE_DIRECTORY=.changes
TYPES=(added changed deprecated removed fixed security maintenance)
SECTIONS=('Breaking Changes' Security Added Changed Deprecated Removed Fixed Maintenance)
BOLD=$'\033[1m' GREEN=$'\033[32m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
[ -t 1 ] || BOLD='' GREEN='' RED='' DIM='' RESET=''
heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; }
ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
die() {
printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
confirm() {
local answer
printf '\n %s [y/N] ' "$*"
read -r answer
[[ "$answer" =~ ^[Yy]$ ]]
}
cd "$(dirname "$0")/.."
trap 'tput cnorm 2> /dev/null || true' EXIT
main() {
read_arguments "$@"
if [ -n "$notes" ]; then
print_notes
return
fi
[ -n "$kind" ] && [ -n "$summary" ] || ask_questions
write_change
}
read_arguments() {
kind='' summary='' commit='' breaking='' notes='' details=()
while [ $# -gt 0 ]; do
case "$1" in
--notes) notes=true ;;
--breaking) breaking=true ;;
--type | --summary | --detail | --commit)
[ $# -ge 2 ] || die "$1 has no value, expected one"
case "$1" in
--type) kind="$2" ;;
--summary) summary="$2" ;;
--detail) details+=("$2") ;;
--commit) commit="$2" ;;
esac
shift
;;
*) die "'$1' is not an option, expected --type, --summary, --detail, --breaking, --commit or --notes" ;;
esac
shift
done
}
ask_questions() {
local selected detail
[ -t 0 ] || die "--type and --summary are required without a terminal"
if [ -z "$kind" ]; then
heading "Select the type"
selected="$(menu 0 "${TYPES[@]}")"
kind="${TYPES[$selected]}"
fi
if [ -z "$summary" ]; then
heading "Summary, one sentence with the heading"
printf ' '
read -r summary
fi
heading "Details, an empty line ends them"
while printf ' - ' && read -r detail && [ -n "$detail" ]; do
details+=("$detail")
done
if confirm "Breaking change?"; then
breaking=true
fi
printf '\n'
}
write_change() {
local path detail
printf '%s\n' "${TYPES[@]}" | grep -qx -- "$kind" || die "the type is '$kind', expected ${TYPES[*]}"
[ -n "$summary" ] || die "the summary is empty, expected one sentence"
if [ -n "$commit" ]; then
git rev-parse --verify --quiet "$commit^{commit}" > /dev/null || die "$commit is not a commit of this repository"
commit="$(git rev-parse --short "$commit")"
fi
mkdir -p "$CHANGE_DIRECTORY"
path="$CHANGE_DIRECTORY/$(file_name).md"
{
printf -- '---\ntype: %s\n' "$kind"
[ -z "$breaking" ] || printf 'breaking: true\n'
[ -z "$commit" ] || printf 'commit: %s\n' "$commit"
printf -- '---\n%s\n' "$summary"
[ "${#details[@]}" = 0 ] || printf '\n'
for detail in ${details[@]+"${details[@]}"}; do
printf -- '- %s\n' "$detail"
done
} > "$path"
ok "$path describes the $BOLD$kind$RESET change"
}
print_notes() {
local section entries
compgen -G "$CHANGE_DIRECTORY/*.md" > /dev/null || die "$CHANGE_DIRECTORY has no change files"
for section in "${SECTIONS[@]}"; do
entries="$(awk -v section="$section" '
FNR == 1 { fences = 0; type = ""; breaking = 0 }
fences < 2 {
if ($0 == "---") fences++
else if ($1 == "type:") type = $2
else if ($0 == "breaking: true") breaking = 1
next
}
NF && tolower(section) == (breaking ? "breaking changes" : type) {
prefix = /^- / ? " " : "- "
print prefix $0
}
' "$CHANGE_DIRECTORY"/*.md)"
[ -z "$entries" ] || printf '### %s\n\n%s\n\n' "$section" "$entries"
done
}
file_name() {
local stem name number=2
stem="$(printf '%s' "$summary" | LC_ALL=C tr -cs '[:alnum:]' '\n' | LC_ALL=C tr '[:upper:]' '[:lower:]' | awk 'NF && ++count <= 6' | paste -sd - -)"
name="$stem"
while [ -e "$CHANGE_DIRECTORY/$name.md" ]; do
name="$stem-$number"
number=$((number + 1))
done
echo "$name"
}
menu() {
local selected="$1" options key i
shift
options=("$@")
tput civis >&2 2> /dev/null || true
while true; do
for i in "${!options[@]}"; do
if [ "$i" = "$selected" ]; then
printf ' %s❯ %s%s\n' "$GREEN" "${options[$i]}" "$RESET" >&2
else
printf ' %s %s%s\n' "$DIM" "${options[$i]}" "$RESET" >&2
fi
done
IFS= read -rsn1 key
if [ "$key" = $'\x1b' ]; then
IFS= read -rsn2 key
if [ "$key" = '[A' ] && [ "$selected" -gt 0 ]; then
selected=$((selected - 1))
elif [ "$key" = '[B' ] && [ "$selected" -lt $((${#options[@]} - 1)) ]; then
selected=$((selected + 1))
fi
elif [ -z "$key" ]; then
break
fi
printf '\033[%dA\033[J' "${#options[@]}" >&2
done
tput cnorm >&2 2> /dev/null || true
echo "$selected"
}
main "$@"
+63 -24
View File
@@ -3,9 +3,10 @@ set -euo pipefail
OPTION="$*"
VERSION_FILE=release.json
CHANGE_DIRECTORY=.changes
SECTIONS=('Breaking Changes' Security Added Changed Deprecated Removed Fixed Maintenance)
ARTIFACT=coolify
UPSTREAM=
BREAKING_CHANGE='^([a-z]+(\([^)]*\))?!|BREAKING[ -]CHANGE): '
WORK_DIR="$(mktemp -d)"
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
@@ -38,6 +39,7 @@ main() {
select_version
heading "Releasing v$new_version"
update_changelog
update_change_files
update_version
commit
push
@@ -64,9 +66,9 @@ check_repository() {
read -r behind ahead <<< "$counts"
[ "$behind$ahead" = 00 ] || die "$branch is $ahead commit(s) ahead of and $behind behind origin/$branch, expected it in sync"
ok "$branch is clean and in sync with origin/$branch"
entries="$(awk '/^## \[Unreleased\]$/ { inside = 1; next } /^## / { inside = 0 } inside && /^- / { count++ } END { print count + 0 }' CHANGELOG.md)"
[ "$entries" != 0 ] || die "CHANGELOG.md has no entries under ## [Unreleased], describe the changes first"
ok "CHANGELOG.md lists $BOLD$entries$RESET change(s) under [Unreleased], the notes of this release"
entries="$(git ls-files "$CHANGE_DIRECTORY" | wc -l | tr -d ' ')"
[ "$entries" != 0 ] || die "$CHANGE_DIRECTORY has no change files, describe the changes first with scripts/change.sh"
ok "$CHANGE_DIRECTORY lists $BOLD$entries$RESET change(s), the notes of this release"
}
read_versions() {
@@ -81,19 +83,17 @@ read_versions() {
}
read_changes() {
local tag range hash subject
tag="$(git describe --tags --abbrev=0 --match "v${UPSTREAM:+*-v}$stable" 2> /dev/null)" || true
range="${tag:+$tag..}HEAD"
local file
required="patch"
if [ -n "$(git log -E --grep="$BREAKING_CHANGE" --format=%h "$range")" ]; then
if grep -qx 'breaking: true' "$CHANGE_DIRECTORY"/*.md; then
required=major
[ "${stable%%.*}" != 0 ] || required=minor
elif git log --format=%s "$range" | awk '/^feat(\([^)]*\))?!?: / && !/^feat\(release\): bump / { found = 1 } END { exit !found }'; then
elif grep -qxE 'type: (added|changed|deprecated|removed)' "$CHANGE_DIRECTORY"/*.md; then
required=minor
fi
info "the commits since ${tag:-the first commit} need a $BOLD$required$RESET release"
git log -E --grep="$BREAKING_CHANGE" --format='%h %s' "$range" | while read -r hash subject; do
warn "$DIM$hash$RESET $subject is a breaking change"
info "the change files need a $BOLD$required$RESET release"
for file in $(grep -lx 'breaking: true' "$CHANGE_DIRECTORY"/*.md || true); do
warn "$file is a breaking change"
done
}
@@ -152,19 +152,58 @@ select_version() {
}
update_changelog() {
local today
if [ "$channel" = preview ]; then
info "CHANGELOG.md keeps the entries under [Unreleased] until the stable release dates them"
return
fi
today="$(date +%Y-%m-%d)"
awk -v heading="## [v$new_version] - $today" '
/^#/ && NR > 1 && previous != "" { print "" }
{ print; previous = $0 }
/^## \[Unreleased\]$/ { print ""; print heading; previous = heading }
local heading=''
[ "$channel" = preview ] || heading="## [v$new_version] - $(date +%Y-%m-%d)"
print_notes > "$WORK_DIR/notes.md"
awk -v heading="$heading" -v notes="$WORK_DIR/notes.md" '
/^## / { inside = 0 }
/^## \[Unreleased\]$/ {
inside = 1
print
print ""
if (heading != "") { print heading; print "" }
while ((getline line < notes) > 0) print line
next
}
!inside
' CHANGELOG.md > "$WORK_DIR/CHANGELOG.md"
cp "$WORK_DIR/CHANGELOG.md" CHANGELOG.md
ok "CHANGELOG.md dates the unreleased entries as ${BOLD}v$new_version$RESET, $today"
ok "CHANGELOG.md lists the changes under $BOLD${heading:-## [Unreleased]}$RESET"
}
print_notes() {
local section entries
for section in "${SECTIONS[@]}"; do
entries="$(awk -v section="$section" '
FNR == 1 { fences = 0; type = ""; breaking = 0 }
fences < 2 {
if ($0 == "---") fences++
else if ($1 == "type:") type = $2
else if ($0 == "breaking: true") breaking = 1
next
}
NF && tolower(section) == (breaking ? "breaking changes" : type) {
prefix = /^- / ? " " : "- "
print prefix $0
}
' "$CHANGE_DIRECTORY"/*.md)"
[ -z "$entries" ] || printf '### %s\n\n%s\n\n' "$section" "$entries"
done
}
update_change_files() {
local file
if [ "$channel" = stable ]; then
rm "$CHANGE_DIRECTORY"/*.md
ok "$CHANGE_DIRECTORY is empty, CHANGELOG.md lists the changes now"
return
fi
for file in "$CHANGE_DIRECTORY"/*.md; do
! grep -q '^version: ' "$file" || continue
awk -v version="$new_version" '/^---$/ && ++fences == 2 { print "version: " version } { print }' "$file" > "$WORK_DIR/change.md"
cp "$WORK_DIR/change.md" "$file"
done
ok "$CHANGE_DIRECTORY names ${BOLD}v$new_version$RESET in the change files that had no version"
}
update_version() {
@@ -174,7 +213,7 @@ update_version() {
}
commit() {
git add "$VERSION_FILE" CHANGELOG.md
git add --all -- "$VERSION_FILE" CHANGELOG.md "$CHANGE_DIRECTORY"
git commit --quiet -S --message "feat(release): bump $ARTIFACT version to v$new_version"
ok "signed commit $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
}
+6 -5
View File
@@ -3,6 +3,7 @@ set -euo pipefail
OPTION="$*"
VERSION_FILE=release.json
CHANGE_DIRECTORY=.changes
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
@@ -26,7 +27,7 @@ main() {
[ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
check_repository
heading "Undoing $(git log -1 --format='%h %s')"
info "resets the commit and restores $VERSION_FILE and CHANGELOG.md"
info "resets the commit and restores $VERSION_FILE, CHANGELOG.md and $CHANGE_DIRECTORY"
confirm "Continue?" || die "aborted, nothing changed"
undo_commit
}
@@ -35,18 +36,18 @@ check_repository() {
local branch
git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
branch="$(git rev-parse --abbrev-ref HEAD)"
git diff --quiet HEAD~1 HEAD -- . ":(exclude)$VERSION_FILE" ":(exclude)CHANGELOG.md" || die "the last commit changes more than $VERSION_FILE and CHANGELOG.md, expected a release commit"
git diff --quiet HEAD~1 HEAD -- . ":(exclude)$VERSION_FILE" ":(exclude)CHANGELOG.md" ":(exclude)$CHANGE_DIRECTORY" || die "the last commit changes more than $VERSION_FILE, CHANGELOG.md and $CHANGE_DIRECTORY, expected a release commit"
[ "$(versions_at HEAD~1)" != "$(versions_at HEAD)" ] || die "the last commit keeps the versions in $VERSION_FILE at $(versions_at HEAD), expected a release commit"
[ -z "$(git status --porcelain -- "$VERSION_FILE" CHANGELOG.md)" ] || die "$VERSION_FILE or CHANGELOG.md has uncommitted changes, commit or stash them first"
[ -z "$(git status --porcelain -- "$VERSION_FILE" CHANGELOG.md "$CHANGE_DIRECTORY")" ] || die "$VERSION_FILE, CHANGELOG.md or $CHANGE_DIRECTORY has uncommitted changes, commit or stash them first"
git fetch --quiet origin || die "could not fetch origin"
[ "$(git rev-list --count "origin/$branch..HEAD")" -ge 1 ] || die "the release commit is on origin/$branch already, nothing to undo locally"
}
undo_commit() {
git reset --quiet --soft HEAD~1
git restore --staged --worktree "$VERSION_FILE" CHANGELOG.md
git restore --staged --worktree "$VERSION_FILE" CHANGELOG.md "$CHANGE_DIRECTORY"
ok "reset to $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
ok "$VERSION_FILE is back to $BOLD$(versions_at HEAD)$RESET, the entries are back under [Unreleased]"
ok "$VERSION_FILE is back to $BOLD$(versions_at HEAD)$RESET, the change files are back"
}
versions_at() {