Skip to content

Commit 0c60787

Browse files
authored
INFRA-3187: Automate Merging Release Branch workflow (#186)
* INFRA-3187: Automate Merging Release Branch workflow Signed-off-by: Pavel Dvorkin <[email protected]> * INFRA-3187: Removed extra action, fixed workflow * INFRA-3187: Update ref for testing * INFRA-2187:Fix already merged branch issue * INFRA-3187: Update comments for merge conflicts * INFRA-3187: Code review fixes * INFRA-3187: Resolve more code review bugs * INFRA-3187: More code review fixes * INFRA-3187: More code review fixes * INFRA-3187: Code review fix * INFRA-3187: Removed workflow in favor of action being called directly * INFRA-3187: Updated commit metadata to use metamaskbot * INFRA-3187: Code review fix * INFRA-2817: Resolved linting error --------- Signed-off-by: Pavel Dvorkin <[email protected]>
1 parent 847a465 commit 0c60787

File tree

2 files changed

+300
-0
lines changed

2 files changed

+300
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 repository
24+
uses: actions/checkout@v6
25+
with:
26+
ref: ${{ inputs.new-release-branch }}
27+
fetch-depth: 0
28+
token: ${{ inputs.github-token }}
29+
30+
- name: Checkout GitHub tools repository
31+
uses: actions/checkout@v6
32+
with:
33+
repository: ${{ inputs.github-tools-repository }}
34+
ref: ${{ inputs.github-tools-ref }}
35+
path: ./github-tools
36+
37+
- name: Set Git user and email
38+
shell: bash
39+
run: |
40+
git config --global user.name "metamaskbot"
41+
git config --global user.email "[email protected]"
42+
43+
- name: Run merge previous releases script
44+
env:
45+
NEW_RELEASE_BRANCH: ${{ inputs.new-release-branch }}
46+
GITHUB_TOKEN: ${{ inputs.github-token }}
47+
shell: bash
48+
run: 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+
#!/bin/bash
2+
3+
# Merge Previous Release Branches Script
4+
#
5+
# This script is triggered when a new release branch is created (e.g., release/2.1.2).
6+
# It finds all previous release branches and merges them into the new release branch.
7+
#
8+
# Key behaviors:
9+
# - Merges ALL older release branches into the new one
10+
# - For merge conflicts, favors the destination branch (new release)
11+
# - Both branches remain open after merge
12+
# - Fails fast on errors to prevent pushing partial merges
13+
#
14+
# Environment variables:
15+
# - NEW_RELEASE_BRANCH: The newly created release branch (e.g., release/2.1.2)
16+
17+
set -e
18+
19+
# Parse a release branch name to extract version components
20+
# Returns: "major minor patch" or empty string if not valid
21+
parse_release_version() {
22+
local branch_name="$1"
23+
if [[ "$branch_name" =~ ^release/([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
24+
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
25+
fi
26+
}
27+
28+
# Check if version A is older than version B
29+
# Returns: exit code 0 if a < b, 1 otherwise
30+
is_version_older() {
31+
local a_major="$1" a_minor="$2" a_patch="$3"
32+
local b_major="$4" b_minor="$5" b_patch="$6"
33+
34+
if [[ "$a_major" -lt "$b_major" ]]; then return 0; fi
35+
if [[ "$a_major" -gt "$b_major" ]]; then return 1; fi
36+
if [[ "$a_minor" -lt "$b_minor" ]]; then return 0; fi
37+
if [[ "$a_minor" -gt "$b_minor" ]]; then return 1; fi
38+
if [[ "$a_patch" -lt "$b_patch" ]]; then return 0; fi
39+
return 1
40+
}
41+
42+
# Execute a git command and log it
43+
git_exec() {
44+
echo "Executing: git $*"
45+
git "$@"
46+
}
47+
48+
# Check if a branch has already been merged into the current branch. If yes, we skip merging it again.
49+
# Returns: exit code 0 if merged, 1 if not merged
50+
is_branch_merged() {
51+
local source_branch="$1"
52+
git merge-base --is-ancestor "origin/${source_branch}" HEAD 2>/dev/null
53+
}
54+
55+
# Merge a source branch (older release branch) into the current branch (new release branch), favoring current branch on conflicts
56+
merge_with_favor_destination() {
57+
local source_branch="$1"
58+
local dest_branch="$2"
59+
60+
echo ""
61+
echo "============================================================"
62+
echo "Merging ${source_branch} into ${dest_branch}"
63+
echo "============================================================"
64+
65+
# Check if already merged
66+
if is_branch_merged "$source_branch"; then
67+
echo "Branch ${source_branch} is already merged into ${dest_branch}. Skipping."
68+
return 1 # Return 1 to indicate skipped
69+
fi
70+
71+
# Try to merge with "ours" strategy for conflicts (favors current branch (new release))
72+
if git_exec merge "origin/${source_branch}" -X ours --no-edit -m "Merge ${source_branch} into ${dest_branch}"; then
73+
echo "✅ Successfully merged ${source_branch} into ${dest_branch}"
74+
return 0 # Return 0 to indicate merged
75+
fi
76+
77+
# If merge still fails (shouldn't happen with -X ours, but just in case)
78+
# First verify we're actually in a merge state (MERGE_HEAD exists)
79+
if [[ ! -f .git/MERGE_HEAD ]]; then
80+
echo "❌ Merge failed unexpectedly (no merge state). Aborting."
81+
exit 1
82+
fi
83+
84+
echo "⚠️ Merge conflict detected! Resolving by favoring destination branch (new release)..."
85+
86+
# Resolve any unmerged (conflicted) files by keeping destination version.
87+
#
88+
# Git merge terminology in this context:
89+
# - "ours" = destination branch (new release, e.g., release/2.1.2) - the branch we're ON
90+
# - "theirs" = source branch (older release, e.g., release/2.1.1) - the branch being merged IN
91+
#
92+
# We favor "ours" (destination) because the new release branch should take precedence.
93+
local conflict_files
94+
local conflict_count=0
95+
conflict_files=$(git diff --name-only --diff-filter=U 2>/dev/null || true)
96+
if [[ -n "$conflict_files" ]]; then
97+
while IFS= read -r file; do
98+
if [[ -n "$file" ]]; then
99+
echo " - Conflict in: ${file} → keeping destination version"
100+
# Try to checkout destination version ("ours")
101+
# If checkout fails, the file was deleted in destination - keep that deletion
102+
if git checkout --ours "$file" 2>/dev/null; then
103+
git add "$file"
104+
else
105+
# Modify/delete conflict scenario:
106+
# - Destination branch (new release) ALREADY deleted this file intentionally
107+
# - Source branch (older release) modified this file
108+
# - Git doesn't know which action to keep
109+
#
110+
# We use "git rm" to confirm the deletion should stand (destination wins).
111+
# This does NOT delete a file that exists - it tells Git "keep the file deleted".
112+
# The --force flag is required because the file is in a conflicted/unmerged state.
113+
echo " (file was deleted in destination, keeping deletion)"
114+
git rm --force "$file" 2>/dev/null || true
115+
fi
116+
((conflict_count++)) || true
117+
fi
118+
done <<< "$conflict_files"
119+
echo "✅ Resolved ${conflict_count} conflict(s) by keeping destination branch version"
120+
fi
121+
122+
# Now add any remaining files (non-conflicted changes), excluding github-tools directory
123+
git_exec add -- . ':!github-tools'
124+
125+
# Complete the merge - always commit when in merge state, even if no content changes
126+
# Check if we're in a merge state (MERGE_HEAD exists)
127+
if [[ -f .git/MERGE_HEAD ]]; then
128+
if ! git_exec commit -m "Merge ${source_branch} into ${dest_branch}" --no-verify --allow-empty; then
129+
echo "Failed to commit merge of ${source_branch}"
130+
exit 1
131+
fi
132+
fi
133+
134+
echo "✅ Successfully merged ${source_branch} into ${dest_branch} (${conflict_count} conflict(s) resolved)"
135+
return 0 # Return 0 to indicate merged
136+
}
137+
138+
main() {
139+
if [[ -z "$NEW_RELEASE_BRANCH" ]]; then
140+
echo "Error: NEW_RELEASE_BRANCH environment variable is not set"
141+
exit 1
142+
fi
143+
144+
echo "New release branch: ${NEW_RELEASE_BRANCH}"
145+
146+
# Parse the new release version
147+
local new_version
148+
new_version=$(parse_release_version "$NEW_RELEASE_BRANCH")
149+
if [[ -z "$new_version" ]]; then
150+
echo "Error: ${NEW_RELEASE_BRANCH} is not a valid release branch (expected format: release/X.Y.Z)"
151+
exit 1
152+
fi
153+
154+
read -r new_major new_minor new_patch <<< "$new_version"
155+
echo "Parsed version: ${new_major}.${new_minor}.${new_patch}"
156+
157+
# Fetch all remote branches
158+
git_exec fetch origin
159+
160+
# Get all release branches
161+
local all_release_branches=()
162+
while IFS= read -r branch; do
163+
# Remove "origin/" prefix and whitespace
164+
branch="${branch#*origin/}"
165+
branch="${branch// /}"
166+
if [[ -n "$branch" ]] && [[ -n "$(parse_release_version "$branch")" ]]; then
167+
all_release_branches+=("$branch")
168+
fi
169+
done < <(git branch -r --list "origin/release/*")
170+
171+
echo ""
172+
echo "Found ${#all_release_branches[@]} release branches:"
173+
for b in "${all_release_branches[@]}"; do
174+
echo " - $b"
175+
done
176+
177+
# Filter to only branches older than the new one
178+
local older_branches=()
179+
for branch in "${all_release_branches[@]}"; do
180+
local version
181+
version=$(parse_release_version "$branch")
182+
if [[ -n "$version" ]]; then
183+
read -r major minor patch <<< "$version"
184+
if is_version_older "$major" "$minor" "$patch" "$new_major" "$new_minor" "$new_patch"; then
185+
older_branches+=("$branch")
186+
fi
187+
fi
188+
done
189+
190+
# Sort older branches from oldest to newest using version sort
191+
local sorted_branches=()
192+
while IFS= read -r branch; do
193+
[[ -n "$branch" ]] && sorted_branches+=("$branch")
194+
done < <(printf '%s\n' "${older_branches[@]}" | sort -V)
195+
older_branches=("${sorted_branches[@]}")
196+
197+
if [[ ${#older_branches[@]} -eq 0 ]]; then
198+
echo ""
199+
echo "No older release branches found. Nothing to merge."
200+
exit 0
201+
fi
202+
203+
echo ""
204+
echo "Older release branches found (oldest to newest):"
205+
for b in "${older_branches[@]}"; do
206+
echo " - $b"
207+
done
208+
209+
echo ""
210+
echo "Will merge all ${#older_branches[@]} older branches."
211+
212+
# Verify we're on the right branch
213+
local current_branch
214+
current_branch=$(git branch --show-current)
215+
if [[ "$current_branch" != "$NEW_RELEASE_BRANCH" ]]; then
216+
echo "Switching to ${NEW_RELEASE_BRANCH}..."
217+
git_exec checkout "$NEW_RELEASE_BRANCH"
218+
fi
219+
220+
# Merge each branch (fail fast on errors)
221+
local merged_count=0
222+
local skipped_count=0
223+
224+
for older_branch in "${older_branches[@]}"; do
225+
if merge_with_favor_destination "$older_branch" "$NEW_RELEASE_BRANCH"; then
226+
((merged_count++)) || true
227+
else
228+
((skipped_count++)) || true
229+
fi
230+
done
231+
232+
# Only push if we actually merged something
233+
if [[ "$merged_count" -gt 0 ]]; then
234+
echo ""
235+
echo "Pushing merged changes..."
236+
git_exec push origin "$NEW_RELEASE_BRANCH"
237+
else
238+
echo ""
239+
echo "No new merges were made (all branches were already merged)."
240+
fi
241+
242+
echo ""
243+
echo "============================================================"
244+
echo "Merge complete!"
245+
echo " Branches merged: ${merged_count}"
246+
echo " Branches skipped (already merged): ${skipped_count}"
247+
echo "All source branches remain open as requested."
248+
echo "============================================================"
249+
}
250+
251+
# Run main and handle errors
252+
main "$@"

0 commit comments

Comments
 (0)