Skip to content

fix(patch-release): derive the CM version slot from the target version #1026

fix(patch-release): derive the CM version slot from the target version

fix(patch-release): derive the CM version slot from the target version #1026

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"