Skip to content

Commit ed5be17

Browse files
committed
INFRA-3187: Automate Merging Release Branch workflow
Signed-off-by: Pavel Dvorkin <pavel.dvorkin@consensys.net>
1 parent c350664 commit ed5be17

4 files changed

Lines changed: 562 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
github-tools-repository:
12+
description: 'The GitHub repository containing the GitHub tools. Defaults to the GitHub tools action repository, and usually does not need to be changed.'
13+
required: false
14+
default: ${{ github.action_repository }}
15+
github-tools-ref:
16+
description: 'The SHA of the action to use. Defaults to the current action ref, and usually does not need to be changed.'
17+
required: false
18+
default: ${{ github.action_ref }}
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Checkout GitHub tools repository
24+
uses: actions/checkout@v6
25+
with:
26+
repository: ${{ inputs.github-tools-repository }}
27+
ref: ${{ inputs.github-tools-ref }}
28+
path: ./github-tools
29+
30+
- name: Set Git user and email
31+
shell: bash
32+
run: |
33+
git config --global user.name "github-actions[bot]"
34+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
35+
36+
- name: Run merge previous releases script
37+
id: merge-releases
38+
env:
39+
NEW_RELEASE_BRANCH: ${{ inputs.new-release-branch }}
40+
GITHUB_TOKEN: ${{ inputs.github-token }}
41+
shell: bash
42+
run: |
43+
# Ensure github-tools is in .gitignore to prevent it from being committed
44+
if ! grep -q "^github-tools/" .gitignore 2>/dev/null; then
45+
echo "github-tools/" >> .gitignore
46+
echo "Added github-tools/ to .gitignore"
47+
fi
48+
49+
# Execute the script from github-tools
50+
bash ./github-tools/.github/scripts/merge-previous-releases.sh
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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 all previous release branches and merges them into the new release branch.
8+
*
9+
* Key behaviors:
10+
* - Merges ALL older release branches into the new one
11+
* - For merge conflicts, favors the destination branch (new release)
12+
* - Both branches remain open after merge
13+
* - Fails fast on errors to prevent pushing partial merges
14+
*
15+
* Environment variables:
16+
* - NEW_RELEASE_BRANCH: The newly created release branch (e.g., release/2.1.2)
17+
*/
18+
19+
const { promisify } = require('util');
20+
const exec = promisify(require('child_process').exec);
21+
22+
/**
23+
* Parse a release branch name to extract version components
24+
* @param {string} branchName - Branch name like "release/2.1.2"
25+
* @returns {object|null} - { major, minor, patch } or null if not a valid release branch
26+
*/
27+
function parseReleaseVersion(branchName) {
28+
// Match release/X.Y.Z format (does not match release candidates like release/2.1.2-rc.1)
29+
const match = branchName.match(/^release\/(\d+)\.(\d+)\.(\d+)$/);
30+
if (!match) {
31+
return null;
32+
}
33+
return {
34+
major: parseInt(match[1], 10),
35+
minor: parseInt(match[2], 10),
36+
patch: parseInt(match[3], 10),
37+
};
38+
}
39+
40+
/**
41+
* Compare two version objects
42+
* @returns {number} - negative if a < b, positive if a > b, 0 if equal
43+
*/
44+
function compareVersions(a, b) {
45+
if (a.major !== b.major) return a.major - b.major;
46+
if (a.minor !== b.minor) return a.minor - b.minor;
47+
return a.patch - b.patch;
48+
}
49+
50+
/**
51+
* Execute a git command and log it
52+
*/
53+
async function gitExec(command, options = {}) {
54+
const { ignoreError = false } = options;
55+
console.log(`Executing: git ${command}`);
56+
try {
57+
const { stdout, stderr } = await exec(`git ${command}`);
58+
if (stdout.trim()) console.log(stdout.trim());
59+
if (stderr.trim()) console.log(stderr.trim());
60+
return { stdout, stderr, success: true };
61+
} catch (error) {
62+
if (ignoreError) {
63+
console.warn(`Warning: ${error.message}`);
64+
return { stdout: error.stdout, stderr: error.stderr, success: false, error };
65+
}
66+
throw error;
67+
}
68+
}
69+
70+
/**
71+
* Get all remote release branches
72+
*/
73+
async function getReleaseBranches() {
74+
await gitExec('fetch origin');
75+
const { stdout } = await exec('git branch -r --list "origin/release/*"');
76+
return stdout
77+
.split('\n')
78+
.map((branch) => branch.trim().replace('origin/', ''))
79+
.filter((branch) => branch && parseReleaseVersion(branch));
80+
}
81+
82+
/**
83+
* Check if a branch has already been merged into the current branch. If yes, skip the merge.
84+
* @param {string} sourceBranch - The branch to check if it has already been merged into the current branch
85+
* @returns {Promise<boolean>} - True if the branch has already been merged into the current branch, false otherwise
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+
165+
if (!newReleaseBranch) {
166+
console.error('Error: NEW_RELEASE_BRANCH environment variable is not set');
167+
process.exit(1);
168+
}
169+
170+
console.log(`New release branch: ${newReleaseBranch}`);
171+
172+
const newVersion = parseReleaseVersion(newReleaseBranch);
173+
if (!newVersion) {
174+
console.error(
175+
`Error: ${newReleaseBranch} is not a valid release branch (expected format: release/X.Y.Z)`,
176+
);
177+
process.exit(1);
178+
}
179+
180+
console.log(`Parsed version: ${newVersion.major}.${newVersion.minor}.${newVersion.patch}`);
181+
182+
// Get all release branches
183+
const allReleaseBranches = await getReleaseBranches();
184+
console.log(`\nFound ${allReleaseBranches.length} release branches:`);
185+
allReleaseBranches.forEach((b) => console.log(` - ${b}`));
186+
187+
// Filter to only branches older than the new one, sorted from oldest to newest
188+
const olderBranches = allReleaseBranches
189+
.filter((branch) => {
190+
const version = parseReleaseVersion(branch);
191+
return version && compareVersions(version, newVersion) < 0;
192+
})
193+
.sort((a, b) => {
194+
const versionA = parseReleaseVersion(a);
195+
const versionB = parseReleaseVersion(b);
196+
return compareVersions(versionA, versionB);
197+
});
198+
199+
if (olderBranches.length === 0) {
200+
console.log('\nNo older release branches found. Nothing to merge.');
201+
return;
202+
}
203+
204+
console.log(`\nOlder release branches found (oldest to newest):`);
205+
olderBranches.forEach((b) => console.log(` - ${b}`));
206+
207+
// Merge all older branches
208+
const branchesToMerge = olderBranches;
209+
console.log(`\nWill merge all ${branchesToMerge.length} older branches.`);
210+
211+
// We should already be on the new release branch (checkout was done in the workflow)
212+
// But let's verify and ensure we're on the right branch
213+
const { stdout: currentBranch } = await exec('git branch --show-current');
214+
if (currentBranch.trim() !== newReleaseBranch) {
215+
console.log(`Switching to ${newReleaseBranch}...`);
216+
await gitExec(`checkout ${newReleaseBranch}`);
217+
}
218+
219+
// Merge each branch (fail fast on errors)
220+
let mergedCount = 0;
221+
let skippedCount = 0;
222+
223+
for (const olderBranch of branchesToMerge) {
224+
const result = await mergeWithFavorDestination(olderBranch, newReleaseBranch);
225+
if (result.skipped) {
226+
skippedCount++;
227+
} else {
228+
mergedCount++;
229+
}
230+
}
231+
232+
// Only push if we actually merged something
233+
if (mergedCount > 0) {
234+
console.log('\nPushing merged changes...');
235+
await gitExec(`push origin ${newReleaseBranch}`);
236+
} else {
237+
console.log('\nNo new merges were made (all branches were already merged).');
238+
}
239+
240+
console.log('\n' + '='.repeat(60));
241+
console.log('Merge complete!');
242+
console.log(` Branches merged: ${mergedCount}`);
243+
console.log(` Branches skipped (already merged): ${skippedCount}`);
244+
console.log(`All source branches remain open as requested.`);
245+
console.log('='.repeat(60));
246+
}
247+
248+
main().catch((error) => {
249+
console.error(`\nFatal error: ${error.message}`);
250+
console.error('Aborting to prevent pushing partial merges.');
251+
process.exit(1);
252+
});

0 commit comments

Comments
 (0)