Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ce4be27
feat: two step release tag creation [ED-24451]
davseve Jun 24, 2026
5a9ba70
build: add compiled dist for release-tag-creation action
davseve Jun 24, 2026
293e617
add tests
davseve Jun 24, 2026
1da6c94
build
davseve Jun 24, 2026
3e0375c
add tests infra
davseve Jun 24, 2026
b6f19a4
lint
davseve Jun 24, 2026
40d6b85
add tests to version re-write
davseve Jun 24, 2026
6eb0903
wip
davseve Jun 24, 2026
b5432e7
summery report
davseve Jun 24, 2026
8a5cb68
wip
davseve Jun 25, 2026
6619d47
Remove v from tag version
davseve Jun 25, 2026
11ce058
fix test
davseve Jun 25, 2026
4f7dc35
change branch model
davseve Jul 5, 2026
351ad86
wip
davseve Jul 5, 2026
d8a80b9
check current version is the valid next version
davseve Jul 8, 2026
270cba7
check that the next version is the correct one
davseve Jul 8, 2026
b091e15
test coverage for version gap validation
davseve Jul 8, 2026
929fb09
remove ts bundle
davseve Jul 8, 2026
621a0ea
Revert "remove ts bundle"
davseve Jul 8, 2026
beac187
remove ts to js bundle
davseve Jul 8, 2026
e2965d3
update package lock
davseve Jul 8, 2026
5903c3e
fix: use npm install instead of npm ci for composite action
davseve Jul 8, 2026
63e0778
fix: inline utils functions to make action self-contained
davseve Jul 8, 2026
0112aa5
fix: call run() in main.ts and set ESM module type
davseve Jul 8, 2026
c9c8f75
Netanetl's review comments
davseve Jul 8, 2026
dedb58f
fix: add .ts extensions to relative imports for Node ESM resolution
davseve Jul 8, 2026
08b73ee
allow extension
davseve Jul 8, 2026
09c77f0
wip
davseve Jul 8, 2026
bcdc0fd
wip
davseve Jul 8, 2026
433a5b9
fix: address CodeQL code injection and command injection findings
davseve Jul 9, 2026
f4c8adb
remove code duplication
davseve Jul 9, 2026
5daee0f
wip
davseve Jul 9, 2026
39d20b6
lint
davseve Jul 9, 2026
5444eeb
fix: resolve lint failures and activate Elementor in test flow
cursoragent Jul 9, 2026
98007cd
fix: use WordPress 6.8 in performance flow test
cursoragent Jul 9, 2026
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Make sure it uses the correct node version

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
2 changes: 1 addition & 1 deletion .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
uses: ./actions/setup-wp-env
with:
php: '8.0'
wp: '6.6'
wp: '6.8'
active-theme: 'hello-elementor'
themes: |-
https://downloads.wordpress.org/theme/hello-elementor.zip
Expand Down
151 changes: 151 additions & 0 deletions actions/release-tag-creation/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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: Install dependencies
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
run: npm install --prefix "$ACTION_PATH"

- name: Handle version input
id: handle-version-input
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
ACTION_PATH: ${{ github.action_path }}
run: node "$ACTION_PATH/main.ts"

- name: Switch to release branch
shell: bash
env:
CHECKOUT_BRANCH: ${{ steps.handle-version-input.outputs.checkout_branch }}
CHECKOUT_SHA: ${{ github.sha }}
run: |
git checkout "${CHECKOUT_BRANCH}"
git checkout "${CHECKOUT_SHA}" -- .github/

- name: Config git user
shell: bash
env:
GIT_USER_NAME: ${{ inputs.maintain_username }}
GIT_USER_EMAIL: ${{ inputs.maintain_email }}
run: |
git config --global user.name "${GIT_USER_NAME}"
git config --global user.email "${GIT_USER_EMAIL}"

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

- name: Commit version bump
shell: bash
env:
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
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
env:
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry run — skipping tag push."
echo "tag_pushed=false" >> "$GITHUB_OUTPUT"
else
git tag -a "${VERSION}" -m "Release version ${VERSION}"
git push origin "${VERSION}"
echo "Release tag ${VERSION} pushed successfully (branch unchanged)."
echo "tag_pushed=true" >> "$GITHUB_OUTPUT"
fi

- name: Write job summary
shell: bash
env:
DRY_RUN: ${{ inputs.dry_run }}
TAG_PUSHED: ${{ steps.create-release-tag.outputs.tag_pushed }}
CHANNEL: ${{ steps.handle-version-input.outputs.channel }}
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 }}
VERSION: ${{ inputs.version }}
run: |
if [[ "${DRY_RUN}" == "true" ]]; then
MODE_BADGE="🔵 Dry Run"
else
MODE_BADGE="🚀 Live"
fi

if [[ "${TAG_PUSHED}" == "true" ]]; then
TAG_STATUS="✅ Pushed"
else
TAG_STATUS="⏭️ Skipped (dry run)"
fi

