From 149d7139f34b3c25fa129b2e6deddaa48ac87191 Mon Sep 17 00:00:00 2001
From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech>
Date: Tue, 26 May 2026 04:47:48 +0000
Subject: [PATCH 1/2] sync: update-server.yml with updates.xml integrity check
[skip ci]
---
.mokogitea/workflows/update-server.yml | 175 +++++++++++++++++++++++++
1 file changed, 175 insertions(+)
diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml
index d7087f6..510ad8e 100644
--- a/.mokogitea/workflows/update-server.yml
+++ b/.mokogitea/workflows/update-server.yml
@@ -455,6 +455,181 @@ jobs:
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
+ - name: Validate updates.xml integrity
+ run: |
+ ERRORS=0
+
+ if [ ! -f "updates.xml" ]; then
+ echo "::error::updates.xml not found"
+ exit 1
+ fi
+
+ # Well-formed XML
+ if ! python3 -c "import xml.etree.ElementTree as ET; ET.parse('updates.xml')" 2>/dev/null; then
+ echo "::error::updates.xml is not valid XML"
+ ERRORS=$((ERRORS+1))
+ fi
+
+ python3 << 'PYEOF'
+ import xml.etree.ElementTree as ET, sys, re, os
+
+ tree = ET.parse("updates.xml")
+ root = tree.getroot()
+ updates = root.findall("update")
+ errors = 0
+ warnings = 0
+ seen_tags = set()
+
+ # All 5 channels MUST be present
+ REQUIRED_CHANNELS = {"stable", "rc", "beta", "alpha", "dev"}
+ VALID_TAGS = REQUIRED_CHANNELS | {"development"} # accept legacy alias
+ REPO = os.environ.get("GITEA_REPO", "")
+ ORG = os.environ.get("GITEA_ORG", "MokoConsulting")
+ REPO_BASE = f"https://git.mokoconsulting.tech/{ORG}/"
+
+ # Gitea release tag names per channel (Moko standard)
+ RELEASE_TAG_MAP = {
+ "stable": "stable",
+ "rc": "release-candidate",
+ "beta": "beta",
+ "alpha": "alpha",
+ "dev": "development",
+ "development": "development",
+ }
+
+ # Joomla update XML required fields per
+ # https://docs.joomla.org/Deploying_an_Update_Server
+ REQUIRED_FIELDS = ["name", "element", "type", "version", "infourl"]
+
+ for i, u in enumerate(updates):
+ tag_el = u.find("tags/tag")
+ tag = tag_el.text.strip() if tag_el is not None and tag_el.text else None
+ label = f"Entry {i+1} ({tag or '?'})"
+
+ # -- Required Joomla fields --
+ for field in REQUIRED_FIELDS:
+ el = u.find(field)
+ if el is None or not (el.text or "").strip():
+ print(f"::error::{label}: missing required <{field}>")
+ errors += 1
+
+ # -- --
+ dl = u.find("downloads/downloadurl")
+ if dl is None or not (dl.text or "").strip():
+ print(f"::error::{label}: missing ")
+ errors += 1
+ else:
+ dl_url = dl.text.strip()
+ # Must point to org repo
+ if REPO_BASE not in dl_url:
+ print(f"::error::{label}: download URL not under {REPO_BASE}: {dl_url}")
+ errors += 1
+ # Must end in .zip
+ if not dl_url.endswith(".zip"):
+ print(f"::error::{label}: download URL must end in .zip: {dl_url}")
+ errors += 1
+ # Must use correct Gitea release tag in path
+ if tag and tag in RELEASE_TAG_MAP:
+ expected_tag = RELEASE_TAG_MAP[tag]
+ if f"/download/{expected_tag}/" not in dl_url:
+ print(f"::error::{label}: download URL should contain /download/{expected_tag}/ but got: {dl_url}")
+ errors += 1
+
+ # -- (required for Joomla to match update) --
+ client = u.find("client")
+ if client is None or not (client.text or "").strip():
+ print(f"::error::{label}: missing (required for Joomla update matching)")
+ errors += 1
+
+ # -- --
+ tp = u.find("targetplatform")
+ if tp is None:
+ print(f"::error::{label}: missing ")
+ errors += 1
+ else:
+ tp_name = tp.get("name", "")
+ tp_ver = tp.get("version", "")
+ if tp_name != "joomla":
+ print(f"::error::{label}: targetplatform name should be 'joomla', got '{tp_name}'")
+ errors += 1
+ if not tp_ver:
+ print(f"::error::{label}: targetplatform missing version regex")
+ errors += 1
+ elif "5" not in tp_ver or "6" not in tp_ver:
+ print(f"::warning::{label}: targetplatform version may not cover Joomla 5+6: {tp_ver}")
+ warnings += 1
+
+ # -- must be valid Joomla type --
+ type_el = u.find("type")
+ if type_el is not None and type_el.text:
+ valid_types = {"component", "module", "plugin", "template", "library", "package", "file"}
+ if type_el.text.strip() not in valid_types:
+ print(f"::error::{label}: invalid type '{type_el.text}' (expected: {valid_types})")
+ errors += 1
+
+ # -- format (XX.YY.ZZ with optional suffix) --
+ ver_el = u.find("version")
+ if ver_el is not None and ver_el.text:
+ if not re.match(r"^\d{2}\.\d{2}\.\d{2}(-\w+)?$", ver_el.text.strip()):
+ print(f"::warning::{label}: version '{ver_el.text}' does not match XX.YY.ZZ format")
+ warnings += 1
+
+ # -- and --
+ for field in ["maintainer", "maintainerurl"]:
+ el = u.find(field)
+ if el is None or not (el.text or "").strip():
+ print(f"::warning::{label}: missing <{field}>")
+ warnings += 1
+
+ # -- Valid stability tag --
+ if tag is None:
+ print(f"::error::{label}: missing ")
+ errors += 1
+ elif tag not in VALID_TAGS:
+ print(f"::error::{label}: invalid tag '{tag}' (expected: {VALID_TAGS})")
+ errors += 1
+
+ # -- Duplicate tag check --
+ norm_tag = "dev" if tag == "development" else tag
+ if norm_tag in seen_tags:
+ print(f"::error::{label}: duplicate channel '{tag}'")
+ errors += 1
+ if norm_tag:
+ seen_tags.add(norm_tag)
+
+ # -- All 5 channels must exist --
+ missing = REQUIRED_CHANNELS - seen_tags
+ if missing:
+ print(f"::error::Missing required update channels: {', '.join(sorted(missing))}")
+ errors += 1
+
+ # -- Version ordering: higher stability must not exceed dev version --
+ channel_versions = {}
+ for u in updates:
+ tag_el = u.find("tags/tag")
+ ver_el = u.find("version")
+ if tag_el is not None and ver_el is not None and tag_el.text and ver_el.text:
+ norm = "dev" if tag_el.text.strip() == "development" else tag_el.text.strip()
+ # Strip suffix for comparison (01.00.18-dev -> 01.00.18)
+ base_ver = re.sub(r"-\w+$", "", ver_el.text.strip())
+ channel_versions[norm] = base_ver
+
+ # Cascade check: dev >= alpha >= beta >= rc >= stable
+ ORDER = ["dev", "alpha", "beta", "rc", "stable"]
+ for j in range(1, len(ORDER)):
+ current = ORDER[j]
+ previous = ORDER[j - 1]
+ if current in channel_versions and previous in channel_versions:
+ if channel_versions[current] > channel_versions[previous]:
+ print(f"::error::{current} version ({channel_versions[current]}) is ahead of {previous} ({channel_versions[previous]})")
+ errors += 1
+
+ # -- Summary --
+ print(f"\nupdates.xml validation: {len(updates)} entries, {errors} error(s), {warnings} warning(s)")
+ if errors > 0:
+ sys.exit(1)
+ PYEOF
+
- name: Summary
if: always()
run: |
--
2.52.0
From c94a64e2802c1d72293461532eeb5ef84cc11fbd Mon Sep 17 00:00:00 2001
From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech>
Date: Tue, 26 May 2026 18:59:37 +0000
Subject: [PATCH 2/2] =?UTF-8?q?fix:=20update-server.yml=20=E2=80=94=20add?=
=?UTF-8?q?=20=20for=20all=20types,=20reclassify=20universal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Authored-by: Moko Consulting
---
.mokogitea/workflows/update-server.yml | 59 +++++++++++++++++---------
1 file changed, 38 insertions(+), 21 deletions(-)
diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml
index 510ad8e..c77cdaa 100644
--- a/.mokogitea/workflows/update-server.yml
+++ b/.mokogitea/workflows/update-server.yml
@@ -4,11 +4,11 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Joomla
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/joomla/update-server.yml.template
-# VERSION: 04.06.00
-# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
+# INGROUP: MokoStandards.Universal
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
+# PATH: /templates/workflows/update-server.yml
+# VERSION: 04.07.00
+# BRIEF: Update server XML feed with stable/rc/beta/alpha/dev entries (universal)
#
# Writes updates.xml with multiple entries:
# - stable on push to main (from auto-release)
@@ -17,7 +17,7 @@
#
# Joomla filters by user's "Minimum Stability" setting.
-name: "Joomla: Update Server"
+name: "Update Server"
on:
push:
@@ -169,9 +169,12 @@ jobs:
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/")
- CLIENT_TAG=""
- [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT}"
- [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site"
+ # Joomla requires on ALL extension types for update matching
+ if [ -n "$EXT_CLIENT" ]; then
+ CLIENT_TAG="${EXT_CLIENT}"
+ else
+ CLIENT_TAG="site"
+ fi
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}"
@@ -384,20 +387,34 @@ jobs:
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
- CONTENT=$(base64 -w0 updates.xml)
- curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
- -H "Content-Type: application/json" \
- "${API_BASE}/contents/updates.xml" \
- -d "$(python3 -c "import json; print(json.dumps({
- 'content': '${CONTENT}',
- 'sha': '${FILE_SHA}',
- 'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
- 'branch': 'main'
- }))")" > /dev/null 2>&1 \
+ python3 -c "
+ import base64, json, urllib.request, sys
+ with open('updates.xml', 'rb') as f:
+ content = base64.b64encode(f.read()).decode()
+ payload = json.dumps({
+ 'content': content,
+ 'sha': '${FILE_SHA}',
+ 'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
+ 'branch': 'main'
+ }).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/contents/updates.xml',
+ data=payload, method='PUT',
+ headers={
+ 'Authorization': 'token ${GA_TOKEN}',
+ 'Content-Type': 'application/json'
+ })
+ try:
+ urllib.request.urlopen(req)
+ print('updates.xml synced to main')
+ except Exception as e:
+ print(f'ERROR: failed to sync updates.xml to main: {e}', file=sys.stderr)
+ sys.exit(1)
+ " \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
- || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
+ || echo "::error::failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
- echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
+ echo "::error::could not get updates.xml SHA from main — file may not exist on main yet" >> $GITHUB_STEP_SUMMARY
fi
- name: SFTP deploy to dev server
--
2.52.0