From 234d15436581810c6e640aa2c40a1cfb49a90ec9 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 10:36:50 -0500 Subject: [PATCH 01/10] fix(gh-cli): print pull request URLs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bed3e0ff-d6c7-426a-8591-4c4b8e74705c --- gh-cli/merge-pull-requests-by-title.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index 9e6c832..104d95e 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -390,7 +390,8 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Process each matching PR while IFS='|' read -r pr_number pr_title pr_author pr_branch pr_head_repo; do - echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" + pr_url="https://github.com/$repo/pull/$pr_number" + echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author) - $pr_url" if [ "$bump_patch_version" = true ]; then # Skip fork-based PRs since we can't push to the head repo @@ -435,9 +436,9 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi fi if gh pr merge "$pr_number" --repo "$repo" "${auto_merge_args[@]}"; then - echo " 🔄 Auto-merge enabled for $repo#$pr_number" + echo " 🔄 Auto-merge enabled for $pr_url" else - echo " ⚠️ Failed to enable auto-merge for $repo#$pr_number" + echo " ⚠️ Failed to enable auto-merge for $pr_url" ((fail_count++)) fi fi @@ -478,7 +479,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do merge_args+=("--auto") fi if [ "$dry_run" = true ]; then - echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" + echo " 🔍 Would merge $pr_url with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" ((success_count++)) else # Prompt for confirmation unless --no-prompt was passed @@ -489,20 +490,20 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi read -r -p " ❓ Merge $repo#$pr_number? [y/N] " confirm < /dev/tty if [[ ! "$confirm" =~ ^[Yy]$ ]]; then - echo " ⏭️ Skipped $repo#$pr_number" + echo " ⏭️ Skipped $pr_url" ((skipped_count++)) continue fi fi if gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then if [ "$enable_auto_merge" = true ]; then - echo " 🔄 Auto-merge enabled for $repo#$pr_number" + echo " 🔄 Auto-merge enabled for $pr_url" else - echo " ✅ Successfully merged $repo#$pr_number" + echo " ✅ Successfully merged $pr_url" fi ((success_count++)) else - echo " ❌ Failed to merge $repo#$pr_number" + echo " ❌ Failed to merge $pr_url" ((fail_count++)) fi fi From c013b3621717f1e3ac8a681f652a1d57ea39e17b Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 10:37:51 -0500 Subject: [PATCH 02/10] feat(gh-cli): publish dependency draft releases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- .gitignore | 1 + gh-cli/README.md | 17 ++- gh-cli/merge-pull-requests-by-title.sh | 78 ++++++++++-- gh-cli/publish-draft-releases.sh | 161 +++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 10 deletions(-) create mode 100755 gh-cli/publish-draft-releases.sh 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 91b1323..ba9b63c 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1542,7 +1542,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 @@ -1556,6 +1556,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`): @@ -1566,6 +1567,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. @@ -1594,6 +1597,18 @@ https://github.com/joshjohanning/repo3/pull/43 Template variables: `{title}` (PR title), `{number}` (PR number), `{body}` (PR body) +### 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. + ### parent-organization-teams.sh Sets the parents of teams in an target organization based on existing child/parent relationship on a source organization teams. diff --git a/gh-cli/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index 9e6c832..334f447 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 @@ -390,7 +405,8 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do # Process each matching PR while IFS='|' read -r pr_number pr_title pr_author pr_branch pr_head_repo; do - echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author)" + pr_url="https://github.com/$repo/pull/$pr_number" + echo " 📋 Found PR #$pr_number: $pr_title (by $pr_author) - $pr_url" if [ "$bump_patch_version" = true ]; then # Skip fork-based PRs since we can't push to the head repo @@ -435,7 +451,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do fi fi if gh pr merge "$pr_number" --repo "$repo" "${auto_merge_args[@]}"; then - echo " 🔄 Auto-merge enabled for $repo#$pr_number" + echo " 🔄 Auto-merge enabled for $pr_url" else echo " ⚠️ Failed to enable auto-merge for $repo#$pr_number" ((fail_count++)) @@ -467,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 @@ -478,7 +494,7 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do merge_args+=("--auto") fi if [ "$dry_run" = true ]; then - echo " 🔍 Would merge $repo#$pr_number with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" + echo " 🔍 Would merge $pr_url with: gh pr merge $pr_number --repo $repo ${merge_args[*]}" ((success_count++)) else # Prompt for confirmation unless --no-prompt was passed @@ -487,22 +503,35 @@ 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 $repo#$pr_number" + echo " ⏭️ Skipped $pr_url" ((skipped_count++)) continue fi fi if gh pr merge "$pr_number" --repo "$repo" "${merge_args[@]}"; then if [ "$enable_auto_merge" = true ]; then - echo " 🔄 Auto-merge enabled for $repo#$pr_number" + echo " 🔄 Auto-merge enabled for $pr_url" else - echo " ✅ Successfully merged $repo#$pr_number" + 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') + if [ -z "$merged_pr" ]; then + echo " ❌ Merged PR but failed to retrieve metadata for the manifest" + ((fail_count++)) + continue + fi + if [ -n "$manifest_entries" ]; then + manifest_entries+=$'\n' + fi + manifest_entries+="$merged_pr" + fi fi ((success_count++)) else - echo " ❌ Failed to merge $repo#$pr_number" + echo " ❌ Failed to merge $pr_url" ((fail_count++)) fi fi @@ -520,6 +549,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..0b9ad00 --- /dev/null +++ b/gh-cli/publish-draft-releases.sh @@ -0,0 +1,161 @@ +#!/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="" + 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 < <(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>/dev/null) + + 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 + + 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:" +echo " ✅ Published: $published_count" +echo " ❌ Failed: $failed_count" +echo " ⏭️ Skipped: $skipped_count" +echo "========================================" + +if [ "$failed_count" -gt 0 ]; then + exit 1 +fi From 712941772f49fb775f5450ff2c775ef9af70a5c5 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 10:55:18 -0500 Subject: [PATCH 03/10] feat(gh-cli): delete intermediate draft releases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 12 +++ gh-cli/delete-draft-releases.sh | 185 ++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100755 gh-cli/delete-draft-releases.sh diff --git a/gh-cli/README.md b/gh-cli/README.md index ba9b63c..44c35f3 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1540,6 +1540,18 @@ Adds users to an organization team from a CSV input list. Creates a (mostly) empty migration for a given organization repository so that it can create a lock. +### delete-draft-releases.sh + +Deletes intermediate draft releases associated with the latest merge manifest. It deletes only a unique draft created after the recorded merge whose target contains that merge commit, and refuses to delete when the release tag already exists. + +Requires `gh` authentication with Contents write permission and `jq`. + +```bash +./delete-draft-releases.sh +``` + +Run this after an intermediate dependency merge has created its draft, then run the merge script for the next dependency. Do not run it after the final merge. + ### 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. 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. diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh new file mode 100755 index 0000000..de853cd --- /dev/null +++ b/gh-cli/delete-draft-releases.sh @@ -0,0 +1,185 @@ +#!/bin/bash + +# Deletes intermediate draft releases created for PRs recorded by merge-pull-requests-by-title.sh +# +# Usage: +# ./delete-draft-releases.sh [manifest_file] [--no-prompt] +# +# Examples: +# ./delete-draft-releases.sh +# ./delete-draft-releases.sh .release-manifests/previous.json --no-prompt +# +# 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 +# - The draft tag must not already exist as a Git reference +# - 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] [--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 + +deleted_count=0 +skipped_count=0 +failed_count=0 + +while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do + 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" + encoded_tag=$(jq -rn --arg value "$tag_name" '$value | @uri') + if gh api "/repos/$repo/git/ref/tags/$encoded_tag" > /dev/null 2>&1; then + echo " ❌ Tag $tag_name already exists; refusing to delete $release_url" + ((failed_count++)) + continue + 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 + + echo " 📦 Matching draft: $tag_name ($created_at) - $release_url" + if [ "$no_prompt" = false ]; then + read -r -p " ❓ Delete this intermediate draft release? [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 + echo " ✅ Deleted intermediate draft $release_url" + ((deleted_count++)) + else + echo " ❌ Failed to delete $release_url" + ((failed_count++)) + fi +done < <(jq -r '.pullRequests[] | [.repository, .pullRequestUrl, .mergedAt, .mergeCommitSha] | @tsv' "$manifest_file") + +echo "========================================" +echo "Summary:" +echo " ✅ Deleted: $deleted_count" +echo " ❌ Failed: $failed_count" +echo " ⏭️ Skipped: $skipped_count" +echo "========================================" + +if [ "$failed_count" -gt 0 ]; then + exit 1 +fi From b9c84239cdb983f82379a777663683b1e51edb17 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 11:09:18 -0500 Subject: [PATCH 04/10] fix(gh-cli): allow tagged draft release deletion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 2 +- gh-cli/delete-draft-releases.sh | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index 44c35f3..570141a 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1542,7 +1542,7 @@ Creates a (mostly) empty migration for a given organization repository so that i ### delete-draft-releases.sh -Deletes intermediate draft releases associated with the latest merge manifest. It deletes only a unique draft created after the recorded merge whose target contains that merge commit, and refuses to delete when the release tag already exists. +Deletes intermediate draft releases associated with the latest merge manifest. It deletes only a unique draft created after the recorded merge whose target contains that merge commit. Existing Git tags are left untouched. Requires `gh` authentication with Contents write permission and `jq`. diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh index de853cd..0e93632 100755 --- a/gh-cli/delete-draft-releases.sh +++ b/gh-cli/delete-draft-releases.sh @@ -16,7 +16,7 @@ # Safety: # - Only draft releases created after the recorded PR merge are considered # - The draft target must contain the recorded merge commit -# - The draft tag must not already exist as a Git reference +# - Git tag references are never deleted # - Exactly one draft must match each manifest entry print_help() { @@ -140,13 +140,6 @@ while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do fi IFS=$'\t' read -r release_id tag_name created_at release_url <<< "$matching_drafts" - encoded_tag=$(jq -rn --arg value "$tag_name" '$value | @uri') - if gh api "/repos/$repo/git/ref/tags/$encoded_tag" > /dev/null 2>&1; then - echo " ❌ Tag $tag_name already exists; refusing to delete $release_url" - ((failed_count++)) - continue - 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" From b0350ab85389f9467a9c8c2b666d160f8c2e7e64 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 11:10:39 -0500 Subject: [PATCH 05/10] feat(gh-cli): limit draft release deletion age Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 4 +-- gh-cli/delete-draft-releases.sh | 43 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index 570141a..d1770f6 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1547,10 +1547,10 @@ Deletes intermediate draft releases associated with the latest merge manifest. I Requires `gh` authentication with Contents write permission and `jq`. ```bash -./delete-draft-releases.sh +./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. Do not run it after the final merge. +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. ### merge-pull-requests-by-title.sh diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh index 0e93632..5c74cac 100755 --- a/gh-cli/delete-draft-releases.sh +++ b/gh-cli/delete-draft-releases.sh @@ -3,11 +3,12 @@ # Deletes intermediate draft releases created for PRs recorded by merge-pull-requests-by-title.sh # # Usage: -# ./delete-draft-releases.sh [manifest_file] [--no-prompt] +# ./delete-draft-releases.sh [manifest_file] [--max-age-minutes ] [--no-prompt] # # Examples: # ./delete-draft-releases.sh -# ./delete-draft-releases.sh .release-manifests/previous.json --no-prompt +# ./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 @@ -22,16 +23,20 @@ print_help() { echo "Delete intermediate draft releases associated with a merge manifest" echo "" - echo "Usage: $0 [manifest_file] [--no-prompt]" + 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 -for arg in "$@"; do +args=("$@") +i=0 +while [ $i -lt ${#args[@]} ]; do + arg="${args[$i]}" case "$arg" in -h|--help) print_help @@ -40,6 +45,14 @@ for arg in "$@"; do --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 @@ -53,6 +66,7 @@ for arg in "$@"; do manifest_provided=true ;; esac + ((i++)) done if [ ! -f "$manifest_file" ]; then @@ -140,6 +154,27 @@ while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do 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" From f7fc1a6665ce127fcfdd8f2f3cdc0e315488fbac Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 11:12:04 -0500 Subject: [PATCH 06/10] fix(gh-cli): separate draft cleanup output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/delete-draft-releases.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh index 5c74cac..8a619ea 100755 --- a/gh-cli/delete-draft-releases.sh +++ b/gh-cli/delete-draft-releases.sh @@ -99,6 +99,9 @@ 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) From 1da9b2229b53a2fe6479c6da36a55d095ebfc128 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 11:20:55 -0500 Subject: [PATCH 07/10] fix(gh-cli): delete intermediate release tags Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 2 +- gh-cli/delete-draft-releases.sh | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index d1770f6..b721bb1 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -1542,7 +1542,7 @@ Creates a (mostly) empty migration for a given organization repository so that i ### delete-draft-releases.sh -Deletes intermediate draft releases associated with the latest merge manifest. It deletes only a unique draft created after the recorded merge whose target contains that merge commit. Existing Git tags are left untouched. +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`. diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh index 8a619ea..08e4f9e 100755 --- a/gh-cli/delete-draft-releases.sh +++ b/gh-cli/delete-draft-releases.sh @@ -17,7 +17,7 @@ # Safety: # - Only draft releases created after the recorded PR merge are considered # - The draft target must contain the recorded merge commit -# - Git tag references are never deleted +# - Only the exact tag named by the verified draft is deleted # - Exactly one draft must match each manifest entry print_help() { @@ -185,9 +185,17 @@ while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do 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? [y/N] " confirm < /dev/tty + 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++)) @@ -196,8 +204,13 @@ while IFS=$'\t' read -r repo pr_url merged_at merge_sha; do fi if gh api --method DELETE "/repos/$repo/releases/$release_id" > /dev/null; then - echo " ✅ Deleted intermediate draft $release_url" - ((deleted_count++)) + 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++)) From c5a5e0c9c77b357186a8e9c8d56fa5af81fcb6b4 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 12:21:35 -0500 Subject: [PATCH 08/10] fix(gh-cli): align release summary counts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/delete-draft-releases.sh | 6 +++--- gh-cli/publish-draft-releases.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gh-cli/delete-draft-releases.sh b/gh-cli/delete-draft-releases.sh index 08e4f9e..8e52e95 100755 --- a/gh-cli/delete-draft-releases.sh +++ b/gh-cli/delete-draft-releases.sh @@ -219,9 +219,9 @@ done < <(jq -r '.pullRequests[] | [.repository, .pullRequestUrl, .mergedAt, .mer echo "========================================" echo "Summary:" -echo " ✅ Deleted: $deleted_count" -echo " ❌ Failed: $failed_count" -echo " ⏭️ Skipped: $skipped_count" +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 diff --git a/gh-cli/publish-draft-releases.sh b/gh-cli/publish-draft-releases.sh index 0b9ad00..1afd8b9 100755 --- a/gh-cli/publish-draft-releases.sh +++ b/gh-cli/publish-draft-releases.sh @@ -151,9 +151,9 @@ done < <(jq -r '.pullRequests[] | [.repository, .pullRequestNumber, .pullRequest echo "========================================" echo "Summary:" -echo " ✅ Published: $published_count" -echo " ❌ Failed: $failed_count" -echo " ⏭️ Skipped: $skipped_count" +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 From ad01e39b53d9b007616d80c6f9f64670e45b7105 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 13:20:42 -0500 Subject: [PATCH 09/10] fix(gh-cli): harden release automation failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 48 +++++++++++++------------- gh-cli/merge-pull-requests-by-title.sh | 11 +++--- gh-cli/publish-draft-releases.sh | 22 ++++++++++-- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index b721bb1..d85afd8 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -507,6 +507,18 @@ Deletes all packages in an organization for a given package type. > [!WARNING] > This is a destructive operation and cannot be undone. +### 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-release.sh Deletes a release from a repository - need the [ID](#get-releasessh) of the release @@ -1540,18 +1552,6 @@ Adds users to an organization team from a CSV input list. Creates a (mostly) empty migration for a given organization repository so that it can create a lock. -### 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. - ### 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. 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. @@ -1609,18 +1609,6 @@ https://github.com/joshjohanning/repo3/pull/43 Template variables: `{title}` (PR title), `{number}` (PR number), `{body}` (PR body) -### 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. - ### parent-organization-teams.sh Sets the parents of teams in an target organization based on existing child/parent relationship on a source organization teams. @@ -1638,6 +1626,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/merge-pull-requests-by-title.sh b/gh-cli/merge-pull-requests-by-title.sh index 99e69b5..9fc8f61 100755 --- a/gh-cli/merge-pull-requests-by-title.sh +++ b/gh-cli/merge-pull-requests-by-title.sh @@ -517,11 +517,14 @@ while IFS= read -r repo_url || [ -n "$repo_url" ]; do 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') - if [ -z "$merged_pr" ]; then + --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" - ((fail_count++)) - continue + if [ -n "$merged_pr" ]; then + echo " $merged_pr" + fi + exit 1 fi if [ -n "$manifest_entries" ]; then manifest_entries+=$'\n' diff --git a/gh-cli/publish-draft-releases.sh b/gh-cli/publish-draft-releases.sh index 1afd8b9..b557b6f 100755 --- a/gh-cli/publish-draft-releases.sh +++ b/gh-cli/publish-draft-releases.sh @@ -95,6 +95,18 @@ while IFS=$'\t' read -r repo pr_number pr_url merged_at merge_sha; do 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}" @@ -110,8 +122,7 @@ while IFS=$'\t' read -r repo pr_number pr_url merged_at merge_sha; do fi matching_drafts+="$release_id"$'\t'"$tag_name"$'\t'"$created_at"$'\t'"$release_url" fi - done < <(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>/dev/null) + done <<< "$releases" match_count=$(printf '%s\n' "$matching_drafts" | awk 'NF { count++ } END { print count+0 }') if [ "$match_count" -eq 0 ]; then @@ -140,6 +151,13 @@ while IFS=$'\t' read -r repo pr_number pr_url merged_at merge_sha; do 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++)) From e6e8ce53c3204d4f9b4207cb970afa2e921b8b69 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Mon, 31 Aug 2026 13:29:58 -0500 Subject: [PATCH 10/10] fix(gh-cli): order draft release documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14d24b4b-af6d-42d0-8743-bbda5d4767a8 --- gh-cli/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gh-cli/README.md b/gh-cli/README.md index dc6b0f3..d054286 100644 --- a/gh-cli/README.md +++ b/gh-cli/README.md @@ -500,13 +500,6 @@ test11-team/test11111-team/textxxx-team ``` -### delete-packages-in-organization.sh - -Deletes all packages in an organization for a given package type. - -> [!WARNING] -> This is a destructive operation and cannot be undone. - ### 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. @@ -519,6 +512,13 @@ Requires `gh` authentication with Contents write permission and `jq`. 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. + +> [!WARNING] +> This is a destructive operation and cannot be undone. + ### delete-release.sh Deletes a release from a repository - need the [ID](#get-releasessh) of the release