From f991e93c62e0ff4545b62461e0bee7ee575dcdfc Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Mon, 9 Nov 2026 13:25:35 +0200 Subject: [PATCH] feat: add force-push-beta-to-stable action [ED-26219] Co-authored-by: Cursor --- actions/force-push-beta-to-stable/action.yml | 45 ++++++ .../force-push-beta-to-stable.sh | 94 +++++++++++ .../force-push-beta-to-stable.test.sh | 151 ++++++++++++++++++ .../list-stable-only-prs.sh | 95 +++++++++++ .../force-push-beta-to-stable/package.json | 9 ++ package-lock.json | 8 + 6 files changed, 402 insertions(+) create mode 100644 actions/force-push-beta-to-stable/action.yml create mode 100644 actions/force-push-beta-to-stable/force-push-beta-to-stable.sh create mode 100644 actions/force-push-beta-to-stable/force-push-beta-to-stable.test.sh create mode 100644 actions/force-push-beta-to-stable/list-stable-only-prs.sh create mode 100644 actions/force-push-beta-to-stable/package.json diff --git a/actions/force-push-beta-to-stable/action.yml b/actions/force-push-beta-to-stable/action.yml new file mode 100644 index 000000000..2f550a449 --- /dev/null +++ b/actions/force-push-beta-to-stable/action.yml @@ -0,0 +1,45 @@ +name: Force Push Beta To Stable +description: >- + Replaces release/stable with the tip of release/beta using a force push with lease, + and reports the PRs in release/stable that are not in release/beta (commits whose patch + is already in release/beta are skipped). The list goes to the job summary and the outputs. + The caller must check out the repository with fetch-depth 0 and a token that may + force push to the target branch. + +inputs: + dry-run: + description: When "true", only report the PRs and do not push + required: false + default: 'true' + source-branch: + description: Branch whose tip replaces the target branch + required: false + default: release/beta + target-branch: + description: Branch that is overwritten + required: false + default: release/stable + +outputs: + count: + description: Number of commits in the target branch that are not in the source branch + value: ${{ steps.force-push.outputs.count }} + prs: + description: JSON array of the PR numbers in the target branch that are not in the source branch + value: ${{ steps.force-push.outputs.prs }} + rollback-sha: + description: Target branch SHA before the push + value: ${{ steps.force-push.outputs.rollback-sha }} + +runs: + using: composite + steps: + - name: Force push and report target-only PRs + id: force-push + shell: bash + env: + DRY_RUN: ${{ inputs.dry-run }} + SOURCE_BRANCH: ${{ inputs.source-branch }} + TARGET_BRANCH: ${{ inputs.target-branch }} + ACTION_PATH: ${{ github.action_path }} + run: bash "$ACTION_PATH/force-push-beta-to-stable.sh" diff --git a/actions/force-push-beta-to-stable/force-push-beta-to-stable.sh b/actions/force-push-beta-to-stable/force-push-beta-to-stable.sh new file mode 100644 index 000000000..4460111d9 --- /dev/null +++ b/actions/force-push-beta-to-stable/force-push-beta-to-stable.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Force pushes the source branch onto the target branch and reports the PRs +# that only the target branch contains, since the push drops them. +# +# Required environment variables: +# SOURCE_BRANCH - branch whose tip replaces the target (e.g. release/beta) +# TARGET_BRANCH - branch that is overwritten (e.g. release/stable) +# DRY_RUN - "true" to only report, without pushing +# GITHUB_REPOSITORY - owner/repo, used for PR links + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR source=list-stable-only-prs.sh +source "${SCRIPT_DIR}/list-stable-only-prs.sh" + +SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +OUTPUT_FILE="${GITHUB_OUTPUT:-/dev/null}" + +require_env() { + local name="$1" + if [ -z "${!name:-}" ]; then + echo "::error::Missing required environment variable: $name" + exit 1 + fi +} + +require_env SOURCE_BRANCH +require_env TARGET_BRANCH +require_env DRY_RUN +require_env GITHUB_REPOSITORY + +if [ "$SOURCE_BRANCH" = "$TARGET_BRANCH" ]; then + echo "::error::Source and target branches must differ (both are ${SOURCE_BRANCH})" + exit 1 +fi + +git fetch --no-tags origin \ + "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" \ + "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}" + +source_sha="$(git rev-parse "origin/${SOURCE_BRANCH}")" +target_sha="$(git rev-parse "origin/${TARGET_BRANCH}")" + +rows="$(collect_target_only_prs "origin/${SOURCE_BRANCH}" "origin/${TARGET_BRANCH}")" +commit_count="$(grep -c . <<< "$rows" || true)" + +{ + echo "count=${commit_count}" + echo "prs=$(pr_numbers_as_json "$rows")" + echo "rollback-sha=${target_sha}" +} >> "$OUTPUT_FILE" + +{ + echo "## Force push \`${SOURCE_BRANCH}\` to \`${TARGET_BRANCH}\`" + echo + echo "| | |" + echo "| --- | --- |" + echo "| **${SOURCE_BRANCH}** | \`${source_sha}\` |" + echo "| **${TARGET_BRANCH}** (rollback SHA) | \`${target_sha}\` |" + echo "| **Dry run** | ${DRY_RUN} |" + echo + echo "### ${commit_count} commits in \`${TARGET_BRANCH}\` that are not in \`${SOURCE_BRANCH}\`" + echo + if [ "$commit_count" -gt 0 ]; then + render_markdown_table "$rows" "$GITHUB_REPOSITORY" + else + echo "None. The force push drops no changes." + fi + echo +} >> "$SUMMARY_FILE" + +if [ "$source_sha" = "$target_sha" ]; then + echo "${TARGET_BRANCH} already matches ${SOURCE_BRANCH}. Nothing to push." | tee -a "$SUMMARY_FILE" + exit 0 +fi + +if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: ${TARGET_BRANCH} was not changed." | tee -a "$SUMMARY_FILE" + exit 0 +fi + +git push --force-with-lease="refs/heads/${TARGET_BRANCH}:${target_sha}" \ + origin "${source_sha}:refs/heads/${TARGET_BRANCH}" + +{ + echo "Pushed \`${source_sha}\` to \`${TARGET_BRANCH}\`." + echo + echo "To roll back:" + echo + echo '```bash' + echo "git push --force-with-lease=refs/heads/${TARGET_BRANCH}:${source_sha} origin ${target_sha}:refs/heads/${TARGET_BRANCH}" + echo '```' +} >> "$SUMMARY_FILE" diff --git a/actions/force-push-beta-to-stable/force-push-beta-to-stable.test.sh b/actions/force-push-beta-to-stable/force-push-beta-to-stable.test.sh new file mode 100644 index 000000000..d56950c82 --- /dev/null +++ b/actions/force-push-beta-to-stable/force-push-beta-to-stable.test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Test suite for force-push-beta-to-stable.sh +# Run with: bash force-push-beta-to-stable.test.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FAILURES=0 +TESTS_RUN=0 + +assert_equals() { + local expected="$1" + local actual="$2" + local message="$3" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if [ "$expected" = "$actual" ]; then + echo " ✓ ${message}" + else + echo " ✗ ${message}" + echo " Expected: ${expected}" + echo " Got: ${actual}" + FAILURES=$((FAILURES + 1)) + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if grep -qF -- "$needle" <<< "$haystack"; then + echo " ✓ ${message}" + else + echo " ✗ ${message}" + echo " Missing: ${needle}" + FAILURES=$((FAILURES + 1)) + fi +} + +commit_file() { + local file="$1" + local content="$2" + local message="$3" + echo "$content" > "$file" + git add "$file" + git commit -q -m "$message" +} + +# Beta and stable share a base. Stable then gets a cherry-pick that beta already +# has, a conflicting cherry-pick of a beta PR, a stable-only fix, and a commit +# with no PR number. +create_fixture_repository() { + local root="$1" + + git init -q --bare "${root}/origin.git" + git clone -q "${root}/origin.git" "${root}/work" 2>/dev/null + cd "${root}/work" + git config user.email "test@example.com" + git config user.name "Test" + + commit_file base.txt base "Internal: Base (#1)" + git branch -M release/beta + + commit_file shared.txt shared "Fix: Shared fix (#200)" + commit_file conflict.txt beta-version "Fix: Beta feature (#300)" + + git checkout -q -b release/stable HEAD~2 + commit_file shared.txt shared "Fix: Cherry-pick PR 200 to release/stable: Shared fix (#201)" + commit_file conflict.txt stable-version "Fix: Cherry-pick PR 300 to release/stable with conflicts: Beta feature (#301)" + commit_file stable.txt stable "Fix: Stable only | hotfix (#400)" + commit_file manual.txt manual "Manual stable commit" + + git push -q origin release/beta release/stable +} + +run_action() { + local dry_run="$1" + SOURCE_BRANCH=release/beta \ + TARGET_BRANCH=release/stable \ + DRY_RUN="$dry_run" \ + GITHUB_REPOSITORY=elementor/test \ + GITHUB_STEP_SUMMARY="${FIXTURE_ROOT}/summary.md" \ + GITHUB_OUTPUT="${FIXTURE_ROOT}/output.txt" \ + bash "${SCRIPT_DIR}/force-push-beta-to-stable.sh" > /dev/null 2>&1 +} + +remote_sha() { + git --git-dir="${FIXTURE_ROOT}/origin.git" rev-parse "$1" +} + +test_dry_run_reports_without_pushing() { + echo "Testing dry run:" + + # Arrange + FIXTURE_ROOT="$(mktemp -d)" + create_fixture_repository "$FIXTURE_ROOT" + local stable_before + stable_before="$(remote_sha release/stable)" + + # Act + run_action true + + # Assert + local summary output + summary="$(cat "${FIXTURE_ROOT}/summary.md")" + output="$(cat "${FIXTURE_ROOT}/output.txt")" + assert_equals "$stable_before" "$(remote_sha release/stable)" "release/stable is not changed" + assert_contains "$output" "count=3" "counts only commits whose patch is not in beta" + assert_contains "$output" "prs=[301,400]" "outputs the stable-only PR numbers" + assert_contains "$output" "rollback-sha=${stable_before}" "outputs the rollback SHA" + assert_contains "$summary" "[#400](https://github.com/elementor/test/pull/400)" "links the stable-only PR" + assert_contains "$summary" "Stable only \\| hotfix" "escapes pipes in titles" + assert_contains "$summary" "Cherry-pick of #300, which is in the source branch" "flags cherry-picks of beta PRs" + assert_contains "$summary" "No PR number in the commit message" "lists commits without a PR number" + + cd "$SCRIPT_DIR" + rm -rf "$FIXTURE_ROOT" +} + +test_push_replaces_stable_with_beta() { + echo "Testing push:" + + # Arrange + FIXTURE_ROOT="$(mktemp -d)" + create_fixture_repository "$FIXTURE_ROOT" + local stable_before + stable_before="$(remote_sha release/stable)" + + # Act + run_action false + + # Assert + local summary + summary="$(cat "${FIXTURE_ROOT}/summary.md")" + assert_equals "$(remote_sha release/beta)" "$(remote_sha release/stable)" "release/stable matches release/beta" + assert_contains "$summary" "${stable_before}:refs/heads/release/stable" "prints the rollback command" + + cd "$SCRIPT_DIR" + rm -rf "$FIXTURE_ROOT" +} + +test_dry_run_reports_without_pushing +test_push_replaces_stable_with_beta + +echo +echo "${TESTS_RUN} tests, ${FAILURES} failures" +[ "$FAILURES" -eq 0 ] diff --git a/actions/force-push-beta-to-stable/list-stable-only-prs.sh b/actions/force-push-beta-to-stable/list-stable-only-prs.sh new file mode 100644 index 000000000..d304b53fe --- /dev/null +++ b/actions/force-push-beta-to-stable/list-stable-only-prs.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Lists the commits on a target branch that the source branch does not contain, +# skipping commits whose patch already exists on the source (cherry-picks). +# Prints one tab-separated row per commit: +# pr_numbershaauthorsubjectnote + +set -euo pipefail + +FIELD_SEPARATOR=$'\t' + +extract_pr_number() { + local subject="$1" + if [[ "$subject" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then + echo "${BASH_REMATCH[1]}" + fi +} + +extract_cherry_picked_pr_number() { + local subject="$1" + if [[ "$subject" =~ [Cc]herry-pick[[:space:]]+PR[[:space:]]+#?([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + fi +} + +subjects_reference_pr() { + local subjects="$1" + local pr_number="$2" + grep -Eq "\(#${pr_number}\)|PR #?${pr_number}([^0-9]|$)" <<< "$subjects" +} + +list_target_only_commits() { + local source_ref="$1" + local target_ref="$2" + git log --no-merges --cherry-pick --right-only --reverse \ + --format="%H${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%s" \ + "${source_ref}...${target_ref}" +} + +describe_commit() { + local source_subjects="$1" + local pr_number="$2" + local original_pr_number="$3" + + if [ -n "$original_pr_number" ] && subjects_reference_pr "$source_subjects" "$original_pr_number"; then + echo "Cherry-pick of #${original_pr_number}, which is in the source branch" + elif [ -n "$pr_number" ] && subjects_reference_pr "$source_subjects" "$pr_number"; then + echo "Referenced by a cherry-pick in the source branch" + elif [ -z "$pr_number" ]; then + echo "No PR number in the commit message" + fi +} + +collect_target_only_prs() { + local source_ref="$1" + local target_ref="$2" + local source_subjects sha author subject pr_number original_pr_number note + + source_subjects="$(git log --format=%s "$source_ref")" + + while IFS="$FIELD_SEPARATOR" read -r sha author subject; do + [ -n "$sha" ] || continue + pr_number="$(extract_pr_number "$subject")" + original_pr_number="$(extract_cherry_picked_pr_number "$subject")" + note="$(describe_commit "$source_subjects" "$pr_number" "$original_pr_number")" + printf '%s\t%s\t%s\t%s\t%s\n' "$pr_number" "$sha" "$author" "$subject" "$note" + done < <(list_target_only_commits "$source_ref" "$target_ref") +} + +escape_markdown_cell() { + echo "${1//|/\\|}" +} + +render_markdown_table() { + local rows="$1" + local repository="$2" + local pr_number sha author subject note reference + + echo '| PR | Title | Author | Note |' + echo '| --- | --- | --- | --- |' + + while IFS="$FIELD_SEPARATOR" read -r pr_number sha author subject note; do + [ -n "$sha" ] || continue + if [ -n "$pr_number" ]; then + reference="[#${pr_number}](https://github.com/${repository}/pull/${pr_number})" + else + reference="[\`${sha:0:10}\`](https://github.com/${repository}/commit/${sha})" + fi + echo "| ${reference} | $(escape_markdown_cell "$subject") | $(escape_markdown_cell "$author") | ${note} |" + done <<< "$rows" +} + +pr_numbers_as_json() { + local rows="$1" + cut -f1 <<< "$rows" | { grep -E '^[0-9]+$' || true; } | sort -un | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)' +} diff --git a/actions/force-push-beta-to-stable/package.json b/actions/force-push-beta-to-stable/package.json new file mode 100644 index 000000000..5e3dc2237 --- /dev/null +++ b/actions/force-push-beta-to-stable/package.json @@ -0,0 +1,9 @@ +{ + "name": "@elementor/force-push-beta-to-stable", + "author": "Elementor Team", + "license": "GPL-3.0-or-later", + "private": true, + "scripts": { + "test": "bash force-push-beta-to-stable.test.sh" + } +} diff --git a/package-lock.json b/package-lock.json index 54fbcf43c..2af97cf73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,10 @@ "npm": ">=10.0.0" } }, + "actions/force-push-beta-to-stable": { + "name": "@elementor/force-push-beta-to-stable", + "license": "GPL-3.0-or-later" + }, "actions/playwright-test-healer": { "name": "@elementor/playwright-test-healer", "license": "GPL-3.0-or-later", @@ -715,6 +719,10 @@ "resolved": "packages/editor-github-actions-utils", "link": true }, + "node_modules/@elementor/force-push-beta-to-stable": { + "resolved": "actions/force-push-beta-to-stable", + "link": true + }, "node_modules/@elementor/playwright-test-healer": { "resolved": "actions/playwright-test-healer", "link": true