Ctrl K
ComunidadGuías…

scripts/release.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
set -euo pipefail

OPTION="$*"
VERSION_FILE=release.json
CHANGE_DIRECTORY=.changes
SECTIONS=('Breaking Changes' Security Added Changed Deprecated Removed Fixed Maintenance)
ARTIFACT=coolify
UPSTREAM=
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'

heading() { printf '\n  %s%s%s\n' "$BOLD" "$*" "$RESET"; }
info() { printf '  %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
ok() { printf '  %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
warn() { printf '  %s⚠%s %s\n' "$YELLOW" "$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]$ ]]
}
hint() { printf '  %-26s%s%s%s\n' "$1" "$DIM" "$2" "$RESET"; }

cd "$(dirname "$0")/.."
trap 'rm -rf "$WORK_DIR"; tput cnorm 2> /dev/null || true' EXIT

main() {
	[ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
	check_repository
	read_versions
	read_changes
	select_stage
	select_version
	heading "Releasing v$new_version"
	update_changelog
	update_change_files
	update_version
	commit
	push
}

check_repository() {
	local counts behind ahead entries fetched stale
	heading "Checking the repository"
	git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
	branch="$(git symbolic-ref --quiet --short HEAD)" || die "HEAD is detached, expected a branch"
	[[ "$branch" =~ ^(main|next|v[0-9]+(\.[0-9]+)?\.x|release/v([0-9]+\.[0-9]+))$ ]] \
		|| die "$branch releases nothing"
	line="${BASH_REMATCH[3]}"
	channel=stable
	[[ ! "$branch" =~ ^(next|release/) ]] || channel=preview
	[ -z "$(git status --porcelain)" ] || die "the working tree is not clean, commit or stash first"
	[ "$(git rev-parse --is-shallow-repository)" = false ] || die "the clone is shallow, run git fetch --unshallow first"
	fetched="$(git fetch --porcelain --no-progress --prune --tags origin)" || {
		stale="$(awk '$1 == "!" { sub("^refs/tags/", "", $4); print $4 }' <<< "$fetched" | paste -sd ' ' -)"
		[ -z "$stale" ] || die "the local tag(s) $stale differ from origin, check them with git ls-remote --tags origin, then run git fetch --tags --force origin"
		die "could not fetch origin"
	}
	counts="$(git rev-list --left-right --count "origin/$branch...HEAD" 2> /dev/null)" || die "origin has no branch $branch, push it first"
	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="$(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() {
	local released
	version="$(artifact_version "$channel")"
	released="$(artifact_version stable)"
	[ -n "$version" ] && [ -n "$released" ] || die "$VERSION_FILE has no versions of $ARTIFACT"
	stable="$(release_tags --merged HEAD | tag_versions | awk '!/-/' | sort -V | tail -n 1)"
	stable="${stable:-$released}"
	! newer "$released" "$stable" || die "$VERSION_FILE sets the stable version $released, $branch released $stable last, publish $released first"
	ok "$VERSION_FILE sets the $channel version $BOLD$version$RESET"
}

read_changes() {
	local file
	required="patch"
	if grep -qx 'breaking: true' "$CHANGE_DIRECTORY"/*.md; then
		required=major
		[ "${stable%%.*}" != 0 ] || required=minor
	elif grep -qxE 'type: (added|changed|deprecated|removed)' "$CHANGE_DIRECTORY"/*.md; then
		required=minor
	fi
	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
}

select_stage() {
	local stages=(alpha beta rc) selected=0 i
	stage=
	[ "$channel" = preview ] || return 0
	for i in "${!stages[@]}"; do
		[[ "$version" != *-"${stages[$i]}".* ]] || selected="$i"
	done
	heading "Select the stage"
	stage="${stages[$(menu "$selected" "${stages[@]}")]}"
}

select_version() {
	local bases=() base candidate options=() selected=0
	if [ -n "$line" ]; then
		bases=("$line.0")
	elif [ "$channel" = preview ]; then
		bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
		[ "$required" != major ] || selected=1
		if [[ "$version" == *-* ]] && newer "${version%%-*}" "$stable"; then
			if taken "${version%%-*}"; then
				info "${version%%-*} is taken by $holder"
			else
				bases=("${version%%-*}" "${bases[@]}")
				selected=0
			fi
		fi
	elif [ "$stable" = 0.0.0 ]; then
		bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
	else
		bases=("$(next_version "$stable" patch)")
		[[ "$branch" == v*.*.x ]] || bases+=("$(next_version "$stable" minor)")
		[ "$branch" != main ] || bases+=("$(next_version "$stable" major)")
		case "$required" in
			minor) selected=1 ;;
			major) selected=2 ;;
		esac
	fi
	for base in "${bases[@]}"; do
		candidate="$base"
		if [ -n "$stage" ]; then
			candidate="$base-$stage.1"
			[ "$base-$stage" != "${version%.*}" ] || candidate="$base-$stage.$((${version##*.} + 1))"
		fi
		if newer "$candidate" "$version" && [[ " ${options[*]:-} " != *" $candidate "* ]]; then
			options+=("$candidate")
			! in_progress "$base" || warn "$base is in progress, held by $holder"
		fi
	done
	[ -n "${options[*]:-}" ] || die "$branch is at $version, no $stage prerelease follows it"
	[ "$selected" -lt "${#options[@]}" ] || selected=$((${#options[@]} - 1))
	heading "Select the version"
	new_version="${options[$(menu "$selected" "${options[@]/#/v}")]}"
}

update_changelog() {
	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 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() {
	jq --tab --arg channel "$channel" --arg artifact "$ARTIFACT" --arg version "$new_version" '.[$channel][$artifact] = $version' "$VERSION_FILE" > "$WORK_DIR/release.json"
	cp "$WORK_DIR/release.json" "$VERSION_FILE"
	ok "$VERSION_FILE sets the $channel version $version → $BOLD$new_version$RESET"
}

commit() {
	git add --all -- "$VERSION_FILE" CHANGELOG.md "$CHANGE_DIRECTORY"
	local sign=-S kind=signed
	if ! git commit-tree -S -m 'release signing check' 'HEAD^{tree}' > /dev/null 2>&1 < /dev/null; then
		sign=--no-gpg-sign kind=unsigned
		warn "git cannot sign a commit (no user.signingkey, or no agent that holds the key), the release commit is unsigned"
	fi
	git commit --quiet "$sign" --message "feat(release): bump $ARTIFACT version to v$new_version"
	ok "$kind commit $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
}

push() {
	local runs
	if confirm "Push to origin/$branch?"; then
		git push --quiet origin "$branch"
		ok "pushed ${BOLD}v$new_version$RESET to origin/$branch"
		runs="$(git remote get-url origin | sed -E 's#^(ssh://)?git@github\.com[:/]#https://github.com/#; s#\.git$##')/actions/workflows/release.yaml?query=branch%3A$branch"
		info "the release workflow runs at $runs"
	else
		info "not pushed"
		heading "Next"
		hint "git push" "push the release commit later"
		hint "scripts/undo-release.sh" "undo the release commit"
	fi
}

artifact_version() {
	jq -r --arg channel "$1" --arg artifact "$ARTIFACT" '.[$channel][$artifact] // empty' "$VERSION_FILE"
}

release_tags() {
	git tag --list "$@" | grep -E "^v${UPSTREAM:+[^-]+-v}[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" || true
}

tag_versions() {
	sed 's/.*v//'
}

next_version() {
	local major minor patch
	IFS=. read -r major minor patch <<< "${1%%-*}"
	while true; do
		case "$2" in
			patch) patch=$((patch + 1)) ;;
			minor) minor=$((minor + 1)) patch=0 ;;
			major) major=$((major + 1)) minor=0 patch=0 ;;
		esac
		taken "$major.$minor.$patch" || break
	done
	echo "$major.$minor.$patch"
}

taken() {
	local tag
	for tag in $(release_tags "*v$1"); do
		holder="$tag, which $branch does not contain"
		git merge-base --is-ancestor "$tag" HEAD || return 0
	done
	[ "$channel" = preview ] && in_progress "$1"
}

in_progress() {
	local tag release="release/v${1%.0}"
	if [ "$release" != "$branch" ] && git rev-parse --quiet --verify "origin/$release" > /dev/null; then
		holder="$release"
		[ "$channel" = stable ] || return 0
		git merge-base --is-ancestor "origin/$release" HEAD || return 0
	fi
	for tag in $(release_tags "*v$1-*"); do
		holder="$tag, which $branch does not contain"
		git merge-base --is-ancestor "$tag" HEAD || return 0
	done
	return 1
}

newer() {
	[ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sed 's/-/~/' | sort -V | tail -n 1 | sed 's/~/-/')" = "$1" ]
}

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