-
Notifications
You must be signed in to change notification settings - Fork 328
Add GitHub Actions workflow to automate cherry-pick hotfix PRs #4106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
0e4f882
Added a GitHub action to create hotfix PRs for merged PRs that have "…
paulmedynski 2f1a222
Address PR feedback: fork permissions, squash-merge support, branch n…
paulmedynski f6f77dd
Specified dev/automation as the branch name prefix.
paulmedynski d8afc60
Future-proofing our pipelines to trigger on main and release branches.
paulmedynski eb18fff
fix: address Copilot review - parent count, conflict instructions, la…
paulmedynski 5c4c1c7
Address review comments: idempotent labels, default-branch guard, mil…
paulmedynski dd53de5
Detect already-applied commits before cherry-pick
paulmedynski f012d23
Extract workflow scripts with documentation and bats tests
paulmedynski 481bb5b
Add README for bats test suite
paulmedynski de78174
Fix git ls-remote, cherry-pick arg order, and conflict command order
paulmedynski 00ed23a
fix: deduplicate --help by emitting header comments, add GITHUB_REPOS…
paulmedynski 58dbce0
Address open Copilot review feedback on PR #4106
paulmedynski 9b625e2
Addressed PR comments/suggestions.
paulmedynski f1925d1
Address second round of review feedback on PR #4106
paulmedynski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| #!/usr/bin/env bash | ||
| ################################################################################# | ||
| # Licensed to the .NET Foundation under one or more agreements. # | ||
| # The .NET Foundation licenses this file to you under the MIT license. # | ||
| # See the LICENSE file in the project root for more information. # | ||
| ################################################################################# | ||
| # | ||
| # cherry-pick-to-release.sh | ||
| # | ||
| # Cherry-picks a merge commit from the default branch onto a release branch | ||
| # and opens a pull request for the result. If the cherry-pick conflicts, an | ||
| # empty-commit placeholder PR is created with manual resolution instructions. | ||
| # | ||
| # OVERVIEW | ||
| # -------- | ||
| # This script performs the following steps: | ||
| # | ||
| # 1. Derive the target release branch from the version's major.minor | ||
| # (e.g. "7.0.1" → release/7.0). | ||
| # | ||
| # 2. Check whether the commit's patch is already present on the target | ||
| # branch (via 'git cherry'). If so, exit cleanly — nothing to do. | ||
| # | ||
| # 3. Detect whether the merge commit is a true merge (2+ parents) or a | ||
| # squash-merge (1 parent). True merges require '--mainline 1'. | ||
| # | ||
| # 4. Attempt the cherry-pick: | ||
| # - On success: push the branch, look up the milestone, create a PR. | ||
| # - On conflict: abort, push an empty-commit placeholder, create a | ||
| # "CONFLICTS" PR with manual resolution instructions. | ||
| # | ||
| # 5. Milestone lookup is best-effort. If the milestone doesn't exist yet | ||
| # the PR is created without one and a warning note is added to the body. | ||
| # | ||
| # REQUIRED ENVIRONMENT VARIABLES | ||
| # ------------------------------ | ||
| # VERSION Full hotfix version, e.g. "7.0.1". | ||
| # MERGE_COMMIT_SHA SHA of the merge commit on the default branch. | ||
| # PR_NUMBER Number of the original PR that was merged. | ||
| # PR_TITLE Title of the original PR (used in cherry-pick PR title). | ||
| # GH_TOKEN GitHub token for 'gh' CLI authentication. | ||
| # GITHUB_REPOSITORY Owner/repo (e.g. "dotnet/SqlClient"). Set by Actions. | ||
| # | ||
| # OUTPUTS | ||
| # ------- | ||
| # On success or conflict, a new PR is created on GitHub. | ||
| # On already-applied, the script exits 0 with a notice. | ||
| # | ||
| # USAGE | ||
| # Typically called from the cherry-pick-hotfix.yml workflow. | ||
| # The git working directory must have full history (fetch-depth: 0) and | ||
| # user.name / user.email must be configured before calling this script. | ||
| # | ||
| # Local testing example (dry-run — comment out 'gh pr create' calls): | ||
| # | ||
| # export VERSION="7.0.1" | ||
| # export MERGE_COMMIT_SHA="abc123" | ||
| # export PR_NUMBER=42 | ||
| # export PR_TITLE="Fix connection timeout" | ||
| # export GH_TOKEN="ghp_..." | ||
| # export GITHUB_REPOSITORY="dotnet/SqlClient" | ||
| # bash .github/scripts/cherry-pick-to-release.sh | ||
| # | ||
| ################################################################################# | ||
| set -euo pipefail | ||
|
|
||
| # -- Runtime help ------------------------------------------------------------- | ||
| if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then | ||
| # Print the header comment block (between the license banner and the | ||
| # closing banner), stripping the leading '# ' prefix. | ||
| awk '/^#{2,}$/ { n++; next } n == 2 { sub(/^# ?/, ""); print }' "$0" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # -- Input validation --------------------------------------------------------- | ||
| : "${VERSION:?VERSION environment variable is required}" | ||
| : "${MERGE_COMMIT_SHA:?MERGE_COMMIT_SHA environment variable is required}" | ||
| : "${PR_NUMBER:?PR_NUMBER environment variable is required}" | ||
| : "${PR_TITLE:?PR_TITLE environment variable is required}" | ||
| : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY environment variable is required}" | ||
|
|
||
| # -- Step 1: Derive target branch from major.minor --------------------------- | ||
| # "7.0.1" → "7.0", so target branch is "release/7.0". | ||
| # Use a bash regex for portability (grep -P is not available on macOS BSD grep). | ||
| if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+) ]]; then | ||
| BRANCH_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" | ||
| else | ||
| BRANCH_VERSION="" | ||
| fi | ||
| if [[ -z "${BRANCH_VERSION}" ]]; then | ||
| echo "::error::Could not parse major.minor from version '${VERSION}'." | ||
| exit 1 | ||
| fi | ||
|
|
||
| TARGET_BRANCH="release/${BRANCH_VERSION}" | ||
| CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${VERSION}" | ||
|
|
||
| echo "Version: ${VERSION}" | ||
| echo "Target branch: ${TARGET_BRANCH}" | ||
| echo "Cherry-pick branch: ${CHERRY_PICK_BRANCH}" | ||
| echo "Merge commit: ${MERGE_COMMIT_SHA}" | ||
|
|
||
| # Ensure the target branch ref is available locally. | ||
| git fetch origin "${TARGET_BRANCH}" | ||
|
|
||
| # -- Step 2: Check if the patch is already applied ---------------------------- | ||
| # 'git cherry <upstream> <head> <limit>' lists commits in <limit>..<head> and | ||
| # marks each with '-' (patch already on <upstream>) or '+' (not yet applied). | ||
| # By passing <head>=MERGE_COMMIT_SHA and <limit>=MERGE_COMMIT_SHA^ we scope | ||
| # the check to exactly one commit — the PR being cherry-picked. | ||
| if git cherry "origin/${TARGET_BRANCH}" "${MERGE_COMMIT_SHA}" "${MERGE_COMMIT_SHA}^" \ | ||
| | grep -q '^-'; then | ||
| echo "::notice::Commit ${MERGE_COMMIT_SHA} is already applied on" \ | ||
| "${TARGET_BRANCH}. Skipping cherry-pick." | ||
| exit 0 | ||
| fi | ||
|
paulmedynski marked this conversation as resolved.
|
||
|
|
||
| # Create the cherry-pick working branch from the target release branch. | ||
| git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" | ||
|
|
||
| # -- Step 3: Detect merge commit type ----------------------------------------- | ||
| # True merge commits have 2+ parents and require '--mainline 1' to tell git | ||
| # which parent's tree to diff against (the first parent = the target branch). | ||
| # Squash-merge commits have exactly 1 parent and must NOT use --mainline. | ||
| # | ||
| # 'git rev-list --parents -n1 <sha>' outputs: <sha> <parent1> [<parent2> ...] | ||
| # awk counts fields and subtracts 1 (the SHA itself) to get the parent count. | ||
| PARENT_COUNT=$(git rev-list --parents -n1 "${MERGE_COMMIT_SHA}" \ | ||
| | awk '{print NF - 1}') | ||
| MAINLINE_FLAG="" | ||
| if [[ "${PARENT_COUNT}" -gt 1 ]]; then | ||
| MAINLINE_FLAG="--mainline 1" | ||
| echo "Merge commit has ${PARENT_COUNT} parents — using --mainline 1." | ||
| else | ||
| echo "Squash-merge commit (single parent) — no --mainline flag needed." | ||
| fi | ||
|
|
||
| # -- Helper: look up milestone ------------------------------------------------ | ||
| # Milestone assignment is best-effort. If the milestone doesn't exist yet, the | ||
| # PR is created without one and a note is appended to the PR body. | ||
| lookup_milestone() { | ||
| local version="$1" | ||
| MILESTONE_ARG="" | ||
| MILESTONE_NOTE="" | ||
|
|
||
| if gh api "repos/${GITHUB_REPOSITORY}/milestones" --paginate \ | ||
| --field state=open --jq '.[].title' | grep -qx "${version}"; then | ||
| MILESTONE_ARG="--milestone ${version}" | ||
| echo "Milestone '${version}' found." | ||
| else | ||
| echo "::warning::Milestone '${version}' does not exist." \ | ||
| "PR will be created without a milestone." | ||
| MILESTONE_NOTE=$'\n\n> **Note:** Milestone `'"${version}"'` does not exist yet. Please create it and assign this PR manually.' | ||
| fi | ||
| } | ||
|
|
||
| # -- Step 4: Attempt the cherry-pick ------------------------------------------ | ||
| # Options (--mainline) must precede the commit operand. | ||
| if git cherry-pick ${MAINLINE_FLAG} "${MERGE_COMMIT_SHA}"; then | ||
| # --- Success path --- | ||
|
paulmedynski marked this conversation as resolved.
|
||
| echo "Cherry-pick succeeded. Pushing branch and creating PR." | ||
| git push origin "${CHERRY_PICK_BRANCH}" | ||
|
|
||
| lookup_milestone "${VERSION}" | ||
|
|
||
| gh pr create \ | ||
| --base "${TARGET_BRANCH}" \ | ||
| --head "${CHERRY_PICK_BRANCH}" \ | ||
| --title "[${VERSION} Cherry-pick] ${PR_TITLE}" \ | ||
| --body "Cherry-pick of #${PR_NUMBER} (${MERGE_COMMIT_SHA}) into \`${TARGET_BRANCH}\`.${MILESTONE_NOTE}" \ | ||
| ${MILESTONE_ARG} | ||
| else | ||
| # --- Conflict path --- | ||
| echo "::error::Cherry-pick of ${MERGE_COMMIT_SHA} failed due to conflicts." | ||
| git cherry-pick --abort | ||
|
|
||
| # Build the cherry-pick command for inclusion in the conflict-resolution | ||
| # instructions. Options must precede the commit SHA. | ||
| CHERRY_PICK_CMD="git cherry-pick" | ||
| if [[ -n "${MAINLINE_FLAG}" ]]; then | ||
| CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MAINLINE_FLAG}" | ||
| fi | ||
|
paulmedynski marked this conversation as resolved.
|
||
| CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MERGE_COMMIT_SHA}" | ||
|
|
||
| # Create a branch with an empty commit so a PR can be opened. The PR body | ||
| # contains step-by-step instructions for manual conflict resolution. | ||
| git checkout "origin/${TARGET_BRANCH}" | ||
| git checkout -B "${CHERRY_PICK_BRANCH}" | ||
| git commit --allow-empty \ | ||
| -m "Cherry-pick of #${PR_NUMBER} requires manual resolution" \ | ||
| -m "To resolve, run: ${CHERRY_PICK_CMD}" | ||
| git push origin "${CHERRY_PICK_BRANCH}" | ||
|
|
||
| lookup_milestone "${VERSION}" | ||
|
|
||
| gh pr create \ | ||
| --base "${TARGET_BRANCH}" \ | ||
| --head "${CHERRY_PICK_BRANCH}" \ | ||
| --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ | ||
| ${MILESTONE_ARG} \ | ||
| --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```' | ||
| fi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| #!/usr/bin/env bash | ||
| ################################################################################# | ||
| # Licensed to the .NET Foundation under one or more agreements. # | ||
| # The .NET Foundation licenses this file to you under the MIT license. # | ||
| # See the LICENSE file in the project root for more information. # | ||
| ################################################################################# | ||
| # | ||
| # extract-hotfix-versions.sh | ||
| # | ||
| # Parses "Hotfix X.Y.Z" labels from a merged GitHub PR and emits a JSON array | ||
| # of version strings suitable for use as a GitHub Actions matrix dimension. | ||
| # | ||
| # OVERVIEW | ||
| # -------- | ||
| # This script handles two distinct trigger scenarios: | ||
| # | ||
| # 1. 'closed' event — The PR was just merged. ALL "Hotfix X.Y.Z" labels on | ||
| # the PR are processed, emitting one version per valid label. | ||
| # | ||
| # 2. 'labeled' event — A label was added to an already-merged PR. Only the | ||
| # NEWLY ADDED label is considered, and only if a cherry-pick for that | ||
| # version hasn't already been created (branch or PR exists). | ||
| # | ||
| # Label names must match the exact pattern "Hotfix <major>.<minor>.<patch>" | ||
| # (e.g. "Hotfix 7.0.1"). All other labels are silently ignored. | ||
| # | ||
| # REQUIRED ENVIRONMENT VARIABLES | ||
| # ------------------------------ | ||
| # LABELS Comma-separated list of all label names on the PR. | ||
| # EVENT_ACTION The GitHub event action: "closed" or "labeled". | ||
| # EVENT_LABEL For 'labeled' events, the name of the label that was added. | ||
| # Empty or unset for 'closed' events. | ||
| # PR_NUMBER The pull request number (used to derive cherry-pick branch names). | ||
| # GH_TOKEN GitHub token for API calls (gh CLI auth). | ||
| # GITHUB_REPOSITORY Owner/repo (e.g. "dotnet/SqlClient"). Set automatically by Actions. | ||
| # | ||
| # OUTPUTS | ||
| # ------- | ||
| # Writes to $GITHUB_OUTPUT: | ||
| # versions=<JSON array> e.g. versions=["7.0.1","8.0.0"] | ||
| # | ||
| # An empty array (versions=[]) means no work is needed. | ||
| # The script exits with code 1 if the 'closed' event has no valid labels. | ||
| # | ||
| # USAGE | ||
| # Called from the cherry-pick-hotfix.yml workflow. Can also be run locally | ||
| # for testing by setting the required environment variables and providing a | ||
| # writable GITHUB_OUTPUT file: | ||
| # | ||
| # export LABELS="Hotfix 7.0.1,bug" | ||
| # export EVENT_ACTION="closed" | ||
| # export PR_NUMBER=42 | ||
| # export GITHUB_OUTPUT=$(mktemp) | ||
| # bash .github/scripts/extract-hotfix-versions.sh | ||
| # cat "$GITHUB_OUTPUT" | ||
| # | ||
| ################################################################################# | ||
| set -euo pipefail | ||
|
|
||
| # -- Runtime help ------------------------------------------------------------- | ||
| if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then | ||
| # Print the header comment block (between the license banner and the | ||
| # closing banner), stripping the leading '# ' prefix. | ||
| awk '/^#{2,}$/ { n++; next } n == 2 { sub(/^# ?/, ""); print }' "$0" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # -- Input validation --------------------------------------------------------- | ||
| : "${LABELS:?LABELS environment variable is required}" | ||
| : "${EVENT_ACTION:?EVENT_ACTION environment variable is required}" | ||
| : "${PR_NUMBER:?PR_NUMBER environment variable is required}" | ||
| : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY environment variable is required}" | ||
|
|
||
|
paulmedynski marked this conversation as resolved.
|
||
| # -- 'labeled' event: process only the newly added label ---------------------- | ||
| if [[ "${EVENT_ACTION}" == "labeled" ]]; then | ||
| # Extract version from the new label. If it doesn't match "Hotfix X.Y.Z", | ||
| # this is a non-hotfix label — emit empty matrix and exit cleanly. | ||
| if [[ "${EVENT_LABEL:-}" =~ ^Hotfix\ ([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then | ||
| CANDIDATE="${BASH_REMATCH[1]}" | ||
| else | ||
| CANDIDATE="" | ||
| fi | ||
|
|
||
|
paulmedynski marked this conversation as resolved.
|
||
| if [[ -z "${CANDIDATE}" ]]; then | ||
| echo "Label '${EVENT_LABEL:-}' is not a valid 'Hotfix X.Y.Z' label. Skipping." | ||
| echo "versions=[]" >> "${GITHUB_OUTPUT}" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Guard against duplicate cherry-picks. If the cherry-pick branch already | ||
| # exists on the remote, or a PR (open, closed, or merged) was already created | ||
| # from it, there is nothing left to do. | ||
| # | ||
| # NOTE: We use the GitHub API rather than 'git ls-remote' because the | ||
| # detect-versions job does not check out the repository (no .git directory). | ||
| CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${CANDIDATE}" | ||
|
|
||
| if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${CHERRY_PICK_BRANCH}" \ | ||
| --silent 2>/dev/null; then | ||
| echo "Cherry-pick branch '${CHERRY_PICK_BRANCH}' already exists. Skipping." | ||
| echo "versions=[]" >> "${GITHUB_OUTPUT}" | ||
| exit 0 | ||
| fi | ||
|
|
||
| EXISTING_PR=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${CHERRY_PICK_BRANCH}" --state all \ | ||
| --json number --jq 'length') | ||
| if [[ "${EXISTING_PR}" -gt 0 ]]; then | ||
|
paulmedynski marked this conversation as resolved.
|
||
| echo "A cherry-pick PR from '${CHERRY_PICK_BRANCH}' already exists. Skipping." | ||
| echo "versions=[]" >> "${GITHUB_OUTPUT}" | ||
| exit 0 | ||
| fi | ||
|
|
||
| VERSIONS="${CANDIDATE}" | ||
| else | ||
| # -- 'closed' event: process all hotfix labels on the PR -------------------- | ||
| # Split by comma, keep only labels matching "Hotfix X.Y.Z", extract the version. | ||
| # Use sed -E for portable extended regex (works on both GNU and BSD sed). | ||
| VERSIONS=$(echo "${LABELS}" | tr ',' '\n' \ | ||
| | sed -nE 's/^Hotfix ([0-9]+\.[0-9]+\.[0-9]+)$/\1/p') | ||
| fi | ||
|
|
||
| # -- Validate that at least one version was found ---------------------------- | ||
| if [[ -z "${VERSIONS}" ]]; then | ||
| echo "::error::No valid 'Hotfix X.Y.Z' label found. " \ | ||
| "Labels must match 'Hotfix <major>.<minor>.<patch>'." | ||
| exit 1 | ||
| fi | ||
|
|
||
| # -- Emit JSON array for the matrix strategy ---------------------------------- | ||
| # Convert the newline-separated version list into a compact JSON array. | ||
| # e.g. "7.0.1\n8.0.0" → ["7.0.1","8.0.0"] | ||
| JSON=$(echo "${VERSIONS}" \ | ||
| | jq -R -s -c 'split("\n") | map(select(length > 0))') | ||
|
|
||
| echo "versions=${JSON}" >> "${GITHUB_OUTPUT}" | ||
| echo "Detected hotfix versions: ${JSON}" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.