Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
13 changes: 13 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@
- name: Run Lint
run: npm run lint

test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4

- name: Install Dependencies
uses: ./.github/actions/install-deps

- name: Run Tests
run: npm test

require-build:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Require Build in PR
runs-on: ubuntu-latest
steps:
Expand Down
95 changes: 95 additions & 0 deletions actions/release-tag-creation/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Release Tag Creation
description: Validates the version, bumps version files, creates and pushes a git release tag.

inputs:
version:
required: true
description: 'Version to release (e.g. 4.1.0-beta1 or 4.0.11).'
dry_run:
required: false
default: 'true'
description: 'Dry run — validate everything but do not push commits or create tag.'
maintain_username:
required: true
description: 'Git user.name for the version-bump commit.'
maintain_email:
required: true
description: 'Git user.email for the version-bump commit.'

outputs:
channel:
description: 'The release channel: stable or beta.'
value: ${{ steps.handle-version-input.outputs.channel }}
checkout_branch:
description: 'The branch the version bump was applied to.'
value: ${{ steps.handle-version-input.outputs.checkout_branch }}
tag_pushed:
description: 'Whether the tag was pushed (true/false).'
value: ${{ steps.create-release-tag.outputs.tag_pushed }}
readme_stable_tag:
description: 'The Stable tag value written to readme.txt.'
value: ${{ steps.capture-readme.outputs.readme_stable_tag }}
readme_beta_tag:
description: 'The Beta tag value written to readme.txt.'
value: ${{ steps.capture-readme.outputs.readme_beta_tag }}

runs:
using: composite
steps:
- name: Handle version input
id: handle-version-input
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: node "${{ github.action_path }}/dist/index.js"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ github.action_path }
, which may be controlled by an external user.

- name: Switch to release branch
shell: bash
run: |
git checkout ${{ steps.handle-version-input.outputs.checkout_branch }}

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ steps.handle-version-input.outputs.checkout_branch }
, which may be controlled by an external user.
git checkout ${{ github.sha }} -- .github/

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ github.sha }
, which may be controlled by an external user.

- name: Config git user
shell: bash
run: |
git config --global user.name "${{ inputs.maintain_username }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.maintain_username }
, which may be controlled by an external user.
git config --global user.email "${{ inputs.maintain_email }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.maintain_email }
, which may be controlled by an external user.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

- name: Update version files
id: capture-readme
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_CHANNEL: ${{ steps.handle-version-input.outputs.channel }}
run: node "${{ github.action_path }}/dist/update-version-files.js"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ github.action_path }
, which may be controlled by an external user.

- name: Commit version bump
shell: bash
run: |
VERSION="${{ inputs.version }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.version }
, which may be controlled by an external user.
DRY_RUN="${{ inputs.dry_run }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.dry_run }
, which may be controlled by an external user.
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry run — skipping commit."
exit 0
fi
git add elementor.php readme.txt
if git diff --cached --quiet; then
echo "No changes to commit — version files already at ${VERSION}."
exit 0
fi
git commit -m "Bump version to ${VERSION}"
echo "Committed version bump to ${VERSION}."

- name: Create release tag
id: create-release-tag
shell: bash
run: |
if [[ "${{ inputs.dry_run }}" == "true" ]]; then

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.dry_run }
, which may be controlled by an external user.
echo "Dry run — skipping tag push."
echo "tag_pushed=false" >> "$GITHUB_OUTPUT"
else
git tag -a "${{ inputs.version }}" -m "Release version ${{ inputs.version }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.version }
, which may be controlled by an external user.

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.version }
, which may be controlled by an external user.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
git push origin "v${{ inputs.version }}"

Check warning

Code scanning / CodeQL

Code injection Medium

Potential code injection in
${ inputs.version }
, which may be controlled by an external user.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
echo "Release tag v${{ inputs.version }} pushed successfully (branch unchanged)."
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
echo "tag_pushed=true" >> "$GITHUB_OUTPUT"
fi
6 changes: 6 additions & 0 deletions actions/release-tag-creation/dist/index.js

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions actions/release-tag-creation/dist/update-version-files.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions actions/release-tag-creation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { run } from './main';

run();
118 changes: 118 additions & 0 deletions actions/release-tag-creation/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { appendFileSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
deriveBranch,
extractChannel,
getVersion,
setOutput,
validateFormat,
} from './main';

vi.mock('node:fs');

const mockAppendFileSync = vi.mocked(appendFileSync);

// ─── getVersion ───────────────────────────────────────────────────────────────

describe('getVersion', () => {
afterEach(() => {
delete process.env['INPUT_VERSION'];
});

it('returns the trimmed version from INPUT_VERSION', () => {
process.env['INPUT_VERSION'] = ' 3.11.0 ';
expect(getVersion()).toBe('3.11.0');
});

it('throws when INPUT_VERSION is not set', () => {
delete process.env['INPUT_VERSION'];
expect(() => getVersion()).toThrow('No version provided');
});

it('throws when INPUT_VERSION is an empty string', () => {
process.env['INPUT_VERSION'] = ' ';
expect(() => getVersion()).toThrow('No version provided');
});
});

// ─── setOutput ────────────────────────────────────────────────────────────────

