From e1f23eaa13ce6afd92b728fdf0de7b0c9dfaea80 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Tue, 14 Jul 2026 04:48:16 +0000 Subject: [PATCH] chore: sync ci-joomla.yml from Template-Joomla [skip ci] --- .mokogitea/workflows/ci-joomla.yml | 2198 ++++++++++++++++++++++++++++ 1 file changed, 2198 insertions(+) create mode 100644 .mokogitea/workflows/ci-joomla.yml diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml new file mode 100644 index 0000000..e61ed5b --- /dev/null +++ b/.mokogitea/workflows/ci-joomla.yml @@ -0,0 +1,2198 @@ +# Copyright (C) 2026 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: MokoGitea.Workflow.Template +# INGROUP: MokoCLI.CI +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /templates/workflows/joomla/ci-joomla.yml.template +# VERSION: 04.06.00 +# BRIEF: CI workflow for Joomla extensions — lint, validate, test + +name: "Joomla: Extension CI" + +on: + pull_request: + branches: + - main + - dev + - 'dev/**' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + lint-and-validate: + name: Lint & Validate + runs-on: ubuntu-latest + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + run: | + if ! command -v php &> /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 + php -v && composer --version + + - name: Setup MokoCLI tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + run: | + if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then + echo "MokoCLI already available on runner — skipping clone" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \ + /tmp/mokocli 2>/dev/null || echo "MokoCLI clone skipped — continuing without it" + fi + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install \ + --no-interaction \ + --prefer-dist \ + --optimize-autoloader + else + echo "No composer.json found — skipping dependency install" + fi + + - name: PHP syntax check + run: | + ERRORS=0 + for DIR in src/ htdocs/; do + if [ -d "$DIR" ]; then + FOUND=1 + while IFS= read -r -d '' FILE; do + OUTPUT=$(php -l "$FILE" 2>&1) + if echo "$OUTPUT" | grep -q "Parse error"; then + echo "::error file=${FILE}::${OUTPUT}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find "$DIR" -name "*.php" -print0) + fi + done + echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY + fi + + - name: XML manifest validation + run: | + echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find the extension manifest (XML with /dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY + + # Validate well-formed XML + php -r " + \$xml = @simplexml_load_file('$MANIFEST'); + if (\$xml === false) { + echo 'INVALID'; + exit(1); + } + echo 'VALID'; + " > /tmp/xml_result 2>&1 + XML_RESULT=$(cat /tmp/xml_result) + if [ "$XML_RESULT" != "VALID" ]; then + echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY + fi + + # Check required tags: name, version, author + for TAG in name version author; do + if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then + echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + fi + done + + # Namespace is required for components/plugins but not packages + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + if ! grep -q "/dev/null; then + echo "Missing required tag: \`\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`\`" >> $GITHUB_STEP_SUMMARY + fi + else + echo "Package extension — \`\` not required." >> $GITHUB_STEP_SUMMARY + fi + fi + + if [ "${ERRORS}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Update server & packaging checks + continue-on-error: true + run: | + echo "### Update Server & Packaging" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + # Find the extension manifest + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + + # 1. Check exists and uses MokoGitea update server + if ! grep -q '' "$MANIFEST" 2>/dev/null; then + echo "::warning file=${MANIFEST}::Missing \`\` tag — extension will not receive OTA updates" + echo "- **Missing** \`\` — extension will not receive OTA updates" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + SERVER_URL=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + if [ -z "$SERVER_URL" ]; then + echo "::warning file=${MANIFEST}::\`\` is empty — no server URL defined" + echo "- **Empty** \`\` — no server URL defined" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + elif ! echo "$SERVER_URL" | grep -q 'git\.mokoconsulting\.tech'; then + echo "::warning file=${MANIFEST}::Update server does not use MokoGitea engine: ${SERVER_URL}" + echo "- **Non-MokoGitea update server:** \`${SERVER_URL}\`" >> $GITHUB_STEP_SUMMARY + echo " Expected: \`https://git.mokoconsulting.tech/{org}/{repo}/updates.xml\`" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + echo "- \`\`: MokoGitea engine ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + + # 2. Check tag exists + if ! grep -q '/dev/null; then + echo "::warning file=${MANIFEST}::Missing \`\` tag — download ID authentication is not configured" + echo "- **Missing** \`\` — download ID authentication not configured" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + echo "- \`\`: present ✓" >> $GITHUB_STEP_SUMMARY + fi + + # 3. For packages: check tag + if [ "$EXT_TYPE" = "package" ]; then + if ! grep -q '' "$MANIFEST" 2>/dev/null; then + echo "::warning file=${MANIFEST}::Package is missing \`\` — child extensions will not be removed on uninstall" + echo "- **Missing** \`\` — child extensions will remain when package is uninstalled" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + echo "- \`\`: present ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} packaging warning(s).** These won't block CI but should be addressed." >> $GITHUB_STEP_SUMMARY + else + echo "**Update server & packaging checks passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check language files referenced in manifest + run: | + echo "### Language File Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -n "$MANIFEST" ]; then + # Extract language file references from manifest + LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + if [ -z "$LANG_FILES" ]; then + echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY + else + while IFS= read -r LANG_FILE; do + LANG_FILE=$(echo "$LANG_FILE" | xargs) + if [ -z "$LANG_FILE" ]; then + continue + fi + # Check in common locations + FOUND=0 + for BASE in "." "src" "htdocs"; do + if [ -f "${BASE}/${LANG_FILE}" ]; then + FOUND=1 + break + fi + done + if [ "$FOUND" -eq 0 ]; then + echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY + fi + done <<< "$LANG_FILES" + fi + else + echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY + fi + + if [ "${ERRORS}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check index.html files in directories + run: | + echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY + MISSING=0 + CHECKED=0 + + for DIR in src/ htdocs/; do + if [ -d "$DIR" ]; then + while IFS= read -r -d '' SUBDIR; do + CHECKED=$((CHECKED + 1)) + if [ ! -f "${SUBDIR}/index.html" ]; then + echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY + MISSING=$((MISSING + 1)) + fi + done < <(find "$DIR" -type d -print0) + fi + done + + if [ "${CHECKED}" -eq 0 ]; then + echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY + elif [ "${MISSING}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY + fi + + - name: Check config.xml and access.xml for components + run: | + echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find all component manifests (XML with type="component") + # Uses maxdepth 10 to reach into nested package repos (packages/*/source/packages/com_*/...) + COMP_MANIFESTS=$(find . -maxdepth 10 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./.claude/*" -exec grep -l ']*type="component"' {} \; 2>/dev/null || true) + + if [ -z "$COMP_MANIFESTS" ]; then + echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $COMP_MANIFESTS; do + COMP_DIR=$(dirname "$MANIFEST") + COMP_NAME=$(basename "$COMP_DIR") + echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY + + # Check access.xml exists + ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ACCESS_FILE" ]; then + echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then + echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + for ACTION in core.admin core.manage; do + if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then + echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + + # Check config.xml exists + CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$CONFIG_FILE" ]; then + echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then + echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: SQL schema validation + run: | + echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find SQL files in source/htdocs + SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$SQL_FILES" ]; then + echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY + + for FILE in $SQL_FILES; do + # 1. Empty / whitespace-only file check + SIZE=$(wc -c < "$FILE" | tr -d ' ') + if [ "$SIZE" -eq 0 ]; then + echo "::error file=${FILE}::Empty SQL file" + echo "- **Empty** SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + continue + fi + CONTENT_SIZE=$(grep -cP '\S' "$FILE" 2>/dev/null || echo "0") + if [ "$CONTENT_SIZE" -eq 0 ]; then + echo "::error file=${FILE}::Whitespace-only SQL file" + echo "- **Whitespace-only** SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + continue + fi + + echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY + + # 2. Joomla table prefix — must use #__ not hardcoded prefixes + # Match table names in CREATE/ALTER/DROP/INSERT/UPDATE/DELETE that don't use #__ + HARDCODED=$(grep -nPi '(CREATE\s+TABLE|ALTER\s+TABLE|DROP\s+TABLE|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(`?)(?!#__)[a-z][a-z0-9_]+\2' "$FILE" 2>/dev/null || true) + if [ -n "$HARDCODED" ]; then + COUNT=$(echo "$HARDCODED" | wc -l) + echo "::error file=${FILE}::${COUNT} table reference(s) missing #__ prefix" + echo " - **${COUNT} hardcoded table name(s)** — must use \`#__\` prefix" >> $GITHUB_STEP_SUMMARY + while IFS= read -r LINE; do + LINE_NUM=$(echo "$LINE" | cut -d: -f1) + LINE_TEXT=$(echo "$LINE" | cut -d: -f2- | sed 's/^ *//') + echo " - Line ${LINE_NUM}: \`${LINE_TEXT}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$HARDCODED" + ERRORS=$((ERRORS + COUNT)) + fi + + # 3. CREATE TABLE must use IF NOT EXISTS + BAD_CREATE=$(grep -nPi 'CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)' "$FILE" 2>/dev/null || true) + if [ -n "$BAD_CREATE" ]; then + COUNT=$(echo "$BAD_CREATE" | wc -l) + echo "::error file=${FILE}::${COUNT} CREATE TABLE without IF NOT EXISTS" + echo " - **${COUNT} CREATE TABLE** missing \`IF NOT EXISTS\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + COUNT)) + fi + + # 4. DROP TABLE must use IF EXISTS + BAD_DROP=$(grep -nPi 'DROP\s+TABLE\s+(?!IF\s+EXISTS)' "$FILE" 2>/dev/null || true) + if [ -n "$BAD_DROP" ]; then + COUNT=$(echo "$BAD_DROP" | wc -l) + echo "::error file=${FILE}::${COUNT} DROP TABLE without IF EXISTS" + echo " - **${COUNT} DROP TABLE** missing \`IF EXISTS\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + COUNT)) + fi + + # 5. Balanced parentheses in CREATE TABLE statements + OPEN=$(grep -oP '\(' "$FILE" 2>/dev/null | wc -l) + CLOSE=$(grep -oP '\)' "$FILE" 2>/dev/null | wc -l) + if [ "$OPEN" -ne "$CLOSE" ]; then + echo "::error file=${FILE}::Unbalanced parentheses (${OPEN} open vs ${CLOSE} close)" + echo " - **Unbalanced parentheses**: ${OPEN} \`(\` vs ${CLOSE} \`)\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # 6. ENGINE=InnoDB must be specified on CREATE TABLE + if grep -qPi 'CREATE\s+TABLE' "$FILE" 2>/dev/null; then + if ! grep -qPi 'ENGINE\s*=\s*InnoDB' "$FILE" 2>/dev/null; then + echo "::error file=${FILE}::CREATE TABLE missing ENGINE=InnoDB" + echo " - **Missing \`ENGINE=InnoDB\`** — required for Joomla 4+/5" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + + # 7. DEFAULT CHARSET=utf8mb4 must be specified on CREATE TABLE + if grep -qPi 'CREATE\s+TABLE' "$FILE" 2>/dev/null; then + if ! grep -qPi 'CHARSET\s*=\s*utf8mb4|CHARACTER\s+SET\s+utf8mb4' "$FILE" 2>/dev/null; then + echo "::error file=${FILE}::CREATE TABLE missing DEFAULT CHARSET=utf8mb4" + echo " - **Missing \`DEFAULT CHARSET=utf8mb4\`** — required for Joomla 4+/5" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + done + + # 8. Update SQL files must follow version numbering pattern + UPDATE_DIRS=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null) + if [ -n "$UPDATE_DIRS" ]; then + while IFS= read -r UPDATE_DIR; do + for UFILE in "$UPDATE_DIR"/*.sql; do + [ ! -f "$UFILE" ] && continue + BASENAME=$(basename "$UFILE" .sql) + if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then + echo "::error file=${UFILE}::Update file does not follow version naming (expected X.Y.Z.sql)" + echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + done <<< "$UPDATE_DIRS" + fi + + # 9. Install/uninstall pairing — if install exists, uninstall must too + INSTALL_FILES=$(find . -name "install.mysql.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -n "$INSTALL_FILES" ]; then + while IFS= read -r INSTALL_FILE; do + SQL_DIR=$(dirname "$INSTALL_FILE") + if [ ! -f "${SQL_DIR}/uninstall.mysql.sql" ]; then + echo "::error file=${INSTALL_FILE}::install.mysql.sql found but uninstall.mysql.sql is missing" + echo "- **Missing \`uninstall.mysql.sql\`** in \`${SQL_DIR}/\` — every install must have a matching uninstall" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done <<< "$INSTALL_FILES" + fi + + # 10 & 11. Per-component schema/version consistency. Each component + # versions independently, so resolve every sql/updates/mysql dir + # against ITS OWN manifest (not one package-level version): + # (10) the component's manifest version must have a matching update file + # (11) no update file may exceed the component's manifest version + # (this is the guard that catches migration files drifting onto + # a different numbering lane and getting ahead of the release) + UPDATE_DIRS=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null) + if [ -n "$UPDATE_DIRS" ]; then + while IFS= read -r UPDATE_DIR; do + # Component root is three levels up from /sql/updates/mysql + COMP_ROOT=$(dirname "$(dirname "$(dirname "$UPDATE_DIR")")") + # Resolve this component's OWN manifest (XML with at its root) + COMP_MANIFEST="" + for XML_FILE in "$COMP_ROOT"/*.xml; do + [ -f "$XML_FILE" ] || continue + if grep -q "/dev/null; then + COMP_MANIFEST="$XML_FILE" + break + fi + done + if [ -z "$COMP_MANIFEST" ]; then + echo "- No component manifest found for \`${UPDATE_DIR}\` — skipping version match" >> $GITHUB_STEP_SUMMARY + continue + fi + COMP_VERSION=$(grep -oP '\K[^<]+' "$COMP_MANIFEST" 2>/dev/null | head -1) + [ -z "$COMP_VERSION" ] && continue + # Normalise: strip any pre-release suffix (-dev, -rc.N, ...) for comparison + CV_BASE=$(echo "$COMP_VERSION" | sed -E 's/-.*$//') + # (10) a matching update file must exist for the component version + if [ ! -f "${UPDATE_DIR}/${CV_BASE}.sql" ]; then + echo "::error file=${COMP_MANIFEST}::No update SQL file for version ${CV_BASE}" + echo "- **Missing \`${UPDATE_DIR}/${CV_BASE}.sql\`** — update file must exist for manifest version \`${CV_BASE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + # (11) no update file may exceed the component version + for UFILE in "$UPDATE_DIR"/*.sql; do + [ -f "$UFILE" ] || continue + UVER=$(basename "$UFILE" .sql) + echo "$UVER" | grep -qP '^\d+\.\d+\.\d+$' || continue + TOP=$(printf '%s\n%s\n' "$CV_BASE" "$UVER" | sort -V | tail -1) + if [ "$UVER" != "$CV_BASE" ] && [ "$TOP" = "$UVER" ]; then + echo "::error file=${UFILE}::Update file ${UVER} exceeds manifest version ${CV_BASE}" + echo "- **Update file \`${UVER}.sql\` exceeds manifest version \`${CV_BASE}\`** — renumber onto the release lane (must be <= manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + done <<< "$UPDATE_DIRS" + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Manifest file references check + run: | + echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check references + FILENAMES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FILENAMES; do + if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + # Check references + FOLDERS=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FOLDERS; do + if [ ! -d "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + # Check references in package manifests (ZIP files won't exist in source) + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ "$EXT_TYPE" != "package" ]; then + FILES=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + for F in $FILES; do + if [ ! -f "${MANIFEST_DIR}/${F}" ]; then + echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Form XML validation + run: | + echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$FORM_FILES" ]; then + echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY + for FILE in $FORM_FILES; do + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then + echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + # Check for valid Joomla form structure + if ! grep -qE '/dev/null; then + echo "- \`${FILE}\`: no \`
\`, \`\`, or \`
\` elements found" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Deprecated Joomla API check + continue-on-error: true + run: | + echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Joomla 3/4 deprecated patterns that break in Joomla 6 + PATTERNS=( + 'JFactory::' + 'JText::' + 'JHtml::' + 'JRoute::' + 'JUri::' + 'JLog::' + 'JTable::' + 'JInput' + 'CMSFactory::\$application' + 'JApplicationCms' + ) + + for PATTERN in "${PATTERNS[@]}"; do + HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true) + if [ -n "$HITS" ]; then + COUNT=$(echo "$HITS" | wc -l) + echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + COUNT)) + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY + else + echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Template output escaping check + continue-on-error: true + run: | + echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$TMPL_FILES" ]; then + echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY + + for FILE in $TMPL_FILES; do + # Check for unescaped output: or echo $var without escape() + UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true) + if [ -n "$UNESCAPED" ]; then + HITS=$(echo "$UNESCAPED" | wc -l) + echo "- \`${FILE}\`: ${HITS} unescaped \`\` output(s) — use \`escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + HITS)) + fi + + # Check for echo without escaping in template context + RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true) + if [ -n "$RAW_ECHO" ]; then + HITS=$(echo "$RAW_ECHO" | wc -l) + echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + HITS)) + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY + else + echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Namespace consistency check + run: | + echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find component/plugin manifests with tags + MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '/dev/null || true) + + if [ -z "$MANIFESTS" ]; then + echo "No manifests with \`\` found — skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $MANIFESTS; do + NS_PATH=$(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + [ -z "$NS_PATH" ] && continue + MANIFEST_DIR=$(dirname "$MANIFEST") + + echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY + + # Check PHP files have matching namespace + while IFS= read -r -d '' PHP_FILE; do + FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1) + [ -z "$FILE_NS" ] && continue + + # Namespace should start with the manifest namespace path + if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then + echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null) + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: SPDX license header check + continue-on-error: true + run: | + echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY + MISSING=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + TOTAL=0 + while IFS= read -r -d '' FILE; do + TOTAL=$((TOTAL + 1)) + if ! head -10 "$FILE" | grep -qi "SPDX"; then + echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY + MISSING=$((MISSING + 1)) + fi + done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0) + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$MISSING" -gt 0 ]; then + echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY + else + echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Service provider check + run: | + echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$PROVIDERS" ]; then + echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY + else + for FILE in $PROVIDERS; do + # Must return a ServiceProviderInterface + if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then + echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY + fi + + # Must have return statement + if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then + echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Script file reference check + run: | + echo "### Script File Reference" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + MANIFEST_DIR=$(dirname "$MANIFEST") + SCRIPT_FILE=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + if [ -z "$SCRIPT_FILE" ]; then + echo "No \`\` referenced — skipping." >> $GITHUB_STEP_SUMMARY + elif [ ! -f "${MANIFEST_DIR}/${SCRIPT_FILE}" ]; then + echo "::error file=${MANIFEST}::Manifest references \`${SCRIPT_FILE}\` but file does not exist" + echo "- **Missing** \`${SCRIPT_FILE}\` — referenced in \`\` but not found" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${SCRIPT_FILE}\`: present ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} script file issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Script file reference check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Media folder validation + run: | + echo "### Media Folder Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check tag and its folder/filename children + MEDIA_DEST=$(grep -oP ']*\bdestination="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1) + MEDIA_FOLDER=$(grep -oP ']*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1) + + if [ -z "$MEDIA_DEST" ] && [ -z "$MEDIA_FOLDER" ]; then + echo "No \`\` tag found — skipping." >> $GITHUB_STEP_SUMMARY + else + if [ -n "$MEDIA_FOLDER" ] && [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then + echo "::error file=${MANIFEST}::\`\` references missing directory" + echo "- **Missing** media folder \`${MEDIA_FOLDER}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- Media folder \`${MEDIA_FOLDER:-(inline)}\`: present ✓" >> $GITHUB_STEP_SUMMARY + + # Check child references inside block + if [ -n "$MEDIA_FOLDER" ]; then + MEDIA_FOLDERS=$(sed -n '//p' "$MANIFEST" | grep -oP '\K[^<]+' 2>/dev/null || true) + for F in $MEDIA_FOLDERS; do + if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then + echo "- **Missing** media subfolder \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + MEDIA_FILES=$(sed -n '//p' "$MANIFEST" | grep -oP '\K[^<]+' 2>/dev/null || true) + for F in $MEDIA_FILES; do + if [ ! -f "${MANIFEST_DIR}/${MEDIA_FOLDER}/${F}" ]; then + echo "- **Missing** media file \`${MEDIA_FOLDER}/${F}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} media reference issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Media folder validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Target platform check + continue-on-error: true + run: | + echo "### Target Platform Check" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Check updates.xml for targetplatform if it exists + if [ -f "updates.xml" ]; then + if ! grep -q '/dev/null; then + echo "::warning file=updates.xml::No \`\` found — Joomla updater cannot filter by compatible version" + echo "- **Missing** \`\` in updates.xml" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + echo "- \`\` in updates.xml: present ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + + # Check manifest for minimum PHP/Joomla version hints + if ! grep -qP '|targetplatform|joomla.*version' "$MANIFEST" 2>/dev/null; then + echo "::warning file=${MANIFEST}::No minimum Joomla or PHP version constraint found in manifest" + echo "- **Missing** version constraints (\`\` or \`\`)" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + echo "- Version constraints in manifest: present ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} target platform warning(s).**" >> $GITHUB_STEP_SUMMARY + else + echo "**Target platform check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Changelog URL check + continue-on-error: true + run: | + echo "### Changelog URL Check" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + if ! grep -q '' "$MANIFEST" 2>/dev/null; then + echo "::warning file=${MANIFEST}::Missing \`\` — Joomla updater will not display changelogs" + echo "- **Missing** \`\` — Joomla 4+ shows changelogs in the update manager when this is set" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + else + CHANGELOG_URL=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1) + echo "- \`\`: \`${CHANGELOG_URL}\` ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} changelog URL warning(s).**" >> $GITHUB_STEP_SUMMARY + else + echo "**Changelog URL check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Duplicate file references check + continue-on-error: true + run: | + echo "### Duplicate File References" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Extract all and references + ALL_REFS=$(grep -oP '<(filename|folder)[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | sort || true) + if [ -z "$ALL_REFS" ]; then + echo "No file/folder references found — skipping." >> $GITHUB_STEP_SUMMARY + else + DUPES=$(echo "$ALL_REFS" | uniq -d) + if [ -n "$DUPES" ]; then + while IFS= read -r DUP; do + COUNT=$(echo "$ALL_REFS" | grep -cx "$DUP") + echo "::warning file=${MANIFEST}::Duplicate reference: \`${DUP}\` appears ${COUNT} times (may be valid if in different sections)" + echo "- **Duplicate:** \`${DUP}\` (${COUNT}x) — check if cross-section" >> $GITHUB_STEP_SUMMARY + WARNINGS=$((WARNINGS + 1)) + done <<< "$DUPES" + else + TOTAL=$(echo "$ALL_REFS" | wc -l) + echo "All ${TOTAL} file/folder references are unique." >> $GITHUB_STEP_SUMMARY + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} duplicate reference(s) found.** Review for cross-section validity." >> $GITHUB_STEP_SUMMARY + else + echo "**Duplicate file references check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Empty language keys check + continue-on-error: true + run: | + echo "### Empty Language Keys" >> $GITHUB_STEP_SUMMARY + WARNINGS=0 + + LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$LANG_FILES" ]; then + echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY + else + TOTAL_FILES=0 + for FILE in $LANG_FILES; do + TOTAL_FILES=$((TOTAL_FILES + 1)) + # Find lines with KEY= but no value (empty or whitespace-only after =) + EMPTY_KEYS=$(grep -nP '^[A-Z_]+=\s*$' "$FILE" 2>/dev/null || true) + if [ -n "$EMPTY_KEYS" ]; then + COUNT=$(echo "$EMPTY_KEYS" | wc -l) + echo "::warning file=${FILE}::${COUNT} empty language key(s)" + echo "- \`${FILE}\`: ${COUNT} empty key(s)" >> $GITHUB_STEP_SUMMARY + while IFS= read -r LINE; do + LINE_NUM=$(echo "$LINE" | cut -d: -f1) + KEY=$(echo "$LINE" | cut -d: -f2 | cut -d= -f1) + echo " - Line ${LINE_NUM}: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$EMPTY_KEYS" + WARNINGS=$((WARNINGS + COUNT)) + fi + done + + if [ "$WARNINGS" -eq 0 ]; then + echo "All ${TOTAL_FILES} language file(s) have populated keys." >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$WARNINGS" -gt 0 ]; then + echo "**${WARNINGS} empty language key(s) across ${TOTAL_FILES} file(s).**" >> $GITHUB_STEP_SUMMARY + else + echo "**Empty language keys check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Language key usage consistency + run: | + echo "### Language Key Usage Consistency" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Collect all defined language keys from .ini files + LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$LANG_FILES" ]; then + echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Build list of defined keys + DEFINED_KEYS=$(cat $LANG_FILES 2>/dev/null | grep -oP '^[A-Z][A-Z0-9_]+(?==)' | sort -u) + if [ -z "$DEFINED_KEYS" ]; then + echo "No language keys defined — skipping." >> $GITHUB_STEP_SUMMARY + else + DEFINED_COUNT=$(echo "$DEFINED_KEYS" | wc -l) + echo "Found ${DEFINED_COUNT} defined key(s) across .ini files" >> $GITHUB_STEP_SUMMARY + + # Find keys used in PHP files (Text::_('KEY'), Text::sprintf('KEY'), etc.) + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -n "$SRC_DIR" ]; then + USED_IN_PHP=$(grep -rohP "Text::(?:_|sprintf|plural|alt)\(\s*['\"]([A-Z][A-Z0-9_]+)['\"]" "$SRC_DIR" --include="*.php" 2>/dev/null | grep -oP "[A-Z][A-Z0-9_]+" | sort -u || true) + fi + + # Find keys used in XML files (label=, description=, etc.) + USED_IN_XML=$(find . -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -ohP '(?:label|description|title|message|note)="([A-Z][A-Z0-9_]+)"' {} \; 2>/dev/null | grep -oP '[A-Z][A-Z0-9_]+' | sort -u || true) + + ALL_USED=$(printf '%s\n%s' "$USED_IN_PHP" "$USED_IN_XML" | sort -u | grep -v '^$' || true) + + if [ -n "$ALL_USED" ]; then + # Keys used but not defined + MISSING=$(comm -23 <(echo "$ALL_USED") <(echo "$DEFINED_KEYS") 2>/dev/null || true) + if [ -n "$MISSING" ]; then + COUNT=$(echo "$MISSING" | wc -l) + echo "::error::${COUNT} language key(s) used in code but not defined in .ini files" + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Used but not defined:**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r KEY; do + echo "- \`${KEY}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + done <<< "$MISSING" + fi + else + echo "No language key usage found in source files." >> $GITHUB_STEP_SUMMARY + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} undefined language key(s) — these will appear as raw key strings in the UI.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Language key usage consistency passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Orphan language keys + run: | + echo "### Orphan Language Keys" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$LANG_FILES" ]; then + echo "No .ini language files found — skipping." >> $GITHUB_STEP_SUMMARY + else + DEFINED_KEYS=$(cat $LANG_FILES 2>/dev/null | grep -oP '^[A-Z][A-Z0-9_]+(?==)' | sort -u) + if [ -z "$DEFINED_KEYS" ]; then + echo "No language keys defined — skipping." >> $GITHUB_STEP_SUMMARY + else + # Search all PHP, XML, and INI files for key references + ALL_SOURCE=$(find . \( -name "*.php" -o -name "*.xml" -o -name "*.ini" -o -name "*.js" \) -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null) + ORPHANS="" + while IFS= read -r KEY; do + # Skip sys.ini _DESC/_TITLE keys — these are Joomla convention keys + # Search for the key in all source files + if ! grep -rql "$KEY" --include="*.php" --include="*.xml" --include="*.js" . 2>/dev/null | grep -qv '\.ini' 2>/dev/null; then + ORPHANS="${ORPHANS}${KEY}\n" + fi + done <<< "$DEFINED_KEYS" + + ORPHANS=$(printf '%b' "$ORPHANS" | grep -v '^$' || true) + if [ -n "$ORPHANS" ]; then + COUNT=$(echo "$ORPHANS" | wc -l) + echo "::error::${COUNT} language key(s) defined in .ini but never referenced in code" + echo "**Orphan keys (defined but unreferenced):**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r KEY; do + echo "- \`${KEY}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + done <<< "$ORPHANS" + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} orphan language key(s) — remove unused keys to reduce translation burden.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Orphan language keys check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: PHP CodeSniffer (Joomla standard) + run: | + echo "### PHP CodeSniffer" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Install PHPCS + Joomla coding standard if not already available + PHPCS="" + if [ -f "vendor/bin/phpcs" ]; then + PHPCS="vendor/bin/phpcs" + else + composer global require --no-interaction --quiet \ + squizlabs/php_codesniffer \ + joomla/coding-standards 2>/dev/null || true + GLOBAL_BIN=$(composer global config bin-dir --absolute 2>/dev/null) + if [ -f "${GLOBAL_BIN}/phpcs" ]; then + PHPCS="${GLOBAL_BIN}/phpcs" + # Register Joomla standard + GLOBAL_VENDOR=$(composer global config vendor-dir --absolute 2>/dev/null) + if [ -d "${GLOBAL_VENDOR}/joomla/coding-standards" ]; then + $PHPCS --config-set installed_paths "${GLOBAL_VENDOR}/joomla/coding-standards" 2>/dev/null || true + fi + fi + fi + + if [ -z "$PHPCS" ]; then + echo "Could not install PHP CodeSniffer — skipping." >> $GITHUB_STEP_SUMMARY + else + # Use repo phpcs.xml if present, otherwise use Joomla standard + ARGS="--report=summary --no-colors" + if [ -f "phpcs.xml" ] || [ -f "phpcs.xml.dist" ] || [ -f ".phpcs.xml" ]; then + echo "Using project PHPCS config." >> $GITHUB_STEP_SUMMARY + else + # Check if Joomla standard is available + if $PHPCS -i 2>/dev/null | grep -qi "Joomla"; then + ARGS="$ARGS --standard=Joomla" + echo "Using Joomla coding standard." >> $GITHUB_STEP_SUMMARY + else + ARGS="$ARGS --standard=PSR12" + echo "Joomla standard not available — falling back to PSR-12." >> $GITHUB_STEP_SUMMARY + fi + ARGS="$ARGS ${SRC_DIR}" + fi + + $PHPCS $ARGS 2>&1 | tee /tmp/phpcs-output.txt + EXIT=${PIPESTATUS[0]} + + if [ $EXIT -eq 0 ]; then + echo "**No coding standard violations found.**" >> $GITHUB_STEP_SUMMARY + else + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/phpcs-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "**Coding standard violations found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + fi + fi + + - name: Security patterns check + run: | + echo "### Security Patterns" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + # 1. Direct superglobal access ($_GET, $_POST, $_REQUEST, $_COOKIE) + SUPERGLOBAL_HITS=$(grep -rnP '\$_(GET|POST|REQUEST|COOKIE|FILES)\b' "$SRC_DIR" --include="*.php" 2>/dev/null || true) + if [ -n "$SUPERGLOBAL_HITS" ]; then + COUNT=$(echo "$SUPERGLOBAL_HITS" | wc -l) + echo "::error::${COUNT} direct superglobal access(es) — use Joomla Input API instead" + echo "**Direct superglobal access (use \`\$app->getInput()\` or \`InputFilter\`):**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r HIT; do + FILE=$(echo "$HIT" | cut -d: -f1) + LINE=$(echo "$HIT" | cut -d: -f2) + echo "- \`${FILE}:${LINE}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$SUPERGLOBAL_HITS" + ERRORS=$((ERRORS + COUNT)) + fi + + # 2. Raw SQL query construction (string concatenation in query) + RAW_SQL=$(grep -rnP '\$db->setQuery\(\s*["\x27]' "$SRC_DIR" --include="*.php" 2>/dev/null || true) + if [ -n "$RAW_SQL" ]; then + COUNT=$(echo "$RAW_SQL" | wc -l) + echo "::error::${COUNT} raw SQL string(s) in setQuery() — use query builder or prepared statements" + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Raw SQL strings in \`setQuery()\` (use query builder):**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r HIT; do + FILE=$(echo "$HIT" | cut -d: -f1) + LINE=$(echo "$HIT" | cut -d: -f2) + echo "- \`${FILE}:${LINE}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$RAW_SQL" + ERRORS=$((ERRORS + COUNT)) + fi + + # 3. eval() usage + EVAL_HITS=$(grep -rnP '\beval\s*\(' "$SRC_DIR" --include="*.php" 2>/dev/null || true) + if [ -n "$EVAL_HITS" ]; then + COUNT=$(echo "$EVAL_HITS" | wc -l) + echo "::error::${COUNT} eval() usage(s) — never use eval in extensions" + echo "" >> $GITHUB_STEP_SUMMARY + echo "**\`eval()\` usage (critical security risk):**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r HIT; do + FILE=$(echo "$HIT" | cut -d: -f1) + LINE=$(echo "$HIT" | cut -d: -f2) + echo "- \`${FILE}:${LINE}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$EVAL_HITS" + ERRORS=$((ERRORS + COUNT)) + fi + + # 4. Missing defined('_JEXEC') or JDEFINED guard + while IFS= read -r -d '' PHP_FILE; do + FIRST_PHP=$(grep -nP '^\s*<\?php' "$PHP_FILE" 2>/dev/null | head -1) + if [ -n "$FIRST_PHP" ]; then + # Check first 5 non-empty lines after /dev/null; then + # Skip service provider files (they use a different entry point) + if ! echo "$PHP_FILE" | grep -q "provider.php"; then + echo "::error file=${PHP_FILE}::Missing _JEXEC direct access guard" + echo "- \`${PHP_FILE}\`: missing \`defined('_JEXEC') or die;\` guard" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + fi + done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0 2>/dev/null) + + if [ "$ERRORS" -eq 0 ]; then + echo "No security issues found." >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} security issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Security patterns check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: updates.xml structure validation + run: | + echo "### updates.xml Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + UPDATE_XML=$(find . -maxdepth 2 -name "updates.xml" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null | head -1) + if [ -z "$UPDATE_XML" ]; then + echo "No updates.xml found — skipping." >> $GITHUB_STEP_SUMMARY + else + echo "Found: \`${UPDATE_XML}\`" >> $GITHUB_STEP_SUMMARY + + # 1. Well-formed XML + if command -v php &> /dev/null; then + if ! php -r "@simplexml_load_file('$UPDATE_XML') ?: exit(1);" 2>/dev/null; then + echo "::error file=${UPDATE_XML}::updates.xml is not well-formed XML" + echo "- **Malformed XML** — cannot be parsed" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- Well-formed XML ✓" >> $GITHUB_STEP_SUMMARY + + # 2. Must have root element + if ! grep -q '' "$UPDATE_XML" 2>/dev/null; then + echo "::error file=${UPDATE_XML}::Missing root element" + echo "- **Missing** \`\` root element" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`\` root element ✓" >> $GITHUB_STEP_SUMMARY + fi + + # 3. Each must have required children + UPDATE_COUNT=$(grep -c '' "$UPDATE_XML" 2>/dev/null || echo "0") + if [ "$UPDATE_COUNT" -eq 0 ]; then + echo "::error file=${UPDATE_XML}::No entries found" + echo "- **No \`\` entries** — file is empty" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- ${UPDATE_COUNT} \`\` entr(ies) found" >> $GITHUB_STEP_SUMMARY + + for TAG in name version type downloadurl; do + if ! grep -q "<${TAG}>" "$UPDATE_XML" 2>/dev/null; then + echo "::error file=${UPDATE_XML}::Missing required <${TAG}> in update entry" + echo "- **Missing** \`<${TAG}>\` in update entry" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`<${TAG}>\` present ✓" >> $GITHUB_STEP_SUMMARY + fi + done + + # 4. Must have + if ! grep -q '/dev/null; then + echo "::error file=${UPDATE_XML}::Missing — Joomla cannot filter by compatible version" + echo "- **Missing** \`\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`\` present ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} updates.xml issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**updates.xml validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: CSS/JS asset existence check + run: | + echo "### CSS/JS Asset Existence" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Find WebAssetManager registrations in PHP + # registerAndUseScript/Style, useScript/Style with asset name + ASSET_REFS=$(grep -rohP "(?:registerAndUse|register|use)(?:Script|Style)\(\s*['\"]([^'\"]+)['\"]" "$SRC_DIR" --include="*.php" 2>/dev/null | grep -oP "['\"][^'\"]+['\"]" | tr -d "'\"" | sort -u || true) + + # Find joomla.asset.json files + ASSET_JSONS=$(find . -name "joomla.asset.json" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + + # Find media directories + MEDIA_DIRS=$(find . -type d -name "media" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null) + + if [ -z "$ASSET_REFS" ] && [ -z "$ASSET_JSONS" ]; then + echo "No asset registrations found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Validate joomla.asset.json is well-formed + if [ -n "$ASSET_JSONS" ]; then + for ASSET_JSON in $ASSET_JSONS; do + if command -v php &> /dev/null; then + VALID=$(php -r "echo json_decode(file_get_contents('$ASSET_JSON')) ? 'VALID' : 'INVALID';" 2>/dev/null) + if [ "$VALID" != "VALID" ]; then + echo "::error file=${ASSET_JSON}::joomla.asset.json is not valid JSON" + echo "- **Invalid JSON**: \`${ASSET_JSON}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${ASSET_JSON}\`: valid JSON ✓" >> $GITHUB_STEP_SUMMARY + + # Check that referenced URIs in asset JSON point to existing files + URIS=$(php -r " + \$j = json_decode(file_get_contents('$ASSET_JSON'), true); + \$assets = array_merge(\$j['assets'] ?? [], []); + foreach (\$assets as \$a) { + if (isset(\$a['uri'])) echo \$a['uri'] . PHP_EOL; + if (isset(\$a['css'])) foreach ((array)\$a['css'] as \$c) echo (is_array(\$c) ? \$c['uri'] ?? '' : \$c) . PHP_EOL; + if (isset(\$a['js'])) foreach ((array)\$a['js'] as \$c) echo (is_array(\$c) ? \$c['uri'] ?? '' : \$c) . PHP_EOL; + } + " 2>/dev/null | grep -v '^$' | grep -v '^http' || true) + + ASSET_DIR=$(dirname "$ASSET_JSON") + for URI in $URIS; do + # Resolve relative path from asset JSON location + if [ ! -f "${ASSET_DIR}/${URI}" ]; then + echo "::error file=${ASSET_JSON}::Asset URI '${URI}' references missing file" + echo " - **Missing**: \`${URI}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + fi + done + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} asset issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**CSS/JS asset existence check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: MVC naming conventions check + run: | + echo "### MVC Naming Conventions" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + SRC_DIR="" + for DIR in source/ src/ htdocs/; do + [ -d "$DIR" ] && SRC_DIR="$DIR" && break + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Check View classes — file must match class name pattern + VIEW_FILES=$(find "$SRC_DIR" -name "*.php" -path "*/View/*" -not -path "./vendor/*" 2>/dev/null) + if [ -n "$VIEW_FILES" ]; then + for FILE in $VIEW_FILES; do + BASENAME=$(basename "$FILE" .php) + # View class should contain class declaration matching filename + CLASS_NAME=$(grep -oP '^\s*class\s+\K[A-Za-z0-9_]+' "$FILE" 2>/dev/null | head -1) + if [ -n "$CLASS_NAME" ]; then + # Class name should end with View (HtmlView, JsonView, etc.) + if ! echo "$CLASS_NAME" | grep -qP 'View$'; then + echo "::error file=${FILE}::View class '${CLASS_NAME}' does not end with 'View'" + echo "- \`${FILE}\`: class \`${CLASS_NAME}\` should end with \`View\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + done + fi + + # Check Controller classes + CTRL_FILES=$(find "$SRC_DIR" -name "*.php" -path "*/Controller/*" -not -path "./vendor/*" 2>/dev/null) + if [ -n "$CTRL_FILES" ]; then + for FILE in $CTRL_FILES; do + CLASS_NAME=$(grep -oP '^\s*class\s+\K[A-Za-z0-9_]+' "$FILE" 2>/dev/null | head -1) + if [ -n "$CLASS_NAME" ]; then + if ! echo "$CLASS_NAME" | grep -qP 'Controller$'; then + echo "::error file=${FILE}::Controller class '${CLASS_NAME}' does not end with 'Controller'" + echo "- \`${FILE}\`: class \`${CLASS_NAME}\` should end with \`Controller\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + done + fi + + # Check Model classes + MODEL_FILES=$(find "$SRC_DIR" -name "*.php" -path "*/Model/*" -not -path "./vendor/*" 2>/dev/null) + if [ -n "$MODEL_FILES" ]; then + for FILE in $MODEL_FILES; do + CLASS_NAME=$(grep -oP '^\s*class\s+\K[A-Za-z0-9_]+' "$FILE" 2>/dev/null | head -1) + if [ -n "$CLASS_NAME" ]; then + if ! echo "$CLASS_NAME" | grep -qP 'Model$'; then + echo "::error file=${FILE}::Model class '${CLASS_NAME}' does not end with 'Model'" + echo "- \`${FILE}\`: class \`${CLASS_NAME}\` should end with \`Model\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + done + fi + + # Check Table classes + TABLE_FILES=$(find "$SRC_DIR" -name "*.php" -path "*/Table/*" -not -path "./vendor/*" 2>/dev/null) + if [ -n "$TABLE_FILES" ]; then + for FILE in $TABLE_FILES; do + CLASS_NAME=$(grep -oP '^\s*class\s+\K[A-Za-z0-9_]+' "$FILE" 2>/dev/null | head -1) + if [ -n "$CLASS_NAME" ]; then + if ! echo "$CLASS_NAME" | grep -qP 'Table$'; then + echo "::error file=${FILE}::Table class '${CLASS_NAME}' does not end with 'Table'" + echo "- \`${FILE}\`: class \`${CLASS_NAME}\` should end with \`Table\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + done + fi + + if [ -z "$VIEW_FILES" ] && [ -z "$CTRL_FILES" ] && [ -z "$MODEL_FILES" ] && [ -z "$TABLE_FILES" ]; then + echo "No MVC classes found — skipping." >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} MVC naming issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**MVC naming conventions check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Router validation + run: | + echo "### Router Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Only applies to components + COMP_MANIFESTS=$(find . -maxdepth 10 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./.claude/*" -exec grep -l ']*type="component"' {} \; 2>/dev/null || true) + + if [ -z "$COMP_MANIFESTS" ]; then + echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $COMP_MANIFESTS; do + COMP_DIR=$(dirname "$MANIFEST") + COMP_NAME=$(basename "$COMP_DIR") + echo "Component: \`${COMP_NAME}\`" >> $GITHUB_STEP_SUMMARY + + # Check for router class in src/Service/ + ROUTER_FILE=$(find "$COMP_DIR" -name "Router.php" -path "*/Service/*" -not -path "./vendor/*" 2>/dev/null | head -1) + if [ -z "$ROUTER_FILE" ]; then + # Also check for legacy router.php + ROUTER_FILE=$(find "$COMP_DIR" -name "router.php" -not -path "./vendor/*" 2>/dev/null | head -1) + fi + + if [ -z "$ROUTER_FILE" ]; then + echo "::error file=${MANIFEST}::Component '${COMP_NAME}' has no Router class" + echo "- **Missing** Router — SEF URLs will not work" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- Router found: \`${ROUTER_FILE}\`" >> $GITHUB_STEP_SUMMARY + + # Check router implements RouterServiceInterface or RouterInterface + if ! grep -qP 'RouterServiceInterface|RouterInterface|RouterBase' "$ROUTER_FILE" 2>/dev/null; then + echo "::error file=${ROUTER_FILE}::Router does not implement RouterServiceInterface or RouterInterface" + echo " - **Does not implement** required router interface" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo " - Implements router interface ✓" >> $GITHUB_STEP_SUMMARY + fi + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} router issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Router validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: ACL language keys check + run: | + echo "### ACL Language Keys" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + ACCESS_FILES=$(find . -name "access.xml" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + if [ -z "$ACCESS_FILES" ]; then + echo "No access.xml files found — skipping." >> $GITHUB_STEP_SUMMARY + else + # Collect all defined language keys + LANG_FILES=$(find . -name "*.ini" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null) + DEFINED_KEYS="" + if [ -n "$LANG_FILES" ]; then + DEFINED_KEYS=$(cat $LANG_FILES 2>/dev/null | grep -oP '^[A-Z][A-Z0-9_]+(?==)' | sort -u) + fi + + for ACCESS_FILE in $ACCESS_FILES; do + echo "Checking: \`${ACCESS_FILE}\`" >> $GITHUB_STEP_SUMMARY + + # Extract action names and titles/descriptions from access.xml + ACTIONS=$(grep -oP ']*name="\K[^"]+' "$ACCESS_FILE" 2>/dev/null || true) + TITLES=$(grep -oP ']*title="\K[^"]+' "$ACCESS_FILE" 2>/dev/null || true) + DESCS=$(grep -oP ']*description="\K[^"]+' "$ACCESS_FILE" 2>/dev/null || true) + + # Check title keys are defined + for KEY in $TITLES $DESCS; do + # Only check keys that look like language constants (ALL_CAPS_WITH_UNDERSCORES) + if echo "$KEY" | grep -qP '^[A-Z][A-Z0-9_]+$'; then + if [ -n "$DEFINED_KEYS" ]; then + if ! echo "$DEFINED_KEYS" | grep -qx "$KEY"; then + echo "::error file=${ACCESS_FILE}::ACL key '${KEY}' not defined in language files" + echo "- **Undefined** ACL key: \`${KEY}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + fi + done + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} ACL language key issue(s) — these will show raw key strings in permissions UI.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**ACL language keys check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Webservices plugin API manifest check + run: | + echo "### Webservices API Manifest" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find webservices plugin manifests + WS_MANIFESTS=$(find . -maxdepth 10 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./.claude/*" -exec grep -l ']*type="plugin"' {} \; 2>/dev/null | while read -r F; do + if grep -q 'group="webservices"' "$F" 2>/dev/null; then + echo "$F" + fi + done) + + if [ -z "$WS_MANIFESTS" ]; then + echo "No webservices plugins found — skipping." >> $GITHUB_STEP_SUMMARY + else + for MANIFEST in $WS_MANIFESTS; do + WS_DIR=$(dirname "$MANIFEST") + WS_NAME=$(basename "$WS_DIR") + echo "Webservices plugin: \`${WS_NAME}\`" >> $GITHUB_STEP_SUMMARY + + # Find the plugin PHP file + WS_PHP=$(find "$WS_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" 2>/dev/null | head -1) + if [ -z "$WS_PHP" ]; then + WS_PHP=$(find "$WS_DIR" -name "*.php" -not -name "provider.php" -not -path "*/services/*" -not -path "./vendor/*" 2>/dev/null | head -1) + fi + + if [ -n "$WS_PHP" ]; then + # Check it implements WebserviceInterface or registers routes + if ! grep -qP 'WebserviceInterface|onBeforeApiRoute|ApiRouter' "$WS_PHP" 2>/dev/null; then + echo "::error file=${WS_PHP}::Webservices plugin does not implement WebserviceInterface or register API routes" + echo "- \`${WS_PHP}\`: missing \`WebserviceInterface\` or route registration" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "- \`${WS_PHP}\`: API routing ✓" >> $GITHUB_STEP_SUMMARY + fi + else + echo "- No plugin PHP class found in \`${WS_DIR}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} webservices API issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Webservices API manifest check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: ZIP dry-run build + run: | + echo "### ZIP Dry-Run Build" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY + else + MANIFEST_DIR=$(dirname "$MANIFEST") + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + + # Collect all files that should be in the ZIP + MISSING_FILES="" + TOTAL_FILES=0 + + # Files from tags + for F in $(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true); do + TOTAL_FILES=$((TOTAL_FILES + 1)) + if [ ! -f "${MANIFEST_DIR}/${F}" ]; then + MISSING_FILES="${MISSING_FILES}${F}\n" + fi + done + + # Folders from tags + for F in $(grep -oP ']*>\K[^<]+' "$MANIFEST" 2>/dev/null || true); do + TOTAL_FILES=$((TOTAL_FILES + 1)) + if [ ! -d "${MANIFEST_DIR}/${F}" ]; then + MISSING_FILES="${MISSING_FILES}${F}/\n" + fi + done + + # Script file + SCRIPT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + if [ -n "$SCRIPT" ]; then + TOTAL_FILES=$((TOTAL_FILES + 1)) + if [ ! -f "${MANIFEST_DIR}/${SCRIPT}" ]; then + MISSING_FILES="${MISSING_FILES}${SCRIPT}\n" + fi + fi + + # Media folder + MEDIA_FOLDER=$(grep -oP ']*\bfolder="\K[^"]+' "$MANIFEST" 2>/dev/null | head -1) + if [ -n "$MEDIA_FOLDER" ]; then + TOTAL_FILES=$((TOTAL_FILES + 1)) + if [ ! -d "${MANIFEST_DIR}/${MEDIA_FOLDER}" ]; then + MISSING_FILES="${MISSING_FILES}${MEDIA_FOLDER}/\n" + fi + fi + + # SQL folder + SQL_FOLDER=$(grep -oP '\s*\s*]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1) + if [ -n "$SQL_FOLDER" ]; then + SQL_DIR=$(dirname "$SQL_FOLDER") + TOTAL_FILES=$((TOTAL_FILES + 1)) + if [ ! -f "${MANIFEST_DIR}/${SQL_FOLDER}" ]; then + MISSING_FILES="${MISSING_FILES}${SQL_FOLDER}\n" + fi + fi + + MISSING_FILES=$(printf '%b' "$MISSING_FILES" | grep -v '^$' || true) + if [ -n "$MISSING_FILES" ]; then + COUNT=$(echo "$MISSING_FILES" | wc -l) + echo "::error file=${MANIFEST}::ZIP build would fail — ${COUNT} referenced file(s) missing" + echo "**Missing files that would cause ZIP build to fail:**" >> $GITHUB_STEP_SUMMARY + while IFS= read -r F; do + echo "- \`${F}\`" >> $GITHUB_STEP_SUMMARY + done <<< "$MISSING_FILES" + ERRORS=$((ERRORS + COUNT)) + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "Checked ${TOTAL_FILES} manifest reference(s) for \`${EXT_TYPE}\` extension." >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} ZIP build issue(s) — package would be incomplete.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**ZIP dry-run build passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: JavaScript linting + run: | + echo "### JavaScript Linting" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + JS_FILES=$(find . -name "*.js" -not -name "*.min.js" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null) + if [ -z "$JS_FILES" ]; then + echo "No JavaScript files found — skipping." >> $GITHUB_STEP_SUMMARY + else + JS_COUNT=$(echo "$JS_FILES" | wc -l) + echo "Found ${JS_COUNT} JavaScript file(s)" >> $GITHUB_STEP_SUMMARY + + # Check if Node.js is available + if command -v node &> /dev/null && command -v npx &> /dev/null; then + # Use project ESLint config if present, otherwise install and use defaults + if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.yml" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then + if [ -f "package.json" ]; then + npm install --silent 2>/dev/null || true + fi + npx eslint $JS_FILES 2>&1 | tee /tmp/eslint-output.txt + EXIT=${PIPESTATUS[0]} + else + # Basic syntax check without ESLint config + for FILE in $JS_FILES; do + if ! node --check "$FILE" 2>/tmp/js-syntax-err.txt; then + echo "::error file=${FILE}::JavaScript syntax error" + echo "- \`${FILE}\`: syntax error" >> $GITHUB_STEP_SUMMARY + cat /tmp/js-syntax-err.txt >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + EXIT=$ERRORS + fi + + if [ $EXIT -ne 0 ] && [ -f /tmp/eslint-output.txt ]; then + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/eslint-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + else + echo "Node.js not available — skipping JavaScript lint." >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} JavaScript issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**JavaScript linting passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: CSS linting + run: | + echo "### CSS Linting" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + CSS_FILES=$(find . -name "*.css" -not -name "*.min.css" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null) + if [ -z "$CSS_FILES" ]; then + echo "No CSS files found — skipping." >> $GITHUB_STEP_SUMMARY + else + CSS_COUNT=$(echo "$CSS_FILES" | wc -l) + echo "Found ${CSS_COUNT} CSS file(s)" >> $GITHUB_STEP_SUMMARY + + for FILE in $CSS_FILES; do + # Basic CSS validation: check for unbalanced braces + OPEN=$(grep -oP '\{' "$FILE" 2>/dev/null | wc -l) + CLOSE=$(grep -oP '\}' "$FILE" 2>/dev/null | wc -l) + if [ "$OPEN" -ne "$CLOSE" ]; then + echo "::error file=${FILE}::Unbalanced braces (${OPEN} open vs ${CLOSE} close)" + echo "- \`${FILE}\`: **unbalanced braces** (${OPEN} \`{\` vs ${CLOSE} \`}\`)" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # Check for empty file + SIZE=$(wc -c < "$FILE" | tr -d ' ') + if [ "$SIZE" -eq 0 ]; then + echo "::error file=${FILE}::Empty CSS file" + echo "- \`${FILE}\`: **empty file**" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done + + # Use Stylelint if available and configured + if command -v npx &> /dev/null; then + if [ -f ".stylelintrc" ] || [ -f ".stylelintrc.json" ] || [ -f "stylelint.config.js" ] || [ -f "stylelint.config.mjs" ]; then + if [ -f "package.json" ]; then + npm install --silent 2>/dev/null || true + fi + npx stylelint "**/*.css" --ignore-pattern "*.min.css" --ignore-pattern "node_modules" --ignore-pattern "vendor" 2>&1 | tee /tmp/stylelint-output.txt + EXIT=${PIPESTATUS[0]} + if [ $EXIT -ne 0 ]; then + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/stylelint-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} CSS issue(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**CSS linting passed.**" >> $GITHUB_STEP_SUMMARY + fi + + release-readiness: + name: Release Readiness Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.base_ref == 'main' + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate release readiness + run: | + echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Extract version from README.md + README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1) + if [ -z "$README_VERSION" ]; then + echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Find the extension manifest + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY + + # Check matches README VERSION + MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1) + if [ -z "$MANIFEST_VERSION" ]; then + echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then + echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Check extension type, element, client attributes + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ -z "$EXT_TYPE" ]; then + echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Element check (component/module/plugin name) + HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0") + if [ "$HAS_ELEMENT" -eq 0 ]; then + echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # Client attribute for site/admin modules and plugins + if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then + HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0") + if [ "$HAS_CLIENT" -eq 0 ]; then + echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + fi + + # Check updates.xml exists + if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then + echo "Update XML present." >> $GITHUB_STEP_SUMMARY + else + echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # Check CHANGELOG.md exists + if [ -f "CHANGELOG.md" ]; then + echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY + else + echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ $ERRORS -gt 0 ]; then + echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY + fi + + test: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} + needs: lint-and-validate + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + run: | + if ! command -v php &> /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 + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install \ + --no-interaction \ + --prefer-dist \ + --optimize-autoloader + else + echo "No composer.json found — skipping dependency install" + fi + + - name: Run tests + run: | + echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY + if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then + vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log + EXIT=${PIPESTATUS[0]} + if [ $EXIT -eq 0 ]; then + echo "All tests passed." >> $GITHUB_STEP_SUMMARY + else + echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + exit $EXIT + else + echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY + fi + + static-analysis: + name: PHPStan Analysis + runs-on: ubuntu-latest + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} + needs: lint-and-validate + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + run: | + if ! command -v php &> /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 + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install --no-interaction --prefer-dist --optimize-autoloader + fi + + - name: Install PHPStan + run: | + if ! command -v vendor/bin/phpstan &> /dev/null; then + composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \ + composer global require phpstan/phpstan --no-interaction + fi + + - name: Run PHPStan + run: | + echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY + PHPSTAN="vendor/bin/phpstan" + if [ ! -f "$PHPSTAN" ]; then + PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan + fi + + # Determine source directory + SRC_DIR="" + for DIR in src/ htdocs/ lib/; do + if [ -d "$DIR" ]; then + SRC_DIR="$DIR" + break + fi + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Use repo phpstan.neon if present, otherwise use baseline config + ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table" + if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then + echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY + else + ARGS="$ARGS --level=3" + echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY + fi + + $PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt + EXIT=${PIPESTATUS[0]} + + if [ $EXIT -eq 0 ]; then + echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY + else + ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some") + echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + exit $EXIT + + pre-release: + name: Build RC Pre-Release + runs-on: ubuntu-latest + needs: [lint-and-validate, test] + if: github.event_name == 'pull_request' + + steps: + - name: Trigger pre-release build + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + run: | + curl -s -X POST \ + "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \ + -H "Authorization: token ${MOKOGITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY