1
0

backport.sh 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. #!/usr/bin/env bash
  2. #
  3. # Backport a merged PR to a release branch.
  4. #
  5. # Cherry-picks the merge commit of a merged PR onto release-1.11 and opens a
  6. # backport PR, mirroring the shape of #1842.
  7. #
  8. # Usage: ./backport.sh <pr-number> [target-version] [--continue]
  9. # pr-number The merged PR to backport
  10. # target-version Release version to target (default: 1.11)
  11. # --continue Resume after resolving cherry-pick conflicts: assumes the
  12. # fixes are staged, runs `git cherry-pick --continue`, and
  13. # proceeds to push and open the backport PR.
  14. #
  15. # Requires: gh (authenticated), git, jq.
  16. set -euo pipefail
  17. PR_NUMBER=${1:?usage: backport.sh <pr-number> [target-version] [--continue]}
  18. shift
  19. CONTINUE=0
  20. TARGET_VERSION=""
  21. for arg in "$@"; do
  22. case "$arg" in
  23. --continue) CONTINUE=1 ;;
  24. *) TARGET_VERSION=$arg ;;
  25. esac
  26. done
  27. for cmd in gh git jq; do
  28. command -v "$cmd" >/dev/null || { echo "error: $cmd is required" >&2; exit 1; }
  29. done
  30. # Pull the source PR's metadata. Refuse to backport a PR that never merged.
  31. pr_json=$(gh pr view "$PR_NUMBER" --json title,body,mergedAt,mergeCommit,milestone)
  32. PR_TITLE=$(jq -r '.title' <<<"$pr_json")
  33. PR_BODY=$(jq -r '.body' <<<"$pr_json")
  34. MERGE_SHA=$(jq -r '.mergeCommit.oid // empty' <<<"$pr_json")
  35. PR_MILESTONE=$(jq -r '.milestone.title // empty' <<<"$pr_json")
  36. if [ "$(jq -r '.mergedAt // empty' <<<"$pr_json")" = "" ] || [ -z "$MERGE_SHA" ]; then
  37. echo "error: PR #${PR_NUMBER} is not merged (no merge commit to cherry-pick)" >&2
  38. exit 1
  39. fi
  40. # Default the target to one minor below the PR's milestone: a PR landing in
  41. # v1.12.0 backports to release-1.11. An explicit target-version arg overrides.
  42. if [ -z "$TARGET_VERSION" ]; then
  43. if [[ $PR_MILESTONE =~ ^v?([0-9]+)\.([0-9]+) ]]; then
  44. TARGET_VERSION="${BASH_REMATCH[1]}.$(( BASH_REMATCH[2] - 1 ))"
  45. else
  46. echo "error: PR #${PR_NUMBER} has no v<major>.<minor>.* milestone to infer the target from." >&2
  47. echo "Pass the target version explicitly, e.g. $0 ${PR_NUMBER} 1.11" >&2
  48. exit 1
  49. fi
  50. fi
  51. TARGET_BRANCH="release-${TARGET_VERSION}"
  52. # Slug from the PR title: lower-case, non-alphanumerics to dashes, trimmed.
  53. slug=$(printf '%s' "$PR_TITLE" \
  54. | tr '[:upper:]' '[:lower:]' \
  55. | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' \
  56. | cut -c1-50 \
  57. | sed -E 's/-+$//')
  58. BRANCH="backport-${TARGET_VERSION//./-}-${slug}"
  59. if [ "$CONTINUE" -eq 1 ]; then
  60. # Resuming: the branch already exists and a cherry-pick is mid-conflict.
  61. current=$(git rev-parse --abbrev-ref HEAD)
  62. if [ "$current" != "$BRANCH" ]; then
  63. echo "error: --continue expects to be on ${BRANCH}, but HEAD is ${current}" >&2
  64. exit 1
  65. fi
  66. echo "Resuming backport of #${PR_NUMBER} on ${BRANCH}"
  67. # Conflicts assumed resolved and staged; core.editor=true keeps the commit message.
  68. git -c core.editor=true cherry-pick --continue
  69. else
  70. # A fresh run switches branches and cherry-picks, so tracked changes must be
  71. # clean. Untracked files are fine, and --continue is exempt (its resolved
  72. # conflicts are meant to be staged).
  73. if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
  74. echo "error: working tree has uncommitted changes to tracked files; commit or stash them first." >&2
  75. exit 1
  76. fi
  77. echo "Backporting #${PR_NUMBER} (${MERGE_SHA}) onto ${TARGET_BRANCH} as ${BRANCH}"
  78. git fetch origin
  79. git checkout -b "$BRANCH" "origin/${TARGET_BRANCH}"
  80. # -m 1 handles a real merge commit; plain cherry-pick handles a squash merge.
  81. if ! { git cherry-pick -x -m 1 "$MERGE_SHA" 2>/dev/null || git cherry-pick -x "$MERGE_SHA"; }; then
  82. echo "error: ${MERGE_SHA} did not cherry-pick cleanly onto ${TARGET_BRANCH}." >&2
  83. echo "Resolve the conflicts, 'git add' them, then re-run:" >&2
  84. echo " $0 ${PR_NUMBER} ${TARGET_VERSION} --continue" >&2
  85. exit 1
  86. fi
  87. fi
  88. title="backport v${TARGET_VERSION}: ${PR_TITLE}"
  89. body=$(printf 'Backport from #%s to release v%s\n\n---\n\n%s' \
  90. "$PR_NUMBER" "$TARGET_VERSION" "$PR_BODY")
  91. # Highest open milestone matching v<version>.* (e.g. v1.11.1), if any.
  92. MILESTONE=$(gh api "repos/{owner}/{repo}/milestones?state=open" \
  93. --jq ".[].title | select(startswith(\"v${TARGET_VERSION}.\"))" \
  94. | sort -V | tail -n1)
  95. milestone_args=()
  96. if [ -n "$MILESTONE" ]; then
  97. milestone_args=(--milestone "$MILESTONE")
  98. else
  99. echo "warning: no open milestone matching v${TARGET_VERSION}.* found; PR will have no milestone" >&2
  100. fi
  101. echo
  102. echo "About to run:"
  103. printf ' git push -u --force-with-lease origin %q\n' "$BRANCH"
  104. printf ' gh pr create --base %q --head %q --title %q --body %q' \
  105. "$TARGET_BRANCH" "$BRANCH" "$title" "$body"
  106. [ -n "$MILESTONE" ] && printf ' --milestone %q' "$MILESTONE"
  107. printf '\n'
  108. echo
  109. read -r -p "Open this pull request? [y/N] " reply
  110. case "$reply" in
  111. [yY] | [yY][eE][sS]) ;;
  112. *) echo "Aborted. The cherry-pick is on local branch ${BRANCH}; push it manually if desired."; exit 0 ;;
  113. esac
  114. git push -u --force-with-lease origin "$BRANCH"
  115. gh pr create \
  116. --base "$TARGET_BRANCH" \
  117. --head "$BRANCH" \
  118. --title "$title" \
  119. --body "$body" \
  120. ${milestone_args[@]+"${milestone_args[@]}"}
  121. # The backport now exists, so drop the label that flagged this PR for one.
  122. gh pr edit "$PR_NUMBER" --remove-label needs-backport
  123. # Switch back to the original branch we were on before doing the backport
  124. git checkout -