diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1bcff2a..838bbec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,7 +3,7 @@ name: PHP console plugin
on:
pull_request:
push:
- branches: [main, feature/jcb-mcp-runtime]
+ branches: [main]
permissions:
contents: read
@@ -13,7 +13,7 @@ concurrency:
cancel-in-progress: true
jobs:
- package:
+ source:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
@@ -31,14 +31,18 @@ jobs:
coverage: none
- name: PHP syntax
run: find . -type f -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l
- - name: Manifest and reproducible package contracts
+ - name: Downloaded source ZIP and release contracts
run: |
- php tests/run.php
+ source_archive="$(mktemp -d)"
+ trap 'rm -rf -- "$source_archive"' EXIT
+ git archive --format=zip --prefix=mcp_plugin/ --output="$source_archive/source.zip" HEAD
+ unzip -q "$source_archive/source.zip" -d "$source_archive/extracted"
+ php "$source_archive/extracted/mcp_plugin/tests/run.php"
php tests/release.php
- uses: actions/checkout@v7
with:
- repository: joomengine/mcp_component
- ref: feature/jcb-mcp-runtime
+ repository: ${{ vars.MCP_COMPONENT_REPOSITORY || 'joomengine/mcp_component' }}
+ ref: ${{ vars.MCP_COMPONENT_REF || 'main' }}
path: build/component-contract
persist-credentials: false
- name: Native Joomla console registration and runtime contracts
@@ -48,12 +52,3 @@ jobs:
run: |
bash tests/prepare-native.sh
php tests/native.php
- - uses: actions/upload-artifact@v7
- if: matrix.php == '8.3'
- with:
- name: console-plugin-development-package
- path: |
- build/plg_console_joomengine_mcp-*.zip
- build/plg_console_joomengine_mcp-*.zip.sha256
- if-no-files-found: error
- retention-days: 7
diff --git a/.github/workflows/installed.yml b/.github/workflows/installed.yml
index 6cbf19f..fa84fcf 100644
--- a/.github/workflows/installed.yml
+++ b/.github/workflows/installed.yml
@@ -5,10 +5,9 @@ on:
inputs:
component_ref:
type: string
- default: main
pull_request:
push:
- branches: [main, feature/jcb-mcp-runtime]
+ branches: [main]
permissions:
contents: read
@@ -42,24 +41,35 @@ jobs:
with:
path: plugin
persist-credentials: false
+ - name: Select the matching component revision
+ id: component
+ env:
+ COMPONENT_REPOSITORY: ${{ vars.MCP_COMPONENT_REPOSITORY || 'joomengine/mcp_component' }}
+ COMPONENT_REF: ${{ inputs.component_ref || vars.MCP_COMPONENT_REF }}
+ CANDIDATE_REF: ${{ github.head_ref || github.ref_name }}
+ run: |
+ set -euo pipefail
+ if [[ -z "$COMPONENT_REF" ]]; then
+ COMPONENT_REF=main
+ if [[ -n "$CANDIDATE_REF" ]] && git ls-remote --exit-code --heads \
+ "https://github.com/$COMPONENT_REPOSITORY.git" "refs/heads/$CANDIDATE_REF" >/dev/null 2>&1; then
+ COMPONENT_REF="$CANDIDATE_REF"
+ fi
+ fi
+ git check-ref-format --branch "$COMPONENT_REF" >/dev/null
+ printf 'ref=%s\n' "$COMPONENT_REF" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
- repository: joomengine/mcp_component
- ref: ${{ inputs.component_ref || (github.ref_name == 'main' && 'main' || 'feature/jcb-mcp-runtime') }}
+ repository: ${{ vars.MCP_COMPONENT_REPOSITORY || 'joomengine/mcp_component' }}
+ ref: ${{ steps.component.outputs.ref }}
path: component
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, dom, fileinfo, intl, json, mbstring, mysqli, pdo_mysql, simplexml, sodium, xml, zip
- tools: composer:v2
coverage: none
- - name: Build the matching component
- working-directory: component
- run: |
- bash tools/build.sh
- bash tools/build-distribution.sh
- - name: Install and exercise this plugin checkout through native Joomla CLI
+ - name: Install source ZIPs and exercise this plugin through native Joomla CLI
working-directory: component
env:
MCP_TEST_ALLOW_DESTRUCTIVE: '1'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4f75dda..13a1009 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,74 +1,65 @@
-name: Publish console plugin release
+name: Release console plugin with OctoShoom
on:
workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (for example 1.2.3 or v1.2.3)'
+ required: true
+ type: string
permissions:
contents: read
concurrency:
- group: console-plugin-release
+ group: joomla-extension-release
cancel-in-progress: false
jobs:
- installed:
- if: github.ref == 'refs/heads/main'
- uses: ./.github/workflows/installed.yml
- with:
- component_ref: main
- publish:
- needs: installed
+ release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
- timeout-minutes: 15
- permissions:
- contents: write
steps:
- uses: actions/checkout@v7
with:
+ ref: main
fetch-depth: 0
+ persist-credentials: false
+ - name: Setup Git User
+ uses: octoleo/git-user@v2
+ with:
+ gpg-key: ${{ secrets.GPG_KEY }}
+ gpg-user: ${{ secrets.GPG_USER }}
+ ssh-key: ${{ secrets.SSH_KEY }}
+ ssh-pub: ${{ secrets.SSH_PUB }}
+ git-user: ${{ secrets.GIT_USER }}
+ git-email: ${{ secrets.GIT_EMAIL }}
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- extensions: dom, simplexml, zip
coverage: none
- - name: Validate immutable version and rebuild package
+ - name: Publish the version and Joomla update entry
env:
- GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ inputs.version }}
run: |
- set -euo pipefail
- php tests/run.php
- php tests/release.php
- version="$(php -r 'echo (string) simplexml_load_file("joomengine_mcp.xml")->version;')"
- [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
+ version="${VERSION#v}"
tag="v$version"
- git fetch origin main --tags
- [[ "$(git rev-parse origin/main)" == "$GITHUB_SHA" ]]
- if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
- echo 'This immutable version already exists; bump the manifest in a reviewed change.' >&2
- exit 1
+ git remote set-url origin git@github.com:joomengine/mcp_plugin.git
+ if ! git show-ref --verify --quiet "refs/tags/$tag"; then
+ php tools/release.php prepare "$version"
+ git add joomengine_mcp.xml CHANGELOG.md joomengine_mcp_changelog.xml
+ git commit -m "Release $tag"
+ git tag -a "$tag" -m "Release $tag"
+ git push --atomic origin HEAD:main "refs/tags/$tag"
fi
- printf 'PLUGIN_VERSION=%s\nPLUGIN_TAG=%s\n' "$version" "$tag" >> "$GITHUB_ENV"
- - name: Publish verified versioned archive and checksum
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- archive="build/plg_console_joomengine_mcp-$PLUGIN_VERSION.zip"
- gh release create "$PLUGIN_TAG" "$archive" "$archive.sha256" \
- --target "$GITHUB_SHA" --title "JoomEngine MCP console $PLUGIN_VERSION" \
- --notes-file CHANGELOG.md --draft
- gh release edit "$PLUGIN_TAG" --draft=false
- mkdir -p build/published
- gh release download "$PLUGIN_TAG" --dir build/published --pattern '*.zip' --pattern '*.sha256'
- cmp "$archive" "build/published/$(basename "$archive")"
- gh api "repos/$GITHUB_REPOSITORY/releases/tags/$PLUGIN_TAG" > build/published/release.json
- php tools/update-feed.php build/published/release.json "build/published/$(basename "$archive")"
- - name: Commit feed only after publication succeeds
- run: |
- set -euo pipefail
- git config user.name 'github-actions[bot]'
- git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ php tools/release.php feed "$version"
git add joomengine_mcp_update_server.xml
- git commit -m "Publish console plugin $PLUGIN_VERSION update metadata"
- git push origin HEAD:main
+ if ! git diff --cached --quiet; then
+ git commit -m "Add $tag to the Joomla update server"
+ git push origin HEAD:main
+ fi
+ - name: Update hashes
+ uses: octoleo/octoshoom@master
+ with:
+ config-json: |
+ {"update_servers":[{"owner":"joomengine","repo":"mcp_plugin","branch":"main","path":"joomengine_mcp_update_server.xml"}]}
diff --git a/AGENTS.md b/AGENTS.md
index d963a7c..0e3aca1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
Complete the original companion/CLI migration from `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77efad32e1a36` without losing supported commands, schemas, native actions, preview/plan, approval, verification or recovery. **Also complete the JCB CLI integration required by docs/JCB-INTEGRATION.md and the component's canonical JCB roadmap.** Core-only support is not the completed objective.
-Stay on `feature/jcb-mcp-runtime` / PR #1. Push coherent commits and update docs/IMPLEMENTATION.md with actual tests and remaining work. Do not replace branches, force-push, merge, publish or alter the original MCP/JCB source repositories without separate instruction.
+The migration PR #1 has been merged. Branch new work from current `main`, or continue the relevant open PR branch. Push coherent commits and update docs/IMPLEMENTATION.md with actual tests and remaining work. Do not force-push, merge, run a release or alter the original MCP/JCB source repositories without instruction.
Use element `joomengine_mcp`, group `console`, namespace `VDM\Plugin\Console\JoomEngineMcp`; dependency `com_joomengine_mcp`, namespace `VDM\Component\JoomEngineMcp`. Joomla 6 native plugin/event/DI/console contracts are authoritative. Follow JCB's plugin-root manifest/installer/services/src/language/update layout. PHP style authority: https://github.com/extension-builder/joomla/blob/main/docs/development/php-code-style.md (tabs, LF, Allman braces, explicit typed properties/constructor injection, meaningful docblocks, no closing tags or isolated strict_types/promotion/readonly changes). Preserve inherited signatures.
@@ -15,3 +15,13 @@ JCB's installed command plugin owns `componentbuilder:*` registration. Inventory
Stdio stdout contains only JSON-RPC. Keep banners/notices/logs off it; preserve nonzero failures and EOF/byte bounds. Missing/incompatible component or JCB dependencies must fail the affected operation clearly without breaking unrelated Joomla/core commands. Restore native identity/input/factory state or use isolated job workers so consecutive requests cannot contaminate one another.
Run syntax, provider/registration, manifest/package tests and coordinated installed Joomla/JCB API/CLI/stdio tests. Exercise true writes/read-back/cleanup, dependency queues, compile/install artifacts, command ordering, concurrency, cancellation, errors and HTTP/local-authority separation. Package checks are not live passes. Align server package versions/update feeds, retain licences and never advertise unpublished artifacts or completed JCB coverage without evidence.
+
+## Source installation and releases
+
+- This repository is exclusively the console plugin. Its GitHub source ZIP must install directly into a Joomla site with the compatible component installed. Keep every manifest file tracked; no build, Composer run or repacking is required downstream.
+- Do not add plugin/package builders, copied component dependencies, combined package manifests or package feeds. The component's release invokes OctoJPack using its standalone `.octojpack` configuration, which selects this plugin's latest tag. The resulting Joomla package belongs to its own repository.
+- The manual Release workflow accepts the next version, freezes manifest/changelog metadata, creates an immutable tag, adds its source ZIP to this plugin's Joomla update feed, and waits for OctoShoom to commit the checksum. This workflow never invokes OctoJPack. Keep the fixed `joomengine/mcp_plugin` repository, `main` branch and feed path in the workflow; never move an existing tag.
+- Use `octoleo/git-user@v2` and `octoleo/octoshoom@master` directly as actions, following their quick starts. Configure authentication once through git-user's documented secrets; let OctoShoom inherit it. Do not add custom SSH setup, action checkouts, repository discovery, hash rechecks or wrappers around the shared tools. Local release support only freezes version metadata and appends the Joomla update entry. Engine changes require an explicit request.
+- Log every meaningful change in **both** `CHANGELOG.md` and `joomengine_mcp_changelog.xml`. Pending entries use the exact version marker `[[[NEXT_VERSION]]]`. Create a pending section when absent; leave released sections unchanged. The release workflow replaces this marker with the selected version.
+- Joomla changelog identity is element `joomengine_mcp`, type `plugin`, folder `console`. Native categories are `security`, `fix`, `language`, `addition`, `change`, `remove`, and `note`, with `item` children. Match their human headings in Markdown. Compatibility warnings belong under Note, errors fixed under Fix, and security fixes under Security. Keep both formats consistent and retain the manifest's valid `changelogurl`.
+- Verify source archive completeness and release metadata for successive versions. Leave checksum generation and its verification to OctoShoom; do not maintain another implementation here.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c428673..f59e8d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,15 +1,39 @@
# Changelog
-## Unreleased
+## [[[NEXT_VERSION]]]
+
+### Addition
+
+- Add a manual next-version release workflow that freezes both changelogs, creates an immutable tag, updates the native Joomla feed and waits for OctoShoom to commit its checksum.
+- Add the categorized Joomla plugin changelog and document GitHub secrets and agent responsibilities.
+
+### Change
+
+- Install and test the unchanged repository source ZIP; no build or Composer step is required.
+- Keep combined package assembly in the component's external OctoJPack release process and its separate package repository.
+- Call git-user and OctoShoom directly as actions with fixed repository/feed settings and inherited authentication.
+
+### Remove
+
+- Remove the local plugin ZIP builder and release-asset/checksum publication implementation.
+- Remove custom SSH setup, temporary action checkouts, duplicate hash checks and release-tool configuration variables.
+
+### Note
+
+- Install the compatible component before the console plugin. The plugin release does not invoke OctoJPack.
+- Configure the six git-user secrets documented in docs/RELEASE.md before releasing.
+
+## 0.1.0 — development baseline
+
+### Addition
- Establish the exact joomengine_mcp console plugin identity, local-server authority and shared component contract.
-- Add native plugin/provider/lazy command adapters, output isolation, installer checks, languages/update metadata and PHP-only reproducible packaging.
+- Add native plugin/provider/lazy command adapters, output isolation, installer checks and language metadata.
- Expose explicit local JCB catalogue synchronization through the component-owned runtime without replacing JCB's commands.
- Preserve native global options, atomic command registration and output restoration after console errors.
-- Verify native Joomla console contracts and 18 actual installed command/stdio assertions, including whitespace and exact-limit NDJSON frames, on PHP 8.3 and 8.4.
-- Verify coordinated installed component/plugin/client execution on MySQL and PostgreSQL; retain separate JCB golden-image evidence in the component acceptance checklist.
-- Add explicit main-only release publication with verified versioned archives, checksums and post-publication update metadata.
-- Separate external Composer-client/remote-bridge ownership into `joomengine/mcp_client`; no server/plugin dependency on that package.
-- Require complete first-class JCB API/CLI coverage and document native command registration, compiler/package semantics, shared jobs and installed acceptance responsibilities.
+- Verify native Joomla console contracts and installed command/stdio behavior, including whitespace and exact-limit NDJSON frames.
+- Verify coordinated installed component/plugin/client execution on MySQL and PostgreSQL; retain JCB golden-image evidence in the component acceptance checklist.
+- Separate external Composer-client/remote-bridge ownership into joomengine/mcp_client; no server/plugin dependency on that package.
+- Document native command registration, compiler/package semantics, shared jobs and installed acceptance responsibilities.
-Exact tested revisions and workflow results are recorded in [implementation evidence](docs/IMPLEMENTATION.md). Coordinated JCB compiler/package/job acceptance is tracked in the [component checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349). No release has been published by this implementation work.
+Exact tested revisions are recorded in [implementation evidence](docs/IMPLEMENTATION.md). Development baseline entries describe source, not a previously published release. Published immutable tags establish release availability.
diff --git a/README.md b/README.md
index 8d1e9d2..a419391 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
PHP-only local Joomla console integration for `com_joomengine_mcp`.
-Requires the built component version **0.1.1 or later in the same major version**, including its explicit JCB synchronization runtime.
+Requires component version **0.1.1 or later in the same major version**, including its explicit JCB synchronization runtime.
**Element:** `joomengine_mcp`
**Group:** `console`
@@ -11,6 +11,10 @@ Requires the built component version **0.1.1 or later in the same major version*
The plugin connects Joomla's console lifecycle to the component-owned database catalogue and execution engine. It provides the `joomla:mcp:serve`, `describe`, `dispatch`, `self-test`, `cli-inventory` and `jcb-sync` adapters. It does not contain a second MCP catalogue or an HTTP webservices plugin.
+## Download and install
+
+Download this repository using **Code → Download ZIP**, or download a release tag's source ZIP, and upload it in Joomla's extension installer after installing the component. All plugin runtime files are tracked. No Composer, build or repacking step is needed. OctoJPack can also include this plugin in the combined server package published to the separately configured package repository.
+
## Three repository boundaries
- [`mcp_component`](https://github.com/joomengine/mcp_component): installed server, database definitions, HTTP authentication/ACL/routing, administrator application, API/native handlers, durable plans/jobs and verification.
@@ -29,7 +33,7 @@ After installing or upgrading JCB, the server owner runs `php cli/joomla.php joo
## Status and local authority
-Implementation is on `feature/jcb-mcp-runtime` / [PR #1](https://github.com/joomengine/mcp_plugin/pull/1). The native provider, lazy command adapters, output guard, installer and PHP-only package builder are implemented. Native Joomla console tests cover registration, global options, typed runtime delegation and output restoration; installed workflows exercise this checkout through the actual Joomla CLI and the shared JCB runtime. The PR records current check results and review status; [implementation evidence](docs/IMPLEMENTATION.md) describes the verification layers.
+The native provider, lazy command adapters, output guard and installer are implemented. Native Joomla console tests cover registration, global options, typed runtime delegation and output restoration; installed workflows exercise this checkout through the actual Joomla CLI and the shared JCB runtime. [Implementation evidence](docs/IMPLEMENTATION.md) describes the verification layers and historical results.
Local execution uses the genuine Joomla console application under CLI SAPI, without a Joomla API token or row-viewing-level restriction. Input validation, explicit action semantics, grants/plans, bounded output, audit, verification and recovery still apply. HTTP requests and database values cannot manufacture this local privilege.
@@ -37,6 +41,8 @@ Original migration source: `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77e
## Verification and release
-Run `php tests/run.php` and `php tests/release.php` for packaging and publication metadata checks. With a full Joomla distribution in `JOOMLA_ROOT` and the component checkout in `MCP_COMPONENT_SOURCE`, run `php tests/native.php` for actual Joomla class contracts. Installed acceptance requires the component's disposable fixture and `MCP_PLUGIN_SOURCE` pointing to this checkout; its runner installs the plugin and executes `tests/installed.php` before teardown.
+Run `php tests/run.php` and `php tests/release.php` for source completeness and release metadata checks. With a full Joomla distribution in `JOOMLA_ROOT` and the component checkout in `MCP_COMPONENT_SOURCE`, run `php tests/native.php` for actual Joomla class contracts. Installed acceptance requires the component's disposable fixture and `MCP_PLUGIN_SOURCE` pointing to this checkout; its runner installs the source ZIP and executes `tests/installed.php` before teardown.
+
+Run the manual **Release** workflow from `main` with the next version. It freezes both changelogs, creates the source tag, adds its ZIP URL to the Joomla update feed, and invokes OctoShoom directly to publish the checksum. The plugin release stops there. The component's release invokes OctoJPack separately. [Release instructions](docs/RELEASE.md) list the six git-user secrets.
-Release publication is an explicit manual workflow on `main`, after merge and review. It runs installed acceptance against the component's `main`, refuses an existing version tag, publishes the versioned archive and checksum, downloads and verifies those assets, then commits the update feed. The feed remains empty until an archive is published. The component owns combined server package assembly.
+Human-readable changes are in [CHANGELOG.md](CHANGELOG.md); Joomla reads [joomengine_mcp_changelog.xml](joomengine_mcp_changelog.xml). Pending changes use `[[[NEXT_VERSION]]]` in both files until the release workflow assigns their version.
diff --git a/build.php b/build.php
deleted file mode 100644
index 39c4115..0000000
--- a/build.php
+++ /dev/null
@@ -1,76 +0,0 @@
-
- * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved.
- * @license GNU General Public License version 3 or later; see LICENSE
- */
-
-if (PHP_SAPI !== 'cli' || !class_exists(ZipArchive::class))
-{
- fwrite(STDERR, "Build requires PHP CLI with the zip extension.\n");
- exit(1);
-}
-
-$root = __DIR__;
-$manifest = simplexml_load_file($root . '/joomengine_mcp.xml');
-
-if ($manifest === false || preg_match('/\A\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?\z/D', (string) $manifest->version) !== 1)
-{
- throw new RuntimeException('Invalid plugin manifest version.');
-}
-
-$files = ['joomengine_mcp.xml', 'script.php', 'LICENSE'];
-
-foreach (['src', 'services', 'language'] as $directory)
-{
- foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS)) as $file)
- {
- if ($file->isLink())
- {
- throw new RuntimeException('Plugin archives may not contain symbolic links.');
- }
-
- if ($file->isFile())
- {
- $files[] = substr($file->getPathname(), strlen($root) + 1);
- }
- }
-}
-
-sort($files, SORT_STRING);
-$output = $root . '/build';
-
-if (!is_dir($output) && !mkdir($output, 0775, true))
-{
- throw new RuntimeException('Cannot create the build directory.');
-}
-
-$path = $output . '/plg_console_joomengine_mcp-' . (string) $manifest->version . '.zip';
-$zip = new ZipArchive();
-
-if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true)
-{
- throw new RuntimeException('Cannot create the plugin archive.');
-}
-
-$epoch = getenv('SOURCE_DATE_EPOCH');
-$mtime = $epoch !== false && ctype_digit($epoch) ? max(315532800, (int) $epoch) : 1789603200;
-
-foreach ($files as $file)
-{
- if (!$zip->addFile($root . '/' . $file, $file) || !$zip->setMtimeName($file, $mtime)
- || !$zip->setExternalAttributesName($file, ZipArchive::OPSYS_UNIX, 0100644 << 16))
- {
- throw new RuntimeException('Cannot add a file to the plugin archive.');
- }
-}
-
-if (!$zip->close())
-{
- throw new RuntimeException('Cannot finalize the plugin archive.');
-}
-
-file_put_contents($path . '.sha256', hash_file('sha256', $path) . ' ' . basename($path) . "\n");
-echo $path . PHP_EOL;
diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md
index 9fb1854..5c10b80 100644
--- a/docs/IMPLEMENTATION.md
+++ b/docs/IMPLEMENTATION.md
@@ -1,14 +1,14 @@
-# Implementation status — 24 September 2026
+# Implementation status — 28 September 2026
## Branch
-Implementation is on `feature/jcb-mcp-runtime` / [PR #1](https://github.com/joomengine/mcp_plugin/pull/1). The PR records current checks and review status; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) tracks coordinated Joomla/JCB execution evidence.
+The migration [PR #1](https://github.com/joomengine/mcp_plugin/pull/1) is merged. Source-installation and release realignment is on `fix/octo-release-workflow`; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) tracks coordinated Joomla/JCB execution evidence.
Plugin version 0.1.0 requires component version 0.1.1 or later within the same major version, because the explicit JCB synchronization operation is part of that runtime contract.
## Implemented runtime
-Exact plugin element/group/namespace, Joomla DI/event integration, lazy adapters for serve/describe/dispatch/self-test/cli-inventory/jcb-sync, local-only checks and shared typed component runtime resolution are present. The output guard isolates protocol framing from Joomla diagnostics. Installer checks, initial enablement with update-state preservation, languages/update/changelog metadata and reproducible PHP ZIP building exist.
+Exact plugin element/group/namespace, Joomla DI/event integration, lazy adapters for serve/describe/dispatch/self-test/cli-inventory/jcb-sync, local-only checks and shared typed component runtime resolution are present. The output guard isolates protocol framing from Joomla diagnostics. Installer checks, initial enablement with update-state preservation and language metadata are implemented. The tracked source is directly installable; no plugin ZIP builder is maintained here.
The component supplies ConsoleRuntimeInterface/ConsoleRuntimeProviderInterface and the runtime composition. The plugin forwards native input/output objects and exact exit status; it contains no JCB catalogue or business handlers. Registration checks all MCP names before mutation, binds native global options before selecting protocol output protection, and restores formatter state after successful execution and native application errors.
@@ -20,7 +20,9 @@ External Composer-client/remote-stdio ownership is exclusively in `joomengine/mc
JCB handlers, reviewed command/API bindings, input freezing, jobs and artifacts belong to the component. The plugin consumes them through its existing shared runtime. Its console adapters do not re-register JCB's native commands or depend on the external client. The canonical JCB acceptance matrix remains docs/JCB-INTEGRATION.md and the component roadmap.
-## Verification layers
+## Historical verification layers
+
+The following installed/runtime results belong to the recorded September 24 revisions. The former ZIP/release-asset implementation is replaced by source-archive installation and OctoShoom; current checks are recorded in the release-alignment PR.
Local PHP 8.3.6 verification: syntax, manifest/language/reproducible package checks; 29 assertions using genuine Joomla 6.1.3 console/plugin/input/output classes; and five release metadata assertions. The native class suite verifies idempotent/atomic registration, global-option handling, help/core output preservation, error restoration, lazy runtime resolution, native input forwarding and nonzero statuses. It uses a substitutable shared-runtime implementation and is not an installed JCB execution test.
@@ -36,6 +38,8 @@ Verified runtime and test revision: plugin `3526cae818803a02971374c044a2e2184f1c
These installed core fixtures have no JCB installation. The component's golden-image workflow installs a pinned version of this plugin alongside JCB and runs the same actual-entrypoint suite; its JCB operation matrix supplies the separate compiler/package/job evidence. The verified golden-image revisions, results and inherited native limitations are recorded in the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349).
-Ordinary installed CI pairs the feature branches before merge and uses the component's `main` for plugin `main`. Reusable callers can select an explicit component revision. Manual main-only publication runs installed acceptance first, refuses reused version tags, publishes immutable versioned ZIP/checksum assets, verifies downloaded bytes and updates the feed only after publication. No release has been published by this work.
+Installed CI uses the component's main branch by default; configuration and reusable callers can select an explicit component revision. Manual next-version releases freeze both changelogs and manifest metadata, create an immutable tag, append its source ZIP to the Joomla update feed, and invoke `octoleo/octoshoom@master` directly. Authentication and signing use `octoleo/git-user@v2`; custom SSH setup, temporary action checkouts and duplicate hash checks have been removed. Existing tags and feed entries are left unchanged on rerun. This plugin never builds a combined package or invokes OctoJPack. See RELEASE.md. No release has been run by this implementation work.
+
+The simplified release support passes actionlint, PHP 8.3 syntax checks, 13 isolated metadata checks, and the source manifest/language/installation-completeness checks. These checks do not exercise publication credentials or replace the installed runtime evidence above.
The component golden-image suite exercises shared JCB operations, native options/dependencies, persisted read-back, generated/install artifacts, state isolation, long jobs/cancellation/recovery and cleanup. Each result belongs to its recorded component/JCB/plugin revisions. The linked PR and acceptance checklist are authoritative for current completion; historical runs do not certify later runtime changes. External-client interoperability is tracked in `mcp_client` and the coordinated component suite. Review/merge and deliberate release publication remain separate actions.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..af3f8c8
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,36 @@
+# Console plugin releases
+
+The repository source ZIP installs directly into Joomla after the compatible component is installed. There is no build, Composer step or combined package in this repository.
+
+## Release a version
+
+Open Actions → **Release console plugin with OctoShoom**, select `main`, and enter the next version, such as `1.2.3`. A `v` prefix is optional.
+
+The workflow uses [git-user](https://github.com/octoleo/git-user#workflows) to configure Git authentication and signing. Its small metadata step freezes `[[[NEXT_VERSION]]]` in both changelogs, updates the manifest version/date, commits and tags the source, then adds the tagged ZIP to `joomengine_mcp_update_server.xml`. It calls the [OctoShoom action](https://github.com/octoleo/octoshoom#quick-start) directly to hash the downloads and commit the update feed.
+
+Repository `joomengine/mcp_plugin`, branch `main` and the update-feed path are fixed in the workflow. OctoShoom inherits the Git identity and authentication from git-user. Existing tags and feed entries are left unchanged when rerunning the same version.
+
+The plugin workflow stops after OctoShoom. Release the plugin before the component; OctoJPack reads the component's standalone `.octojpack` configuration and selects the latest plugin tag for the combined package.
+
+## GitHub secrets
+
+Set these under **Settings → Secrets and variables → Actions**. The SSH identity must be allowed to push to this repository.
+
+| Secret | Value |
+| --- | --- |
+| `GPG_KEY` | ASCII-armored private signing key. |
+| `GPG_USER` | Signing key's user ID. |
+| `SSH_KEY` | SSH private key. |
+| `SSH_PUB` | Matching SSH public key. |
+| `GIT_USER` | Git author name. |
+| `GIT_EMAIL` | Git author email. |
+
+No release configuration variables or token are required. The shared actions handle authentication setup, signing and hashing; this repository maintains only its version and Joomla metadata.
+
+## Changelogs and checks
+
+Record changes in both `CHANGELOG.md` and `joomengine_mcp_changelog.xml`, under exactly one `[[[NEXT_VERSION]]]` section. After release, create a new pending section. Keep released entries unchanged.
+
+Joomla identity is element `joomengine_mcp`, type `plugin`, folder `console`. Categories are `security`, `fix`, `language`, `addition`, `change`, `remove`, and `note`, with `item` children and matching Markdown headings. Compatibility warnings belong under Note. The manifest links to the raw GitHub XML changelog.
+
+Run `php tests/run.php` for source completeness and `php tests/release.php` for local metadata transitions. These checks do not publish a release. The 0.1.0 changelog describes the development baseline; published tags establish release availability.
diff --git a/joomengine_mcp_changelog.xml b/joomengine_mcp_changelog.xml
index 15ee61a..cb4c9e6 100644
--- a/joomengine_mcp_changelog.xml
+++ b/joomengine_mcp_changelog.xml
@@ -1,4 +1,34 @@
-
+
+ joomengine_mcp
+ plugin
+ console
+ [[[NEXT_VERSION]]]
+
+ - Add manual version releases with changelog freezing, immutable tags, Joomla update entries and synchronous OctoShoom checksums.
+ - Add a categorized Joomla changelog and document GitHub secrets and agent responsibilities.
+
+
+ - Install and test the unchanged repository source ZIP without a build or Composer step.
+ - Keep combined package assembly in the component's external OctoJPack process and separate package repository.
+ - Call git-user and OctoShoom directly as actions with fixed repository/feed settings and inherited authentication.
+
+
+ - Remove the local ZIP builder and release-asset/checksum publication implementation.
+ - Remove custom SSH setup, temporary action checkouts, duplicate hash checks and release-tool configuration variables.
+
+
+ - Install the compatible component first. The plugin release never invokes OctoJPack.
+ - Configure the six git-user secrets documented in docs/RELEASE.md before releasing.
+
+
+
+ joomengine_mcppluginconsole0.1.0
+
+ - Native Joomla console adapters, local-server authority, protocol output isolation, installer checks and shared component contracts.
+ - Explicit JCB synchronization, native command registration and installed console/stdio verification.
+
+ - Development baseline. Published immutable tags establish release availability.
+
diff --git a/src/Installer/InstallerScript.php b/src/Installer/InstallerScript.php
index 6fc70fb..2ff53c3 100644
--- a/src/Installer/InstallerScript.php
+++ b/src/Installer/InstallerScript.php
@@ -67,7 +67,7 @@ public function preflight(string $type, InstallerAdapter $adapter): bool
|| explode('.', $componentVersion)[0] !== explode('.', $pluginVersion)[0]
|| !is_file(JPATH_ADMINISTRATOR . '/components/com_joomengine_mcp/vendor/autoload.php'))
{
- throw new RuntimeException('Install and enable the built JoomEngine MCP component version 0.1.1 or later in the same major version before its console plugin.');
+ throw new RuntimeException('Install and enable JoomEngine MCP component version 0.1.1 or later in the same major version before its console plugin.');
}
return true;
diff --git a/tests/release.php b/tests/release.php
index 8ca6fc9..56d4efc 100644
--- a/tests/release.php
+++ b/tests/release.php
@@ -1,17 +1,14 @@
* @copyright Copyright (C) 2026 Vast Development Method. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
-$root = dirname(__DIR__);
-$version = (string) simplexml_load_file($root . '/joomengine_mcp.xml')->version;
-$name = 'plg_console_joomengine_mcp-' . $version . '.zip';
-$directory = sys_get_temp_dir() . '/mcp-plugin-release-' . bin2hex(random_bytes(8));
-mkdir($directory . '/tools', 0700, true);
+require dirname(__DIR__) . '/tools/release.php';
+$root = sys_get_temp_dir() . '/mcp-release-' . bin2hex(random_bytes(8));
+mkdir($root, 0700);
$checks = 0;
$check = static function (bool $condition, string $message) use (&$checks): void
{
@@ -21,82 +18,75 @@
}
$checks++;
- echo 'PASS ' . $message . PHP_EOL;
};
-$run = static function (string $directory, string $name): bool
+
+try
{
- $argv = [$directory . '/tools/update-feed.php', $directory . '/release.json', $directory . '/' . $name];
- ob_start();
+ $pending = 'joomengine_mcppluginconsole'
+ . '[[[NEXT_VERSION]]]- Release metadata.
';
+ file_put_contents($root . '/joomengine_mcp.xml', ''
+ . '1.0.0January 2026');
+ file_put_contents($root . '/joomengine_mcp_changelog.xml', $pending);
+ file_put_contents($root . '/CHANGELOG.md', "# Changelog\n\n## [[[NEXT_VERSION]]]\n\n### Fix\n\n- Release metadata.\n");
+ file_put_contents($root . '/joomengine_mcp_update_server.xml', '');
- try
- {
- require $argv[0];
+ $originalFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['prepare', 'v1.1.0'], $root);
+ $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml');
+ $check($manifest->getElementsByTagName('version')->item(0)->textContent === '1.1.0', 'Manifest uses the selected version.');
+ $check($manifest->getElementsByTagName('creationDate')->item(0)->textContent === gmdate('F Y'), 'Manifest date is updated.');
+ $check(!str_contains(file_get_contents($root . '/CHANGELOG.md'), '[[[NEXT_VERSION]]]')
+ && !str_contains(file_get_contents($root . '/joomengine_mcp_changelog.xml'), '[[[NEXT_VERSION]]]'), 'Both changelogs are frozen.');
+ $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $originalFeed, 'Preparing a tag leaves the feed unchanged.');
- return true;
- }
- catch (RuntimeException)
- {
- return false;
- }
- finally
- {
- ob_end_clean();
- }
-};
+ mcpRelease(['feed', '1.1.0'], $root);
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml');
+ $query = new DOMXPath($feed);
+ $check($query->evaluate('string(/updates/update[version="1.1.0"]/downloads/downloadurl)')
+ === 'https://github.com/joomengine/mcp_plugin/archive/refs/tags/v1.1.0.zip', 'Feed downloads the immutable source tag.');
+ $check($query->evaluate('string(/updates/update[version="1.1.0"]/folder)') === 'console', 'Feed identifies the console plugin.');
+ $check($query->query('/updates/update[version="1.1.0"]/sha512')->length === 0, 'Checksum generation belongs to OctoShoom.');
+ mcpReleaseAppend($feed->documentElement->firstChild, 'sha512', str_repeat('a', 128));
+ $feed->save($root . '/joomengine_mcp_update_server.xml');
+ $hashedFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['feed', '1.1.0'], $root);
+ $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $hashedFeed, 'Retry preserves existing feed bytes and hashes.');
-try
-{
- copy($root . '/tools/update-feed.php', $directory . '/tools/update-feed.php');
- copy($root . '/joomengine_mcp.xml', $directory . '/joomengine_mcp.xml');
- copy($root . '/joomengine_mcp_update_server.xml', $directory . '/joomengine_mcp_update_server.xml');
- copy($root . '/build/' . $name, $directory . '/' . $name);
- copy($root . '/build/' . $name . '.sha256', $directory . '/' . $name . '.sha256');
- $tag = 'v' . $version;
- $base = 'https://github.com/joomengine/mcp_plugin/releases/';
- $release = ['tag_name' => $tag, 'draft' => true, 'prerelease' => false, 'published_at' => '2026-09-21T00:00:00Z',
- 'html_url' => $base . 'tag/' . $tag, 'assets' => []];
+ file_put_contents($root . '/joomengine_mcp_changelog.xml', str_replace('',
+ '' . preg_replace('#?changelogs>#', '', $pending), file_get_contents($root . '/joomengine_mcp_changelog.xml')));
+ file_put_contents($root . '/CHANGELOG.md', "## [[[NEXT_VERSION]]]\n\n### Fix\n\n- Next release.\n\n"
+ . file_get_contents($root . '/CHANGELOG.md'));
+
+ mcpRelease(['prepare', '1.2.0'], $root);
+ mcpRelease(['feed', '1.2.0'], $root);
+ $query = new DOMXPath(mcpReleaseXml($root . '/joomengine_mcp_update_server.xml'));
+ $check($query->query('/updates/update')->length === 2, 'Next release retains the previous update.');
+ $check($query->evaluate('string(/updates/update[version="1.1.0"]/sha512)') === str_repeat('a', 128), 'Next release retains the previous checksum.');
- foreach ([$name, $name . '.sha256'] as $asset)
+ foreach (['01.1.0', '1.0', '1.0.0;false'] as $invalid)
{
- $release['assets'][] = ['name' => $asset, 'state' => 'uploaded', 'size' => filesize($directory . '/' . $asset),
- 'browser_download_url' => $base . 'download/' . $tag . '/' . $asset];
+ $rejected = false;
+
+ try
+ {
+ mcpRelease(['prepare', $invalid], $root);
+ }
+ catch (RuntimeException)
+ {
+ $rejected = true;
+ }
+
+ $check($rejected, 'Invalid versions are rejected.');
}
- $write = static fn () => file_put_contents($directory . '/release.json', json_encode($release, JSON_THROW_ON_ERROR));
- $before = file_get_contents($directory . '/joomengine_mcp_update_server.xml');
- $write();
- $check(!$run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before,
- 'Unpublished releases cannot advertise an update');
- $release['draft'] = false;
- file_put_contents($directory . '/release.json', json_encode($release, JSON_THROW_ON_ERROR));
- $check($run($directory, $name), 'Published matching release generates an update');
- $feed = simplexml_load_file($directory . '/joomengine_mcp_update_server.xml');
- $entries = $feed->xpath('update[version="' . $version . '"]');
- $check(count($entries) === 1 && (string) $entries[0]->sha256 === hash_file('sha256', $directory . '/' . $name)
- && (string) $entries[0]->downloads->downloadurl === $release['assets'][0]['browser_download_url'],
- 'Published feed binds the correct archive URL, version and checksum');
- $before = file_get_contents($directory . '/joomengine_mcp_update_server.xml');
- $check($run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before,
- 'Repeating verified metadata generation is idempotent');
- file_put_contents($directory . '/' . $name . '.sha256', str_repeat('0', 64) . ' ' . $name . "\n");
- $check(!$run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before,
- 'Mismatched downloaded checksums leave the published feed unchanged');
+ echo json_encode(['checks' => $checks, 'metadataTransitions' => 'passed'], JSON_THROW_ON_ERROR) . "\n";
}
finally
{
- foreach (glob($directory . '/tools/*') as $file)
+ foreach (glob($root . '/*') as $path)
{
- unlink($file);
+ unlink($path);
}
- rmdir($directory . '/tools');
-
- foreach (glob($directory . '/*') as $file)
- {
- unlink($file);
- }
-
- rmdir($directory);
+ rmdir($root);
}
-
-echo json_encode(['checks' => $checks, 'releaseMetadata' => 'passed'], JSON_THROW_ON_ERROR) . PHP_EOL;
diff --git a/tests/run.php b/tests/run.php
index f867a3a..efb330b 100644
--- a/tests/run.php
+++ b/tests/run.php
@@ -10,7 +10,7 @@
$root = dirname(__DIR__);
$manifest = simplexml_load_file($root . '/joomengine_mcp.xml');
-if ($manifest === false || (string) $manifest['group'] !== 'console'
+if ($manifest === false || (string) $manifest['type'] !== 'plugin' || (string) $manifest['group'] !== 'console'
|| (string) $manifest->namespace !== 'VDM\\Plugin\\Console\\JoomEngineMcp'
|| (string) $manifest->files->folder[0]['plugin'] !== 'joomengine_mcp')
{
@@ -32,44 +32,81 @@
throw new RuntimeException('Update or changelog XML is invalid.');
}
-require $root . '/build.php';
-$archive = $root . '/build/plg_console_joomengine_mcp-' . (string) $manifest->version . '.zip';
-$firstHash = hash_file('sha256', $archive);
-require $root . '/build.php';
+/** Every installed file must already exist in the downloaded source tree. */
+$sourcePath = static function (string $relative) use ($root): string
+{
+ $resolved = realpath($root . '/' . $relative);
+
+ if ($relative === '' || str_starts_with($relative, '/') || str_contains($relative, '\\')
+ || in_array('..', explode('/', $relative), true) || $resolved === false
+ || !str_starts_with($resolved, $root . '/') || is_link($root . '/' . $relative))
+ {
+ throw new RuntimeException('Missing or unsafe manifest source path: ' . $relative);
+ }
-if (!hash_equals($firstHash, hash_file('sha256', $archive)))
+ return $resolved;
+};
+$installed = ['joomengine_mcp.xml' => true];
+$script = (string) $manifest->scriptfile;
+
+if (!is_file($sourcePath($script)))
{
- throw new RuntimeException('The same plugin source did not produce a reproducible archive.');
+ throw new RuntimeException('The installer script is missing from the source tree.');
}
-$zip = new ZipArchive();
-$zip->open($archive);
+$installed[$script] = true;
-foreach (['joomengine_mcp.xml', 'services/provider.php', 'src/Extension/JoomEngineMcpPlugin.php', 'src/Console/McpCommand.php', 'script.php', 'LICENSE'] as $required)
+foreach ($manifest->files->children() as $entry)
{
- if ($zip->locateName($required) === false)
+ $relative = (string) $entry;
+ $path = $sourcePath($relative);
+
+ if ($entry->getName() === 'folder')
{
- throw new RuntimeException('The plugin archive is missing an installation dependency.');
+ if (!is_dir($path))
+ {
+ throw new RuntimeException('A manifest folder is not a source directory: ' . $relative);
+ }
+
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS)) as $file)
+ {
+ if ($file->isLink())
+ {
+ throw new RuntimeException('Installed plugin sources must not contain symbolic links.');
+ }
+
+ if ($file->isFile())
+ {
+ $installed[substr($file->getPathname(), strlen($root) + 1)] = true;
+ }
+ }
+ }
+ elseif ($entry->getName() === 'filename' && is_file($path))
+ {
+ $installed[$relative] = true;
+ }
+ else
+ {
+ throw new RuntimeException('Invalid manifest file entry: ' . $relative);
}
}
-for ($index = 0; $index < $zip->numFiles; $index++)
+foreach ($manifest->languages->language as $entry)
{
- $name = $zip->getNameIndex($index);
- $system = 0;
- $attributes = 0;
-
- if (!$zip->getExternalAttributesIndex($index, $system, $attributes)
- || $system !== ZipArchive::OPSYS_UNIX || ($attributes >> 16) !== 0100644)
+ if (!is_file($sourcePath((string) $entry)) || parse_ini_file($sourcePath((string) $entry)) === false)
{
- throw new RuntimeException('The plugin archive does not normalize source file permissions.');
+ throw new RuntimeException('The plugin language source is missing or invalid.');
}
+}
- if (str_starts_with($name, '/') || str_contains($name, '..') || str_starts_with($name, 'tests/') || str_ends_with($name, '.ts'))
+foreach (['services/provider.php', 'src/Extension/JoomEngineMcpPlugin.php', 'src/Console/McpCommand.php',
+ 'src/Console/OutputGuard.php', 'src/Installer/InstallerScript.php', 'script.php', 'LICENSE'] as $required)
+{
+ if (!isset($installed[$required]))
{
- throw new RuntimeException('The plugin archive contains a forbidden path.');
+ throw new RuntimeException('The source manifest does not install a runtime dependency: ' . $required);
}
}
-$zip->close();
-echo json_encode(['manifest' => 'passed', 'languages' => 'passed', 'package' => 'passed', 'reproducible' => true, 'installedRuntime' => 'Run tests/installed.php separately.'], JSON_PRETTY_PRINT) . PHP_EOL;
+echo json_encode(['manifest' => 'passed', 'languages' => 'passed', 'sourceInstallation' => 'complete',
+ 'installedRuntime' => 'Run tests/installed.php separately.'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL;
diff --git a/tools/release.php b/tools/release.php
new file mode 100644
index 0000000..e304c82
--- /dev/null
+++ b/tools/release.php
@@ -0,0 +1,121 @@
+
+ * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+/** Read repository release metadata. */
+function mcpReleaseXml(string $path): DOMDocument
+{
+ $document = new DOMDocument('1.0', 'utf-8');
+ $document->preserveWhiteSpace = false;
+ $document->formatOutput = true;
+
+ if (!$document->load($path, LIBXML_NONET) || $document->doctype !== null)
+ {
+ throw new RuntimeException('Invalid release XML: ' . $path);
+ }
+
+ return $document;
+}
+
+/** Append an XML element with escaped text. */
+function mcpReleaseAppend(DOMNode $parent, string $name, string $value = ''): DOMElement
+{
+ $node = $parent->ownerDocument->createElement($name);
+ $node->appendChild($parent->ownerDocument->createTextNode($value));
+ $parent->appendChild($node);
+
+ return $node;
+}
+
+/** Freeze the plugin version or add its tagged download to the Joomla feed. */
+function mcpRelease(array $arguments, string $root): void
+{
+ [$command, $version] = array_pad($arguments, 2, '');
+ $version = preg_replace('/\Av/', '', $version);
+
+ if (!in_array($command, ['prepare', 'feed'], true)
+ || preg_match('/\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z/D', $version) !== 1)
+ {
+ throw new RuntimeException('Usage: release.php prepare|feed VERSION');
+ }
+
+ $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml');
+ $manifestVersion = $manifest->getElementsByTagName('version')->item(0);
+ $writes = [];
+
+ if ($command === 'prepare')
+ {
+ foreach (['CHANGELOG.md', 'joomengine_mcp_changelog.xml'] as $path)
+ {
+ $contents = file_get_contents($root . '/' . $path);
+
+ if (substr_count($contents, '[[[NEXT_VERSION]]]') !== 1
+ || version_compare($version, $manifestVersion->textContent, '<'))
+ {
+ throw new RuntimeException('Use an unreleased version and exactly one [[[NEXT_VERSION]]] section in each changelog.');
+ }
+
+ $writes[$path] = str_replace('[[[NEXT_VERSION]]]', $version, $contents);
+ }
+
+ $manifestVersion->nodeValue = $version;
+ $manifest->getElementsByTagName('creationDate')->item(0)->nodeValue = gmdate('F Y');
+ $writes['joomengine_mcp.xml'] = $manifest->saveXML();
+ }
+ else
+ {
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml');
+
+ if ((new DOMXPath($feed))->query('/updates/update[version="' . $version . '"]')->length > 0)
+ {
+ return;
+ }
+
+ $entry = $feed->createElement('update');
+ $feed->documentElement->insertBefore($entry, $feed->documentElement->firstChild);
+
+ foreach (['name' => 'JoomEngine MCP Console', 'description' => 'JoomEngine MCP console plugin.',
+ 'element' => 'joomengine_mcp', 'type' => 'plugin', 'version' => $version, 'folder' => 'console'] as $name => $value)
+ {
+ mcpReleaseAppend($entry, $name, $value);
+ }
+
+ $download = mcpReleaseAppend(mcpReleaseAppend($entry, 'downloads'), 'downloadurl',
+ 'https://github.com/joomengine/mcp_plugin/archive/refs/tags/v' . $version . '.zip');
+ $download->setAttribute('type', 'full');
+ $download->setAttribute('format', 'zip');
+ mcpReleaseAppend(mcpReleaseAppend($entry, 'tags'), 'tag', 'stable');
+ $platform = mcpReleaseAppend($entry, 'targetplatform');
+ $platform->setAttribute('name', 'joomla');
+ $platform->setAttribute('version', '6\\.[1-9][0-9]*');
+ mcpReleaseAppend($entry, 'php_minimum', '8.3.0');
+ mcpReleaseAppend($entry, 'detailsurl', 'https://github.com/joomengine/mcp_plugin/tree/v' . $version);
+ mcpReleaseAppend($entry, 'changelogurl', 'https://raw.githubusercontent.com/joomengine/mcp_plugin/main/joomengine_mcp_changelog.xml');
+ $writes['joomengine_mcp_update_server.xml'] = $feed->saveXML();
+ }
+
+ foreach ($writes as $path => $contents)
+ {
+ if (file_put_contents($root . '/' . $path, $contents) === false)
+ {
+ throw new RuntimeException('Cannot write release metadata: ' . $path);
+ }
+ }
+}
+
+if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__)
+{
+ try
+ {
+ mcpRelease(array_slice($argv, 1), dirname(__DIR__));
+ }
+ catch (Throwable $error)
+ {
+ fwrite(STDERR, $error->getMessage() . "\n");
+ exit(1);
+ }
+}
diff --git a/tools/update-feed.php b/tools/update-feed.php
deleted file mode 100644
index d3076d6..0000000
--- a/tools/update-feed.php
+++ /dev/null
@@ -1,125 +0,0 @@
-
- * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved.
- * @license GNU General Public License version 3 or later; see LICENSE
- */
-
-/** Generate Joomla update metadata only for an already published verified release. */
-$root = dirname(__DIR__);
-$manifest = simplexml_load_file($root . '/joomengine_mcp.xml');
-$version = (string) $manifest->version;
-$metadata = $argv[1] ?? '';
-$archive = $argv[2] ?? '';
-
-if (PHP_SAPI !== 'cli' || !is_file($metadata) || !is_file($archive)
- || preg_match('/\A\d+\.\d+\.\d+\z/D', $version) !== 1)
-{
- throw new RuntimeException('Supply published GitHub release JSON and its downloaded plugin ZIP for a stable manifest version.');
-}
-
-$release = json_decode(file_get_contents($metadata), true, 64, JSON_THROW_ON_ERROR);
-$tag = 'v' . $version;
-$filename = 'plg_console_joomengine_mcp-' . $version . '.zip';
-$base = 'https://github.com/joomengine/mcp_plugin/releases/';
-$url = $base . 'download/' . $tag . '/' . $filename;
-
-if (($release['tag_name'] ?? '') !== $tag || ($release['draft'] ?? true) || ($release['prerelease'] ?? true)
- || ($release['html_url'] ?? '') !== $base . 'tag/' . $tag || empty($release['published_at']) || basename($archive) !== $filename)
-{
- throw new RuntimeException('Update feeds require the matching published stable GitHub release.');
-}
-
-$assets = [];
-
-foreach ($release['assets'] ?? [] as $asset)
-{
- $assets[$asset['name']] = $asset;
-}
-
-foreach ([$filename, $filename . '.sha256'] as $asset)
-{
- if (($assets[$asset]['state'] ?? '') !== 'uploaded'
- || ($assets[$asset]['browser_download_url'] ?? '') !== $base . 'download/' . $tag . '/' . $asset)
- {
- throw new RuntimeException('A release archive or checksum has not been published at its immutable version URL.');
- }
-}
-
-$checksum = hash_file('sha256', $archive);
-$expected = is_file($archive . '.sha256') ? trim(file_get_contents($archive . '.sha256')) : '';
-
-if (!hash_equals($checksum . ' ' . $filename, $expected) || (int) ($assets[$filename]['size'] ?? -1) !== filesize($archive))
-{
- throw new RuntimeException('The downloaded release archive does not match its published checksum or asset size.');
-}
-
-$zip = new ZipArchive();
-
-if ($zip->open($archive) !== true)
-{
- throw new RuntimeException('The published archive is not a ZIP.');
-}
-
-$packaged = simplexml_load_string((string) $zip->getFromName('joomengine_mcp.xml'));
-$zip->close();
-
-if ($packaged === false || (string) $packaged->version !== $version
- || (string) $packaged['group'] !== 'console' || (string) $packaged->namespace !== (string) $manifest->namespace)
-{
- throw new RuntimeException('The published archive has a different extension identity or version.');
-}
-
-$document = new DOMDocument('1.0', 'utf-8');
-$document->preserveWhiteSpace = false;
-$document->formatOutput = true;
-
-if (!$document->load($root . '/joomengine_mcp_update_server.xml', LIBXML_NONET) || $document->documentElement->nodeName !== 'updates')
-{
- throw new RuntimeException('The existing update feed is invalid.');
-}
-
-$query = new DOMXPath($document);
-
-foreach ($query->query('/updates/update[version="' . $version . '"]') as $old)
-{
- $old->parentNode->removeChild($old);
-}
-
-$update = $document->createElement('update');
-$append = static function (DOMNode $parent, string $name, string $value) use ($document): DOMElement
-{
- $element = $document->createElement($name);
- $element->appendChild($document->createTextNode($value));
- $parent->appendChild($element);
-
- return $element;
-};
-$append($update, 'name', 'JoomEngine MCP Console');
-$append($update, 'description', 'Local Joomla console integration for JoomEngine MCP.');
-$append($update, 'element', 'joomengine_mcp');
-$append($update, 'type', 'plugin');
-$append($update, 'folder', 'console');
-$append($update, 'version', $version);
-$downloads = $document->createElement('downloads');
-$update->appendChild($downloads);
-$download = $append($downloads, 'downloadurl', $url);
-$download->setAttribute('type', 'full');
-$download->setAttribute('format', 'zip');
-$append($update, 'sha256', $checksum);
-$append($update, 'tags', '')->appendChild($document->createElement('tag', 'stable'));
-$target = $append($update, 'targetplatform', '');
-$target->setAttribute('name', 'joomla');
-$target->setAttribute('version', '6\\.[1-9][0-9]*');
-$append($update, 'php_minimum', '8.3.0');
-$append($update, 'detailsurl', $release['html_url']);
-$document->documentElement->appendChild($update);
-
-if ($document->save($root . '/joomengine_mcp_update_server.xml') === false)
-{
- throw new RuntimeException('Cannot save verified release metadata.');
-}
-
-echo 'Published update metadata for ' . $tag . PHP_EOL;