diff --git a/Makefile b/Makefile index 08462b6..b3325bc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry +.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push BINARY = unarr SENTRY_DSN ?= @@ -71,6 +71,19 @@ release-dry: @test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; } @./scripts/release.sh --dry-run $(V) +## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions. +## Reads version from internal/cmd/version.go unless V= is provided. +ship: + @./scripts/ship.sh $(V) + +## Ship + git push tag to GH afterwards +ship-push: + @./scripts/ship.sh --push $(V) + +## Preview ship steps without executing +ship-dry: + @./scripts/ship.sh --dry-run $(V) + ## Remove generated files clean: rm -f $(BINARY) coverage.out coverage.html diff --git a/scripts/ship.sh b/scripts/ship.sh new file mode 100755 index 0000000..e45eab2 --- /dev/null +++ b/scripts/ship.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# +# ship.sh — End-to-end CLI release pipeline. +# +# Standalone backup for when GitHub Actions is unavailable (org shadow-ban, +# CI outage, etc). Mirrors what release.yml + docker job in CI would do. +# +# Pre-requisites: +# - scripts/release.sh already ran → version.go bumped + tag created locally +# - SENTRY_DSN exported (Sentry disabled in build if missing) +# - docker logged in to docker.io as the org user +# - SSH key for Hetzner publishing (see publish-cli-release.sh) +# +# Pipeline: +# 1. Sanity: clean tree, tag at HEAD, version.go matches +# 2. goreleaser build (skip GH publish — produces dist/*) +# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh +# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub +# 5. Smoke checks (torrentclaw.com/version + docker run image version) +# 6. Optional `git push --follow-tags` +# +# Usage: +# scripts/ship.sh Detect version from internal/cmd/version.go +# scripts/ship.sh 0.9.12 Explicit version +# scripts/ship.sh --dry-run Preview steps, no side effects +# scripts/ship.sh --push 0.9.12 Also git-push tag to GH afterwards +# +# Env knobs: +# SENTRY_DSN telemetry DSN injected at build time +# RELEASE_SIGNING_PUBKEY ed25519 pubkey (base64) for self-update signature check +# DOCKER_IMAGE default torrentclaw/unarr +# PUBLISH_SCRIPT default ../torrentclaw-web/scripts/publish-cli-release.sh +# SKIP_DOCKER=1 skip Docker build/push +# SKIP_HETZNER=1 skip Hetzner publish +# SKIP_SMOKE=1 skip smoke checks +# +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_DIR" + +DOCKER_IMAGE="${DOCKER_IMAGE:-torrentclaw/unarr}" +PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-cli-release.sh}" +SKIP_DOCKER="${SKIP_DOCKER:-0}" +SKIP_HETZNER="${SKIP_HETZNER:-0}" +SKIP_SMOKE="${SKIP_SMOKE:-0}" + +DRY_RUN=false +PUSH_TAG=false +VERSION="" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +for a in "$@"; do + case "$a" in + --dry-run) DRY_RUN=true ;; + --push) PUSH_TAG=true ;; + -h|--help) + sed -n '2,/^set /p' "$0" | sed 's/^#\s\?//;$d' + exit 0 ;; + [0-9]*) VERSION="$a" ;; + *) die "unknown arg: $a (use --help)" ;; + esac +done + +read_version_go() { + grep 'var Version' internal/cmd/version.go | sed 's/.*"\(.*\)".*/\1/' +} + +REPO_VERSION="$(read_version_go)" +[ -z "$VERSION" ] && VERSION="$REPO_VERSION" +[ -n "$VERSION" ] || die "cannot detect version (pass explicit X.Y.Z)" +TAG="v$VERSION" +MINOR="${VERSION%.*}" + +echo "" +echo -e " ${BOLD}Ship Plan${NC}" +echo -e " ─────────────────────────────" +echo -e " Version: ${GREEN}$TAG${NC}" +echo -e " Docker image: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}" +echo -e " Skip Hetzner: $SKIP_HETZNER" +echo -e " Skip Docker: $SKIP_DOCKER" +echo -e " Push to GH: $PUSH_TAG" +echo -e " Dry run: $DRY_RUN" +echo "" + +# Sanity +[ "$REPO_VERSION" = "$VERSION" ] || die "version.go=$REPO_VERSION ≠ requested $VERSION (bump with make release-* first)" + +if [ "$DRY_RUN" = false ]; then + [ -z "$(git status --porcelain)" ] || die "working tree dirty" + git rev-parse "$TAG" >/dev/null 2>&1 || die "tag $TAG missing — run scripts/release.sh first" + + HEAD_SHA="$(git rev-parse HEAD)" + TAG_SHA="$(git rev-parse "$TAG^{commit}")" + [ "$HEAD_SHA" = "$TAG_SHA" ] || die "HEAD ($HEAD_SHA) ≠ tag commit ($TAG_SHA) — checkout $TAG first" + + command -v goreleaser >/dev/null || die "goreleaser not installed" + [ "$SKIP_DOCKER" = "1" ] || command -v docker >/dev/null || die "docker not installed" + [ "$SKIP_HETZNER" = "1" ] || [ -x "$PUBLISH_SCRIPT" ] || die "publish script missing or not executable: $PUBLISH_SCRIPT" + + if [ -z "${SENTRY_DSN:-}" ]; then + warn "SENTRY_DSN unset — built binaries will have Sentry disabled" + fi +fi + +if [ "$DRY_RUN" = true ]; then + ok "Dry run complete — no changes made" + exit 0 +fi + +# 1. Build +info "goreleaser build ($TAG)" +SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_PUBKEY="${RELEASE_SIGNING_PUBKEY:-}" \ + goreleaser release --clean --skip=publish +ok "dist/ ready" + +# 2. Hetzner +if [ "$SKIP_HETZNER" != "1" ]; then + info "publishing to Hetzner releases volume" + "$PUBLISH_SCRIPT" "$VERSION" + ok "Hetzner version.txt flipped to $VERSION" +fi + +# 3. Docker +if [ "$SKIP_DOCKER" != "1" ]; then + info "docker buildx multi-arch push ($DOCKER_IMAGE:$VERSION, :$MINOR, :latest)" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="$TAG" \ + -t "$DOCKER_IMAGE:$VERSION" \ + -t "$DOCKER_IMAGE:$MINOR" \ + -t "$DOCKER_IMAGE:latest" \ + --push . + ok "Docker Hub: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}" +fi + +# 4. Smoke +if [ "$SKIP_SMOKE" != "1" ]; then + info "smoke checks" + if [ "$SKIP_HETZNER" != "1" ]; then + LIVE_VERSION="$(curl -fsSL https://torrentclaw.com/version 2>/dev/null | tr -d '[:space:]' || echo '')" + if [ "$LIVE_VERSION" = "$VERSION" ]; then + ok "torrentclaw.com/version = $LIVE_VERSION" + else + warn "torrentclaw.com/version = '$LIVE_VERSION' (expected $VERSION)" + fi + fi + + if [ "$SKIP_DOCKER" != "1" ]; then + DOCKER_VERSION="$(docker run --rm "$DOCKER_IMAGE:$VERSION" version 2>/dev/null | grep -oE 'v[0-9.]+' | head -1)" + if [ "$DOCKER_VERSION" = "$TAG" ]; then + ok "docker image $DOCKER_IMAGE:$VERSION reports $DOCKER_VERSION" + else + warn "docker image reports '$DOCKER_VERSION' (expected $TAG)" + fi + fi +fi + +# 5. Optional push +if [ "$PUSH_TAG" = true ]; then + info "git push origin main --follow-tags" + git push origin main --follow-tags + ok "tag $TAG pushed to GitHub" +fi + +echo "" +ok "${BOLD}$TAG shipped${NC}"