From 0fefe522474f29eee17f0f8d324fe74b09656b14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com>
Date: Mon, 28 Sep 2026 12:23:35 +0200
Subject: [PATCH 1/2] Release installable plugin source tags with OctoShoom
---
.github/workflows/ci.yml | 25 ++-
.github/workflows/installed.yml | 32 ++--
.github/workflows/release.yml | 170 +++++++++++++++------
AGENTS.md | 11 +-
CHANGELOG.md | 38 ++++-
README.md | 14 +-
build.php | 76 ---------
docs/IMPLEMENTATION.md | 12 +-
docs/RELEASE.md | 50 ++++++
joomengine_mcp_changelog.xml | 28 +++-
src/Installer/InstallerScript.php | 2 +-
tests/release.php | 172 ++++++++++++++-------
tests/run.php | 85 ++++++++---
tools/release.php | 246 ++++++++++++++++++++++++++++++
tools/update-feed.php | 125 ---------------
15 files changed, 708 insertions(+), 378 deletions(-)
delete mode 100644 build.php
create mode 100644 docs/RELEASE.md
create mode 100644 tools/release.php
delete mode 100644 tools/update-feed.php
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..1080060 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,74 +1,148 @@
-name: Publish console plugin release
+name: Release console plugin with OctoShoom
on:
workflow_dispatch:
+ inputs:
+ version:
+ description: 'Stable version to release (for example 1.2.3 or v1.2.3); repeat to resume a failed release'
+ required: true
+ type: string
permissions:
- contents: read
+ contents: write
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
- if: github.ref == 'refs/heads/main'
+ release:
runs-on: ubuntu-latest
- timeout-minutes: 15
- permissions:
- contents: write
+ timeout-minutes: 30
+ env:
+ RELEASE_BRANCH: ${{ vars.RELEASE_BRANCH || github.event.repository.default_branch }}
+ INPUT_VERSION: ${{ inputs.version }}
+ OCTOSHOOM_REPOSITORY: ${{ vars.OCTOSHOOM_REPOSITORY }}
+ OCTOSHOOM_REF: ${{ vars.OCTOSHOOM_REF }}
+ RELEASE_SSH_KNOWN_HOSTS: ${{ vars.RELEASE_SSH_KNOWN_HOSTS }}
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ GIT_AUTHOR_NAME: github-actions[bot]
+ GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: github-actions[bot]
+ GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
+
steps:
- - uses: actions/checkout@v7
+ - name: Validate release configuration before changing the repository
+ id: configuration
+ env:
+ RELEASE_SSH_KEY: ${{ secrets.RELEASE_SSH_KEY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ fail() { echo "::error::$1"; exit 1; }
+ for variable in RELEASE_BRANCH OCTOSHOOM_REPOSITORY OCTOSHOOM_REF RELEASE_SSH_KNOWN_HOSTS GH_TOKEN RELEASE_SSH_KEY; do
+ [[ -n "${!variable}" ]] || fail "Configure the required variable or secret: $variable"
+ done
+ [[ "$GITHUB_REF" == "refs/heads/$RELEASE_BRANCH" ]] || fail "Run this workflow from $RELEASE_BRANCH."
+ git check-ref-format "refs/heads/$RELEASE_BRANCH"
+ [[ "$OCTOSHOOM_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail 'Invalid OCTOSHOOM_REPOSITORY.'
+ [[ "$OCTOSHOOM_REF" =~ ^[a-fA-F0-9]{40}$ ]] || fail 'Pin OCTOSHOOM_REF to a full reviewed commit SHA.'
+ version="${INPUT_VERSION#v}"
+ [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail 'Use a stable version such as 1.2.3.'
+ printf 'RELEASE_VERSION=%s\nRELEASE_TAG=v%s\n' "$version" "$version" >> "$GITHUB_ENV"
+ printf 'version=%s\ntag=v%s\n' "$version" "$version" >> "$GITHUB_OUTPUT"
+ mkdir -p "$RUNNER_TEMP/release-ssh"
+ chmod 700 "$RUNNER_TEMP/release-ssh"
+ printf '%s\n' "$RELEASE_SSH_KEY" > "$RUNNER_TEMP/release-ssh/key"
+ printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$RUNNER_TEMP/release-ssh/known_hosts"
+ chmod 600 "$RUNNER_TEMP/release-ssh/key" "$RUNNER_TEMP/release-ssh/known_hosts"
+ printf 'GIT_SSH_COMMAND=ssh -i %s/release-ssh/key -o IdentitiesOnly=yes -o UserKnownHostsFile=%s/release-ssh/known_hosts -o StrictHostKeyChecking=yes\n' "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
+ git config --global user.name "$GIT_AUTHOR_NAME"
+ git config --global user.email "$GIT_AUTHOR_EMAIL"
+ config="$(jq -cn --arg owner "${GITHUB_REPOSITORY%/*}" --arg repo "${GITHUB_REPOSITORY#*/}" --arg branch "$RELEASE_BRANCH" \
+ '{update_servers:[{owner:$owner,repo:$repo,branch:$branch,path:"joomengine_mcp_update_server.xml"}]}')"
+ printf 'shoom=%s\n' "$config" >> "$GITHUB_OUTPUT"
+
+ - name: Check out the current release branch
+ uses: actions/checkout@v7
with:
+ ref: ${{ env.RELEASE_BRANCH }}
fetch-depth: 0
+ token: ${{ secrets.RELEASE_TOKEN }}
+ - name: Check out the reviewed OctoShoom action
+ uses: actions/checkout@v7
+ with:
+ repository: ${{ vars.OCTOSHOOM_REPOSITORY }}
+ ref: ${{ vars.OCTOSHOOM_REF }}
+ path: .release-actions/octoshoom
+ token: ${{ secrets.RELEASE_TOKEN }}
+ persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, simplexml, zip
coverage: none
- - name: Validate immutable version and rebuild package
- env:
- GH_TOKEN: ${{ github.token }}
+
+ - name: Verify metadata transitions
+ run: php tests/release.php
+ - name: Freeze the changelogs and create the immutable tag
+ shell: bash
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]+$ ]]
- 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 fetch origin "$RELEASE_BRANCH" --tags
+ git checkout -B release-source "origin/$RELEASE_BRANCH"
+ if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then
+ git merge-base --is-ancestor "$RELEASE_TAG^{commit}" "origin/$RELEASE_BRANCH"
+ git worktree add --detach "$RUNNER_TEMP/released-source" "$RELEASE_TAG"
+ php -r 'require $argv[1]; mcpRelease(["verify-tag", $argv[3], $argv[4], $argv[5]], $argv[2]);' \
+ tools/release.php "$RUNNER_TEMP/released-source" "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
+ git worktree remove "$RUNNER_TEMP/released-source"
+ echo "Resuming $RELEASE_TAG; its tag and release metadata remain unchanged."
+ else
+ php tools/release.php prepare "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
+ git add joomengine_mcp.xml CHANGELOG.md joomengine_mcp_changelog.xml
+ git commit -m "Release $RELEASE_TAG"
+ git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
+ git push --atomic origin "HEAD:refs/heads/$RELEASE_BRANCH" "refs/tags/$RELEASE_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 }}
+ - name: Add the Joomla update entry after its tag exists
+ shell: bash
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
+ # Fetch current main again: a retry or another contributor may have advanced it.
+ git fetch origin "$RELEASE_BRANCH"
+ git checkout -B release-source "origin/$RELEASE_BRANCH"
+ php tools/release.php feed "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
+ git add joomengine_mcp_update_server.xml
+ if ! git diff --cached --quiet; then
+ git commit -m "Add $RELEASE_TAG to the Joomla update server"
+ git push origin "HEAD:refs/heads/$RELEASE_BRANCH"
+ fi
+ - name: Generate and publish update hashes with OctoShoom
+ uses: ./.release-actions/octoshoom
+ with:
+ config-json: ${{ steps.configuration.outputs.shoom }}
+ git-url: github.com
+ git-user: github-actions[bot]
+ git-email: 41898282+github-actions[bot]@users.noreply.github.com
+ commit-message: 'Add Joomla release archive SHA-512 values'
+ - name: Verify the committed SHA-512 against the actual tag download
+ shell: bash
run: |
set -euo pipefail
- git config user.name 'github-actions[bot]'
- git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- git add joomengine_mcp_update_server.xml
- git commit -m "Publish console plugin $PLUGIN_VERSION update metadata"
- git push origin HEAD:main
+ git fetch origin "$RELEASE_BRANCH"
+ git show "origin/$RELEASE_BRANCH:joomengine_mcp_update_server.xml" > joomengine_mcp_update_server.xml
+ curl --fail --silent --show-error --location --retry 3 \
+ "https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/$RELEASE_TAG.zip" \
+ --output "$RUNNER_TEMP/released-extension.zip"
+ php tools/release.php verify-hash "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH" "$RUNNER_TEMP/released-extension.zip"
+
+ - name: Record the completed extension release
+ shell: bash
+ run: |
+ printf 'Released [%s](https://github.com/%s/tree/%s). The Joomla update server contains the verified OctoShoom SHA-512.\n' \
+ "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY"
+ - name: Remove temporary authentication material
+ if: always()
+ shell: bash
+ run: rm -rf -- "$RUNNER_TEMP/release-ssh"
diff --git a/AGENTS.md b/AGENTS.md
index d963a7c..ab55e1a 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,12 @@ 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 the shared OctoJPack tool and selects this plugin's immutable tag. The resulting Joomla package belongs to a separate configured 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. Configure tool repositories/refs and secrets using the documented GitHub variables in `docs/RELEASE.md`; never move an existing tag.
+- 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, release metadata for successive versions, retry behavior and failed hash behavior. Improve hash/package tools in their own repositories; do not maintain alternate implementations here.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c428673..196a2a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,15 +1,37 @@
# 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 configuration, safe retries 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.
+
+### Remove
+
+- Remove the local plugin ZIP builder and release-asset/checksum publication implementation.
+
+### Note
+
+- Install the compatible component before the console plugin. The plugin release does not invoke OctoJPack.
+- Configure the GitHub variables and 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..31942cc 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 with the next version. It freezes both changelogs, creates the immutable source tag, adds its ZIP URL to the Joomla update feed, and waits for OctoShoom to commit the checksum. The plugin release stops there. The component's release invokes OctoJPack separately. [Release instructions](docs/RELEASE.md) describe GitHub variables, secrets and safe retries.
-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..0c27ed1 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,6 @@ 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 wait for OctoShoom to commit the checksum. Safe retries verify existing tag metadata without moving the tag. This plugin never builds a combined package or invokes OctoJPack. See RELEASE.md. No release has been run by this implementation work.
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..b76d79b
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,50 @@
+# Console plugin releases
+
+The repository source ZIP is the installable Joomla console plugin. Download **Code → Download ZIP**, or a tagged source ZIP, and upload it through Joomla's extension installer after installing the compatible component. There is no local ZIP builder and no Composer step. This repository contains no combined Joomla package.
+
+## Release a version
+
+After merging reviewed changes and checking CI, open Actions → **Release console plugin with OctoShoom**, select the configured release branch and enter the next stable `X.Y.Z` version. A `v` prefix is optional; Git tags use `vX.Y.Z`, Joomla metadata uses `X.Y.Z`.
+
+1. Validate configuration and run the metadata transition checks.
+2. Freeze `[[[NEXT_VERSION]]]` in `CHANGELOG.md` and `joomengine_mcp_changelog.xml`; update the plugin manifest's version/date and live metadata URLs. Atomically push the metadata commit and immutable tag.
+3. Add the tag's source ZIP URL to `joomengine_mcp_update_server.xml` and commit it, retaining earlier releases.
+4. Run the shared OctoShoom action synchronously. It downloads the tagged archive, adds SHA-512 and commits the feed. Verify the committed checksum against the real tag download before reporting success.
+
+The workflow stops after OctoShoom. It never invokes OctoJPack. Once this release succeeds, configure its exact `CONSOLE_TAG` in the component repository and run the component release, which invokes OctoJPack and publishes the combined package to the separate package repository.
+
+Use the manual version workflow rather than pushing a bare tag: metadata must be frozen before the tag is created. The first run fills the initially empty feed with a real tagged download. No GitHub Release assets or fabricated historical entries are needed.
+
+## GitHub variables and secrets
+
+Set these under **Settings → Secrets and variables → Actions**.
+
+| Variable | Value |
+| --- | --- |
+| `RELEASE_BRANCH` | Optional release branch; defaults to this repository's default branch. Select it when running the workflow. |
+| `OCTOSHOOM_REPOSITORY` | Shared hash action repository, normally `octoleo/octoshoom`. |
+| `OCTOSHOOM_REF` | Full reviewed 40-character commit SHA. Inspected compatible revision: `a4eba6191388335e0301f74969d92151bc0f520d`. |
+| `RELEASE_SSH_KNOWN_HOSTS` | Verified GitHub SSH `known_hosts` lines; strict checking is enabled. |
+
+| Secret | Access needed |
+| --- | --- |
+| `RELEASE_TOKEN` | GitHub token for source metadata/tag writes and tool-repository checkout. |
+| `RELEASE_SSH_KEY` | Unencrypted SSH private key with write access to this repository, used by OctoShoom. A write-enabled deploy key or machine/user identity can be used. |
+
+The identity must be permitted to push metadata and tags under your repository rules. Credentials stay in GitHub secrets. Source repository identity comes from `github.repository`; manifest and feed URLs are derived from it and the release branch.
+
+The component workflow reads the update-server URL from the released console manifest, so a configured release branch is supported.
+
+## Changelog convention
+
+Record every meaningful change in both `CHANGELOG.md` and `joomengine_mcp_changelog.xml`. Keep exactly one pending section headed `[[[NEXT_VERSION]]]`; after release, create a new pending section for further work. The workflow replaces the marker with its version input. Never rewrite released history.
+
+Joomla XML identity is `element` = `joomengine_mcp`, `type` = `plugin`, `folder` = `console`. Categories are `security`, `fix`, `language`, `addition`, `change`, `remove`, and `note`, containing `item` children. Use matching Markdown headings. Compatibility warnings belong under Note; errors fixed under Fix; security corrections under Security. The manifest's `changelogurl` points to the live XML file.
+
+The 0.1.0 entry describes the development baseline, not a published tag. Select a new unused version for the first release.
+
+## Retrying and verification
+
+Rerun the same version after an interrupted release. The workflow verifies the existing tag's manifest/changelog identity and ancestry, leaves the tag untouched, preserves earlier update entries and resumes feed/hash work. Missing or mismatched hashes fail the workflow; they cannot produce a success summary.
+
+Use `php tests/run.php` for source completeness and `php tests/release.php` for isolated positive/negative release transitions. CI also tests the unmodified source ZIP. Installed acceptance can select a component revision through `MCP_COMPONENT_REF` or its reusable workflow input; otherwise it checks the matching component branch when present, falling back to main. No release is published by those tests.
diff --git a/joomengine_mcp_changelog.xml b/joomengine_mcp_changelog.xml
index 15ee61a..2f774f7 100644
--- a/joomengine_mcp_changelog.xml
+++ b/joomengine_mcp_changelog.xml
@@ -1,4 +1,30 @@
-
+
+ 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 release configuration, retries 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.
+
+ - Remove the local ZIP builder and release-asset/checksum publication implementation.
+
+ - Install the compatible component first. The plugin release never invokes OctoJPack.
+ - Configure the GitHub variables and 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..aba672a 100644
--- a/tests/release.php
+++ b/tests/release.php
@@ -1,17 +1,15 @@
* @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);
+/** Exercise release transitions without network, publishing tags, or building packages. */
+require dirname(__DIR__) . '/tools/release.php';
+$directory = sys_get_temp_dir() . '/mcp-release-' . bin2hex(random_bytes(8));
+mkdir($directory, 0700, true);
$checks = 0;
$check = static function (bool $condition, string $message) use (&$checks): void
{
@@ -21,82 +19,138 @@
}
$checks++;
- echo 'PASS ' . $message . PHP_EOL;
};
-$run = static function (string $directory, string $name): bool
+$reject = static function (array $arguments, string $root) use ($check): void
{
- $argv = [$directory . '/tools/update-feed.php', $directory . '/release.json', $directory . '/' . $name];
- ob_start();
+ $before = [];
- try
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) as $file)
{
- require $argv[0];
+ $before[$file->getPathname()] = hash_file('sha256', $file->getPathname());
+ }
- return true;
+ $rejected = false;
+
+ try
+ {
+ mcpRelease($arguments, $root);
}
catch (RuntimeException)
{
- return false;
+ $rejected = true;
}
- finally
+
+ $check($rejected, 'Invalid release operation must fail.');
+
+ foreach ($before as $path => $hash)
{
- ob_end_clean();
+ $check(hash_file('sha256', $path) === $hash, 'Rejected operation must not change existing metadata.');
}
};
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' => []];
-
- foreach ([$name, $name . '.sha256'] as $asset)
+ foreach (['component', 'plugin'] as $type)
{
- $release['assets'][] = ['name' => $asset, 'state' => 'uploaded', 'size' => filesize($directory . '/' . $asset),
- 'browser_download_url' => $base . 'download/' . $tag . '/' . $asset];
+ $root = $directory . '/' . $type;
+ mkdir($root . '/plugins/webservices/joomengine_mcp', 0700, true);
+ $element = $type === 'component' ? 'com_joomengine_mcp' : 'joomengine_mcp';
+ $folder = $type === 'plugin' ? 'console' : '';
+ $changelogName = $type === 'component' ? 'changelog.xml' : 'joomengine_mcp_changelog.xml';
+ $pending = '' . $element . '' . $type . '' . $folder
+ . '[[[NEXT_VERSION]]]- Preserve existing releases & user changes.
';
+ file_put_contents($root . '/joomengine_mcp.xml', ''
+ . '1.0.0January 2026'
+ . 'https://example.invalid/old.xml'
+ . 'https://example.invalid/old-changelog.xml');
+ file_put_contents($root . '/plugins/webservices/joomengine_mcp/joomengine_mcp.xml',
+ '1.0.0January 2026');
+ file_put_contents($root . '/.octojpack', '{"package":{"version":"1.0.0"},"repository":{"owner":"[[[PACKAGE_OWNER]]]"}}');
+ file_put_contents($root . '/' . $changelogName, '' . $pending . '');
+ file_put_contents($root . '/CHANGELOG.md', "# Changelog\n\n## [[[NEXT_VERSION]]]\n\n### Fixed\n\n- Preserve updates.\n");
+ file_put_contents($root . '/joomengine_mcp_update_server.xml', '');
+ $repository = 'test-owner/' . $type;
+ $branch = $type === 'component' ? 'stable/6.x' : 'stable/release&next#1';
+ $encodedBranch = $type === 'component' ? 'stable/6.x' : 'stable/release%26next%231';
+
+ foreach (['01.1.0', '1.0', '1.0.0-beta', '1.0.0;false', '0.9.0'] as $invalid)
+ {
+ $reject(['prepare', $invalid, $repository, $branch], $root);
+ }
+
+ $reject(['prepare', '1.1.0', 'invalid repository', $branch], $root);
+ $reject(['prepare', '1.1.0', $repository, "main\ninjected"], $root);
+ $initialFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['prepare', 'v1.1.0', $repository, $branch], $root);
+ $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml', 'extension');
+ $check(mcpReleaseValue($manifest, '/extension/version') === '1.1.0', 'Release normalizes the v prefix.');
+ $check(mcpReleaseValue($manifest, '/extension/creationDate') === gmdate('F Y'), 'Release refreshes the manifest date.');
+ $check(mcpReleaseValue($manifest, '/extension/updateservers/server')
+ === 'https://raw.githubusercontent.com/' . $repository . '/' . $encodedBranch . '/joomengine_mcp_update_server.xml',
+ 'Repository and branch determine the live feed URL.');
+ $check(!str_contains(file_get_contents($root . '/CHANGELOG.md'), '[[[NEXT_VERSION]]]')
+ && !str_contains(file_get_contents($root . '/' . $changelogName), '[[[NEXT_VERSION]]]'),
+ 'Both pending changelog sections are frozen into the release.');
+ $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $initialFeed,
+ 'Preparing a tag does not advertise its download before the tag exists.');
+ mcpRelease(['verify-tag', '1.1.0', $repository, $branch], $root);
+ $checks++;
+ $reject(['verify-tag', '1.0.0', $repository, $branch], $root);
+ $reject(['prepare', '1.1.0', $repository, $branch], $root);
+ mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
+ $check(mcpReleaseValue($feed, '/updates/update/downloads/downloadurl')
+ === 'https://github.com/' . $repository . '/archive/refs/tags/v1.1.0.zip', 'Update uses the immutable repository tag ZIP.');
+ $check((new DOMXPath($feed))->query('/updates/update/sha512')->length === 0,
+ 'Only OctoShoom supplies the release checksum.');
+ $check(mcpReleaseValue($feed, '/updates/update/' . ($type === 'component' ? 'client' : 'folder'))
+ === ($type === 'component' ? '1' : 'console'), 'Update preserves Joomla extension identity.');
+ $archive = $root . '/archive.zip';
+ file_put_contents($archive, 'Synthetic archive bytes for checksum comparison only.');
+ $reject(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
+ mcpReleaseAppend($feed->documentElement->firstChild, 'sha512', hash_file('sha512', $archive));
+ $feed->save($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
+ $checks++;
+ $hashedFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
+ $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $hashedFeed,
+ 'Retry preserves published feed bytes including the OctoShoom hash.');
+ file_put_contents($archive, 'Tampered archive');
+ $reject(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
+ $reject(['feed', '1.1.0', 'different-owner/' . $type, $branch], $root);
+ $reject(['feed', '2.0.0', $repository, $branch], $root);
+
+ $changelog = file_get_contents($root . '/' . $changelogName);
+ file_put_contents($root . '/' . $changelogName, str_replace('', '' . $pending, $changelog));
+ $markdown = file_get_contents($root . '/CHANGELOG.md');
+ file_put_contents($root . '/CHANGELOG.md', str_replace('# Changelog', "# Changelog\n\n## [[[NEXT_VERSION]]]\n\n- Next changes.", $markdown));
+ mcpRelease(['prepare', '1.2.0', $repository, $branch], $root);
+ mcpRelease(['feed', '1.2.0', $repository, $branch], $root);
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
+ $check((new DOMXPath($feed))->query('/updates/update')->length === 2, 'Next release retains previous update entries.');
+ $check(strlen(mcpReleaseValue($feed, '/updates/update[version="1.1.0"]/sha512')) === 128,
+ 'Next release retains the previous immutable checksum.');
+ $latestFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
+ mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
+ $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $latestFeed,
+ 'Retrying an older published tag never rolls back a newer feed entry.');
+ $duplicate = $feed->documentElement->lastChild->cloneNode(true);
+ $feed->documentElement->appendChild($duplicate);
+ $feed->save($root . '/joomengine_mcp_update_server.xml');
+ $reject(['feed', '1.1.0', $repository, $branch], $root);
}
- $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',
+ 'publication' => 'not run; no network or package generation'], JSON_THROW_ON_ERROR) . "\n";
}
finally
{
- foreach (glob($directory . '/tools/*') as $file)
- {
- unlink($file);
- }
-
- rmdir($directory . '/tools');
-
- foreach (glob($directory . '/*') as $file)
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST) as $file)
{
- unlink($file);
+ $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($directory);
}
-
-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..3921496
--- /dev/null
+++ b/tools/release.php
@@ -0,0 +1,246 @@
+
+ * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved.
+ * @license GNU General Public License version 3 or later; see LICENSE
+ */
+
+/** Read local release metadata without resolving external XML entities. */
+function mcpReleaseXml(string $path, string $rootName): DOMDocument
+{
+ $document = new DOMDocument('1.0', 'utf-8');
+ $document->preserveWhiteSpace = false;
+ $document->formatOutput = true;
+
+ if (!is_file($path) || !$document->load($path, LIBXML_NONET)
+ || $document->doctype !== null || $document->documentElement->nodeName !== $rootName)
+ {
+ throw new RuntimeException('Invalid release XML: ' . $path);
+ }
+
+ return $document;
+}
+
+/** Append escaped text to an XML element. */
+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;
+}
+
+/** Require a single metadata value, preventing ambiguous manifests and feeds. */
+function mcpReleaseValue(DOMDocument $document, string $expression): string
+{
+ $nodes = (new DOMXPath($document))->query($expression);
+
+ if ($nodes === false || $nodes->length !== 1)
+ {
+ throw new RuntimeException('Expected exactly one XML value: ' . $expression);
+ }
+
+ return $nodes->item(0)->textContent;
+}
+
+/** Change one existing metadata value. */
+function mcpReleaseSet(DOMDocument $document, string $expression, string $value): void
+{
+ mcpReleaseValue($document, $expression);
+ $node = (new DOMXPath($document))->query($expression)->item(0);
+
+ while ($node->firstChild !== null)
+ {
+ $node->removeChild($node->firstChild);
+ }
+
+ $node->appendChild($document->createTextNode($value));
+}
+
+/** Prepare version metadata, append a tag update, or verify OctoShoom's published checksum. */
+function mcpRelease(array $arguments, string $root): void
+{
+ [$command, $version, $repository, $branch] = array_pad($arguments, 4, '');
+ $version = preg_replace('/\Av/', '', $version);
+
+ if (!in_array($command, ['prepare', 'verify-tag', 'feed', 'verify-hash'], true)
+ || preg_match('/\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z/D', $version) !== 1
+ || preg_match('/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/D', $repository) !== 1
+ || $branch === '' || preg_match('/[\x00-\x20\x7f?*\[\\\\~^:]/', $branch)
+ || str_contains($branch, '..') || str_contains($branch, '@{'))
+ {
+ throw new RuntimeException('Usage: release.php prepare|verify-tag|feed|verify-hash VERSION OWNER/REPOSITORY BRANCH [ARCHIVE]');
+ }
+
+ $tag = 'v' . $version;
+ $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml', 'extension');
+ $type = $manifest->documentElement->getAttribute('type');
+ $isComponent = $type === 'component';
+
+ if (!$isComponent && ($type !== 'plugin' || $manifest->documentElement->getAttribute('group') !== 'console'))
+ {
+ throw new RuntimeException('This release helper supports the MCP component or console plugin only.');
+ }
+
+ $element = $isComponent ? 'com_joomengine_mcp' : 'joomengine_mcp';
+ $changelogName = $isComponent ? 'changelog.xml' : 'joomengine_mcp_changelog.xml';
+ $changelog = mcpReleaseXml($root . '/' . $changelogName, 'changelogs');
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
+ $encodedBranch = implode('/', array_map('rawurlencode', explode('/', $branch)));
+ $rawBase = 'https://raw.githubusercontent.com/' . $repository . '/' . $encodedBranch . '/';
+ $archiveUrl = 'https://github.com/' . $repository . '/archive/refs/tags/' . $tag . '.zip';
+ $currentVersion = mcpReleaseValue($manifest, '/extension/version');
+ $entryPath = '/updates/update[version="' . $version . '"]';
+ $entryNodes = (new DOMXPath($feed))->query($entryPath);
+ $writes = [];
+
+ if ($command === 'prepare')
+ {
+ $markdown = file_get_contents($root . '/CHANGELOG.md');
+ $pending = (new DOMXPath($changelog))->query('/changelogs/changelog[version="[[[NEXT_VERSION]]]"]');
+ $existing = (new DOMXPath($changelog))->query('/changelogs/changelog[version="' . $version . '"]');
+
+ if (version_compare($version, $currentVersion, '<') || $existing->length !== 0 || $pending->length !== 1
+ || substr_count($markdown, '[[[NEXT_VERSION]]]') !== 1
+ || preg_match('/^## \[\[\[NEXT_VERSION\]\]\]/m', $markdown) !== 1)
+ {
+ throw new RuntimeException('Choose an unreleased version at least as new as the manifest and provide exactly one pending changelog section in both files.');
+ }
+
+ $pendingEntry = $pending->item(0);
+ $pendingQuery = new DOMXPath($changelog);
+
+ if ($pendingQuery->query('element[text()="' . $element . '"]', $pendingEntry)->length !== 1
+ || $pendingQuery->query('type[text()="' . $type . '"]', $pendingEntry)->length !== 1
+ || $pendingQuery->query('security/item|fix/item|language/item|addition/item|change/item|remove/item|note/item', $pendingEntry)->length === 0
+ || (!$isComponent && $pendingQuery->query('folder[text()="console"]', $pendingEntry)->length !== 1))
+ {
+ throw new RuntimeException('Pending Joomla changelog must identify this extension and contain categorized changes.');
+ }
+
+ mcpReleaseSet($manifest, '/extension/version', $version);
+ mcpReleaseSet($manifest, '/extension/creationDate', gmdate('F Y'));
+ mcpReleaseSet($manifest, '/extension/updateservers/server', $rawBase . 'joomengine_mcp_update_server.xml');
+ mcpReleaseSet($manifest, '/extension/changelogurl', $rawBase . $changelogName);
+ mcpReleaseSet($changelog, '/changelogs/changelog/version[text()="[[[NEXT_VERSION]]]"]', $version);
+ $writes['joomengine_mcp.xml'] = $manifest->saveXML();
+ $writes[$changelogName] = $changelog->saveXML();
+ $writes['CHANGELOG.md'] = str_replace('[[[NEXT_VERSION]]]', $version, $markdown);
+
+ if ($isComponent)
+ {
+ $routingPath = 'plugins/webservices/joomengine_mcp/joomengine_mcp.xml';
+ $routing = mcpReleaseXml($root . '/' . $routingPath, 'extension');
+ mcpReleaseSet($routing, '/extension/version', $version);
+ mcpReleaseSet($routing, '/extension/creationDate', gmdate('F Y'));
+ $configuration = json_decode(file_get_contents($root . '/.octojpack'), true, 64, JSON_THROW_ON_ERROR);
+ $configuration['package']['version'] = $version;
+ $writes[$routingPath] = $routing->saveXML();
+ $writes['.octojpack'] = json_encode($configuration, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
+ }
+ }
+ elseif ($command === 'verify-tag')
+ {
+ $record = '/changelogs/changelog[version="' . $version . '"]';
+
+ if ($currentVersion !== $version || mcpReleaseValue($changelog, $record . '/element') !== $element
+ || mcpReleaseValue($changelog, $record . '/type') !== $type
+ || (!$isComponent && mcpReleaseValue($changelog, $record . '/folder') !== 'console')
+ || mcpReleaseValue($manifest, '/extension/updateservers/server') !== $rawBase . 'joomengine_mcp_update_server.xml'
+ || mcpReleaseValue($manifest, '/extension/changelogurl') !== $rawBase . $changelogName
+ || !preg_match('/^## ' . preg_quote($version, '/') . '(?:\s|$)/m', file_get_contents($root . '/CHANGELOG.md')))
+ {
+ throw new RuntimeException('The existing tag does not contain the requested released extension metadata.');
+ }
+
+ if ($isComponent)
+ {
+ $routing = mcpReleaseXml($root . '/plugins/webservices/joomengine_mcp/joomengine_mcp.xml', 'extension');
+ $config = json_decode(file_get_contents($root . '/.octojpack'), true, 64, JSON_THROW_ON_ERROR);
+
+ if (mcpReleaseValue($routing, '/extension/version') !== $version || ($config['package']['version'] ?? '') !== $version)
+ {
+ throw new RuntimeException('The tag contains inconsistent routing plugin or OctoJPack versions.');
+ }
+ }
+ }
+ else
+ {
+ if ($entryNodes->length > 1 || version_compare($version, $currentVersion, '>'))
+ {
+ throw new RuntimeException('Duplicate update versions or update newer than the prepared manifest.');
+ }
+
+ if ($entryNodes->length === 1)
+ {
+ if (mcpReleaseValue($feed, $entryPath . '/element') !== $element
+ || mcpReleaseValue($feed, $entryPath . '/type') !== $type
+ || mcpReleaseValue($feed, $entryPath . '/downloads/downloadurl') !== $archiveUrl
+ || (!$isComponent && mcpReleaseValue($feed, $entryPath . '/folder') !== 'console'))
+ {
+ throw new RuntimeException('Existing update identity or tag URL differs; refusing to overwrite it.');
+ }
+ }
+ elseif ($command === 'feed')
+ {
+ $entry = $feed->createElement('update');
+ $feed->documentElement->insertBefore($entry, $feed->documentElement->firstChild);
+
+ foreach (['name' => $isComponent ? 'JoomEngine MCP' : 'JoomEngine MCP Console',
+ 'description' => $isComponent ? 'JoomEngine MCP component.' : 'JoomEngine MCP console plugin.',
+ 'element' => $element, 'type' => $type, 'version' => $version] as $name => $value)
+ {
+ mcpReleaseAppend($entry, $name, $value);
+ }
+
+ mcpReleaseAppend($entry, $isComponent ? 'client' : 'folder', $isComponent ? '1' : 'console');
+ $download = mcpReleaseAppend(mcpReleaseAppend($entry, 'downloads', ''), 'downloadurl', $archiveUrl);
+ $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/' . $repository . '/tree/' . $tag);
+ mcpReleaseAppend($entry, 'changelogurl', $rawBase . $changelogName);
+ $writes['joomengine_mcp_update_server.xml'] = $feed->saveXML();
+ }
+
+ if ($command === 'verify-hash')
+ {
+ $archive = $arguments[4] ?? '';
+ $hash = strtolower(mcpReleaseValue($feed, $entryPath . '/sha512'));
+
+ if (!is_file($archive) || preg_match('/\A[a-f0-9]{128}\z/D', $hash) !== 1
+ || !hash_equals($hash, hash_file('sha512', $archive)))
+ {
+ throw new RuntimeException('OctoShoom has not published a matching SHA-512 for the immutable tag archive.');
+ }
+ }
+ }
+
+ 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__));
+ echo "Release metadata verified.\n";
+ }
+ 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;
From c8ab6089f545b9bf9abda0c44eae983e0c270eb0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?eW=C9=98yn?= <5607939+Llewellynvdm@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:11:29 +0200
Subject: [PATCH 2/2] Simplify plugin release to native git-user and OctoShoom
actions
---
.github/workflows/release.yml | 147 ++++++-----------------
AGENTS.md | 7 +-
CHANGELOG.md | 6 +-
README.md | 2 +-
docs/IMPLEMENTATION.md | 4 +-
docs/RELEASE.md | 52 +++------
joomengine_mcp_changelog.xml | 10 +-
tests/release.php | 170 +++++++++------------------
tools/release.php | 211 +++++++---------------------------
9 files changed, 166 insertions(+), 443 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1080060..13a1009 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
inputs:
version:
- description: 'Stable version to release (for example 1.2.3 or v1.2.3); repeat to resume a failed release'
+ description: 'Version to release (for example 1.2.3 or v1.2.3)'
required: true
type: string
permissions:
- contents: write
+ contents: read
concurrency:
group: joomla-extension-release
@@ -17,132 +17,49 @@ concurrency:
jobs:
release:
+ if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
- timeout-minutes: 30
- env:
- RELEASE_BRANCH: ${{ vars.RELEASE_BRANCH || github.event.repository.default_branch }}
- INPUT_VERSION: ${{ inputs.version }}
- OCTOSHOOM_REPOSITORY: ${{ vars.OCTOSHOOM_REPOSITORY }}
- OCTOSHOOM_REF: ${{ vars.OCTOSHOOM_REF }}
- RELEASE_SSH_KNOWN_HOSTS: ${{ vars.RELEASE_SSH_KNOWN_HOSTS }}
- GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
-
steps:
- - name: Validate release configuration before changing the repository
- id: configuration
- env:
- RELEASE_SSH_KEY: ${{ secrets.RELEASE_SSH_KEY }}
- shell: bash
- run: |
- set -euo pipefail
- fail() { echo "::error::$1"; exit 1; }
- for variable in RELEASE_BRANCH OCTOSHOOM_REPOSITORY OCTOSHOOM_REF RELEASE_SSH_KNOWN_HOSTS GH_TOKEN RELEASE_SSH_KEY; do
- [[ -n "${!variable}" ]] || fail "Configure the required variable or secret: $variable"
- done
- [[ "$GITHUB_REF" == "refs/heads/$RELEASE_BRANCH" ]] || fail "Run this workflow from $RELEASE_BRANCH."
- git check-ref-format "refs/heads/$RELEASE_BRANCH"
- [[ "$OCTOSHOOM_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail 'Invalid OCTOSHOOM_REPOSITORY.'
- [[ "$OCTOSHOOM_REF" =~ ^[a-fA-F0-9]{40}$ ]] || fail 'Pin OCTOSHOOM_REF to a full reviewed commit SHA.'
- version="${INPUT_VERSION#v}"
- [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail 'Use a stable version such as 1.2.3.'
- printf 'RELEASE_VERSION=%s\nRELEASE_TAG=v%s\n' "$version" "$version" >> "$GITHUB_ENV"
- printf 'version=%s\ntag=v%s\n' "$version" "$version" >> "$GITHUB_OUTPUT"
- mkdir -p "$RUNNER_TEMP/release-ssh"
- chmod 700 "$RUNNER_TEMP/release-ssh"
- printf '%s\n' "$RELEASE_SSH_KEY" > "$RUNNER_TEMP/release-ssh/key"
- printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$RUNNER_TEMP/release-ssh/known_hosts"
- chmod 600 "$RUNNER_TEMP/release-ssh/key" "$RUNNER_TEMP/release-ssh/known_hosts"
- printf 'GIT_SSH_COMMAND=ssh -i %s/release-ssh/key -o IdentitiesOnly=yes -o UserKnownHostsFile=%s/release-ssh/known_hosts -o StrictHostKeyChecking=yes\n' "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- git config --global user.name "$GIT_AUTHOR_NAME"
- git config --global user.email "$GIT_AUTHOR_EMAIL"
- config="$(jq -cn --arg owner "${GITHUB_REPOSITORY%/*}" --arg repo "${GITHUB_REPOSITORY#*/}" --arg branch "$RELEASE_BRANCH" \
- '{update_servers:[{owner:$owner,repo:$repo,branch:$branch,path:"joomengine_mcp_update_server.xml"}]}')"
- printf 'shoom=%s\n' "$config" >> "$GITHUB_OUTPUT"
-
- - name: Check out the current release branch
- uses: actions/checkout@v7
+ - uses: actions/checkout@v7
with:
- ref: ${{ env.RELEASE_BRANCH }}
+ ref: main
fetch-depth: 0
- token: ${{ secrets.RELEASE_TOKEN }}
- - name: Check out the reviewed OctoShoom action
- uses: actions/checkout@v7
- with:
- repository: ${{ vars.OCTOSHOOM_REPOSITORY }}
- ref: ${{ vars.OCTOSHOOM_REF }}
- path: .release-actions/octoshoom
- token: ${{ secrets.RELEASE_TOKEN }}
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: Verify metadata transitions
- run: php tests/release.php
- - name: Freeze the changelogs and create the immutable tag
- shell: bash
+ - name: Publish the version and Joomla update entry
+ env:
+ VERSION: ${{ inputs.version }}
run: |
- set -euo pipefail
- git fetch origin "$RELEASE_BRANCH" --tags
- git checkout -B release-source "origin/$RELEASE_BRANCH"
- if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then
- git merge-base --is-ancestor "$RELEASE_TAG^{commit}" "origin/$RELEASE_BRANCH"
- git worktree add --detach "$RUNNER_TEMP/released-source" "$RELEASE_TAG"
- php -r 'require $argv[1]; mcpRelease(["verify-tag", $argv[3], $argv[4], $argv[5]], $argv[2]);' \
- tools/release.php "$RUNNER_TEMP/released-source" "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
- git worktree remove "$RUNNER_TEMP/released-source"
- echo "Resuming $RELEASE_TAG; its tag and release metadata remain unchanged."
- else
- php tools/release.php prepare "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
+ version="${VERSION#v}"
+ tag="v$version"
+ 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 $RELEASE_TAG"
- git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
- git push --atomic origin "HEAD:refs/heads/$RELEASE_BRANCH" "refs/tags/$RELEASE_TAG"
+ git commit -m "Release $tag"
+ git tag -a "$tag" -m "Release $tag"
+ git push --atomic origin HEAD:main "refs/tags/$tag"
fi
- - name: Add the Joomla update entry after its tag exists
- shell: bash
- run: |
- set -euo pipefail
- # Fetch current main again: a retry or another contributor may have advanced it.
- git fetch origin "$RELEASE_BRANCH"
- git checkout -B release-source "origin/$RELEASE_BRANCH"
- php tools/release.php feed "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH"
+ php tools/release.php feed "$version"
git add joomengine_mcp_update_server.xml
if ! git diff --cached --quiet; then
- git commit -m "Add $RELEASE_TAG to the Joomla update server"
- git push origin "HEAD:refs/heads/$RELEASE_BRANCH"
+ git commit -m "Add $tag to the Joomla update server"
+ git push origin HEAD:main
fi
- - name: Generate and publish update hashes with OctoShoom
- uses: ./.release-actions/octoshoom
+ - name: Update hashes
+ uses: octoleo/octoshoom@master
with:
- config-json: ${{ steps.configuration.outputs.shoom }}
- git-url: github.com
- git-user: github-actions[bot]
- git-email: 41898282+github-actions[bot]@users.noreply.github.com
- commit-message: 'Add Joomla release archive SHA-512 values'
- - name: Verify the committed SHA-512 against the actual tag download
- shell: bash
- run: |
- set -euo pipefail
- git fetch origin "$RELEASE_BRANCH"
- git show "origin/$RELEASE_BRANCH:joomengine_mcp_update_server.xml" > joomengine_mcp_update_server.xml
- curl --fail --silent --show-error --location --retry 3 \
- "https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/$RELEASE_TAG.zip" \
- --output "$RUNNER_TEMP/released-extension.zip"
- php tools/release.php verify-hash "$RELEASE_VERSION" "$GITHUB_REPOSITORY" "$RELEASE_BRANCH" "$RUNNER_TEMP/released-extension.zip"
-
- - name: Record the completed extension release
- shell: bash
- run: |
- printf 'Released [%s](https://github.com/%s/tree/%s). The Joomla update server contains the verified OctoShoom SHA-512.\n' \
- "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY"
- - name: Remove temporary authentication material
- if: always()
- shell: bash
- run: rm -rf -- "$RUNNER_TEMP/release-ssh"
+ 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 ab55e1a..0e3aca1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,8 +19,9 @@ Run syntax, provider/registration, manifest/package tests and coordinated instal
## 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 the shared OctoJPack tool and selects this plugin's immutable tag. The resulting Joomla package belongs to a separate configured 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. Configure tool repositories/refs and secrets using the documented GitHub variables in `docs/RELEASE.md`; never move an existing tag.
+- 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, release metadata for successive versions, retry behavior and failed hash behavior. Improve hash/package tools in their own repositories; do not maintain alternate implementations here.
+- 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 196a2a7..f59e8d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,21 +5,23 @@
### 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 configuration, safe retries and agent responsibilities.
+- 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 GitHub variables and secrets documented in docs/RELEASE.md before releasing.
+- Configure the six git-user secrets documented in docs/RELEASE.md before releasing.
## 0.1.0 — development baseline
diff --git a/README.md b/README.md
index 31942cc..a419391 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,6 @@ Original migration source: `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77e
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 with the next version. It freezes both changelogs, creates the immutable source tag, adds its ZIP URL to the Joomla update feed, and waits for OctoShoom to commit the checksum. The plugin release stops there. The component's release invokes OctoJPack separately. [Release instructions](docs/RELEASE.md) describe GitHub variables, secrets and safe retries.
+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.
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/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md
index 0c27ed1..5c10b80 100644
--- a/docs/IMPLEMENTATION.md
+++ b/docs/IMPLEMENTATION.md
@@ -38,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).
-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 wait for OctoShoom to commit the checksum. Safe retries verify existing tag metadata without moving the tag. This plugin never builds a combined package or invokes OctoJPack. See RELEASE.md. No release has been run by this implementation 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
index b76d79b..af3f8c8 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -1,50 +1,36 @@
# Console plugin releases
-The repository source ZIP is the installable Joomla console plugin. Download **Code → Download ZIP**, or a tagged source ZIP, and upload it through Joomla's extension installer after installing the compatible component. There is no local ZIP builder and no Composer step. This repository contains no combined Joomla package.
+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
-After merging reviewed changes and checking CI, open Actions → **Release console plugin with OctoShoom**, select the configured release branch and enter the next stable `X.Y.Z` version. A `v` prefix is optional; Git tags use `vX.Y.Z`, Joomla metadata uses `X.Y.Z`.
+Open Actions → **Release console plugin with OctoShoom**, select `main`, and enter the next version, such as `1.2.3`. A `v` prefix is optional.
-1. Validate configuration and run the metadata transition checks.
-2. Freeze `[[[NEXT_VERSION]]]` in `CHANGELOG.md` and `joomengine_mcp_changelog.xml`; update the plugin manifest's version/date and live metadata URLs. Atomically push the metadata commit and immutable tag.
-3. Add the tag's source ZIP URL to `joomengine_mcp_update_server.xml` and commit it, retaining earlier releases.
-4. Run the shared OctoShoom action synchronously. It downloads the tagged archive, adds SHA-512 and commits the feed. Verify the committed checksum against the real tag download before reporting success.
+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.
-The workflow stops after OctoShoom. It never invokes OctoJPack. Once this release succeeds, configure its exact `CONSOLE_TAG` in the component repository and run the component release, which invokes OctoJPack and publishes the combined package to the separate package repository.
+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.
-Use the manual version workflow rather than pushing a bare tag: metadata must be frozen before the tag is created. The first run fills the initially empty feed with a real tagged download. No GitHub Release assets or fabricated historical entries are needed.
+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 variables and secrets
+## GitHub secrets
-Set these under **Settings → Secrets and variables → Actions**.
+Set these under **Settings → Secrets and variables → Actions**. The SSH identity must be allowed to push to this repository.
-| Variable | Value |
+| Secret | Value |
| --- | --- |
-| `RELEASE_BRANCH` | Optional release branch; defaults to this repository's default branch. Select it when running the workflow. |
-| `OCTOSHOOM_REPOSITORY` | Shared hash action repository, normally `octoleo/octoshoom`. |
-| `OCTOSHOOM_REF` | Full reviewed 40-character commit SHA. Inspected compatible revision: `a4eba6191388335e0301f74969d92151bc0f520d`. |
-| `RELEASE_SSH_KNOWN_HOSTS` | Verified GitHub SSH `known_hosts` lines; strict checking is enabled. |
+| `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. |
-| Secret | Access needed |
-| --- | --- |
-| `RELEASE_TOKEN` | GitHub token for source metadata/tag writes and tool-repository checkout. |
-| `RELEASE_SSH_KEY` | Unencrypted SSH private key with write access to this repository, used by OctoShoom. A write-enabled deploy key or machine/user identity can be used. |
-
-The identity must be permitted to push metadata and tags under your repository rules. Credentials stay in GitHub secrets. Source repository identity comes from `github.repository`; manifest and feed URLs are derived from it and the release branch.
-
-The component workflow reads the update-server URL from the released console manifest, so a configured release branch is supported.
-
-## Changelog convention
-
-Record every meaningful change in both `CHANGELOG.md` and `joomengine_mcp_changelog.xml`. Keep exactly one pending section headed `[[[NEXT_VERSION]]]`; after release, create a new pending section for further work. The workflow replaces the marker with its version input. Never rewrite released history.
-
-Joomla XML identity is `element` = `joomengine_mcp`, `type` = `plugin`, `folder` = `console`. Categories are `security`, `fix`, `language`, `addition`, `change`, `remove`, and `note`, containing `item` children. Use matching Markdown headings. Compatibility warnings belong under Note; errors fixed under Fix; security corrections under Security. The manifest's `changelogurl` points to the live XML file.
+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.
-The 0.1.0 entry describes the development baseline, not a published tag. Select a new unused version for the first release.
+## Changelogs and checks
-## Retrying and verification
+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.
-Rerun the same version after an interrupted release. The workflow verifies the existing tag's manifest/changelog identity and ancestry, leaves the tag untouched, preserves earlier update entries and resumes feed/hash work. Missing or mismatched hashes fail the workflow; they cannot produce a success summary.
+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.
-Use `php tests/run.php` for source completeness and `php tests/release.php` for isolated positive/negative release transitions. CI also tests the unmodified source ZIP. Installed acceptance can select a component revision through `MCP_COMPONENT_REF` or its reusable workflow input; otherwise it checks the matching component branch when present, falling back to main. No release is published by those tests.
+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 2f774f7..cb4c9e6 100644
--- a/joomengine_mcp_changelog.xml
+++ b/joomengine_mcp_changelog.xml
@@ -7,16 +7,20 @@
[[[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 release configuration, retries and agent responsibilities.
+ - 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 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 GitHub variables and secrets documented in docs/RELEASE.md before releasing.
+ - Configure the six git-user secrets documented in docs/RELEASE.md before releasing.
diff --git a/tests/release.php b/tests/release.php
index aba672a..56d4efc 100644
--- a/tests/release.php
+++ b/tests/release.php
@@ -6,10 +6,9 @@
* @license GNU General Public License version 3 or later; see LICENSE
*/
-/** Exercise release transitions without network, publishing tags, or building packages. */
require dirname(__DIR__) . '/tools/release.php';
-$directory = sys_get_temp_dir() . '/mcp-release-' . bin2hex(random_bytes(8));
-mkdir($directory, 0700, true);
+$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
{
@@ -20,137 +19,74 @@
$checks++;
};
-$reject = static function (array $arguments, string $root) use ($check): void
+
+try
{
- $before = [];
+ $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', '');
- foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) as $file)
- {
- $before[$file->getPathname()] = hash_file('sha256', $file->getPathname());
- }
+ $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.');
- $rejected = false;
+ 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
- {
- mcpRelease($arguments, $root);
- }
- catch (RuntimeException)
- {
- $rejected = true;
- }
+ 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'));
- $check($rejected, 'Invalid release operation must fail.');
+ 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 ($before as $path => $hash)
+ foreach (['01.1.0', '1.0', '1.0.0;false'] as $invalid)
{
- $check(hash_file('sha256', $path) === $hash, 'Rejected operation must not change existing metadata.');
- }
-};
-
-try
-{
- foreach (['component', 'plugin'] as $type)
- {
- $root = $directory . '/' . $type;
- mkdir($root . '/plugins/webservices/joomengine_mcp', 0700, true);
- $element = $type === 'component' ? 'com_joomengine_mcp' : 'joomengine_mcp';
- $folder = $type === 'plugin' ? 'console' : '';
- $changelogName = $type === 'component' ? 'changelog.xml' : 'joomengine_mcp_changelog.xml';
- $pending = '' . $element . '' . $type . '' . $folder
- . '[[[NEXT_VERSION]]]- Preserve existing releases & user changes.
';
- file_put_contents($root . '/joomengine_mcp.xml', ''
- . '1.0.0January 2026'
- . 'https://example.invalid/old.xml'
- . 'https://example.invalid/old-changelog.xml');
- file_put_contents($root . '/plugins/webservices/joomengine_mcp/joomengine_mcp.xml',
- '1.0.0January 2026');
- file_put_contents($root . '/.octojpack', '{"package":{"version":"1.0.0"},"repository":{"owner":"[[[PACKAGE_OWNER]]]"}}');
- file_put_contents($root . '/' . $changelogName, '' . $pending . '');
- file_put_contents($root . '/CHANGELOG.md', "# Changelog\n\n## [[[NEXT_VERSION]]]\n\n### Fixed\n\n- Preserve updates.\n");
- file_put_contents($root . '/joomengine_mcp_update_server.xml', '');
- $repository = 'test-owner/' . $type;
- $branch = $type === 'component' ? 'stable/6.x' : 'stable/release&next#1';
- $encodedBranch = $type === 'component' ? 'stable/6.x' : 'stable/release%26next%231';
+ $rejected = false;
- foreach (['01.1.0', '1.0', '1.0.0-beta', '1.0.0;false', '0.9.0'] as $invalid)
+ try
{
- $reject(['prepare', $invalid, $repository, $branch], $root);
+ mcpRelease(['prepare', $invalid], $root);
+ }
+ catch (RuntimeException)
+ {
+ $rejected = true;
}
- $reject(['prepare', '1.1.0', 'invalid repository', $branch], $root);
- $reject(['prepare', '1.1.0', $repository, "main\ninjected"], $root);
- $initialFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
- mcpRelease(['prepare', 'v1.1.0', $repository, $branch], $root);
- $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml', 'extension');
- $check(mcpReleaseValue($manifest, '/extension/version') === '1.1.0', 'Release normalizes the v prefix.');
- $check(mcpReleaseValue($manifest, '/extension/creationDate') === gmdate('F Y'), 'Release refreshes the manifest date.');
- $check(mcpReleaseValue($manifest, '/extension/updateservers/server')
- === 'https://raw.githubusercontent.com/' . $repository . '/' . $encodedBranch . '/joomengine_mcp_update_server.xml',
- 'Repository and branch determine the live feed URL.');
- $check(!str_contains(file_get_contents($root . '/CHANGELOG.md'), '[[[NEXT_VERSION]]]')
- && !str_contains(file_get_contents($root . '/' . $changelogName), '[[[NEXT_VERSION]]]'),
- 'Both pending changelog sections are frozen into the release.');
- $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $initialFeed,
- 'Preparing a tag does not advertise its download before the tag exists.');
- mcpRelease(['verify-tag', '1.1.0', $repository, $branch], $root);
- $checks++;
- $reject(['verify-tag', '1.0.0', $repository, $branch], $root);
- $reject(['prepare', '1.1.0', $repository, $branch], $root);
- mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
- $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
- $check(mcpReleaseValue($feed, '/updates/update/downloads/downloadurl')
- === 'https://github.com/' . $repository . '/archive/refs/tags/v1.1.0.zip', 'Update uses the immutable repository tag ZIP.');
- $check((new DOMXPath($feed))->query('/updates/update/sha512')->length === 0,
- 'Only OctoShoom supplies the release checksum.');
- $check(mcpReleaseValue($feed, '/updates/update/' . ($type === 'component' ? 'client' : 'folder'))
- === ($type === 'component' ? '1' : 'console'), 'Update preserves Joomla extension identity.');
- $archive = $root . '/archive.zip';
- file_put_contents($archive, 'Synthetic archive bytes for checksum comparison only.');
- $reject(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
- mcpReleaseAppend($feed->documentElement->firstChild, 'sha512', hash_file('sha512', $archive));
- $feed->save($root . '/joomengine_mcp_update_server.xml');
- mcpRelease(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
- $checks++;
- $hashedFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
- mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
- $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $hashedFeed,
- 'Retry preserves published feed bytes including the OctoShoom hash.');
- file_put_contents($archive, 'Tampered archive');
- $reject(['verify-hash', '1.1.0', $repository, $branch, $archive], $root);
- $reject(['feed', '1.1.0', 'different-owner/' . $type, $branch], $root);
- $reject(['feed', '2.0.0', $repository, $branch], $root);
-
- $changelog = file_get_contents($root . '/' . $changelogName);
- file_put_contents($root . '/' . $changelogName, str_replace('', '' . $pending, $changelog));
- $markdown = file_get_contents($root . '/CHANGELOG.md');
- file_put_contents($root . '/CHANGELOG.md', str_replace('# Changelog', "# Changelog\n\n## [[[NEXT_VERSION]]]\n\n- Next changes.", $markdown));
- mcpRelease(['prepare', '1.2.0', $repository, $branch], $root);
- mcpRelease(['feed', '1.2.0', $repository, $branch], $root);
- $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
- $check((new DOMXPath($feed))->query('/updates/update')->length === 2, 'Next release retains previous update entries.');
- $check(strlen(mcpReleaseValue($feed, '/updates/update[version="1.1.0"]/sha512')) === 128,
- 'Next release retains the previous immutable checksum.');
- $latestFeed = file_get_contents($root . '/joomengine_mcp_update_server.xml');
- mcpRelease(['feed', '1.1.0', $repository, $branch], $root);
- $check(file_get_contents($root . '/joomengine_mcp_update_server.xml') === $latestFeed,
- 'Retrying an older published tag never rolls back a newer feed entry.');
- $duplicate = $feed->documentElement->lastChild->cloneNode(true);
- $feed->documentElement->appendChild($duplicate);
- $feed->save($root . '/joomengine_mcp_update_server.xml');
- $reject(['feed', '1.1.0', $repository, $branch], $root);
+ $check($rejected, 'Invalid versions are rejected.');
}
- echo json_encode(['checks' => $checks, 'metadataTransitions' => 'passed',
- 'publication' => 'not run; no network or package generation'], JSON_THROW_ON_ERROR) . "\n";
+ echo json_encode(['checks' => $checks, 'metadataTransitions' => 'passed'], JSON_THROW_ON_ERROR) . "\n";
}
finally
{
- foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST) as $file)
+ foreach (glob($root . '/*') as $path)
{
- $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
+ unlink($path);
}
- rmdir($directory);
+ rmdir($root);
}
diff --git a/tools/release.php b/tools/release.php
index 3921496..e304c82 100644
--- a/tools/release.php
+++ b/tools/release.php
@@ -6,15 +6,14 @@
* @license GNU General Public License version 3 or later; see LICENSE
*/
-/** Read local release metadata without resolving external XML entities. */
-function mcpReleaseXml(string $path, string $rootName): DOMDocument
+/** Read repository release metadata. */
+function mcpReleaseXml(string $path): DOMDocument
{
$document = new DOMDocument('1.0', 'utf-8');
$document->preserveWhiteSpace = false;
$document->formatOutput = true;
- if (!is_file($path) || !$document->load($path, LIBXML_NONET)
- || $document->doctype !== null || $document->documentElement->nodeName !== $rootName)
+ if (!$document->load($path, LIBXML_NONET) || $document->doctype !== null)
{
throw new RuntimeException('Invalid release XML: ' . $path);
}
@@ -22,8 +21,8 @@ function mcpReleaseXml(string $path, string $rootName): DOMDocument
return $document;
}
-/** Append escaped text to an XML element. */
-function mcpReleaseAppend(DOMNode $parent, string $name, string $value): DOMElement
+/** 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));
@@ -32,194 +31,71 @@ function mcpReleaseAppend(DOMNode $parent, string $name, string $value): DOMElem
return $node;
}
-/** Require a single metadata value, preventing ambiguous manifests and feeds. */
-function mcpReleaseValue(DOMDocument $document, string $expression): string
-{
- $nodes = (new DOMXPath($document))->query($expression);
-
- if ($nodes === false || $nodes->length !== 1)
- {
- throw new RuntimeException('Expected exactly one XML value: ' . $expression);
- }
-
- return $nodes->item(0)->textContent;
-}
-
-/** Change one existing metadata value. */
-function mcpReleaseSet(DOMDocument $document, string $expression, string $value): void
-{
- mcpReleaseValue($document, $expression);
- $node = (new DOMXPath($document))->query($expression)->item(0);
-
- while ($node->firstChild !== null)
- {
- $node->removeChild($node->firstChild);
- }
-
- $node->appendChild($document->createTextNode($value));
-}
-
-/** Prepare version metadata, append a tag update, or verify OctoShoom's published checksum. */
+/** Freeze the plugin version or add its tagged download to the Joomla feed. */
function mcpRelease(array $arguments, string $root): void
{
- [$command, $version, $repository, $branch] = array_pad($arguments, 4, '');
+ [$command, $version] = array_pad($arguments, 2, '');
$version = preg_replace('/\Av/', '', $version);
- if (!in_array($command, ['prepare', 'verify-tag', 'feed', 'verify-hash'], true)
- || preg_match('/\A(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\z/D', $version) !== 1
- || preg_match('/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/D', $repository) !== 1
- || $branch === '' || preg_match('/[\x00-\x20\x7f?*\[\\\\~^:]/', $branch)
- || str_contains($branch, '..') || str_contains($branch, '@{'))
- {
- throw new RuntimeException('Usage: release.php prepare|verify-tag|feed|verify-hash VERSION OWNER/REPOSITORY BRANCH [ARCHIVE]');
- }
-
- $tag = 'v' . $version;
- $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml', 'extension');
- $type = $manifest->documentElement->getAttribute('type');
- $isComponent = $type === 'component';
-
- if (!$isComponent && ($type !== 'plugin' || $manifest->documentElement->getAttribute('group') !== 'console'))
+ 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('This release helper supports the MCP component or console plugin only.');
+ throw new RuntimeException('Usage: release.php prepare|feed VERSION');
}
- $element = $isComponent ? 'com_joomengine_mcp' : 'joomengine_mcp';
- $changelogName = $isComponent ? 'changelog.xml' : 'joomengine_mcp_changelog.xml';
- $changelog = mcpReleaseXml($root . '/' . $changelogName, 'changelogs');
- $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml', 'updates');
- $encodedBranch = implode('/', array_map('rawurlencode', explode('/', $branch)));
- $rawBase = 'https://raw.githubusercontent.com/' . $repository . '/' . $encodedBranch . '/';
- $archiveUrl = 'https://github.com/' . $repository . '/archive/refs/tags/' . $tag . '.zip';
- $currentVersion = mcpReleaseValue($manifest, '/extension/version');
- $entryPath = '/updates/update[version="' . $version . '"]';
- $entryNodes = (new DOMXPath($feed))->query($entryPath);
+ $manifest = mcpReleaseXml($root . '/joomengine_mcp.xml');
+ $manifestVersion = $manifest->getElementsByTagName('version')->item(0);
$writes = [];
if ($command === 'prepare')
{
- $markdown = file_get_contents($root . '/CHANGELOG.md');
- $pending = (new DOMXPath($changelog))->query('/changelogs/changelog[version="[[[NEXT_VERSION]]]"]');
- $existing = (new DOMXPath($changelog))->query('/changelogs/changelog[version="' . $version . '"]');
-
- if (version_compare($version, $currentVersion, '<') || $existing->length !== 0 || $pending->length !== 1
- || substr_count($markdown, '[[[NEXT_VERSION]]]') !== 1
- || preg_match('/^## \[\[\[NEXT_VERSION\]\]\]/m', $markdown) !== 1)
+ foreach (['CHANGELOG.md', 'joomengine_mcp_changelog.xml'] as $path)
{
- throw new RuntimeException('Choose an unreleased version at least as new as the manifest and provide exactly one pending changelog section in both files.');
- }
+ $contents = file_get_contents($root . '/' . $path);
- $pendingEntry = $pending->item(0);
- $pendingQuery = new DOMXPath($changelog);
+ 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.');
+ }
- if ($pendingQuery->query('element[text()="' . $element . '"]', $pendingEntry)->length !== 1
- || $pendingQuery->query('type[text()="' . $type . '"]', $pendingEntry)->length !== 1
- || $pendingQuery->query('security/item|fix/item|language/item|addition/item|change/item|remove/item|note/item', $pendingEntry)->length === 0
- || (!$isComponent && $pendingQuery->query('folder[text()="console"]', $pendingEntry)->length !== 1))
- {
- throw new RuntimeException('Pending Joomla changelog must identify this extension and contain categorized changes.');
+ $writes[$path] = str_replace('[[[NEXT_VERSION]]]', $version, $contents);
}
- mcpReleaseSet($manifest, '/extension/version', $version);
- mcpReleaseSet($manifest, '/extension/creationDate', gmdate('F Y'));
- mcpReleaseSet($manifest, '/extension/updateservers/server', $rawBase . 'joomengine_mcp_update_server.xml');
- mcpReleaseSet($manifest, '/extension/changelogurl', $rawBase . $changelogName);
- mcpReleaseSet($changelog, '/changelogs/changelog/version[text()="[[[NEXT_VERSION]]]"]', $version);
+ $manifestVersion->nodeValue = $version;
+ $manifest->getElementsByTagName('creationDate')->item(0)->nodeValue = gmdate('F Y');
$writes['joomengine_mcp.xml'] = $manifest->saveXML();
- $writes[$changelogName] = $changelog->saveXML();
- $writes['CHANGELOG.md'] = str_replace('[[[NEXT_VERSION]]]', $version, $markdown);
-
- if ($isComponent)
- {
- $routingPath = 'plugins/webservices/joomengine_mcp/joomengine_mcp.xml';
- $routing = mcpReleaseXml($root . '/' . $routingPath, 'extension');
- mcpReleaseSet($routing, '/extension/version', $version);
- mcpReleaseSet($routing, '/extension/creationDate', gmdate('F Y'));
- $configuration = json_decode(file_get_contents($root . '/.octojpack'), true, 64, JSON_THROW_ON_ERROR);
- $configuration['package']['version'] = $version;
- $writes[$routingPath] = $routing->saveXML();
- $writes['.octojpack'] = json_encode($configuration, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
- }
- }
- elseif ($command === 'verify-tag')
- {
- $record = '/changelogs/changelog[version="' . $version . '"]';
-
- if ($currentVersion !== $version || mcpReleaseValue($changelog, $record . '/element') !== $element
- || mcpReleaseValue($changelog, $record . '/type') !== $type
- || (!$isComponent && mcpReleaseValue($changelog, $record . '/folder') !== 'console')
- || mcpReleaseValue($manifest, '/extension/updateservers/server') !== $rawBase . 'joomengine_mcp_update_server.xml'
- || mcpReleaseValue($manifest, '/extension/changelogurl') !== $rawBase . $changelogName
- || !preg_match('/^## ' . preg_quote($version, '/') . '(?:\s|$)/m', file_get_contents($root . '/CHANGELOG.md')))
- {
- throw new RuntimeException('The existing tag does not contain the requested released extension metadata.');
- }
-
- if ($isComponent)
- {
- $routing = mcpReleaseXml($root . '/plugins/webservices/joomengine_mcp/joomengine_mcp.xml', 'extension');
- $config = json_decode(file_get_contents($root . '/.octojpack'), true, 64, JSON_THROW_ON_ERROR);
-
- if (mcpReleaseValue($routing, '/extension/version') !== $version || ($config['package']['version'] ?? '') !== $version)
- {
- throw new RuntimeException('The tag contains inconsistent routing plugin or OctoJPack versions.');
- }
- }
}
else
{
- if ($entryNodes->length > 1 || version_compare($version, $currentVersion, '>'))
- {
- throw new RuntimeException('Duplicate update versions or update newer than the prepared manifest.');
- }
+ $feed = mcpReleaseXml($root . '/joomengine_mcp_update_server.xml');
- if ($entryNodes->length === 1)
+ if ((new DOMXPath($feed))->query('/updates/update[version="' . $version . '"]')->length > 0)
{
- if (mcpReleaseValue($feed, $entryPath . '/element') !== $element
- || mcpReleaseValue($feed, $entryPath . '/type') !== $type
- || mcpReleaseValue($feed, $entryPath . '/downloads/downloadurl') !== $archiveUrl
- || (!$isComponent && mcpReleaseValue($feed, $entryPath . '/folder') !== 'console'))
- {
- throw new RuntimeException('Existing update identity or tag URL differs; refusing to overwrite it.');
- }
+ return;
}
- elseif ($command === 'feed')
- {
- $entry = $feed->createElement('update');
- $feed->documentElement->insertBefore($entry, $feed->documentElement->firstChild);
-
- foreach (['name' => $isComponent ? 'JoomEngine MCP' : 'JoomEngine MCP Console',
- 'description' => $isComponent ? 'JoomEngine MCP component.' : 'JoomEngine MCP console plugin.',
- 'element' => $element, 'type' => $type, 'version' => $version] as $name => $value)
- {
- mcpReleaseAppend($entry, $name, $value);
- }
- mcpReleaseAppend($entry, $isComponent ? 'client' : 'folder', $isComponent ? '1' : 'console');
- $download = mcpReleaseAppend(mcpReleaseAppend($entry, 'downloads', ''), 'downloadurl', $archiveUrl);
- $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/' . $repository . '/tree/' . $tag);
- mcpReleaseAppend($entry, 'changelogurl', $rawBase . $changelogName);
- $writes['joomengine_mcp_update_server.xml'] = $feed->saveXML();
- }
+ $entry = $feed->createElement('update');
+ $feed->documentElement->insertBefore($entry, $feed->documentElement->firstChild);
- if ($command === 'verify-hash')
+ foreach (['name' => 'JoomEngine MCP Console', 'description' => 'JoomEngine MCP console plugin.',
+ 'element' => 'joomengine_mcp', 'type' => 'plugin', 'version' => $version, 'folder' => 'console'] as $name => $value)
{
- $archive = $arguments[4] ?? '';
- $hash = strtolower(mcpReleaseValue($feed, $entryPath . '/sha512'));
-
- if (!is_file($archive) || preg_match('/\A[a-f0-9]{128}\z/D', $hash) !== 1
- || !hash_equals($hash, hash_file('sha512', $archive)))
- {
- throw new RuntimeException('OctoShoom has not published a matching SHA-512 for the immutable tag archive.');
- }
+ 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)
@@ -236,7 +112,6 @@ function mcpRelease(array $arguments, string $root): void
try
{
mcpRelease(array_slice($argv, 1), dirname(__DIR__));
- echo "Release metadata verified.\n";
}
catch (Throwable $error)
{