/
/
/
1name: Backport to stable
2permissions:
3 contents: write
4 pull-requests: write
5
6on:
7 push:
8 branches:
9 - dev
10 pull_request_target:
11 types: [labeled]
12 branches:
13 - dev
14
15jobs:
16 backport:
17 name: Backport PRs with 'backport-to-stable' label to stable
18 runs-on: ubuntu-latest
19 if: |
20 (github.event_name == 'push' && github.event.commits[0].distinct == true) ||
21 (github.event_name == 'pull_request_target')
22 steps:
23 - name: Checkout repository
24 uses: actions/checkout@v6
25 with:
26 fetch-depth: 0 # Needed for full git history
27
28 - name: Determine workflow trigger and context
29 id: trigger
30 uses: actions/github-script@v8
31 with:
32 script: |
33 // Determine if this is a push or label event
34 const isPushEvent = context.eventName === 'push';
35 const isLabelEvent = context.eventName === 'pull_request_target';
36
37 core.setOutput('is_push_event', isPushEvent);
38 core.setOutput('is_label_event', isLabelEvent);
39
40 // Early exit for wrong label in label events
41 if (isLabelEvent) {
42 const labelName = context.payload.label?.name;
43 if (labelName !== 'backport-to-stable') {
44 console.log(`Label '${labelName}' is not backport-to-stable, skipping`);
45 process.exit(0);
46 }
47 }
48
49 - name: Get merged PR info
50 id: prinfo
51 uses: actions/github-script@v8
52 with:
53 script: |
54 const isPushEvent = '${{ steps.trigger.outputs.is_push_event }}' === 'true';
55 const isLabelEvent = '${{ steps.trigger.outputs.is_label_event }}' === 'true';
56
57 let merged;
58
59 if (isPushEvent) {
60 // Existing logic: Find PR from commit
61 const pr = await github.rest.pulls.list({
62 owner: context.repo.owner,
63 repo: context.repo.repo,
64 state: 'closed',
65 base: 'dev',
66 sort: 'updated',
67 direction: 'desc',
68 per_page: 10
69 });
70 merged = pr.data.find(p => p.merge_commit_sha === context.payload.head_commit.id);
71 if (!merged) return core.setFailed('No merged PR found for this commit.');
72 } else if (isLabelEvent) {
73 // New logic: Get PR from label event context
74 const pr = context.payload.pull_request;
75
76 // Verify PR is merged (exit gracefully if not)
77 if (!pr.merged_at) {
78 console.log('PR is not merged yet, skipping backport');
79 process.exit(0);
80 }
81
82 // Fetch full PR details to get merge commit SHA
83 const fullPR = await github.rest.pulls.get({
84 owner: context.repo.owner,
85 repo: context.repo.repo,
86 pull_number: pr.number
87 });
88 merged = fullPR.data;
89 }
90
91 core.setOutput('pr_number', merged.number);
92 core.setOutput('pr_title', merged.title);
93 core.setOutput('pr_labels', merged.labels.map(l => l.name).join(','));
94 core.setOutput('merge_commit_sha', merged.merge_commit_sha);
95
96 - name: Check for backport-to-stable label
97 id: checklabel
98 run: |
99 echo "PR labels: ${{ steps.prinfo.outputs.pr_labels }}"
100 if [[ "${{ steps.prinfo.outputs.pr_labels }}" == *"backport-to-stable"* ]]; then
101 echo "backport-to-stable label found, proceeding with backport."
102 echo "should_backport=true" >> $GITHUB_OUTPUT
103 else
104 echo "No backport-to-stable label found, skipping backport."
105 echo "should_backport=false" >> $GITHUB_OUTPUT
106 fi
107
108 - name: Set up Git user
109 if: steps.checklabel.outputs.should_backport == 'true'
110 run: |
111 git config user.name "github-actions[bot]"
112 git config user.email "github-actions[bot]@users.noreply.github.com"
113
114 - name: Calculate next patch version
115 if: steps.checklabel.outputs.should_backport == 'true'
116 id: nextver
117 run: |
118 git fetch origin stable --tags
119 # Filter out beta/rc tags and get only stable versions (e.g., 2.5.5, v2.5.5)
120 latest_tag=$(git tag --merged origin/stable --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
121 if [[ -z "$latest_tag" ]]; then
122 echo "No stable tags found on stable branch" >&2
123 exit 1
124 fi
125 echo "Latest stable tag: $latest_tag"
126
127 # Remove 'v' prefix if present
128 version="$latest_tag"
129 if [[ "$version" =~ ^v ]]; then
130 version="${version#v}"
131 fi
132
133 # Parse version components
134 if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
135 major="${BASH_REMATCH[1]}"
136 minor="${BASH_REMATCH[2]}"
137 patch="${BASH_REMATCH[3]}"
138 else
139 echo "Invalid version format: $version" >&2
140 exit 1
141 fi
142
143 next_patch=$((patch + 1))
144 next_version="$major.$minor.$next_patch"
145
146 echo "Current version: $version"
147 echo "Next version: $next_version"
148 echo "next_patch_version=$next_version" >> $GITHUB_OUTPUT
149
150 - name: Create or update backport branch
151 if: steps.checklabel.outputs.should_backport == 'true'
152 id: create_or_update_backport_branch
153 run: |
154 next_version="${{ steps.nextver.outputs.next_patch_version }}"
155 branch_name="backport/$next_version"
156
157 echo "Creating/updating branch: $branch_name"
158
159 # Check if branch already exists on remote
160 git fetch origin $branch_name || true
161 if git show-ref --verify --quiet refs/remotes/origin/$branch_name; then
162 echo "Branch $branch_name already exists, checking out"
163 git checkout -B $branch_name origin/$branch_name
164 else
165 echo "Branch $branch_name does not exist, creating from stable"
166 git checkout -b $branch_name origin/stable
167 fi
168
169 echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
170
171 - name: Cherry-pick commit
172 if: steps.checklabel.outputs.should_backport == 'true'
173 run: |
174 # Check if commit is already in the branch to avoid redundant cherry-picks
175 if git log --format=%H | grep -q "^${{ steps.prinfo.outputs.merge_commit_sha }}$"; then
176 echo "Commit ${{ steps.prinfo.outputs.merge_commit_sha }} already exists in backport branch, skipping cherry-pick"
177 exit 0
178 fi
179
180 # Try cherry-pick with --empty=drop to handle redundant commits gracefully
181 if git cherry-pick --empty=drop ${{ steps.prinfo.outputs.merge_commit_sha }}; then
182 echo "Cherry-pick successful"
183 else
184 echo 'Cherry-pick failed, please resolve conflicts manually.'
185 exit 1
186 fi
187
188 - name: Push backport branch
189 if: steps.checklabel.outputs.should_backport == 'true'
190 run: |
191 git push origin ${{ steps.create_or_update_backport_branch.outputs.branch_name }}:${{ steps.create_or_update_backport_branch.outputs.branch_name }} --force
192
193 - name: Create or update backport PR with cherry-picked commits
194 if: steps.checklabel.outputs.should_backport == 'true'
195 uses: actions/github-script@v8
196 with:
197 script: |
198 const pr_number = process.env.pr_number;
199 const pr_title = process.env.pr_title;
200 const next_patch_version = process.env.next_patch_version;
201 const branch = process.env.branch_name;
202 const cherry_commit = process.env.cherry_commit;
203
204 console.log(`Processing backport for PR #${pr_number}: ${pr_title}`);
205 console.log(`Next patch version: ${next_patch_version}`);
206 console.log(`Branch: ${branch}`);
207 console.log(`Cherry-pick commit: ${cherry_commit}`);
208
209 const prs = await github.rest.pulls.list({
210 owner: context.repo.owner,
211 repo: context.repo.repo,
212 state: 'open',
213 head: `${context.repo.owner}:${branch}`,
214 base: 'stable'
215 });
216
217 const commit_url = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${cherry_commit}`;
218 const commit_item = `- [${cherry_commit.substring(0,7)}](${commit_url}) - ${pr_title} (#${pr_number})`;
219
220 if (prs.data.length === 0) {
221 // Create new PR with initial commit in body
222 console.log('Creating new backport PR');
223 await github.rest.pulls.create({
224 owner: context.repo.owner,
225 repo: context.repo.repo,
226 title: `[Backport to stable] ${next_patch_version}`,
227 head: branch,
228 base: 'stable',
229 body: `Automated backport PR for stable release ${next_patch_version} with cherry-picked commits:\n\n${commit_item}`
230 });
231 } else {
232 // Update PR body to append new commit if not already present
233 console.log('Updating existing backport PR');
234 const pr = prs.data[0];
235 let body = pr.body || '';
236
237 if (!body.includes(cherry_commit.substring(0,7))) {
238 // Try to find the start of the list (case-insensitive)
239 const listMatch = body.match(/(cherry-picked commits:\n\n)([\s\S]*)/i);
240 if (listMatch) {
241 // Append to existing list
242 const before = listMatch[1];
243 const list = listMatch[2].trim();
244 const newList = list + '\n' + commit_item;
245 body = body.replace(/(cherry-picked commits:\n\n)([\s\S]*)/i, before + newList);
246 } else {
247 // Add new list
248 body = body.trim() + `\n\nCherry-picked commits:\n\n${commit_item}`;
249 }
250
251 await github.rest.pulls.update({
252 owner: context.repo.owner,
253 repo: context.repo.repo,
254 pull_number: pr.number,
255 body
256 });
257 } else {
258 console.log('Commit already exists in PR body, skipping update');
259 }
260 }
261 env:
262 pr_number: ${{ steps.prinfo.outputs.pr_number }}
263 pr_title: ${{ steps.prinfo.outputs.pr_title }}
264 next_patch_version: ${{ steps.nextver.outputs.next_patch_version }}
265 branch_name: ${{ steps.create_or_update_backport_branch.outputs.branch_name }}
266 cherry_commit: ${{ steps.prinfo.outputs.merge_commit_sha }}
267