|
| 1 | +#!/usr/bin/env bash |
| 2 | +# ============================================================================= |
| 3 | +# release.sh — iFace GitHub Release Script |
| 4 | +# |
| 5 | +# Usage: |
| 6 | +# ./scripts/release.sh # use version from package.json |
| 7 | +# ./scripts/release.sh 1.0.0 # override version |
| 8 | +# ./scripts/release.sh --dry-run # preview without making changes |
| 9 | +# |
| 10 | +# Requirements: |
| 11 | +# - git |
| 12 | +# - gh (GitHub CLI, `brew install gh` then `gh auth login`) |
| 13 | +# ============================================================================= |
| 14 | + |
| 15 | +set -euo pipefail |
| 16 | + |
| 17 | +# ─── Colors ─────────────────────────────────────────────────────────────────── |
| 18 | +RED='\033[0;31m' |
| 19 | +GREEN='\033[0;32m' |
| 20 | +YELLOW='\033[1;33m' |
| 21 | +BLUE='\033[0;34m' |
| 22 | +CYAN='\033[0;36m' |
| 23 | +BOLD='\033[1m' |
| 24 | +RESET='\033[0m' |
| 25 | + |
| 26 | +# ─── Helpers ────────────────────────────────────────────────────────────────── |
| 27 | +log_info() { echo -e "${BLUE}ℹ${RESET} $*"; } |
| 28 | +log_success() { echo -e "${GREEN}✓${RESET} $*"; } |
| 29 | +log_warn() { echo -e "${YELLOW}⚠${RESET} $*"; } |
| 30 | +log_error() { echo -e "${RED}✗${RESET} $*" >&2; } |
| 31 | +log_step() { echo -e "\n${BOLD}${CYAN}▶ $*${RESET}"; } |
| 32 | + |
| 33 | +die() { |
| 34 | + log_error "$*" |
| 35 | + exit 1 |
| 36 | +} |
| 37 | + |
| 38 | +# ─── Parse Args ─────────────────────────────────────────────────────────────── |
| 39 | +DRY_RUN=false |
| 40 | +VERSION_OVERRIDE="" |
| 41 | + |
| 42 | +for arg in "$@"; do |
| 43 | + case "$arg" in |
| 44 | + --dry-run) DRY_RUN=true ;; |
| 45 | + --help|-h) |
| 46 | + echo "Usage: $0 [version] [--dry-run]" |
| 47 | + echo "" |
| 48 | + echo " version Semantic version string, e.g. 1.2.0 (default: from package.json)" |
| 49 | + echo " --dry-run Preview all steps without making any changes" |
| 50 | + exit 0 |
| 51 | + ;; |
| 52 | + -*) die "Unknown flag: $arg. Use --help for usage." ;; |
| 53 | + *) VERSION_OVERRIDE="$arg" ;; |
| 54 | + esac |
| 55 | +done |
| 56 | + |
| 57 | +# ─── Resolve project root ───────────────────────────────────────────────────── |
| 58 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 59 | +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" |
| 60 | +cd "$ROOT" |
| 61 | + |
| 62 | +# ─── Check prerequisites ────────────────────────────────────────────────────── |
| 63 | +log_step "Checking prerequisites" |
| 64 | + |
| 65 | +command -v git >/dev/null 2>&1 || die "git is not installed" |
| 66 | +command -v gh >/dev/null 2>&1 || die "GitHub CLI (gh) is not installed. Run: brew install gh" |
| 67 | + |
| 68 | +gh auth status >/dev/null 2>&1 || die "Not authenticated with GitHub CLI. Run: gh auth login" |
| 69 | +log_success "Prerequisites OK" |
| 70 | + |
| 71 | +# ─── Resolve version ────────────────────────────────────────────────────────── |
| 72 | +log_step "Resolving version" |
| 73 | + |
| 74 | +if [[ -n "$VERSION_OVERRIDE" ]]; then |
| 75 | + VERSION="$VERSION_OVERRIDE" |
| 76 | + log_info "Using override version: $VERSION" |
| 77 | +else |
| 78 | + VERSION="$(node -p "require('./package.json').version" 2>/dev/null)" \ |
| 79 | + || die "Could not read version from package.json" |
| 80 | + log_info "Using version from package.json: $VERSION" |
| 81 | +fi |
| 82 | + |
| 83 | +# Validate semver format |
| 84 | +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$'; then |
| 85 | + die "Invalid version format: '$VERSION'. Expected semver, e.g. 1.2.3 or 1.2.3-beta.1" |
| 86 | +fi |
| 87 | + |
| 88 | +TAG="v$VERSION" |
| 89 | + |
| 90 | +# ─── Git status checks ──────────────────────────────────────────────────────── |
| 91 | +log_step "Checking git status" |
| 92 | + |
| 93 | +CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" |
| 94 | +log_info "Current branch: $CURRENT_BRANCH" |
| 95 | + |
| 96 | +if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then |
| 97 | + log_warn "You are not on main/master branch (currently on '$CURRENT_BRANCH')" |
| 98 | + read -rp " Continue anyway? [y/N] " confirm |
| 99 | + [[ "$confirm" =~ ^[Yy]$ ]] || { log_info "Aborted."; exit 0; } |
| 100 | +fi |
| 101 | + |
| 102 | +# Check for uncommitted changes |
| 103 | +if ! git diff --quiet || ! git diff --cached --quiet; then |
| 104 | + die "You have uncommitted changes. Please commit or stash them before releasing." |
| 105 | +fi |
| 106 | +log_success "Working tree is clean" |
| 107 | + |
| 108 | +# Check if tag already exists |
| 109 | +if git tag --list | grep -q "^${TAG}$"; then |
| 110 | + die "Tag '${TAG}' already exists. Bump the version in package.json first." |
| 111 | +fi |
| 112 | + |
| 113 | +# ─── Build ──────────────────────────────────────────────────────────────────── |
| 114 | +log_step "Building project" |
| 115 | + |
| 116 | +if $DRY_RUN; then |
| 117 | + log_warn "[dry-run] Skipping build" |
| 118 | +else |
| 119 | + if command -v bun >/dev/null 2>&1; then |
| 120 | + bun run build |
| 121 | + elif command -v npm >/dev/null 2>&1; then |
| 122 | + npm run build |
| 123 | + else |
| 124 | + die "No package manager found (bun / npm)" |
| 125 | + fi |
| 126 | + log_success "Build succeeded" |
| 127 | +fi |
| 128 | + |
| 129 | +# ─── Generate changelog from git log ───────────────────────────────────────── |
| 130 | +log_step "Generating changelog" |
| 131 | + |
| 132 | +# Find the previous tag to diff against |
| 133 | +PREV_TAG="$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 || true)" |
| 134 | + |
| 135 | +if [[ -z "$PREV_TAG" ]]; then |
| 136 | + log_info "No previous tag found — using full history" |
| 137 | + LOG_RANGE="HEAD" |
| 138 | +else |
| 139 | + log_info "Comparing with previous tag: $PREV_TAG" |
| 140 | + LOG_RANGE="${PREV_TAG}..HEAD" |
| 141 | +fi |
| 142 | + |
| 143 | +# Categorise commits by conventional commit type |
| 144 | +get_commits_by_type() { |
| 145 | + local type="$1" |
| 146 | + git log "$LOG_RANGE" \ |
| 147 | + --pretty=format:"- %s ([%h](../../commit/%H))" \ |
| 148 | + --no-merges \ |
| 149 | + 2>/dev/null \ |
| 150 | + | grep -iE "^- ${type}(\(.+\))?[!]?:" \ |
| 151 | + | sed -E "s/^- ${type}(\(.+\))?[!]?:/ -/" \ |
| 152 | + || true |
| 153 | +} |
| 154 | + |
| 155 | +FEAT="$(get_commits_by_type "feat")" |
| 156 | +FIX="$(get_commits_by_type "fix")" |
| 157 | +PERF="$(get_commits_by_type "perf")" |
| 158 | +REFACTOR="$(get_commits_by_type "refactor")" |
| 159 | +CHORE="$(get_commits_by_type "chore")" |
| 160 | +DOCS="$(get_commits_by_type "docs")" |
| 161 | + |
| 162 | +# Commits that don't match conventional format |
| 163 | +OTHER="$(git log "$LOG_RANGE" \ |
| 164 | + --pretty=format:"- %s ([%h](../../commit/%H))" \ |
| 165 | + --no-merges \ |
| 166 | + 2>/dev/null \ |
| 167 | + | grep -vE "^- (feat|fix|perf|refactor|chore|docs|test|ci|style|build)(\(.+\))?[!]?:" \ |
| 168 | + || true)" |
| 169 | + |
| 170 | +RELEASE_DATE="$(date +%Y-%m-%d)" |
| 171 | + |
| 172 | +# Build the release notes body |
| 173 | +NOTES="" |
| 174 | +NOTES+="## iFace ${TAG} — ${RELEASE_DATE}\n\n" |
| 175 | + |
| 176 | +[[ -n "$FEAT" ]] && NOTES+="### ✨ 新功能\n${FEAT}\n\n" |
| 177 | +[[ -n "$FIX" ]] && NOTES+="### 🐛 问题修复\n${FIX}\n\n" |
| 178 | +[[ -n "$PERF" ]] && NOTES+="### ⚡ 性能优化\n${PERF}\n\n" |
| 179 | +[[ -n "$REFACTOR" ]] && NOTES+="### ♻️ 代码重构\n${REFACTOR}\n\n" |
| 180 | +[[ -n "$DOCS" ]] && NOTES+="### 📖 文档\n${DOCS}\n\n" |
| 181 | +[[ -n "$CHORE" ]] && NOTES+="### 🔧 其他\n${CHORE}\n\n" |
| 182 | +[[ -n "$OTHER" ]] && NOTES+="### 📦 变更\n${OTHER}\n\n" |
| 183 | + |
| 184 | +if [[ -z "$FEAT$FIX$PERF$REFACTOR$DOCS$CHORE$OTHER" ]]; then |
| 185 | + NOTES+="_No changes found since last release._\n\n" |
| 186 | +fi |
| 187 | + |
| 188 | +NOTES+="---\n" |
| 189 | +NOTES+="**Full Changelog**: https://github.com/dogxii/iFace/compare/${PREV_TAG:-}...${TAG}" |
| 190 | + |
| 191 | +echo -e "\n${BOLD}Release Notes Preview:${RESET}" |
| 192 | +echo "─────────────────────────────────────────────" |
| 193 | +echo -e "$NOTES" |
| 194 | +echo "─────────────────────────────────────────────" |
| 195 | + |
| 196 | +# ─── Confirm ────────────────────────────────────────────────────────────────── |
| 197 | +echo "" |
| 198 | +if $DRY_RUN; then |
| 199 | + log_warn "[dry-run] Would create tag '${TAG}' and GitHub Release" |
| 200 | + log_success "Dry run complete — no changes were made" |
| 201 | + exit 0 |
| 202 | +fi |
| 203 | + |
| 204 | +read -rp "$(echo -e "${BOLD}Create release ${TAG}?${RESET} [y/N] ")" confirm |
| 205 | +[[ "$confirm" =~ ^[Yy]$ ]] || { log_info "Aborted."; exit 0; } |
| 206 | + |
| 207 | +# ─── Create and push tag ────────────────────────────────────────────────────── |
| 208 | +log_step "Creating git tag ${TAG}" |
| 209 | + |
| 210 | +git tag -a "$TAG" -m "Release ${TAG}" |
| 211 | +log_success "Tag '${TAG}' created" |
| 212 | + |
| 213 | +git push origin "$TAG" |
| 214 | +log_success "Tag '${TAG}' pushed to origin" |
| 215 | + |
| 216 | +# ─── Create GitHub Release ──────────────────────────────────────────────────── |
| 217 | +log_step "Creating GitHub Release" |
| 218 | + |
| 219 | +NOTES_FILE="$(mktemp)" |
| 220 | +echo -e "$NOTES" > "$NOTES_FILE" |
| 221 | + |
| 222 | +# Determine if pre-release |
| 223 | +IS_PRERELEASE=false |
| 224 | +echo "$VERSION" | grep -qE '-(alpha|beta|rc)' && IS_PRERELEASE=true |
| 225 | + |
| 226 | +PRERELEASE_FLAG="" |
| 227 | +$IS_PRERELEASE && PRERELEASE_FLAG="--prerelease" |
| 228 | + |
| 229 | +gh release create "$TAG" \ |
| 230 | + --title "iFace ${TAG}" \ |
| 231 | + --notes-file "$NOTES_FILE" \ |
| 232 | + $PRERELEASE_FLAG |
| 233 | + |
| 234 | +rm -f "$NOTES_FILE" |
| 235 | + |
| 236 | +# ─── Done ───────────────────────────────────────────────────────────────────── |
| 237 | +echo "" |
| 238 | +echo -e "${GREEN}${BOLD}🎉 Release ${TAG} published successfully!${RESET}" |
| 239 | +echo -e " ${CYAN}https://github.com/dogxii/iFace/releases/tag/${TAG}${RESET}" |
0 commit comments