diff --git a/actions/sync-release-branches/README.md b/actions/sync-release-branches/README.md new file mode 100644 index 000000000..e07d0a860 --- /dev/null +++ b/actions/sync-release-branches/README.md @@ -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_to_` 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 diff --git a/actions/sync-release-branches/action.yml b/actions/sync-release-branches/action.yml new file mode 100644 index 000000000..c863558ce --- /dev/null +++ b/actions/sync-release-branches/action.yml @@ -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" diff --git a/actions/sync-release-branches/sync-release-branches.sh b/actions/sync-release-branches/sync-release-branches.sh new file mode 100755 index 000000000..d2f34687c --- /dev/null +++ b/actions/sync-release-branches/sync-release-branches.sh @@ -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 \` +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"