diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea7f500..91f8c63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,12 @@ Version format: `XX.YY.ZZ` (zero-padded semver).
## [Unreleased]
+### Fixed
+- `updates_xml_build.php`: cascade entries down to lower channels — stable now writes all 5 entries instead of wiping them
+- `updates_xml_build.php`: separate Joomla stability tags (`dev`, `rc`) from Gitea release tags (`development`, `release-candidate`) — download URLs now point to correct release assets
+- `updates_xml_build.php`: only emit `site` for templates and modules, not packages or components
+- `updates_xml_build.php`: preservation logic matches Joomla tag names when deciding which existing entries to keep
+
## [08.00.00] - 2026-05-26
### Changed
diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php
index 106d8e6..025945c 100644
--- a/cli/updates_xml_build.php
+++ b/cli/updates_xml_build.php
@@ -194,8 +194,7 @@ $stabilitySuffixMap = [
'development' => '-dev',
];
-// Joomla's stabilityTagToInteger() maps these to STABILITY_* constants.
-// MUST use 'dev' not 'development' — STABILITY_DEVELOPMENT does not exist.
+// Joomla values — maps to Joomla's stabilityTagToInteger()
$stabilityTagMap = [
'stable' => 'stable',
'rc' => 'rc',
@@ -204,23 +203,26 @@ $stabilityTagMap = [
'development' => 'dev',
];
-// -- Build update entries -----------------------------------------------------
-$releaseTag = $stabilityTagMap[$stability] ?? $stability;
+// Gitea release tag names (used in download/info URLs)
+$releaseTagMap = [
+ 'stable' => 'stable',
+ 'rc' => 'release-candidate',
+ 'beta' => 'beta',
+ 'alpha' => 'alpha',
+ 'development' => 'development',
+];
+// -- Build update entries -----------------------------------------------------
// For the primary entry: apply suffix if not stable
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
$primaryVersion = $version . $primarySuffix;
-$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip";
-$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}";
-
-// Build client tag — Joomla defaults to client_id=1 (administrator) when missing.
-// Packages install with client_id=0 (site), so we MUST include site
-// for all types to prevent a mismatch that causes extension_id=0 in #__updates.
+// Build client tag — only needed for templates and modules (site vs admin).
+// Packages and components don't use client; plugins use folder instead.
$clientTag = '';
if (!empty($extClient)) {
$clientTag = " {$extClient}";
-} else {
+} elseif (in_array($extType, ['template', 'module'])) {
$clientTag = ' site';
}
@@ -286,41 +288,44 @@ function buildEntry(
}
// -- Determine which channels to write ----------------------------------------
-// Stable cascades to all channels; pre-releases only write their level and below
-// Each channel gets its own suffixed version:
-// development -> 04.01.00-dev
-// alpha -> 04.01.00-alpha
-// beta -> 04.01.00-beta
-// rc -> 04.01.00-rc
-// stable -> 04.01.00
+// Stable cascades to all channels; pre-releases cascade down to lower channels.
+// Each channel entry represents "latest release available at this stability or higher".
+// When stable releases, ALL channels point to stable (it's the newest for everyone).
+// When RC releases, rc/beta/alpha/dev point to RC; stable is preserved.
+// When dev releases, only dev is updated; everything else is preserved.
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
-// Write only the current channel entry (not cascade)
-// Each channel release only creates its own entry; preserved entries handle other channels
+// Write entries for the current channel AND all lower channels (cascade down)
+// All cascaded entries point to the CURRENT release (the highest stability being built)
$entries = [];
-$channelName = $allChannels[$stabilityIndex];
-$channelSuffix = $stabilitySuffixMap[$channelName] ?? '';
-$channelVersion = $version . $channelSuffix;
-$channelTag = $stabilityTagMap[$channelName] ?? $channelName;
-$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
-$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}";
+$giteaTag = $releaseTagMap[$stability] ?? $stability;
+$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
+$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
+$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
-$entries[] = buildEntry(
- $channelName,
- $channelVersion,
- $channelDownloadUrl,
- $extName,
- $extElement,
- $extType,
- $clientTag,
- $folderTag,
- $channelInfoUrl,
- $targetPlatform,
- $phpTag,
- $shaTag
-);
+for ($i = 0; $i <= $stabilityIndex; $i++) {
+ $channelName = $allChannels[$i];
+ $joomlaTag = $stabilityTagMap[$channelName] ?? $channelName;
+ // Only attach SHA to the primary channel entry
+ $entrySha = ($i === $stabilityIndex) ? $shaTag : '';
+
+ $entries[] = buildEntry(
+ $joomlaTag,
+ $channelVersion,
+ $channelDownloadUrl,
+ $extName,
+ $extElement,
+ $extType,
+ $clientTag,
+ $folderTag,
+ $channelInfoUrl,
+ $targetPlatform,
+ $phpTag,
+ $entrySha
+ );
+}
// -- Preserve existing entries for channels not being updated -----------------
$dest = $outputFile ?? "{$root}/updates.xml";
@@ -329,10 +334,10 @@ $preservedEntries = [];
if (file_exists($dest)) {
$existingXml = @simplexml_load_file($dest);
if ($existingXml) {
- // Channels we're writing — don't preserve these
+ // Joomla tags we're writing — don't preserve these
$writtenChannels = [];
for ($i = 0; $i <= $stabilityIndex; $i++) {
- $writtenChannels[] = $allChannels[$i];
+ $writtenChannels[] = $stabilityTagMap[$allChannels[$i]] ?? $allChannels[$i];
}
foreach ($existingXml->update as $existingUpdate) {
diff --git a/lib/Enterprise/ApiClient.php b/lib/Enterprise/ApiClient.php
index 8fb5f1a..eb6a369 100644
--- a/lib/Enterprise/ApiClient.php
+++ b/lib/Enterprise/ApiClient.php
@@ -92,6 +92,8 @@ class CircuitBreakerOpen extends RuntimeException
* );
* $response = $client->get('/repos/owner/repo');
* ```
+ *
+ * @since 04.00.00
*/
class ApiClient
{
diff --git a/lib/Enterprise/CliFramework.php b/lib/Enterprise/CliFramework.php
index f786ec5..52e08f9 100644
--- a/lib/Enterprise/CliFramework.php
+++ b/lib/Enterprise/CliFramework.php
@@ -716,6 +716,9 @@ class ValidationCLI extends CLIApp
* Lifecycle: configure() -> parseArguments() -> printBanner() -> initialize() -> run()
*
* All new scripts must extend CliFramework and implement configure() + run().
+ *
+ * @since 04.00.15
+ * @see CLIApp Legacy base class (deprecated)
*/
abstract class CliFramework
{
@@ -932,6 +935,11 @@ abstract class CliFramework
// Argument parsing (internal)
// =========================================================================
+ /**
+ * Parse CLI arguments from $_SERVER['argv'] into registered argument definitions.
+ *
+ * @since 04.00.15
+ */
private function parseArguments(): void
{
$argv = array_slice($_SERVER['argv'] ?? [], 1);
@@ -970,6 +978,11 @@ abstract class CliFramework
// Help screen
// =========================================================================
+ /**
+ * Print auto-generated help screen from registered arguments.
+ *
+ * @since 04.00.15
+ */
protected function printHelp(): void
{
$w = $this->termWidth();
diff --git a/lib/Enterprise/GitHubAdapter.php b/lib/Enterprise/GitHubAdapter.php
index efddeb4..d34e701 100644
--- a/lib/Enterprise/GitHubAdapter.php
+++ b/lib/Enterprise/GitHubAdapter.php
@@ -32,12 +32,17 @@ use RuntimeException;
* - Workflow dir: .github/workflows
*
* @package MokoStandards\Enterprise
- * @version 04.06.10
+ * @since 04.06.10
+ * @see GitPlatformAdapter
*/
class GitHubAdapter implements GitPlatformAdapter
{
+ /** @var ApiClient HTTP client for GitHub API calls. */
private ApiClient $apiClient;
+ /**
+ * @param ApiClient $apiClient Configured API client for api.github.com
+ */
public function __construct(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
diff --git a/lib/Enterprise/MokoGiteaAdapter.php b/lib/Enterprise/MokoGiteaAdapter.php
index 9cc014d..79b0c5c 100644
--- a/lib/Enterprise/MokoGiteaAdapter.php
+++ b/lib/Enterprise/MokoGiteaAdapter.php
@@ -34,13 +34,21 @@ use RuntimeException;
* - Workflow dir: .mokogitea/workflows
*
* @package MokoStandards\Enterprise
- * @version 04.06.10
+ * @since 04.06.10
+ * @see GitPlatformAdapter
*/
class MokoGiteaAdapter implements GitPlatformAdapter
{
+ /** @var ApiClient HTTP client for Gitea API calls. */
private ApiClient $apiClient;
+
+ /** @var string Base URL for Gitea API (e.g. https://git.mokoconsulting.tech/api/v1). */
private string $baseUrl;
+ /**
+ * @param ApiClient $apiClient Configured API client
+ * @param string $baseUrl Gitea API base URL
+ */
public function __construct(ApiClient $apiClient, string $baseUrl = 'https://git.mokoconsulting.tech/api/v1')
{
$this->apiClient = $apiClient;
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 3ee856b..4f77b05 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -415,15 +415,9 @@ parameters:
path: cli/theme_lint.php
-
- message: '#^Offset ''alpha''\|''beta''\|''development''\|''rc''\|''stable'' on array\{stable\: '''', rc\: ''\-rc'', beta\: ''\-beta'', alpha\: ''\-alpha'', development\: ''\-dev''\} on left side of \?\? always exists and is not nullable\.$#'
+ message: '#^Offset ''alpha''\|''beta''\|''development''\|''rc''\|''stable'' on array\{stable\: ''stable'', rc\: ''rc'', beta\: ''beta'', alpha\: ''alpha'', development\: ''dev''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
- count: 1
- path: cli/updates_xml_build.php
-
- -
- message: '#^Offset ''alpha''\|''beta''\|''development''\|''rc''\|''stable'' on array\{stable\: ''stable'', rc\: ''rc'', beta\: ''beta'', alpha\: ''alpha'', development\: ''development''\} on left side of \?\? always exists and is not nullable\.$#'
- identifier: nullCoalesce.offset
- count: 1
+ count: 2
path: cli/updates_xml_build.php
-