diff --git a/.gitignore b/.gitignore index 54f5823..8e9a66c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ test*.sh temp tool .env +.release-manifests/ diff --git a/gh-cli/README.md b/gh-cli/README.md index 02b03aa..d054286 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -500,6 +500,18 @@ test11-team/test11111-team/textxxx-team ``` +### delete-draft-releases.sh + +Deletes intermediate draft releases and their exact tags using the latest merge manifest. It deletes only a unique draft created after the recorded merge whose target contains that merge commit, then deletes the tag named by that verified draft. + +Requires `gh` authentication with Contents write permission and `jq`. + +```bash +./delete-draft-releases.sh --max-age-minutes 15 --no-prompt +``` + +Run this after an intermediate dependency merge has created its draft, then run the merge script for the next dependency. `--max-age-minutes` skips older drafts as an additional safety guard. Do not run it after the final merge. + ### delete-packages-in-organization.sh Deletes all packages in an organization for a given package type. @@ -1564,7 +1576,7 @@ Creates a (mostly) empty migration for a given organization repository so that i ### merge-pull-requests-by-title.sh -Finds and merges pull requests matching a title pattern across multiple repositories. Supports batch merging Dependabot PRs, bumping npm patch versions, and enabling auto-merge. Repositories can be specified via a file list or dynamically via `--owner` with optional `--topic` filtering. +Finds and merges pull requests matching a title pattern across multiple repositories. Supports batch merging Dependabot PRs, bumping npm patch versions, and enabling auto-merge. Successful immediate merges are automatically recorded in the ignored `.release-manifests/latest.json` file for safely publishing generated drafts. Repositories can be specified via a file list or dynamically via `--owner` with optional `--topic` filtering. ```bash # Merge PRs matching a wildcard title pattern @@ -1578,6 +1590,7 @@ Finds and merges pull requests matching a title pattern across multiple reposito # Search by owner and topic instead of file list ./merge-pull-requests-by-title.sh --owner joshjohanning --topic node-action "chore(deps)*" --dry-run + ``` Input file format (`repos.txt`): @@ -1588,6 +1601,8 @@ https://github.com/joshjohanning/repo2 https://github.com/joshjohanning/repo3 ``` +Automatic manifest output requires `jq` and applies only to immediate merges, not dry runs, version bumps, or auto-merge. + ### merge-pull-requests-from-list.sh Merges a list of pull requests from a file containing PR URLs with customizable commit messages. Useful for batch merging similar PRs across multiple repositories (e.g., Dependabot updates). Supports dry-run mode to preview merges. @@ -1633,6 +1648,18 @@ The script has three parameters: - `target-org` - The target organization name to which teams will be updated OR created - `create parent(s) if not exist` - OPTIONAL (default `false`) if set to true, the teams which have parents that do not exist in the target org, they will be created. (also creates parents of parents) otherwise it will print a message parent doesn't exist and it will skipped. +### publish-draft-releases.sh + +Publishes only draft releases associated with PRs in a manifest written by `merge-pull-requests-by-title.sh`. A draft must be newer than its PR merge, its target must contain the merge commit, and it must be the only matching draft for that repository. + +Requires `gh` authentication with Contents write permission and `jq`. + +```bash +./publish-draft-releases.sh +``` + +The script defaults to `.release-manifests/latest.json`; pass a different manifest path only when publishing from a saved prior run. + ### remove-branch-protection-status-check-contexts.sh Removes specific branch protection status check(s) from a branch protection rule diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh new file mode 100755 index 0000000..8e52e95 --- /dev/null +++ b/gh-cli/delete-draft-releases.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +# Deletes intermediate draft releases created for PRs recorded by merge-pull-requests-by-title.sh +# +# Usage: +# ./delete-draft-releases.sh [manifest_file] [--max-age-minutes ] [--no-prompt] +# +# Examples: +# ./delete-draft-releases.sh +# ./delete-draft-releases.sh --max-age-minutes 15 --no-prompt +# ./delete-draft-releases.sh .release-manifests/previous.json --max-age-minutes 15 +# +# Requirements: +# - gh authenticated with Contents: write permission for each repository +# - jq installed +# +# Safety: +# - Only draft releases created after the recorded PR merge are considered +# - The draft target must contain the recorded merge commit +# - Only the exact tag named by the verified draft is deleted +# - Exactly one draft must match each manifest entry + +print_help() { + echo "Delete intermediate draft releases associated with a merge manifest" + echo "" + echo "Usage: $0 [manifest_file] [--max-age-minutes ] [--no-prompt]" + echo "" + echo "Defaults to .release-manifests/latest.json" +} + +no_prompt=false +max_age_minutes="" +manifest_file=".release-manifests/latest.json" +manifest_provided=false + +args=("$@") +i=0 +while [ $i -lt ${#args[@]} ]; do + arg="${args[$i]}" + case "$arg" in + -h|--help) + print_help + exit 0 + ;; + --no-prompt) + no_prompt=true + ;; + --max-age-minutes) + ((i++)) + max_age_minutes="${args[$i]}" + if ! [[ "$max_age_minutes" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: --max-age-minutes requires a positive integer" + exit 1 + fi + ;; + --*) + echo "Error: Unknown flag '$arg'" + exit 1 + ;; + *) + if [ "$manifest_provided" = true ]; then + echo "Error: Only one manifest file may be provided" + exit 1 + fi + manifest_file="$arg" + manifest_provided=true + ;; + esac + ((i++)) +done + +if [ ! -f "$manifest_file" ]; then + echo "Error: Manifest file does not exist: $manifest_file" + exit 1 +fi + +if ! command -v gh > /dev/null 2>&1; then + echo "Error: gh is required but not installed" + exit 1 +fi + +if ! command -v jq > /dev/null 2>&1; then + echo "Error: jq is required but not installed" + exit 1 +fi + +if ! jq -e '.schemaVersion == 1 and (.pullRequests | type == "array")' "$manifest_file" > /dev/null 2>&1; then + echo "Error: Invalid or unsupported release manifest: $manifest_file" + exit 1 +fi + +if [ "$no_prompt" = false ] && { ! [[ -t 1 ]] || ! [[ -r /dev/tty ]]; }; then + echo "Error: No TTY available for interactive prompt - use --no-prompt" + exit 1 +fi + +deleted_count=0 +skipped_count=0 +failed_count=0 + +while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do + if [ "$deleted_count" -gt 0 ] || [ "$skipped_count" -gt 0 ] || [ "$failed_count" -gt 0 ]; then + echo "" + fi + echo "Checking $pr_url" + + default_branch=$(gh api "/repos/$repo" --jq '.default_branch' 2>/dev/null) + if [ -z "$default_branch" ]; then + echo " ❌ Could not read repository metadata" + ((failed_count++)) + continue + fi + + matching_drafts="" + release_query_error=$(mktemp) + releases=$(gh api --paginate "/repos/$repo/releases?per_page=100" \ + --jq ".[] | select(.draft == true and .created_at >= \"$merged_at\") | [.id, .tag_name, (.target_commitish // \"\"), .created_at, .html_url] | @tsv" 2>"$release_query_error") + release_query_status=$? + if [ $release_query_status -ne 0 ]; then + echo " ❌ Failed to list releases: $(cat "$release_query_error")" + rm -f "$release_query_error" + ((failed_count++)) + continue + fi + rm -f "$release_query_error" + + while IFS=$'\t' read -r release_id tag_name target created_at release_url; do + [ -z "$release_id" ] && continue + target="${target:-$default_branch}" + target_sha=$(gh api "/repos/$repo/commits/$target" --jq '.sha' 2>/dev/null) + if [ -z "$target_sha" ]; then + continue + fi + + comparison=$(gh api "/repos/$repo/compare/$merge_sha...$target_sha" --jq '.status' 2>/dev/null) + if [ "$comparison" = "identical" ] || [ "$comparison" = "ahead" ]; then + if [ -n "$matching_drafts" ]; then + matching_drafts+=$'\n' + fi + matching_drafts+="$release_id"$'\t'"$tag_name"$'\t'"$created_at"$'\t'"$release_url" + fi + done <<< "$releases" + + match_count=$(printf '%s\n' "$matching_drafts" | awk 'NF { count++ } END { print count+0 }') + if [ "$match_count" -eq 0 ]; then + echo " ⏭️ No matching draft release found" + ((skipped_count++)) + continue + fi + if [ "$match_count" -gt 1 ]; then + echo " ❌ Found $match_count matching drafts; refusing to guess" + printf '%s\n' "$matching_drafts" | while IFS=$'\t' read -r _ tag _ url; do + echo " $tag - $url" + done + ((failed_count++)) + continue + fi + + IFS=$'\t' read -r release_id tag_name created_at release_url <<< "$matching_drafts" + if [ -n "$max_age_minutes" ]; then + created_epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$created_at" "+%s" 2>/dev/null) + if [ -z "$created_epoch" ]; then + created_epoch=$(date -u -d "$created_at" "+%s" 2>/dev/null) + fi + if [ -z "$created_epoch" ]; then + echo " ❌ Could not parse draft creation time: $created_at" + ((failed_count++)) + continue + fi + + current_epoch=$(date -u "+%s") + age_seconds=$((current_epoch - created_epoch)) + max_age_seconds=$((max_age_minutes * 60)) + if [ "$age_seconds" -gt "$max_age_seconds" ]; then + echo " ⏭️ Draft is older than $max_age_minutes minutes; skipping $release_url" + ((skipped_count++)) + continue + fi + fi + + current_release=$(gh api "/repos/$repo/releases/$release_id" --jq '[.draft, .tag_name] | @tsv' 2>/dev/null) + if [ "$current_release" != $'true\t'"$tag_name" ]; then + echo " ❌ Release changed during verification; refusing to delete $release_url" + ((failed_count++)) + continue + fi + + encoded_tag=$(jq -rn --arg value "$tag_name" '$value | @uri') + tag_ref=$(gh api "/repos/$repo/git/ref/tags/$encoded_tag" --jq '.ref' 2>/dev/null) + if [ "$tag_ref" != "refs/tags/$tag_name" ]; then + echo " ❌ Exact tag $tag_name was not found; refusing partial cleanup" + ((failed_count++)) + continue + fi + + echo " 📦 Matching draft: $tag_name ($created_at) - $release_url" + if [ "$no_prompt" = false ]; then + read -r -p " ❓ Delete this intermediate draft release and tag $tag_name? [y/N] " confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo " ⏭️ Skipped $release_url" + ((skipped_count++)) + continue + fi + fi + + if gh api --method DELETE "/repos/$repo/releases/$release_id" > /dev/null; then + if gh api --method DELETE "/repos/$repo/git/refs/tags/$encoded_tag" > /dev/null; then + echo " ✅ Deleted intermediate draft $release_url and tag $tag_name" + ((deleted_count++)) + else + echo " ❌ Deleted the draft but failed to delete tag $tag_name" + ((failed_count++)) + fi + else + echo " ❌ Failed to delete $release_url" + ((failed_count++)) + fi +done < <(jq -r '.pullRequests[] | [.repository, .pullRequestUrl, .mergedAt, .mergeCommitSha] | @tsv' "$manifest_file") + +echo "========================================" +echo "Summary:" +printf " ✅ %-10s %d\n" "Deleted:" "$deleted_count" +printf " ❌ %-10s %d\n" "Failed:" "$failed_count" +printf " ⏭️ %-10s %d\n" "Skipped:" "$skipped_count" +echo "========================================" + +if [ "$failed_count" -gt 0 ]; then + exit 1 +fi diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index f0815ec..0eab2e8 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -53,6 +53,7 @@ # https://github.com/joshjohanning/repo3 # # Notes: +# - Requires jq for immediate merges because release metadata is recorded automatically # - PRs must be open and in a mergeable state # - Use * as a wildcard in the title pattern (e.g., "chore(deps)*" matches any title starting with "chore(deps)") # - If multiple PRs match in a repo, all will be processed @@ -61,6 +62,7 @@ # - --bump-patch-version only works with same-repo PRs (fork-based PRs are skipped) # - --enable-auto-merge queues PRs to merge once all required checks pass (does not bypass protections) # - By default, merge mode prompts for confirmation before each PR merge; use --no-prompt to skip +# - Immediate merges are recorded in .release-manifests/latest.json for safe release publishing # # TODO: # - Add --delete-branch flag to delete remote branch after merge @@ -111,6 +113,7 @@ bump_patch_version=false enable_auto_merge=false no_prompt=false search_owner="" +manifest_file=".release-manifests/latest.json" topics=() valid_flags=("-h" "--help" "--dry-run" "--bump-patch-version" "--enable-auto-merge" "--no-prompt" "--owner" "--topic") args=("$@") @@ -135,6 +138,7 @@ while [ $i -lt ${#args[@]} ]; do echo "Error: --owner requires a value" exit 1 fi + if ! [[ "$search_owner" =~ ^[a-zA-Z0-9._-]+$ ]]; then echo "Error: Invalid owner '$search_owner' - must be a valid GitHub username or organization" exit 1 @@ -170,6 +174,16 @@ if [ "$dry_run" = true ] && [ "$enable_auto_merge" = true ]; then exit 1 fi +write_manifest=true +if [ "$dry_run" = true ] || [ "$enable_auto_merge" = true ] || [ "$bump_patch_version" = true ]; then + write_manifest=false +fi + +if [ "$write_manifest" = true ] && ! command -v jq > /dev/null 2>&1; then + echo "Error: jq is required to record immediate merges for release publishing" + exit 1 +fi + # Parse positional args, skipping flags and their values positional_args=() i=0 @@ -312,6 +326,7 @@ success_count=0 fail_count=0 skipped_count=0 not_found_count=0 +manifest_entries="" while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Skip empty lines and comments @@ -468,7 +483,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do if [ "$enable_auto_merge" = false ]; then failed_checks=$(gh pr checks "$pr_number" --repo "$repo" --json "name,state" --jq '[.[] | select(.state == "FAILURE")] | length' 2>/dev/null) if [ -n "$failed_checks" ] && [ "$failed_checks" -gt 0 ] 2>/dev/null; then - echo " ⚠️ Skipping $repo#$pr_number - $failed_checks status check(s) failed" + echo " ⚠️ Skipping $pr_url - $failed_checks status check(s) failed" ((skipped_count++)) continue fi @@ -488,7 +503,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo "Error: No TTY available for interactive prompt - use --no-prompt" exit 1 fi - read -r -p " ❓ Merge $repo#$pr_number? [y/N] " confirm < /dev/tty + read -r -p " ❓ Merge $pr_url? [y/N] " confirm < /dev/tty if [[ ! "$confirm" =~ ^[Yy]$ ]]; then echo " ⏭️ Skipped $pr_url" ((skipped_count++)) @@ -500,6 +515,22 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do echo " 🔄 Auto-merge enabled for $pr_url" else echo " ✅ Successfully merged $pr_url" + if [ "$write_manifest" = true ]; then + merged_pr=$(gh api "/repos/$repo/pulls/$pr_number" \ + --jq '[.base.repo.full_name, (.number | tostring), .html_url, .title, .merged_at, .merge_commit_sha] | @tsv' 2>&1) + metadata_exit=$? + if [ $metadata_exit -ne 0 ] || [ -z "$merged_pr" ]; then + echo " ❌ Merged PR but failed to retrieve metadata for the manifest" + if [ -n "$merged_pr" ]; then + echo " $merged_pr" + fi + exit 1 + fi + if [ -n "$manifest_entries" ]; then + manifest_entries+=$'\n' + fi + manifest_entries+="$merged_pr" + fi fi ((success_count++)) else @@ -521,6 +552,37 @@ else cat "$repo_list_file" fi) +if [ "$write_manifest" = true ]; then + manifest_dir=$(dirname "$manifest_file") + if ! mkdir -p "$manifest_dir"; then + echo "Error: Could not create manifest directory: $manifest_dir" + exit 1 + fi + + { + printf '{\n "schemaVersion": 1,\n "createdAt": "%s",\n "pullRequests": [' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + first_entry=true + while IFS=$'\t' read -r manifest_repo manifest_number manifest_url manifest_title manifest_merged_at manifest_sha; do + [ -z "$manifest_repo" ] && continue + if [ "$first_entry" = false ]; then + printf ',' + fi + printf '\n {' + printf '"repository":%s,' "$(printf '%s' "$manifest_repo" | jq -Rs .)" + printf '"pullRequestNumber":%s,' "$manifest_number" + printf '"pullRequestUrl":%s,' "$(printf '%s' "$manifest_url" | jq -Rs .)" + printf '"title":%s,' "$(printf '%s' "$manifest_title" | jq -Rs .)" + printf '"mergedAt":%s,' "$(printf '%s' "$manifest_merged_at" | jq -Rs .)" + printf '"mergeCommitSha":%s' "$(printf '%s' "$manifest_sha" | jq -Rs .)" + printf '}' + first_entry=false + done <<< "$manifest_entries" + printf '\n ]\n}\n' + } > "$manifest_file" + echo "" + echo "Release manifest: $manifest_file" +fi + echo "========================================" echo "Summary:" if [ "$bump_patch_version" = true ]; then diff --git a/gh-cli/publish-draft-releases.sh b/gh-cli/publish-draft-releases.sh new file mode 100755 index 0000000..b557b6f --- /dev/null +++ b/gh-cli/publish-draft-releases.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# Publishes draft releases created for PRs recorded by merge-pull-requests-by-title.sh +# +# Usage: +# ./publish-draft-releases.sh [manifest_file] [--no-prompt] +# +# Examples: +# ./publish-draft-releases.sh +# ./publish-draft-releases.sh .release-manifests/previous.json --no-prompt +# +# Requirements: +# - gh authenticated with Contents: write permission for each repository +# - jq installed +# +# Safety: +# - A draft must be created after the recorded PR merge +# - The draft's target commit must contain the recorded merge commit +# - Exactly one unpublished draft must match each manifest entry + +print_help() { + echo "Publish draft releases associated with a merge manifest" + echo "" + echo "Usage: $0 [manifest_file] [--no-prompt]" + echo "" + echo "Defaults to .release-manifests/latest.json" +} + +no_prompt=false +manifest_file=".release-manifests/latest.json" +manifest_provided=false + +for arg in "$@"; do + case "$arg" in + -h|--help) + print_help + exit 0 + ;; + --no-prompt) + no_prompt=true + ;; + --*) + echo "Error: Unknown flag '$arg'" + exit 1 + ;; + *) + if [ "$manifest_provided" = true ]; then + echo "Error: Only one manifest file may be provided" + exit 1 + fi + manifest_file="$arg" + manifest_provided=true + ;; + esac +done + +if [ ! -f "$manifest_file" ]; then + echo "Error: Manifest file does not exist: $manifest_file" + exit 1 +fi + +if ! command -v gh > /dev/null 2>&1; then + echo "Error: gh is required but not installed" + exit 1 +fi + +if ! command -v jq > /dev/null 2>&1; then + echo "Error: jq is required but not installed" + exit 1 +fi + +if ! jq -e '.schemaVersion == 1 and (.pullRequests | type == "array")' "$manifest_file" > /dev/null 2>&1; then + echo "Error: Invalid or unsupported release manifest: $manifest_file" + exit 1 +fi + +if [ "$no_prompt" = false ] && { ! [[ -t 1 ]] || ! [[ -r /dev/tty ]]; }; then + echo "Error: No TTY available for interactive prompt - use --no-prompt" + exit 1 +fi + +published_count=0 +skipped_count=0 +failed_count=0 + +while IFS=$'\t' read -r repo pr_number pr_url merged_at merge_sha; do + echo "Checking $pr_url" + + repo_info=$(gh api "/repos/$repo" --jq '[.default_branch, .html_url] | @tsv' 2>/dev/null) + if [ -z "$repo_info" ]; then + echo " ❌ Could not read repository metadata" + ((failed_count++)) + continue + fi + IFS=$'\t' read -r default_branch repo_url <<< "$repo_info" + + matching_drafts="" + release_query_error=$(mktemp) + releases=$(gh api --paginate "/repos/$repo/releases?per_page=100" \ + --jq ".[] | select(.draft == true and .created_at >= \"$merged_at\") | [.id, .tag_name, (.target_commitish // \"\"), .created_at, .html_url] | @tsv" 2>"$release_query_error") + release_query_status=$? + if [ $release_query_status -ne 0 ]; then + echo " ❌ Failed to list releases: $(cat "$release_query_error")" + rm -f "$release_query_error" + ((failed_count++)) + continue + fi + rm -f "$release_query_error" + + while IFS=$'\t' read -r release_id tag_name target created_at release_url; do + [ -z "$release_id" ] && continue + target="${target:-$default_branch}" + target_sha=$(gh api "/repos/$repo/commits/$target" --jq '.sha' 2>/dev/null) + if [ -z "$target_sha" ]; then + continue + fi + + comparison=$(gh api "/repos/$repo/compare/$merge_sha...$target_sha" --jq '.status' 2>/dev/null) + if [ "$comparison" = "identical" ] || [ "$comparison" = "ahead" ]; then + if [ -n "$matching_drafts" ]; then + matching_drafts+=$'\n' + fi + matching_drafts+="$release_id"$'\t'"$tag_name"$'\t'"$created_at"$'\t'"$release_url" + fi + done <<< "$releases" + + match_count=$(printf '%s\n' "$matching_drafts" | awk 'NF { count++ } END { print count+0 }') + if [ "$match_count" -eq 0 ]; then + echo " ⏭️ No matching draft release found" + ((skipped_count++)) + continue + fi + if [ "$match_count" -gt 1 ]; then + echo " ❌ Found $match_count matching drafts; refusing to guess" + printf '%s\n' "$matching_drafts" | while IFS=$'\t' read -r _ tag _ url; do + echo " $tag - $url" + done + ((failed_count++)) + continue + fi + + IFS=$'\t' read -r release_id tag_name created_at release_url <<< "$matching_drafts" + echo " 📦 Matching draft: $tag_name ($created_at) - $release_url" + + if [ "$no_prompt" = false ]; then + read -r -p " ❓ Publish this draft release? [y/N] " confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo " ⏭️ Skipped $release_url" + ((skipped_count++)) + continue + fi + fi + + current_release=$(gh api "/repos/$repo/releases/$release_id" --jq '[.draft, .tag_name] | @tsv' 2>/dev/null) + if [ "$current_release" != $'true\t'"$tag_name" ]; then + echo " ❌ Release changed during verification; refusing to publish $release_url" + ((failed_count++)) + continue + fi + + if gh api --method PATCH "/repos/$repo/releases/$release_id" -F draft=false > /dev/null; then + echo " ✅ Published $repo_url/releases/tag/$tag_name" + ((published_count++)) + else + echo " ❌ Failed to publish $release_url" + ((failed_count++)) + fi +done < <(jq -r '.pullRequests[] | [.repository, .pullRequestNumber, .pullRequestUrl, .mergedAt, .mergeCommitSha] | @tsv' "$manifest_file") + +echo "========================================" +echo "Summary:" +printf " ✅ %-10s %d\n" "Published:" "$published_count" +printf " ❌ %-10s %d\n" "Failed:" "$failed_count" +printf " ⏭️ %-10s %d\n" "Skipped:" "$skipped_count" +echo "========================================" + +if [ "$failed_count" -gt 0 ]; then + exit 1 +fi