Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions actions/sync-release-branches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Sync Release Branches

Creates downstream PRs after a release-branch merge using isolated git worktrees. This action does **not** cherry-pick — it merges the merged commit into a branch based on each target and opens a normal PR.

## Cascade rules

| Merged into | PR targets |
| ---------------- | ---------------------- |
| `release/stable` | `release/beta`, `main` |
| `release/beta` | `main` |

Branch names are configurable via inputs.

## Usage

The calling workflow must check out the target repository with `fetch-depth: 0` before invoking this action. Skip PRs whose head branch starts with `sync-pr` to avoid cascade loops.

```yaml
permissions:
contents: write
pull-requests: write

steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}

- uses: elementor/elementor-editor-github-actions/actions/sync-release-branches@main
with:
token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
using-pat: ${{ secrets.GH_PAT != '' }}
base-ref: ${{ github.event.pull_request.base.ref }}
pr-number: ${{ github.event.pull_request.number }}
merge-sha: ${{ github.event.pull_request.merge_commit_sha }}
pr-title: ${{ github.event.pull_request.title }}
pr-user-login: ${{ github.event.pull_request.user.login }}
pr-url: ${{ github.event.pull_request.html_url }}
source-repo: ${{ github.event.pull_request.head.repo.full_name }}
head-ref: ${{ github.event.pull_request.head.ref }}
```

## Inputs

| Input | Required | Default | Description |
| --------------- | -------- | ---------------- | -------------------------------------------------------------- |
| `token` | yes | — | GitHub token with `contents: write` and `pull-requests: write` |
| `base-ref` | yes | — | Branch the source PR was merged into |
| `pr-number` | yes | — | Source PR number |
| `merge-sha` | yes | — | Merge commit SHA |
| `pr-title` | yes | — | Source PR title (reused for downstream PR title) |
| `pr-user-login` | yes | — | Source PR author |
| `pr-url` | yes | — | Source PR URL |
| `source-repo` | yes | — | Head repo (`owner/repo`) |
| `head-ref` | no | `''` | Source PR head branch |
| `using-pat` | no | `false` | Set to `true` when using `GH_PAT` |
| `stable-branch` | no | `release/stable` | Stable branch name |
| `beta-branch` | no | `release/beta` | Beta branch name |
| `main-branch` | no | `main` | Main branch name |

## Behavior

- Creates one git worktree per target branch under `$RUNNER_TEMP/release-sync-worktrees/`
- Merges the source merge commit into `sync-pr<PR#>_to_<target>` branches
- Opens a PR with the **same title** as the merged PR
- Opens a draft PR with conflict markers when merge fails
- Skips targets whose remote branch does not exist
76 changes: 76 additions & 0 deletions actions/sync-release-branches/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Sync Release Branches
description: >-
Create PRs on downstream release branches after a merge using isolated git worktrees.
release/stable merges open PRs to release/beta and main; release/beta merges open a PR to main.

inputs:
token:
description: GitHub token with contents and pull-requests write access
required: true
base-ref:
description: Base branch the PR was merged into
required: true
pr-number:
description: Merged pull request number
required: true
merge-sha:
description: Merge commit SHA to sync into downstream branches
required: true
pr-title:
description: Original PR title
required: true
pr-user-login:
description: Original PR author login
required: true
pr-url:
description: Original PR URL
required: true
source-repo:
description: Head repository full name (owner/repo)
required: true
head-ref:
description: Original PR head branch name
required: false
default: ''
using-pat:
description: Whether a PAT is used so downstream PR workflows trigger automatically
required: false
default: 'false'
stable-branch:
description: Stable release branch name
required: false
default: release/stable
beta-branch:
description: Beta release branch name
required: false
default: release/beta
main-branch:
description: Main branch name
required: false
default: main

runs:
using: composite
steps:
- name: Fetch all branches
shell: bash
run: git fetch --all

