Skip to content

Commit 354173e

Browse files
committed
add release script
1 parent 1ac8255 commit 354173e

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

scripts/release.sh

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
#!/usr/bin/env bash
2+
# scripts/release.sh - Interactive GitHub release creator
3+
# Usage: ./scripts/release.sh
4+
5+
set -euo pipefail
6+
7+
# ─── Helpers ──────────────────────────────────────────────────────────────────
8+
9+
red() { printf '\033[31m%s\033[0m\n' "$*"; }
10+
green() { printf '\033[32m%s\033[0m\n' "$*"; }
11+
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
12+
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
13+
info() { printf ' %s\n' "$*"; }
14+
15+
die() { red "Error: $*" >&2; exit 1; }
16+
17+
semver_valid() {
18+
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
19+
}
20+
21+
semver_parts() {
22+
local tag="${1#v}" # strip leading 'v'
23+
IFS='.' read -r major minor patch <<< "$tag"
24+
echo "$major" "$minor" "$patch"
25+
}
26+
27+
bump_version() {
28+
local base="$1" bump="$2"
29+
read -r major minor patch <<< "$(semver_parts "$base")"
30+
case "$bump" in
31+
major) echo "v$((major + 1)).0.0" ;;
32+
minor) echo "v${major}.$((minor + 1)).0" ;;
33+
patch) echo "v${major}.${minor}.$((patch + 1))" ;;
34+
esac
35+
}
36+
37+
# ─── Prereqs ──────────────────────────────────────────────────────────────────
38+
39+
command -v gh >/dev/null 2>&1 || die "'gh' CLI not found. Install from https://cli.github.com"
40+
command -v git >/dev/null 2>&1 || die "'git' not found."
41+
42+
gh auth status >/dev/null 2>&1 || die "Not authenticated. Run: gh auth login"
43+
44+
# ─── Repo context ─────────────────────────────────────────────────────────────
45+
46+
REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null) \
47+
|| die "Not inside a GitHub repository (or 'gh repo view' failed)."
48+
49+
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
50+
CURRENT_SHA=$(git rev-parse HEAD)
51+
52+
echo
53+
bold "=== GitHub Release Creator ==="
54+
info "Repo: $REPO"
55+
info "Branch: $CURRENT_BRANCH"
56+
info "SHA: $CURRENT_SHA"
57+
echo
58+
59+
# ─── Fetch latest remote tags ─────────────────────────────────────────────────
60+
61+
printf 'Fetching tags from remote...'
62+
git fetch --tags --quiet
63+
printf ' done.\n\n'
64+
65+
# ─── List existing tags ───────────────────────────────────────────────────────
66+
67+
EXISTING_TAGS=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
68+
LATEST_TAG=$(echo "$EXISTING_TAGS" | head -1)
69+
70+
if [[ -z "$EXISTING_TAGS" ]]; then
71+
bold "No existing semver tags found."
72+
LATEST_TAG=""
73+
else
74+
bold "Existing tags (most recent first):"
75+
echo "$EXISTING_TAGS" | head -15 | while read -r t; do
76+
info "$t"
77+
done
78+
total=$(echo "$EXISTING_TAGS" | wc -l | tr -d ' ')
79+
[[ $total -gt 15 ]] && info "... and $((total - 15)) more"
80+
echo
81+
info "Latest tag: $LATEST_TAG"
82+
echo
83+
fi
84+
85+
# ─── Choose / create a tag ────────────────────────────────────────────────────
86+
87+
bold "How would you like to set the new tag?"
88+
echo " 1) Bump patch $([ -n "$LATEST_TAG" ] && bump_version "$LATEST_TAG" patch || echo '(no base tag)')"
89+
echo " 2) Bump minor $([ -n "$LATEST_TAG" ] && bump_version "$LATEST_TAG" minor || echo '(no base tag)')"
90+
echo " 3) Bump major $([ -n "$LATEST_TAG" ] && bump_version "$LATEST_TAG" major || echo '(no base tag)')"
91+
echo " 4) Enter a custom tag"
92+
echo
93+
94+
while true; do
95+
read -rp "Choice [1-4]: " choice
96+
case "$choice" in
97+
1|2|3)
98+
if [[ -z "$LATEST_TAG" ]]; then
99+
red "No existing tag to bump from. Choose option 4 to enter a custom tag."
100+
continue
101+
fi
102+
case "$choice" in
103+
1) NEW_TAG=$(bump_version "$LATEST_TAG" patch) ;;
104+
2) NEW_TAG=$(bump_version "$LATEST_TAG" minor) ;;
105+
3) NEW_TAG=$(bump_version "$LATEST_TAG" major) ;;
106+
esac
107+
break
108+
;;
109+
4)
110+
while true; do
111+
read -rp "Enter new tag (e.g. v1.2.3): " NEW_TAG
112+
if semver_valid "$NEW_TAG"; then
113+
break
114+
else
115+
red "Invalid format. Tag must match vX.X.X (e.g. v1.2.3)."
116+
fi
117+
done
118+
break
119+
;;
120+
*)
121+
red "Please enter 1, 2, 3, or 4."
122+
;;
123+
esac
124+
done
125+
126+
echo
127+
bold "New tag: $NEW_TAG"
128+
129+
# Check for tag collision
130+
if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
131+
yellow "Tag '$NEW_TAG' already exists locally."
132+
EXISTING_TAG_SHA=$(git rev-parse "$NEW_TAG^{}")
133+
info "Points to: $EXISTING_TAG_SHA"
134+
read -rp "Use this existing tag? [y/N]: " use_existing
135+
if [[ ! "$use_existing" =~ ^[Yy]$ ]]; then
136+
die "Aborted. Please choose a different tag."
137+
fi
138+
TAG_ALREADY_EXISTS=true
139+
else
140+
TAG_ALREADY_EXISTS=false
141+
fi
142+
143+
echo
144+
145+
# ─── Create local tag if needed ───────────────────────────────────────────────
146+
147+
if [[ "$TAG_ALREADY_EXISTS" == false ]]; then
148+
read -rp "Tag message (leave blank for default \"Release $NEW_TAG\"): " tag_message
149+
tag_message="${tag_message:-Release $NEW_TAG}"
150+
151+
git tag -a "$NEW_TAG" -m "$tag_message"
152+
green "Created local tag '$NEW_TAG'."
153+
echo
154+
fi
155+
156+
# ─── Push tag to remote ───────────────────────────────────────────────────────
157+
158+
read -rp "Push tag '$NEW_TAG' to origin? [Y/n]: " push_confirm
159+
if [[ "$push_confirm" =~ ^[Nn]$ ]]; then
160+
die "Aborted before pushing tag."
161+
fi
162+
163+
git push origin "$NEW_TAG"
164+
green "Pushed tag '$NEW_TAG' to origin."
165+
echo
166+
167+
# ─── Build release notes ──────────────────────────────────────────────────────
168+
169+
bold "Generating release notes..."
170+
171+
# Determine the commit range for the changelog
172+
if [[ -n "$LATEST_TAG" && "$NEW_TAG" != "$LATEST_TAG" ]]; then
173+
RANGE="${LATEST_TAG}..HEAD"
174+
bold "Commits since $LATEST_TAG:"
175+
git log "$RANGE" --oneline | while read -r line; do info "$line"; done
176+
else
177+
bold "No previous tag to diff against — showing last 10 commits:"
178+
git log --oneline -10 | while read -r line; do info "$line"; done
179+
fi
180+
181+
echo
182+
183+
# Let gh generate notes automatically, then let the user edit
184+
NOTES_FILE=$(mktemp /tmp/release-notes.XXXXXX.md)
185+
trap 'rm -f "$NOTES_FILE"' EXIT
186+
187+
# Use gh's auto-generated notes as the starting point
188+
gh api \
189+
--method POST \
190+
"/repos/${REPO}/releases/generate-notes" \
191+
-f "tag_name=${NEW_TAG}" \
192+
${LATEST_TAG:+-f "previous_tag_name=${LATEST_TAG}"} \
193+
--jq '.body' > "$NOTES_FILE" 2>/dev/null \
194+
|| printf "## What's Changed\n\n<!-- Add release notes here -->\n" > "$NOTES_FILE"
195+
196+
green "Auto-generated release notes written to temp file."
197+
echo
198+
199+
read -rp "Open notes in \$EDITOR to review/edit before creating release? [Y/n]: " edit_notes
200+
if [[ ! "$edit_notes" =~ ^[Nn]$ ]]; then
201+
EDITOR="${EDITOR:-vi}"
202+
"$EDITOR" "$NOTES_FILE"
203+
fi
204+
205+
RELEASE_NOTES=$(cat "$NOTES_FILE")
206+
207+
# ─── Release options ──────────────────────────────────────────────────────────
208+
209+
echo
210+
bold "Release options:"
211+
212+
read -rp "Mark as pre-release? [y/N]: " is_prerelease
213+
read -rp "Create as draft (do not publish yet)? [y/N]: " is_draft
214+
read -rp "Mark as latest release? [Y/n]: " is_latest
215+
216+
PRERELEASE_FLAG=""
217+
[[ "$is_prerelease" =~ ^[Yy]$ ]] && PRERELEASE_FLAG="--prerelease"
218+
219+
DRAFT_FLAG=""
220+
[[ "$is_draft" =~ ^[Yy]$ ]] && DRAFT_FLAG="--draft"
221+
222+
LATEST_FLAG="--latest"
223+
[[ "$is_latest" =~ ^[Nn]$ ]] && LATEST_FLAG=""
224+
225+
# ─── Confirm & create release ─────────────────────────────────────────────────
226+
227+
echo
228+
bold "=== Summary ==="
229+
info "Repo: $REPO"
230+
info "Tag: $NEW_TAG"
231+
info "Draft: $( [[ -n "$DRAFT_FLAG" ]] && echo yes || echo no )"
232+
info "Pre-release:$( [[ -n "$PRERELEASE_FLAG" ]] && echo yes || echo no )"
233+
info "Latest: $( [[ -n "$LATEST_FLAG" ]] && echo yes || echo no )"
234+
echo
235+
bold "Release notes:"
236+
echo "$RELEASE_NOTES"
237+
echo
238+
239+
read -rp "Create release now? [Y/n]: " final_confirm
240+
if [[ "$final_confirm" =~ ^[Nn]$ ]]; then
241+
# Clean up the remote tag we just pushed if the user bails
242+
read -rp "Delete the remote tag '$NEW_TAG' since we're aborting? [y/N]: " del_remote
243+
if [[ "$del_remote" =~ ^[Yy]$ ]]; then
244+
git push origin ":refs/tags/$NEW_TAG"
245+
[[ "$TAG_ALREADY_EXISTS" == false ]] && git tag -d "$NEW_TAG"
246+
green "Remote (and local) tag removed."
247+
fi
248+
die "Release creation cancelled."
249+
fi
250+
251+
# ─── Create the release ───────────────────────────────────────────────────────
252+
253+
RELEASE_URL=$(gh release create "$NEW_TAG" \
254+
--title "$NEW_TAG" \
255+
--notes "$RELEASE_NOTES" \
256+
$DRAFT_FLAG \
257+
$PRERELEASE_FLAG \
258+
$LATEST_FLAG \
259+
2>&1)
260+
261+
echo
262+
green "Release created successfully!"
263+
info "URL: $RELEASE_URL"
264+
echo

0 commit comments

Comments
 (0)