Files
radicle-explorer/scripts/release
T
Rūdolfs Ošiņš eca06b7b31 Check if prerequisites are met before cutting release
Make sure docker and podman are up and running as they're necessary for
the arch packaging and artifact building.

This also shows what commands would have been run when a phase fails.
2026-06-09 17:04:59 +02:00

437 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Interactive, phased release orchestrator for radicle-desktop.
#
# Automates the mechanical steps of the release process and pauses at the human
# gates (CI/patch approval, DMG smoke-test, the cross-repo apt signing, delegate
# tag approval, Zulip announce). Each phase prints what to do next and the exact
# flag to resume with. State is not persisted: the version comes from --version
# (prep) or tauri.conf.json (later phases), and commit SHAs are derived from git.
#
# Usage:
# scripts/release --phase prep --version X.Y.Z
# scripts/release --phase build
# scripts/release --phase apt
# scripts/release --phase publish
# scripts/release --phase arch
# scripts/release --phase tag
#
# scripts/release --list List the phases in order
# scripts/release --help Show this help
#
# Add --dry-run (-n) to a phase to print the commands it would run without
# executing them (read-only lookups still run, so the preview is realistic).
#
# Config is auto-detected and overridable via environment variables:
# FULL_NAME (default: git config user.name)
# EMAIL (default: git config user.email)
# USERNAME (default: $USER)
# SSH_KEY (default: $HOME/.ssh/id_ed25519)
# SIGNING_KEY (default: $HOME/.ssh/apt_signing_key)
# FILES_HOST (default: files.radicle.dev)
set -Eeuo pipefail
DRY_RUN=false
CURRENT_PHASE=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TAURI_CONF="$PROJECT_DIR/crates/radicle-tauri/tauri.conf.json"
PKGBUILD="$PROJECT_DIR/arch/radicle-desktop/PKGBUILD"
CHANGELOG="$PROJECT_DIR/CHANGELOG.md"
APT_REPO_DIR="$(cd "$PROJECT_DIR/../radicle-apt-repo" 2>/dev/null && pwd || true)"
PHASES=(prep build apt publish arch tag)
# ---- Output helpers ----
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
step() { echo; bold "==> $*"; }
info() { echo " $*"; }
warn() { echo " WARNING: $*" >&2; }
die() { echo "Error: $*" >&2; trap - ERR; exit 1; }
# Fires on any uncaught command failure inside a phase. Points at --dry-run,
# which prints the phase's full command sequence (with real values) so the
# remaining steps can be finished by hand.
on_error() {
local code=$?
trap - ERR
echo >&2
echo "Error: phase '$CURRENT_PHASE' did not finish (exit $code)." >&2
echo "Fix the problem above, then review the phase's commands to continue" >&2
echo "manually from where it stopped:" >&2
echo >&2
echo " scripts/release --phase $CURRENT_PHASE --dry-run" >&2
}
confirm() {
local prompt="$1" reply
if [[ "$DRY_RUN" == true ]]; then
info "$prompt [dry-run: assuming yes]"
return 0
fi
read -r -p " $prompt [y/N] " reply
[[ "$reply" =~ ^[Yy]$ ]]
}
# Run a mutating command, or just print it under --dry-run.
run() {
if [[ "$DRY_RUN" == true ]]; then
printf ' [dry-run] %s\n' "$*"
else
"$@"
fi
}
# Like run(), but for a command that must run in directory $1.
run_sh() {
if [[ "$DRY_RUN" == true ]]; then
printf ' [dry-run] (cd %s && %s)\n' "$1" "${*:2}"
else
( cd "$1" && "${@:2}" )
fi
}
# Abort on a missing prerequisite — but only warn under --dry-run, so later
# phases can still be previewed without their inputs present.
fail() {
if [[ "$DRY_RUN" == true ]]; then warn "$*"; else die "$*"; fi
}
# generate-srcinfo.sh needs a running Docker daemon. Check it before prep
# mutates anything, so a docker-down situation fails cleanly. Skipped under
# --dry-run, where the Docker step only prints.
docker_available() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
podman_available() { command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; }
require_docker() {
if [[ "$DRY_RUN" == true ]]; then
docker_available || warn "Docker is not available (a real run needs it to regenerate .SRCINFO)"
return 0
fi
command -v docker >/dev/null 2>&1 || die "docker not found (needed to regenerate .SRCINFO)"
docker info >/dev/null 2>&1 || die "Docker daemon is not running — start Docker and re-run"
}
# build-artifacts enforces a running Podman itself on a real build. This mirror
# only surfaces it under --dry-run (build-artifacts isn't executed then), so the
# preview reports a down/absent Podman too.
require_podman() {
[[ "$DRY_RUN" == true ]] || return 0
podman_available || warn "Podman is not available (a real build needs it for the Linux artifacts)"
}
# Every phase runs on the default branch (prep branches off it; later phases run
# after the patch is merged back). Bail with a clear message if we're elsewhere,
# so the user can switch and re-run. Only a warning under --dry-run.
require_branch() {
local current
current="$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD)"
[[ "$current" == "$EXPECTED_BRANCH" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
warn "on branch '$current', expected '$EXPECTED_BRANCH'"
else
die "on branch '$current', expected '$EXPECTED_BRANCH' — run 'git checkout $EXPECTED_BRANCH', then re-run"
fi
}
# Print the command(s) the user should run next to resume the flow.
# Under --dry-run the hint carries the flag through, so previewing one phase
# suggests previewing the next.
next() {
echo
bold "Next:"
local cmd
for cmd in "$@"; do
[[ "$DRY_RUN" == true ]] && cmd+=" --dry-run"
printf ' %s\n' "$cmd"
done
}
# ---- Config (auto-detect, env-overridable) ----
FULL_NAME="${FULL_NAME:-$(git -C "$PROJECT_DIR" config user.name)}"
EMAIL="${EMAIL:-$(git -C "$PROJECT_DIR" config user.email)}"
USERNAME="${USERNAME:-${USER:-}}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
SIGNING_KEY="${SIGNING_KEY:-$HOME/.ssh/apt_signing_key}"
FILES_HOST="${FILES_HOST:-files.radicle.dev}"
EXPECTED_BRANCH="${EXPECTED_BRANCH:-main}"
# ---- Version helpers ----
# Reads the current version from tauri.conf.json.
conf_version() {
jq -r '.version' "$TAURI_CONF"
}
# The SHA of the arch-package commit for $1, looked up by its commit subject.
# This is the second commit of the release patch (its tip) and the one we tag.
arch_commit_sha() {
git -C "$PROJECT_DIR" log --grep="^Update arch package to v$1\$" -1 --format=%H
}
# ---- Phases ----
phase_prep() {
local version="$1"
[[ -n "$version" ]] || die "prep requires --version X.Y.Z"
require_docker
step "Preparing release v$version"
info "Syncing and fetching"
run rad sync
run git -C "$PROJECT_DIR" fetch
info "Creating branch release/v$version"
run git -C "$PROJECT_DIR" checkout -b "release/v$version"
step "Changelog material (for the v$version entry)"
local prev
prev="$(git -C "$PROJECT_DIR" log --grep='^Release v' -1 --format=%H || true)"
if [[ -n "$prev" ]]; then
info "Commits since $(git -C "$PROJECT_DIR" log -1 --format='%s' "$prev"):"
echo
git -C "$PROJECT_DIR" log "$prev"..HEAD --format='- %s (%an <%ae>)'
echo
bold " External contributors (map these to DID links for the shout-out):"
echo
git -C "$PROJECT_DIR" log "$prev"..HEAD --format='%an <%ae>' \
| sort -u | grep -v -F "$EMAIL" || true
echo
info "Format the list into the user-facing changes for the CHANGELOG entry."
else
warn "no previous 'Release v*' commit found; writing the entry from scratch"
fi
if ! confirm "Continue and open CHANGELOG.md in \$EDITOR?"; then
die "Aborted before editing CHANGELOG."
fi
step "Bumping version to $version"
run sed -i '' -E "s/^( \"version\": \")[^\"]+(\",)$/\1$version\2/" "$TAURI_CONF"
[[ "$DRY_RUN" == true ]] || info "tauri.conf.json -> $(conf_version)"
step "Editing CHANGELOG.md"
info "Add the v$version entry (Features, Bug fixes, Shout-out block)."
run "${EDITOR:-vi}" "$CHANGELOG"
step "Creating Release Commit"
run git -C "$PROJECT_DIR" commit -a -m "Release v$version"
local sha
sha="$(git -C "$PROJECT_DIR" rev-parse HEAD)"
info "Release commit: $sha"
step "Updating Arch package to v$version"
run sed -i '' -E "s/^_commit=.*/_commit=$sha/" "$PKGBUILD"
run sed -i '' -E "s/^pkgver=.*/pkgver='$version'/" "$PKGBUILD"
run_sh "$PROJECT_DIR/arch" ./generate-srcinfo.sh
run git -C "$PROJECT_DIR" add arch/radicle-desktop/.SRCINFO arch/radicle-desktop/PKGBUILD
run git -C "$PROJECT_DIR" commit -m "Update arch package to v$version"
step "Pushing Release Patch"
if confirm "Push the Release Patch now (git push rad HEAD:refs/patches)?"; then
run git -C "$PROJECT_DIR" push rad HEAD:refs/patches
info "Wait for CI to pass, get the patch approved, and merge it into main."
else
info "Skipped. Push it yourself when ready: git push rad HEAD:refs/patches"
fi
next "scripts/release --phase build"
}
phase_build() {
local version
version="$(conf_version)"
require_podman
step "Building release artifacts for v$version"
run "$SCRIPT_DIR/build-artifacts"
step "Build complete"
info "Install releases/v$version/radicle-desktop-aarch64.dmg and verify it runs."
next "scripts/release --phase apt"
}
phase_apt() {
local version
version="$(conf_version)"
local deb="radicle-desktop_${version}_amd64.deb"
local src="$PROJECT_DIR/releases/v$version/$deb"
step "Staging the Debian package for signing"
[[ -f "$src" ]] || fail "$src not found — run --phase build first"
[[ -n "$APT_REPO_DIR" ]] || fail "../radicle-apt-repo not found"
run cp "$src" "${APT_REPO_DIR:-../radicle-apt-repo}/"
info "Copied $deb -> ${APT_REPO_DIR:-../radicle-apt-repo}/"
step "Sign and publish the package from radicle-apt-repo"
info "Run these in $APT_REPO_DIR:"
echo
cat <<EOF
rad sync && git fetch
docker build -t radicle-apt scripts
docker run --rm -it \\
-v "\$(pwd):/src/apt" \\
-v "$SIGNING_KEY:/src/keys/signing-key" \\
-e SIGNING_KEY=/src/keys/signing-key \\
-e DEBEMAIL="$EMAIL" \\
-e DEBFULLNAME="$FULL_NAME" \\
-e SSH_USER="$USERNAME" \\
-v "$SSH_KEY:/root/.ssh/id_ed25519:ro" \\
radicle-apt
# Inside the container:
RADICLE_APT_SIGNING_KEY=/src/keys/signing-key ./import-deb $deb
# Back on the host:
git checkout -b release-v$version
git add db/ dists/ pool/
git commit -m "Update radicle-desktop to $version"
git push rad HEAD:refs/patches
# After peer approval:
git checkout main && git merge --ff-only release-v$version && git push
make publish
EOF
next "scripts/release --phase publish"
}
phase_publish() {
local version
version="$(conf_version)"
local release_dir="$PROJECT_DIR/releases/v$version"
local remote="/var/www/$FILES_HOST/releases/radicle-desktop"
local key target
key="$(rad path)/keys/radicle"
target="$USERNAME@$FILES_HOST"
step "Publishing release files to $FILES_HOST"
[[ -d "$release_dir" ]] || fail "$release_dir not found — run --phase build first"
if [[ "$DRY_RUN" == true ]]; then
info "Would check whether $remote/$version exists and prompt before overwrite."
elif ssh -i "$key" "$target" "test -d '$remote/$version'"; then
warn "$remote/$version already exists on $FILES_HOST"
confirm "Overwrite it and re-point latest?" || die "Aborted."
fi
info "Uploading to $remote/$version/"
run ssh -i "$key" "$target" "mkdir -p '$remote/$version'"
run scp -i "$key" "$release_dir"/* "$target:$remote/$version/"
info "Pointing latest -> $version"
run ssh -i "$key" "$target" "ln -sfn '$version' '$remote/latest'"
step "Published"
info "https://$FILES_HOST/releases/radicle-desktop/$version/"
info "latest now resolves to $version (update checker reads latest/latest.json)."
next "scripts/release --phase arch"
}
phase_arch() {
local version
version="$(conf_version)"
step "Publishing the Arch package to the AUR"
run_sh "$PROJECT_DIR/arch" ./publish.sh "Release v$version"
step "AUR package pushed"
info "Verify the version at https://aur.archlinux.org/packages/radicle-desktop"
next "scripts/release --phase tag"
}
phase_tag() {
local version
version="$(conf_version)"
step "Tagging canonical release v$version"
local sha
sha="$(arch_commit_sha "$version")"
[[ -n "$sha" ]] || fail "could not find the 'Update arch package to v$version' commit (is the patch merged into main?)"
info "Tagging commit: ${sha:-<arch-commit-sha>}"
run git -C "$PROJECT_DIR" tag "releases/$version" "${sha:-<arch-commit-sha>}" -m "Release $version"
run git -C "$PROJECT_DIR" push rad --tags
run rad sync
step "Canonical tag pushed — needs delegate approval"
info "Ask another delegate to approve the tag by running:"
echo
printf ' %s\n' \
"git push -f self $USERNAME/tags/releases/$version:refs/tags/releases/$version" \
"rad sync -a"
step "Announce on Zulip (once the tag is approved)"
local entry
entry="$(awk -v v="### v$version" '
$0 == v {grab=1}
grab && NR>1 && /^### v/ && $0 != v {exit}
grab {print}
' "$CHANGELOG")"
[[ -n "$entry" ]] || fail "no '### v$version' section found in CHANGELOG.md"
echo
bold " Copy the message below into the announcements channel:"
echo " --------------------------------------------------------------------"
echo
echo "@**all** A new Radicle Desktop release is out :point_right: [give it a try](https://radicle.dev/desktop)!"
echo
echo "$entry"
echo " --------------------------------------------------------------------"
echo
info "Post it under a topic named: radicle-desktop v$version"
info "Then resolve the previous release topic."
echo
bold "Release v$version complete."
}
# ---- Dispatch ----
usage() {
awk 'NR==1 {next} /^#/ {sub(/^# ?/, ""); print; next} {exit}' "$0"
}
list_phases() {
echo "Phases (run in order):"
printf ' %s\n' "${PHASES[@]}"
}
main() {
local phase="" version=""
while [[ $# -gt 0 ]]; do
case "$1" in
--phase) [[ $# -ge 2 ]] || die "--phase needs a value (try --list)"
phase="$2"; shift 2 ;;
--version) [[ $# -ge 2 ]] || die "--version needs a value, e.g. X.Y.Z"
version="$2"; shift 2 ;;
--dry-run|-n) DRY_RUN=true; shift ;;
--list) list_phases; exit 0 ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1 (try --help)" ;;
esac
done
[[ -n "$phase" ]] || { usage; exit 1; }
[[ "$DRY_RUN" == true ]] && bold "DRY RUN — no commands will be executed"
require_branch
CURRENT_PHASE="$phase"
trap on_error ERR
case "$phase" in
prep) phase_prep "$version" ;;
build) phase_build ;;
apt) phase_apt ;;
publish) phase_publish ;;
arch) phase_arch ;;
tag) phase_tag ;;
*) die "Unknown phase: $phase (try --list)" ;;
esac
}
main "$@"