diff --git a/actions/release-tag-creation/action.yml b/actions/release-tag-creation/action.yml index 59a7754a7..84c13c3f2 100644 --- a/actions/release-tag-creation/action.yml +++ b/actions/release-tag-creation/action.yml @@ -15,6 +15,14 @@ inputs: maintain_email: required: true description: 'Git user.email for the version-bump commit.' + plugin: + 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: @@ -32,6 +40,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,20 +88,28 @@ 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 }} + 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 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 +143,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 +170,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..509d8d469 --- /dev/null +++ b/actions/release-tag-creation/elementor-tested-up-to.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + 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 via GitHub Contents API', + async (channel) => { + const fetchMock = mockFetchResponse( + [' { + 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 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( + `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..75bbdb262 --- /dev/null +++ b/actions/release-tag-creation/plugin-config.ts @@ -0,0 +1,32 @@ +import type { VersionConstant } from '@elementor/editor-github-actions-utils'; + +export type Plugin = 'core' | 'pro'; + +export type PluginConfig = { + pluginFile: string; + versionConstant: VersionConstant; + 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/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 new file mode 100644 index 000000000..7544ed7ac --- /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 }; + 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 2284a0028..ec46ff502 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,90 @@ 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, + token?: string, +): Promise { + const coreVersion = await fetchCoreReleaseVersion(channel, token); + 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 githubToken = process.env['INPUT_GITHUB_TOKEN']; + 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); + + 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, + githubToken, + ); + 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); + setOutput('plugin_file', pluginFile); + setOutput('update_readme', String(shouldUpdateReadme)); + + if (shouldUpdateReadme) { + updateReadme(version, channel, getEnv('INPUT_COMPANION_TAG')); + } } catch (err) { console.error(`\n::error::${(err as Error).message}\n`); process.exit(1); } } - -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..47aa542d5 100644 --- a/packages/editor-github-actions-utils/src/version-files.ts +++ b/packages/editor-github-actions-utils/src/version-files.ts @@ -1,15 +1,59 @@ 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 the two version markers in elementor.php file content: + * 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}`); +} + +export type VersionConstant = 'ELEMENTOR_VERSION' | 'ELEMENTOR_PRO_VERSION'; + +/** + * 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: VersionConstant = '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 ───────────────────────────────────────────────────────────────