{
echo "## Release Tag Creation Report"
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| **Mode** | ${MODE_BADGE} |"
echo "| **Version** | \`${VERSION}\` |"
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}\` |"
} >> "$GITHUB_STEP_SUMMARY"
100 changes: 100 additions & 0 deletions actions/release-tag-creation/current-version-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { execSync } from 'node:child_process';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { checkVersionIsNext } from './current-version-validation.ts';

vi.mock('node:child_process');
vi.mock('node:fs');

const mockExecSync = vi.mocked(execSync);

function makeLsRemoteTags(tags: string[]): string {
return tags.map((t) => `abc123\trefs/tags/${t}`).join('\n');
}

describe('checkVersionIsNext', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it.each([
// stable — patch
['stable', ['4.2.0', '4.2.1', '4.2.2'], '4.2.3', true, null],
[
'stable',
['4.2.0', '4.2.1', '4.2.2'],
'4.2.4',
false,
'Expected next patch release to be 4.2.3',
],
[
'stable',
['4.2.0', '4.2.1', '4.2.2'],
'4.2.2',
false,
'Expected next patch release to be 4.2.3',
],
// stable — minor
['stable', ['4.1.0', '4.2.0', '4.2.5'], '4.3.0', true, null],
[
'stable',
['4.1.0', '4.2.0', '4.2.5'],
'4.4.0',
false,
'Expected next minor release to be 4.3.0',
],
// stable — no existing tags
['stable', ['4.1.0-beta1'], '4.1.0', true, null],
// beta — next in same series
['beta', ['4.2.0-beta1', '4.2.0-beta2'], '4.2.0-beta3', true, null],
[
'beta',
['4.2.0-beta1', '4.2.0-beta2'],
'4.2.0-beta4',
false,
'Expected next beta to be 4.2.0-beta3',
],
[
'beta',
['4.2.0-beta1', '4.2.0-beta2'],
'4.2.0-beta2',
false,
'Expected next beta to be 4.2.0-beta3',
],
// beta — new version line
['beta', ['4.2.0-beta1', '4.2.0-beta2'], '4.3.0-beta1', true, null],
[
'beta',
['4.2.0-beta1', '4.2.0-beta2'],
'4.3.0-beta2',
false,
'must be beta1',
],
// beta — no existing tags
['beta', ['4.1.0', '4.2.0'], '4.2.0-beta1', true, null],
] as const)(
'%s %s → %s (valid=%s)',
(channel, existingTags, version, valid, errorMsg) => {
mockExecSync.mockReturnValue(
makeLsRemoteTags([...existingTags]) as never,
);
const fn = () => {
checkVersionIsNext(version, channel);
};
if (valid) {
expect(fn).not.toThrow();
} else {
expect(fn).toThrow(errorMsg);
}
},
);

it('throws when git ls-remote fails', () => {
mockExecSync.mockImplementation(() => {
throw new Error('network error');
});
expect(() => {
checkVersionIsNext('4.2.3', 'stable');
}).toThrow('Failed to fetch remote tags');
});
});
104 changes: 104 additions & 0 deletions actions/release-tag-creation/current-version-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { execSync } from 'node:child_process';
import semver from 'semver';

export const ALLOWED_PATTERN = /^\d+\.\d+\.\d+(-beta\d+)?$/;

function fetchTagsByChannel(channel: 'stable' | 'beta'): string[] {
let tagsOutput: string;
try {
tagsOutput = execSync('git ls-remote --tags origin', {
encoding: 'utf8',
});
} catch (err) {
throw new Error(
`Failed to fetch remote tags: ${(err as Error).message}`,
);
}

return tagsOutput
.split('\n')
.map((line) => line.split('\t')[1]?.replace('refs/tags/', '').trim())
.filter((tag): tag is string => {
if (!tag || !ALLOWED_PATTERN.test(tag)) return false;
Comment on lines +18 to +22
const isBeta = tag.includes('-beta');
return channel === 'beta' ? isBeta : !isBeta;
});
}

function validateNextStable(
version: string,
n: semver.SemVer,
l: semver.SemVer,
): void {
const isNewMinor = n.patch === 0;
const expected = isNewMinor
? `${String(l.major)}.${String(l.minor + 1)}.0`
: `${String(l.major)}.${String(l.minor)}.${String(l.patch + 1)}`;
if (version !== expected) {
const kind = isNewMinor ? 'minor' : 'patch';
throw new Error(
`Expected next ${kind} release to be ${expected}, got ${version} (latest: ${l.version}).`,
);
}
}

function validateNextBeta(
version: string,
n: semver.SemVer,
l: semver.SemVer,
latest: string,
): void {
const latestBetaNum = Number(String(l.prerelease[0]).replace('beta', ''));
const newBetaNum = Number(String(n.prerelease[0]).replace('beta', ''));
const sameLine =
n.major === l.major && n.minor === l.minor && n.patch === l.patch;
if (sameLine && newBetaNum !== latestBetaNum + 1) {
throw new Error(
`Expected next beta to be ${latest.replace(`beta${String(latestBetaNum)}`, `beta${String(latestBetaNum + 1)}`)}, got ${version}.`,
);
}
if (!sameLine && newBetaNum !== 1) {
throw new Error(
`First beta of a new version line must be beta1, got ${version}.`,
);
}
}

export function fetchCompanionTag(channel: 'stable' | 'beta'): string | null {
const counterpartChannel = channel === 'stable' ? 'beta' : 'stable';
const tags = fetchTagsByChannel(counterpartChannel);
if (tags.length === 0) return null;
return tags.sort(semver.rcompare)[0] ?? null;
}

export function checkVersionIsNext(
version: string,
channel: 'stable' | 'beta',
): void {
const channelTags = fetchTagsByChannel(channel);
if (channelTags.length === 0) {
console.log(
`✅ No existing ${channel} tags — treating as first release.`,
);
return;
}
const latest = channelTags.sort(semver.rcompare)[0];
if (!latest) {
return;
}
const l = semver.parse(latest);
const n = semver.parse(version);
if (!l || !n) {
throw new Error(
`Failed to parse version strings: latest=${latest}, version=${version}`,
);
}
if (channel === 'stable') {
validateNextStable(version, n, l);
} else {
validateNextBeta(version, n, l, latest);
}
console.log(
`✅ Version ${version} is the correct next version after ${latest}.`,
);
}
Loading
Loading