|
| 1 | +#!/usr/bin/env bash |
| 2 | +################################################################################# |
| 3 | +# Licensed to the .NET Foundation under one or more agreements. # |
| 4 | +# The .NET Foundation licenses this file to you under the MIT license. # |
| 5 | +# See the LICENSE file in the project root for more information. # |
| 6 | +################################################################################# |
| 7 | +# |
| 8 | +# cherry-pick-to-release.sh |
| 9 | +# |
| 10 | +# Cherry-picks a merge commit from the default branch onto a release branch |
| 11 | +# and opens a pull request for the result. If the cherry-pick conflicts, an |
| 12 | +# empty-commit placeholder PR is created with manual resolution instructions. |
| 13 | +# |
| 14 | +# OVERVIEW |
| 15 | +# -------- |
| 16 | +# This script performs the following steps: |
| 17 | +# |
| 18 | +# 1. Derive the target release branch from the version's major.minor |
| 19 | +# (e.g. "7.0.1" → release/7.0). |
| 20 | +# |
| 21 | +# 2. Check whether the commit's patch is already present on the target |
| 22 | +# branch (via 'git cherry'). If so, exit cleanly — nothing to do. |
| 23 | +# |
| 24 | +# 3. Detect whether the merge commit is a true merge (2+ parents) or a |
| 25 | +# squash-merge (1 parent). True merges require '--mainline 1'. |
| 26 | +# |
| 27 | +# 4. Attempt the cherry-pick: |
| 28 | +# - On success: push the branch, look up the milestone, create a PR. |
| 29 | +# - On conflict: abort, push an empty-commit placeholder, create a |
| 30 | +# "CONFLICTS" PR with manual resolution instructions. |
| 31 | +# |
| 32 | +# 5. Milestone lookup is best-effort. If the milestone doesn't exist yet |
| 33 | +# the PR is created without one and a warning note is added to the body. |
| 34 | +# |
| 35 | +# REQUIRED ENVIRONMENT VARIABLES |
| 36 | +# ------------------------------ |
| 37 | +# VERSION Full hotfix version, e.g. "7.0.1". |
| 38 | +# MERGE_COMMIT_SHA SHA of the merge commit on the default branch. |
| 39 | +# PR_NUMBER Number of the original PR that was merged. |
| 40 | +# PR_TITLE Title of the original PR (used in cherry-pick PR title). |
| 41 | +# GH_TOKEN GitHub token for 'gh' CLI authentication. |
| 42 | +# GITHUB_REPOSITORY Owner/repo (e.g. "dotnet/SqlClient"). Set by Actions. |
| 43 | +# |
| 44 | +# OUTPUTS |
| 45 | +# ------- |
| 46 | +# On success or conflict, a new PR is created on GitHub. |
| 47 | +# On already-applied, the script exits 0 with a notice. |
| 48 | +# |
| 49 | +# USAGE |
| 50 | +# Typically called from the cherry-pick-hotfix.yml workflow. |
| 51 | +# The git working directory must have full history (fetch-depth: 0) and |
| 52 | +# user.name / user.email must be configured before calling this script. |
| 53 | +# |
| 54 | +# Local testing example (dry-run — comment out 'gh pr create' calls): |
| 55 | +# |
| 56 | +# export VERSION="7.0.1" |
| 57 | +# export MERGE_COMMIT_SHA="abc123" |
| 58 | +# export PR_NUMBER=42 |
| 59 | +# export PR_TITLE="Fix connection timeout" |
| 60 | +# export GH_TOKEN="ghp_..." |
| 61 | +# export GITHUB_REPOSITORY="dotnet/SqlClient" |
| 62 | +# bash .github/scripts/cherry-pick-to-release.sh |
| 63 | +# |
| 64 | +################################################################################# |
| 65 | +set -euo pipefail |
| 66 | + |
| 67 | +# -- Runtime help ------------------------------------------------------------- |
| 68 | +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then |
| 69 | + # Print the header comment block (between the license banner and the |
| 70 | + # closing banner), stripping the leading '# ' prefix. |
| 71 | + awk '/^#{2,}$/ { n++; next } n == 2 { sub(/^# ?/, ""); print }' "$0" |
| 72 | + exit 0 |
| 73 | +fi |
| 74 | + |
| 75 | +# -- Input validation --------------------------------------------------------- |
| 76 | +: "${VERSION:?VERSION environment variable is required}" |
| 77 | +: "${MERGE_COMMIT_SHA:?MERGE_COMMIT_SHA environment variable is required}" |
| 78 | +: "${PR_NUMBER:?PR_NUMBER environment variable is required}" |
| 79 | +: "${PR_TITLE:?PR_TITLE environment variable is required}" |
| 80 | +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY environment variable is required}" |
| 81 | + |
| 82 | +# -- Step 1: Derive target branch from major.minor --------------------------- |
| 83 | +# "7.0.1" → "7.0", so target branch is "release/7.0". |
| 84 | +# Use a bash regex for portability (grep -P is not available on macOS BSD grep). |
| 85 | +if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+) ]]; then |
| 86 | + BRANCH_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" |
| 87 | +else |
| 88 | + BRANCH_VERSION="" |
| 89 | +fi |
| 90 | +if [[ -z "${BRANCH_VERSION}" ]]; then |
| 91 | + echo "::error::Could not parse major.minor from version '${VERSION}'." |
| 92 | + exit 1 |
| 93 | +fi |
| 94 | + |
| 95 | +TARGET_BRANCH="release/${BRANCH_VERSION}" |
| 96 | +CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${VERSION}" |
| 97 | + |
| 98 | +echo "Version: ${VERSION}" |
| 99 | +echo "Target branch: ${TARGET_BRANCH}" |
| 100 | +echo "Cherry-pick branch: ${CHERRY_PICK_BRANCH}" |
| 101 | +echo "Merge commit: ${MERGE_COMMIT_SHA}" |
| 102 | + |
| 103 | +# Ensure the target branch ref is available locally. |
| 104 | +git fetch origin "${TARGET_BRANCH}" |
| 105 | + |
| 106 | +# -- Step 2: Check if the patch is already applied ---------------------------- |
| 107 | +# 'git cherry <upstream> <head> <limit>' lists commits in <limit>..<head> and |
| 108 | +# marks each with '-' (patch already on <upstream>) or '+' (not yet applied). |
| 109 | +# By passing <head>=MERGE_COMMIT_SHA and <limit>=MERGE_COMMIT_SHA^ we scope |
| 110 | +# the check to exactly one commit — the PR being cherry-picked. |
| 111 | +if git cherry "origin/${TARGET_BRANCH}" "${MERGE_COMMIT_SHA}" "${MERGE_COMMIT_SHA}^" \ |
| 112 | + | grep -q '^-'; then |
| 113 | + echo "::notice::Commit ${MERGE_COMMIT_SHA} is already applied on" \ |
| 114 | + "${TARGET_BRANCH}. Skipping cherry-pick." |
| 115 | + exit 0 |
| 116 | +fi |
| 117 | + |
| 118 | +# Create the cherry-pick working branch from the target release branch. |
| 119 | +git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" |
| 120 | + |
| 121 | +# -- Step 3: Detect merge commit type ----------------------------------------- |
| 122 | +# True merge commits have 2+ parents and require '--mainline 1' to tell git |
| 123 | +# which parent's tree to diff against (the first parent = the target branch). |
| 124 | +# Squash-merge commits have exactly 1 parent and must NOT use --mainline. |
| 125 | +# |
| 126 | +# 'git rev-list --parents -n1 <sha>' outputs: <sha> <parent1> [<parent2> ...] |
| 127 | +# awk counts fields and subtracts 1 (the SHA itself) to get the parent count. |
| 128 | +PARENT_COUNT=$(git rev-list --parents -n1 "${MERGE_COMMIT_SHA}" \ |
| 129 | + | awk '{print NF - 1}') |
| 130 | +MAINLINE_FLAG="" |
| 131 | +if [[ "${PARENT_COUNT}" -gt 1 ]]; then |
| 132 | + MAINLINE_FLAG="--mainline 1" |
| 133 | + echo "Merge commit has ${PARENT_COUNT} parents — using --mainline 1." |
| 134 | +else |
| 135 | + echo "Squash-merge commit (single parent) — no --mainline flag needed." |
| 136 | +fi |
| 137 | + |
| 138 | +# -- Helper: look up milestone ------------------------------------------------ |
| 139 | +# Milestone assignment is best-effort. If the milestone doesn't exist yet, the |
| 140 | +# PR is created without one and a note is appended to the PR body. |
| 141 | +lookup_milestone() { |
| 142 | + local version="$1" |
| 143 | + MILESTONE_ARG="" |
| 144 | + MILESTONE_NOTE="" |
| 145 | + |
| 146 | + if gh api "repos/${GITHUB_REPOSITORY}/milestones" --paginate \ |
| 147 | + --field state=open --jq '.[].title' | grep -qx "${version}"; then |
| 148 | + MILESTONE_ARG="--milestone ${version}" |
| 149 | + echo "Milestone '${version}' found." |
| 150 | + else |
| 151 | + echo "::warning::Milestone '${version}' does not exist." \ |
| 152 | + "PR will be created without a milestone." |
| 153 | + MILESTONE_NOTE=$'\n\n> **Note:** Milestone `'"${version}"'` does not exist yet. Please create it and assign this PR manually.' |
| 154 | + fi |
| 155 | +} |
| 156 | + |
| 157 | +# -- Step 4: Attempt the cherry-pick ------------------------------------------ |
| 158 | +# Options (--mainline) must precede the commit operand. |
| 159 | +if git cherry-pick ${MAINLINE_FLAG} "${MERGE_COMMIT_SHA}"; then |
| 160 | + # --- Success path --- |
| 161 | + echo "Cherry-pick succeeded. Pushing branch and creating PR." |
| 162 | + git push origin "${CHERRY_PICK_BRANCH}" |
| 163 | + |
| 164 | + lookup_milestone "${VERSION}" |
| 165 | + |
| 166 | + gh pr create \ |
| 167 | + --base "${TARGET_BRANCH}" \ |
| 168 | + --head "${CHERRY_PICK_BRANCH}" \ |
| 169 | + --title "[${VERSION} Cherry-pick] ${PR_TITLE}" \ |
| 170 | + --body "Cherry-pick of #${PR_NUMBER} (${MERGE_COMMIT_SHA}) into \`${TARGET_BRANCH}\`.${MILESTONE_NOTE}" \ |
| 171 | + ${MILESTONE_ARG} |
| 172 | +else |
| 173 | + # --- Conflict path --- |
| 174 | + echo "::error::Cherry-pick of ${MERGE_COMMIT_SHA} failed due to conflicts." |
| 175 | + git cherry-pick --abort |
| 176 | + |
| 177 | + # Build the cherry-pick command for inclusion in the conflict-resolution |
| 178 | + # instructions. Options must precede the commit SHA. |
| 179 | + CHERRY_PICK_CMD="git cherry-pick" |
| 180 | + if [[ -n "${MAINLINE_FLAG}" ]]; then |
| 181 | + CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MAINLINE_FLAG}" |
| 182 | + fi |
| 183 | + CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MERGE_COMMIT_SHA}" |
| 184 | + |
| 185 | + # Create a branch with an empty commit so a PR can be opened. The PR body |
| 186 | + # contains step-by-step instructions for manual conflict resolution. |
| 187 | + git checkout "origin/${TARGET_BRANCH}" |
| 188 | + git checkout -B "${CHERRY_PICK_BRANCH}" |
| 189 | + git commit --allow-empty \ |
| 190 | + -m "Cherry-pick of #${PR_NUMBER} requires manual resolution" \ |
| 191 | + -m "To resolve, run: ${CHERRY_PICK_CMD}" |
| 192 | + git push origin "${CHERRY_PICK_BRANCH}" |
| 193 | + |
| 194 | + lookup_milestone "${VERSION}" |
| 195 | + |
| 196 | + gh pr create \ |
| 197 | + --base "${TARGET_BRANCH}" \ |
| 198 | + --head "${CHERRY_PICK_BRANCH}" \ |
| 199 | + --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ |
| 200 | + ${MILESTONE_ARG} \ |
| 201 | + --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.'"${MILESTONE_NOTE}"$'\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout '"${CHERRY_PICK_BRANCH}"'\n'"${CHERRY_PICK_CMD}"$'\n# resolve conflicts\ngit push origin '"${CHERRY_PICK_BRANCH}"' --force\n```' |
| 202 | +fi |
0 commit comments