TT-16103: trigger release to suggested branch after merge #151
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 to Release Branch (light checkout + GitHub App, resilient) | |
| on: | |
| # Direct usage in this repo: comment "/release to <branch>" on a PR | |
| issue_comment: | |
| types: [created] | |
| # Reusable usage from another workflow / repo | |
| workflow_call: | |
| secrets: | |
| APP_ID: | |
| description: "GitHub App ID" | |
| required: false | |
| APP_PRIVATE_KEY: | |
| description: "GitHub App private key (PEM)" | |
| required: false | |
| # Kept for compatibility, but we do not pass it to the action (avoids warnings) | |
| APP_INSTALLATION_ID: | |
| description: "GitHub App installation ID (optional, unused)" | |
| required: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| cherry_pick: | |
| runs-on: ubuntu-latest | |
| env: | |
| # Depths to try (in order) when we need to scan the base branch to find a squash "merge" | |
| BASE_FETCH_DEPTHS: "100 500 2000" | |
| # Depth for the target release branch | |
| TARGET_FETCH_DEPTH: "50" | |
| steps: | |
| # 1) Only run when someone comments `/release to <branch>` on a PR | |
| - name: Check for release command | |
| id: check_command | |
| uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { issue, comment } = context.payload || {}; | |
| if (!issue || !issue.pull_request || !comment) { | |
| core.setOutput('release_valid','false'); | |
| return; | |
| } | |
| const m = (comment.body || '').trim().match(/^\/release to\s+([^\s]+)\s*$/i); | |
| if (!m) { | |
| core.setOutput('release_valid','false'); | |
| return; | |
| } | |
| core.setOutput('release_valid','true'); | |
| core.setOutput('release_branch', m[1]); | |
| core.setOutput('pr_number', issue.number); | |
| - name: Skip if not a valid command | |
| if: steps.check_command.outputs.release_valid == 'false' | |
| run: | | |
| echo "Skipping: no '/release to <branch>' command found." | |
| # 2) Minimal checkout (shallow + partial clone) | |
| - name: Checkout repository (shallow, partial) | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| fetch-depth: 1 | |
| filter: blob:none | |
| persist-credentials: false | |
| # 3) Resolve GitHub App credentials (supports workflow_call APP_* and repo PROBE_APP_*) | |
| - name: Resolve GitHub App credentials | |
| id: resolve_app | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| env: | |
| CALL_APP_ID: ${{ secrets.APP_ID }} | |
| CALL_APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} | |
| CALL_APP_INSTALLATION_ID: ${{ secrets.APP_INSTALLATION_ID }} | |
| PROBE_APP_ID: ${{ secrets.PROBE_APP_ID }} | |
| PROBE_APP_PRIVATE_KEY: ${{ secrets.PROBE_APP_PRIVATE_KEY }} | |
| PROBE_APP_INSTALLATION_ID: ${{ secrets.PROBE_APP_INSTALLATION_ID }} | |
| run: | | |
| set -euo pipefail | |
| APP_ID="${CALL_APP_ID:-}" | |
| APP_PRIVATE_KEY="${CALL_APP_PRIVATE_KEY:-}" | |
| # Fallbacks for direct runs in this repo | |
| if [ -z "${APP_ID}" ] && [ -n "${PROBE_APP_ID:-}" ]; then APP_ID="${PROBE_APP_ID}"; fi | |
| if [ -z "${APP_PRIVATE_KEY}" ] && [ -n "${PROBE_APP_PRIVATE_KEY:-}" ]; then APP_PRIVATE_KEY="${PROBE_APP_PRIVATE_KEY}"; fi | |
| HAS_APP=false | |
| if [ -n "${APP_ID}" ] && [ -n "${APP_PRIVATE_KEY}" ]; then HAS_APP=true; fi | |
| echo "HAS_APP=${HAS_APP}" >> "$GITHUB_ENV" | |
| if [ "${HAS_APP}" = "true" ]; then | |
| { | |
| echo "APP_ID=${APP_ID}" | |
| echo "APP_PRIVATE_KEY<<EOF" | |
| echo "${APP_PRIVATE_KEY}" | |
| echo "EOF" | |
| } >> "$GITHUB_ENV" | |
| fi | |
| # Create GitHub App token (owner-based; avoids 'installation-id' warning) | |
| - name: Create GitHub App token | |
| id: app_token | |
| if: env.HAS_APP == 'true' | |
| uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 | |
| with: | |
| app-id: ${{ env.APP_ID }} | |
| private-key: ${{ env.APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Select auth token & prepare git remote | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| run: | | |
| set -euo pipefail | |
| TOKEN="${{ steps.app_token.outputs.token }}" | |
| if [ -z "$TOKEN" ]; then TOKEN="${{ secrets.GITHUB_TOKEN }}"; fi | |
| echo "GITHUB_TOKEN=$TOKEN" >> "$GITHUB_ENV" | |
| echo "GH_TOKEN=$TOKEN" >> "$GITHUB_ENV" | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| git remote set-url origin "https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git" | |
| git remote -v | |
| - name: Configure git user | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| run: | | |
| git config --global user.email "bot@tyk.io" | |
| git config --global user.name "Tyk Bot" | |
| git config --global advice.submoduleMergeConflict false | |
| # 4) Find the merge (or squash) commit that landed on the base branch, using REST only | |
| - name: Find merge commit on base branch (shallow fetch) | |
| id: pr_details | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| env: | |
| GH_TOKEN: ${{ env.GH_TOKEN }} | |
| BASE_FETCH_DEPTHS: ${{ env.BASE_FETCH_DEPTHS }} | |
| run: | | |
| set -euo pipefail | |
| PR_NUMBER='${{ steps.check_command.outputs.pr_number }}' | |
| OWNER_REPO='${{ github.repository }}' | |
| PR_JSON="$(gh api "repos/${OWNER_REPO}/pulls/${PR_NUMBER}")" | |
| PR_TITLE="$(jq -r '.title' <<<"$PR_JSON")" | |
| BASE_REF="$(jq -r '.base.ref' <<<"$PR_JSON")" | |
| MERGED="$(jq -r '.merged' <<<"$PR_JSON")" | |
| MERGE_SHA="$(jq -r '.merge_commit_sha // empty' <<<"$PR_JSON")" | |
| if [ "${MERGED}" != "true" ]; then | |
| echo "PR #${PR_NUMBER} is not merged; cannot use merge commit." >&2 | |
| exit 1 | |
| fi | |
| if [ -n "${MERGE_SHA}" ] && [ "${MERGE_SHA}" != "null" ]; then | |
| for D in 50 200 1000; do | |
| git fetch --no-tags --filter=blob:none --depth="$D" origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" | |
| if git cat-file -e "${MERGE_SHA}^{commit}" 2>/dev/null; then | |
| break | |
| fi | |
| done | |
| else | |
| MERGE_SHA="" | |
| for D in ${BASE_FETCH_DEPTHS}; do | |
| git fetch --no-tags --filter=blob:none --depth="$D" origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" | |
| MERGE_SHA="$(git log "origin/${BASE_REF}" --grep="(#${PR_NUMBER})" -n 1 --pretty=format:%H || true)" | |
| if [ -n "${MERGE_SHA}" ]; then break; fi | |
| done | |
| if [ -z "${MERGE_SHA}" ]; then | |
| echo "Could not locate the squash commit for PR #${PR_NUMBER} on ${BASE_REF} within depth(s): ${BASE_FETCH_DEPTHS}" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| { | |
| echo "COMMIT_SHA=${MERGE_SHA}" | |
| echo "PR_TITLE=${PR_TITLE}" | |
| echo "BASE_REF=${BASE_REF}" | |
| echo "PR_NUMBER=${PR_NUMBER}" | |
| } >> "$GITHUB_OUTPUT" | |
| # 5) Cherry-pick to the target branch, with robust conflict handling + guaranteed PR | |
| - name: Cherry-pick merge commit to target release (shallow + resilient) | |
| id: cherry_pick | |
| if: steps.check_command.outputs.release_valid == 'true' | |
| env: | |
| GH_TOKEN: ${{ env.GH_TOKEN }} | |
| GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} | |
| GITHUB_REPO: ${{ github.repository }} | |
| GITHUB_BRANCH: ${{ steps.check_command.outputs.release_branch }} | |
| GITHUB_CHERRY_PICK_COMMIT: ${{ steps.pr_details.outputs.COMMIT_SHA }} | |
| PR_TITLE: ${{ steps.pr_details.outputs.PR_TITLE }} | |
| BASE_REF: ${{ steps.pr_details.outputs.BASE_REF }} | |
| TARGET_FETCH_DEPTH: ${{ env.TARGET_FETCH_DEPTH }} | |
| run: | | |
| set -euo pipefail | |
| # Shallow fetch only target & ensure base exists | |
| git fetch --no-tags --filter=blob:none --depth="${TARGET_FETCH_DEPTH}" origin "+refs/heads/${GITHUB_BRANCH}:refs/remotes/origin/${GITHUB_BRANCH}" || { | |
| echo "Target branch '${GITHUB_BRANCH}' not found on remote." >&2 | |
| exit 1 | |
| } | |
| git fetch --no-tags --filter=blob:none --depth=10 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true | |
| # Ensure we have the commit object | |
| git fetch --no-tags --filter=blob:none origin "${GITHUB_CHERRY_PICK_COMMIT}" || true | |
| # Work on the target branch (shallow) | |
| git checkout -B "${GITHUB_BRANCH}" "origin/${GITHUB_BRANCH}" | |
| # Create a consistent branch name (optionally include JIRA key) | |
| JIRA_ID="$(echo "${PR_TITLE}" | grep -oE '[A-Z]{1,10}-[0-9]{1,10}' | head -n 1 || true)" | |
| BRANCH_NAME="merge/${GITHUB_BRANCH}/${GITHUB_CHERRY_PICK_COMMIT}" | |
| [ -n "${JIRA_ID}" ] && BRANCH_NAME="${BRANCH_NAME}/${JIRA_ID}" | |
| # Clean any stale branches locally/remotely | |
| git branch -D "${BRANCH_NAME}" 2>/dev/null || true | |
| git push origin --delete "${BRANCH_NAME}" 2>/dev/null || true | |
| # Create working branch from target | |
| git switch -c "${BRANCH_NAME}" | |
| # If the commit touches submodules and .gitmodules exists, shallow-init submodules | |
| if [ -f .gitmodules ]; then | |
| SUB_PATHS="$(git config -f .gitmodules --name-only --get-regexp '^submodule\..*\.path' | sed -E 's/^submodule\.[^.]+\.path=//')" | |
| NEED_SUBS=0 | |
| for P in $SUB_PATHS; do | |
| if git diff-tree --no-commit-id --name-only -r "${GITHUB_CHERRY_PICK_COMMIT}" | grep -Fx "$P" >/dev/null 2>&1; then | |
| NEED_SUBS=1; break | |
| fi | |
| done | |
| if [ "$NEED_SUBS" -eq 1 ]; then | |
| git submodule sync --recursive | |
| git submodule update --init --depth=1 --recursive --recommend-shallow | |
| fi | |
| fi | |
| # Is it a merge commit (has >1 parent)? | |
| PARENTS_LINE="$(git rev-list --parents -n 1 "${GITHUB_CHERRY_PICK_COMMIT}")" | |
| WORDS_COUNT="$(wc -w <<<"${PARENTS_LINE}")" | |
| MERGE_FAILED=0 | |
| # Try to apply; if conflicts arise, we won't force-continue here | |
| if [ "${WORDS_COUNT}" -gt 2 ]; then | |
| git cherry-pick -x -m 1 "${GITHUB_CHERRY_PICK_COMMIT}" || MERGE_FAILED=$? | |
| else | |
| git cherry-pick -x "${GITHUB_CHERRY_PICK_COMMIT}" || MERGE_FAILED=$? | |
| fi | |
| CONFLICT_COUNT=0 | |
| CONFLICT_LIST="" | |
| if [ "${MERGE_FAILED}" -ne 0 ]; then | |
| # Gather a short conflicts summary BEFORE aborting | |
| CONFLICT_COUNT="$(git ls-files -u | wc -l || true)" | |
| CONFLICT_LIST="$(git diff --name-only --diff-filter=U | head -n 40 | sed 's/^/ - /' || true)" | |
| # Abort to get the branch back to a clean state | |
| git cherry-pick --abort || true | |
| fi | |
| # If branch has no commits compared to target (e.g., due to abort), add an empty commit | |
| AHEAD_COUNT="$(git rev-list --count "origin/${GITHUB_BRANCH}..HEAD" || echo 0)" | |
| if [ "${AHEAD_COUNT}" -eq 0 ]; then | |
| git commit --allow-empty -m "chore: prepare PR for cherry-pick ${GITHUB_CHERRY_PICK_COMMIT} → ${GITHUB_BRANCH} | |
| Cherry-pick failed with conflicts and requires manual resolution. | |
| This empty commit exists to allow opening a draft PR. | |
| " | |
| fi | |
| # Push branch | |
| git push origin "${BRANCH_NAME}" --force | |
| # Compose title/body | |
| MESSAGE="$(git show -s --format=%B "${GITHUB_CHERRY_PICK_COMMIT}" 2>/dev/null || echo "")" | |
| [ -z "${MESSAGE}" ] && MESSAGE="Cherry-pick ${GITHUB_CHERRY_PICK_COMMIT} to ${GITHUB_BRANCH}" | |
| TITLE="$(echo "${MESSAGE}" | head -n 1)" | |
| if [ "${MERGE_FAILED}" -ne 0 ]; then | |
| BODY="Cherry-pick of \`${GITHUB_CHERRY_PICK_COMMIT}\` from \`${BASE_REF}\` to \`${GITHUB_BRANCH}\` requires manual resolution. | |
| **Conflicts detected:** ${CONFLICT_COUNT} | |
| ${CONFLICT_LIST} | |
| Tips: | |
| - Check out this branch locally and run: \`git cherry-pick -x ${GITHUB_CHERRY_PICK_COMMIT}\` | |
| - Resolve conflicts (including submodules if any), then push back to this branch. | |
| Original commit: https://github.com/${GITHUB_REPO}/commit/${GITHUB_CHERRY_PICK_COMMIT} | |
| " | |
| else | |
| BODY="${MESSAGE}" | |
| fi | |
| # Create PR (draft if conflicts) | |
| PR_URL="$(gh pr view --repo "${GITHUB_REPO}" --head "${BRANCH_NAME}" --json url -q .url 2>/dev/null || true)" | |
| if [ -z "${PR_URL}" ]; then | |
| if [ "${MERGE_FAILED}" -ne 0 ]; then | |
| PR_URL="$(gh pr create --draft \ | |
| --repo "${GITHUB_REPO}" \ | |
| --base "${GITHUB_BRANCH}" \ | |
| --head "${BRANCH_NAME}" \ | |
| --title "Merging to ${GITHUB_BRANCH}: ${TITLE}" \ | |
| --body "${BODY}")" | |
| else | |
| PR_URL="$(gh pr create \ | |
| --repo "${GITHUB_REPO}" \ | |
| --base "${GITHUB_BRANCH}" \ | |
| --head "${BRANCH_NAME}" \ | |
| --title "Merging to ${GITHUB_BRANCH}: ${TITLE}" \ | |
| --body "${BODY}")" | |
| fi | |
| fi | |
| # Optionally label the PR if conflicts | |
| if [ "${MERGE_FAILED}" -ne 0 ]; then | |
| gh label create "needs-manual-cherry-pick" --color FF8700 --description "Cherry-pick has conflicts" --repo "${GITHUB_REPO}" 2>/dev/null || true | |
| gh pr edit "${PR_URL}" --add-label "needs-manual-cherry-pick" --repo "${GITHUB_REPO}" || true | |
| fi | |
| { | |
| echo "PR_URL=${PR_URL}" | |
| echo "MERGE_FAILED=${MERGE_FAILED}" | |
| echo "BRANCH_NAME=${BRANCH_NAME}" | |
| } >> "$GITHUB_OUTPUT" | |
| # 6) Comment back on the original PR with the result (uses selected token) | |
| - name: Comment back on original PR | |
| if: always() && steps.check_command.outputs.release_valid == 'true' | |
| uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 | |
| with: | |
| github-token: ${{ env.GITHUB_TOKEN }} | |
| script: | | |
| const prUrl = '${{ steps.cherry_pick.outputs.PR_URL }}'; | |
| const mergeFailed = '${{ steps.cherry_pick.outputs.MERGE_FAILED }}' === '1'; | |
| let body; | |
| if ('${{ job.status }}' === 'success') { | |
| body = mergeFailed | |
| ? `⚠️ Cherry-pick encountered conflicts. A draft PR was created: ${prUrl}` | |
| : `✅ Cherry-pick successful. A PR was created: ${prUrl}`; | |
| } else { | |
| body = '❌ Cherry-pick failed. Please check the workflow logs.'; | |
| } | |
| github.rest.issues.createComment({ | |
| issue_number: ${{ steps.check_command.outputs.pr_number }}, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body | |
| }); |