Skip to content

Instantly share code, notes, and snippets.

@Konamiman
Created January 20, 2026 22:03
Show Gist options
  • Select an option

  • Save Konamiman/2f5de1fedef6f71608e13b30f709e195 to your computer and use it in GitHub Desktop.

Select an option

Save Konamiman/2f5de1fedef6f71608e13b30f709e195 to your computer and use it in GitHub Desktop.
GitHub draft release creator
#!/usr/bin/env bash
#
# Create a draft GitHub release from pull requests targeting a milestone.
#
# Usage: create-github-release <repo> <milestone> [options]
#
# Arguments:
# repo Repository URL or user/repo format
# milestone Milestone name to collect PRs from
#
# Options:
# --tag=TAG Release tag (default: same as milestone)
# --title=TITLE Release title (default: same as tag)
# --token=TOKEN GitHub API token (or use GITHUB_TOKEN env var)
# --include-open Include PRs that are still open
# --dry-run Preview the release body without creating it
# --force Create release even if tag already exists
#
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
error() {
echo -e "${RED}Error: $1${NC}" >&2
exit 1
}
warn() {
echo -e "${YELLOW}$1${NC}" >&2
}
info() {
echo -e "${GREEN}$1${NC}"
}
usage() {
cat <<EOF
Usage: $(basename "$0") <repo> <milestone> [options]
Create a draft GitHub release from pull requests targeting a milestone.
Arguments:
repo Repository URL (https://github.com/user/repo) or user/repo format
milestone Milestone name to collect PRs from
Options:
--tag=TAG Release tag (default: same as milestone)
--title=TITLE Release title (default: same as tag)
--token=TOKEN GitHub API token (or use GITHUB_TOKEN env var)
--include-open Include PRs that are still open
--dry-run Preview the release body without creating it
--force Create release even if tag already exists
Examples:
$(basename "$0") user/repo v1.0.0
$(basename "$0") https://github.com/user/repo "Release 1.0" --tag=v1.0.0
$(basename "$0") user/repo v2.0.0 --include-open --dry-run
EOF
exit 1
}
# Parse repository from URL or user/repo format
parse_repo() {
local input="$1"
# Remove trailing slash if present
input="${input%/}"
# Extract user/repo from URL
if [[ "$input" =~ ^https?://github\.com/([^/]+/[^/]+) ]]; then
echo "${BASH_REMATCH[1]}"
elif [[ "$input" =~ ^([^/]+/[^/]+)$ ]]; then
echo "$input"
else
error "Invalid repository format: $input. Use 'user/repo' or 'https://github.com/user/repo'"
fi
}
# Main variables
REPO=""
MILESTONE=""
TAG=""
TITLE=""
TOKEN=""
INCLUDE_OPEN=false
DRY_RUN=false
FORCE=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--tag=*)
TAG="${1#*=}"
shift
;;
--title=*)
TITLE="${1#*=}"
shift
;;
--token=*)
TOKEN="${1#*=}"
shift
;;
--include-open)
INCLUDE_OPEN=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--force)
FORCE=true
shift
;;
--help|-h)
usage
;;
--*)
error "Unknown option: $1"
;;
*)
if [[ -z "$REPO" ]]; then
REPO="$1"
elif [[ -z "$MILESTONE" ]]; then
MILESTONE="$1"
else
error "Unexpected argument: $1"
fi
shift
;;
esac
done
# Validate required arguments
[[ -z "$REPO" ]] && error "Repository is required"
[[ -z "$MILESTONE" ]] && error "Milestone is required"
# Parse and validate repo format
REPO=$(parse_repo "$REPO")
# Set defaults
[[ -z "$TAG" ]] && TAG="$MILESTONE"
[[ -z "$TITLE" ]] && TITLE="$TAG"
# Handle token
if [[ -n "$TOKEN" ]]; then
export GITHUB_TOKEN="$TOKEN"
elif [[ -z "${GITHUB_TOKEN:-}" ]]; then
# Check if gh is already authenticated
if ! gh auth status &>/dev/null; then
error "GitHub token is required. Use --token=TOKEN or set GITHUB_TOKEN environment variable, or run 'gh auth login'"
fi
fi
# Check if gh CLI is available
if ! command -v gh &>/dev/null; then
error "GitHub CLI (gh) is required. Install it from https://cli.github.com/"
fi
# Verify repository exists
if ! gh repo view "$REPO" &>/dev/null; then
error "Repository not found or not accessible: $REPO"
fi
# Verify milestone exists and get its number
MILESTONE_DATA=$(gh api "repos/$REPO/milestones" --jq ".[] | select(.title == \"$MILESTONE\")" 2>/dev/null || true)
if [[ -z "$MILESTONE_DATA" ]]; then
echo -e "${RED}Error: Milestone '$MILESTONE' not found.${NC}" >&2
echo "" >&2
echo "Available milestones:" >&2
MILESTONES=$(gh api "repos/$REPO/milestones?state=all" --jq '.[].title' 2>/dev/null || true)
if [[ -n "$MILESTONES" ]]; then
echo "$MILESTONES" | sed 's/^/ - /' >&2
else
echo " (no milestones found)" >&2
fi
exit 1
fi
# Check if release already exists (unless --force)
if ! $FORCE && ! $DRY_RUN; then
if gh api "repos/$REPO/releases/tags/$TAG" &>/dev/null; then
error "Release with tag '$TAG' already exists. Use --force to create anyway, or choose a different tag."
fi
fi
# Build search query for PRs
if $INCLUDE_OPEN; then
PR_STATE="all"
SORT_FIELD="createdAt"
else
PR_STATE="merged"
SORT_FIELD="mergedAt"
fi
# Fetch PRs with the milestone
# Note: gh pr list --search doesn't work well with milestone names containing spaces,
# so we fetch by milestone number
MILESTONE_NUMBER=$(echo "$MILESTONE_DATA" | jq -r '.number')
info "Fetching pull requests for milestone '$MILESTONE'..."
if $INCLUDE_OPEN; then
# Fetch all PRs (merged + open) for the milestone
PRS_JSON=$(gh pr list --repo "$REPO" --state all --json number,title,url,createdAt,state \
--jq "[.[] | select(.milestone.title == \"$MILESTONE\") | {number, title, url, sortDate: .createdAt}]" 2>/dev/null || true)
# If the above doesn't work (milestone not in default fields), use API directly
if [[ -z "$PRS_JSON" ]] || [[ "$PRS_JSON" == "[]" ]]; then
PRS_JSON=$(gh api "repos/$REPO/issues?milestone=$MILESTONE_NUMBER&state=all" \
--jq '[.[] | select(.pull_request != null) | {number, title, url: .pull_request.html_url, sortDate: .created_at}]' 2>/dev/null || true)
fi
else
# Fetch only merged PRs using search API
PRS_JSON=$(gh api "search/issues?q=repo:$REPO+milestone:\"$MILESTONE\"+is:pr+is:merged&per_page=100" \
--jq '[.items[] | {number, title, url: .pull_request.html_url, sortDate: .closed_at}]' 2>/dev/null || true)
# Fallback: fetch from issues endpoint and filter
if [[ -z "$PRS_JSON" ]] || [[ "$PRS_JSON" == "[]" ]] || [[ "$PRS_JSON" == "null" ]]; then
PRS_JSON=$(gh api "repos/$REPO/issues?milestone=$MILESTONE_NUMBER&state=closed" \
--jq '[.[] | select(.pull_request != null) | {number, title, url: .pull_request.html_url, sortDate: .closed_at}]' 2>/dev/null || true)
# Filter to only merged PRs (not just closed)
if [[ -n "$PRS_JSON" ]] && [[ "$PRS_JSON" != "[]" ]]; then
# We need to check each PR's merged status
FILTERED_PRS="[]"
for PR_NUM in $(echo "$PRS_JSON" | jq -r '.[].number'); do
PR_MERGED=$(gh api "repos/$REPO/pulls/$PR_NUM" --jq '.merged_at // empty' 2>/dev/null || true)
if [[ -n "$PR_MERGED" ]]; then
PR_DATA=$(echo "$PRS_JSON" | jq ".[] | select(.number == $PR_NUM) | .sortDate = \"$PR_MERGED\"")
FILTERED_PRS=$(echo "$FILTERED_PRS" | jq ". + [$PR_DATA]")
fi
done
PRS_JSON="$FILTERED_PRS"
fi
fi
fi
# Check if we got any PRs
if [[ -z "$PRS_JSON" ]] || [[ "$PRS_JSON" == "[]" ]] || [[ "$PRS_JSON" == "null" ]]; then
if $INCLUDE_OPEN; then
error "No pull requests found for milestone '$MILESTONE'"
else
error "No merged pull requests found for milestone '$MILESTONE'. Use --include-open to include open PRs."
fi
fi
# Sort PRs by date (oldest first) and format the body
RELEASE_BODY=$(echo "$PRS_JSON" | jq -r 'sort_by(.sortDate) | .[] | "* [\(.title) (#\(.number))](\(.url))"')
# Count PRs
PR_COUNT=$(echo "$PRS_JSON" | jq 'length')
info "Found $PR_COUNT pull request(s)"
# Show the release body
echo ""
echo "Release body:"
echo "----------------------------------------"
echo "$RELEASE_BODY"
echo "----------------------------------------"
echo ""
if $DRY_RUN; then
info "Dry run - no release created"
echo "Would create draft release:"
echo " Repository: $REPO"
echo " Tag: $TAG"
echo " Title: $TITLE"
exit 0
fi
# Create the draft release
info "Creating draft release..."
RELEASE_URL=$(gh release create "$TAG" \
--repo "$REPO" \
--title "$TITLE" \
--notes "$RELEASE_BODY" \
--draft \
2>&1)
info "Draft release created successfully!"
echo "URL: $RELEASE_URL"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment