From 0e4f882534e702dc1d14a2defcd19db10040c48b Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:14:45 -0300 Subject: [PATCH 01/14] Added a GitHub action to create hotfix PRs for merged PRs that have "Hotfix X.Y.Z" labels. --- .github/workflows/cherry-pick-hotfix.yml | 144 +++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .github/workflows/cherry-pick-hotfix.yml diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml new file mode 100644 index 0000000000..64ee467c0d --- /dev/null +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -0,0 +1,144 @@ +################################################################################# +# 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 Hotfix to Release Branch +# +# Automatically cherry-picks merged PRs into release branches when a +# "Hotfix " label is present. Supports multiple hotfix labels on +# a single PR — each one produces an independent cherry-pick PR targeting +# the corresponding release/ branch. +# +# Usage: +# 1. Merge a PR to the default branch. +# 2. Add a "Hotfix " label (e.g. "Hotfix 7.0.1") either before +# or after merging. +# 3. The workflow creates a cherry-pick PR to release/ with the +# same title, prefixed with "[ Cherry-pick]". +# 4. If the cherry-pick has conflicts, a placeholder PR is opened with +# "[ Cherry-pick - CONFLICTS]" and manual resolution steps. +# +################################################################################# + +name: Cherry-pick Hotfix to release branch + +# Triggers: +# - 'closed': fires at merge time — if a "Hotfix " label is already present, +# the cherry-pick runs immediately. +# - 'labeled': fires when a label is added after merge — allows retroactive cherry-picks +# by adding the label to an already-merged PR. +on: + pull_request: + types: [closed, labeled] + +# 'contents: write' is needed to push the cherry-pick branch. +# 'pull-requests: write' is needed to create the new PR via the GitHub CLI. +permissions: + contents: write + pull-requests: write + +jobs: + # First job: extract all hotfix versions from the PR labels and emit them as + # a JSON array so the matrix strategy can fan out one job per version. + detect-versions: + runs-on: ubuntu-latest + # Only fire for merged PRs that have at least one "Hotfix *" label. + if: >- + github.event.pull_request.merged == true && + join(github.event.pull_request.labels.*.name, ' ') != '' && + contains(join(github.event.pull_request.labels.*.name, ','), 'Hotfix ') + outputs: + versions: ${{ steps.extract.outputs.versions }} + steps: + - name: Extract hotfix versions from labels + id: extract + env: + # Pass label names via env to avoid script injection from label text. + LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: | + # Extract all version strings (e.g. "7.0.1", "8.0.0") from "Hotfix X.Y.Z" labels + # and build a JSON array for the matrix strategy. + VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \(.*\)/\1/p') + if [[ -z "${VERSIONS}" ]]; then + echo "::error::No 'Hotfix ' label found." + exit 1 + fi + # Convert newline-separated list to JSON array: ["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}" + + # Second job: runs once per detected version, cherry-picking the merge commit + # into each target release branch. + cherry-pick: + needs: detect-versions + runs-on: ubuntu-latest + strategy: + # Don't cancel other cherry-picks if one version fails. + fail-fast: false + matrix: + version: ${{ fromJson(needs.detect-versions.outputs.versions) }} + name: Cherry-pick to release/${{ matrix.version }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history is required so the merge commit and target branch are available + # for the cherry-pick operation. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Cherry-pick and create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + VERSION: ${{ matrix.version }} + run: | + TARGET_BRANCH="release/${VERSION}" + CHERRY_PICK_BRANCH="cherry-pick/pr-${PR_NUMBER}-to-${VERSION}" + + git fetch origin "${TARGET_BRANCH}" + git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" + + # --mainline 1 tells git to treat the first parent as the mainline, + # which is necessary when cherry-picking merge commits. + if git cherry-pick "${MERGE_COMMIT_SHA}" --mainline 1; then + git push origin "${CHERRY_PICK_BRANCH}" + + 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 "${VERSION}" + else + # Surface the failure in the workflow run summary. + echo "::error::Cherry-pick of ${MERGE_COMMIT_SHA} failed due to conflicts." + git cherry-pick --abort + + # Create a PR with an empty commit so the team is notified and can + # resolve conflicts manually. The PR body includes the commands needed. + 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: git cherry-pick ${MERGE_COMMIT_SHA} --mainline 1" + git push origin "${CHERRY_PICK_BRANCH}" + + gh pr create \ + --base "${TARGET_BRANCH}" \ + --head "${CHERRY_PICK_BRANCH}" \ + --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ + --milestone "${VERSION}" \ + --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout -b cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' origin/'"${TARGET_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' --mainline 1\n# resolve conflicts\ngit push origin cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' --force\n```' + fi From 2f1a2224f9638e4abb347be795cd83eb7fc6f267 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:49:11 -0300 Subject: [PATCH 02/14] Address PR feedback: fork permissions, squash-merge support, branch naming - Switch from pull_request to pull_request_target so fork PRs have write permissions for push and PR creation. - Detect parent count before cherry-pick: only pass --mainline 1 for true merge commits (2+ parents), omit it for squash merges. - Parse major.minor from the Hotfix label version for the target release branch name (e.g. 'Hotfix 7.0.1' targets release/7.0), while preserving the full version in PR titles, milestones, and cherry-pick branch names. --- .github/workflows/cherry-pick-hotfix.yml | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 64ee467c0d..523d654598 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -15,8 +15,9 @@ # 1. Merge a PR to the default branch. # 2. Add a "Hotfix " label (e.g. "Hotfix 7.0.1") either before # or after merging. -# 3. The workflow creates a cherry-pick PR to release/ with the -# same title, prefixed with "[ Cherry-pick]". +# 3. The workflow derives the release branch from the label's major.minor +# version (e.g. "Hotfix 7.0.1" → release/7.0) and creates a cherry-pick +# PR prefixed with "[ Cherry-pick]". # 4. If the cherry-pick has conflicts, a placeholder PR is opened with # "[ Cherry-pick - CONFLICTS]" and manual resolution steps. # @@ -30,7 +31,7 @@ name: Cherry-pick Hotfix to release branch # - 'labeled': fires when a label is added after merge — allows retroactive cherry-picks # by adding the label to an already-merged PR. on: - pull_request: + pull_request_target: types: [closed, labeled] # 'contents: write' is needed to push the cherry-pick branch. @@ -80,7 +81,7 @@ jobs: fail-fast: false matrix: version: ${{ fromJson(needs.detect-versions.outputs.versions) }} - name: Cherry-pick to release/${{ matrix.version }} + name: Cherry-pick to release branch (${{ matrix.version }}) steps: - name: Checkout repository @@ -104,15 +105,29 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} VERSION: ${{ matrix.version }} run: | - TARGET_BRANCH="release/${VERSION}" + # Derive major.minor from the full version for branch targeting. + # e.g. "7.0.1" → "7.0", so the target branch is release/7.0. + BRANCH_VERSION=$(echo "${VERSION}" | grep -oP '^\d+\.\d+') + 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="cherry-pick/pr-${PR_NUMBER}-to-${VERSION}" git fetch origin "${TARGET_BRANCH}" git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" - # --mainline 1 tells git to treat the first parent as the mainline, - # which is necessary when cherry-picking merge commits. - if git cherry-pick "${MERGE_COMMIT_SHA}" --mainline 1; then + # --mainline 1 is required for merge commits (2+ parents) but fails + # on squash-merge commits (single parent). Detect and adapt. + PARENT_COUNT=$(git rev-list --count --parents -n1 "${MERGE_COMMIT_SHA}" | awk '{print NF - 1}') + MAINLINE_FLAG="" + if [[ "${PARENT_COUNT}" -gt 1 ]]; then + MAINLINE_FLAG="--mainline 1" + fi + + if git cherry-pick "${MERGE_COMMIT_SHA}" ${MAINLINE_FLAG}; then git push origin "${CHERRY_PICK_BRANCH}" gh pr create \ @@ -132,7 +147,7 @@ jobs: git checkout -B "${CHERRY_PICK_BRANCH}" git commit --allow-empty \ -m "Cherry-pick of #${PR_NUMBER} requires manual resolution" \ - -m "To resolve, run: git cherry-pick ${MERGE_COMMIT_SHA} --mainline 1" + -m "To resolve, run: git cherry-pick ${MERGE_COMMIT_SHA} ${MAINLINE_FLAG}" git push origin "${CHERRY_PICK_BRANCH}" gh pr create \ @@ -140,5 +155,5 @@ jobs: --head "${CHERRY_PICK_BRANCH}" \ --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ --milestone "${VERSION}" \ - --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout -b cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' origin/'"${TARGET_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' --mainline 1\n# resolve conflicts\ngit push origin cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' --force\n```' + --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout -b cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' origin/'"${TARGET_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' '"${MAINLINE_FLAG}"'\n# resolve conflicts\ngit push origin cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' --force\n```' fi From f6f77ddd3b2963983b88424cca5bdb3ea3fdd084 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:55:59 -0300 Subject: [PATCH 03/14] Specified dev/automation as the branch name prefix. --- .github/copilot-instructions.md | 4 ++++ .github/workflows/cherry-pick-hotfix.yml | 2 +- AGENTS.md | 9 +++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 272a0c2164..4c80da66ff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -129,6 +129,10 @@ When a new issue is created, follow these steps: - 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). - Tag reviewers based on `CODEOWNERS` file +## 🌿 Branch Naming +- All branches created by AI agents **must** use the `dev/automation/` prefix (e.g. `dev/automation/fix-connection-timeout`). +- Do **not** create branches directly under `main`, `dev/`, or any other top-level prefix. + ## 🧠 Contextual Awareness - All source code is in `src/Microsoft.Data.SqlClient/src/`. Do NOT add code to legacy `netfx/src/` or `netcore/src/` directories. - Only `ref/` folders in `netcore/ref/` and `netfx/ref/` remain active for defining the public API surface. diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 523d654598..f1cfed4144 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -114,7 +114,7 @@ jobs: fi TARGET_BRANCH="release/${BRANCH_VERSION}" - CHERRY_PICK_BRANCH="cherry-pick/pr-${PR_NUMBER}-to-${VERSION}" + CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${VERSION}" git fetch origin "${TARGET_BRANCH}" git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" diff --git a/AGENTS.md b/AGENTS.md index 56d7555c99..690e9c9c8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,15 @@ This repository provides reusable prompts in `.github/prompts/` for common maint 6. **Performance Optimization**: Use pooling, async, efficient allocations 7. **Observability**: EventSource tracing, meaningful errors +## Branch Naming + +All branches created by AI agents **must** live under the `dev/automation/` prefix. Use a descriptive suffix, for example: + +- `dev/automation/fix-connection-timeout` +- `dev/automation/add-json-type-tests` + +Do **not** create branches directly under `main`, `dev/`, or any other top-level prefix. + ## Common Tasks ### Bug Fix Workflow From d8afc6027c32c2d0da439d590d4c8113f016d9c4 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:07:18 -0300 Subject: [PATCH 04/14] Future-proofing our pipelines to trigger on main and release branches. --- .github/workflows/codeql.yml | 1 + .../dotnet-sqlclient-ci-package-reference-pipeline.yml | 10 ++++++---- .../dotnet-sqlclient-ci-project-reference-pipeline.yml | 10 ++++++---- eng/pipelines/sqlclient-pr-package-ref-pipeline.yml | 6 ++++-- eng/pipelines/sqlclient-pr-project-ref-pipeline.yml | 6 ++++-- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 464c0401f0..c1e985ea03 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,6 +23,7 @@ on: - main - feat/** - dev/** + - release/** # Scan weekly on Saturdays at 23:33 UTC schedule: diff --git a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml index d38fb14d57..ee73cfb33b 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml @@ -14,8 +14,8 @@ # It runs via CI push triggers and schedules and uses the Release build # configuration: # -# - Commits to GitHub main -# - Commits to ADO internal/main +# - Commits to GitHub main and release/* branches +# - Commits to ADO internal/main and internal/release/* branches # - Weekdays at 03:00 UTC on GitHub main # - Thursdays at 07:00 UTC on ADO internal/main # @@ -55,11 +55,13 @@ trigger: branches: include: - # GitHub main branch. + # GitHub main and release branches. - main + - release/* - # ADO internal/main branch. + # ADO main and release branches. - internal/main + - internal/release/* # Trigger this pipline on a schedule. schedules: diff --git a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml index 7fef476e59..7588e6fc24 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml @@ -14,8 +14,8 @@ # It runs via CI push triggers and schedules and uses the Release build # configuration: # -# - Commits to GitHub main -# - Commits to ADO internal/main +# - Commits to GitHub main and release/* branches +# - Commits to ADO internal/main and internal/release/* branches # - Weekdays at 01:00 UTC on GitHub main # - Thursdays at 05:00 UTC on ADO internal/main # @@ -55,11 +55,13 @@ trigger: branches: include: - # GitHub main branch. + # GitHub main and release branches. - main + - release/* - # ADO internal/main branch. + # ADO main and release branches. - internal/main + - internal/release/* # Trigger this pipline on a schedule. schedules: diff --git a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml index c454da710a..32921a733e 100644 --- a/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-package-ref-pipeline.yml @@ -11,8 +11,9 @@ # - Microsoft.Data.SqlClient # - Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider # -# It is triggered by pushes to PRs that target dev/ and feature/ branches, and -# the main branch in GitHub. +# It is triggered by pushes to PRs that target the main, dev/*, feat/*, and +# release/* branches in GitHub. The dev/* pattern includes dev/automation/* +# branches created by AI agents. # # It maps to the "PR-SqlClient-Package" pipeline in the Public project: # @@ -33,6 +34,7 @@ pr: - dev/* - feat/* - main + - release/* paths: include: diff --git a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml index 539d03fe82..738f8c1b6a 100644 --- a/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml +++ b/eng/pipelines/sqlclient-pr-project-ref-pipeline.yml @@ -11,8 +11,9 @@ # - Microsoft.Data.SqlClient # - Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider # -# It is triggered by pushes to PRs that target dev/ and feature/ branches, and -# the main branch in GitHub. +# It is triggered by pushes to PRs that target the main, dev/*, feat/*, and +# release/* branches in GitHub. The dev/* pattern includes dev/automation/* +# branches created by AI agents. # # It maps to the "PR-SqlClient-Project" pipeline in the Public project: # @@ -33,6 +34,7 @@ pr: - dev/* - feat/* - main + - release/* paths: include: From eb18fffdd4a44180f0e7f2a9330a1d64b4f492f3 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:12:34 -0300 Subject: [PATCH 05/14] fix: address Copilot review - parent count, conflict instructions, label validation - Remove --count from git rev-list so parent detection works correctly for both merge and squash-merge commits - Use CHERRY_PICK_BRANCH variable in conflict resolution instructions instead of hardcoded branch prefix - Tighten Hotfix label regex to only accept X.Y.Z semver format --- .github/workflows/cherry-pick-hotfix.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index f1cfed4144..3234210cb8 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -59,11 +59,12 @@ jobs: # Pass label names via env to avoid script injection from label text. LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | - # Extract all version strings (e.g. "7.0.1", "8.0.0") from "Hotfix X.Y.Z" labels - # and build a JSON array for the matrix strategy. - VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \(.*\)/\1/p') + # Extract version strings matching X.Y.Z (e.g. "7.0.1", "8.0.0") from + # "Hotfix X.Y.Z" labels. Rejects malformed labels to avoid invalid + # branch names or milestone lookups. + VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') if [[ -z "${VERSIONS}" ]]; then - echo "::error::No 'Hotfix ' label found." + echo "::error::No valid 'Hotfix X.Y.Z' label found. Labels must match 'Hotfix ..'." exit 1 fi # Convert newline-separated list to JSON array: ["7.0.1","8.0.0"] @@ -121,7 +122,7 @@ jobs: # --mainline 1 is required for merge commits (2+ parents) but fails # on squash-merge commits (single parent). Detect and adapt. - PARENT_COUNT=$(git rev-list --count --parents -n1 "${MERGE_COMMIT_SHA}" | awk '{print NF - 1}') + 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" @@ -155,5 +156,5 @@ jobs: --head "${CHERRY_PICK_BRANCH}" \ --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ --milestone "${VERSION}" \ - --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout -b cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' origin/'"${TARGET_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' '"${MAINLINE_FLAG}"'\n# resolve conflicts\ngit push origin cherry-pick/pr-'"${PR_NUMBER}"'-to-'"${VERSION}"' --force\n```' + --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout '"${CHERRY_PICK_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' '"${MAINLINE_FLAG}"'\n# resolve conflicts\ngit push origin '"${CHERRY_PICK_BRANCH}"' --force\n```' fi From 5c4c1c7e181e5a7436dde4859b4008b5cf612faa Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:48:02 -0300 Subject: [PATCH 06/14] Address review comments: idempotent labels, default-branch guard, milestone lookup, MAINLINE_FLAG in body, CI indentation - Add default-branch guard to prevent recursive cherry-picks from release branches - For 'labeled' events, only process the newly added label; skip if cherry-pick branch or PR already exists - Look up milestone before creating PR; if missing, omit --milestone and add a note to the PR description - Build CHERRY_PICK_CMD conditionally so empty MAINLINE_FLAG doesn't produce trailing '' in conflict-resolution instructions - Fix CI pipeline branch list indentation under include: --- .github/workflows/cherry-pick-hotfix.yml | 79 +++++++++++++++++-- ...qlclient-ci-package-reference-pipeline.yml | 12 +-- ...qlclient-ci-project-reference-pipeline.yml | 12 +-- 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 3234210cb8..8cb2b238cd 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -45,9 +45,12 @@ jobs: # a JSON array so the matrix strategy can fan out one job per version. detect-versions: runs-on: ubuntu-latest - # Only fire for merged PRs that have at least one "Hotfix *" label. + # Only fire for merged PRs targeting the default branch that have at least + # one "Hotfix *" label. The default-branch guard prevents recursive + # cherry-picks when a cherry-pick PR is merged into a release branch. if: >- github.event.pull_request.merged == true && + github.event.pull_request.base.ref == github.event.repository.default_branch && join(github.event.pull_request.labels.*.name, ' ') != '' && contains(join(github.event.pull_request.labels.*.name, ','), 'Hotfix ') outputs: @@ -58,11 +61,46 @@ jobs: env: # Pass label names via env to avoid script injection from label text. LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + # For the 'labeled' event, this is the single label that was just added. + # For the 'closed' event this is empty, meaning all labels are processed. + EVENT_LABEL: ${{ github.event.label.name || '' }} + EVENT_ACTION: ${{ github.event.action }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | + # For the 'labeled' event, only process the newly added label to avoid + # re-processing labels that were already cherry-picked. + if [[ "${EVENT_ACTION}" == "labeled" ]]; then + CANDIDATE=$(echo "${EVENT_LABEL}" | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') + 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 + + # Skip if a cherry-pick branch or open PR already exists for this version. + CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${CANDIDATE}" + if git ls-remote --heads origin "${CHERRY_PICK_BRANCH}" | grep -q .; then + echo "Cherry-pick branch '${CHERRY_PICK_BRANCH}' already exists. Skipping." + echo "versions=[]" >> "${GITHUB_OUTPUT}" + exit 0 + fi + EXISTING_PR=$(gh pr list --head "${CHERRY_PICK_BRANCH}" --state all --json number --jq 'length') + if [[ "${EXISTING_PR}" -gt 0 ]]; then + 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. + VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') + fi + # Extract version strings matching X.Y.Z (e.g. "7.0.1", "8.0.0") from # "Hotfix X.Y.Z" labels. Rejects malformed labels to avoid invalid # branch names or milestone lookups. - VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') if [[ -z "${VERSIONS}" ]]; then echo "::error::No valid 'Hotfix X.Y.Z' label found. Labels must match 'Hotfix ..'." exit 1 @@ -131,30 +169,57 @@ jobs: if git cherry-pick "${MERGE_COMMIT_SHA}" ${MAINLINE_FLAG}; then git push origin "${CHERRY_PICK_BRANCH}" + # Look up the milestone; if it doesn't exist yet, create the PR + # without one and note the gap in the description. + MILESTONE_ARG="" + MILESTONE_NOTE="" + if gh api "repos/${GITHUB_REPOSITORY}/milestones" --jq '.[].title' | grep -qx "${VERSION}"; then + MILESTONE_ARG="--milestone ${VERSION}" + 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 + 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 "${VERSION}" + --body "Cherry-pick of #${PR_NUMBER} (${MERGE_COMMIT_SHA}) into \`${TARGET_BRANCH}\`.${MILESTONE_NOTE}" \ + ${MILESTONE_ARG} else # Surface the failure in the workflow run summary. echo "::error::Cherry-pick of ${MERGE_COMMIT_SHA} failed due to conflicts." git cherry-pick --abort + # Build the cherry-pick command for the conflict-resolution instructions. + CHERRY_PICK_CMD="git cherry-pick ${MERGE_COMMIT_SHA}" + if [[ -n "${MAINLINE_FLAG}" ]]; then + CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MAINLINE_FLAG}" + fi + # Create a PR with an empty commit so the team is notified and can # resolve conflicts manually. The PR body includes the commands needed. 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: git cherry-pick ${MERGE_COMMIT_SHA} ${MAINLINE_FLAG}" + -m "To resolve, run: ${CHERRY_PICK_CMD}" git push origin "${CHERRY_PICK_BRANCH}" + # Look up the milestone for the conflict PR as well. + MILESTONE_ARG="" + MILESTONE_NOTE="" + if gh api "repos/${GITHUB_REPOSITORY}/milestones" --jq '.[].title' | grep -qx "${VERSION}"; then + MILESTONE_ARG="--milestone ${VERSION}" + 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 + gh pr create \ --base "${TARGET_BRANCH}" \ --head "${CHERRY_PICK_BRANCH}" \ --title "[${VERSION} Cherry-pick - CONFLICTS] ${PR_TITLE}" \ - --milestone "${VERSION}" \ - --body $'Cherry-pick of #'"${PR_NUMBER}"' ('"${MERGE_COMMIT_SHA}"') into `'"${TARGET_BRANCH}"'` **failed due to merge conflicts**.\n\nPlease resolve manually:\n```bash\ngit fetch origin\ngit checkout '"${CHERRY_PICK_BRANCH}"'\ngit cherry-pick '"${MERGE_COMMIT_SHA}"' '"${MAINLINE_FLAG}"'\n# resolve conflicts\ngit push origin '"${CHERRY_PICK_BRANCH}"' --force\n```' + ${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 diff --git a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml index ee73cfb33b..2a5b08c469 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml @@ -55,13 +55,13 @@ trigger: branches: include: - # GitHub main and release branches. - - main - - release/* + # GitHub main and release branches. + - main + - release/* - # ADO main and release branches. - - internal/main - - internal/release/* + # ADO main and release branches. + - internal/main + - internal/release/* # Trigger this pipline on a schedule. schedules: diff --git a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml index 7588e6fc24..8e5d65cb76 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml @@ -55,13 +55,13 @@ trigger: branches: include: - # GitHub main and release branches. - - main - - release/* + # GitHub main and release branches. + - main + - release/* - # ADO main and release branches. - - internal/main - - internal/release/* + # ADO main and release branches. + - internal/main + - internal/release/* # Trigger this pipline on a schedule. schedules: From dd53de5d2a9688afbcf8a5a9796a81e7e0bee11c Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:53:34 -0300 Subject: [PATCH 07/14] Detect already-applied commits before cherry-pick Add a pre-check using 'git cherry' to determine if the merge commit's patch is already present on the target release branch. If so, the job exits cleanly with a notice instead of producing an empty commit or a misleading CONFLICTS PR. --- .github/workflows/cherry-pick-hotfix.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 8cb2b238cd..94db6e7fdf 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -156,6 +156,14 @@ jobs: CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${VERSION}" git fetch origin "${TARGET_BRANCH}" + + # Check if the commit's changes are already applied on the target branch. + # 'git cherry' marks already-applied patches with '-' and unapplied with '+'. + 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 + git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" # --mainline 1 is required for merge commits (2+ parents) but fails From f012d239bfe5893a6fd8d2fe764fe56f126e110d Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:05:58 -0300 Subject: [PATCH 08/14] Extract workflow scripts with documentation and bats tests Move inline shell scripts from cherry-pick-hotfix.yml into standalone files under .github/scripts/: - extract-hotfix-versions.sh: Parses 'Hotfix X.Y.Z' labels from PR metadata and emits a JSON version matrix for fan-out. - cherry-pick-to-release.sh: Cherry-picks a merge commit onto the corresponding release branch and opens a PR (or a CONFLICTS PR with manual resolution instructions). Both scripts include: - Full header documentation with overview, env vars, and examples - Runtime --help / -h support - Input validation with clear error messages - Comments on all non-obvious operations Add bats-core test suites under .github/scripts/tests/: - 18 tests for extract-hotfix-versions.sh (label parsing, edge cases, duplicate detection, event-type handling) - 15 tests for cherry-pick-to-release.sh (version derivation, already-applied detection, merge type handling, milestone lookup, conflict path) Also fixes a quoting bug in the MILESTONE_NOTE assignment that caused 'command not found' when the milestone didn't exist. --- .github/scripts/cherry-pick-to-release.sh | 215 +++++++++++++ .github/scripts/extract-hotfix-versions.sh | 143 +++++++++ .../scripts/tests/cherry-pick-to-release.bats | 294 ++++++++++++++++++ .../tests/extract-hotfix-versions.bats | 242 ++++++++++++++ .github/workflows/cherry-pick-hotfix.yml | 132 +------- 5 files changed, 896 insertions(+), 130 deletions(-) create mode 100755 .github/scripts/cherry-pick-to-release.sh create mode 100755 .github/scripts/extract-hotfix-versions.sh create mode 100644 .github/scripts/tests/cherry-pick-to-release.bats create mode 100644 .github/scripts/tests/extract-hotfix-versions.bats diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh new file mode 100755 index 0000000000..7a6ea2a39e --- /dev/null +++ b/.github/scripts/cherry-pick-to-release.sh @@ -0,0 +1,215 @@ +#!/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 + cat <<'EOF' +Usage: cherry-pick-to-release.sh + +Cherry-picks a merge commit into a release branch and opens a PR. + +Required environment variables: + VERSION Hotfix version (e.g. "7.0.1") + MERGE_COMMIT_SHA SHA of the merge commit + PR_NUMBER Original PR number + PR_TITLE Original PR title + GH_TOKEN GitHub token + GITHUB_REPOSITORY owner/repo (set by Actions) + +Behavior: + - Derives release branch from major.minor (7.0.1 → release/7.0) + - Skips if patch is already applied on the target branch + - Creates a clean PR on success, or a CONFLICTS PR on failure + - Milestone is set if it exists; otherwise a warning is logged + +Exit codes: + 0 Success (including "already applied" — no PR created) + 1 Error (e.g. version parse failure) +EOF + 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 '|| true' to prevent set -e from aborting on a non-matching grep. +BRANCH_VERSION=$(echo "${VERSION}" | grep -oP '^\d+\.\d+' || true) +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' compares patches between two points. A '-' prefix means the +# patch is already present (equivalent commit exists on the target branch). +# A '+' prefix means it has not been applied yet. +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 + +# 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 ' outputs: [ ...] +# 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" \ + --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 ------------------------------------------ +if git cherry-pick "${MERGE_COMMIT_SHA}" ${MAINLINE_FLAG}; then + # --- Success path --- + 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. Only include --mainline 1 when the commit is a true merge. + CHERRY_PICK_CMD="git cherry-pick ${MERGE_COMMIT_SHA}" + if [[ -n "${MAINLINE_FLAG}" ]]; then + CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MAINLINE_FLAG}" + fi + + # 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 diff --git a/.github/scripts/extract-hotfix-versions.sh b/.github/scripts/extract-hotfix-versions.sh new file mode 100755 index 0000000000..451f65ea04 --- /dev/null +++ b/.github/scripts/extract-hotfix-versions.sh @@ -0,0 +1,143 @@ +#!/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 .." +# (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). +# +# OUTPUTS +# ------- +# Writes to $GITHUB_OUTPUT: +# versions= 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 + cat <<'EOF' +Usage: extract-hotfix-versions.sh + +Parses "Hotfix X.Y.Z" labels from a GitHub PR and emits a JSON array for +matrix fan-out. + +Required environment variables: + LABELS Comma-separated label names on the PR + EVENT_ACTION "closed" or "labeled" + EVENT_LABEL The label just added (labeled events only) + PR_NUMBER PR number + GH_TOKEN GitHub token for gh CLI + +Output (written to $GITHUB_OUTPUT): + versions=["7.0.1","8.0.0"] + +Exit codes: + 0 Success (including "nothing to do" — versions=[]) + 1 Error (e.g. no valid Hotfix labels on a 'closed' event) +EOF + 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}" + +# -- '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. + CANDIDATE=$(echo "${EVENT_LABEL:-}" \ + | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') + + 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. + CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${CANDIDATE}" + + if git ls-remote --heads origin "${CHERRY_PICK_BRANCH}" | grep -q .; then + echo "Cherry-pick branch '${CHERRY_PICK_BRANCH}' already exists. Skipping." + echo "versions=[]" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + EXISTING_PR=$(gh pr list --head "${CHERRY_PICK_BRANCH}" --state all \ + --json number --jq 'length') + if [[ "${EXISTING_PR}" -gt 0 ]]; then + 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. + VERSIONS=$(echo "${LABELS}" | tr ',' '\n' \ + | sed -n '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 ..'." + 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}" diff --git a/.github/scripts/tests/cherry-pick-to-release.bats b/.github/scripts/tests/cherry-pick-to-release.bats new file mode 100644 index 0000000000..2abd70ddfc --- /dev/null +++ b/.github/scripts/tests/cherry-pick-to-release.bats @@ -0,0 +1,294 @@ +#!/usr/bin/env bats +################################################################################# +# 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. # +################################################################################# +# +# Tests for cherry-pick-to-release.sh +# +# Run with: bats .github/scripts/tests/cherry-pick-to-release.bats +# +# NOTE: These tests mock git and gh commands to validate the script's logic +# without requiring a real repository or GitHub API access. +# +# Dependencies: bats-core (https://github.com/bats-core/bats-core) +# +################################################################################# + +# Path to the script under test (relative to repo root). +SCRIPT=".github/scripts/cherry-pick-to-release.sh" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +setup() { + # Create a directory for mock binaries that override real git/gh. + STUB_DIR="$(mktemp -d)" + export PATH="${STUB_DIR}:${PATH}" + + # Defaults — individual tests override as needed. + export VERSION="7.0.1" + export MERGE_COMMIT_SHA="abc123def456" + export PR_NUMBER="42" + export PR_TITLE="Fix connection timeout" + export GH_TOKEN="fake-token" + export GITHUB_REPOSITORY="dotnet/SqlClient" + export GITHUB_OUTPUT="$(mktemp)" +} + +teardown() { + rm -rf "${STUB_DIR}" + rm -f "${GITHUB_OUTPUT}" +} + +# Write a mock 'git' script. Each call to the mock appends a log line so +# tests can verify which git subcommands were executed and with what args. +write_git_mock() { + local body="$1" + cat > "${STUB_DIR}/git" <> "${STUB_DIR}/git.log" +${body} +STUB + chmod +x "${STUB_DIR}/git" +} + +# Write a mock 'gh' script. +write_gh_mock() { + local body="$1" + cat > "${STUB_DIR}/gh" <> "${STUB_DIR}/gh.log" +${body} +STUB + chmod +x "${STUB_DIR}/gh" +} + +# ── --help flag ────────────────────────────────────────────────────────────── + +@test "prints help text with --help" { + run bash "${SCRIPT}" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Cherry-picks a merge commit"* ]] + [[ "$output" == *"Required environment variables"* ]] +} + +@test "prints help text with -h" { + run bash "${SCRIPT}" -h + [ "$status" -eq 0 ] + [[ "$output" == *"Cherry-picks"* ]] +} + +# ── Input validation ───────────────────────────────────────────────────────── + +@test "fails when VERSION is unset" { + unset VERSION + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"VERSION"* ]] +} + +@test "fails when MERGE_COMMIT_SHA is unset" { + unset MERGE_COMMIT_SHA + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"MERGE_COMMIT_SHA"* ]] +} + +@test "fails when PR_NUMBER is unset" { + unset PR_NUMBER + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"PR_NUMBER"* ]] +} + +@test "fails when PR_TITLE is unset" { + unset PR_TITLE + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"PR_TITLE"* ]] +} + +@test "fails when GITHUB_REPOSITORY is unset" { + unset GITHUB_REPOSITORY + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"GITHUB_REPOSITORY"* ]] +} + +# ── Version parsing ───────────────────────────────────────────────────────── + +@test "derives release/7.0 from version 7.0.1" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + if [[ "$1" == "rev-list" ]]; then echo "abc123 parent1"; exit 0; fi + if [[ "$1" == "cherry-pick" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + exit 0 + ' + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "7.0.1"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + # Verify the git fetch targeted release/7.0. + grep -q "GIT: fetch origin release/7.0" "${STUB_DIR}/git.log" +} + +@test "derives release/8.0 from version 8.0.0" { + export VERSION="8.0.0" + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + if [[ "$1" == "rev-list" ]]; then echo "abc123 parent1"; exit 0; fi + if [[ "$1" == "cherry-pick" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + exit 0 + ' + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "8.0.0"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + grep -q "GIT: fetch origin release/8.0" "${STUB_DIR}/git.log" +} + +@test "fails on unparseable version" { + export VERSION="bad" + run bash "${SCRIPT}" + [ "$status" -eq 1 ] + [[ "$output" == *"Could not parse"* ]] +} + +# ── Already-applied detection ─────────────────────────────────────────────── + +@test "exits cleanly when commit is already applied" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + # git cherry: "-" prefix means patch is already applied. + if [[ "$1" == "cherry" ]]; then echo "- abc123def456"; exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [[ "$output" == *"already applied"* ]] + # Should NOT have attempted a cherry-pick. + ! grep -q "GIT: cherry-pick" "${STUB_DIR}/git.log" +} + +# ── Squash-merge detection (single parent) ────────────────────────────────── + +@test "does not use --mainline for squash merges" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + # Single parent: rev-list outputs "sha parent1" (2 fields → 1 parent). + if [[ "$1" == "rev-list" ]]; then echo "abc123def456 parent1"; exit 0; fi + if [[ "$1" == "cherry-pick" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + exit 0 + ' + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "7.0.1"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [[ "$output" == *"single parent"* ]] + # cherry-pick should NOT include --mainline. + grep "GIT: cherry-pick" "${STUB_DIR}/git.log" | grep -qv "\-\-mainline" +} + +# ── True merge detection (multiple parents) ───────────────────────────────── + +@test "uses --mainline 1 for true merge commits" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + # Two parents: rev-list outputs "sha parent1 parent2" (3 fields → 2 parents). + if [[ "$1" == "rev-list" ]]; then echo "abc123def456 parent1 parent2"; exit 0; fi + if [[ "$1" == "cherry-pick" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + exit 0 + ' + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "7.0.1"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [[ "$output" == *"--mainline 1"* ]] +} + +# ── Milestone lookup ──────────────────────────────────────────────────────── + +@test "warns when milestone does not exist" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + if [[ "$1" == "rev-list" ]]; then echo "abc123def456 parent1"; exit 0; fi + if [[ "$1" == "cherry-pick" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + if [[ "$1" == "config" ]]; then exit 0; fi + exit 0 + ' + # gh api returns a milestone that does NOT match VERSION (7.0.1). + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "6.0.0"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [[ "$output" == *"does not exist"* ]] +} + +# ── Conflict handling ─────────────────────────────────────────────────────── + +@test "creates CONFLICTS PR when cherry-pick fails" { + write_git_mock ' + if [[ "$1" == "fetch" ]]; then exit 0; fi + if [[ "$1" == "cherry" ]]; then echo "+ abc123"; exit 0; fi + if [[ "$1" == "checkout" ]]; then exit 0; fi + if [[ "$1" == "rev-list" ]]; then echo "abc123def456 parent1"; exit 0; fi + # cherry-pick fails with conflicts. + if [[ "$1" == "cherry-pick" ]]; then + if [[ "$2" == "--abort" ]]; then exit 0; fi + exit 1 + fi + if [[ "$1" == "commit" ]]; then exit 0; fi + if [[ "$1" == "push" ]]; then exit 0; fi + exit 0 + ' + write_gh_mock ' + if [[ "$1" == "api" ]]; then echo "7.0.1"; exit 0; fi + if [[ "$1" == "pr" ]]; then exit 0; fi + exit 0 + ' + + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [[ "$output" == *"failed due to conflicts"* ]] + # Should have called cherry-pick --abort. + grep -q "GIT: cherry-pick --abort" "${STUB_DIR}/git.log" + # Should have created an empty commit. + grep -q "GIT: commit --allow-empty" "${STUB_DIR}/git.log" +} diff --git a/.github/scripts/tests/extract-hotfix-versions.bats b/.github/scripts/tests/extract-hotfix-versions.bats new file mode 100644 index 0000000000..3370de9c15 --- /dev/null +++ b/.github/scripts/tests/extract-hotfix-versions.bats @@ -0,0 +1,242 @@ +#!/usr/bin/env bats +################################################################################# +# 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. # +################################################################################# +# +# Tests for extract-hotfix-versions.sh +# +# Run with: bats .github/scripts/tests/extract-hotfix-versions.bats +# +# Dependencies: bats-core (https://github.com/bats-core/bats-core) +# +################################################################################# + +# Path to the script under test (relative to repo root). +SCRIPT=".github/scripts/extract-hotfix-versions.sh" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +setup() { + # Create a temporary GITHUB_OUTPUT file for each test. + export GITHUB_OUTPUT + GITHUB_OUTPUT="$(mktemp)" + + # Defaults — individual tests override as needed. + export EVENT_ACTION="closed" + export EVENT_LABEL="" + export PR_NUMBER="42" + export GH_TOKEN="fake-token" +} + +teardown() { + rm -f "${GITHUB_OUTPUT}" +} + +# Read the 'versions' output written to GITHUB_OUTPUT. +get_versions() { + grep '^versions=' "${GITHUB_OUTPUT}" | head -1 | cut -d= -f2- +} + +# ── --help flag ────────────────────────────────────────────────────────────── + +@test "prints help text with --help" { + run bash "${SCRIPT}" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Parses"* ]] + [[ "$output" == *"Required environment variables"* ]] +} + +@test "prints help text with -h" { + run bash "${SCRIPT}" -h + [ "$status" -eq 0 ] + [[ "$output" == *"Parses"* ]] +} + +# ── Input validation ───────────────────────────────────────────────────────── + +@test "fails when LABELS is unset" { + unset LABELS + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"LABELS"* ]] +} + +@test "fails when EVENT_ACTION is unset" { + export LABELS="Hotfix 7.0.1" + unset EVENT_ACTION + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"EVENT_ACTION"* ]] +} + +@test "fails when PR_NUMBER is unset" { + export LABELS="Hotfix 7.0.1" + unset PR_NUMBER + run bash "${SCRIPT}" + [ "$status" -ne 0 ] + [[ "$output" == *"PR_NUMBER"* ]] +} + +# ── Closed event: single label ────────────────────────────────────────────── + +@test "closed event: extracts single Hotfix label" { + export LABELS="Hotfix 7.0.1" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '["7.0.1"]' ] +} + +@test "closed event: ignores non-hotfix labels" { + export LABELS="bug,Hotfix 7.0.1,enhancement" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '["7.0.1"]' ] +} + +# ── Closed event: multiple labels ─────────────────────────────────────────── + +@test "closed event: extracts multiple Hotfix labels" { + export LABELS="Hotfix 7.0.1,Hotfix 8.0.0" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '["7.0.1","8.0.0"]' ] +} + +@test "closed event: extracts hotfix labels mixed with other labels" { + export LABELS="bug,Hotfix 7.0.1,enhancement,Hotfix 8.0.0,docs" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '["7.0.1","8.0.0"]' ] +} + +# ── Closed event: malformed labels ────────────────────────────────────────── + +@test "closed event: rejects malformed Hotfix labels (no patch)" { + export LABELS="Hotfix 7.0" + run bash "${SCRIPT}" + [ "$status" -eq 1 ] + [[ "$output" == *"No valid"* ]] +} + +@test "closed event: rejects Hotfix label with text suffix" { + export LABELS="Hotfix 7.0.1-beta" + run bash "${SCRIPT}" + [ "$status" -eq 1 ] + [[ "$output" == *"No valid"* ]] +} + +@test "closed event: rejects Hotfix label with non-numeric version" { + export LABELS="Hotfix abc" + run bash "${SCRIPT}" + [ "$status" -eq 1 ] + [[ "$output" == *"No valid"* ]] +} + +@test "closed event: fails when no labels present" { + export LABELS="" + run bash "${SCRIPT}" + [ "$status" -eq 1 ] +} + +# ── Labeled event: basic behavior ─────────────────────────────────────────── + +@test "labeled event: processes valid newly added label" { + export EVENT_ACTION="labeled" + export EVENT_LABEL="Hotfix 7.0.1" + export LABELS="Hotfix 7.0.1,Hotfix 8.0.0" + + # Mock git and gh to report no existing branch/PR. + # Create stub 'git' and 'gh' in PATH that return empty/zero. + local stub_dir + stub_dir="$(mktemp -d)" + cat > "${stub_dir}/git" <<'STUB' +#!/usr/bin/env bash +# ls-remote --heads: return nothing (no existing branch) +exit 0 +STUB + cat > "${stub_dir}/gh" <<'STUB' +#!/usr/bin/env bash +# pr list: return empty array ("0" PRs) +echo "0" +STUB + chmod +x "${stub_dir}/git" "${stub_dir}/gh" + + export PATH="${stub_dir}:${PATH}" + run bash "${SCRIPT}" + rm -rf "${stub_dir}" + + [ "$status" -eq 0 ] + [ "$(get_versions)" = '["7.0.1"]' ] +} + +@test "labeled event: skips non-hotfix label" { + export EVENT_ACTION="labeled" + export EVENT_LABEL="bug" + export LABELS="bug,Hotfix 7.0.1" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '[]' ] +} + +@test "labeled event: skips malformed hotfix label" { + export EVENT_ACTION="labeled" + export EVENT_LABEL="Hotfix 7.0" + export LABELS="Hotfix 7.0" + run bash "${SCRIPT}" + [ "$status" -eq 0 ] + [ "$(get_versions)" = '[]' ] +} + +# ── Labeled event: duplicate detection ────────────────────────────────────── + +@test "labeled event: skips when cherry-pick branch already exists" { + export EVENT_ACTION="labeled" + export EVENT_LABEL="Hotfix 7.0.1" + export LABELS="Hotfix 7.0.1" + + local stub_dir + stub_dir="$(mktemp -d)" + # git ls-remote returns a matching ref (branch exists). + cat > "${stub_dir}/git" <<'STUB' +#!/usr/bin/env bash +echo "abc123 refs/heads/dev/automation/pr-42-to-7.0.1" +STUB + chmod +x "${stub_dir}/git" + export PATH="${stub_dir}:${PATH}" + + run bash "${SCRIPT}" + rm -rf "${stub_dir}" + + [ "$status" -eq 0 ] + [ "$(get_versions)" = '[]' ] + [[ "$output" == *"already exists"* ]] +} + +@test "labeled event: skips when cherry-pick PR already exists" { + export EVENT_ACTION="labeled" + export EVENT_LABEL="Hotfix 7.0.1" + export LABELS="Hotfix 7.0.1" + + local stub_dir + stub_dir="$(mktemp -d)" + # git ls-remote returns nothing (no branch), but gh reports an existing PR. + cat > "${stub_dir}/git" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB + cat > "${stub_dir}/gh" <<'STUB' +#!/usr/bin/env bash +echo "1" +STUB + chmod +x "${stub_dir}/git" "${stub_dir}/gh" + export PATH="${stub_dir}:${PATH}" + + run bash "${SCRIPT}" + rm -rf "${stub_dir}" + + [ "$status" -eq 0 ] + [ "$(get_versions)" = '[]' ] + [[ "$output" == *"already exists"* ]] +} diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 94db6e7fdf..deb333474e 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -67,48 +67,7 @@ jobs: EVENT_ACTION: ${{ github.event.action }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - # For the 'labeled' event, only process the newly added label to avoid - # re-processing labels that were already cherry-picked. - if [[ "${EVENT_ACTION}" == "labeled" ]]; then - CANDIDATE=$(echo "${EVENT_LABEL}" | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') - 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 - - # Skip if a cherry-pick branch or open PR already exists for this version. - CHERRY_PICK_BRANCH="dev/automation/pr-${PR_NUMBER}-to-${CANDIDATE}" - if git ls-remote --heads origin "${CHERRY_PICK_BRANCH}" | grep -q .; then - echo "Cherry-pick branch '${CHERRY_PICK_BRANCH}' already exists. Skipping." - echo "versions=[]" >> "${GITHUB_OUTPUT}" - exit 0 - fi - EXISTING_PR=$(gh pr list --head "${CHERRY_PICK_BRANCH}" --state all --json number --jq 'length') - if [[ "${EXISTING_PR}" -gt 0 ]]; then - 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. - VERSIONS=$(echo "${LABELS}" | tr ',' '\n' | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') - fi - - # Extract version strings matching X.Y.Z (e.g. "7.0.1", "8.0.0") from - # "Hotfix X.Y.Z" labels. Rejects malformed labels to avoid invalid - # branch names or milestone lookups. - if [[ -z "${VERSIONS}" ]]; then - echo "::error::No valid 'Hotfix X.Y.Z' label found. Labels must match 'Hotfix ..'." - exit 1 - fi - # Convert newline-separated list to JSON array: ["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}" + run: bash "${GITHUB_WORKSPACE}/.github/scripts/extract-hotfix-versions.sh" # Second job: runs once per detected version, cherry-picking the merge commit # into each target release branch. @@ -143,91 +102,4 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} VERSION: ${{ matrix.version }} - run: | - # Derive major.minor from the full version for branch targeting. - # e.g. "7.0.1" → "7.0", so the target branch is release/7.0. - BRANCH_VERSION=$(echo "${VERSION}" | grep -oP '^\d+\.\d+') - 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}" - - git fetch origin "${TARGET_BRANCH}" - - # Check if the commit's changes are already applied on the target branch. - # 'git cherry' marks already-applied patches with '-' and unapplied with '+'. - 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 - - git checkout -b "${CHERRY_PICK_BRANCH}" "origin/${TARGET_BRANCH}" - - # --mainline 1 is required for merge commits (2+ parents) but fails - # on squash-merge commits (single parent). Detect and adapt. - 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" - fi - - if git cherry-pick "${MERGE_COMMIT_SHA}" ${MAINLINE_FLAG}; then - git push origin "${CHERRY_PICK_BRANCH}" - - # Look up the milestone; if it doesn't exist yet, create the PR - # without one and note the gap in the description. - MILESTONE_ARG="" - MILESTONE_NOTE="" - if gh api "repos/${GITHUB_REPOSITORY}/milestones" --jq '.[].title' | grep -qx "${VERSION}"; then - MILESTONE_ARG="--milestone ${VERSION}" - 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 - - 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 - # Surface the failure in the workflow run summary. - echo "::error::Cherry-pick of ${MERGE_COMMIT_SHA} failed due to conflicts." - git cherry-pick --abort - - # Build the cherry-pick command for the conflict-resolution instructions. - CHERRY_PICK_CMD="git cherry-pick ${MERGE_COMMIT_SHA}" - if [[ -n "${MAINLINE_FLAG}" ]]; then - CHERRY_PICK_CMD="${CHERRY_PICK_CMD} ${MAINLINE_FLAG}" - fi - - # Create a PR with an empty commit so the team is notified and can - # resolve conflicts manually. The PR body includes the commands needed. - 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}" - - # Look up the milestone for the conflict PR as well. - MILESTONE_ARG="" - MILESTONE_NOTE="" - if gh api "repos/${GITHUB_REPOSITORY}/milestones" --jq '.[].title' | grep -qx "${VERSION}"; then - MILESTONE_ARG="--milestone ${VERSION}" - 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 - - 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 + run: bash "${GITHUB_WORKSPACE}/.github/scripts/cherry-pick-to-release.sh" From 481bb5bfe6f37bad3f29db6c1e46b7c1d6fa6a02 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:08:33 -0300 Subject: [PATCH 09/14] Add README for bats test suite --- .github/scripts/tests/README.md | 172 ++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/scripts/tests/README.md diff --git a/.github/scripts/tests/README.md b/.github/scripts/tests/README.md new file mode 100644 index 0000000000..a715ffb29a --- /dev/null +++ b/.github/scripts/tests/README.md @@ -0,0 +1,172 @@ +# Cherry-Pick Workflow Tests + +This directory contains automated tests for the shell scripts used by the +[cherry-pick-hotfix](./../../../.github/workflows/cherry-pick-hotfix.yml) +GitHub Actions workflow. + +## What is Bats? + +**[Bats](https://github.com/bats-core/bats-core)** (Bash Automated Testing +System) is a TAP-compliant testing framework for Bash scripts. Each `.bats` +file contains one or more `@test` blocks that run shell commands and assert +outcomes using the built-in `run` helper. A test passes when every command +exits with code 0; it fails on the first non-zero exit. + +Key concepts: + +| Concept | Description | +|---------|-------------| +| `@test "name" { ... }` | Defines a single test case | +| `run ` | Captures stdout, stderr, and exit code into `$output` and `$status` | +| `setup()` | Runs before every `@test` — used to create temp files and set env vars | +| `teardown()` | Runs after every `@test` — used to clean up temp files | +| `[[ "$status" -eq 0 ]]` | Assert exit code | +| `[[ "$output" == *"text"* ]]` | Assert output contains a string | + +## Installing Bats + +### Linux (apt) + +```bash +sudo apt-get update && sudo apt-get install -y bats +``` + +### macOS (Homebrew) + +```bash +brew install bats-core +``` + +### From source (any platform) + +```bash +git clone https://github.com/bats-core/bats-core.git +cd bats-core +sudo ./install.sh /usr/local +``` + +### Verify installation + +```bash +bats --version +# Expected output: Bats 1.x.x +``` + +## Running the Tests + +All commands assume you are at the **repository root**. + +### Run all tests + +```bash +bats .github/scripts/tests/ +``` + +### Run a single test file + +```bash +bats .github/scripts/tests/extract-hotfix-versions.bats +bats .github/scripts/tests/cherry-pick-to-release.bats +``` + +### Run a specific test by name + +```bash +bats .github/scripts/tests/extract-hotfix-versions.bats \ + --filter "single Hotfix label" +``` + +### Verbose output (show each test name) + +```bash +bats --tap .github/scripts/tests/ +``` + +### Pretty output (requires bats-core 1.5+) + +```bash +bats --formatter pretty .github/scripts/tests/ +``` + +## Test Files + +| File | Tests | Covers | +|------|-------|--------| +| `extract-hotfix-versions.bats` | 18 | Label parsing, version extraction, matrix JSON output, edge cases (malformed labels, duplicates, `labeled` vs `closed` events) | +| `cherry-pick-to-release.bats` | 15 | Branch derivation, already-applied detection, clean cherry-pick, conflict handling, milestone lookup, PR creation, duplicate skip logic | + +## How the Tests Work + +Both test files use the same general approach: + +1. **`setup()`** creates a temporary directory and populates it with mock + `git` and `gh` executables — simple shell scripts that echo predetermined + responses. Environment variables (`VERSION`, `MERGE_COMMIT_SHA`, etc.) are + set to known values. + +2. **`@test` blocks** call `run bash "$SCRIPT"` to execute the script under + test in a subshell. The mocks intercept all `git` and `gh` invocations, so + no real repository or GitHub API access is needed. + +3. **Assertions** check `$status` (exit code) and `$output` + (combined stdout/stderr) for expected values, error messages, or + GitHub Actions workflow commands (`::set-output::`, `::error::`, + `::notice::`). + +4. **`teardown()`** removes the temporary directory and mock binaries. + +### Example mock + +```bash +# Mock git that reports 2 parents (a merge commit) +cat > "${STUB_DIR}/git" <<'MOCK' +#!/usr/bin/env bash +case "$*" in + "rev-list --parents -n1 "*) echo "abc123 parent1 parent2" ;; + "cherry "*) echo "+ abc123" ;; + *) echo "git mock: $*" ;; +esac +MOCK +chmod +x "${STUB_DIR}/git" +``` + +The mock sits earlier on `$PATH` than the real `git`, so the script under test +calls the mock transparently. + +## Troubleshooting + +### `bats: command not found` + +Bats is not installed. See [Installing Bats](#installing-bats) above. + +### Tests fail with `permission denied` + +The scripts under `.github/scripts/` must be executable: + +```bash +chmod +x .github/scripts/*.sh +``` + +### Tests pass locally but fail in CI + +Ensure the CI workflow installs bats before running tests. For GitHub Actions: + +```yaml +- name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + +- name: Run tests + run: bats .github/scripts/tests/ +``` + +### A test fails unexpectedly + +Run with `set -x` tracing to see each command: + +```bash +bats --tap .github/scripts/tests/cherry-pick-to-release.bats \ + --filter "name of failing test" 2>&1 +``` + +Or add `echo "DEBUG: $variable" >&3` inside a test to print to the terminal +(file descriptor 3 is bats's "direct to terminal" channel). From de78174c36d33c85c243cb0d2d9e00c499e348f0 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:28:11 -0300 Subject: [PATCH 10/14] Fix git ls-remote, cherry-pick arg order, and conflict command order - Replace git ls-remote with gh api for branch existence check in extract-hotfix-versions.sh (detect-versions job has no checkout) - Put cherry-pick options (--mainline 1) before SHA operand - Fix conflict-resolution command to put options before SHA - Update bats test mocks to match new gh api usage --- .github/scripts/cherry-pick-to-release.sh | 8 ++-- .github/scripts/extract-hotfix-versions.sh | 6 ++- .../tests/extract-hotfix-versions.bats | 39 ++++++++++--------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh index 7a6ea2a39e..bcbd0c6dab 100755 --- a/.github/scripts/cherry-pick-to-release.sh +++ b/.github/scripts/cherry-pick-to-release.sh @@ -170,7 +170,8 @@ lookup_milestone() { } # -- Step 4: Attempt the cherry-pick ------------------------------------------ -if git cherry-pick "${MERGE_COMMIT_SHA}" ${MAINLINE_FLAG}; then +# Options (--mainline) must precede the commit operand. +if git cherry-pick ${MAINLINE_FLAG} "${MERGE_COMMIT_SHA}"; then # --- Success path --- echo "Cherry-pick succeeded. Pushing branch and creating PR." git push origin "${CHERRY_PICK_BRANCH}" @@ -189,11 +190,12 @@ else git cherry-pick --abort # Build the cherry-pick command for inclusion in the conflict-resolution - # instructions. Only include --mainline 1 when the commit is a true merge. - CHERRY_PICK_CMD="git cherry-pick ${MERGE_COMMIT_SHA}" + # 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 + 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. diff --git a/.github/scripts/extract-hotfix-versions.sh b/.github/scripts/extract-hotfix-versions.sh index 451f65ea04..5fca917862 100755 --- a/.github/scripts/extract-hotfix-versions.sh +++ b/.github/scripts/extract-hotfix-versions.sh @@ -102,9 +102,13 @@ if [[ "${EVENT_ACTION}" == "labeled" ]]; then # 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 git ls-remote --heads origin "${CHERRY_PICK_BRANCH}" | grep -q .; then + 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 diff --git a/.github/scripts/tests/extract-hotfix-versions.bats b/.github/scripts/tests/extract-hotfix-versions.bats index 3370de9c15..57046cef77 100644 --- a/.github/scripts/tests/extract-hotfix-versions.bats +++ b/.github/scripts/tests/extract-hotfix-versions.bats @@ -147,21 +147,20 @@ get_versions() { export EVENT_LABEL="Hotfix 7.0.1" export LABELS="Hotfix 7.0.1,Hotfix 8.0.0" - # Mock git and gh to report no existing branch/PR. - # Create stub 'git' and 'gh' in PATH that return empty/zero. + # Mock gh to report no existing branch or PR. + # The script now uses 'gh api' for branch checks (no git checkout in this job). local stub_dir stub_dir="$(mktemp -d)" - cat > "${stub_dir}/git" <<'STUB' -#!/usr/bin/env bash -# ls-remote --heads: return nothing (no existing branch) -exit 0 -STUB cat > "${stub_dir}/gh" <<'STUB' #!/usr/bin/env bash -# pr list: return empty array ("0" PRs) +# gh api repos/.../git/ref/heads/...: exit 1 (branch not found) +# gh pr list: return "0" PRs +if [[ "$1" == "api" && "$2" == repos/*/git/ref/heads/* ]]; then + exit 1 +fi echo "0" STUB - chmod +x "${stub_dir}/git" "${stub_dir}/gh" + chmod +x "${stub_dir}/gh" export PATH="${stub_dir}:${PATH}" run bash "${SCRIPT}" @@ -198,12 +197,15 @@ STUB local stub_dir stub_dir="$(mktemp -d)" - # git ls-remote returns a matching ref (branch exists). - cat > "${stub_dir}/git" <<'STUB' + # gh api returns success — branch exists on the remote. + cat > "${stub_dir}/gh" <<'STUB' #!/usr/bin/env bash -echo "abc123 refs/heads/dev/automation/pr-42-to-7.0.1" +if [[ "$1" == "api" && "$2" == repos/*/git/ref/heads/* ]]; then + exit 0 +fi +echo "0" STUB - chmod +x "${stub_dir}/git" + chmod +x "${stub_dir}/gh" export PATH="${stub_dir}:${PATH}" run bash "${SCRIPT}" @@ -221,16 +223,15 @@ STUB local stub_dir stub_dir="$(mktemp -d)" - # git ls-remote returns nothing (no branch), but gh reports an existing PR. - cat > "${stub_dir}/git" <<'STUB' -#!/usr/bin/env bash -exit 0 -STUB + # gh api for branch check returns 1 (not found), but pr list returns 1 PR. cat > "${stub_dir}/gh" <<'STUB' #!/usr/bin/env bash +if [[ "$1" == "api" && "$2" == repos/*/git/ref/heads/* ]]; then + exit 1 +fi echo "1" STUB - chmod +x "${stub_dir}/git" "${stub_dir}/gh" + chmod +x "${stub_dir}/gh" export PATH="${stub_dir}:${PATH}" run bash "${SCRIPT}" From 00ed23aa55b5f7638618c770fa9899a40f216459 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:26:38 -0300 Subject: [PATCH 11/14] fix: deduplicate --help by emitting header comments, add GITHUB_REPOSITORY validation - Replace duplicated --help heredocs with awk extraction of the header comment block in both scripts, eliminating content drift. - Add GITHUB_REPOSITORY to required env var validation in extract-hotfix-versions.sh (was used with set -u but not validated). - Pass --repo to 'gh pr list' so it works without a .git directory. - Set GITHUB_REPOSITORY in bats test setup() for deterministic runs. - Update test assertions to match uppercase header comment headings. --- .github/scripts/cherry-pick-to-release.sh | 26 ++----------- .github/scripts/extract-hotfix-versions.sh | 39 ++++++------------- .../scripts/tests/cherry-pick-to-release.bats | 2 +- .../tests/extract-hotfix-versions.bats | 3 +- 4 files changed, 18 insertions(+), 52 deletions(-) diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh index bcbd0c6dab..15dfd1e89d 100755 --- a/.github/scripts/cherry-pick-to-release.sh +++ b/.github/scripts/cherry-pick-to-release.sh @@ -66,29 +66,9 @@ set -euo pipefail # -- Runtime help ------------------------------------------------------------- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - cat <<'EOF' -Usage: cherry-pick-to-release.sh - -Cherry-picks a merge commit into a release branch and opens a PR. - -Required environment variables: - VERSION Hotfix version (e.g. "7.0.1") - MERGE_COMMIT_SHA SHA of the merge commit - PR_NUMBER Original PR number - PR_TITLE Original PR title - GH_TOKEN GitHub token - GITHUB_REPOSITORY owner/repo (set by Actions) - -Behavior: - - Derives release branch from major.minor (7.0.1 → release/7.0) - - Skips if patch is already applied on the target branch - - Creates a clean PR on success, or a CONFLICTS PR on failure - - Milestone is set if it exists; otherwise a warning is logged - -Exit codes: - 0 Success (including "already applied" — no PR created) - 1 Error (e.g. version parse failure) -EOF + # 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 diff --git a/.github/scripts/extract-hotfix-versions.sh b/.github/scripts/extract-hotfix-versions.sh index 5fca917862..65745fdfee 100755 --- a/.github/scripts/extract-hotfix-versions.sh +++ b/.github/scripts/extract-hotfix-versions.sh @@ -26,12 +26,13 @@ # # 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). +# 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 # ------- @@ -58,26 +59,9 @@ set -euo pipefail # -- Runtime help ------------------------------------------------------------- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - cat <<'EOF' -Usage: extract-hotfix-versions.sh - -Parses "Hotfix X.Y.Z" labels from a GitHub PR and emits a JSON array for -matrix fan-out. - -Required environment variables: - LABELS Comma-separated label names on the PR - EVENT_ACTION "closed" or "labeled" - EVENT_LABEL The label just added (labeled events only) - PR_NUMBER PR number - GH_TOKEN GitHub token for gh CLI - -Output (written to $GITHUB_OUTPUT): - versions=["7.0.1","8.0.0"] - -Exit codes: - 0 Success (including "nothing to do" — versions=[]) - 1 Error (e.g. no valid Hotfix labels on a 'closed' event) -EOF + # 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 @@ -85,6 +69,7 @@ fi : "${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}" # -- 'labeled' event: process only the newly added label ---------------------- if [[ "${EVENT_ACTION}" == "labeled" ]]; then @@ -114,7 +99,7 @@ if [[ "${EVENT_ACTION}" == "labeled" ]]; then exit 0 fi - EXISTING_PR=$(gh pr list --head "${CHERRY_PICK_BRANCH}" --state all \ + EXISTING_PR=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${CHERRY_PICK_BRANCH}" --state all \ --json number --jq 'length') if [[ "${EXISTING_PR}" -gt 0 ]]; then echo "A cherry-pick PR from '${CHERRY_PICK_BRANCH}' already exists. Skipping." diff --git a/.github/scripts/tests/cherry-pick-to-release.bats b/.github/scripts/tests/cherry-pick-to-release.bats index 2abd70ddfc..b8d40a3b3e 100644 --- a/.github/scripts/tests/cherry-pick-to-release.bats +++ b/.github/scripts/tests/cherry-pick-to-release.bats @@ -70,7 +70,7 @@ STUB run bash "${SCRIPT}" --help [ "$status" -eq 0 ] [[ "$output" == *"Cherry-picks a merge commit"* ]] - [[ "$output" == *"Required environment variables"* ]] + [[ "$output" == *"REQUIRED ENVIRONMENT VARIABLES"* ]] } @test "prints help text with -h" { diff --git a/.github/scripts/tests/extract-hotfix-versions.bats b/.github/scripts/tests/extract-hotfix-versions.bats index 57046cef77..e0767ffd04 100644 --- a/.github/scripts/tests/extract-hotfix-versions.bats +++ b/.github/scripts/tests/extract-hotfix-versions.bats @@ -28,6 +28,7 @@ setup() { export EVENT_LABEL="" export PR_NUMBER="42" export GH_TOKEN="fake-token" + export GITHUB_REPOSITORY="dotnet/SqlClient" } teardown() { @@ -45,7 +46,7 @@ get_versions() { run bash "${SCRIPT}" --help [ "$status" -eq 0 ] [[ "$output" == *"Parses"* ]] - [[ "$output" == *"Required environment variables"* ]] + [[ "$output" == *"REQUIRED ENVIRONMENT VARIABLES"* ]] } @test "prints help text with -h" { From 58dbce03f123a73bea2d156119c98603b69dfda5 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:26:13 -0300 Subject: [PATCH 12/14] Address open Copilot review feedback on PR #4106 - Add checkout step to detect-versions job so scripts exist on runner - Fix git cherry invocation (remove incorrect limit argument) - Replace grep -oP with portable bash regex for macOS compat - Replace GNU sed \+ with sed -E / bash regex for portability - Document jq and gh prerequisites in test README --- .github/scripts/cherry-pick-to-release.sh | 10 +++++++--- .github/scripts/extract-hotfix-versions.sh | 10 +++++++--- .github/scripts/tests/README.md | 18 ++++++++++++++++++ .github/workflows/cherry-pick-hotfix.yml | 7 +++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh index 15dfd1e89d..9e642341ed 100755 --- a/.github/scripts/cherry-pick-to-release.sh +++ b/.github/scripts/cherry-pick-to-release.sh @@ -81,8 +81,12 @@ fi # -- Step 1: Derive target branch from major.minor --------------------------- # "7.0.1" → "7.0", so target branch is "release/7.0". -# Use '|| true' to prevent set -e from aborting on a non-matching grep. -BRANCH_VERSION=$(echo "${VERSION}" | grep -oP '^\d+\.\d+' || true) +# 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 @@ -103,7 +107,7 @@ git fetch origin "${TARGET_BRANCH}" # 'git cherry' compares patches between two points. A '-' prefix means the # patch is already present (equivalent commit exists on the target branch). # A '+' prefix means it has not been applied yet. -if git cherry "origin/${TARGET_BRANCH}" "${MERGE_COMMIT_SHA}^" "${MERGE_COMMIT_SHA}" \ +if git cherry "origin/${TARGET_BRANCH}" "${MERGE_COMMIT_SHA}^" \ | grep -q '^-'; then echo "::notice::Commit ${MERGE_COMMIT_SHA} is already applied on" \ "${TARGET_BRANCH}. Skipping cherry-pick." diff --git a/.github/scripts/extract-hotfix-versions.sh b/.github/scripts/extract-hotfix-versions.sh index 65745fdfee..b7c702a8f8 100755 --- a/.github/scripts/extract-hotfix-versions.sh +++ b/.github/scripts/extract-hotfix-versions.sh @@ -75,8 +75,11 @@ fi 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. - CANDIDATE=$(echo "${EVENT_LABEL:-}" \ - | sed -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') + if [[ "${EVENT_LABEL:-}" =~ ^Hotfix\ ([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + CANDIDATE="${BASH_REMATCH[1]}" + else + CANDIDATE="" + fi if [[ -z "${CANDIDATE}" ]]; then echo "Label '${EVENT_LABEL:-}' is not a valid 'Hotfix X.Y.Z' label. Skipping." @@ -111,8 +114,9 @@ if [[ "${EVENT_ACTION}" == "labeled" ]]; then 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 -n 's/^Hotfix \([0-9]\+\.[0-9]\+\.[0-9]\+\)$/\1/p') + | sed -nE 's/^Hotfix ([0-9]+\.[0-9]+\.[0-9]+)$/\1/p') fi # -- Validate that at least one version was found ---------------------------- diff --git a/.github/scripts/tests/README.md b/.github/scripts/tests/README.md index a715ffb29a..d4b9cca2fe 100644 --- a/.github/scripts/tests/README.md +++ b/.github/scripts/tests/README.md @@ -45,6 +45,24 @@ cd bats-core sudo ./install.sh /usr/local ``` +## Additional Prerequisites + +The scripts under test also require: + +- **jq** — used to build JSON matrix output +- **gh** (GitHub CLI) — used for API calls and PR creation (mocked during tests, + but must be on `$PATH` for the test stubs to shadow it) + +Most CI runners and development machines have these pre-installed. If not: + +```bash +# Linux (apt) +sudo apt-get install -y jq gh + +# macOS (Homebrew) +brew install jq gh +``` + ### Verify installation ```bash diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index deb333474e..15ad549ec0 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -56,6 +56,13 @@ jobs: outputs: versions: ${{ steps.extract.outputs.versions }} steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Only the scripts directory is needed; skip full history. + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + - name: Extract hotfix versions from labels id: extract env: From 9b625e211ddc6658e4928d2527fbcc7361342f05 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:45:23 -0300 Subject: [PATCH 13/14] Addressed PR comments/suggestions. --- .github/scripts/cherry-pick-to-release.sh | 2 +- .github/scripts/tests/README.md | 62 +++++++++-------------- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh index 9e642341ed..449b5efe7d 100755 --- a/.github/scripts/cherry-pick-to-release.sh +++ b/.github/scripts/cherry-pick-to-release.sh @@ -142,7 +142,7 @@ lookup_milestone() { MILESTONE_ARG="" MILESTONE_NOTE="" - if gh api "repos/${GITHUB_REPOSITORY}/milestones" \ + if gh api "repos/${GITHUB_REPOSITORY}/milestones" --paginate \ --jq '.[].title' | grep -qx "${version}"; then MILESTONE_ARG="--milestone ${version}" echo "Milestone '${version}' found." diff --git a/.github/scripts/tests/README.md b/.github/scripts/tests/README.md index d4b9cca2fe..ed8ef5e85d 100644 --- a/.github/scripts/tests/README.md +++ b/.github/scripts/tests/README.md @@ -1,21 +1,21 @@ # Cherry-Pick Workflow Tests -This directory contains automated tests for the shell scripts used by the -[cherry-pick-hotfix](./../../../.github/workflows/cherry-pick-hotfix.yml) -GitHub Actions workflow. +This directory contains tests for the shell scripts used by the +[cherry-pick-hotfix](./../../../.github/workflows/cherry-pick-hotfix.yml) GitHub Actions workflow. +These tests are intended to be run manually by developers when they are changing the associated +scripts, and not as part of any CI runs. ## What is Bats? -**[Bats](https://github.com/bats-core/bats-core)** (Bash Automated Testing -System) is a TAP-compliant testing framework for Bash scripts. Each `.bats` -file contains one or more `@test` blocks that run shell commands and assert -outcomes using the built-in `run` helper. A test passes when every command -exits with code 0; it fails on the first non-zero exit. +**[Bats](https://github.com/bats-core/bats-core)** (Bash Automated Testing System) is a +TAP-compliant testing framework for Bash scripts. Each `.bats` file contains one or more `@test` +blocks that run shell commands and assert outcomes using the built-in `run` helper. A test passes +when every command exits with code 0; it fails on the first non-zero exit. Key concepts: | Concept | Description | -|---------|-------------| +| ------- | ----------- | | `@test "name" { ... }` | Defines a single test case | | `run ` | Captures stdout, stderr, and exit code into `$output` and `$status` | | `setup()` | Runs before every `@test` — used to create temp files and set env vars | @@ -50,8 +50,8 @@ sudo ./install.sh /usr/local The scripts under test also require: - **jq** — used to build JSON matrix output -- **gh** (GitHub CLI) — used for API calls and PR creation (mocked during tests, - but must be on `$PATH` for the test stubs to shadow it) +- **gh** (GitHub CLI) — used for API calls and PR creation (mocked during tests, but must be on + `$PATH` for the test stubs to shadow it) Most CI runners and development machines have these pre-installed. If not: @@ -109,7 +109,7 @@ bats --formatter pretty .github/scripts/tests/ ## Test Files | File | Tests | Covers | -|------|-------|--------| +| ---- | ----- | ------ | | `extract-hotfix-versions.bats` | 18 | Label parsing, version extraction, matrix JSON output, edge cases (malformed labels, duplicates, `labeled` vs `closed` events) | | `cherry-pick-to-release.bats` | 15 | Branch derivation, already-applied detection, clean cherry-pick, conflict handling, milestone lookup, PR creation, duplicate skip logic | @@ -117,18 +117,16 @@ bats --formatter pretty .github/scripts/tests/ Both test files use the same general approach: -1. **`setup()`** creates a temporary directory and populates it with mock - `git` and `gh` executables — simple shell scripts that echo predetermined - responses. Environment variables (`VERSION`, `MERGE_COMMIT_SHA`, etc.) are - set to known values. +1. **`setup()`** creates a temporary directory and populates it with mock `git` and `gh` executables + — simple shell scripts that echo predetermined responses. Environment variables (`VERSION`, + `MERGE_COMMIT_SHA`, etc.) are set to known values. -2. **`@test` blocks** call `run bash "$SCRIPT"` to execute the script under - test in a subshell. The mocks intercept all `git` and `gh` invocations, so - no real repository or GitHub API access is needed. +2. **`@test` blocks** call `run bash "$SCRIPT"` to execute the script under test in a subshell. The + mocks intercept all `git` and `gh` invocations, so no real repository or GitHub API access is + needed. -3. **Assertions** check `$status` (exit code) and `$output` - (combined stdout/stderr) for expected values, error messages, or - GitHub Actions workflow commands (`::set-output::`, `::error::`, +3. **Assertions** check `$status` (exit code) and `$output` (combined stdout/stderr) for expected + values, error messages, or GitHub Actions workflow commands (`::set-output::`, `::error::`, `::notice::`). 4. **`teardown()`** removes the temporary directory and mock binaries. @@ -148,8 +146,8 @@ MOCK chmod +x "${STUB_DIR}/git" ``` -The mock sits earlier on `$PATH` than the real `git`, so the script under test -calls the mock transparently. +The mock sits earlier on `$PATH` than the real `git`, so the script under test calls the mock +transparently. ## Troubleshooting @@ -165,18 +163,6 @@ The scripts under `.github/scripts/` must be executable: chmod +x .github/scripts/*.sh ``` -### Tests pass locally but fail in CI - -Ensure the CI workflow installs bats before running tests. For GitHub Actions: - -```yaml -- name: Install bats - run: sudo apt-get update && sudo apt-get install -y bats - -- name: Run tests - run: bats .github/scripts/tests/ -``` - ### A test fails unexpectedly Run with `set -x` tracing to see each command: @@ -186,5 +172,5 @@ bats --tap .github/scripts/tests/cherry-pick-to-release.bats \ --filter "name of failing test" 2>&1 ``` -Or add `echo "DEBUG: $variable" >&3` inside a test to print to the terminal -(file descriptor 3 is bats's "direct to terminal" channel). +Or add `echo "DEBUG: $variable" >&3` inside a test to print to the terminal (file descriptor 3 is +bats's "direct to terminal" channel). From f1925d17bd001b8b4713d149f9d344e26249372e Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:35:57 -0300 Subject: [PATCH 14/14] Address second round of review feedback on PR #4106 - Fix git cherry arg order: scope already-applied check to the exact PR commit - Guard cherry-pick job with if-condition when matrix is empty ([]) - Fix ::set-output:: reference in test README (scripts use $GITHUB_OUTPUT) - Fix 'pipline' typo in both CI pipeline YAML files - Scope milestones API call to open milestones only (--field state=open) --- .github/scripts/cherry-pick-to-release.sh | 11 ++++++----- .github/scripts/tests/README.md | 4 ++-- .github/workflows/cherry-pick-hotfix.yml | 1 + ...dotnet-sqlclient-ci-package-reference-pipeline.yml | 2 +- ...dotnet-sqlclient-ci-project-reference-pipeline.yml | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/scripts/cherry-pick-to-release.sh b/.github/scripts/cherry-pick-to-release.sh index 449b5efe7d..8b3172cc50 100755 --- a/.github/scripts/cherry-pick-to-release.sh +++ b/.github/scripts/cherry-pick-to-release.sh @@ -104,10 +104,11 @@ echo "Merge commit: ${MERGE_COMMIT_SHA}" git fetch origin "${TARGET_BRANCH}" # -- Step 2: Check if the patch is already applied ---------------------------- -# 'git cherry' compares patches between two points. A '-' prefix means the -# patch is already present (equivalent commit exists on the target branch). -# A '+' prefix means it has not been applied yet. -if git cherry "origin/${TARGET_BRANCH}" "${MERGE_COMMIT_SHA}^" \ +# 'git cherry ' lists commits in .. and +# marks each with '-' (patch already on ) or '+' (not yet applied). +# By passing =MERGE_COMMIT_SHA and =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." @@ -143,7 +144,7 @@ lookup_milestone() { MILESTONE_NOTE="" if gh api "repos/${GITHUB_REPOSITORY}/milestones" --paginate \ - --jq '.[].title' | grep -qx "${version}"; then + --field state=open --jq '.[].title' | grep -qx "${version}"; then MILESTONE_ARG="--milestone ${version}" echo "Milestone '${version}' found." else diff --git a/.github/scripts/tests/README.md b/.github/scripts/tests/README.md index ed8ef5e85d..9aac1c6c88 100644 --- a/.github/scripts/tests/README.md +++ b/.github/scripts/tests/README.md @@ -126,8 +126,8 @@ Both test files use the same general approach: needed. 3. **Assertions** check `$status` (exit code) and `$output` (combined stdout/stderr) for expected - values, error messages, or GitHub Actions workflow commands (`::set-output::`, `::error::`, - `::notice::`). + values, error messages, GitHub Actions output file writes via `$GITHUB_OUTPUT`, or other + workflow commands such as `::error::` and `::notice::`. 4. **`teardown()`** removes the temporary directory and mock binaries. diff --git a/.github/workflows/cherry-pick-hotfix.yml b/.github/workflows/cherry-pick-hotfix.yml index 15ad549ec0..9110a58fa2 100644 --- a/.github/workflows/cherry-pick-hotfix.yml +++ b/.github/workflows/cherry-pick-hotfix.yml @@ -80,6 +80,7 @@ jobs: # into each target release branch. cherry-pick: needs: detect-versions + if: needs.detect-versions.outputs.versions != '[]' runs-on: ubuntu-latest strategy: # Don't cancel other cherry-picks if one version fails. diff --git a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml index 2a5b08c469..98cac1bcdd 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-package-reference-pipeline.yml @@ -63,7 +63,7 @@ trigger: - internal/main - internal/release/* -# Trigger this pipline on a schedule. +# Trigger this pipeline on a schedule. schedules: # GitHub main on weekdays diff --git a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml index 8e5d65cb76..7068c9693c 100644 --- a/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml +++ b/eng/pipelines/dotnet-sqlclient-ci-project-reference-pipeline.yml @@ -63,7 +63,7 @@ trigger: - internal/main - internal/release/* -# Trigger this pipline on a schedule. +# Trigger this pipeline on a schedule. schedules: # GitHub main on weekdays