diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml index d7087f6..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 @@ -455,6 +472,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: |