Skip to content
Merged
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
45 changes: 45 additions & 0 deletions actions/force-push-beta-to-stable/action.yml
Original file line number Diff line number Diff line change
@@ -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"
94 changes: 94 additions & 0 deletions actions/force-push-beta-to-stable/force-push-beta-to-stable.sh
Original file line number Diff line number Diff line change
@@ -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"
151 changes: 151 additions & 0 deletions actions/force-push-beta-to-stable/force-push-beta-to-stable.test.sh
Original file line number Diff line number Diff line change
@@ -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 ]
95 changes: 95 additions & 0 deletions actions/force-push-beta-to-stable/list-stable-only-prs.sh
Original file line number Diff line number Diff line change
@@ -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_number<TAB>sha<TAB>author<TAB>subject<TAB>note

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)'
}
9 changes: 9 additions & 0 deletions actions/force-push-beta-to-stable/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading