Skip to content

Commit f8183c1

Browse files
authored
Add GitHub Actions workflow to automate cherry-pick hotfix PRs (#4106)
1 parent 9046321 commit f8183c1

13 files changed

Lines changed: 1205 additions & 18 deletions

.github/copilot-instructions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ When a new issue is created, follow these steps:
129129
- Suggest release note entries for fixes by updating files under `release-notes/` or by using the `release-notes` prompt (instead of editing `CHANGELOG.md` directly).
130130
- Tag reviewers based on `CODEOWNERS` file
131131

132+
## 🌿 Branch Naming
133+
- All branches created by AI agents **must** use the `dev/automation/` prefix (e.g. `dev/automation/fix-connection-timeout`).
134+
- Do **not** create branches directly under `main`, `dev/`, or any other top-level prefix.
135+
132136
## 🧠 Contextual Awareness
133137
- All source code is in `src/Microsoft.Data.SqlClient/src/`. Do NOT add code to legacy `netfx/src/` or `netcore/src/` directories.
134138
- Only `ref/` folders in `netcore/ref/` and `netfx/ref/` remain active for defining the public API surface.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
# extract-hotfix-versions.sh
9+
#
10+
# Parses "Hotfix X.Y.Z" labels from a merged GitHub PR and emits a JSON array
11+
# of version strings suitable for use as a GitHub Actions matrix dimension.
12+
#
13+
# OVERVIEW
14+
# --------
15+
# This script handles two distinct trigger scenarios:
16+
#
17+
# 1. 'closed' event — The PR was just merged. ALL "Hotfix X.Y.Z" labels on
18+
# the PR are processed, emitting one version per valid label.
19+
#
20+
# 2. 'labeled' event — A label was added to an already-merged PR. Only the
21+
# NEWLY ADDED label is considered, and only if a cherry-pick for that
22+
# version hasn't already been created (branch or PR exists).
23+
#
24+
# Label names must match the exact pattern "Hotfix <major>.<minor>.<patch>"
25+
# (e.g. "Hotfix 7.0.1"). All other labels are silently ignored.
26+
#
27+
# REQUIRED ENVIRONMENT VARIABLES
28+
# ------------------------------
29+
# LABELS Comma-separated list of all label names on the PR.
30+
# EVENT_ACTION The GitHub event action: "closed" or "labeled".
31+
# EVENT_LABEL For 'labeled' events, the name of the label that was added.
32+
# Empty or unset for 'closed' events.
33+
# PR_NUMBER The pull request number (used to derive cherry-pick branch names).
34+
# GH_TOKEN GitHub token for API calls (gh CLI auth).
35+
# GITHUB_REPOSITORY Owner/repo (e.g. "dotnet/SqlClient"). Set automatically by Actions.
36+
#
37+
# OUTPUTS
38+
# -------
39+
# Writes to $GITHUB_OUTPUT:
40+
# versions=<JSON array> e.g. versions=["7.0.1","8.0.0"]
41+
#
42+
# An empty array (versions=[]) means no work is needed.
43+
# The script exits with code 1 if the 'closed' event has no valid labels.
44+
#
45+
# USAGE
46+
# Called from the cherry-pick-hotfix.yml workflow. Can also be run locally
47+
# for testing by setting the required environment variables and providing a
48+
# writable GITHUB_OUTPUT file:
49+
#
50+
# export LABELS="Hotfix 7.0.1,bug"
51+
# export EVENT_ACTION="closed"
52+
# export PR_NUMBER=42
53+
# export GITHUB_OUTPUT=$(mktemp)
54+
# bash .github/scripts/extract-hotfix-versions.sh
55+
# cat "$GITHUB_OUTPUT"
56+
#
57+
#################################################################################
58+
set -euo pipefail
59+
60+
# -- Runtime help -------------------------------------------------------------
61+
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
62+
# Print the header comment block (between the license banner and the
63+
# closing banner), stripping the leading '# ' prefix.
64+
awk '/^#{2,}$/ { n++; next } n == 2 { sub(/^# ?/, ""); print }' "$0"
65+
exit 0
66+
fi
67+
68+
# -- Input validation ---------------------------------------------------------
69+
: "${LABELS:?LABELS environment variable is required}"
70+
: "${EVENT_ACTION:?EVENT_ACTION environment variable is required}"
71+
: "${PR_NUMBER:?PR_NUMBER environment variable is required}"
72+
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY environment variable is required}"
73+
74+
# -- 'labeled' event: process only the newly added label ----------------------
75+
if [[ "${EVENT_ACTION}" == "labeled" ]]; then
76+
# Extract version from the new label. If it doesn't match "Hotfix X.Y.Z",
77+
# this is a non-hotfix label — emit empty matrix and exit cleanly.
78+
if [[ "${EVENT_LABEL:-}" =~ ^Hotfix\ ([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
79+
CANDIDATE="${BASH_REMATCH[1]}"
80+
else
81+
CANDIDATE=""
82+
fi
83+
84+
if [[ -z "${CANDIDATE}" ]]; then
85+
echo "Label '${EVENT_LABEL:-}' is not a valid 'Hotfix X.Y.Z' label. Skipping."
86+
echo "versions=[]" >> "${GITHUB_OUTPUT}"
87+
exit 0
88+
fi
89+
90+
# Guard against duplicate cherry-picks. If the cherry-pick branch already
91+
# exists on the remote, or a PR (open, closed, or merged) was already created
92+
# from it, there is nothing left to do.
93+
#
94+
# NOTE: We use the GitHub API rather than 'git ls-remote' because the
95+
# detect-versions job does not check out the repository (no .git directory).
96+
CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${CANDIDATE}"
97+
98+
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${CHERRY_PICK_BRANCH}" \
99+
--silent 2>/dev/null; then
100+
echo "Cherry-pick branch '${CHERRY_PICK_BRANCH}' already exists. Skipping."
101+
echo "versions=[]" >> "${GITHUB_OUTPUT}"
102+
exit 0
103+
fi
104+
105+
EXISTING_PR=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${CHERRY_PICK_BRANCH}" --state all \
106+
--json number --jq 'length')
107+
if [[ "${EXISTING_PR}" -gt 0 ]]; then
108+
echo "A cherry-pick PR from '${CHERRY_PICK_BRANCH}' already exists. Skipping."
109+
echo "versions=[]" >> "${GITHUB_OUTPUT}"
110+
exit 0
111+
fi
112+
113+
VERSIONS="${CANDIDATE}"
114+
else
115+
# -- 'closed' event: process all hotfix labels on the PR --------------------
116+
# Split by comma, keep only labels matching "Hotfix X.Y.Z", extract the version.
117+
# Use sed -E for portable extended regex (works on both GNU and BSD sed).
118+
VERSIONS=$(echo "${LABELS}" | tr ',' '\n' \
119+
| sed -nE 's/^Hotfix ([0-9]+\.[0-9]+\.[0-9]+)$/\1/p')
120+
fi
121+
122+
# -- Validate that at least one version was found ----------------------------
123+
if [[ -z "${VERSIONS}" ]]; then
124+
echo "::error::No valid 'Hotfix X.Y.Z' label found. " \
125+
"Labels must match 'Hotfix <major>.<minor>.<patch>'."
126+
exit 1
127+
fi
128+
129+
# -- Emit JSON array for the matrix strategy ----------------------------------
130+
# Convert the newline-separated version list into a compact JSON array.
131+
# e.g. "7.0.1\n8.0.0" → ["7.0.1","8.0.0"]
132+
JSON=$(echo "${VERSIONS}" \
133+
| jq -R -s -c 'split("\n") | map(select(length > 0))')
134+
135+
echo "versions=${JSON}" >> "${GITHUB_OUTPUT}"
136+
echo "Detected hotfix versions: ${JSON}"

0 commit comments

Comments
 (0)