Kova/scripts/release.sh
2026-04-29 12:45:35 +01:00

314 lines
8.1 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
timestamp() {
date '+%H:%M:%S'
}
log_step() {
printf '[%s] %s\n' "$(timestamp)" "$*" >&2
}
run_step() {
local description="$1"
shift
local started_at="$SECONDS"
log_step "$description"
"$@"
log_step "done: ${description} ($((SECONDS - started_at))s)"
}
usage() {
cat <<'EOF'
Prepare or resume a signed Kova release from the current main branch.
Usage:
scripts/release.sh <version> [--remote <name>] [--skip-checks]
Examples:
scripts/release.sh 0.2.0
scripts/release.sh 1.0.0-beta.1 --remote upstream
EOF
}
package_version() {
node -p 'require("./package.json").version'
}
ref_commit() {
git rev-list -n1 "$1" 2>/dev/null || true
}
remote_ref_commit() {
git ls-remote "$remote" "$1" | awk 'NR==1 { print $1 }'
}
remote_tag_commit() {
git ls-remote "$remote" "refs/tags/${tag}^{}" "refs/tags/${tag}" | awk '
$2 ~ /\^\{\}$/ { print $1; found=1; exit }
NR == 1 { first=$1 }
END { if (!found && first != "") print first }
'
}
tag_has_signature() {
git cat-file -p "$1" | grep -Eq -- '-----BEGIN (PGP|SSH) SIGNATURE-----'
}
refresh_dirty_files() {
dirty_files=()
while IFS= read -r file; do
[[ -n "$file" ]] || continue
dirty_files+=("$file")
done < <(
{
git diff --name-only --ignore-submodules --
git diff --cached --name-only --ignore-submodules --
} | sort -u
)
}
only_version_files_dirty() {
local file
[[ "${#dirty_files[@]}" -gt 0 ]] || return 1
for file in "${dirty_files[@]}"; do
case "$file" in
package.json)
;;
*)
return 1
;;
esac
done
return 0
}
current_head_subject() {
git log -1 --pretty=%s 2>/dev/null || true
}
log_resume_state() {
log_step "resume state: $*"
}
log_skip() {
log_step "skip: $*"
}
version=""
remote="origin"
skip_checks=0
while [[ $# -gt 0 ]]; do
case "$1" in
--remote)
shift
[[ $# -gt 0 ]] || { echo "error: --remote requires a value" >&2; exit 1; }
remote="$1"
;;
--skip-checks)
skip_checks=1
;;
--help|-h)
usage
exit 0
;;
-*)
echo "error: unknown argument: $1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$version" ]]; then
echo "error: version was already provided: $version" >&2
usage >&2
exit 1
fi
version="$1"
;;
esac
shift
done
if [[ -z "$version" ]]; then
usage >&2
exit 1
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "error: version must look like 1.2.3 or 1.0.0-beta.1" >&2
exit 1
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/.." && pwd)"
cd "$repo_root"
branch="$(git symbolic-ref --quiet --short HEAD || true)"
if [[ "$branch" != "main" ]]; then
echo "error: releases must be prepared from the main branch (current: ${branch:-detached})" >&2
exit 1
fi
if ! git remote get-url "$remote" >/dev/null 2>&1; then
echo "error: git remote not found: $remote" >&2
exit 1
fi
tag="v${version}"
release_commit_message="chore: bump version to ${version}"
current_version="$(package_version)"
if [[ -z "$current_version" ]]; then
echo "error: could not read Kova version from package.json" >&2
exit 1
fi
refresh_dirty_files
head_sha="$(git rev-parse HEAD)"
head_subject="$(current_head_subject)"
head_is_release_commit=0
if [[ "$head_subject" == "$release_commit_message" ]]; then
head_is_release_commit=1
fi
local_tag_commit_sha="$(ref_commit "$tag")"
remote_tag_commit_sha="$(remote_tag_commit)"
remote_main_sha="$(remote_ref_commit "refs/heads/main")"
if [[ -n "$local_tag_commit_sha" && "$local_tag_commit_sha" != "$head_sha" ]]; then
echo "error: local tag ${tag} already exists and does not point at HEAD" >&2
exit 1
fi
if [[ -n "$remote_tag_commit_sha" && "$remote_tag_commit_sha" != "$head_sha" ]]; then
echo "error: remote tag ${tag} already exists on ${remote} and does not point at HEAD" >&2
exit 1
fi
need_update_version=0
need_checks=0
need_commit=0
log_step "Preparing release ${tag} from branch ${branch} using remote ${remote}"
if [[ "$current_version" == "$version" ]]; then
if [[ "${#dirty_files[@]}" -gt 0 ]]; then
if ! only_version_files_dirty; then
echo "error: tracked changes are present; commit or stash them before running scripts/release.sh" >&2
exit 1
fi
if [[ "$head_is_release_commit" -eq 1 || -n "$local_tag_commit_sha" || -n "$remote_tag_commit_sha" ]]; then
echo "error: package.json is dirty for ${version}, but a release commit or tag already exists; clean up release state before retrying" >&2
exit 1
fi
need_checks=1
need_commit=1
log_resume_state "package.json already updated to ${version}"
else
if [[ "$head_is_release_commit" -ne 1 ]]; then
echo "error: Kova is already on ${version}, but HEAD is not the expected release commit; clean up or finish that release state manually" >&2
exit 1
fi
log_resume_state "release commit already exists at ${head_sha:0:7}"
fi
else
if [[ "${#dirty_files[@]}" -gt 0 ]]; then
echo "error: tracked changes are present; commit or stash them before running scripts/release.sh" >&2
exit 1
fi
if [[ -n "$local_tag_commit_sha" || -n "$remote_tag_commit_sha" ]]; then
echo "error: release tag ${tag} already exists, but package.json is still on ${current_version}" >&2
exit 1
fi
need_update_version=1
need_checks=1
need_commit=1
fi
if [[ "$skip_checks" -eq 0 ]]; then
if [[ "$need_checks" -eq 1 ]]; then
log_step "Local checks are enabled"
else
log_skip "local checks already passed before this resume point"
fi
else
log_step "Local checks are skipped"
need_checks=0
fi
if [[ "$need_update_version" -eq 1 ]]; then
run_step "Updating package.json to ${version}" "${script_dir}/update-version.sh" "$version"
else
log_skip "package.json is already set to ${version}"
fi
if [[ "$need_checks" -eq 1 ]]; then
run_step "Running Kova self-check" npm run check
run_step "Building release archive" "${script_dir}/package-release.sh" --output-dir ./dist
fi
if [[ "$need_commit" -eq 1 ]]; then
run_step "Staging package.json" git add package.json
if git diff --cached --quiet --ignore-submodules -- package.json; then
echo "error: no staged version change remains for ${version}; cannot create release commit" >&2
exit 1
fi
run_step "Creating release commit" git commit -m "$release_commit_message"
head_sha="$(git rev-parse HEAD)"
else
log_skip "release commit already exists"
fi
if [[ -z "$local_tag_commit_sha" && -n "$remote_tag_commit_sha" ]]; then
run_step "Fetching existing tag ${tag} from ${remote}" git fetch "$remote" "refs/tags/${tag}:refs/tags/${tag}"
local_tag_commit_sha="$(ref_commit "$tag")"
fi
if [[ -z "$local_tag_commit_sha" ]]; then
log_step "Creating signed tag ${tag}; git signing may prompt here"
if ! git -c tag.gpgSign=true tag -a "$tag" -m "$tag"; then
echo "error: failed to create signed tag ${tag}; make sure git tag signing is configured" >&2
exit 1
fi
if ! tag_has_signature "$tag"; then
git tag -d "$tag" >/dev/null 2>&1 || true
echo "error: created tag ${tag} was not signed; configure git tag signing before retrying" >&2
exit 1
fi
log_step "done: Creating signed tag ${tag}"
local_tag_commit_sha="$(ref_commit "$tag")"
else
log_skip "local tag ${tag} already exists"
fi
remote_main_sha="$(remote_ref_commit "refs/heads/main")"
remote_tag_commit_sha="$(remote_tag_commit)"
push_targets=()
if [[ "$remote_main_sha" != "$head_sha" ]]; then
push_targets+=("main")
fi
if [[ "$remote_tag_commit_sha" != "$head_sha" ]]; then
push_targets+=("$tag")
fi
if [[ "${#push_targets[@]}" -gt 0 ]]; then
run_step "Pushing ${push_targets[*]} to ${remote}" git push "$remote" "${push_targets[@]}"
else
log_skip "main and ${tag} are already pushed to ${remote}"
fi
cat <<EOF
Release prep complete for ${tag}.
Next:
1. Open GitHub Releases
2. Create or publish the release ${tag} from the existing tag
3. The release workflow will build and upload the tarballs
Optional GitHub CLI:
gh release create ${tag} --title ${tag} --generate-notes
EOF