From deae2efe7ae47137fba1051a8836371b9e03d7b1 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Tue, 6 Oct 2026 14:02:36 +0300 Subject: [PATCH 1/8] ci: support Pro in release-tag-creation action [ED-25747] Replace plugin_file, version_constant, update_readme and update_elementor_tested_up_to with a single required plugin input (core | pro). Pro bumps elementor-pro.php / ELEMENTOR_PRO_VERSION and sets the "Elementor tested up to" header from Core release/; Core keeps bumping elementor.php and readme.txt. Co-authored-by: Cursor --- actions/release-tag-creation/action.yml | 28 ++++- .../elementor-tested-up-to.test.ts | 71 +++++++++++ .../elementor-tested-up-to.ts | 39 ++++++ .../plugin-config.test.ts | 29 +++++ actions/release-tag-creation/plugin-config.ts | 30 +++++ .../update-version-files.ts | 112 ++++++++++++++---- .../src/version-files.test.ts | 78 ++++++++++++ .../src/version-files.ts | 56 +++++++-- 8 files changed, 409 insertions(+), 34 deletions(-) create mode 100644 actions/release-tag-creation/elementor-tested-up-to.test.ts create mode 100644 actions/release-tag-creation/elementor-tested-up-to.ts create mode 100644 actions/release-tag-creation/plugin-config.test.ts create mode 100644 actions/release-tag-creation/plugin-config.ts diff --git a/actions/release-tag-creation/action.yml b/actions/release-tag-creation/action.yml index 59a7754a7..24d9a6d69 100644 --- a/actions/release-tag-creation/action.yml +++ b/actions/release-tag-creation/action.yml @@ -15,6 +15,9 @@ inputs: maintain_email: required: true description: 'Git user.email for the version-bump commit.' + plugin: + required: true + description: 'Plugin being released: "core" (bumps elementor.php and readme.txt) or "pro" (bumps elementor-pro.php and sets "Elementor tested up to" from Core release/).' outputs: channel: @@ -32,6 +35,9 @@ outputs: readme_beta_tag: description: 'The Beta tag value written to readme.txt.' value: ${{ steps.capture-readme.outputs.readme_beta_tag }} + elementor_tested_up_to: + description: 'The "Elementor tested up to" value written to the plugin file. Empty when not updated.' + value: ${{ steps.capture-readme.outputs.elementor_tested_up_to }} runs: using: composite @@ -77,6 +83,7 @@ runs: INPUT_VERSION: ${{ inputs.version }} INPUT_CHANNEL: ${{ steps.handle-version-input.outputs.channel }} INPUT_COMPANION_TAG: ${{ steps.handle-version-input.outputs.companion_tag }} + INPUT_PLUGIN: ${{ inputs.plugin }} ACTION_PATH: ${{ github.action_path }} run: node "$ACTION_PATH/update-version-files.ts" @@ -85,12 +92,18 @@ runs: env: VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry_run }} + PLUGIN_FILE: ${{ steps.capture-readme.outputs.plugin_file }} + UPDATE_README: ${{ steps.capture-readme.outputs.update_readme }} run: | if [[ "${DRY_RUN}" == "true" ]]; then echo "Dry run — skipping commit." exit 0 fi - git add elementor.php readme.txt + FILES_TO_COMMIT=("${PLUGIN_FILE}") + if [[ "${UPDATE_README}" == "true" ]]; then + FILES_TO_COMMIT+=("readme.txt") + fi + git add "${FILES_TO_COMMIT[@]}" if git diff --cached --quiet; then echo "No changes to commit — version files already at ${VERSION}." exit 0 @@ -124,6 +137,9 @@ runs: BRANCH: ${{ steps.handle-version-input.outputs.checkout_branch }} STABLE_TAG: ${{ steps.capture-readme.outputs.readme_stable_tag }} BETA_TAG: ${{ steps.capture-readme.outputs.readme_beta_tag }} + TESTED_UP_TO: ${{ steps.capture-readme.outputs.elementor_tested_up_to }} + PLUGIN_FILE: ${{ steps.capture-readme.outputs.plugin_file }} + UPDATE_README: ${{ steps.capture-readme.outputs.update_readme }} VERSION: ${{ inputs.version }} run: | if [[ "${DRY_RUN}" == "true" ]]; then @@ -148,6 +164,12 @@ runs: echo "| **Channel** | ${CHANNEL} |" echo "| **Branch** | \`${BRANCH}\` |" echo "| **Tag pushed** | ${TAG_STATUS} |" - echo "| **readme.txt stable tag** | \`${STABLE_TAG}\` |" - echo "| **readme.txt beta tag** | \`${BETA_TAG}\` |" + echo "| **Plugin file** | \`${PLUGIN_FILE}\` |" + if [[ "${UPDATE_README}" == "true" ]]; then + echo "| **readme.txt stable tag** | \`${STABLE_TAG}\` |" + echo "| **readme.txt beta tag** | \`${BETA_TAG}\` |" + fi + if [[ -n "${TESTED_UP_TO}" ]]; then + echo "| **Elementor tested up to** | \`${TESTED_UP_TO}\` |" + fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/release-tag-creation/elementor-tested-up-to.test.ts b/actions/release-tag-creation/elementor-tested-up-to.test.ts new file mode 100644 index 000000000..33b844a9f --- /dev/null +++ b/actions/release-tag-creation/elementor-tested-up-to.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + coreReleasePluginFileUrl, + fetchCoreReleaseVersion, + toTestedUpToVersion, +} from './elementor-tested-up-to.ts'; + +const HTTP_OK = 200; +const HTTP_NOT_FOUND = 404; + +function mockFetchResponse(body: string, status = HTTP_OK) { + const fetchMock = vi.fn().mockResolvedValue(new Response(body, { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('toTestedUpToVersion', () => { + it.each([ + ['4.3.0', '4.3.0'], + ['4.4.0-beta2', '4.4.0'], + ['4.10.12', '4.10.12'], + ])('%s → %s', (coreVersion, expected) => { + expect(toTestedUpToVersion(coreVersion)).toBe(expected); + }); + + it('throws on an invalid version', () => { + expect(() => toTestedUpToVersion('4.3')).toThrow('not valid semver'); + }); +}); + +describe('fetchCoreReleaseVersion', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(['stable', 'beta'] as const)( + 'reads Version from Core release/%s', + async (channel) => { + const fetchMock = mockFetchResponse( + [' { + mockFetchResponse('Not Found', HTTP_NOT_FOUND); + + await expect(fetchCoreReleaseVersion('beta')).rejects.toThrow( + 'HTTP 404', + ); + }); + + it('throws when the Version header is missing', async () => { + mockFetchResponse(' { + const url = coreReleasePluginFileUrl(channel); + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch Core plugin file from ${url}: HTTP ${String(response.status)}`, + ); + } + + const version = parseHeaderField(await response.text(), 'Version'); + + if (!version) { + throw new Error(`No "Version:" header found in ${url}`); + } + + return version; +} diff --git a/actions/release-tag-creation/plugin-config.test.ts b/actions/release-tag-creation/plugin-config.test.ts new file mode 100644 index 000000000..f189d9298 --- /dev/null +++ b/actions/release-tag-creation/plugin-config.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { resolvePluginConfig } from './plugin-config.ts'; + +describe('resolvePluginConfig', () => { + it('resolves Core to elementor.php with readme updates', () => { + expect(resolvePluginConfig('core')).toEqual({ + pluginFile: 'elementor.php', + versionConstant: 'ELEMENTOR_VERSION', + updateReadme: true, + updateElementorTestedUpTo: false, + }); + }); + + it('resolves Pro to elementor-pro.php with Elementor tested up to updates', () => { + expect(resolvePluginConfig('pro')).toEqual({ + pluginFile: 'elementor-pro.php', + versionConstant: 'ELEMENTOR_PRO_VERSION', + updateReadme: false, + updateElementorTestedUpTo: true, + }); + }); + + it.each(['', 'Core', 'elementor'])('throws on "%s"', (plugin) => { + expect(() => resolvePluginConfig(plugin)).toThrow( + 'plugin must be "core" or "pro"', + ); + }); +}); diff --git a/actions/release-tag-creation/plugin-config.ts b/actions/release-tag-creation/plugin-config.ts new file mode 100644 index 000000000..0c2b60160 --- /dev/null +++ b/actions/release-tag-creation/plugin-config.ts @@ -0,0 +1,30 @@ +export type Plugin = 'core' | 'pro'; + +export type PluginConfig = { + pluginFile: string; + versionConstant: string; + updateReadme: boolean; + updateElementorTestedUpTo: boolean; +}; + +const PLUGIN_CONFIGS: Record = { + core: { + pluginFile: 'elementor.php', + versionConstant: 'ELEMENTOR_VERSION', + updateReadme: true, + updateElementorTestedUpTo: false, + }, + pro: { + pluginFile: 'elementor-pro.php', + versionConstant: 'ELEMENTOR_PRO_VERSION', + updateReadme: false, + updateElementorTestedUpTo: true, + }, +}; + +export function resolvePluginConfig(plugin: string): PluginConfig { + if (plugin !== 'core' && plugin !== 'pro') { + throw new Error(`plugin must be "core" or "pro", got "${plugin}"`); + } + return PLUGIN_CONFIGS[plugin]; +} diff --git a/actions/release-tag-creation/update-version-files.ts b/actions/release-tag-creation/update-version-files.ts index 2284a0028..8b52a7b1a 100644 --- a/actions/release-tag-creation/update-version-files.ts +++ b/actions/release-tag-creation/update-version-files.ts @@ -1,8 +1,18 @@ import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; import { + patchPhpHeaderField, patchPhpVersion, patchReadmeTxt, } from '@elementor/editor-github-actions-utils'; +import { + fetchCoreReleaseVersion, + toTestedUpToVersion, + type ReleaseChannel, +} from './elementor-tested-up-to.ts'; +import { resolvePluginConfig } from './plugin-config.ts'; + +const README_PATH = 'readme.txt'; +const ELEMENTOR_TESTED_UP_TO_HEADER = 'Elementor tested up to'; export function resolveReadmeTags( version: string, @@ -23,6 +33,14 @@ function getEnv(name: string): string { return value; } +function getChannelEnv(name: string): ReleaseChannel { + const value = getEnv(name); + if (value !== 'stable' && value !== 'beta') { + throw new Error(`${name} must be "stable" or "beta", got "${value}"`); + } + return value; +} + function setOutput(name: string, value: string): void { const outputFile = process.env['GITHUB_OUTPUT']; if (outputFile) { @@ -32,41 +50,87 @@ function setOutput(name: string, value: string): void { } } -export function run(): void { +function updateReadme( + version: string, + channel: ReleaseChannel, + companionTag: string, +): void { + const readmeTags = resolveReadmeTags(version, channel, companionTag); + + const patchedReadme = patchReadmeTxt( + readFileSync(README_PATH, 'utf8'), + readmeTags, + ); + writeFileSync(README_PATH, patchedReadme, 'utf8'); + console.log( + `✅ ${README_PATH} patched — Stable: ${readmeTags.stable}, Beta: ${readmeTags.beta}`, + ); + + setOutput('readme_stable_tag', readmeTags.stable); + setOutput('readme_beta_tag', readmeTags.beta); +} + +async function resolveElementorTestedUpTo( + channel: ReleaseChannel, +): Promise { + const coreVersion = await fetchCoreReleaseVersion(channel); + const testedUpTo = toTestedUpToVersion(coreVersion); + console.log( + `✅ Core release/${channel} is at ${coreVersion} — ${ELEMENTOR_TESTED_UP_TO_HEADER}: ${testedUpTo}`, + ); + + return testedUpTo; +} + +export async function run(): Promise { try { const version = getEnv('INPUT_VERSION'); - const channel = getEnv('INPUT_CHANNEL'); - const companionTag = getEnv('INPUT_COMPANION_TAG'); + const channel = getChannelEnv('INPUT_CHANNEL'); + const plugin = getEnv('INPUT_PLUGIN'); + const { + pluginFile, + versionConstant, + updateReadme: shouldUpdateReadme, + updateElementorTestedUpTo: shouldUpdateTestedUpTo, + } = resolvePluginConfig(plugin); - // ── elementor.php ──────────────────────────────────────────────────────── - const phpPath = 'elementor.php'; - const patchedPhp = patchPhpVersion( - readFileSync(phpPath, 'utf8'), + console.log('INPUT_VERSION', version); + console.log('INPUT_CHANNEL', channel); + console.log('INPUT_PLUGIN', plugin); + console.log('Plugin file', pluginFile); + console.log('Version constant', versionConstant); + console.log('Update readme', shouldUpdateReadme); + console.log('Update Elementor tested up to', shouldUpdateTestedUpTo); + + setOutput('plugin_file', pluginFile); + setOutput('update_readme', String(shouldUpdateReadme)); + + let patchedPhp = patchPhpVersion( + readFileSync(pluginFile, 'utf8'), version, + versionConstant, ); - writeFileSync(phpPath, patchedPhp, 'utf8'); - console.log(`✅ elementor.php patched to ${version}`); - // ── readme.txt ─────────────────────────────────────────────────────────── - const readmePath = 'readme.txt'; - const readmeTags = resolveReadmeTags(version, channel, companionTag); + if (shouldUpdateTestedUpTo) { + const testedUpTo = await resolveElementorTestedUpTo(channel); + patchedPhp = patchPhpHeaderField( + patchedPhp, + ELEMENTOR_TESTED_UP_TO_HEADER, + testedUpTo, + ); + setOutput('elementor_tested_up_to', testedUpTo); + } - const patchedReadme = patchReadmeTxt( - readFileSync(readmePath, 'utf8'), - readmeTags, - ); - writeFileSync(readmePath, patchedReadme, 'utf8'); - console.log( - `✅ readme.txt patched — Stable: ${readmeTags.stable}, Beta: ${readmeTags.beta}`, - ); + writeFileSync(pluginFile, patchedPhp, 'utf8'); + console.log(`✅ ${pluginFile} patched to ${version}`); - // ── outputs ────────────────────────────────────────────────────────────── - setOutput('readme_stable_tag', readmeTags.stable); - setOutput('readme_beta_tag', readmeTags.beta); + if (shouldUpdateReadme) { + updateReadme(version, channel, getEnv('INPUT_COMPANION_TAG')); + } } catch (err) { console.error(`\n::error::${(err as Error).message}\n`); process.exit(1); } } -run(); +void run(); diff --git a/packages/editor-github-actions-utils/src/version-files.test.ts b/packages/editor-github-actions-utils/src/version-files.test.ts index f46f2ed10..fe666ae65 100644 --- a/packages/editor-github-actions-utils/src/version-files.test.ts +++ b/packages/editor-github-actions-utils/src/version-files.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { parseLatestTagFromLsRemote, + patchPhpHeaderField, patchPhpVersion, patchReadmeTxt, } from './version-files'; @@ -42,6 +43,83 @@ describe('patchPhpVersion', () => { expect(result).toContain(' * Plugin Name: Elementor'); expect(result).toContain(' * Description: Drag & drop website builder'); }); + + it('throws when the version constant is missing', () => { + expect(() => { + patchPhpVersion(original, '4.1.0', 'ELEMENTOR_PRO_VERSION'); + }).toThrow('"ELEMENTOR_PRO_VERSION" define not found'); + }); + + it('throws when the Version header is missing', () => { + const noHeader = "define( 'ELEMENTOR_VERSION', '3.10.0' );"; + expect(() => { + patchPhpVersion(noHeader, '4.1.0'); + }).toThrow('"Version:" header not found'); + }); + + describe('with a custom constant (Pro)', () => { + const pro = [ + ' { + const result = patchPhpVersion( + pro, + '4.4.0-beta1', + 'ELEMENTOR_PRO_VERSION', + ); + expect(result).toContain(' * Version: 4.4.0-beta1'); + expect(result).toContain( + "define( 'ELEMENTOR_PRO_VERSION', '4.4.0-beta1' );", + ); + }); + + it('does not touch other constants or headers', () => { + const result = patchPhpVersion( + pro, + '4.4.0-beta1', + 'ELEMENTOR_PRO_VERSION', + ); + expect(result).toContain( + "define( 'ELEMENTOR_PRO_REQUIRED_CORE_VERSION', '4.0' );", + ); + expect(result).toContain(' * Elementor tested up to: 4.3.0'); + }); + }); +}); + +// ─── patchPhpHeaderField ────────────────────────────────────────────────────── + +describe('patchPhpHeaderField', () => { + const original = [ + '/**', + ' * Version: 4.3.0', + ' * Elementor tested up to: 4.3.0', + ' */', + ].join('\n'); + + it('updates the given header field only', () => { + const result = patchPhpHeaderField( + original, + 'Elementor tested up to', + '4.10.2', + ); + expect(result).toContain(' * Elementor tested up to: 4.10.2'); + expect(result).toContain(' * Version: 4.3.0'); + }); + + it('throws when the header field is missing', () => { + expect(() => { + patchPhpHeaderField(original, 'Requires PHP', '8.0'); + }).toThrow('"Requires PHP:" header not found'); + }); }); // ─── patchReadmeTxt ─────────────────────────────────────────────────────────── diff --git a/packages/editor-github-actions-utils/src/version-files.ts b/packages/editor-github-actions-utils/src/version-files.ts index 8ceabfe78..965695edb 100644 --- a/packages/editor-github-actions-utils/src/version-files.ts +++ b/packages/editor-github-actions-utils/src/version-files.ts @@ -1,15 +1,57 @@ import * as semver from 'semver'; -// ─── elementor.php ──────────────────────────────────────────────────────────── +// ─── plugin main file (elementor.php / elementor-pro.php) ──────────────────── + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Replaces a ` * : ` line in the plugin header. + * Throws if the field is missing so a wrong field name can't silently ship. + * + * Example — patchPhpHeaderField(content, 'Elementor tested up to', '4.4.0'): + * * Elementor tested up to: 4.3.0 → * Elementor tested up to: 4.4.0 + */ +export function patchPhpHeaderField( + content: string, + field: string, + value: string, +): string { + const pattern = new RegExp(`^( \\* ${escapeRegExp(field)}: ).*$`, 'm'); + + if (!pattern.test(content)) { + throw new Error( + `patchPhpHeaderField: "${field}:" header not found in plugin file`, + ); + } + + return content.replace(pattern, `$1${value}`); +} /** - * Replaces the two version markers in elementor.php file content: + * Replaces the two version markers in the plugin main file content: * * Version: X.Y.Z - * define( 'ELEMENTOR_VERSION', 'X.Y.Z' ) + * define( '', 'X.Y.Z' ) */ -export function patchPhpVersion(content: string, version: string): string { - return content - .replace(/( \* Version: ).*/, `$1${version}`) - .replace(/(define\( 'ELEMENTOR_VERSION', ')[^']*'/, `$1${version}'`); +export function patchPhpVersion( + content: string, + version: string, + constant = 'ELEMENTOR_VERSION', +): string { + const definePattern = new RegExp( + `(define\\( '${escapeRegExp(constant)}', ')[^']*'`, + ); + + if (!definePattern.test(content)) { + throw new Error( + `patchPhpVersion: "${constant}" define not found in plugin file`, + ); + } + + return patchPhpHeaderField(content, 'Version', version).replace( + definePattern, + `$1${version}'`, + ); } // ─── readme.txt ─────────────────────────────────────────────────────────────── From 42cc408af1c23c7d956d1df2578c58a3bb9a02fe Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Tue, 6 Oct 2026 14:51:57 +0300 Subject: [PATCH 2/8] Sast issues --- packages/editor-github-actions-utils/src/version-files.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/editor-github-actions-utils/src/version-files.ts b/packages/editor-github-actions-utils/src/version-files.ts index 965695edb..286ca570b 100644 --- a/packages/editor-github-actions-utils/src/version-files.ts +++ b/packages/editor-github-actions-utils/src/version-files.ts @@ -33,10 +33,12 @@ export function patchPhpHeaderField( * * Version: X.Y.Z * define( '', 'X.Y.Z' ) */ +export type VersionConstant = 'ELEMENTOR_VERSION' | 'ELEMENTOR_PRO_VERSION'; + export function patchPhpVersion( content: string, version: string, - constant = 'ELEMENTOR_VERSION', + constant: VersionConstant = 'ELEMENTOR_VERSION', ): string { const definePattern = new RegExp( `(define\\( '${escapeRegExp(constant)}', ')[^']*'`, From 60c08cddaa8596efee5036d389ca5edb6c4e4ea6 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Tue, 6 Oct 2026 14:52:56 +0300 Subject: [PATCH 3/8] ci: restrict patchPhpVersion constant to known version constants [ED-25747] Co-authored-by: Cursor --- actions/release-tag-creation/plugin-config.ts | 4 +++- packages/editor-github-actions-utils/src/version-files.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/release-tag-creation/plugin-config.ts b/actions/release-tag-creation/plugin-config.ts index 0c2b60160..75bbdb262 100644 --- a/actions/release-tag-creation/plugin-config.ts +++ b/actions/release-tag-creation/plugin-config.ts @@ -1,8 +1,10 @@ +import type { VersionConstant } from '@elementor/editor-github-actions-utils'; + export type Plugin = 'core' | 'pro'; export type PluginConfig = { pluginFile: string; - versionConstant: string; + versionConstant: VersionConstant; updateReadme: boolean; updateElementorTestedUpTo: boolean; }; diff --git a/packages/editor-github-actions-utils/src/version-files.ts b/packages/editor-github-actions-utils/src/version-files.ts index 286ca570b..47aa542d5 100644 --- a/packages/editor-github-actions-utils/src/version-files.ts +++ b/packages/editor-github-actions-utils/src/version-files.ts @@ -28,13 +28,13 @@ export function patchPhpHeaderField( return content.replace(pattern, `$1${value}`); } +export type VersionConstant = 'ELEMENTOR_VERSION' | 'ELEMENTOR_PRO_VERSION'; + /** * Replaces the two version markers in the plugin main file content: * * Version: X.Y.Z * define( '', 'X.Y.Z' ) */ -export type VersionConstant = 'ELEMENTOR_VERSION' | 'ELEMENTOR_PRO_VERSION'; - export function patchPhpVersion( content: string, version: string, From dfbe0df85c4f74ce4becf5688900ba3816ec0501 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Tue, 6 Oct 2026 14:55:44 +0300 Subject: [PATCH 4/8] ci: default release-tag-creation plugin input to core [ED-25747] Co-authored-by: Cursor --- actions/release-tag-creation/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/release-tag-creation/action.yml b/actions/release-tag-creation/action.yml index 24d9a6d69..316574799 100644 --- a/actions/release-tag-creation/action.yml +++ b/actions/release-tag-creation/action.yml @@ -16,7 +16,8 @@ inputs: required: true description: 'Git user.email for the version-bump commit.' plugin: - required: true + required: false + default: 'core' description: 'Plugin being released: "core" (bumps elementor.php and readme.txt) or "pro" (bumps elementor-pro.php and sets "Elementor tested up to" from Core release/).' outputs: From 509703d74d59068ab06ce1ba56c2aff9301ce7a4 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Tue, 6 Oct 2026 16:19:08 +0300 Subject: [PATCH 5/8] ci: add check-release-owner action [ED-25747] Co-authored-by: Cursor --- actions/check-release-owner/README.md | 22 +++++++++++++++ actions/check-release-owner/action.yml | 24 ++++++++++++++++ .../check-release-owner.sh | 28 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 actions/check-release-owner/README.md create mode 100644 actions/check-release-owner/action.yml create mode 100755 actions/check-release-owner/check-release-owner.sh diff --git a/actions/check-release-owner/README.md b/actions/check-release-owner/README.md new file mode 100644 index 000000000..f8ef738ce --- /dev/null +++ b/actions/check-release-owner/README.md @@ -0,0 +1,22 @@ +# Check release owner + +Fails the job unless the user who triggered the run (`github.triggering_actor`) is a required reviewer of an environment, `release-owners` by default. Core and Pro use it so the same people who approve a release are the only ones who can start one. + +Only user reviewers are checked. Team reviewers are skipped with a warning, because the workflow token cannot read team membership. + +## Usage + +```yaml +permissions: + actions: read + +steps: + - uses: elementor/elementor-editor-github-actions/actions/check-release-owner@main +``` + +## Inputs + +| Input | Default | Description | +| -------------- | --------------------- | ----------------------------------------------- | +| `environment` | `release-owners` | Environment whose required reviewers may start. | +| `github-token` | `${{ github.token }}` | Token that can read the environment. | diff --git a/actions/check-release-owner/action.yml b/actions/check-release-owner/action.yml new file mode 100644 index 000000000..99bd9080e --- /dev/null +++ b/actions/check-release-owner/action.yml @@ -0,0 +1,24 @@ +name: Check release owner +description: Fails unless the user who triggered the run is a required reviewer of the given environment. + +inputs: + environment: + description: 'Environment whose required reviewers may start a release.' + required: false + default: 'release-owners' + github-token: + description: 'Token that can read the environment. The workflow token needs actions: read.' + required: false + default: ${{ github.token }} + +runs: + using: 'composite' + steps: + - name: Check release owner + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + ENVIRONMENT: ${{ inputs.environment }} + REPOSITORY: ${{ github.repository }} + ACTOR: ${{ github.triggering_actor }} + run: bash "${GITHUB_ACTION_PATH}/check-release-owner.sh" diff --git a/actions/check-release-owner/check-release-owner.sh b/actions/check-release-owner/check-release-owner.sh new file mode 100755 index 000000000..a7c741188 --- /dev/null +++ b/actions/check-release-owner/check-release-owner.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +REVIEWERS_QUERY='.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]?' + +if ! ENVIRONMENT_JSON=$(gh api "repos/${REPOSITORY}/environments/${ENVIRONMENT}"); then + echo "::error::Could not read environment '${ENVIRONMENT}' in ${REPOSITORY}. The job needs actions: read." + exit 1 +fi + +USER_REVIEWERS=$(jq -r "${REVIEWERS_QUERY} | select(.type == \"User\") | .reviewer.login" <<< "${ENVIRONMENT_JSON}") +TEAM_REVIEWERS=$(jq -r "${REVIEWERS_QUERY} | select(.type == \"Team\") | .reviewer.slug" <<< "${ENVIRONMENT_JSON}") + +if [[ -n "${TEAM_REVIEWERS}" ]]; then + echo "::warning::Team reviewers on '${ENVIRONMENT}' are not checked (the workflow token cannot read team membership): ${TEAM_REVIEWERS//$'\n'/, }" +fi + +if [[ -z "${USER_REVIEWERS}" ]]; then + echo "::error::Environment '${ENVIRONMENT}' has no user reviewers, so nobody can start a release." + exit 1 +fi + +if ! grep -Fxiq -- "${ACTOR}" <<< "${USER_REVIEWERS}"; then + echo "::error::${ACTOR} is not a reviewer of the '${ENVIRONMENT}' environment and cannot start a release." + exit 1 +fi + +echo "✅ ${ACTOR} is a reviewer of the '${ENVIRONMENT}' environment." From df341e3285d1f9c2c8ecf9c96772b4241f3ac8c4 Mon Sep 17 00:00:00 2001 From: David Seveloff Date: Wed, 7 Oct 2026 15:33:28 +0300 Subject: [PATCH 6/8] ci: remove check-release-owner action [ED-25747] Co-authored-by: Cursor --- actions/check-release-owner/README.md | 22 --------------- actions/check-release-owner/action.yml | 24 ---------------- .../check-release-owner.sh | 28 ------------------- 3 files changed, 74 deletions(-) delete mode 100644 actions/check-release-owner/README.md delete mode 100644 actions/check-release-owner/action.yml delete mode 100755 actions/check-release-owner/check-release-owner.sh diff --git a/actions/check-release-owner/README.md b/actions/check-release-owner/README.md deleted file mode 100644 index f8ef738ce..000000000 --- a/actions/check-release-owner/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Check release owner - -Fails the job unless the user who triggered the run (`github.triggering_actor`) is a required reviewer of an environment, `release-owners` by default. Core and Pro use it so the same people who approve a release are the only ones who can start one. - -Only user reviewers are checked. Team reviewers are skipped with a warning, because the workflow token cannot read team membership. - -## Usage - -```yaml -permissions: - actions: read - -steps: - - uses: elementor/elementor-editor-github-actions/actions/check-release-owner@main -``` - -## Inputs - -| Input | Default | Description | -| -------------- | --------------------- | ----------------------------------------------- | -| `environment` | `release-owners` | Environment whose required reviewers may start. | -| `github-token` | `${{ github.token }}` | Token that can read the environment. | diff --git a/actions/check-release-owner/action.yml b/actions/check-release-owner/action.yml deleted file mode 100644 index 99bd9080e..000000000 --- a/actions/check-release-owner/action.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Check release owner -description: Fails unless the user who triggered the run is a required reviewer of the given environment. - -inputs: - environment: - description: 'Environment whose required reviewers may start a release.' - required: false - default: 'release-owners' - github-token: - description: 'Token that can read the environment. The workflow token needs actions: read.' - required: false - default: ${{ github.token }} - -runs: - using: 'composite' - steps: - - name: Check release owner - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - ENVIRONMENT: ${{ inputs.environment }} - REPOSITORY: ${{ github.repository }} - ACTOR: ${{ github.triggering_actor }} - run: bash "${GITHUB_ACTION_PATH}/check-release-owner.sh" diff --git a/actions/check-release-owner/check-release-owner.sh b/actions/check-release-owner/check-release-owner.sh deleted file mode 100755 index a7c741188..000000000 --- a/actions/check-release-owner/check-release-owner.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -set -euo pipefail - -REVIEWERS_QUERY='.protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]?' - -if ! ENVIRONMENT_JSON=$(gh api "repos/${REPOSITORY}/environments/${ENVIRONMENT}"); then - echo "::error::Could not read environment '${ENVIRONMENT}' in ${REPOSITORY}. The job needs actions: read." - exit 1 -fi - -USER_REVIEWERS=$(jq -r "${REVIEWERS_QUERY} | select(.type == \"User\") | .reviewer.login" <<< "${ENVIRONMENT_JSON}") -TEAM_REVIEWERS=$(jq -r "${REVIEWERS_QUERY} | select(.type == \"Team\") | .reviewer.slug" <<< "${ENVIRONMENT_JSON}") - -if [[ -n "${TEAM_REVIEWERS}" ]]; then - echo "::warning::Team reviewers on '${ENVIRONMENT}' are not checked (the workflow token cannot read team membership): ${TEAM_REVIEWERS//$'\n'/, }" -fi - -if [[ -z "${USER_REVIEWERS}" ]]; then - echo "::error::Environment '${ENVIRONMENT}' has no user reviewers, so nobody can start a release." - exit 1 -fi - -if ! grep -Fxiq -- "${ACTOR}" <<< "${USER_REVIEWERS}"; then - echo "::error::${ACTOR} is not a reviewer of the '${ENVIRONMENT}' environment and cannot start a release." - exit 1 -fi - -echo "✅ ${ACTOR} is a reviewer of the '${ENVIRONMENT}' environment." From 1892fd9406d7ccaa96b5866b16d9a4f3b7924722 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 08:29:18 +0000 Subject: [PATCH 7/8] ci: use GitHub Contents API for fetchCoreReleaseVersion, move setOutput after file writes, add tests [ED-25747] Co-authored-by: Davids Seveloff --- actions/release-tag-creation/action.yml | 5 + .../elementor-tested-up-to.test.ts | 35 ++- .../elementor-tested-up-to.ts | 20 +- .../update-version-files.test.ts | 260 ++++++++++++++++++ .../update-version-files.ts | 19 +- 5 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 actions/release-tag-creation/update-version-files.test.ts diff --git a/actions/release-tag-creation/action.yml b/actions/release-tag-creation/action.yml index 316574799..45432f8d3 100644 --- a/actions/release-tag-creation/action.yml +++ b/actions/release-tag-creation/action.yml @@ -19,6 +19,10 @@ inputs: required: false default: 'core' description: 'Plugin being released: "core" (bumps elementor.php and readme.txt) or "pro" (bumps elementor-pro.php and sets "Elementor tested up to" from Core release/).' + github_token: + required: false + default: ${{ github.token }} + description: 'GitHub token for fetching Core release version (optional, improves rate limits and avoids CDN cache).' outputs: channel: @@ -85,6 +89,7 @@ runs: INPUT_CHANNEL: ${{ steps.handle-version-input.outputs.channel }} INPUT_COMPANION_TAG: ${{ steps.handle-version-input.outputs.companion_tag }} INPUT_PLUGIN: ${{ inputs.plugin }} + INPUT_GITHUB_TOKEN: ${{ inputs.github_token }} ACTION_PATH: ${{ github.action_path }} run: node "$ACTION_PATH/update-version-files.ts" diff --git a/actions/release-tag-creation/elementor-tested-up-to.test.ts b/actions/release-tag-creation/elementor-tested-up-to.test.ts index 33b844a9f..509d8d469 100644 --- a/actions/release-tag-creation/elementor-tested-up-to.test.ts +++ b/actions/release-tag-creation/elementor-tested-up-to.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - coreReleasePluginFileUrl, fetchCoreReleaseVersion, toTestedUpToVersion, } from './elementor-tested-up-to.ts'; @@ -35,7 +34,7 @@ describe('fetchCoreReleaseVersion', () => { }); it.each(['stable', 'beta'] as const)( - 'reads Version from Core release/%s', + 'reads Version from Core release/%s via GitHub Contents API', async (channel) => { const fetchMock = mockFetchResponse( [' { '4.4.0-beta2', ); expect(fetchMock).toHaveBeenCalledWith( - coreReleasePluginFileUrl(channel), - ); - expect(coreReleasePluginFileUrl(channel)).toContain( - `/refs/heads/release/${channel}/elementor.php`, + `https://api.github.com/repos/elementor/elementor/contents/elementor.php?ref=release/${channel}`, + { + headers: { + Accept: 'application/vnd.github.raw', + 'User-Agent': 'elementor-editor-github-actions', + }, + }, ); }, ); + it('includes Authorization header when token is provided', async () => { + const fetchMock = mockFetchResponse( + [' { mockFetchResponse('Not Found', HTTP_NOT_FOUND); diff --git a/actions/release-tag-creation/elementor-tested-up-to.ts b/actions/release-tag-creation/elementor-tested-up-to.ts index ebf3cfb0c..942250d53 100644 --- a/actions/release-tag-creation/elementor-tested-up-to.ts +++ b/actions/release-tag-creation/elementor-tested-up-to.ts @@ -3,10 +3,6 @@ import { parseHeaderField } from '@elementor/editor-github-actions-utils'; export type ReleaseChannel = 'stable' | 'beta'; -export function coreReleasePluginFileUrl(channel: ReleaseChannel): string { - return `https://raw.githubusercontent.com/elementor/elementor/refs/heads/release/${channel}/elementor.php`; -} - export function toTestedUpToVersion(coreVersion: string): string { const parsed = semver.parse(coreVersion); @@ -19,9 +15,21 @@ export function toTestedUpToVersion(coreVersion: string): string { export async function fetchCoreReleaseVersion( channel: ReleaseChannel, + token?: string, ): Promise { - const url = coreReleasePluginFileUrl(channel); - const response = await fetch(url); + const ref = `release/${channel}`; + const url = `https://api.github.com/repos/elementor/elementor/contents/elementor.php?ref=${ref}`; + + const headers: HeadersInit = { + Accept: 'application/vnd.github.raw', + 'User-Agent': 'elementor-editor-github-actions', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { headers }); if (!response.ok) { throw new Error( diff --git a/actions/release-tag-creation/update-version-files.test.ts b/actions/release-tag-creation/update-version-files.test.ts new file mode 100644 index 000000000..f96634215 --- /dev/null +++ b/actions/release-tag-creation/update-version-files.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const HTTP_OK = 200; +const HTTP_NOT_FOUND = 404; + +let testDir: string; +let originalCwd: string; +let originalEnv: NodeJS.ProcessEnv; + +function mockFetchResponse(body: string, status = HTTP_OK) { + const fetchMock = vi.fn().mockResolvedValue(new Response(body, { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +beforeEach(() => { + vi.resetModules(); + testDir = mkdtempSync(join(tmpdir(), 'update-version-files-test-')); + originalCwd = process.cwd(); + originalEnv = { ...process.env }; + process.env = { ...originalEnv, NODE_ENV: 'test' }; + process.chdir(testDir); +}); + +afterEach(() => { + process.chdir(originalCwd); + process.env = originalEnv; + rmSync(testDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); +}); + +describe('run', () => { + it('patches plugin file for core and writes outputs after file write succeeds', async () => { + const pluginContent = [ + ' {}) as never); + + const { run } = await import('./update-version-files.ts'); + await run(); + + expect(mockExit).not.toHaveBeenCalled(); + + const patchedPhp = readFileSync(pluginPath, 'utf8'); + expect(patchedPhp).toContain("define( 'ELEMENTOR_VERSION', '4.4.0' );"); + expect(patchedPhp).toContain(' * Version: 4.4.0'); + + const patchedReadme = readFileSync(readmePath, 'utf8'); + expect(patchedReadme).toContain('Stable tag: 4.4.0'); + + const output = readFileSync(outputFile, 'utf8'); + expect(output).toContain('plugin_file=elementor.php\n'); + expect(output).toContain('update_readme=true\n'); + expect(output).toContain('readme_stable_tag=4.4.0\n'); + expect(output).toContain('readme_beta_tag=4.4.0-beta1\n'); + + mockExit.mockRestore(); + }); + + it('patches plugin file for pro with tested-up-to and writes outputs after file write succeeds', async () => { + const pluginContent = [ + ' {}) as never); + + const { run } = await import('./update-version-files.ts'); + await run(); + + expect(mockExit).not.toHaveBeenCalled(); + + const patchedPhp = readFileSync(pluginFilePath, 'utf8'); + expect(patchedPhp).toContain( + "define( 'ELEMENTOR_PRO_VERSION', '4.4.0' );", + ); + expect(patchedPhp).toContain(' * Version: 4.4.0'); + expect(patchedPhp).toContain(' * Elementor tested up to: 4.4.1'); + + const output = readFileSync(outputFile, 'utf8'); + expect(output).toContain('plugin_file=elementor-pro.php\n'); + expect(output).toContain('update_readme=false\n'); + expect(output).toContain('elementor_tested_up_to=4.4.1\n'); + + mockExit.mockRestore(); + }); + + it('exits with error when fetch fails and does not write outputs', async () => { + const pluginContent = [ + ' {}) as never); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { run } = await import('./update-version-files.ts'); + await run(); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('::error::'), + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('HTTP 404'), + ); + expect(mockExit).toHaveBeenCalledWith(1); + + const patchedPhp = readFileSync(pluginFilePath, 'utf8'); + expect(patchedPhp).toContain( + "define( 'ELEMENTOR_PRO_VERSION', '4.3.0' );", + ); + expect(patchedPhp).not.toContain( + "define( 'ELEMENTOR_PRO_VERSION', '4.4.0' );", + ); + + const outputExists = (() => { + try { + readFileSync(outputFile, 'utf8'); + return true; + } catch { + return false; + } + })(); + + if (outputExists) { + const output = readFileSync(outputFile, 'utf8'); + expect(output).not.toContain('plugin_file='); + expect(output).not.toContain('update_readme='); + } + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('exits with error when patching fails and does not write outputs', async () => { + const pluginContent = [ + ' {}) as never); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { run } = await import('./update-version-files.ts'); + await run(); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('::error::'), + ); + expect(mockExit).toHaveBeenCalledWith(1); + + const patchedPhp = readFileSync(pluginFilePath, 'utf8'); + expect(patchedPhp).toContain("define( 'UNKNOWN_CONSTANT', '4.3.0' );"); + + const outputExists = (() => { + try { + readFileSync(outputFile, 'utf8'); + return true; + } catch { + return false; + } + })(); + + if (outputExists) { + const output = readFileSync(outputFile, 'utf8'); + expect(output).not.toContain('plugin_file='); + expect(output).not.toContain('update_readme='); + } + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); +}); diff --git a/actions/release-tag-creation/update-version-files.ts b/actions/release-tag-creation/update-version-files.ts index 8b52a7b1a..daaf72220 100644 --- a/actions/release-tag-creation/update-version-files.ts +++ b/actions/release-tag-creation/update-version-files.ts @@ -72,8 +72,9 @@ function updateReadme( async function resolveElementorTestedUpTo( channel: ReleaseChannel, + token?: string, ): Promise { - const coreVersion = await fetchCoreReleaseVersion(channel); + const coreVersion = await fetchCoreReleaseVersion(channel, token); const testedUpTo = toTestedUpToVersion(coreVersion); console.log( `✅ Core release/${channel} is at ${coreVersion} — ${ELEMENTOR_TESTED_UP_TO_HEADER}: ${testedUpTo}`, @@ -87,6 +88,7 @@ export async function run(): Promise { const version = getEnv('INPUT_VERSION'); const channel = getChannelEnv('INPUT_CHANNEL'); const plugin = getEnv('INPUT_PLUGIN'); + const githubToken = process.env['INPUT_GITHUB_TOKEN']; const { pluginFile, versionConstant, @@ -102,9 +104,6 @@ export async function run(): Promise { console.log('Update readme', shouldUpdateReadme); console.log('Update Elementor tested up to', shouldUpdateTestedUpTo); - setOutput('plugin_file', pluginFile); - setOutput('update_readme', String(shouldUpdateReadme)); - let patchedPhp = patchPhpVersion( readFileSync(pluginFile, 'utf8'), version, @@ -112,7 +111,10 @@ export async function run(): Promise { ); if (shouldUpdateTestedUpTo) { - const testedUpTo = await resolveElementorTestedUpTo(channel); + const testedUpTo = await resolveElementorTestedUpTo( + channel, + githubToken, + ); patchedPhp = patchPhpHeaderField( patchedPhp, ELEMENTOR_TESTED_UP_TO_HEADER, @@ -124,6 +126,9 @@ export async function run(): Promise { writeFileSync(pluginFile, patchedPhp, 'utf8'); console.log(`✅ ${pluginFile} patched to ${version}`); + setOutput('plugin_file', pluginFile); + setOutput('update_readme', String(shouldUpdateReadme)); + if (shouldUpdateReadme) { updateReadme(version, channel, getEnv('INPUT_COMPANION_TAG')); } @@ -133,4 +138,6 @@ export async function run(): Promise { } } -void run(); +if (process.env['NODE_ENV'] !== 'test') { + void run(); +} From 73fe456048afae597fa0f98e50870913d9bee365 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 08:31:39 +0000 Subject: [PATCH 8/8] ci: split update-version-files entrypoint to prevent NODE_ENV=test production risk [ED-25747] Co-authored-by: Davids Seveloff --- actions/release-tag-creation/action.yml | 2 +- actions/release-tag-creation/run-update-version-files.ts | 3 +++ actions/release-tag-creation/update-version-files.test.ts | 2 +- actions/release-tag-creation/update-version-files.ts | 4 ---- 4 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 actions/release-tag-creation/run-update-version-files.ts diff --git a/actions/release-tag-creation/action.yml b/actions/release-tag-creation/action.yml index 45432f8d3..84c13c3f2 100644 --- a/actions/release-tag-creation/action.yml +++ b/actions/release-tag-creation/action.yml @@ -91,7 +91,7 @@ runs: INPUT_PLUGIN: ${{ inputs.plugin }} INPUT_GITHUB_TOKEN: ${{ inputs.github_token }} ACTION_PATH: ${{ github.action_path }} - run: node "$ACTION_PATH/update-version-files.ts" + run: node "$ACTION_PATH/run-update-version-files.ts" - name: Commit version bump shell: bash diff --git a/actions/release-tag-creation/run-update-version-files.ts b/actions/release-tag-creation/run-update-version-files.ts new file mode 100644 index 000000000..1b4fd9a29 --- /dev/null +++ b/actions/release-tag-creation/run-update-version-files.ts @@ -0,0 +1,3 @@ +import { run } from './update-version-files.ts'; + +void run(); diff --git a/actions/release-tag-creation/update-version-files.test.ts b/actions/release-tag-creation/update-version-files.test.ts index f96634215..7544ed7ac 100644 --- a/actions/release-tag-creation/update-version-files.test.ts +++ b/actions/release-tag-creation/update-version-files.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), 'update-version-files-test-')); originalCwd = process.cwd(); originalEnv = { ...process.env }; - process.env = { ...originalEnv, NODE_ENV: 'test' }; + process.env = { ...originalEnv }; process.chdir(testDir); }); diff --git a/actions/release-tag-creation/update-version-files.ts b/actions/release-tag-creation/update-version-files.ts index daaf72220..ec46ff502 100644 --- a/actions/release-tag-creation/update-version-files.ts +++ b/actions/release-tag-creation/update-version-files.ts @@ -137,7 +137,3 @@ export async function run(): Promise { process.exit(1); } } - -if (process.env['NODE_ENV'] !== 'test') { - void run(); -}