Skip to content

feat: add runtime beta gating to sync-release-branches action - #62

Merged
davseve merged 3 commits into
mainfrom
cursor/runtime-beta-gating-4d43
Oct 13, 2026
Merged

davseve merged 3 commits into
mainfrom
cursor/runtime-beta-gating-4d43

Conversation

@davseve

@davseve davseve commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements runtime gating for the release/stable → release/beta sync PR in the sync-release-branches composite action to prevent noisy sync PRs when no active beta cycle exists.

⚠️ Review Updates - Commit 9b2bc6e

All review comments from Netanel and David have been addressed. Rewrote the entire solution in pure bash for reliability and simplicity.

High Priority Fixes ✅

  1. Stdout pollution - All warnings/diagnostics now go to stderr (>&2). Only CSV output goes to stdout.
  2. npx tsx / missing deps - Rewrote in pure bash. No npm dependencies, uses git show + gh api directly. Works offline.
  3. Logic: releases/latest wrong for Pro - Now lists ALL releases with pagination, excludes drafts/prereleases/-latest-*/nightly/-beta/-dev/-rc, takes highest remaining semver. Works for both Core and Pro.

Medium Priority Fixes ✅

  1. Tests don't exercise real logic - Replaced with bash test suite (12 tests) that tests real functions.
  2. Empty JSON fails closed - Now validates result, only exact true/false decides, any other value fails open.
  3. README inaccuracies - Documents complete filtering logic, removed false claims, explains edge case "reopens the problem".

Low Priority Fixes ✅

7-14. All addressed: no fragile import.meta.url (pure bash), clear missing ref error, numeric compare, removed unused deps, exact CSV match, updated action.yml description.

Context

Related: ED-25747 (release automation)

