A release meant running the RELEASING.md checklist by hand: tag, push twice, create the GitHub release, then remember the Gitea push. The two scripts do the machine-checkable part of it, with the shared flow in scripts/lib/ so they cannot drift apart: clean worktree, not behind the remote, typecheck, tests and the install.sh syntax check, then the tag at HEAD, the branch push and the release object. Only the release object differs. GitHub creates it with gh, and publishing it is what fires the image build. Gitea gets the same notes through its API, with the token from GITEA_TOKEN or the one tea already stored, since nothing there builds images. Both accept --version (defaulting to package.json), --branch, --yes, --dry-run and --skip-checks, and a rerun finishes a release that failed half way instead of starting over: an existing tag is reused, a pushed ref is skipped and an existing release is left alone. A tag sitting on another commit is refused, since publishing would ship that commit's code.
325 lines
9.0 KiB
Bash
Executable File
325 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Shared helpers for the publish scripts.
|
|
#
|
|
# . "$(dirname "$0")/lib/publish-common.sh"
|
|
#
|
|
# Never run this file: it only defines functions and constants, so the
|
|
# scripts own their own `set -Eeuo pipefail` and a test can source it
|
|
# without changing the caller's shell options.
|
|
|
|
# MARK:preamble
|
|
if [ -t 1 ]; then
|
|
C_RESET=$'\033[0m'
|
|
C_BOLD=$'\033[1m'
|
|
C_DIM=$'\033[2m'
|
|
C_RED=$'\033[31m'
|
|
C_GREEN=$'\033[32m'
|
|
C_YELLOW=$'\033[33m'
|
|
C_BLUE=$'\033[34m'
|
|
else
|
|
C_RESET=""
|
|
C_BOLD=""
|
|
C_DIM=""
|
|
C_RED=""
|
|
C_GREEN=""
|
|
C_YELLOW=""
|
|
C_BLUE=""
|
|
fi
|
|
|
|
DRY_RUN="${DRY_RUN:-0}"
|
|
ASSUME_YES="${ASSUME_YES:-0}"
|
|
RELEASE_NOTES_MAX_COMMITS="${RELEASE_NOTES_MAX_COMMITS:-100}"
|
|
|
|
step() { printf '\n%s==>%s %s\n' "$C_BLUE$C_BOLD" "$C_RESET" "$*"; }
|
|
ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
|
detail() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
|
|
|
|
fail() {
|
|
printf '%sfail%s %s\n' "$C_RED" "$C_RESET" "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Runs a command, or prints it when the script was asked for a dry run.
|
|
# Only use this for commands that can be spelled as a single command.
|
|
run() {
|
|
if [ "$DRY_RUN" = "1" ]; then
|
|
printf ' %s[dry run]%s %s\n' "$C_DIM" "$C_RESET" "$*"
|
|
return 0
|
|
fi
|
|
"$@"
|
|
}
|
|
|
|
is_dry_run() { [ "$DRY_RUN" = "1" ]; }
|
|
# END:preamble
|
|
|
|
# MARK:require-cmd
|
|
require_cmd() {
|
|
local name="$1"
|
|
local hint="${2:-}"
|
|
if ! command -v "$name" >/dev/null 2>&1; then
|
|
if [ -n "$hint" ]; then
|
|
fail "Missing '$name'. $hint"
|
|
fi
|
|
fail "Missing '$name'."
|
|
fi
|
|
}
|
|
# END:require-cmd
|
|
|
|
# Moves into the top of the checkout and remembers it as ROOT.
|
|
require_repo_root() {
|
|
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || fail "Not inside a git checkout."
|
|
cd "$ROOT"
|
|
export ROOT
|
|
}
|
|
|
|
# MARK:git-state
|
|
require_clean_worktree() {
|
|
local dirty
|
|
dirty="$(git status --porcelain --untracked-files=no)"
|
|
if [ -n "$dirty" ]; then
|
|
printf '%s\n' "$dirty" >&2
|
|
fail "The working tree has uncommitted changes. Commit or stash them before publishing."
|
|
fi
|
|
}
|
|
|
|
require_on_branch() {
|
|
local branch="$1"
|
|
local current
|
|
current="$(git rev-parse --abbrev-ref HEAD)"
|
|
if [ "$current" != "$branch" ]; then
|
|
fail "On '$current', not '$branch'. Check out $branch before publishing (or pass --branch)."
|
|
fi
|
|
}
|
|
|
|
require_remote() {
|
|
local remote="$1"
|
|
if ! git remote get-url "$remote" >/dev/null 2>&1; then
|
|
fail "No '$remote' git remote. Add it with: git remote add $remote <url>"
|
|
fi
|
|
}
|
|
|
|
# Fails when the remote branch has commits HEAD does not have. HEAD being
|
|
# ahead is fine: the script pushes.
|
|
require_not_behind() {
|
|
local remote="$1"
|
|
local branch="$2"
|
|
local counts ahead behind
|
|
|
|
# A remote with no branch yet is not behind: the first push creates it.
|
|
if ! git ls-remote --exit-code --heads "$remote" "refs/heads/$branch" >/dev/null 2>&1; then
|
|
detail "$remote has no $branch yet; the first push creates it."
|
|
return 0
|
|
fi
|
|
|
|
git fetch --quiet "$remote" "$branch" || fail "Could not fetch $remote/$branch."
|
|
if ! git rev-parse -q --verify "refs/remotes/$remote/$branch" >/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
counts="$(git rev-list --left-right --count "HEAD...$remote/$branch")"
|
|
ahead="$(printf '%s' "$counts" | cut -f1)"
|
|
behind="$(printf '%s' "$counts" | cut -f2)"
|
|
if [ "$behind" != "0" ]; then
|
|
fail "$remote/$branch has $behind commit(s) HEAD does not have. Pull before publishing."
|
|
fi
|
|
if [ "$ahead" != "0" ]; then
|
|
detail "HEAD is $ahead commit(s) ahead of $remote/$branch; they are pushed with the release."
|
|
fi
|
|
}
|
|
|
|
# Prints the tag already on the remote for a version, or nothing.
|
|
remote_tag_exists() {
|
|
local remote="$1"
|
|
local tag="$2"
|
|
local found
|
|
found="$(git ls-remote --tags "$remote" "refs/tags/$tag" 2>/dev/null || true)"
|
|
[ -n "$found" ] && printf 'yes'
|
|
}
|
|
|
|
local_tag_exists() {
|
|
git rev-parse -q --verify "refs/tags/$1" >/dev/null
|
|
}
|
|
|
|
# Creates the annotated tag when it is missing, and reuses an existing one.
|
|
ensure_tag() {
|
|
local tag="$1"
|
|
local message="${2:-$1}"
|
|
if local_tag_exists "$tag"; then
|
|
ok "Tag $tag already exists locally; reusing it."
|
|
return 0
|
|
fi
|
|
run git tag -a "$tag" -m "$message"
|
|
}
|
|
# END:git-state
|
|
|
|
# MARK:remote-url
|
|
# Splits a git remote URL into REMOTE_HOST, REMOTE_OWNER and REMOTE_REPO.
|
|
# Understands git@host:owner/repo.git, ssh://git@host[:port]/owner/repo.git
|
|
# and https://host[:port]/owner/repo.git.
|
|
parse_remote_url() {
|
|
local url="$1"
|
|
local rest path
|
|
|
|
case "$url" in
|
|
*://*)
|
|
rest="${url#*://}"
|
|
rest="${rest#*@}"
|
|
REMOTE_HOST="${rest%%/*}"
|
|
path="${rest#*/}"
|
|
;;
|
|
*@*:*)
|
|
rest="${url#*@}"
|
|
REMOTE_HOST="${rest%%:*}"
|
|
path="${rest#*:}"
|
|
;;
|
|
*)
|
|
fail "Could not read a host out of the remote URL '$url'."
|
|
;;
|
|
esac
|
|
|
|
path="${path#/}"
|
|
path="${path%.git}"
|
|
REMOTE_OWNER="${path%%/*}"
|
|
REMOTE_REPO="${path##*/}"
|
|
|
|
if [ -z "$REMOTE_HOST" ] || [ -z "$REMOTE_OWNER" ] || [ -z "$REMOTE_REPO" ]; then
|
|
fail "Could not read owner and repository out of the remote URL '$url'."
|
|
fi
|
|
}
|
|
# END:remote-url
|
|
|
|
# MARK:version
|
|
VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$'
|
|
|
|
# Turns `0.1.2` or `v0.1.2` into `v0.1.2`.
|
|
normalize_version() {
|
|
local raw="$1"
|
|
printf 'v%s' "${raw#v}"
|
|
}
|
|
|
|
# Refuses anything that is not a release version, so a typo never becomes a
|
|
# tag that the release workflow cannot read a major.minor from.
|
|
validate_version() {
|
|
local raw="$1"
|
|
local bare="${raw#v}"
|
|
if ! printf '%s' "$bare" | grep -Eq "$VERSION_PATTERN"; then
|
|
fail "Version '$raw' is not a release version. Expected vMAJOR.MINOR.PATCH, e.g. v0.1.2."
|
|
fi
|
|
}
|
|
|
|
# The version in package.json, read the same way the release workflow reads it.
|
|
source_version() {
|
|
sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' "${ROOT:?}/package.json" | head -n1
|
|
}
|
|
|
|
# The newest release tag before `to`, or nothing when there is none.
|
|
previous_tag() {
|
|
local to="$1"
|
|
git -C "${ROOT:?}" describe --tags --abbrev=0 --match 'v[0-9]*' "${to}^" 2>/dev/null || true
|
|
}
|
|
# END:version
|
|
|
|
# MARK:notes
|
|
# Builds a release body from the commit log, so the Gitea mirror gets notes
|
|
# too (only GitHub can generate them itself).
|
|
release_notes() {
|
|
local from=""
|
|
local to=""
|
|
local compare_url=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--from) from="$2"; shift 2 ;;
|
|
--to) to="$2"; shift 2 ;;
|
|
--compare-url) compare_url="$2"; shift 2 ;;
|
|
*) fail "release_notes: unknown argument '$1'." ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$to" ]; then
|
|
fail "release_notes: --to is required."
|
|
fi
|
|
|
|
local range="$to"
|
|
if [ -n "$from" ]; then
|
|
range="$from..$to"
|
|
fi
|
|
|
|
local commits total
|
|
commits="$(git -C "${ROOT:?}" log --no-merges --pretty=format:'- %s (%h)' --max-count="$RELEASE_NOTES_MAX_COMMITS" "$range")"
|
|
total="$(git -C "$ROOT" log --no-merges --oneline "$range" | wc -l | tr -d ' ')"
|
|
|
|
printf '## Changes\n\n'
|
|
if [ -z "$commits" ]; then
|
|
printf 'No commits listed.\n'
|
|
else
|
|
printf '%s\n' "$commits"
|
|
if [ "$total" -gt "$RELEASE_NOTES_MAX_COMMITS" ]; then
|
|
printf '\n_Showing the newest %s of %s commits._\n' "$RELEASE_NOTES_MAX_COMMITS" "$total"
|
|
fi
|
|
fi
|
|
if [ -n "$compare_url" ]; then
|
|
printf '\n**Full changelog**: %s\n' "$compare_url"
|
|
fi
|
|
}
|
|
# END:notes
|
|
|
|
# MARK:json-escape
|
|
# Escapes a string for a JSON body. jq is used when it is installed, since it
|
|
# handles every control character; the bash fallback covers the ones release
|
|
# notes actually contain.
|
|
json_escape() {
|
|
local text="$1"
|
|
|
|
if command -v jq >/dev/null 2>&1; then
|
|
# `-s` produces one JSON string; the quotes it wraps it in are the
|
|
# characters the caller's template adds, so they come back off.
|
|
local escaped
|
|
escaped="$(printf '%s' "$text" | jq -R -s '.')"
|
|
escaped="${escaped:1:${#escaped}-2}"
|
|
printf '%s' "$escaped"
|
|
return 0
|
|
fi
|
|
|
|
local out=""
|
|
local char
|
|
local i=0
|
|
local length=${#text}
|
|
while [ "$i" -lt "$length" ]; do
|
|
char="${text:$i:1}"
|
|
case "$char" in
|
|
'\') out+='\\' ;;
|
|
'"') out+='\"' ;;
|
|
$'\n') out+='\n' ;;
|
|
$'\r') out+='\r' ;;
|
|
$'\t') out+='\t' ;;
|
|
$'\b') out+='\b' ;;
|
|
$'\f') out+='\f' ;;
|
|
*) out+="$char" ;;
|
|
esac
|
|
i=$((i + 1))
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
# END:json-escape
|
|
|
|
# MARK:confirm
|
|
confirm() {
|
|
local prompt="$1"
|
|
local answer=""
|
|
|
|
if [ "$ASSUME_YES" = "1" ]; then
|
|
return 0
|
|
fi
|
|
if [ ! -t 0 ]; then
|
|
fail "Not a terminal, so nothing was confirmed. Rerun with --yes to publish without a prompt."
|
|
fi
|
|
read -r -p "$prompt [y/N] " answer || true
|
|
case "$answer" in
|
|
y | Y | yes | YES) return 0 ;;
|
|
*) fail "Cancelled." ;;
|
|
esac
|
|
}
|
|
# END:confirm
|