- name: Create downstream PRs
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
USING_PAT: ${{ inputs.using-pat }}
BASE_REF: ${{ inputs.base-ref }}
PR_NUMBER: ${{ inputs.pr-number }}
MERGE_SHA: ${{ inputs.merge-sha }}
PR_TITLE: ${{ inputs.pr-title }}
PR_USER_LOGIN: ${{ inputs.pr-user-login }}
ORIG_URL: ${{ inputs.pr-url }}
SOURCE_REPO: ${{ inputs.source-repo }}
HEAD_REF: ${{ inputs.head-ref }}
STABLE_BRANCH: ${{ inputs.stable-branch }}
BETA_BRANCH: ${{ inputs.beta-branch }}
MAIN_BRANCH: ${{ inputs.main-branch }}
ACTION_PATH: ${{ github.action_path }}
run: bash "$ACTION_PATH/sync-release-branches.sh"
223 changes: 223 additions & 0 deletions actions/sync-release-branches/sync-release-branches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
set -euo pipefail

# Create downstream PRs after a release-branch merge using isolated git worktrees.
# Applies the merged commit with git merge (not cherry-pick) and opens normal PRs.
#
# Required environment variables:
# BASE_REF - branch the PR was merged into (e.g. release/stable)
# PR_NUMBER - merged PR number
# MERGE_SHA - merge commit SHA to sync
# PR_TITLE - original PR title
# PR_USER_LOGIN - original PR author login
# ORIG_URL - original PR URL
# SOURCE_REPO - head repo full name
#
# Optional:
# HEAD_REF - original PR head branch (used for loop prevention messaging)
# USING_PAT - "true" when a PAT is used (workflows trigger automatically)
# STABLE_BRANCH - default: release/stable
# BETA_BRANCH - default: release/beta
# MAIN_BRANCH - default: main

WORKTREE_ROOT="${RUNNER_TEMP:-/tmp}/release-sync-worktrees"
STABLE_BRANCH="${STABLE_BRANCH:-release/stable}"
BETA_BRANCH="${BETA_BRANCH:-release/beta}"
MAIN_BRANCH="${MAIN_BRANCH:-main}"
SYNC_BRANCH_PREFIX="sync-pr"

require_env() {
local name="$1"
if [ -z "${!name:-}" ]; then
echo "::error::Missing required environment variable: $name"
exit 1
fi
}

require_env BASE_REF
require_env PR_NUMBER
require_env MERGE_SHA
require_env PR_TITLE
require_env PR_USER_LOGIN
require_env ORIG_URL
require_env SOURCE_REPO

sanitize_title() {
echo "$1" | sed 's/[;&|<>`$()]//g'
}

target_to_safe() {
echo "$1" | sed 's/[\/\.]/_/g'
}

resolve_targets_csv() {
case "$1" in
"$STABLE_BRANCH") echo "${BETA_BRANCH},${MAIN_BRANCH}" ;;
"$BETA_BRANCH") echo "${MAIN_BRANCH}" ;;
*) echo "" ;;
esac
}

merge_merged_commit() {
local merge_sha="$1"

git merge --no-ff "$merge_sha" -m "Sync PR #${PR_NUMBER} from ${BASE_REF}"
}

sanitize_pr_title() {
SANITIZED_PR_TITLE="$(sanitize_title "$PR_TITLE")"
}