describe('setOutput', () => {
afterEach(() => {
delete process.env['GITHUB_OUTPUT'];
vi.clearAllMocks();
});

it('writes to GITHUB_OUTPUT file when env var is set', () => {
process.env['GITHUB_OUTPUT'] = '/tmp/output';
setOutput('channel', 'stable');
expect(mockAppendFileSync).toHaveBeenCalledWith('/tmp/output', 'channel=stable\n');
});

it('logs to console when GITHUB_OUTPUT is not set', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
setOutput('channel', 'beta');
expect(consoleSpy).toHaveBeenCalledWith('OUTPUT channel=beta');
consoleSpy.mockRestore();
});
});

// ─── validateFormat ───────────────────────────────────────────────────────────

describe('validateFormat', () => {
it.each([
['4.1.0'],
['5.20.15'],
['4.1.0-beta1'],
['5.20.0-beta3'],
['10.0.0-beta10'],
])('accepts valid version %s', (version) => {
expect(() => { validateFormat(version); }).not.toThrow();
});

it.each([
['v4.1.0'],
['4.1'],
['4.1.0-rc1'],
['4.1.0-beta.1'],
['4.1.0-beta 1'],
['not-a-version'],
[''],
])('rejects invalid version %s', (version) => {
expect(() => { validateFormat(version); }).toThrow('not in the correct format');
});
});

// ─── extractChannel ───────────────────────────────────────────────────────────

describe('extractChannel', () => {
it('returns "stable" for a plain release version', () => {
expect(extractChannel('4.1.0')).toBe('stable');
});

it('returns "beta" for a beta pre-release', () => {
expect(extractChannel('4.1.0-beta1')).toBe('beta');
});

it('returns "beta" for a higher beta number', () => {
expect(extractChannel('5.20.0-beta3')).toBe('beta');
});

it('throws for an unrecognised pre-release identifier', () => {
expect(() => extractChannel('4.1.0-rc1')).toThrow('Could not determine channel');
});
});

// ─── deriveBranch ─────────────────────────────────────────────────────────────

describe('deriveBranch', () => {
it.each([
['4.1.0', '4.01'],
['5.20.15', '5.20'],
['10.0.0', '10.00'],
['4.1.0-beta1', '4.01'],
])('derives branch %s → %s', (version, expected) => {
expect(deriveBranch(version)).toBe(expected);
});
});
96 changes: 96 additions & 0 deletions actions/release-tag-creation/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import semver from 'semver';

// X.Y.Z or X.Y.Z-betaN — no dot between "beta" and the number
const ALLOWED_PATTERN = /^\d+\.\d+\.\d+(-beta\d+)?$/;

// ─── I/O helpers ──────────────────────────────────────────────────────────────

export function getVersion(): string {
const version = (process.env['INPUT_VERSION'] ?? '').trim();

if (!version) {
throw new Error('No version provided. Set the INPUT_VERSION environment variable.');
}

return version;
}

export function setOutput(name: string, value: string): void {
const outputFile = process.env['GITHUB_OUTPUT'];

if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
} else {
console.log(`OUTPUT ${name}=${value}`);
}
}

// ─── validation ───────────────────────────────────────────────────────────────

export function validateFormat(version: string): void {
if (!semver.valid(version) || !ALLOWED_PATTERN.test(version)) {
throw new Error(
`Version "${version}" is not in the correct format.\n` +
'Expected: X.Y.Z or X.Y.Z-betaN (e.g. 4.1.0, 5.20.15, 4.1.0-beta1, 5.20.0-beta3)',
);
}

console.log(`✅ Version format is valid: ${version}`);
}

// ─── derivation ───────────────────────────────────────────────────────────────

export function extractChannel(version: string): 'stable' | 'beta' {
const prerelease = semver.prerelease(version);

console.log({ prerelease, version });

if (prerelease === null) {
return 'stable';
}

if (typeof prerelease[0] === 'string' && prerelease[0].startsWith('beta')) {
return 'beta';
}

throw new Error(
`Could not determine channel from version "${version}".\n` +
'Pre-release identifier must be "beta" (e.g. 4.1.0-beta1).',
);
}

export function deriveBranch(version: string): string {
const parsed = semver.parse(version);

if (!parsed) {
throw new Error(`Failed to parse version: ${version}`);
}

const paddedMinor = String(parsed.minor).padStart(2, '0');

return `${String(parsed.major)}.${paddedMinor}`;
}

// ─── entry point ──────────────────────────────────────────────────────────────

export function run(): void {
try {
const version = getVersion();

validateFormat(version);

const channel = extractChannel(version);
console.log(`✅ Channel resolved to: ${channel}`);

const branch = deriveBranch(version);
console.log(`✅ Checkout branch: ${branch}`);

setOutput('channel', channel);
setOutput('checkout_branch', branch);
} catch (err) {
console.error(`\n::error::${(err as Error).message}\n`);
process.exit(1);
}
}
22 changes: 22 additions & 0 deletions actions/release-tag-creation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@elementor/release-tag-creation",
"author": "Elementor Team",
"license": "GPL-3.0-or-later",
"private": true,
"scripts": {
"build": "tsup --config ./tsup.config.ts",
"dev": "npm run build -- --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@elementor/editor-github-actions-utils": "^1.0.0",
"semver": "^7.7.2"
},
"devDependencies": {
"@types/semver": "^7.5.8",
"tsup": "^8.5.0",
"vitest": "^4.1.9"
}
}
11 changes: 11 additions & 0 deletions actions/release-tag-creation/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['index.ts', 'update-version-files.ts'],
outDir: 'dist',
format: 'cjs',
noExternal: [/.+/],
platform: 'node',
minify: true,
clean: true,
});
Loading
Loading