mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-12 02:03:55 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
Executable
+186
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user