fix(patch-release): derive the CM version slot from the target version #1026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Cherry-pick patch PRs to release branch | |
| # Pre-merge flow for PRs labeled `patch`: | |
| # 1. labeled / synchronize → (re)create `cherry-pick/<release>/pr-<N>` off | |
| # the release branch, cherry-pick the PR's | |
| # commits, run integration tests, post a sticky | |
| # comment on the original PR. | |
| # 2. unlabeled → tear the branch down, mark sticky comment | |
| # cancelled. | |
| # 3. closed && merged → re-pick from the final merge SHA and push the | |
| # release branch. | |
| # | |
| # Conflicts are pushed with markers and a sticky comment @-mentions Claude | |
| # asking for a suggested resolution patch (handled by claude-mention.yml). | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'PR number to cherry-pick onto the release branch' | |
| required: true | |
| type: string | |
| # `pull_request_target` (not `pull_request`) so the workflow definition is | |
| # always loaded from `main` regardless of what's on the PR's head ref. With | |
| # `pull_request`, PRs branched off `main` before this workflow landed still | |
| # use their own (outdated) copy and won't trigger on label events. The token | |
| # has write scope; we mitigate the usual `pull_request_target` risk by | |
| # pinning checkout to `main` and only running `git` against PR commits (no | |
| # npm install or PR-controlled scripts executed in this workflow). | |
| pull_request_target: | |
| types: [labeled, unlabeled, synchronize, closed] | |
| branches: [main] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: write | |
| concurrency: | |
| group: cherry-pick-${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| cancel-in-progress: false | |
| jobs: | |
| cherry-pick: | |
| name: Cherry-pick onto ${{ vars.RELEASE_BRANCH || 'v5.0' }} | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'pull_request_target' && ( | |
| (github.event.action == 'labeled' && github.event.label.name == 'patch') || | |
| (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'patch')) || | |
| (github.event.action == 'unlabeled' && github.event.label.name == 'patch') || | |
| (github.event.action == 'closed' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'patch')) | |
| )) | |
| env: | |
| RELEASE_BRANCH: ${{ vars.RELEASE_BRANCH || 'v5.0' }} | |
| STICKY_MARKER: '<!-- patch-ci -->' | |
| PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| steps: | |
| # Pin to `main` so the helper script (.github/scripts/...) is always | |
| # present in the working tree. For `pull_request` events the default | |
| # checkout is the PR head — if the PR branched off before this script | |
| # landed on main, the script is missing and the workflow fails. | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| ref: main | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| fetch-depth: 0 | |
| # The Cherry-pick step below switches the working tree to the release | |
| # branch (which may not contain this helper), so stash it under | |
| # $RUNNER_TEMP and invoke it from there in all subsequent steps. | |
| - name: Stash sticky-comment helper | |
| run: cp .github/scripts/upsert-sticky-comment.js "$RUNNER_TEMP/upsert-sticky-comment.js" | |
| - name: Configure git | |
| run: | | |
| git config user.email "noreply@harperdb.io" | |
| git config user.name "Harperfast" | |
| # ── Unlabeled: tear down branch + cancel sticky comment ───────────── | |
| - name: Handle unlabel | |
| if: github.event.action == 'unlabeled' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| BRANCH="cherry-pick/${RELEASE_BRANCH}/pr-${PR_NUMBER}" | |
| git push origin --delete "$BRANCH" 2>/dev/null || true | |
| BODY=$(printf '%s\n## Patch cherry-pick: cancelled\n\nThe `patch` label was removed; cherry-pick branch `%s` was deleted.\n' "$STICKY_MARKER" "$BRANCH") | |
| node "$RUNNER_TEMP/upsert-sticky-comment.js" "$PR_NUMBER" "$STICKY_MARKER" "$BODY" | |
| # ── Main path: labeled / synchronize / merged / dispatch ──────────── | |
| - name: Cherry-pick | |
| if: github.event.action != 'unlabeled' | |
| id: pick | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| ACTION: ${{ github.event.action }} | |
| EVENT: ${{ github.event_name }} | |
| MERGE_SHA_FROM_EVENT: ${{ github.event.pull_request.merge_commit_sha }} | |
| run: | | |
| set -euo pipefail | |
| PR_DATA=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \ | |
| --jq '{title: .title, head_sha: .head.sha, base_sha: .base.sha, merge_sha: .merge_commit_sha, merged: .merged, state: .state}') | |
| PR_TITLE=$(echo "$PR_DATA" | jq -r '.title') | |
| HEAD_SHA=$(echo "$PR_DATA" | jq -r '.head_sha') | |
| BASE_SHA=$(echo "$PR_DATA" | jq -r '.base_sha') | |
| MERGED=$(echo "$PR_DATA" | jq -r '.merged') | |
| MERGE_SHA=$(echo "$PR_DATA" | jq -r '.merge_sha') | |
| # Trust the API's merged field, not the event action — covers the | |
| # case where `patch` is labeled onto an already-merged PR (action = | |
| # `labeled`) and workflow_dispatch on a merged PR. | |
| IS_MERGED=false | |
| if [ "$MERGED" = "true" ] && [ -n "$MERGE_SHA" ] && [ "$MERGE_SHA" != "null" ]; then | |
| IS_MERGED=true | |
| fi | |
| BRANCH="cherry-pick/${RELEASE_BRANCH}/pr-${PR_NUMBER}" | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "pr_title=$PR_TITLE" >> "$GITHUB_OUTPUT" | |
| echo "is_merged=$IS_MERGED" >> "$GITHUB_OUTPUT" | |
| git fetch origin main "$RELEASE_BRANCH" "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-${PR_NUMBER}" | |
| # ── Determine commits to pick ─────────────────────────────────── | |
| if [ "$IS_MERGED" = "true" ]; then | |
| PARENT_COUNT=$(git cat-file -p "$MERGE_SHA" | grep -c "^parent ") | |
| if [ "$PARENT_COUNT" -gt 1 ]; then | |
| # True merge commit: cherry-pick it directly with -m 1 | |
| PICK_FLAGS="-m 1" | |
| PICK_SHAS="$MERGE_SHA" | |
| else | |
| # Squash or rebase merge: pick the original PR commits so we get | |
| # the full set. For squash this is equivalent (same net diff). For | |
| # rebase, MERGE_SHA is only the last rebased commit — the earlier | |
| # ones would be silently skipped if we picked MERGE_SHA alone. | |
| MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") | |
| PICK_FLAGS="" | |
| PICK_SHAS=$(git rev-list --reverse "${MERGE_BASE}..${HEAD_SHA}" | tr '\n' ' ') | |
| if [ -z "$(echo "$PICK_SHAS" | tr -d ' ')" ]; then | |
| PICK_SHAS="$MERGE_SHA" | |
| fi | |
| fi | |
| else | |
| # Open PR: pick the commit range merge_base..HEAD | |
| MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") | |
| PICK_FLAGS="" | |
| PICK_SHAS=$(git rev-list --reverse "${MERGE_BASE}..${HEAD_SHA}" | tr '\n' ' ') | |
| if [ -z "$(echo "$PICK_SHAS" | tr -d ' ')" ]; then | |
| echo "No commits to cherry-pick between $MERGE_BASE and $HEAD_SHA" | |
| exit 0 | |
| fi | |
| fi | |
| echo "pick_flags=$PICK_FLAGS" >> "$GITHUB_OUTPUT" | |
| echo "pick_shas=$PICK_SHAS" >> "$GITHUB_OUTPUT" | |
| # ── Create / reset cherry-pick branch off release branch ──────── | |
| git checkout -B "$BRANCH" "origin/$RELEASE_BRANCH" | |
| # ── Apply commits, tracking which conflicted ──────────────────── | |
| CONFLICTS="" | |
| for SHA in $PICK_SHAS; do | |
| # shellcheck disable=SC2086 | |
| if ! git cherry-pick $PICK_FLAGS "$SHA"; then | |
| CONFLICTS="${CONFLICTS:+$CONFLICTS }$SHA" | |
| # Stage conflict markers and commit so reviewer sees the state | |
| git add -A | |
| GIT_EDITOR=true git cherry-pick --continue || git cherry-pick --skip || true | |
| fi | |
| done | |
| echo "conflicts=$CONFLICTS" >> "$GITHUB_OUTPUT" | |
| # Force-push (we rewrite this branch on each push to the PR) | |
| git push --force origin "$BRANCH" | |
| # ── Merged path: fast-forward release branch ──────────────────────── | |
| - name: Merge into release branch | |
| if: github.event.action != 'unlabeled' && steps.pick.outputs.is_merged == 'true' && steps.pick.outputs.conflicts == '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| BRANCH="${{ steps.pick.outputs.branch }}" | |
| git checkout "$RELEASE_BRANCH" | |
| git merge --ff-only "$BRANCH" | |
| git push origin "$RELEASE_BRANCH" | |
| git push origin --delete "$BRANCH" || true | |
| BODY=$(printf '%s\n## Patch cherry-pick: merged\n\nCherry-picked onto `%s`.\n' "$STICKY_MARKER" "$RELEASE_BRANCH") | |
| node "$RUNNER_TEMP/upsert-sticky-comment.js" "$PR_NUMBER" "$STICKY_MARKER" "$BODY" | |
| # ── Conflict path: post sticky comment, @-mention Claude ──────────── | |
| - name: Report conflict | |
| if: github.event.action != 'unlabeled' && steps.pick.outputs.conflicts != '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| IS_MERGED: ${{ steps.pick.outputs.is_merged }} | |
| PR_TITLE: ${{ steps.pick.outputs.pr_title }} | |
| run: | | |
| set -euo pipefail | |
| BRANCH="${{ steps.pick.outputs.branch }}" | |
| CONFLICTS="${{ steps.pick.outputs.conflicts }}" | |
| if [ "$IS_MERGED" = "true" ]; then | |
| # PR already merged — open a release-branch PR so the conflict can | |
| # be resolved and merged without any future trigger from the original PR. | |
| PATCH_PR_URL=$(gh pr list --repo "$GITHUB_REPOSITORY" \ | |
| --base "$RELEASE_BRANCH" --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true) | |
| if [ -z "$PATCH_PR_URL" ] || [ "$PATCH_PR_URL" = "null" ]; then | |
| PATCH_PR_URL=$(gh pr create --repo "$GITHUB_REPOSITORY" \ | |
| --base "$RELEASE_BRANCH" \ | |
| --head "$BRANCH" \ | |
| --title "cherry-pick: ${PR_TITLE} (conflicts → ${RELEASE_BRANCH})" \ | |
| --body "$(printf 'Cherry-pick of PR #%s onto \`%s\` produced conflicts on commit(s): \`%s\`.\n\nResolve the conflict markers on branch \`%s\` and merge this PR.\n\n@claude please review branch \`%s\` and suggest a patch that resolves the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`) introduced by cherry-picking PR #%s onto \`%s\`. Post the suggested patch as a comment here — do not push.\n' \ | |
| "$PR_NUMBER" "$RELEASE_BRANCH" "$CONFLICTS" "$BRANCH" "$BRANCH" "$PR_NUMBER" "$RELEASE_BRANCH")") | |
| fi | |
| BODY=$(printf '%s\n## Patch cherry-pick: conflict\n\nCherry-pick onto `%s` produced conflicts on commit(s): `%s`\n\nThe conflict markers are committed on branch [`%s`](../tree/%s).\nA pull request has been opened to land this patch: %s\n' \ | |
| "$STICKY_MARKER" "$RELEASE_BRANCH" "$CONFLICTS" "$BRANCH" "$BRANCH" "$PATCH_PR_URL") | |
| else | |
| BODY=$(printf '%s\n## Patch cherry-pick: conflict\n\nCherry-pick onto `%s` produced conflicts on commit(s): `%s`\n\nThe conflict markers are committed on branch [`%s`](../tree/%s).\nIntegration tests are **not** running until conflicts are resolved.\n\n@claude please review branch `%s` and suggest a patch that resolves the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`) introduced by cherry-picking PR #%s onto `%s`. Post the suggested patch as a comment on this PR — do not push.\n' \ | |
| "$STICKY_MARKER" "$RELEASE_BRANCH" "$CONFLICTS" "$BRANCH" "$BRANCH" "$BRANCH" "$PR_NUMBER" "$RELEASE_BRANCH") | |
| fi | |
| node "$RUNNER_TEMP/upsert-sticky-comment.js" "$PR_NUMBER" "$STICKY_MARKER" "$BODY" | |
| # ── Success path (open PR): trigger integration tests, wait, report ─ | |
| # | |
| # We poll for completion inline instead of using a separate | |
| # `workflow_run`-triggered workflow because `workflow_run` events do not | |
| # fire for `workflow_dispatch`-triggered runs on non-default branches — | |
| # so a downstream reporter never sees the cherry-pick run finish. | |
| - name: Trigger integration tests and wait for completion | |
| if: github.event.action != 'unlabeled' && steps.pick.outputs.is_merged != 'true' && steps.pick.outputs.conflicts == '' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| BRANCH="${{ steps.pick.outputs.branch }}" | |
| gh workflow run integration-tests.yaml --ref "$BRANCH" --repo "$GITHUB_REPOSITORY" | |
| # Locate the dispatched run id (workflow run creation is async) | |
| RUN_ID="" | |
| for _ in 1 2 3 4 5 6 7 8 9 10; do | |
| sleep 3 | |
| RUN_ID=$(gh run list --workflow=integration-tests.yaml --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId' || true) | |
| if [ -n "$RUN_ID" ] && [ "$RUN_ID" != "null" ]; then break; fi | |
| done | |
| post_sticky() { | |
| local heading="$1" | |
| local run_line="$2" | |
| local body | |
| body=$(printf '%s\n## Patch cherry-pick: %s\n\nCherry-picked PR #%s onto `%s` at branch [`%s`](../tree/%s).\n%s\n\nOn merge of this PR the cherry-pick branch will be fast-forwarded into `%s`.\n' \ | |
| "$STICKY_MARKER" "$heading" "$PR_NUMBER" "$RELEASE_BRANCH" "$BRANCH" "$BRANCH" "$run_line" "$RELEASE_BRANCH") | |
| node "$RUNNER_TEMP/upsert-sticky-comment.js" "$PR_NUMBER" "$STICKY_MARKER" "$body" | |
| } | |
| if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then | |
| post_sticky "tests dispatched" "Integration tests dispatched; could not locate run id to wait on." | |
| exit 0 | |
| fi | |
| RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" | |
| post_sticky "tests running" "Integration tests: $RUN_URL" | |
| # Poll until completion (30s interval, ~30 min max) | |
| STATUS="" | |
| CONCLUSION="" | |
| for _ in $(seq 1 60); do | |
| sleep 30 | |
| RUN_JSON=$(gh run view "$RUN_ID" --json status,conclusion --repo "$GITHUB_REPOSITORY" 2>/dev/null || true) | |
| STATUS=$(echo "$RUN_JSON" | jq -r '.status // empty') | |
| CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion // empty') | |
| if [ "$STATUS" = "completed" ]; then break; fi | |
| done | |
| case "$CONCLUSION" in | |
| success) STATUS_LINE='✅ passed' ;; | |
| failure) STATUS_LINE='❌ failed' ;; | |
| cancelled) STATUS_LINE='⚪ cancelled' ;; | |
| "") STATUS_LINE='⏱️ timed out waiting' ;; | |
| *) STATUS_LINE="⚠️ $CONCLUSION" ;; | |
| esac | |
| post_sticky "tests $STATUS_LINE" "Integration tests: **$STATUS_LINE** — $RUN_URL" |