Created
January 20, 2026 22:03
-
-
Save Konamiman/2f5de1fedef6f71608e13b30f709e195 to your computer and use it in GitHub Desktop.
GitHub draft release creator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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