Compare commits

..

6 Commits

Author SHA1 Message Date
jmiller 065b25fc88 chore: sync updates.xml 02.20.00 from main [skip ci] 2026-06-04 15:53:59 +00:00
jmiller 4ca69e5af3 chore: sync updates.xml 02.20.00 from main [skip ci] 2026-06-04 14:03:29 +00:00
Jonathan Miller fd359ae7f7 Revert "fix(script): auto-remove duplicate MokoOnyx and stale MokoCassiopeia extensions"
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
This reverts commit 4c6d9396f8.
2026-06-04 08:11:34 -05:00
Jonathan Miller 4c6d9396f8 fix(script): auto-remove duplicate MokoOnyx and stale MokoCassiopeia extensions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
On update, detects and removes:
- Duplicate MokoOnyx entries in #__extensions (keeps the locked/active
  one, deletes ghosts from re-installs or migration)
- Stale MokoCassiopeia extension entry (only if not set as default
  template, also cleans up its styles and update site links)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 08:04:56 -05:00
Jonathan Miller 1513d6d51a fix(license): link directly to update site record instead of generic instructions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:43:50 -05:00
Jonathan Miller 3083aa6e0e feat(license): scaffold update server migration and download key check
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Adds placeholder infrastructure for migrating from raw-branch update
URLs to the MokoGitea license system:

- migrateUpdateServer(): removes old update sites, creates new licensed
  entry with dlid support (gated behind NEW_UPDATE_URL constant — no-op
  until URL is configured)
- checkDownloadKey(): warns admin if no download key is set on the
  update site after migration
- OLD_UPDATE_URLS list for cleanup
- NEW_UPDATE_URL placeholder (TODO)

