Skip to content

Commit 29c17fe

Browse files
committed
INFRA-3187: Automate Merging Release Branch workflow
1 parent c350664 commit 29c17fe

File tree

3 files changed

+371
-0
lines changed

3 files changed

+371
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Merge Previous Releases
2+
description: 'An action to merge previous release branches into a newly created release branch.'
3+
4+
inputs:
5+
new-release-branch:
6+
required: true
7+
description: 'The newly created release branch (e.g., release/2.1.2)'
8+
github-token:
9+
description: 'GitHub token used for authentication.'
10+
required: true
11+
merge-all-older-branches:
12+
description: 'Set to "true" to merge ALL older release branches, "false" to only merge the most recent one'
13+
required: false
14+
default: 'false'
15+
github-tools-repository:
16+
description: 'The GitHub repository containing the GitHub tools. Defaults to the GitHub tools action repository, and usually does not need to be changed.'
17+
required: false
18+
default: ${{ github.action_repository }}
19+
github-tools-ref:
20+
description: 'The SHA of the action to use. Defaults to the current action ref, and usually does not need to be changed.'
21+
required: false
22+
default: ${{ github.action_ref }}
23+
24+
runs:
25+
using: composite
26+
steps:
27+
- name: Checkout GitHub tools repository
28+
uses: actions/checkout@v4
29+
with:
30+
repository: ${{ inputs.github-tools-repository }}
31+
ref: ${{ inputs.github-tools-ref }}
32+
path: ./github-tools
33+
34+
- name: Setup Node.js
35+
uses: actions/setup-node@v4
36+
with:
37+
node-version: '20'
38+
39+
- name: Set Git user and email
40+
shell: bash
41+
run: |
42+
git config --global user.name "github-actions[bot]"
43+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
44+
45+
- name: Run merge previous releases script
46+
id: merge-releases
47+
env:
48+
NEW_RELEASE_BRANCH: ${{ inputs.new-release-branch }}
49+
MERGE_ALL_OLDER_BRANCHES: ${{ inputs.merge-all-older-branches }}
50+
GITHUB_TOKEN: ${{ inputs.github-token }}
51+
shell: bash
52+
run: |
53+
# Ensure github-tools is in .gitignore to prevent it from being committed
54+
if ! grep -q "^github-tools/" .gitignore 2>/dev/null; then
55+
echo "github-tools/" >> .gitignore
56+
echo "Added github-tools/ to .gitignore"
57+
fi
58+
59+
# Execute the script from github-tools
60+
node ./github-tools/.github/scripts/merge-previous-releases.js
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Merge Previous Release Branches Script
5+
*
6+
* This script is triggered when a new release branch is created (e.g., release/2.1.2).
7+
* It finds previous release branches and merges them into the new release branch.
8+
*
9+
* Key behaviors:
10+
* - By default, only merges the MOST RECENT older release branch (e.g., 2.1.1 into 2.1.2)
11+
* - Set MERGE_ALL_OLDER_BRANCHES=true to merge ALL older branches
12+
* - For merge conflicts, favors the destination branch (new release)
13+
* - Both branches remain open after merge
14+
* - Fails fast on errors to prevent pushing partial merges
15+
*
16+
* Environment variables:
17+
* - NEW_RELEASE_BRANCH: The newly created release branch (e.g., release/2.1.2)
18+
* - MERGE_ALL_OLDER_BRANCHES: Set to 'true' to merge all older branches (default: false)
19+
*/
20+
21+
const { promisify } = require('util');
22+
const exec = promisify(require('child_process').exec);
23+
24+
/**
25+
* Parse a release branch name to extract version components
26+
* @param {string} branchName - Branch name like "release/2.1.2"
27+
* @returns {object|null} - { major, minor, patch } or null if not a valid release branch
28+
*/
29+
function parseReleaseVersion(branchName) {
30+
// Match release/X.Y.Z format (does not match release candidates like release/2.1.2-rc.1)
31+
const match = branchName.match(/^release\/(\d+)\.(\d+)\.(\d+)$/);
32+
if (!match) {
33+
return null;
34+
}
35+
return {
36+
major: parseInt(match[1], 10),
37+
minor: parseInt(match[2], 10),
38+
patch: parseInt(match[3], 10),
39+
};
40+
}
41+
42+
/**
43+
* Compare two version objects
44+
* @returns {number} - negative if a < b, positive if a > b, 0 if equal
45+
*/
46+
function compareVersions(a, b) {
47+
if (a.major !== b.major) return a.major - b.major;
48+
if (a.minor !== b.minor) return a.minor - b.minor;
49+
return a.patch - b.patch;
50+
}
51+
52+
/**
53+
* Execute a git command and log it
54+
*/
55+
async function gitExec(command, options = {}) {
56+
const { ignoreError = false } = options;
57+
console.log(`Executing: git ${command}`);
58+
try {
59+
const { stdout, stderr } = await exec(`git ${command}`);
60+
if (stdout.trim()) console.log(stdout.trim());
61+
if (stderr.trim()) console.log(stderr.trim());
62+
return { stdout, stderr, success: true };
63+
} catch (error) {
64+
if (ignoreError) {
65+
console.warn(`Warning: ${error.message}`);
66+
return { stdout: error.stdout, stderr: error.stderr, success: false, error };
67+
}
68+
throw error;
69+
}
70+
}
71+
72+
/**
73+
* Get all remote release branches
74+
*/
75+
async function getReleaseBranches() {
76+
await gitExec('fetch origin');
77+
const { stdout } = await exec('git branch -r --list "origin/release/*"');
78+
return stdout
79+
.split('\n')
80+
.map((branch) => branch.trim().replace('origin/', ''))
81+
.filter((branch) => branch && parseReleaseVersion(branch));
82+
}
83+
84+
/**
85+
* Check if a branch has already been merged into the current branch
86+
*/
87+
async function isBranchMerged(sourceBranch) {
88+
try {
89+
// Check if the source branch's HEAD is an ancestor of current HEAD
90+
const { stdout } = await exec(
91+
`git merge-base --is-ancestor origin/${sourceBranch} HEAD && echo "merged" || echo "not-merged"`,
92+
);
93+
return stdout.trim() === 'merged';
94+
} catch {
95+
// If the command fails, assume not merged
96+
return false;
97+
}
98+
}
99+
100+
/**
101+
* Merge a source branch into the current branch, favoring current branch on conflicts
102+
* Uses approach similar to stable-sync.js
103+
*/
104+
async function mergeWithFavorDestination(sourceBranch, destBranch) {
105+
console.log(`\n${'='.repeat(60)}`);
106+
console.log(`Merging ${sourceBranch} into ${destBranch}`);
107+
console.log('='.repeat(60));
108+
109+
// Check if already merged
110+
const alreadyMerged = await isBranchMerged(sourceBranch);
111+
if (alreadyMerged) {
112+
console.log(`Branch ${sourceBranch} is already merged into ${destBranch}. Skipping.`);
113+
return { skipped: true };
114+
}
115+
116+
// Try to merge with "ours" strategy for conflicts (favors current branch)
117+
const mergeResult = await gitExec(
118+
`merge origin/${sourceBranch} -X ours --no-edit -m "Merge ${sourceBranch} into ${destBranch}"`,
119+
{ ignoreError: true },
120+
);
121+
122+
if (!mergeResult.success) {
123+
// If merge still fails (shouldn't happen with -X ours, but just in case)
124+
console.log('Merge had conflicts, resolving by favoring destination branch...');
125+
126+
// Add all files and resolve conflicts by keeping destination version
127+
await gitExec('add .');
128+
129+
// For any remaining conflicts, checkout our version
130+
try {
131+
const { stdout: conflictFiles } = await exec('git diff --name-only --diff-filter=U');
132+
if (conflictFiles.trim()) {
133+
for (const file of conflictFiles.trim().split('\n')) {
134+
if (file) {
135+
console.log(`Resolving conflict in ${file} by keeping destination version`);
136+
await gitExec(`checkout --ours "${file}"`);
137+
await gitExec(`add "${file}"`);
138+
}
139+
}
140+
}
141+
} catch (e) {
142+
// No conflicts to resolve
143+
}
144+
145+
// Complete the merge
146+
const { stdout: status } = await exec('git status --porcelain');
147+
if (status.trim()) {
148+
const commitResult = await gitExec(
149+
`commit -m "Merge ${sourceBranch} into ${destBranch}" --no-verify`,
150+
{ ignoreError: true },
151+
);
152+
if (!commitResult.success) {
153+
throw new Error(`Failed to commit merge of ${sourceBranch}: ${commitResult.error?.message}`);
154+
}
155+
}
156+
}
157+
158+
console.log(`Successfully merged ${sourceBranch} into ${destBranch}`);
159+
return { skipped: false };
160+
}
161+
162+
async function main() {
163+
const newReleaseBranch = process.env.NEW_RELEASE_BRANCH;
164+
const mergeAllOlderBranches = (process.env.MERGE_ALL_OLDER_BRANCHES || 'false').toLowerCase() === 'true';
165+
166+
if (!newReleaseBranch) {
167+
console.error('Error: NEW_RELEASE_BRANCH environment variable is not set');
168+
process.exit(1);
169+
}
170+
171+
console.log(`New release branch: ${newReleaseBranch}`);
172+
console.log(`Merge all older branches: ${mergeAllOlderBranches}`);
173+
174+
const newVersion = parseReleaseVersion(newReleaseBranch);
175+
if (!newVersion) {
176+
console.error(
177+
`Error: ${newReleaseBranch} is not a valid release branch (expected format: release/X.Y.Z)`,
178+
);
179+
process.exit(1);
180+
}
181+
182+
console.log(`Parsed version: ${newVersion.major}.${newVersion.minor}.${newVersion.patch}`);
183+
184+
// Get all release branches
185+
const allReleaseBranches = await getReleaseBranches();
186+
console.log(`\nFound ${allReleaseBranches.length} release branches:`);
187+
allReleaseBranches.forEach((b) => console.log(` - ${b}`));
188+
189+
// Filter to only branches older than the new one, sorted from oldest to newest
190+
const olderBranches = allReleaseBranches
191+
.filter((branch) => {
192+
const version = parseReleaseVersion(branch);
193+
return version && compareVersions(version, newVersion) < 0;
194+
})
195+
.sort((a, b) => {
196+
const versionA = parseReleaseVersion(a);
197+
const versionB = parseReleaseVersion(b);
198+
return compareVersions(versionA, versionB);
199+
});
200+
201+
if (olderBranches.length === 0) {
202+
console.log('\nNo older release branches found. Nothing to merge.');
203+
return;
204+
}
205+
206+
console.log(`\nOlder release branches found (oldest to newest):`);
207+
olderBranches.forEach((b) => console.log(` - ${b}`));
208+
209+
// Determine which branches to merge
210+
let branchesToMerge;
211+
if (mergeAllOlderBranches) {
212+
branchesToMerge = olderBranches;
213+
console.log(`\nWill merge ALL ${branchesToMerge.length} older branches.`);
214+
} else {
215+
// Only merge the most recent older branch (last in the sorted array)
216+
branchesToMerge = [olderBranches[olderBranches.length - 1]];
217+
console.log(`\nWill merge only the most recent older branch: ${branchesToMerge[0]}`);
218+
}
219+
220+
// We should already be on the new release branch (checkout was done in the workflow)
221+
// But let's verify and ensure we're on the right branch
222+
const { stdout: currentBranch } = await exec('git branch --show-current');
223+
if (currentBranch.trim() !== newReleaseBranch) {
224+
console.log(`Switching to ${newReleaseBranch}...`);
225+
await gitExec(`checkout ${newReleaseBranch}`);
226+
}
227+
228+
// Merge each branch (fail fast on errors)
229+
let mergedCount = 0;
230+
let skippedCount = 0;
231+
232+
for (const olderBranch of branchesToMerge) {
233+
const result = await mergeWithFavorDestination(olderBranch, newReleaseBranch);
234+
if (result.skipped) {
235+
skippedCount++;
236+
} else {
237+
mergedCount++;
238+
}
239+
}
240+
241+
// Only push if we actually merged something
242+
if (mergedCount > 0) {
243+
console.log('\nPushing merged changes...');
244+
await gitExec(`push origin ${newReleaseBranch}`);
245+
} else {
246+
console.log('\nNo new merges were made (all branches were already merged).');
247+
}
248+
249+
console.log('\n' + '='.repeat(60));
250+
console.log('Merge complete!');
251+
console.log(` Branches merged: ${mergedCount}`);
252+
console.log(` Branches skipped (already merged): ${skippedCount}`);
253+
console.log(`All source branches remain open as requested.`);
254+
console.log('='.repeat(60));
255+
}
256+
257+
main().catch((error) => {
258+
console.error(`\nFatal error: ${error.message}`);
259+
console.error('Aborting to prevent pushing partial merges.');
260+
process.exit(1);
261+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Merge Previous Releases
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
new-release-branch:
7+
required: true
8+
type: string
9+
description: 'The newly created release branch (e.g., release/2.1.2)'
10+
merge-all-older-branches:
11+
required: false
12+
type: string
13+
description: 'Set to "true" to merge ALL older release branches, "false" to only merge the most recent one'
14+
default: 'false'
15+
secrets:
16+
github-token:
17+
required: true
18+
description: 'GitHub token for authentication'
19+
20+
workflow_dispatch:
21+
inputs:
22+
new-release-branch:
23+
required: true
24+
type: string
25+
description: 'The newly created release branch (e.g., release/2.1.2)'
26+
merge-all-older-branches:
27+
required: false
28+
type: choice
29+
description: 'Merge all older release branches or only the most recent one'
30+
options:
31+
- 'false'
32+
- 'true'
33+
default: 'false'
34+
35+
jobs:
36+
merge-previous-releases:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout repository
40+
uses: actions/checkout@v4
41+
with:
42+
ref: ${{ inputs.new-release-branch }}
43+
fetch-depth: 0
44+
45+
- name: Merge previous releases
46+
uses: ./.github/actions/merge-previous-releases
47+
with:
48+
new-release-branch: ${{ inputs.new-release-branch }}
49+
merge-all-older-branches: ${{ inputs.merge-all-older-branches }}
50+
github-token: ${{ secrets.github-token || secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)