feat: modernize

This commit is contained in:
eric
2026-03-21 01:27:42 +01:00
parent e9d04c7f8d
commit 8fec37023f
37 changed files with 3270 additions and 3145 deletions

View File

@@ -2,539 +2,18 @@
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD=""
CREATED_TAG=""
VERSION_META_LINES=()
VERSION_META_EXPORT_NAMES=()
# ── logging ────────────────────────────────────────────────────────────────
log() { echo "[release] $*" >&2; }
usage() {
local cmd
cmd="$(basename "$0")"
printf '%s\n' \
"Usage:" \
" ${cmd} [major|minor|patch] [stable|__CHANNEL_LIST__]" \
" ${cmd} set <version>" \
"" \
"Bump types:" \
" (none) bump patch, keep current channel" \
" major/minor/patch bump the given part, keep current channel" \
" stable / full remove prerelease suffix (only opt-in path to promote prerelease -> stable)" \
" __CHANNEL_LIST__ switch channel (from stable, auto-bumps patch unless bump is specified)" \
"" \
"Safety rule:" \
" If current version is prerelease (e.g. x.y.z-beta.N), promotion to stable is allowed only via 'stable' or 'full'." \
" Commands like '${cmd} set x.y.z' or '${cmd} patch stable' are blocked from prerelease channels." \
"" \
"Examples:" \
" ${cmd} # patch bump on current channel" \
" ${cmd} minor # minor bump on current channel" \
" ${cmd} beta # from stable: patch bump + beta.1" \
" ${cmd} patch beta # patch bump, switch to beta channel" \
" ${cmd} rc # switch to rc channel" \
" ${cmd} stable # promote prerelease to stable (opt-in)" \
" ${cmd} set 1.2.3" \
" ${cmd} set 1.2.3-beta.1"
}
# ── git ────────────────────────────────────────────────────────────────────
require_clean_git() {
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: git working tree is not clean. Commit or stash changes first." >&2
exit 1
fi
}
revert_on_failure() {
local status=$?
if [[ -n $START_HEAD ]]; then
log "Release failed — reverting to $START_HEAD"
git reset --hard "$START_HEAD"
fi
if [[ -n $CREATED_TAG ]]; then
git tag -d "$CREATED_TAG" >/dev/null 2>&1 || true
fi
exit $status
}
# ── version parsing ────────────────────────────────────────────────────────
parse_base_version() {
local v="$1"
if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Error: invalid base version '$v' (expected x.y.z)" >&2
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
}
parse_full_version() {
local v="$1"
CHANNEL="stable"
PRERELEASE_NUM=""
if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
CHANNEL="${BASH_REMATCH[2]}"
PRERELEASE_NUM="${BASH_REMATCH[3]}"
elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "Error: invalid version '$v' (expected x.y.z or x.y.z-channel.N)" >&2
exit 1
fi
parse_base_version "$BASE_VERSION"
}
validate_channel() {
local ch="$1"
[[ $ch == "stable" ]] && return 0
local valid_channels="__CHANNEL_LIST__"
for c in $valid_channels; do
[[ $ch == "$c" ]] && return 0
done
echo "Error: unknown channel '$ch'. Valid channels: stable $valid_channels" >&2
exit 1
}
version_cmp() {
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
# Stable > prerelease for same base version
local v1="$1" v2="$2"
[[ $v1 == "$v2" ]] && return 0
local base1="" pre1="" base2="" pre2=""
if [[ $v1 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base1="${BASH_REMATCH[1]}"
pre1="${BASH_REMATCH[2]}"
else
base1="$v1"
fi
if [[ $v2 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base2="${BASH_REMATCH[1]}"
pre2="${BASH_REMATCH[2]}"
else
base2="$v2"
fi
if [[ $base1 != "$base2" ]]; then
local highest_base
highest_base=$(printf '%s\n%s\n' "$base1" "$base2" | sort -V | tail -n1)
[[ $highest_base == "$base1" ]] && return 1 || return 2
fi
[[ -z $pre1 && -n $pre2 ]] && return 1 # stable > prerelease
[[ -n $pre1 && -z $pre2 ]] && return 2 # prerelease < stable
[[ -z $pre1 && -z $pre2 ]] && return 0 # both stable
local highest_pre
highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1)
[[ $highest_pre == "$pre1" ]] && return 1 || return 2
}
bump_base_version() {
case "$1" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch) PATCH=$((PATCH + 1)) ;;
*)
echo "Error: unknown bump part '$1'" >&2
exit 1
;;
esac
BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}"
}
compute_full_version() {
if [[ $CHANNEL == "stable" || -z $CHANNEL ]]; then
FULL_VERSION="$BASE_VERSION"
else
FULL_VERSION="${BASE_VERSION}-${CHANNEL}.${PRERELEASE_NUM:-1}"
fi
FULL_TAG="v$FULL_VERSION"
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG
}
meta_env_name() {
local key="$1"
key="${key//[^[:alnum:]]/_}"
key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
printf 'VERSION_META_%s\n' "$key"
}
clear_version_meta_exports() {
local export_name
for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do
unset "$export_name"
done
VERSION_META_EXPORT_NAMES=()
}
load_version_metadata() {
VERSION_META_LINES=()
[[ ! -f "$ROOT_DIR/VERSION" ]] && return 0
while IFS= read -r line || [[ -n $line ]]; do
VERSION_META_LINES+=("$line")
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_metadata() {
clear_version_meta_exports
local line key value export_name
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line != *=* ]] && continue
key="${line%%=*}"
value="${line#*=}"
[[ -z $key ]] && continue
export_name="$(meta_env_name "$key")"
printf -v "$export_name" '%s' "$value"
export "${export_name?}=$value"
VERSION_META_EXPORT_NAMES+=("$export_name")
done
}
write_version_file() {
local channel_to_write="$1"
local n_to_write="$2"
{
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
printf '%s\n' "$line"
done
} >"$ROOT_DIR/VERSION"
}
version_meta_get() {
local key="${1-}"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
if [[ $line == "$key="* ]]; then
printf '%s\n' "${line#*=}"
return 0
fi
done
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
[[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1
local updated=0
local index
for index in "${!VERSION_META_LINES[@]}"; do
if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then
VERSION_META_LINES[index]="$key=$value"
updated=1
break
fi
done
if [[ $updated -eq 0 ]]; then
VERSION_META_LINES+=("$key=$value")
fi
export_version_metadata
version_meta_write
}
version_meta_unset() {
local key="${1-}"
[[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1
local filtered=()
local line
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line == "$key="* ]] && continue
filtered+=("$line")
done
VERSION_META_LINES=("${filtered[@]}")
export_version_metadata
version_meta_write
}
version_meta_write() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
write_version_file "$channel_to_write" "$n_to_write"
}
# ── gitlint ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() {
[[ ! -f $GITLINT_FILE ]] && return 0
awk '
/^\[title-match-regex\]$/ { in_section=1; next }
/^\[/ { in_section=0 }
in_section && /^regex=/ { sub(/^regex=/, ""); print; exit }
' "$GITLINT_FILE"
}
validate_commit_message() {
local msg="$1"
local regex
regex="$(get_gitlint_title_regex)"
if [[ -n $regex && ! $msg =~ $regex ]]; then
echo "Error: commit message does not match .gitlint title-match-regex" >&2
echo "Regex: $regex" >&2
echo "Message: $msg" >&2
exit 1
fi
}
# ── version file generation ────────────────────────────────────────────────
run_release_steps() {
:
__RELEASE_STEPS__
}
# ── version source (built-in) ──────────────────────────────────────────────
# Initializes $ROOT_DIR/VERSION from git tags if it doesn't exist.
# Must be called outside of any subshell so log output stays on stderr
# and never contaminates the stdout of do_read_version.
init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then
load_version_metadata
export_version_metadata
return 0
fi
local highest_tag=""
while IFS= read -r raw_tag; do
local tag="${raw_tag#v}"
[[ $tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$ ]] || continue
if [[ -z $highest_tag ]]; then
highest_tag="$tag"
continue
fi
local cmp_status=0
version_cmp "$tag" "$highest_tag" || cmp_status=$?
[[ $cmp_status -eq 1 ]] && highest_tag="$tag"
done < <(git tag --list)
[[ -z $highest_tag ]] && highest_tag="0.0.1"
parse_full_version "$highest_tag"
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
VERSION_META_LINES=()
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
}
do_read_version() {
load_version_metadata
export_version_metadata
local base_line channel_line n_line
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
n_line="$(sed -n '3p' "$ROOT_DIR/VERSION" | tr -d '\r')"
if [[ -z $channel_line || $channel_line == "stable" ]]; then
printf '%s\n' "$base_line"
else
printf '%s-%s.%s\n' "$base_line" "$channel_line" "$n_line"
fi
}
do_write_version() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
}
# ── user-provided hook ─────────────────────────────────────────────────────
do_post_version() {
:
__POST_VERSION__
}
# ── main ───────────────────────────────────────────────────────────────────
main() {
[[ ${1-} == "-h" || ${1-} == "--help" ]] && usage && exit 0
require_clean_git
START_HEAD="$(git rev-parse HEAD)"
trap revert_on_failure ERR
# Initialize VERSION file outside any subshell so log lines never
# bleed into the stdout capture below.
init_version_file
local raw_version
raw_version="$(do_read_version | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$' | tail -n1)"
if [[ -z $raw_version ]]; then
echo "Error: could not determine current version from VERSION source" >&2
exit 1
fi
parse_full_version "$raw_version"
compute_full_version
local current_full="$FULL_VERSION"
log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}"
local action="${1-}"
shift || true
if [[ $action == "set" ]]; then
local newv="${1-}"
local current_channel="$CHANNEL"
[[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1
parse_full_version "$newv"
validate_channel "$CHANNEL"
if [[ $current_channel != "stable" && $CHANNEL == "stable" ]]; then
echo "Error: from prerelease channel '$current_channel', promote using 'stable' or 'full' only" >&2
exit 1
fi
compute_full_version
local cmp_status=0
version_cmp "$FULL_VERSION" "$current_full" || cmp_status=$?
case $cmp_status in
0)
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
;;
2)
echo "Error: $FULL_VERSION is lower than current $current_full" >&2
exit 1
;;
esac
else
local part="" target_channel="" was_channel_only=0
case "$action" in
"") part="patch" ;;
major | minor | patch)
part="$action"
target_channel="${1-}"
;;
stable | full)
[[ -n ${1-} ]] && echo "Error: '$action' takes no second argument" >&2 && usage && exit 1
target_channel="stable"
;;
*)
# check if action is a valid channel
local is_channel=0
for c in __CHANNEL_LIST__; do
[[ $action == "$c" ]] && is_channel=1 && break
done
if [[ $is_channel == 1 ]]; then
[[ -n ${1-} ]] && echo "Error: channel-only bump takes no second argument" >&2 && usage && exit 1
target_channel="$action"
was_channel_only=1
else
echo "Error: unknown argument '$action'" >&2
usage
exit 1
fi
;;
esac
[[ -z $target_channel ]] && target_channel="$CHANNEL"
[[ $target_channel == "full" ]] && target_channel="stable"
validate_channel "$target_channel"
if [[ $CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then
echo "Error: from prerelease channel '$CHANNEL', promote using 'stable' or 'full' only" >&2
exit 1
fi
if [[ -z $part && $was_channel_only -eq 1 && $CHANNEL == "stable" && $target_channel != "stable" ]]; then
part="patch"
fi
local old_base="$BASE_VERSION" old_channel="$CHANNEL" old_pre="$PRERELEASE_NUM"
[[ -n $part ]] && bump_base_version "$part"
if [[ $target_channel == "stable" ]]; then
CHANNEL="stable"
PRERELEASE_NUM=""
else
if [[ $BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then
PRERELEASE_NUM=$((old_pre + 1))
else
PRERELEASE_NUM=1
fi
CHANNEL="$target_channel"
fi
fi
compute_full_version
if [[ $FULL_VERSION == "$current_full" ]]; then
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
fi
log "Releasing $FULL_VERSION"
do_write_version
log "Updated version source"
run_release_steps
log "Release steps done"
do_post_version
log "Post-version hook done"
(cd "$ROOT_DIR" && nix fmt)
log "Formatted files"
git add -A
local commit_msg="chore(release): v$FULL_VERSION"
validate_commit_message "$commit_msg"
git commit -m "$commit_msg"
log "Created commit"
git tag "$FULL_TAG"
CREATED_TAG="$FULL_TAG"
log "Tagged $FULL_TAG"
git push
git push --tags
log "Done — released $FULL_TAG"
trap - ERR
}
main "$@"
REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)"
export REPO_LIB_RELEASE_ROOT_DIR
export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__'
REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF'
__RELEASE_STEPS_JSON__
EOF
)"
export REPO_LIB_RELEASE_STEPS_JSON
REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF'
__POST_VERSION__
EOF
)"
export REPO_LIB_RELEASE_POST_VERSION
exec __RELEASE_RUNNER__ "$@"