Problem: When a PR merges into release/stable, the action opens downstream PRs to both release/beta and main. The release/beta branch always exists even when there is no active beta, resulting in noisy sync PRs that often conflict and are later closed (e.g., elementor/elementor#37578, #37522).

Solution

The action now computes at runtime whether a beta is active:

  • Beta is active if the X.Y version on release/beta > X.Y of the latest published stable release
  • Latest published stable release is determined by:
    1. Listing all releases via GitHub API with pagination
    2. Excluding drafts and prereleases
    3. Excluding automated build tags (matching -latest-, nightly)
    4. Excluding pre-release version strings (matching -beta, -dev, -rc)
    5. Taking the highest remaining semver by tag
  • Only when active does resolve_targets_csv include release/beta for merges into release/stable
  • Otherwise, only main is targeted
  • Fail-open: If the computation errors (API failure, unparsable version, etc.), the action keeps today's behavior (includes beta) with clear logging to stderr

Implementation

Core Logic (check-beta-active.sh)

Pure bash script that:

  1. Auto-detects plugin type (Core: elementor.php, Pro: elementor-pro.php) by checking which file exists on release/beta
  2. Extracts version from the plugin file on release/beta using git show origin/release/beta:<file>
  3. Lists all releases via GitHub API: gh api repos/$REPO/releases --paginate
  4. Filters out drafts, prereleases, automated builds, and pre-release versions
  5. Compares X.Y versions using pure bash numeric comparison
  6. Returns JSON result with decision and reasoning
  7. All diagnostics go to stderr for visibility

Bash Integration

Updated sync-release-branches.sh:

  • New check_beta_active() function that calls the bash script
  • Modified resolve_targets_csv() to conditionally include beta target based on check result
  • Added decision logging to main script body (not in function) to avoid polluting CSV output
  • Graceful degradation: fails open if jq unavailable or check errors
  • All warnings to stderr

Testing

Comprehensive bash test suite in check-beta-active.test.sh:

  • ✅ Tests extract_major_minor with various version formats
  • ✅ Tests version_gt for major/minor comparison
  • ✅ Tests extract_version_from_file for Core header, Core constant, Pro constant
  • 12 tests total covering all scenarios

Scenarios validated:

  • Today: main=4.4.0, beta=4.3.0, stable=4.3.x → NO beta sync
  • After branch-out: main=4.5.0, beta=4.4.0, stable=4.3.x → YES beta sync
  • Draft release exists: 4.4.0 tag+draft created but not published → YES beta sync (drafts excluded)
  • Beta published: beta=4.4.0, stable=4.4.0 published → NO beta sync
  • Stable patch during beta: stable=4.3.6 published while beta=4.4.0 → YES beta sync (X.Y comparison ignores patch)
  • Automated -latest- tags → excluded from stable baseline
  • Error handling: fails open with clear logging

Why Pure Bash?

Chose bash over TypeScript for:

  1. No build step - no npm dependencies, no bundling
  2. Works offline - composite action doesn't need npm network access
  3. Simpler - straightforward git/gh/jq pipeline
  4. Reliable - no ERR_MODULE_NOT_FOUND, no npx download failures
  5. Testable - bash test suite validates all core functions

Open Points Resolved

1. Stable patch releases format

Confirmed: Stable patch releases (e.g., 4.3.6) are published as normal (non-prerelease) GitHub Releases. The filtering logic handles this: X.Y comparison ignores patch versions.

2. Why not file-vs-file (release/beta vs release/stable)?

Answer: Using the GitHub Releases API with proper filtering is more reliable because:

  • API explicitly filters published vs draft releases
  • Can exclude automated -latest- builds that pollute the "Latest" badge
  • Can exclude pre-release version strings (-beta, -dev, -rc)
  • Version files on branches may be bumped at tag creation, but the filtering ensures only real stable releases count
  • Handles the case where release/stable version file is bumped before publish

3. Automated -latest- builds

Verified: Pro's published releases include many 4.4.0-latest-<timestamp> builds marked as non-prerelease. The new filtering logic explicitly excludes these, fixing the core issue where Pro beta gating never activated.

Changes

  • New: actions/sync-release-branches/check-beta-active.sh - Pure bash implementation with filtering
  • New: actions/sync-release-branches/check-beta-active.test.sh - Bash test suite
  • Deleted: check-beta-active.ts, check-beta-active.test.ts, tsconfig.json, vitest.config.ts (replaced with bash)
  • Modified: sync-release-branches/sync-release-branches.sh - Calls bash script, all diagnostics to stderr
  • Modified: sync-release-branches/README.md - Complete filtering documentation
  • Modified: sync-release-branches/action.yml - Updated description to mention beta gating
  • Modified: sync-release-branches/package.json - Simplified, runs bash tests

Testing Results

✅ All tests pass: npm test
   - 12 bash tests for beta checking logic
   - All other package tests pass

✅ Linting passes: npm run lint
   - eslint ✓
   - tsc ✓
   - prettier ✓

Edge Case

Hotfix on older line: If a hotfix release on an older stable line becomes "Latest" (e.g., 4.3.7 published while 4.4.0 already exists), it can lower the stable X.Y baseline and make beta appear active again, reopening the noisy sync PR issue this gating aims to prevent. This is noted in the README.

Deployment Notes

Caller workflows: Both elementor/elementor and elementor/elementor-pro pin sync-release-branches.yml to @main in their workflows. After this PR merges:

  1. The change takes effect immediately for new merges into release/stable
  2. No caller workflow updates needed (they already reference @main)
  3. Monitor initial runs to verify beta gating works as expected
  4. Logs will show: "Beta sync is ACTIVE" or "Beta sync is INACTIVE"
  5. Stderr will show beta check diagnostics and fail-open reasons
  6. If issues arise, can easily revert the @main commit

Verification plan:

  1. Test on a non-production repo or wait for natural release/stable merge
  2. Confirm log messages appear correctly
  3. Verify correct PR targets based on beta status
  4. Monitor stderr for any fail-open cases
Open in Web Open in Cursor 

Ref: ED-25747

Implement runtime gating to prevent sync PRs to release/beta when beta is inactive.
Beta is active if the X.Y version on release/beta > X.Y of latest published stable release.

Changes:
- Add check-beta-active.ts utility to determine beta status
- Update sync-release-branches.sh to call beta check and conditionally include beta target
- Add vitest tests covering all scenarios
- Update README with beta gating explanation
- Fail open (include beta) on errors or unavailable dependencies

Co-authored-by: Davids Seveloff <davseve@users.noreply.github.com>
@wiz-9a149474ff

wiz-9a149474ff Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total -

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

pluginFile: string,
): BetaCheckResult {
const betaVersion = getVersionFromBranch('release/beta', pluginFile);
if (!betaVersion) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please log this (e.g. console.warn) so a fail-open is visible in the run log. Today the reason only lives in the JSON, and the shell reads just .isActive. Note that the shell runs this with 2>&1, so stderr output would break the jq parse; drop 2>&1 there.

Comment thread actions/sync-release-branches/README.md Outdated
cursor Bot pushed a commit that referenced this pull request Oct 11, 2026
- Log fail-open reasons to stderr via console.warn for visibility
- Remove 2>&1 from beta check invocation so stderr warnings don't break jq
- Clarify 'latest published stable GitHub Release' definition in README
- Document edge case where hotfix release can trigger harmless extra sync PR

Addresses review comments:
- #62 (comment)
- #62 (comment)

Co-authored-by: Davids Seveloff <davseve@users.noreply.github.com>

@Ntnelbaba Ntnelbaba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: action.yml's description still says release/stable merges open PRs to both release/beta and main; it should mention the beta gating.

Comment thread actions/sync-release-branches/sync-release-branches.sh Outdated
Comment thread actions/sync-release-branches/sync-release-branches.sh Outdated
Comment thread actions/sync-release-branches/check-beta-active.ts Outdated
Comment thread actions/sync-release-branches/check-beta-active.test.ts Outdated
Comment thread actions/sync-release-branches/sync-release-branches.sh Outdated
Comment thread actions/sync-release-branches/check-beta-active.ts Outdated
Comment thread actions/sync-release-branches/package.json Outdated
Comment thread actions/sync-release-branches/sync-release-branches.sh Outdated
Comment thread actions/sync-release-branches/sync-release-branches.sh Outdated
Comment thread actions/sync-release-branches/README.md Outdated
Ref: ED-25747

Comprehensive fixes addressing all review comments from Netanel and David:

HIGH PRIORITY FIXES:
1. Fixed stdout pollution (L60, L65): All diagnostic messages now go to stderr
   using >&2. Only CSV output goes to stdout from resolve_targets_csv.

2. Fixed npx tsx dependency issue: Rewrote entire solution in pure bash.
   - No npm dependencies needed
   - Uses git show + gh api for data fetching
   - Simpler, more reliable, works offline in composite action
   - Eliminated ERR_MODULE_NOT_FOUND errors

3. Fixed releases/latest logic for Pro compatibility:
   - List all releases via GitHub API with pagination
   - Exclude drafts and prereleases
   - Exclude automated build tags (-latest-, nightly)
   - Exclude pre-release version strings (-beta, -dev, -rc)
   - Take highest remaining semver by tag
   - Fixes issue where Pro's 4.4.0-latest-<ts> builds were treated as stable

MEDIUM PRIORITY FIXES:
4. Tests now exercise real logic with bash test suite (check-beta-active.test.sh)
   - Tests extract_major_minor, version_gt, extract_version_from_file
   - All helper functions tested directly
   - 12 tests covering all scenarios

5. Empty/invalid JSON now fails open:
   - Check for empty result
   - Only exact 'true' or 'false' values decide
   - Any other value (empty, null, etc.) fails open with warning

6. README corrections:
   - Removed false claim that beta tags are always prereleases
   - Documented that Latest can be automated -latest- builds
   - Explained complete filtering logic
   - Noted edge case that hotfix on older line reopens the problem

LOW PRIORITY FIXES:
7. No more fragile import.meta.url check (pure bash)
8. Version extraction now inline (can reuse plugin-config in future PR)
9. Missing origin/release/beta reported clearly as missing ref
10. Pure numeric major/minor comparison (no building X.Y.0 strings)
11. Removed unused @actions/core, @types/node, globals: true
12. Fixed grep -q substring match - now splits CSV and exact matches
13. Updated action.yml description to mention beta gating
14. Branch name variables used consistently ( not hardcoded)

Changes:
- NEW: check-beta-active.sh - Pure bash implementation
- NEW: check-beta-active.test.sh - Bash test suite
- DELETED: check-beta-active.ts, check-beta-active.test.ts (replaced with bash)
- DELETED: tsconfig.json, vitest.config.ts (no longer needed)
- MODIFIED: sync-release-branches.sh - Calls bash script, fixes stderr
- MODIFIED: README.md - Complete filtering documentation
- MODIFIED: action.yml - Updated description
- MODIFIED: package.json - Simplified, runs bash tests

All tests pass, lint passes.

Co-authored-by: Davids Seveloff <davseve@users.noreply.github.com>
@cursor
cursor Bot force-pushed the cursor/runtime-beta-gating-4d43 branch from 1c18dc2 to 9b2bc6e Compare October 12, 2026 14:55
@cursor cursor Bot changed the title feat: Add runtime beta gating to sync-release-branches action feat: add runtime beta gating to sync-release-branches action Oct 13, 2026
- Remove 2>&1 from check-beta-active.sh call to prevent stderr pollution of JSON output
- Remove misplaced 'local found_beta=false' variable outside function scope
- Add BASH_SOURCE guard to check-beta-active.sh for safe sourcing in tests
- Add git rev-parse checks to detect_plugin_file and get_beta_version to fail open when origin/release/beta ref is missing
- Change ordering: release/stable merges now open main PR first, then check beta gating
- Update resolve_targets_csv to only return main for stable branch
- Add post-loop beta check for stable merges
- Add comprehensive tests for get_latest_stable_release with gh stubbed
- Add tests covering Pro -latest- tags, drafts, nightly/beta/dev/rc filtering, and highest semver selection
- Add tests for check_beta_active true/false decision logic
- Update README cascade table and reword hotfix edge case
- Update action.yml description to reflect new ordering

Co-authored-by: Davids Seveloff <davseve@users.noreply.github.com>

@Ntnelbaba Ntnelbaba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good after the bash rewrite, a few small notes inline.


# Detect plugin file (elementor.php or elementor-pro.php)
detect_plugin_file() {
if ! git rev-parse --verify --quiet origin/release/beta >/dev/null; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: this hard-codes origin/release/beta, but beta-branch is a configurable input. Pass BETA_BRANCH in (it's already in the step's env) and use it here, or the check reads the wrong branch when a caller overrides it.

@@ -467,4 +502,14 @@ for target in "${TARGETS[@]}"; do
sync_to_target "$target"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: beta now syncs after the main loop. Under set -e, if sync_to_target returns non-zero for main (the push failure in create_success_pr comes back as return 1), the script exits and beta is never checked or synced. Before this change beta ran first. Consider sync_to_target "$target" || true, or tracking failures so beta still runs.

get_latest_stable_release() {
local releases tag is_draft is_prerelease

if ! releases="$(gh api repos/"${REPO}"/releases --paginate --jq '.[] | {tag_name, draft, prerelease}' 2>&1)"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: --paginate downloads every release on each stable merge, which for Core is several pages, and 2>&1 mixes gh stderr into the data being parsed. Drop 2>&1 and send stderr to a separate variable or file. You could also stop after the first page or two, since the newest stable releases are on page 1.

get_beta_version() {
local plugin_file content version

if ! git rev-parse --verify --quiet origin/release/beta >/dev/null; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this repeats the git rev-parse check that detect_plugin_file already does on L49.

fi

# Extract X.Y and compare
if major_minor="$(extract_major_minor "$tag")"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: major_minor isn't declared local, unlike major/minor on L171.


if [ "$plugin_file" = "elementor.php" ]; then
# Try header format first: * Version: X.Y.Z
if echo "$content" | grep -m1 '^\s*\*\s*Version:' | sed 's/^\s*\*\s*Version:\s*//' | grep -q '^[0-9]'; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this header pipeline runs twice, once to test and once to print. Capture it once in a variable.

IFS='.' read -r major minor <<< "$major_minor"

# Keep the highest version
if [ "$major" -gt "$latest_major" ] || \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: only a higher X.Y replaces the kept tag, so on a tie the first tag seen is kept. The log can then show 4.3.1 when 4.3.4 exists. The decision is unaffected, but the log is misleading.

- Beta tags (e.g., `4.3.0-beta3`) can be published as non-prerelease in some repos
- The GitHub "Latest" release may be an automated build rather than a hand-published stable

By taking the highest semver tag instead of GitHub's "Latest" release, the action correctly handles cases where hotfix releases on older lines (e.g., `4.3.7`) are published after newer releases (e.g., `4.4.0`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this says it takes 'the highest semver tag', but the code compares only X.Y (see L175). Suggest 'highest X.Y'.

@davseve
davseve merged commit 555ec67 into main Oct 13, 2026
17 checks passed
@davseve
davseve deleted the cursor/runtime-beta-gating-4d43 branch October 13, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants