Skip to content

Commit 847a465

Browse files
authored
INFRA-3188:Create workflow to sync release, stable branches (#189)
* INFRA-3188:Create workflow to sync release, stable branches * INFRA-3188: Code Review fixes * INFRA-3188: Added which release branches to sync logic * INFRA-3188: Code Review Fixes * INFRA-3188: Removed extra unused parameter * INFRA-3188: Fixed linting issue * INFRA-3188: Updated commit metadata to use metamaskbot
1 parent 1036dfd commit 847a465

File tree

2 files changed

+387
-0
lines changed

2 files changed

+387
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Release Branch Sync
2+
description: 'Syncs the stable branch into all open release branches after a release is merged.'
3+
4+
inputs:
5+
merged-release-branch:
6+
required: true
7+
description: 'The release branch that was just merged into stable (e.g., release/7.35.0)'
8+
github-token:
9+
description: 'GitHub token used for authentication and PR creation.'
10+
required: true
11+
github-tools-repository:
12+
description: 'The GitHub repository containing the GitHub tools.'
13+
required: false
14+
default: ${{ github.action_repository }}
15+
github-tools-ref:
16+
description: 'The ref of the GitHub tools repository to use.'
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+
fetch-depth: 0
27+
token: ${{ inputs.github-token }}
28+
29+
- name: Checkout GitHub tools repository
30+
uses: actions/checkout@v6
31+
with:
32+
repository: ${{ inputs.github-tools-repository }}
33+
ref: ${{ inputs.github-tools-ref }}
34+
path: ./github-tools
35+
36+
- name: Set Git user and email
37+
shell: bash
38+
run: |
39+
git config --global user.name "metamaskbot"
40+
git config --global user.email "[email protected]"
41+
42+
- name: Run release branch sync script
43+
env:
44+
MERGED_RELEASE_BRANCH: ${{ inputs.merged-release-branch }}
45+
GITHUB_TOKEN: ${{ inputs.github-token }}
46+
shell: bash
47+
run: bash ./github-tools/.github/scripts/release-branch-sync.sh
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/bin/bash
2+
# =============================================================================
3+
# Release Branch Sync Script
4+
# =============================================================================
5+
# Purpose: After a release branch is merged into stable, create PRs to sync
6+
# stable into all active release branches.
7+
#
8+
# Flow:
9+
# 1. Find release branches with active release PRs (open/draft PRs titled "release: X.Y.Z")
10+
# 2. For each one, create a branch from stable (stable-sync-release-X.Y.Z)
11+
# 3. Create a PR from that branch into the release branch
12+
# 4. Conflicts are left for manual resolution by developers
13+
#
14+
# Note: Only release branches with an active release PR are synced. This ensures
15+
# we don't create unnecessary sync PRs for abandoned or completed releases.
16+
#
17+
# Environment variables:
18+
# MERGED_RELEASE_BRANCH - The release branch that was just merged (e.g., release/7.35.0)
19+
# GITHUB_TOKEN - GitHub token for authentication and PR creation
20+
# =============================================================================
21+
22+
set -e
23+
24+
# Regex pattern for valid release branch names (release/X.Y.Z)
25+
RELEASE_BRANCH_PATTERN='^release/[0-9]+\.[0-9]+\.[0-9]+$'
26+
27+
# -----------------------------------------------------------------------------
28+
# Helper Functions
29+
# -----------------------------------------------------------------------------
30+
31+
log_info() {
32+
echo "INFO: $1"
33+
}
34+
35+
log_success() {
36+
echo "SUCCESS: $1"
37+
}
38+
39+
log_warning() {
40+
echo "WARNING: $1"
41+
}
42+
43+
log_error() {
44+
echo "ERROR: $1"
45+
}
46+
47+
log_section() {
48+
echo ""
49+
echo "============================================================"
50+
echo "$1"
51+
echo "============================================================"
52+
}
53+
54+
# Validate that a branch name matches the release/X.Y.Z format
55+
is_valid_release_branch() {
56+
local branch=$1
57+
[[ "$branch" =~ $RELEASE_BRANCH_PATTERN ]]
58+
}
59+
60+
# Check if a sync PR already exists for a release branch
61+
pr_exists() {
62+
local release_branch=$1
63+
local sync_branch=$2
64+
65+
local existing_pr
66+
# Use fallback to "0" if gh command fails (network/auth issues)
67+
# This is safe because gh pr create will also fail if there's a real issue,
68+
# and GitHub rejects duplicate PRs anyway
69+
existing_pr=$(gh pr list --base "$release_branch" --head "$sync_branch" --state open --json number --jq 'length' 2>/dev/null || echo "0")
70+
71+
[[ "$existing_pr" -gt 0 ]]
72+
}
73+
74+
# Parse version from release branch name (release/X.Y.Z -> X.Y.Z)
75+
parse_version() {
76+
local branch=$1
77+
echo "$branch" | sed 's|release/||'
78+
}
79+
80+
# Compare two semantic versions
81+
# Returns: 0 if v1 < v2, 1 if v1 >= v2
82+
is_version_older() {
83+
local v1=$1
84+
local v2=$2
85+
86+
local oldest
87+
oldest=$(printf '%s\n%s\n' "$v1" "$v2" | sort -V | head -n1)
88+
89+
[[ "$v1" == "$oldest" && "$v1" != "$v2" ]]
90+
}
91+
92+
# Check if stable has commits that the release branch doesn't have
93+
stable_has_new_commits() {
94+
local release_branch=$1
95+
96+
# Count commits in stable that are not in the release branch
97+
local ahead_count
98+
ahead_count=$(git rev-list --count "origin/${release_branch}..origin/stable" 2>/dev/null || echo "0")
99+
100+
[[ "$ahead_count" -gt 0 ]]
101+
}
102+
103+
# Find release branches that have active release PRs (open or draft)
104+
# Active release PRs have titles matching "release: X.Y.Z" pattern
105+
# Returns: newline-separated list of release branch names (e.g., release/7.36.0)
106+
get_active_release_branches() {
107+
local branches=""
108+
109+
# Query open and draft PRs with title starting with "release:" (case-insensitive)
110+
# The jq filter extracts version from PR titles like "release: 7.36.0" or "Release: 7.36.0 (#1234)"
111+
local pr_data
112+
pr_data=$(gh pr list \
113+
--state open \
114+
--json title,isDraft \
115+
--jq '.[] | select(.title | test("^release:\\s*[0-9]+\\.[0-9]+\\.[0-9]+"; "i")) | .title' \
116+
2>/dev/null || echo "")
117+
118+
if [[ -z "$pr_data" ]]; then
119+
echo ""
120+
return
121+
fi
122+
123+
# Extract version numbers from PR titles and convert to branch names
124+
while IFS= read -r title; do
125+
if [[ -n "$title" ]]; then
126+
# Extract version (X.Y.Z) from title - jq already validated the format,
127+
# so we just need to extract the first semantic version pattern.
128+
# Using grep -oE is case-agnostic and simpler than matching "release:" variations.
129+
local version
130+
version=$(echo "$title" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
131+
if [[ -n "$version" ]]; then
132+
local branch="release/${version}"
133+
# Only add if not already in list (use grep -Fx for exact string matching,
134+
# avoiding regex issues with '.' in version numbers like 7.3.0)
135+
if [[ -z "$branches" ]] || ! echo "$branches" | grep -Fxq "$branch"; then
136+
if [[ -n "$branches" ]]; then
137+
branches="${branches}"$'\n'"${branch}"
138+
else
139+
branches="$branch"
140+
fi
141+
fi
142+
fi
143+
fi
144+
done <<< "$pr_data"
145+
146+
# Sort by version
147+
echo "$branches" | sort -t'/' -k2 -V
148+
}
149+
150+
# Create a sync PR for a release branch
151+
create_sync_pr() {
152+
local release_branch=$1
153+
local sync_branch=$2
154+
155+
local body="## Summary
156+
157+
This PR syncs the latest changes from \`stable\` into \`${release_branch}\`.
158+
159+
## Why is this needed?
160+
161+
A release branch (\`${MERGED_RELEASE_BRANCH}\`) was merged into \`stable\`. This PR brings those changes (hotfixes, etc.) into \`${release_branch}\`.
162+
163+
## Action Required
164+
165+
**Please review and resolve any merge conflicts manually.**
166+
167+
If there are conflicts, they will appear in this PR. Resolve them to ensure the release branch has all the latest fixes from stable."
168+
169+
gh pr create \
170+
--base "$release_branch" \
171+
--head "$sync_branch" \
172+
--title "chore: sync stable into ${release_branch}" \
173+
--body "$body"
174+
}
175+
176+
# Process a single release branch
177+
# Returns: 0 = PR created, 1 = failed, 2 = skipped
178+
process_release_branch() {
179+
local release_branch=$1
180+
local merged_version=$2
181+
local release_version
182+
release_version=$(parse_version "$release_branch")
183+
184+
log_section "Processing ${release_branch}"
185+
186+
# Skip branches that don't match the release/X.Y.Z format
187+
if ! is_valid_release_branch "$release_branch"; then
188+
log_info "Skipping ${release_branch} (does not match release/X.Y.Z format)"
189+
return 2
190+
fi
191+
192+
# Skip the branch that was just merged
193+
if [[ "$release_branch" == "$MERGED_RELEASE_BRANCH" ]]; then
194+
log_info "Skipping ${release_branch} (just merged into stable)"
195+
return 2
196+
fi
197+
198+
# Skip branches older than the merged release
199+
if is_version_older "$release_version" "$merged_version"; then
200+
log_info "Skipping ${release_branch} (older than merged release ${MERGED_RELEASE_BRANCH})"
201+
return 2
202+
fi
203+
204+
# Verify the branch exists on the remote
205+
if ! git ls-remote --heads origin "$release_branch" | grep -q "$release_branch"; then
206+
log_warning "Skipping ${release_branch} (branch does not exist on remote)"
207+
return 2
208+
fi
209+
210+
# Create sync branch name (replace / with -)
211+
local sync_branch="stable-sync-${release_branch//\//-}"
212+
213+
# Check if a sync PR already exists
214+
if pr_exists "$release_branch" "$sync_branch"; then
215+
log_warning "Sync PR already exists for ${release_branch}, skipping"
216+
return 2
217+
fi
218+
219+
# Check if stable has any new commits compared to the release branch
220+
if ! stable_has_new_commits "$release_branch"; then
221+
log_success "${release_branch} is already up-to-date with stable, no sync needed"
222+
return 2
223+
fi
224+
225+
log_info "Creating sync branch: ${sync_branch} (from stable)"
226+
227+
# Ensure we're on a clean state
228+
git checkout -f origin/stable 2>/dev/null || true
229+
git clean -fd
230+
231+
# Delete local sync branch if it exists
232+
git branch -D "$sync_branch" 2>/dev/null || true
233+
234+
# Create sync branch from stable
235+
git checkout -b "$sync_branch" origin/stable
236+
237+
# Push the sync branch (force in case it exists remotely)
238+
log_info "Pushing ${sync_branch}..."
239+
if git push -u origin "$sync_branch" --force; then
240+
log_success "Pushed ${sync_branch}"
241+
else
242+
log_error "Failed to push ${sync_branch}"
243+
return 1
244+
fi
245+
246+
# Create the PR (stable-sync branch → release branch)
247+
log_info "Creating PR: ${sync_branch}${release_branch}"
248+
if create_sync_pr "$release_branch" "$sync_branch"; then
249+
log_success "Created PR for ${release_branch}"
250+
else
251+
log_error "Failed to create PR for ${release_branch}"
252+
return 1
253+
fi
254+
255+
return 0
256+
}
257+
258+
# -----------------------------------------------------------------------------
259+
# Main Script
260+
# -----------------------------------------------------------------------------
261+
262+
main() {
263+
log_section "Release Branch Sync"
264+
265+
# Validate environment
266+
if [[ -z "$MERGED_RELEASE_BRANCH" ]]; then
267+
log_error "MERGED_RELEASE_BRANCH environment variable is required"
268+
exit 1
269+
fi
270+
271+
# Validate branch format (defense in depth - workflow also validates this)
272+
if ! is_valid_release_branch "$MERGED_RELEASE_BRANCH"; then
273+
log_error "MERGED_RELEASE_BRANCH '${MERGED_RELEASE_BRANCH}' does not match release/X.Y.Z format"
274+
exit 1
275+
fi
276+
277+
if [[ -z "$GITHUB_TOKEN" ]]; then
278+
log_error "GITHUB_TOKEN environment variable is required"
279+
exit 1
280+
fi
281+
282+
log_info "Merged release branch: ${MERGED_RELEASE_BRANCH}"
283+
284+
# Get version of the merged release
285+
local merged_version
286+
merged_version=$(parse_version "$MERGED_RELEASE_BRANCH")
287+
log_info "Merged version: ${merged_version}"
288+
289+
# Fetch all branches
290+
log_info "Fetching all branches..."
291+
git fetch --all --prune
292+
293+
# Find release branches with active release PRs
294+
log_info "Finding release branches with active release PRs (open/draft PRs titled 'release: X.Y.Z')..."
295+
local release_branches
296+
release_branches=$(get_active_release_branches)
297+
298+
if [[ -z "$release_branches" ]]; then
299+
log_warning "No active release branches found (no open/draft PRs with 'release: X.Y.Z' title)"
300+
exit 0
301+
fi
302+
303+
log_info "Found active release branches:"
304+
echo "$release_branches" | while read -r branch; do
305+
echo " - $branch"
306+
done
307+
308+
# Process each release branch
309+
local processed=0
310+
local skipped=0
311+
local failed=0
312+
313+
while IFS= read -r branch; do
314+
if [[ -z "$branch" ]]; then
315+
continue
316+
fi
317+
318+
local result
319+
process_release_branch "$branch" "$merged_version" && result=$? || result=$?
320+
321+
case $result in
322+
0) ((processed++)) || true ;; # PR created
323+
1) ((failed++)) || true ;; # Failed
324+
2) ((skipped++)) || true ;; # Skipped
325+
esac
326+
done <<< "$release_branches"
327+
328+
# Summary
329+
log_section "Summary"
330+
log_info "PRs created: ${processed}"
331+
log_info "Skipped: ${skipped}"
332+
if [[ "$failed" -gt 0 ]]; then
333+
log_error "Failed: ${failed}"
334+
exit 1
335+
fi
336+
337+
log_success "Release branch sync completed!"
338+
}
339+
340+
main "$@"

0 commit comments

Comments
 (0)