From 53e13043b88ba002cea40f30e6fdbf5a4f7b2107 Mon Sep 17 00:00:00 2001 From: John Smith Date: Wed, 2 Sep 2026 23:57:46 -0400 Subject: [PATCH] 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 --- .gitattributes | 15 +++ .github/workflows/release.yml | 109 ++++++++++++++++++++ bin/release.mjs | 186 ++++++++++++++++++++++++++++++++++ bin/release.test.mjs | 85 ++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/release.yml create mode 100755 bin/release.mjs create mode 100644 bin/release.test.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d1eb40d --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c1eddf --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/bin/release.mjs b/bin/release.mjs new file mode 100755 index 0000000..7f27c6d --- /dev/null +++ b/bin/release.mjs @@ -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); + } +} diff --git a/bin/release.test.mjs b/bin/release.test.mjs new file mode 100644 index 0000000..3ff8889 --- /dev/null +++ b/bin/release.test.mjs @@ -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 = `