mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
- release: work on `master`, find radicle-apt-repo next to the monorepo, tag `releases/desktop/<version>` and only commit the release files - install-binaries: use desktop's own `tests/`, not the root one - build-artifacts: pass `GIT_HEAD` to the Linux builds, which only see `desktop/` and have no `.git` - .envrc: watch `rust-toolchain.toml`
394 lines
13 KiB
Bash
Executable File
394 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Interactive, phased release orchestrator for radicle-desktop, which lives in
|
|
# the desktop/ folder of the radicle-gui monorepo.
|
|
#
|
|
# 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 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)"
|
|
REPO_DIR="$(cd "$PROJECT_DIR/.." && pwd)"
|
|
|
|
TAURI_CONF="$PROJECT_DIR/crates/radicle-tauri/tauri.conf.json"
|
|
# Every release commit bumps tauri.conf.json. Releases before desktop moved
|
|
# into desktop/ have it at the repo root, where nothing else in the monorepo
|
|
# lives, so these two paths find desktop's release commits and nothing else.
|
|
RELEASE_PATHS=("$TAURI_CONF" ":(top)crates/radicle-tauri/tauri.conf.json")
|
|
CHANGELOG="$PROJECT_DIR/CHANGELOG.md"
|
|
APT_REPO_DIR="$(cd "$REPO_DIR/../radicle-apt-repo" 2>/dev/null && pwd || true)"
|
|
|
|
PHASES=(prep build apt publish 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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
podman_available() { command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; }
|
|
|
|
# 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:-master}"
|
|
|
|
# ---- Version helpers ----
|
|
|
|
# Reads the current version from tauri.conf.json.
|
|
conf_version() {
|
|
jq -r '.version' "$TAURI_CONF"
|
|
}
|
|
|
|
# The SHA of the release commit for $1, looked up by its commit subject.
|
|
release_commit_sha() {
|
|
git -C "$PROJECT_DIR" log --grep="^Release v$1\$" -1 --format=%H -- "${RELEASE_PATHS[@]}"
|
|
}
|
|
|
|
# ---- Phases ----
|
|
|
|
phase_prep() {
|
|
local version="$1"
|
|
[[ -n "$version" ]] || die "prep requires --version X.Y.Z"
|
|
|
|
step "Preparing release v$version"
|
|
|
|
info "Syncing and fetching"
|
|
run rad sync
|
|
run git -C "$PROJECT_DIR" fetch
|
|
|
|
info "Creating branch release/desktop/$version"
|
|
run git -C "$PROJECT_DIR" checkout -b "release/desktop/$version"
|
|
|
|
step "Changelog material (for the v$version entry)"
|
|
local prev
|
|
prev="$(git -C "$PROJECT_DIR" log --grep='^Release v' -1 --format=%H -- "${RELEASE_PATHS[@]}" || 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>)' -- "$PROJECT_DIR"
|
|
echo
|
|
bold " External contributors (map these to DID links for the shout-out):"
|
|
echo
|
|
git -C "$PROJECT_DIR" log "$prev"..HEAD --format='%an <%ae>' -- "$PROJECT_DIR" \
|
|
| 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 -m "Release v$version" -- "$TAURI_CONF" "$CHANGELOG"
|
|
local sha
|
|
sha="$(git -C "$PROJECT_DIR" rev-parse HEAD)"
|
|
info "Release commit: $sha"
|
|
|
|
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 master."
|
|
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 tag"
|
|
}
|
|
|
|
phase_tag() {
|
|
local version
|
|
version="$(conf_version)"
|
|
step "Tagging canonical release v$version"
|
|
|
|
local sha
|
|
sha="$(release_commit_sha "$version")"
|
|
[[ -n "$sha" ]] || fail "could not find the 'Release v$version' commit (is the patch merged into master?)"
|
|
info "Tagging commit: ${sha:-<release-commit-sha>}"
|
|
|
|
local tag="releases/desktop/$version"
|
|
run git -C "$PROJECT_DIR" tag "$tag" "${sha:-<release-commit-sha>}" -m "Release $version"
|
|
run git -C "$PROJECT_DIR" push rad "refs/tags/$tag"
|
|
run rad sync
|
|
|
|
step "Canonical tag pushed"
|
|
info "Any delegate's push of a releases/ tag makes it canonical."
|
|
|
|
step "Announce on Zulip"
|
|
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 "@**all** A new Radicle Desktop release is out :point_right: [give it a try](https://radicle.network/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 ;;
|
|
tag) phase_tag ;;
|
|
*) die "Unknown phase: $phase (try --list)" ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|