From f51b3a97d95d39680cd2bd35d5c586db441fe2b3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 2 May 2026 16:17:49 -0500 Subject: [PATCH] feat: add Joomla workflow templates from MokoOnyx Adds auto-release, ci-joomla, deploy, deploy-manual, repo-health, and update-server workflows as standard templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/auto-release.yml | 709 ++++++++++++++++++++++++++ .gitea/workflows/ci-joomla.yml | 377 ++++++++++++++ .gitea/workflows/deploy-manual.yml | 126 +++++ .gitea/workflows/deploy.yml | 201 ++++++++ .gitea/workflows/repo-health.yml | 766 +++++++++++++++++++++++++++++ .gitea/workflows/update-server.yml | 464 +++++++++++++++++ 6 files changed, 2643 insertions(+) create mode 100644 .gitea/workflows/auto-release.yml create mode 100644 .gitea/workflows/ci-joomla.yml create mode 100644 .gitea/workflows/deploy-manual.yml create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitea/workflows/repo-health.yml create mode 100644 .gitea/workflows/update-server.yml diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml new file mode 100644 index 0000000..242058b --- /dev/null +++ b/.gitea/workflows/auto-release.yml @@ -0,0 +1,709 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/joomla/auto-release.yml.template +# VERSION: 04.06.00 +# BRIEF: Joomla build & release — ZIP package, updates.xml, SHA-256 checksum +# +# +========================================================================+ +# | BUILD & RELEASE PIPELINE (JOOMLA) | +# +========================================================================+ +# | | +# | Triggers on push to main (skips bot commits + [skip ci]): | +# | | +# | Every push: | +# | 1. Read version from README.md | +# | 3. Set platform version (Joomla ) | +# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files | +# | 5. Write updates.xml (Joomla update server XML) | +# | 6. Create git tag vXX.YY.ZZ | +# | 7a. Patch: update existing Gitea Release for this minor | +# | 8. Build ZIP, upload asset, write SHA-256 to updates.xml | +# | | +# | Every version change: archives main -> version/XX.YY branch | +# | All patches release (including 00). Patch 00/01 = full pipeline. | +# | First release only (patch == 01): | +# | 7b. Create new Gitea Release | +# | | +# | GitHub mirror: stable/rc releases only (continue-on-error) | +# | | +# +========================================================================+ + +name: Build & Release + +on: + pull_request: + types: [closed] + branches: + - main + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + release: + name: Build & Release Pipeline + runs-on: release + if: >- + github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.GA_TOKEN }} + fetch-depth: 0 + + - name: Setup MokoStandards tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' + run: | + # Ensure PHP + Composer are available + 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 + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api + cd /tmp/mokostandards-api + composer install --no-dev --no-interaction --quiet + + # -- STEP 1: Read version ----------------------------------------------- + - name: "Step 1: Read version from README.md" + id: version + run: | + VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null) + if [ -z "$VERSION" ]; then + echo "No VERSION in README.md — skipping release" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Derive major.minor for branch naming (patches update existing branch) + MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}') + PATCH=$(echo "$VERSION" | awk -F. '{print $3}') + + MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') + MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}') + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT" + echo "minor=$MINOR" >> "$GITHUB_OUTPUT" + echo "major=$MAJOR" >> "$GITHUB_OUTPUT" + echo "release_tag=stable" >> "$GITHUB_OUTPUT" + echo "stability=stable" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then + echo "is_minor=true" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION (first release for this minor — full pipeline)" + else + echo "is_minor=false" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION (patch — platform version + badges only)" + fi + + - name: Check if already released + if: steps.version.outputs.skip != 'true' + id: check + run: | + TAG="${{ steps.version.outputs.release_tag }}" + BRANCH="${{ steps.version.outputs.branch }}" + + TAG_EXISTS=false + BRANCH_EXISTS=false + + git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true + git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true + + echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT" + echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT" + + # Tag and branch may persist across patch releases — never skip + echo "already_released=false" >> "$GITHUB_OUTPUT" + + # -- SANITY CHECKS ------------------------------------------------------- + - name: "Sanity: Pre-release validation" + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + ERRORS=0 + + echo "## Pre-Release Sanity Checks (Joomla)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # -- Version drift check (must pass before release) -------- + README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1) + if [ "$README_VER" != "$VERSION" ]; then + echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + else + echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Check CHANGELOG version matches + CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1) + if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then + echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + fi + + # Check composer.json version if present + if [ -f "composer.json" ]; then + COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1) + if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then + echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + fi + fi + + # Common checks + if [ ! -f "LICENSE" ]; then + echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + else + echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY + fi + + if [ ! -d "src" ] && [ ! -d "htdocs" ]; then + echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY + else + echo "- Source directory present" >> $GITHUB_STEP_SUMMARY + fi + + # -- Joomla: manifest version drift -------- + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + if [ -n "$MANIFEST" ]; then + XML_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1) + if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then + echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + else + echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + fi + + # -- Joomla: XML manifest existence -------- + if [ -z "$MANIFEST" ]; then + echo "- No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + else + echo "- Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY + + # -- Joomla: extension type check -------- + TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null) + echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$ERRORS" -gt 0 ]; then + echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY + else + echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY + fi + + # -- STEP 2: Create or update version/XX.YY archive branch --------------- + # Always runs — every version change on main archives to version/XX.YY + - name: "Step 2: Version archive branch" + if: steps.check.outputs.already_released != 'true' + run: | + BRANCH="${{ steps.version.outputs.branch }}" + IS_MINOR="${{ steps.version.outputs.is_minor }}" + PATCH="${{ steps.version.outputs.version }}" + PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}') + + # Check if branch exists + if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + git push origin HEAD:"$BRANCH" --force + echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY + else + git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH" + git push origin "$BRANCH" --force + echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + fi + + # -- STEP 3: Set platform version ---------------------------------------- + - name: "Step 3: Set platform version" + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + php /tmp/mokostandards-api/cli/version_set_platform.php \ + --path . --version "$VERSION" --branch main + + # -- STEP 4: Update version badges ---------------------------------------- + - name: "Step 4: Update version badges" + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do + if grep -q '\[VERSION:' "$f" 2>/dev/null; then + sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f" + fi + done + + # -- STEP 5: Write updates.xml (Joomla update server) --------------------- + - name: "Step 5: Write updates.xml" + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + REPO="${{ github.repository }}" + + # -- Parse extension metadata from XML manifest ---------------- + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Extract fields using sed (portable — no grep -P) + EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) + EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) + PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) + + # Fallbacks + [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" + [ -z "$EXT_TYPE" ] && EXT_TYPE="component" + + # Derive element if not in manifest: + # 1. Try XML filename (e.g. mokowaas.xml → mokowaas) + # 2. Fall back to repo name (lowercased) + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + # If filename is generic (templateDetails, manifest), use repo name + case "$EXT_ELEMENT" in + templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; + esac + fi + + # Build client tag: plugins and frontend modules need site + CLIENT_TAG="" + if [ -n "$EXT_CLIENT" ]; then + CLIENT_TAG="${EXT_CLIENT}" + elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then + CLIENT_TAG="site" + fi + + # Build folder tag for plugins (required for Joomla to match the update) + FOLDER_TAG="" + if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then + FOLDER_TAG="${EXT_FOLDER}" + fi + + # Build targetplatform (fallback to Joomla 5 if not in manifest) + if [ -z "$TARGET_PLATFORM" ]; then + TARGET_PLATFORM=$(printf '' "/") + fi + + # Build php_minimum tag + PHP_TAG="" + if [ -n "$PHP_MINIMUM" ]; then + PHP_TAG="${PHP_MINIMUM}" + fi + + DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${EXT_ELEMENT}-${VERSION}.zip" + INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable" + + # -- Build update entry for a given stability tag + build_entry() { + local TAG_NAME="$1" + printf '%s\n' ' ' + printf '%s\n' " ${EXT_NAME}" + printf '%s\n' " ${EXT_NAME} update" + printf '%s\n' " ${EXT_ELEMENT}" + printf '%s\n' " ${EXT_TYPE}" + printf '%s\n' " ${VERSION}" + [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" + [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" + printf '%s\n' " ${TAG_NAME}" + printf '%s\n' " ${INFO_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${DOWNLOAD_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${TARGET_PLATFORM}" + [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}" + printf '%s\n' ' Moko Consulting' + printf '%s\n' ' https://mokoconsulting.tech' + printf '%s\n' ' ' + } + + # -- Write updates.xml with cascading channels + # Stable release updates ALL channels (development, alpha, beta, rc, stable) + { + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' '' + build_entry "development" + build_entry "alpha" + build_entry "beta" + build_entry "rc" + build_entry "stable" + printf '%s\n' '' + } > updates.xml + + echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY + + # -- Commit all changes --------------------------------------------------- + - name: Commit release changes + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + if git diff --quiet && git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + VERSION="${{ steps.version.outputs.version }}" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + # Set push URL with token for branch-protected repos + git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git commit -m "chore(release): build ${VERSION} [skip ci]" \ + --author="gitea-actions[bot] " + git push -u origin HEAD + + # -- STEP 6: Create tag --------------------------------------------------- + - name: "Step 6: Create git tag" + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.tag_exists != 'true' && + steps.version.outputs.is_minor == 'true' + run: | + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + # Only create the major release tag if it doesn't exist yet + if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then + git tag "$RELEASE_TAG" + git push origin "$RELEASE_TAG" + echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY + else + echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY + fi + echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY + + # -- STEP 7: Create or update Gitea Release -------------------------------- + - name: "Step 7: Gitea Release" + if: >- + steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + BRANCH="${{ steps.version.outputs.branch }}" + MAJOR="${{ steps.version.outputs.major }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + + # Check if the major release already exists + EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true) + EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true) + + if [ -z "$EXISTING_ID" ]; then + # First release for this major + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/releases" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'tag_name': '${RELEASE_TAG}', + 'name': 'v${MAJOR} (latest: ${VERSION})', + 'body': '''${NOTES}''', + 'target_commitish': '${BRANCH}' + }))")" + echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY + else + # Append version notes to existing major release + CURRENT_BODY=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('body',''))" 2>/dev/null || true) + UPDATED_BODY="${CURRENT_BODY} + + --- + ### ${VERSION} + + ${NOTES}" + + curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/releases/${EXISTING_ID}" \ + -d "$(python3 -c "import json,sys; print(json.dumps({ + 'name': 'v${MAJOR} (latest: ${VERSION})', + 'body': sys.stdin.read() + }))" <<< "$UPDATED_BODY")" + echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY + fi + + # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------ + - name: "Step 8: Build Joomla package and update checksum" + if: >- + steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + REPO="${{ github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # All ZIPs upload to the major release tag (vXX) + RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true) + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + if [ -z "$RELEASE_ID" ]; then + echo "No release ${RELEASE_TAG} found — skipping ZIP upload" + exit 0 + fi + + # Find extension element name from manifest + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true) + [ -z "$MANIFEST" ] && exit 0 + + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml) + ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip" + TAR_NAME="${EXT_ELEMENT}-${VERSION}.tar.gz" + + # -- Build install packages from src/ ---------------------------- + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; } + + EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" + + # ZIP package + cd "$SOURCE_DIR" + zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES + cd .. + + # tar.gz package + tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ + --exclude='.ftpignore' --exclude='sftp-config*' \ + --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + + ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown") + TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown") + + # -- Calculate SHA-256 for both ---------------------------------- + SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1) + SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1) + + # -- Delete existing assets with same name before uploading ------ + ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]") + for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do + ASSET_ID=$(echo "$ASSETS" | python3 -c " + import sys,json + assets = json.load(sys.stdin) + for a in assets: + if a['name'] == '${ASSET_NAME}': + print(a['id']); break + " 2>/dev/null || true) + if [ -n "$ASSET_ID" ]; then + curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true + fi + done + + # -- Upload both to release tag ---------------------------------- + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${ZIP_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true + + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${TAR_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true + + # -- Update updates.xml with both download formats --------------- + if [ -f "updates.xml" ]; then + ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}" + TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}" + + # Use Python to update only the stable entry's downloads + sha256 + export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP" + python3 << 'PYEOF' + import re, os + + with open("updates.xml") as f: + content = f.read() + + zip_url = os.environ["PY_ZIP_URL"] + tar_url = os.environ["PY_TAR_URL"] + sha = os.environ["PY_SHA"] + + # Find the stable update block and replace its downloads + sha256 + def replace_stable(m): + block = m.group(0) + # Replace downloads block + new_downloads = ( + " \n" + f" {zip_url}\n" + " " + ) + block = re.sub(r' .*?', new_downloads, block, flags=re.DOTALL) + # Add or replace sha256 + if '' in block: + block = re.sub(r' .*?', f' {sha}', block) + else: + block = block.replace('', f'\n {sha}') + return block + + content = re.sub( + r' .*?stable.*?', + replace_stable, + content, + flags=re.DOTALL + ) + + with open("updates.xml", "w") as f: + f.write(content) + PYEOF + + CURRENT_BRANCH="${{ github.ref_name }}" + git add updates.xml + git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \ + --author="gitea-actions[bot] " || true + git push || true + + # Sync updates.xml to main via direct API (always runs — may be on version/XX branch) + GA_TOKEN="${{ secrets.GA_TOKEN }}" + API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}" + + FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \ + "${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty') + + if [ -n "$FILE_SHA" ]; then + CONTENT=$(base64 -w0 updates.xml) + curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/contents/updates.xml" \ + -d "$(jq -n \ + --arg content "$CONTENT" \ + --arg sha "$FILE_SHA" \ + --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \ + --arg branch "main" \ + '{content: $content, sha: $sha, message: $msg, branch: $branch}' + )" > /dev/null 2>&1 \ + && echo "updates.xml synced to main via API" \ + || echo "WARNING: failed to sync updates.xml to main" + else + echo "WARNING: could not get updates.xml SHA from main" + fi + fi + + echo "### Joomla Packages" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY + echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY + echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY + echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY + echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY + + # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- + - name: "Step 9: Mirror release to GitHub" + if: >- + steps.version.outputs.skip != 'true' && + steps.version.outputs.stability == 'stable' && + secrets.GH_TOKEN != '' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + MAJOR="${{ steps.version.outputs.major }}" + BRANCH="${{ steps.version.outputs.branch }}" + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + + NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + echo "$NOTES" > /tmp/release_notes.md + + EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true) + + if [ -z "$EXISTING" ]; then + gh release create "$RELEASE_TAG" \ + --repo "$GH_REPO" \ + --title "v${MAJOR} (latest: ${VERSION})" \ + --notes-file /tmp/release_notes.md \ + --target "$BRANCH" || true + else + gh release edit "$RELEASE_TAG" \ + --repo "$GH_REPO" \ + --title "v${MAJOR} (latest: ${VERSION})" || true + fi + + # Upload assets to GitHub mirror + for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do + if [ -f "$PKG" ]; then + _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty") + [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true + fi + done + echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY + + # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- + - name: "Step 10: Push main to GitHub mirror" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_TOKEN != '' + continue-on-error: true + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) + GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) + git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ + git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" + git fetch origin main --depth=1 + git push github origin/main:refs/heads/main --force 2>/dev/null \ + && echo "main branch pushed to GitHub mirror" \ + || echo "WARNING: GitHub mirror push failed" + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + if: always() + run: | + VERSION="${{ steps.version.outputs.version }}" + if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY + echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then + echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Build & Release Complete (Joomla)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitea/workflows/ci-joomla.yml b/.gitea/workflows/ci-joomla.yml new file mode 100644 index 0000000..17284d1 --- /dev/null +++ b/.gitea/workflows/ci-joomla.yml @@ -0,0 +1,377 @@ +# 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: Gitea.Workflow.Template +# INGROUP: MokoStandards.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# 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/**' + 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 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP + run: | + php -v && composer --version + + - name: Clone MokoStandards + env: + GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + run: | + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_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, namespace (Joomla 5+) + for TAG in name version author namespace; 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 + 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: 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 + + release-readiness: + name: Release Readiness Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.base_ref == 'main' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - 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 + needs: lint-and-validate + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3'] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP ${{ matrix.php }} + run: | + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_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 diff --git a/.gitea/workflows/deploy-manual.yml b/.gitea/workflows/deploy-manual.yml new file mode 100644 index 0000000..a81cfa5 --- /dev/null +++ b/.gitea/workflows/deploy-manual.yml @@ -0,0 +1,126 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Deploy +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API +# PATH: /templates/workflows/joomla/deploy-manual.yml.template +# VERSION: 04.07.00 +# BRIEF: Manual SFTP deploy to dev server for Joomla repos + +name: Deploy to Dev (Manual) + +on: + workflow_dispatch: + inputs: + clear_remote: + description: 'Delete all remote files before uploading' + required: false + default: 'false' + type: boolean + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + +jobs: + deploy: + name: SFTP Deploy to Dev + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP + run: | + php -v && composer --version + + - name: Setup MokoStandards tools + env: + GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' + run: | + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api 2>/dev/null || true + if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then + cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true + fi + + - name: Check FTP configuration + id: check + env: + HOST: ${{ vars.DEV_FTP_HOST }} + PATH_VAR: ${{ vars.DEV_FTP_PATH }} + PORT: ${{ vars.DEV_FTP_PORT }} + run: | + if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then + echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "host=$HOST" >> "$GITHUB_OUTPUT" + + REMOTE="${PATH_VAR%/}" + echo "remote=$REMOTE" >> "$GITHUB_OUTPUT" + + [ -z "$PORT" ] && PORT="22" + echo "port=$PORT" >> "$GITHUB_OUTPUT" + + - name: Deploy via SFTP + if: steps.check.outputs.skip != 'true' + env: + SFTP_KEY: ${{ secrets.DEV_FTP_KEY }} + SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }} + SFTP_USER: ${{ vars.DEV_FTP_USERNAME }} + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; } + + printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ + "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \ + > /tmp/sftp-config.json + + if [ -n "$SFTP_KEY" ]; then + echo "$SFTP_KEY" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json + else + printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json + fi + + DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json) + [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote) + + PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) + if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then + php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}" + else + php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" + fi + + rm -f /tmp/deploy_key /tmp/sftp-config.json + + - name: Summary + if: always() + run: | + if [ "${{ steps.check.outputs.skip }}" = "true" ]; then + echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY + else + echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f874e9c --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,201 @@ +# 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: GitHub.Workflow +# INGROUP: MokoStandards.Deploy +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API +# PATH: /templates/workflows/joomla/deploy.yml.template +# VERSION: 01.01.00 +# BRIEF: Auto-deploy src/ to dev/live servers via SSH rsync on push +# +# Required repo variables: +# DEPLOY_SSH_HOST -- SSH hostname for dev server +# DEPLOY_SSH_USER -- SSH username +# DEPLOY_SSH_PORT -- SSH port (default: 22) +# DEV_DEPLOY_PATH -- Absolute remote path for dev +# LIVE_DEPLOY_PATH -- Absolute remote path for live +# +# Optional repo variables: +# LIVE_SSH_HOST -- SSH hostname for live server (falls back to DEPLOY_SSH_HOST) +# +# Required org/repo secret: +# DEPLOY_SSH_KEY -- Private SSH key for authentication + +name: Deploy + +on: + push: + branches: [dev, main] + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + inputs: + target: + description: 'Deploy target' + required: true + default: 'dev' + type: choice + options: + - dev + - live + dry_run: + description: 'Dry run (show what would be deployed without deploying)' + required: false + default: false + type: boolean + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + +jobs: + deploy: + name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target || (github.ref_name == 'main' && 'Live' || 'Dev') }} + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Determine deploy target + id: target + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TARGET="${{ inputs.target }}" + elif [ "${{ github.ref_name }}" = "main" ]; then + TARGET="live" + else + TARGET="dev" + fi + + echo "env=${TARGET}" >> "$GITHUB_OUTPUT" + + if [ "$TARGET" = "live" ]; then + echo "remote_path=${{ vars.LIVE_DEPLOY_PATH }}" >> "$GITHUB_OUTPUT" + echo "ssh_host=${{ vars.LIVE_SSH_HOST || vars.DEPLOY_SSH_HOST }}" >> "$GITHUB_OUTPUT" + else + echo "remote_path=${{ vars.DEV_DEPLOY_PATH }}" >> "$GITHUB_OUTPUT" + echo "ssh_host=${{ vars.DEPLOY_SSH_HOST }}" >> "$GITHUB_OUTPUT" + fi + + # Resolve source directory + if [ -d "src" ]; then + echo "source_dir=src" >> "$GITHUB_OUTPUT" + elif [ -d "htdocs" ]; then + echo "source_dir=htdocs" >> "$GITHUB_OUTPUT" + else + echo "source_dir=" >> "$GITHUB_OUTPUT" + fi + + - name: Validate configuration + run: | + ERRORS=0 + + if [ -z "${{ steps.target.outputs.ssh_host }}" ]; then + echo "::error::SSH host not configured (DEPLOY_SSH_HOST or LIVE_SSH_HOST)" + ERRORS=$((ERRORS + 1)) + fi + if [ -z "${{ vars.DEPLOY_SSH_USER }}" ]; then + echo "::error::DEPLOY_SSH_USER variable not configured" + ERRORS=$((ERRORS + 1)) + fi + if [ -z "${{ steps.target.outputs.remote_path }}" ]; then + echo "::error::${{ steps.target.outputs.env == 'live' && 'LIVE' || 'DEV' }}_DEPLOY_PATH variable not configured" + ERRORS=$((ERRORS + 1)) + fi + if [ -z "${{ steps.target.outputs.source_dir }}" ]; then + echo "::error::No src/ or htdocs/ directory found -- nothing to deploy" + ERRORS=$((ERRORS + 1)) + fi + + if [ "$ERRORS" -gt 0 ]; then + echo "### Deploy Failed -- Missing Configuration" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Set the required variables in **Settings > Actions > Variables**." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + PORT="${{ vars.DEPLOY_SSH_PORT }}" + [ -z "$PORT" ] && PORT="22" + + ssh-keyscan -p "$PORT" "${{ steps.target.outputs.ssh_host }}" >> ~/.ssh/known_hosts 2>/dev/null + + cat > ~/.ssh/config <> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Target | \`${TARGET}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Host | \`${{ steps.target.outputs.ssh_host }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Remote | \`${{ steps.target.outputs.remote_path }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Source | \`${{ steps.target.outputs.source_dir }}/\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Trigger | \`${{ github.event_name }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Branch | \`${{ github.ref_name }}\` |" >> "$GITHUB_STEP_SUMMARY" + if [ "$DRY_RUN" = "true" ]; then + echo "| Mode | **Dry Run** (no files changed) |" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitea/workflows/repo-health.yml b/.gitea/workflows/repo-health.yml new file mode 100644 index 0000000..5fe7cb7 --- /dev/null +++ b/.gitea/workflows/repo-health.yml @@ -0,0 +1,766 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/joomla/repo_health.yml.template +# VERSION: 04.06.00 +# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. +# ============================================================================ + +name: Repo Health + +concurrency: + group: repo-health-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: 'Validation profile: all, release, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - release + - scripts + - repo + pull_request: + push: + +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 + + # Repo health policy + REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # File / directory variables + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .github/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission (admin only) + id: perm + env: + TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + ALLOWED=false + PERMISSION=unknown + METHOD="" + + # Hardcoded authorized users — always allowed + case "$ACTOR" in + jmiller|gitea-actions[bot]) + ALLOWED=true + PERMISSION=admin + METHOD="hardcoded allowlist" + ;; + *) + # Detect platform and check permissions via API + API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}" + RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}') + PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown") + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then + ALLOWED=true + fi + METHOD="collaborator API" + ;; + esac + + echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT" + echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT" + + { + echo "## Access Authorization" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Actor** | \`${ACTOR}\` |" + echo "| **Repository** | \`${REPO}\` |" + echo "| **Permission** | \`${PERMISSION}\` |" + echo "| **Method** | ${METHOD} |" + echo "| **Authorized** | ${ALLOWED} |" + echo "" + if [ "$ALLOWED" = "true" ]; then + echo "${ACTOR} authorized (${METHOD})" + else + echo "${ACTOR} is NOT authorized. Requires admin or maintain role." + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Deny execution when not permitted + if: ${{ steps.perm.outputs.allowed != 'true' }} + run: | + set -euo pipefail + 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 + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Scripts folder checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + 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}" = 'release' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}" + IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + 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: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + 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}" = 'release' ] || [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + # Source directory: src/ or htdocs/ (either is valid) + if [ -d "src" ]; then + SOURCE_DIR="src" + elif [ -d "htdocs" ]; then + SOURCE_DIR="htdocs" + else + missing_required+=("src/ or htdocs/ (source directory required)") + fi + + IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" + IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" + IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}" + IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}" + + missing_required=() + missing_optional=() + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = 'dev' ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + fi + done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//') + + if [ "${#dev_paths[@]}" -eq 0 ]; then + missing_required+=("dev/* branch (e.g. dev/01.00.00)") + fi + + if [ "${#dev_branches[@]}" -gt 0 ]; then + missing_required+=("invalid branch dev (must be dev/)") + fi + + content_warnings=() + + if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + fi + + if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export PROFILE_RAW="${profile}" + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")" + export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")" + + report_json="$(python3 - <<'PY' + import json + import os + + profile = os.environ.get('PROFILE_RAW') or 'all' + + missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else [] + missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else [] + content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else [] + + out = { + 'profile': profile, + 'missing_required': [x for x in missing_required if x], + 'missing_optional': [x for x in missing_optional if x], + 'content_warnings': [x for x in content_warnings if x], + } + + print(json.dumps(out, indent=2)) + PY + )" + + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # -- Joomla-specific checks -- + joomla_findings=() + + MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)" + if [ -z "${MANIFEST}" ]; then + joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") + else + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then + joomla_findings+=("XML manifest: type attribute missing or invalid") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP ' missing (required for Joomla 5+)") + fi + fi + + INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)" + if [ "${INI_COUNT}" -eq 0 ]; then + joomla_findings+=("No .ini language files found") + fi + + if [ ! -f 'updates.xml' ]; then + joomla_findings+=("updates.xml missing in root (required for Joomla update server)") + fi + + INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site") + for dir in "${INDEX_DIRS[@]}"; do + if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then + joomla_findings+=("${dir}/index.html missing (directory listing protection)") + fi + done + + if [ "${#joomla_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' '| Check | Status |' + printf '%s\n' '|---|---|' + for f in "${joomla_findings[@]}"; do + printf '%s\n' "| ${f} | Warning |" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' 'All Joomla-specific checks passed.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -f "${DOCS_INDEX}" ]; then + missing_links="$(python3 - <<'PY' + import os + import re + + idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md') + base = os.getcwd() + + bad = [] + pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)') + + with open(idx, 'r', encoding='utf-8') as f: + for line in f: + for m in pat.findall(line): + link = m.strip() + if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'): + continue + if link.startswith('/'): + rel = link.lstrip('/') + else: + rel = os.path.normpath(os.path.join(os.path.dirname(idx), link)) + rel = rel.split('#', 1)[0] + rel = rel.split('?', 1)[0] + if not rel: + continue + p = os.path.join(base, rel) + if not os.path.exists(p): + bad.append(rel) + + print('\n'.join(sorted(set(bad)))) + PY + )" + if [ -n "${missing_links}" ]; then + extended_findings+=("docs/docs-index.md contains broken relative links") + { + printf '%s\n' '### Docs index link integrity' + printf '%s\n' 'Broken relative links:' + while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + printf '%s\n' '| Domain | Status | Notes |' + printf '%s\n' '|---|---|---|' + printf '%s\n' '| Access control | OK | Admin-only execution gate |' + 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 |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" diff --git a/.gitea/workflows/update-server.yml b/.gitea/workflows/update-server.yml new file mode 100644 index 0000000..e6a1924 --- /dev/null +++ b/.gitea/workflows/update-server.yml @@ -0,0 +1,464 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Joomla +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/joomla/update-server.yml.template +# VERSION: 04.06.00 +# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries +# +# Writes updates.xml with multiple entries: +# - stable on push to main (from auto-release) +# - rc on push to rc/** +# - development on push to dev or dev/** +# +# Joomla filters by user's "Minimum Stability" setting. + +name: Update Joomla Update Server XML Feed + +on: + push: + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + pull_request: + types: [closed] + branches: + - 'dev' + - 'dev/**' + - 'alpha/**' + - 'beta/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + inputs: + stability: + description: 'Stability tag' + required: true + default: 'development' + type: choice + options: + - development + - alpha + - beta + - rc + - stable + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + update-xml: + name: Update updates.xml + runs-on: release + if: >- + github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.GA_TOKEN }} + fetch-depth: 0 + + - name: Setup MokoStandards tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}' + run: | + 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 + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api 2>/dev/null || true + if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then + cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true + fi + + - name: Generate updates.xml entry + id: update + run: | + BRANCH="${{ github.ref_name }}" + REPO="${{ github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0") + + # Auto-bump patch on all branches (dev, alpha, beta, rc) + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true) + if [ -n "$BUMPED" ]; then + VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION") + git add -A + git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \ + --author="gitea-actions[bot] " 2>/dev/null || true + git push 2>/dev/null || true + fi + + # Determine stability from branch or input + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + STABILITY="${{ inputs.stability }}" + elif [[ "$BRANCH" == rc/* ]]; then + STABILITY="rc" + elif [[ "$BRANCH" == beta/* ]]; then + STABILITY="beta" + elif [[ "$BRANCH" == alpha/* ]]; then + STABILITY="alpha" + elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then + STABILITY="development" + else + STABILITY="stable" + fi + + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + + # Parse manifest (portable — no grep -P) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "No Joomla manifest found — skipping" + exit 0 + fi + + # Extract fields using sed (works on all runners) + EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) + EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) + TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) + PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) + + # Fallbacks + [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" + [ -z "$EXT_TYPE" ] && EXT_TYPE="component" + + # Derive element if not in manifest: try XML filename, then repo name + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + case "$EXT_ELEMENT" in + templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; + esac + fi + + # Use manifest version if README version is empty + [ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION" + + [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/") + + CLIENT_TAG="" + [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT}" + [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site" + + FOLDER_TAG="" + [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}" + + PHP_TAG="" + [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM}" + + # Version suffix for non-stable + DISPLAY_VERSION="$VERSION" + case "$STABILITY" in + development) DISPLAY_VERSION="${VERSION}-dev" ;; + alpha) DISPLAY_VERSION="${VERSION}-alpha" ;; + beta) DISPLAY_VERSION="${VERSION}-beta" ;; + rc) DISPLAY_VERSION="${VERSION}-rc" ;; + esac + + MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') + + # Each stability level has its own release tag + case "$STABILITY" in + development) RELEASE_TAG="development" ;; + alpha) RELEASE_TAG="alpha" ;; + beta) RELEASE_TAG="beta" ;; + rc) RELEASE_TAG="release-candidate" ;; + *) RELEASE_TAG="v${MAJOR}" ;; + esac + + PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip" + DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" + INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}" + + # -- Build install packages (ZIP + tar.gz) -------------------- + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ -d "$SOURCE_DIR" ]; then + EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*" + TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz" + + cd "$SOURCE_DIR" + zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES + cd .. + tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \ + --exclude='.ftpignore' --exclude='sftp-config*' \ + --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' . + + SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1) + + # Ensure release exists on Gitea + RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true) + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -z "$RELEASE_ID" ]; then + # Create release + RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/releases" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'tag_name': '${RELEASE_TAG}', + 'name': '${RELEASE_TAG} (${DISPLAY_VERSION})', + 'body': '${STABILITY} release', + 'prerelease': True, + 'target_commitish': 'main' + }))")" 2>/dev/null || true) + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + fi + + if [ -n "$RELEASE_ID" ]; then + # Delete existing assets with same name before uploading + ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]") + for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do + ASSET_ID=$(echo "$ASSETS" | python3 -c " + import sys,json + assets = json.load(sys.stdin) + for a in assets: + if a['name'] == '${ASSET_FILE}': + print(a['id']); break + " 2>/dev/null || true) + if [ -n "$ASSET_ID" ]; then + curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true + fi + done + + # Upload both formats + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${PACKAGE_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true + + curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${TAR_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true + fi + + echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY + else + SHA256="" + fi + + # -- Build the new entry (canonical format matching release.yml) -- + NEW_ENTRY="" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME}\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} ${STABILITY} build.\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT}\n" + NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE}\n" + [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n" + [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n" + NEW_ENTRY="${NEW_ENTRY} ${VERSION}\n" + NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d)\n" + NEW_ENTRY="${NEW_ENTRY} https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL}\n" + NEW_ENTRY="${NEW_ENTRY} \n" + [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256}\n" + NEW_ENTRY="${NEW_ENTRY} ${STABILITY}\n" + NEW_ENTRY="${NEW_ENTRY} Moko Consulting\n" + NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech\n" + NEW_ENTRY="${NEW_ENTRY} \n" + [ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_MINIMUM}\n" + NEW_ENTRY="${NEW_ENTRY} " + + # -- Write new entry to temp file -------------------------------- + printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml + + # -- Merge into updates.xml ---------------------------------------- + # Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev + CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development" + TARGETS="" + for entry in $CASCADE_MAP; do + key="${entry%%:*}" + vals="${entry#*:}" + if [ "$key" = "${STABILITY}" ]; then + TARGETS="$vals" + break + fi + done + [ -z "$TARGETS" ] && TARGETS="${STABILITY}" + + echo "Cascade: ${STABILITY} → ${TARGETS}" + + # Create updates.xml if missing + if [ ! -f "updates.xml" ]; then + printf '%s\n' "" > updates.xml + printf '%s\n' "" >> updates.xml + printf '%s\n' "" >> updates.xml + printf '%s\n' "" >> updates.xml + fi + + # Update existing blocks or create missing ones + export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)" + python3 << 'PYEOF' + import re, os + + targets = os.environ["PY_TARGETS"].split(",") + version = os.environ["PY_VERSION"] + date = os.environ["PY_DATE"] + + with open("updates.xml") as f: + content = f.read() + with open("/tmp/new_entry.xml") as f: + new_entry_template = f.read() + + for tag in targets: + tag = tag.strip() + # Build entry with this tag's name + new_entry = re.sub(r"[^<]*", f"{tag}", new_entry_template) + + # Try to find existing block (handles both single-line and multi-line ) + block_pattern = r"((?:(?!).)*?" + re.escape(tag) + r".*?)" + match = re.search(block_pattern, content, re.DOTALL) + + if match: + # Update in place — replace entire block + content = content.replace(match.group(1), new_entry.strip()) + print(f" UPDATED: {tag} → {version}") + else: + # Create — insert before + content = content.replace("", "\n" + new_entry.strip() + "\n\n") + print(f" CREATED: {tag} → {version}") + + # Clean up excessive blank lines + content = re.sub(r"\n{3,}", "\n\n", content) + + with open("updates.xml", "w") as f: + f.write(content) + PYEOF + + # Commit + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git add updates.xml + git diff --cached --quiet || { + git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \ + --author="gitea-actions[bot] " + git push + } + + # -- Sync updates.xml to main (for non-main branches) ---------------------- + - name: Sync updates.xml to main + if: github.ref_name != 'main' + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + GA_TOKEN="${{ secrets.GA_TOKEN }}" + + FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \ + "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) + + if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then + CONTENT=$(base64 -w0 updates.xml) + curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/contents/updates.xml" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'content': '${CONTENT}', + 'sha': '${FILE_SHA}', + 'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]', + 'branch': 'main' + }))")" > /dev/null 2>&1 \ + && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \ + || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY + else + echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY + fi + + - name: SFTP deploy to dev server + if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev' + env: + DEV_HOST: ${{ vars.DEV_FTP_HOST }} + DEV_PATH: ${{ vars.DEV_FTP_PATH }} + DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }} + DEV_USER: ${{ vars.DEV_FTP_USERNAME }} + DEV_PORT: ${{ vars.DEV_FTP_PORT }} + DEV_KEY: ${{ secrets.DEV_FTP_KEY }} + DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }} + run: | + # -- Permission check: admin or maintain role required -------- + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read") + case "$PERMISSION" in + admin|maintain|write) ;; + *) + echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write" + exit 0 + ;; + esac + + [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; } + + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && exit 0 + + PORT="${DEV_PORT:-22}" + REMOTE="${DEV_PATH%/}" + [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}" + + printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ + "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json + if [ -n "$DEV_KEY" ]; then + echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key + printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json + else + printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json + fi + + PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) + if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then + php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then + php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json + fi + rm -f /tmp/deploy_key /tmp/sftp-config.json + echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY + + - name: Summary + if: always() + run: | + echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY