feat(ci): add manual release workflow with decimal version rollover

- Add bin/release.mjs with getNextVersion rollover (+0.0.1 where 0.0.9 -> 0.1.0)
- Add bin/release.test.mjs unit test suite for versioning
- Add .gitattributes to exclude tests and development cruft from release archives
- Add .github/workflows/release.yml for manual workflow_dispatch releases with ZIP asset publishing
This commit is contained in:
John Smith
2026-09-02 23:57:46 -04:00
parent 95ca38e591
commit 53e13043b8
4 changed files with 395 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Exclude developer, test, documentation, and CI files from release zip archives
.git export-ignore
.github export-ignore
.gitignore export-ignore
.gitattributes export-ignore
.claude export-ignore
tests export-ignore
phpunit.xml export-ignore
phpunit.xml.dist export-ignore
composer.json export-ignore
composer.lock export-ignore
CLAUDE.md export-ignore
bin/smoke-free-build.sh export-ignore
bin/mcp-proxy.test.mjs export-ignore
bin/release.test.mjs export-ignore
+109
View File
@@ -0,0 +1,109 @@
name: Release Plugin
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g. 3.15.0). Leave blank to auto-increment by +0.0.1 with rollover (0.0.9 -> 0.1.0).'
required: false
type: string
default: ''
dry_run:
description: 'Dry run: validate, bump, and build zip without committing or publishing'
required: false
type: boolean
default: false
permissions:
contents: write
jobs:
release:
name: Build and Publish Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Unit Tests
run: |
node --test bin/mcp-proxy.test.mjs
node --test bin/release.test.mjs
php -l emcp-tools.php
- name: Calculate & Bump Version
id: bump
run: |
VERSION_FLAG=""
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION_FLAG="--version=${{ github.event.inputs.version }}"
fi
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
node bin/release.mjs --bump $VERSION_FLAG --dry-run
NEW_VER=$(node bin/release.mjs $VERSION_FLAG --next)
else
node bin/release.mjs --bump $VERSION_FLAG
NEW_VER=$(node bin/release.mjs --current)
fi
echo "version=$NEW_VER" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VER" >> $GITHUB_OUTPUT
echo "Target Version: $NEW_VER"
- name: Build Release ZIP (Dry Run)
if: ${{ inputs.dry_run }}
run: |
NEW_VER="${{ steps.bump.outputs.version }}"
git archive --worktree-attributes --format=zip --prefix=elementor-mcp/ HEAD -o "elementor-mcp-${NEW_VER}.zip"
cp "elementor-mcp-${NEW_VER}.zip" "emcp-tools-${NEW_VER}.zip"
echo "Archive built successfully in dry-run mode:"
unzip -l "elementor-mcp-${NEW_VER}.zip" | head -n 35
- name: Commit & Tag Version Bump
if: ${{ !inputs.dry_run }}
run: |
NEW_VER="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -am "chore(release): bump version to ${NEW_VER}"
git tag -a "$TAG" -m "Release $TAG"
- name: Build Release ZIP
if: ${{ !inputs.dry_run }}
run: |
NEW_VER="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
git archive --format=zip --prefix=elementor-mcp/ "$TAG" -o "elementor-mcp-${NEW_VER}.zip"
cp "elementor-mcp-${NEW_VER}.zip" "emcp-tools-${NEW_VER}.zip"
echo "Archive built successfully:"
unzip -l "elementor-mcp-${NEW_VER}.zip" | head -n 35
- name: Push Commit and Tag
if: ${{ !inputs.dry_run }}
run: |
TAG="${{ steps.bump.outputs.tag }}"
git push origin main
git push origin "$TAG"
- name: Create GitHub Release
if: ${{ !inputs.dry_run }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NEW_VER="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
gh release create "$TAG" \
"elementor-mcp-${NEW_VER}.zip" \
"emcp-tools-${NEW_VER}.zip" \
--title "$TAG" \
--generate-notes
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* Release version management for Elementor MCP Tools.
* Handles decimal version incrementing with rollover (+0.0.1 where 0.0.9 -> 0.1.0).
*
* Usage:
* node bin/release.mjs --current
* node bin/release.mjs --next
* node bin/release.mjs --bump
* node bin/release.mjs --bump --version=3.15.0
* node bin/release.mjs --bump --dry-run
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
/**
* Computes the next version by incrementing patch by 1.
* If patch reaches 10, it rolls over to 0 and increments minor by 1 (e.g. 0.0.9 -> 0.1.0).
*
* @param {string} current Current SemVer string (e.g. "3.14.5" or "0.0.9")
* @returns {string} The incremented version string
*/
export function getNextVersion(current) {
const match = String(current).trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`Invalid SemVer format: "${current}". Expected major.minor.patch (e.g. 3.14.5)`);
}
let major = parseInt(match[1], 10);
let minor = parseInt(match[2], 10);
let patch = parseInt(match[3], 10);
patch += 1;
if (patch >= 10) {
patch = 0;
minor += 1;
}
return `${major}.${minor}.${patch}`;
}
/**
* Validates a version string format.
*
* @param {string} version
* @returns {boolean}
*/
export function isValidVersion(version) {
return /^\d+\.\d+\.\d+$/.test(String(version).trim());
}
/**
* Reads the current version from emcp-tools.php.
*
* @param {string} rootDir Root directory of the repository
* @returns {string} Current version string
*/
export function getCurrentVersion(rootDir = ROOT_DIR) {
const pluginFile = path.join(rootDir, 'emcp-tools.php');
if (!fs.existsSync(pluginFile)) {
throw new Error(`Plugin file not found: ${pluginFile}`);
}
const content = fs.readFileSync(pluginFile, 'utf8');
const match = content.match(/define\(\s*['"]EMCP_TOOLS_VERSION['"]\s*,\s*['"]([^'"]+)['"]\s*\);/);
if (!match) {
throw new Error('EMCP_TOOLS_VERSION constant not found in emcp-tools.php');
}
return match[1];
}
/**
* Updates version references in emcp-tools.php and readme.txt.
*
* @param {string} rootDir Root directory of the repository
* @param {string} newVersion Target version string
* @param {boolean} dryRun If true, does not write changes to disk
* @returns {{ filesUpdated: string[] }}
*/
export function updateVersionFiles(rootDir = ROOT_DIR, newVersion, dryRun = false) {
if (!isValidVersion(newVersion)) {
throw new Error(`Invalid version format to set: "${newVersion}"`);
}
const filesUpdated = [];
// 1. Update emcp-tools.php
const pluginFile = path.join(rootDir, 'emcp-tools.php');
if (fs.existsSync(pluginFile)) {
let content = fs.readFileSync(pluginFile, 'utf8');
// Update constant define( 'EMCP_TOOLS_VERSION', 'X.Y.Z' );
content = content.replace(
/(define\(\s*['"]EMCP_TOOLS_VERSION['"]\s*,\s*['"])[^'"]+(['"]\s*\);)/,
`$1${newVersion}$2`
);
// Update docblock header * Version: X.Y.Z
content = content.replace(
/(\*\s*Version:\s*)([0-9A-Za-z.-]+)/,
`$1${newVersion}`
);
if (!dryRun) {
fs.writeFileSync(pluginFile, content, 'utf8');
}
filesUpdated.push(pluginFile);
}
// 2. Update readme.txt
const readmeFile = path.join(rootDir, 'readme.txt');
if (fs.existsSync(readmeFile)) {
let content = fs.readFileSync(readmeFile, 'utf8');
// Update Stable tag: X.Y.Z
content = content.replace(
/(Stable tag:\s*)([0-9A-Za-z.-]+)/,
`$1${newVersion}`
);
if (!dryRun) {
fs.writeFileSync(readmeFile, content, 'utf8');
}
filesUpdated.push(readmeFile);
}
return { filesUpdated };
}
// CLI Execution Entrypoint
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
const args = process.argv.slice(2);
const isBump = args.includes('--bump');
const isCurrent = args.includes('--current');
const isNext = args.includes('--next');
const isDryRun = args.includes('--dry-run');
const customVerArg = args.find((a) => a.startsWith('--version='));
const customVersion = customVerArg ? customVerArg.split('=')[1].trim() : null;
try {
const current = getCurrentVersion(ROOT_DIR);
if (isCurrent) {
console.log(current);
process.exit(0);
}
const next = customVersion || getNextVersion(current);
if (isNext) {
console.log(next);
process.exit(0);
}
if (isBump) {
if (customVersion && !isValidVersion(customVersion)) {
console.error(`Error: Custom version "${customVersion}" is not valid SemVer (X.Y.Z)`);
process.exit(1);
}
console.log(`Current version: ${current}`);
console.log(`Target version: ${next}${isDryRun ? ' (DRY RUN)' : ''}`);
const { filesUpdated } = updateVersionFiles(ROOT_DIR, next, isDryRun);
for (const file of filesUpdated) {
console.log(`Updated: ${path.relative(ROOT_DIR, file)}`);
}
console.log(`Successfully bumped to ${next}!`);
process.exit(0);
}
console.log('Usage:');
console.log(' node bin/release.mjs --current');
console.log(' node bin/release.mjs --next');
console.log(' node bin/release.mjs --bump');
console.log(' node bin/release.mjs --bump --version=3.15.0');
console.log(' node bin/release.mjs --bump --dry-run');
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Unit tests for release versioning and rollover logic.
* Run: node --test bin/release.test.mjs
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getNextVersion, isValidVersion, getCurrentVersion, updateVersionFiles } from './release.mjs';
test('getNextVersion: increments patch standardly', () => {
assert.equal(getNextVersion('0.0.1'), '0.0.2');
assert.equal(getNextVersion('0.0.5'), '0.0.6');
assert.equal(getNextVersion('3.14.5'), '3.14.6');
assert.equal(getNextVersion('1.2.3'), '1.2.4');
});
test('getNextVersion: rolls over 0.0.9 to 0.1.0', () => {
assert.equal(getNextVersion('0.0.9'), '0.1.0');
});
test('getNextVersion: rolls over 3.14.9 to 3.15.0', () => {
assert.equal(getNextVersion('3.14.9'), '3.15.0');
});
test('getNextVersion: rolls over multi-digit minor properly', () => {
assert.equal(getNextVersion('0.1.9'), '0.2.0');
assert.equal(getNextVersion('0.9.9'), '0.10.0');
assert.equal(getNextVersion('1.9.9'), '1.10.0');
assert.equal(getNextVersion('3.99.9'), '3.100.0');
});
test('getNextVersion: throws on invalid format', () => {
assert.throws(() => getNextVersion('invalid'), /Invalid SemVer format/);
assert.throws(() => getNextVersion('1.0'), /Invalid SemVer format/);
assert.throws(() => getNextVersion('v1.0.0'), /Invalid SemVer format/);
});
test('isValidVersion: validates version strings', () => {
assert.equal(isValidVersion('3.14.5'), true);
assert.equal(isValidVersion('0.0.9'), true);
assert.equal(isValidVersion('0.1.0'), true);
assert.equal(isValidVersion('v3.14.5'), false);
assert.equal(isValidVersion('3.14'), false);
assert.equal(isValidVersion(''), false);
});
test('updateVersionFiles: correctly updates mock plugin and readme files', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emcp-release-test-'));
const mockPhp = `<?php
/**
* Plugin Name: Test Plugin
* Version: 3.14.5
*/
define( 'EMCP_TOOLS_VERSION', '3.14.5' );
`;
const mockReadme = `=== Test Plugin ===
Stable tag: 3.14.5
`;
fs.writeFileSync(path.join(tmpDir, 'emcp-tools.php'), mockPhp, 'utf8');
fs.writeFileSync(path.join(tmpDir, 'readme.txt'), mockReadme, 'utf8');
assert.equal(getCurrentVersion(tmpDir), '3.14.5');
// Test dry run first
updateVersionFiles(tmpDir, '3.14.6', true);
assert.equal(fs.readFileSync(path.join(tmpDir, 'emcp-tools.php'), 'utf8'), mockPhp);
// Test real update
const { filesUpdated } = updateVersionFiles(tmpDir, '3.14.6', false);
assert.equal(filesUpdated.length, 2);
const updatedPhp = fs.readFileSync(path.join(tmpDir, 'emcp-tools.php'), 'utf8');
assert.match(updatedPhp, /Version:\s*3\.14\.6/);
assert.match(updatedPhp, /define\(\s*'EMCP_TOOLS_VERSION',\s*'3\.14\.6'\s*\);/);
const updatedReadme = fs.readFileSync(path.join(tmpDir, 'readme.txt'), 'utf8');
assert.match(updatedReadme, /Stable tag:\s*3\.14\.6/);
// Clean up
fs.rmSync(tmpDir, { recursive: true, force: true });
});