Safe to merge — all migration code is behind the empty URL guard.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:40:38 -05:00
26 changed files with 329 additions and 409 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Template - MokoOnyx</display-name>
<org>MokoConsulting</org>
<description>MokoOnyx - Joomla site template (successor to MokoCassiopeia)</description>
<version>02.20.00</version>
<version>02.18.03</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+3 -5
View File
@@ -102,14 +102,13 @@ jobs:
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
@@ -168,8 +167,7 @@ jobs:
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 02.20.00
# VERSION: 02.18.03
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-244
View File
@@ -105,19 +105,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
@@ -147,98 +134,6 @@ jobs:
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
@@ -256,13 +151,6 @@ jobs:
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
@@ -295,138 +183,6 @@ jobs:
;;
esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
+3 -29
View File
@@ -17,10 +17,6 @@ on:
types: [closed]
branches:
- dev
pull_request_target:
types: [synchronize, opened, reopened]
branches:
- main
workflow_dispatch:
inputs:
stability:
@@ -47,8 +43,7 @@ jobs:
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps:
- name: Checkout
@@ -56,7 +51,6 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
- name: Setup moko-platform tools
env:
@@ -66,7 +60,7 @@ jobs:
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
# Always fetch latest CLI tools never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
@@ -82,12 +76,7 @@ jobs:
- name: Resolve metadata and bump version
id: meta
run: |
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
STABILITY="release-candidate"
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
@@ -155,21 +144,6 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Ensure prerelease flag
run: |
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Get release ID by tag and force prerelease=true
RELEASE_ID=$(curl -s "${API_BASE}/releases/tags/${TAG}" \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" | jq -r '.id // empty')
if [ -n "$RELEASE_ID" ]; then
curl -s -X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"prerelease": true}'
echo "Marked release ${TAG} (id=${RELEASE_ID}) as prerelease"
fi
- name: Build package and upload
id: package
run: |
+115 -9
View File
@@ -11,7 +11,7 @@
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: "Generic: Repo Health"
@@ -24,12 +24,13 @@ on:
workflow_dispatch:
inputs:
profile:
description: 'Validation profile: all, scripts, or repo'
description: 'Validation profile: all, release, scripts, or repo'
required: true
default: all
type: choice
options:
- all
- release
- scripts
- repo
pull_request:
@@ -39,6 +40,10 @@ permissions:
contents: read
env:
# Release policy - Repository Variables Only
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
@@ -133,6 +138,101 @@ jobs:
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
exit 1
release_config:
name: Release configuration
needs: access_check
if: ${{ needs.access_check.outputs.allowed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Guardrails release vars
env:
PROFILE_RAW: ${{ github.event.inputs.profile }}
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
set -euo pipefail
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' 'Status: SKIPPED'
printf '%s\n' 'Reason: profile excludes release validation'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
missing=()
missing_optional=()
for k in "${required[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing+=("${k}")
done
for k in "${optional[@]}"; do
v="${!k:-}"
[ -z "${v}" ] && missing_optional+=("${k}")
done
{
printf '%s\n' '### Release configuration (Repository Variables)'
printf '%s\n' "Profile: ${profile}"
printf '%s\n' '| Variable | Status |'
printf '%s\n' '|---|---|'
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing_optional[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing optional repository variables'
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
if [ "${#missing[@]}" -gt 0 ]; then
{
printf '%s\n' '### Missing required repository variables'
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
{
printf '%s\n' '### Repository variables validation result'
printf '%s\n' 'Status: OK'
printf '%s\n' 'All required repository variables present.'
printf '%s\n' ''
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
scripts_governance:
name: Scripts governance
needs: access_check
@@ -156,14 +256,14 @@ jobs:
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'repo' ]; then
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
{
printf '%s\n' '### Scripts governance'
printf '%s\n' "Profile: ${profile}"
@@ -270,14 +370,14 @@ jobs:
profile="${PROFILE_RAW:-all}"
case "${profile}" in
all|scripts|repo) ;;
all|release|scripts|repo) ;;
*)
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = 'scripts' ]; then
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
{
printf '%s\n' '### Repository health'
printf '%s\n' "Profile: ${profile}"
@@ -604,7 +704,7 @@ jobs:
printf '%s\n' '| Domain | Status | Notes |'
printf '%s\n' '|---|---|---|'
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
printf '%s\n' '| Release variables | OK | Repository variables validation |'
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
@@ -673,10 +773,11 @@ jobs:
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
needs: [access_check, release_config, scripts_governance, repo_health]
if: >-
always() &&
(needs.scripts_governance.result == 'failure' ||
(needs.release_config.result == 'failure' ||
needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
steps:
@@ -702,6 +803,10 @@ jobs:
fi
}
report_gate "Release Configuration" \
"${{ needs.release_config.result }}" \
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
@@ -709,3 +814,4 @@ jobs:
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+30 -19
View File
@@ -8,30 +8,41 @@
DEFGROUP: Joomla.Template.Site
INGROUP: MokoOnyx.Documentation
PATH: ./CHANGELOG.md
VERSION: 02.20.00
VERSION: 02.18.03
BRIEF: Changelog file documenting version history of MokoOnyx
-->
# Changelog — MokoOnyx (VERSION: 02.20.00)
# Changelog — MokoOnyx (VERSION: 02.18.03)
## [Unreleased]
## [02.20.00] --- 2026-06-04
### Fixed
- Strip Joomla-injected `p-2` padding class from Font Awesome icons in all menu overrides (default, mainmenu, horizontal)
### Changed
- Migrated update server URL from raw file endpoint to Gitea Pages
- Release workflow no longer manages updates.xml (decoupled to Gitea Pages)
- Added conflict-marker guard to PR check and release workflows
- Added Joomla language file validation (syntax, duplicates, en-GB/en-US consistency)
- Added JEXEC guard, joomla.asset.json, XML well-formedness, and script file CI checks
- Removed RS_FTP_PATH_SUFFIX from repo health requirements
## [02.20.00] --- 2026-06-04
## [02.19.00] --- 2026-06-04
## [02.18.00] --- 2026-06-02
## [02.17.00] --- 2026-06-02
## [02.15.00] --- 2026-05-30
## [02.14.00] --- 2026-05-30
## [02.13.00] --- 2026-05-30
### Added
- Hero image (`hero.jpg`) to template images
- `.fa-solid`, `.fa-regular`, `.fa-brands`, `.fa-light` icon margin spacing
- `.blog-item .item-image` fixed 250px height with object-fit cover
- Hide header on home page option (`hideHeaderHome` template param)
- Hide main menu on home page option (`hideMenuHome` template param)
- Three distinct menu overrides: mainmenu (collapsible), horizontal (always visible), default (vertical)
- `<php_minimum>8.1.0</php_minimum>` to templateDetails.xml
- `<changelogurl>` support in updates.xml
- CONTRIBUTING.md with universal workflow and version policy
### Changed
- Release pipeline rework: independent update streams, CLI-driven workflows
- Version bumps only trigger on `src/` changes (not docs/config)
- Branch protection: CI bot only for push, force push disabled
- Auto-bump supports dev, rc, feature/*, patch/* branches
### Fixed
- Joomla update loop caused by version mismatch between ZIP and updates.xml
- Duplicate hamburger menu on mobile
- Stacked version suffixes (-dev-dev-dev)
- Template name doubling (Template - Template - MokoOnyx)
+1 -1
View File
@@ -10,7 +10,7 @@
INGROUP: MokoOnyx.Governance
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: SECURITY.md
VERSION: 02.20.00
VERSION: 02.18.03
BRIEF: Security policy and vulnerability reporting process for MokoOnyx.
PATH: /SECURITY.md
NOTE: This policy is process oriented and does not replace secure engineering practices.
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: MokoOnyx
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
* PATH: /html/layouts/joomla/module/card.php
* VERSION: 02.20.00
* VERSION: 02.18.03
* BRIEF: Custom card module chrome — renders module titles for all modules
*/
@@ -11,7 +11,7 @@
* INGROUP: MokoOnyx.Layouts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
* PATH: /src/html/layouts/mokoonyx/article-metadata.php
* VERSION: 02.20.00
* VERSION: 02.18.03
* BRIEF: Article metadata footer layout -- renders jcfields grouped by field group
*/
-2
View File
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
if ($itemParams->get('menu_text', 1)) {
$linktype = '<span class="' . $item->menu_icon . '" aria-hidden="true"></span>' . $item->title;
} else {
-2
View File
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
// The link is an icon
if ($itemParams->get('menu_text', 1)) {
// If the link text is to be displayed, the icon is added with aria-hidden
-2
View File
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
// The link is an icon
if ($itemParams->get('menu_text', 1)) {
// If the link text is to be displayed, the icon is added with aria-hidden
-2
View File
@@ -19,8 +19,6 @@ $anchor_css = $item->anchor_css ?: '';
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
// The link is an icon
if ($itemParams->get('menu_text', 1)) {
// If the link text is to be displayed, the icon is added with aria-hidden
-2
View File
@@ -33,8 +33,6 @@ if ($item->anchor_rel) {
$linktype = $item->title;
if ($item->menu_icon) {
// Strip Joomla-injected padding classes that conflict with FA icon sizing
$item->menu_icon = trim(preg_replace('/\bp-[0-5]\b/', '', $item->menu_icon));
// The link is an icon
if ($itemParams->get('menu_text', 1)) {
// If the link text is to be displayed, the icon is added with aria-hidden
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: MokoOnyx.Accessibility
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
* PATH: ./media/css/a11y-high-contrast.css
* VERSION: 02.20.00
* VERSION: 02.18.03
* BRIEF: High-contrast stylesheet for accessibility toolbar
*/
+140 -71
View File
@@ -65,6 +65,8 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
{
$this->logMessage('MokoOnyx template updated.');
$this->migrateUpdateServer();
$synced = $this->syncCustomVariables($parent);
if ($synced > 0) {
Factory::getApplication()->enqueueMessage(
@@ -94,7 +96,6 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
$this->clearFaviconStamp();
$this->cleanMediaFolder();
$this->removeDeletedFiles();
$this->removeDuplicateExtensions();
$this->lockExtension();
}
@@ -485,93 +486,161 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
}
}
// ====================================================================
// LICENSE & UPDATE SERVER MIGRATION
// ====================================================================
/**
* Remove duplicate MokoOnyx extension entries from #__extensions.
* New update server URL (MokoGitea license system).
*
* Re-installs or migrations can leave ghost rows. We keep the one
* that is locked (the active template) and delete any extras.
* Also removes stale MokoCassiopeia entries if present.
* TODO: Replace with the actual licensed update server endpoint once
* the MokoGitea license system is configured. The URL should
* accept a `dlid` query parameter for download-key auth.
*
* Example: https://updates.mokoconsulting.tech/joomla/mokoonyx/updates.xml
*/
private function removeDuplicateExtensions(): void
private const NEW_UPDATE_URL = ''; // TODO: set final URL
/**
* Old update server URLs that should be removed during migration.
*/
private const OLD_UPDATE_URLS = [
'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml',
];
/**
* Migrate the update server from the old raw-branch URL to the new
* MokoGitea license system.
*
* 1. Find existing update site entries for this template
* 2. Remove old entries pointing to the raw-branch URL
* 3. (When NEW_UPDATE_URL is set) create the new update site entry
* 4. Warn the admin if no download key is configured
*
* Safe to run multiple times — skips if already migrated.
*/
private function migrateUpdateServer(): void
{
$db = Factory::getDbo();
// Find all MokoOnyx template entries
$rows = $db->setQuery(
$db->getQuery(true)
->select(['extension_id', 'locked'])
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
->where($db->quoteName('type') . ' = ' . $db->quote('template'))
->order('locked DESC, extension_id ASC')
)->loadObjectList();
if (count($rows) > 1) {
$keep = (int) $rows[0]->extension_id;
$removed = 0;
for ($i = 1; $i < count($rows); $i++) {
$staleId = (int) $rows[$i]->extension_id;
$db->setQuery(
$db->getQuery(true)
->delete('#__update_sites_extensions')
->where('extension_id = ' . $staleId)
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete('#__extensions')
->where('extension_id = ' . $staleId)
)->execute();
$removed++;
}
if ($removed > 0) {
$this->logMessage("Removed {$removed} duplicate MokoOnyx extension(s). Kept ID {$keep}.");
}
if (empty(self::NEW_UPDATE_URL)) {
// Migration not yet active — URL not configured
return;
}
// Remove stale MokoCassiopeia if not set as default
$oldExt = (int) $db->setQuery(
$db = Factory::getDbo();
// Find the extension ID for this template
$extId = (int) $db->setQuery(
$db->getQuery(true)
->select('extension_id')
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
->where($db->quoteName('type') . ' = ' . $db->quote('template'))
)->loadResult();
if ($oldExt) {
$isDefault = (int) $db->setQuery(
if (!$extId) {
return;
}
// Check if already migrated (new URL exists)
$alreadyMigrated = (int) $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__update_sites AS us')
->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id')
->where('use.extension_id = ' . $extId)
->where($db->quoteName('us.location') . ' = ' . $db->quote(self::NEW_UPDATE_URL))
)->loadResult();
if ($alreadyMigrated) {
$this->checkDownloadKey($extId);
return;
}
// Remove old update site entries
$oldSiteIds = $db->setQuery(
$db->getQuery(true)
->select('us.update_site_id')
->from('#__update_sites AS us')
->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id')
->where('use.extension_id = ' . $extId)
->whereIn($db->quoteName('us.location'), array_map([$db, 'quote'], self::OLD_UPDATE_URLS), true)
)->loadColumn();
if (!empty($oldSiteIds)) {
$ids = implode(',', array_map('intval', $oldSiteIds));
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__template_styles')
->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME))
->where($db->quoteName('home') . ' = 1')
)->loadResult();
->delete('#__update_sites_extensions')
->whereIn('update_site_id', $oldSiteIds)
)->execute();
if ($isDefault === 0) {
$db->setQuery(
$db->getQuery(true)
->delete('#__update_sites_extensions')
->where('extension_id = ' . $oldExt)
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete('#__update_sites')
->whereIn('update_site_id', $oldSiteIds)
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete('#__extensions')
->where('extension_id = ' . $oldExt)
)->execute();
$this->logMessage('Removed ' . count($oldSiteIds) . ' old update site(s).');
}
$db->setQuery(
$db->getQuery(true)
->delete('#__template_styles')
->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME))
)->execute();
// Create new update site entry
$newSite = (object) [
'name' => 'MokoOnyx Updates (Licensed)',
'type' => 'extension',
'location' => self::NEW_UPDATE_URL,
'enabled' => 1,
'last_check_timestamp' => 0,
'extra_query' => '',
];
$this->logMessage('Removed stale MokoCassiopeia extension and styles.');
}
$db->insertObject('#__update_sites', $newSite, 'update_site_id');
$newSiteId = (int) $newSite->update_site_id;
if ($newSiteId) {
$link = (object) [
'update_site_id' => $newSiteId,
'extension_id' => $extId,
];
$db->insertObject('#__update_sites_extensions', $link);
$this->logMessage('Created new licensed update site (ID: ' . $newSiteId . ').');
}
$this->checkDownloadKey($extId);
}
/**
* Check whether a download key is configured for this extension's
* update site and warn the admin if not.
*/
private function checkDownloadKey(int $extId): void
{
$db = Factory::getDbo();
$row = $db->setQuery(
$db->getQuery(true)
->select(['us.update_site_id', 'us.extra_query'])
->from('#__update_sites AS us')
->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id')
->where('use.extension_id = ' . $extId)
->where($db->quoteName('us.location') . ' = ' . $db->quote(self::NEW_UPDATE_URL))
)->loadObject();
if (!$row) {
return;
}
// Joomla stores the download key in extra_query as "dlid=XXXXX"
if (empty($row->extra_query) || strpos($row->extra_query, 'dlid=') === false) {
$editUrl = 'index.php?option=com_installer&view=updatesites&task=updatesite.edit&update_site_id='
. (int) $row->update_site_id;
Factory::getApplication()->enqueueMessage(
'<strong>MokoOnyx — Download key required.</strong><br>'
. 'A download key is needed to receive updates. '
. '<a href="' . $editUrl . '">Enter your download key here</a>.',
'warning'
);
}
}
+4 -2
View File
@@ -31,10 +31,12 @@
-->
<extension type="template" client="site" method="upgrade">
<updateservers>
<server type="extension" name="MokoOnyx Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/updates.xml</server>
<server type="extension" priority="1" name="MokoOnyx Update Server (MokoGitea)">
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml
</server>
</updateservers>
<name>mokoonyx</name>
<version>02.20.00</version>
<version>02.18.03-dev</version>
<scriptfile>script.php</scriptfile>
<creationDate>2026-05-16</creationDate>
<author>Jonathan Miller || Moko Consulting</author>
+28
View File
@@ -0,0 +1,28 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 02.20.00
-->
<updates>
<update>
<name>Template - MokoOnyx</name>
<description>Template - MokoOnyx stable build.</description>
<element>mokoonyx</element>
<type>template</type>
<client>site</client>
<version>02.20.00</version>
<creationDate>2026-06-04</creationDate>
<infourl title='Template - MokoOnyx'>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/stable</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/stable/tpl_mokoonyx-02.20.00.zip</downloadurl>
</downloads>
<sha256>6b0397fce0e1f9e15f3318a200ad4679a0777940c6cfa3a703b875ca14f9979a</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name="joomla" version="(5|6)\..*" />
<php_minimum>8.1.0</php_minimum>
</update>
</updates>