mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
Before committing, release.sh now checks whether git can sign by running a throwaway signed commit-tree. If that fails, because there is no user.signingkey or no agent holds the key, the script warns and makes the release commit with --no-gpg-sign. The success message now says whether the commit is signed or unsigned. AGENTS.md is updated to match.
325 lines
11 KiB
Bash
Executable File
325 lines
11 KiB
Bash
Executable File
#!/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
|