create_conflict_pr() {
local target="$1"
local sync_branch="$2"
local conflict_branch="$3"

git add .
git commit -m "Sync PR #${PR_NUMBER} to ${target} with conflicts - manual resolution needed"

if ! git push --force-with-lease origin "${sync_branch}:${conflict_branch}"; then
echo "::warning:: Failed to push conflict branch ${conflict_branch}"
git merge --abort 2>/dev/null || true
return 1
fi

if gh pr list --head "$conflict_branch" --base "$target" --state open | grep -q .; then
echo "::notice:: Draft PR already exists for conflict resolution: ${conflict_branch}"
return 0
fi

gh pr create \
--base "$target" \
--head "$conflict_branch" \
--assignee "$PR_USER_LOGIN" \
--label "auto-reviewed" \
--title "${SANITIZED_PR_TITLE} (sync to ${target} — conflicts)" \
--body "⚠️ **Manual Resolution Required**

This sync PR for [#${PR_NUMBER}](${ORIG_URL}) into \`${target}\` has conflicts that need manual resolution.

**Conflict Files:**
The conflicted files are included in this branch with conflict markers.

**Resolution Steps:**
1. Check out this branch: \`git checkout ${conflict_branch}\`
2. Resolve conflicts in the marked files
3. Stage resolved files: \`git add <resolved-files>\`
4. Amend the commit: \`git commit --amend\`
5. Push changes: \`git push --force-with-lease\`
6. Mark this PR as ready for review

**Original PR:** [#${PR_NUMBER}](${ORIG_URL})
**Merged to:** \`${BASE_REF}\`
**Source branch:** \`${HEAD_REF:-unknown}\`" \
--draft

echo "::notice:: Created draft PR for manual conflict resolution: ${conflict_branch}"
}

create_success_pr() {
local target="$1"
local sync_branch="$2"

if gh pr list --head "$sync_branch" --base "$target" --state open | grep -q .; then
echo "PR already exists for branch ${sync_branch} -> ${target}, skipping creation"
return 0
fi

local pr_url new_pr
pr_url="$(gh pr create \
--base "$target" \
--head "$sync_branch" \
--assignee "$PR_USER_LOGIN" \
--label "auto-reviewed" \
--title "${SANITIZED_PR_TITLE}" \
--body "Automatic sync PR created after [#${PR_NUMBER}](${ORIG_URL}) was merged into \`${BASE_REF}\`.

**Target branch:** \`${target}\`
**Source:** ${SOURCE_REPO}
**Source branch:** \`${HEAD_REF:-unknown}\`
**Original author:** @${PR_USER_LOGIN}")"

new_pr="$(echo "$pr_url" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+' || echo "")"

if [ -z "$new_pr" ]; then
return 0
fi

if [ "${USING_PAT:-false}" = "true" ]; then
echo "::notice:: PR #${new_pr} created with PAT - workflows will trigger automatically"
return 0
fi

echo "Created PR #${new_pr} with GITHUB_TOKEN, triggering workflows via empty commit..."
git commit --allow-empty -m "Trigger workflows for PR #${new_pr}"
git push origin "$sync_branch"
echo "::notice:: Triggered workflows for PR #${new_pr}"
}

sync_to_target() {
local target="$1"
local target_safe sync_branch conflict_branch worktree_path

target_safe="$(target_to_safe "$target")"
sync_branch="${SYNC_BRANCH_PREFIX}${PR_NUMBER}_to_${target_safe}"
conflict_branch="${sync_branch}_conflicts"
worktree_path="${WORKTREE_ROOT}/${target_safe}"

if ! git show-ref --verify --quiet "refs/remotes/origin/${target}"; then
echo "::warning:: Branch ${target} does not exist - skipping"
return 0
fi

git worktree remove --force "$worktree_path" 2>/dev/null || true
rm -rf "$worktree_path"
mkdir -p "$WORKTREE_ROOT"

if ! git worktree add -B "$sync_branch" "$worktree_path" "origin/${target}"; then
echo "::warning:: Failed to create worktree for ${target} - skipping"
return 0
fi

pushd "$worktree_path" >/dev/null

if ! merge_merged_commit "$MERGE_SHA"; then
echo "::error:: Merge conflicts detected for PR #${PR_NUMBER} on branch ${target}"
create_conflict_pr "$target" "$sync_branch" "$conflict_branch" || true
popd >/dev/null
git worktree remove --force "$worktree_path" 2>/dev/null || true
return 0
fi

echo "Merge successful for ${target}"

if ! git push --force-with-lease origin "$sync_branch"; then
echo "::warning:: Failed to push branch ${sync_branch}"
popd >/dev/null
git worktree remove --force "$worktree_path" 2>/dev/null || true
return 0
fi

create_success_pr "$target" "$sync_branch"

popd >/dev/null
git worktree remove --force "$worktree_path" 2>/dev/null || true
}

TARGETS_CSV="$(resolve_targets_csv "$BASE_REF")"
if [ -z "$TARGETS_CSV" ]; then
echo "No sync targets configured for base branch: ${BASE_REF}"
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

sanitize_pr_title

IFS=',' read -ra TARGETS <<< "$TARGETS_CSV"
for target in "${TARGETS[@]}"; do
sync_to_target "$target"
done

rm -rf "$WORKTREE_ROOT"
Loading