From 1c563ac57eb0f8d1b3d25e803400cb607d730ec5 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:14 +0000 Subject: [PATCH 01/45] chore: sync .mokogitea/workflows/auto-release.yml from template [skip ci] --- .mokogitea/workflows/auto-release.yml | 1007 +++++++++++++++++++++++++ 1 file changed, 1007 insertions(+) create mode 100644 .mokogitea/workflows/auto-release.yml diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml new file mode 100644 index 0000000..1fe7aa6 --- /dev/null +++ b/.mokogitea/workflows/auto-release.yml @@ -0,0 +1,1007 @@ +# 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/universal/auto-release.yml.template +# VERSION: 05.00.00 +# BRIEF: Universal build & release � detects platform from .moko-platform +# +# +========================================================================+ +# | UNIVERSAL BUILD & RELEASE PIPELINE | +# +========================================================================+ +# | | +# | Reads .moko-platform (joomla|dolibarr|generic) to branch logic. | +# | | +# | Platform-specific: | +# | joomla: XML manifest, updates.xml, type-prefixed packages | +# | dolibarr: mod*.class.php, update.txt, dev version reset | +# | generic: README-only, no update stream | +# | | +# +========================================================================+ + +name: "Universal: 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 + + + # -- PLATFORM DETECTION --------------------------------------------------- + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + echo "Platform detected: ${PLATFORM}" + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" + echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" + + # -- 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 + + # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------ + - name: "Step 1b: Bump minor version for stable release" + if: steps.version.outputs.skip != 'true' + id: bump + run: | + CURRENT=$(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) + [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + + MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1))) + MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2))) + + # Minor bump, reset patch. Rollover if minor > 99 + MINOR=$((MINOR + 1)) + if [ $MINOR -gt 99 ]; then + MINOR=0 + MAJOR=$((MAJOR + 1)) + fi + + VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR) + TODAY=$(date +%Y-%m-%d) + + echo "Stable bump: ${CURRENT} → ${VERSION} (minor)" + + # Update README.md + sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md + + # Update platform-specific manifest + PLATFORM="${{ steps.platform.outputs.platform }}" + MANIFEST="${{ steps.platform.outputs.manifest }}" + MOD_FILE="${{ steps.platform.outputs.mod_file }}" + case "$PLATFORM" in + joomla) + if [ -n "$MANIFEST" ]; then + MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) + [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" + sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" + fi + ;; + dolibarr) + if [ -n "$MOD_FILE" ]; then + sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE" + fi + echo "${VERSION}" > update.txt + ;; + *) ;; + esac + + # Promote [Unreleased] section in CHANGELOG.md to new version + if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then + sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md + sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md + sed -i "2i ## [Unreleased]" CHANGELOG.md + sed -i "3i \\ " CHANGELOG.md + echo "CHANGELOG promoted to [${VERSION}]" + fi + + # Commit and push + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]" + git push origin HEAD:main 2>&1 + } + + # Override version output for rest of pipeline + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT" + + - 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.bump.outputs.version || steps.version.outputs.version }}" + ERRORS=0 + + PLATFORM="${{ steps.platform.outputs.platform }}" + MANIFEST="${{ steps.platform.outputs.manifest }}" + MOD_FILE="${{ steps.platform.outputs.mod_file }}" + echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $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 + + # -- Platform-specific checks -------- + case "$PLATFORM" in + joomla) + 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 + TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null) + echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY + else + echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY + fi ;; + dolibarr) + if [ -n "$MOD_FILE" ]; then + MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1) + if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then + echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + else + echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + else + echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + fi + if [ ! -f "update.txt" ]; then + echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS+1)) + fi ;; + *) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;; + esac + + 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.bump.outputs.version || 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.bump.outputs.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.bump.outputs.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 update stream" + id: updates + if: >- + steps.version.outputs.skip != 'true' && + steps.check.outputs.already_released != 'true' + run: | + VERSION="${{ steps.bump.outputs.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) + + # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini + if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then + INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2) + [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2) + [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME" + fi + + # Fallbacks + [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" + [ -z "$EXT_TYPE" ] && EXT_TYPE="component" + + # Derive element if not in manifest: + # 1. plugin="xxx" attribute (plugins) + # 2. module="xxx" attribute (modules) + # 3. XML filename (components, packages) + # 4. Repo name fallback (templates, anything else) + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + fi + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + fi + if [ -z "$EXT_ELEMENT" ]; then + FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + # If filename is generic (templateDetails, manifest), use repo name + case "$FNAME" in + templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; + *) EXT_ELEMENT="$FNAME" ;; + esac + fi + # Final fallback + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + + # Save for Steps 7, 8, 8b + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT" + echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT" + + # 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 + + # Build TYPE_PREFIX for download URL + TYPE_PREFIX="" + case "${EXT_TYPE}" in + plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;; + module) TYPE_PREFIX="mod_" ;; + component) TYPE_PREFIX="com_" ;; + template) TYPE_PREFIX="tpl_" ;; + library) TYPE_PREFIX="lib_" ;; + package) TYPE_PREFIX="pkg_" ;; + esac + + DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${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.bump.outputs.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.bump.outputs.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}" + + # Reuse metadata from Step 5 (single source of truth) + EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}" + EXT_NAME="${{ steps.updates.outputs.ext_name }}" + EXT_TYPE="${{ steps.updates.outputs.ext_type }}" + EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}" + + # Fallbacks if Step 5 was skipped + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + fi + [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}" + + NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + + # Build release name: "Pretty Name VERSION (type_element-VERSION)" + TYPE_PREFIX="" + case "${EXT_TYPE}" in + plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;; + module) TYPE_PREFIX="mod_" ;; + component) TYPE_PREFIX="com_" ;; + template) TYPE_PREFIX="tpl_" ;; + library) TYPE_PREFIX="lib_" ;; + package) TYPE_PREFIX="pkg_" ;; + esac + RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})" + + # Delete existing release if present (overwrite, not append) + 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 [ -n "$EXISTING_ID" ]; then + curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true + curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true + echo "Deleted previous stable release (id: ${EXISTING_ID})" + fi + + # Create fresh release + 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_NAME}', + 'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''', + 'target_commitish': '${BRANCH}' + }))")" + echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY + + # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------ + - name: "Step 8: Build package and update checksum" + if: >- + steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.bump.outputs.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 + + # Reuse element from Step 5, with same fallback chain + EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}" + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + fi + # ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip) + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + TYPE_PREFIX="" + case "${EXT_TYPE}" in + plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;; + module) TYPE_PREFIX="mod_" ;; + component) TYPE_PREFIX="com_" ;; + template) TYPE_PREFIX="tpl_" ;; + library) TYPE_PREFIX="lib_" ;; + package) TYPE_PREFIX="pkg_" ;; + esac + ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip" + TAR_NAME="${TYPE_PREFIX}${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 "### 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 8b: Update release description with changelog + SHA ---------------- + - name: "Step 8b: Update release body with changelog and SHA" + if: steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}" + EXT_TYPE="${{ steps.updates.outputs.ext_type }}" + EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}" + + # Build TYPE_PREFIX to match Step 8's ZIP naming + TYPE_PREFIX="" + case "${EXT_TYPE}" in + plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;; + module) TYPE_PREFIX="mod_" ;; + component) TYPE_PREFIX="com_" ;; + template) TYPE_PREFIX="tpl_" ;; + library) TYPE_PREFIX="lib_" ;; + package) TYPE_PREFIX="pkg_" ;; + esac + ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip" + TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz" + + # Get SHA from the built files + SHA256_ZIP="" + [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1) + SHA256_TAR="" + [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1) + + # Extract latest changelog entry (strip the ## header to avoid duplicate) + CHANGELOG="" + if [ -f "CHANGELOG.md" ]; then + CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d') + [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30) + fi + + # Build release body (single header, no duplicate from changelog) + BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n" + if [ -n "$CHANGELOG" ]; then + BODY="${BODY}${CHANGELOG}\n\n" + fi + BODY="${BODY}---\n\n### Checksums\n\n" + BODY="${BODY}| File | SHA-256 |\n|------|--------|\n" + [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n" + [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n" + + # Get release ID and update body + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \ + "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then + python3 -c " + import json, urllib.request + body = '''$(printf '%b' "$BODY")''' + data = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=data, + headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'}, + method='PATCH' + ) + urllib.request.urlopen(req) + " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY + fi + + # -- 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.bump.outputs.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" + + # -- Clean up lesser pre-releases (cascade) --------------------------------- + # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev + - name: "Delete lesser pre-release channels" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.GA_TOKEN }}" + + # Stable deletes all pre-release channels + TAGS_TO_DELETE="development alpha beta release-candidate" + + DELETED=0 + for TAG in $TAGS_TO_DELETE; do + RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/tags/${TAG}" 2>/dev/null || true + echo "Deleted: ${TAG} (id: ${RELEASE_ID})" + DELETED=$((DELETED + 1)) + fi + done + echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY + + # -- STEP 11: Reset dev branch from main ------------------------------------ + - name: "Step 11: Delete and recreate dev branch from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.GA_TOKEN }}" + + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" + + # Recreate dev from main (now includes version bump + changelog promotion) + curl -sf -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" + + echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY + + + # -- Dolibarr post-release: Reset dev version ----------------------------- + - name: "Dolibarr: Reset dev version" + if: >- + steps.version.outputs.skip != 'true' && + steps.platform.outputs.platform == 'dolibarr' && + steps.platform.outputs.mod_file != '' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.GA_TOKEN }}" + MOD_FILE="${{ steps.platform.outputs.mod_file }}" + ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))") + FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true) + FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) + FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true) + if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then + UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/") + ENCODED=$(echo "$UPDATED" | base64 -w0) + curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \ + -d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true + fi + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + if: always() + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + PLATFORM="${{ steps.platform.outputs.platform }}" + 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 (${PLATFORM})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Platform | \`${PLATFORM}\` |" >> $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 -- 2.52.0 From 6b7edbe4bb6a2e03f496e4afe0c8245c6535067a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:15 +0000 Subject: [PATCH 02/45] chore: sync .mokogitea/workflows/cascade-dev.yml from template [skip ci] --- .mokogitea/workflows/cascade-dev.yml | 213 +++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 .mokogitea/workflows/cascade-dev.yml diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml new file mode 100644 index 0000000..4dbb135 --- /dev/null +++ b/.mokogitea/workflows/cascade-dev.yml @@ -0,0 +1,213 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Maintenance +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/cascade-dev.yml.template +# VERSION: 02.00.00 +# BRIEF: Forward-merge main → all open branches after every push to main +# +# +========================================================================+ +# | CASCADE MAIN → ALL BRANCHES | +# +========================================================================+ +# | | +# | Triggers on every push to main (PR merges, bot commits, etc.) | +# | | +# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* | +# | 2. For each: create PR (main → branch), auto-merge if clean | +# | 3. On conflict: leave PR open for manual resolution | +# | | +# +========================================================================+ + +name: "Universal: Cascade Main → Dev" + +on: + push: + branches: + - main + 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 + pull-requests: write + +jobs: + cascade: + name: Cascade main → branches + runs-on: ubuntu-latest + if: >- + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip cascade]') + + steps: + - name: Discover target branches + id: branches + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Fetch all branches (paginated) + PAGE=1 + ALL_BRANCHES="" + while true; do + BATCH=$(curl -sS \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/branches?page=${PAGE}&limit=50" \ + | jq -r '.[].name // empty') + [ -z "$BATCH" ] && break + ALL_BRANCHES="$ALL_BRANCHES $BATCH" + PAGE=$((PAGE + 1)) + done + + # Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/* + TARGETS="" + for BRANCH in $ALL_BRANCHES; do + case "$BRANCH" in + dev|dev/*|rc/*|beta/*|alpha/*) + TARGETS="$TARGETS $BRANCH" + ;; + esac + done + + TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace + + if [ -z "$TARGETS" ]; then + echo "targets=" >> "$GITHUB_OUTPUT" + echo "ℹ️ No cascade target branches found" + else + echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" + COUNT=$(echo "$TARGETS" | wc -w) + echo "📋 Found ${COUNT} target branch(es): ${TARGETS}" + fi + + - name: Cascade to all target branches + if: steps.branches.outputs.targets != '' + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + SHORT_SHA="${GITHUB_SHA:0:7}" + TARGETS="${{ steps.branches.outputs.targets }}" + + SUCCESS=0 + CONFLICTS=0 + SKIPPED=0 + FAILED=0 + + for BRANCH in $TARGETS; do + echo "" + echo "═══ main → ${BRANCH} ═══" + + # Check if branch is already up to date + ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g') + RESPONSE=$(curl -sS \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/compare/${ENCODED_BRANCH}...main") + + AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0') + + if [ "$AHEAD" -eq 0 ]; then + echo " ✅ Already up to date" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + echo " ℹ️ main is ${AHEAD} commit(s) ahead" + + # Check for existing cascade PR + EXISTING=$(curl -sS \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1") + + EXISTING_COUNT=$(echo "$EXISTING" | jq 'length') + PR_NUMBER="" + + if [ "$EXISTING_COUNT" -gt 0 ]; then + PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number') + echo " ℹ️ Reusing existing PR #${PR_NUMBER}" + else + # Create cascade PR + PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -X POST \ + -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\", + \"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\", + \"head\": \"main\", + \"base\": \"${BRANCH}\" + }" \ + "${API}/pulls") + + HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1) + BODY=$(echo "$PR_RESPONSE" | sed '$d') + PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty') + + if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then + MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1) + echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}" + FAILED=$((FAILED + 1)) + continue + fi + + echo " ✅ Created PR #${PR_NUMBER}" + fi + + # Try auto-merge + PR_DATA=$(curl -sS \ + -H "Authorization: token ${GA_TOKEN}" \ + "${API}/pulls/${PR_NUMBER}") + + MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false') + + if [ "$MERGEABLE" != "true" ]; then + echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open" + CONFLICTS=$((CONFLICTS + 1)) + continue + fi + + MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \ + -X POST \ + -H "Authorization: token ${GA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"Do\": \"merge\", + \"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\", + \"delete_branch_after_merge\": false + }" \ + "${API}/pulls/${PR_NUMBER}/merge") + + MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1) + + if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then + echo " ✅ Merged — ${BRANCH} is in sync" + SUCCESS=$((SUCCESS + 1)) + else + MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d') + echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open" + CONFLICTS=$((CONFLICTS + 1)) + fi + done + + # Summary + echo "" + echo "════════════════════════════════════════" + echo " ✅ Merged: ${SUCCESS}" + echo " ⚠️ Conflicts: ${CONFLICTS}" + echo " ⏭️ Up to date: ${SKIPPED}" + echo " ❌ Failed: ${FAILED}" + echo "════════════════════════════════════════" + + if [ "$FAILED" -gt 0 ]; then + exit 1 + fi -- 2.52.0 From 9f8bb4a46762b4b900608a53b33111aeb2dfe010 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:15 +0000 Subject: [PATCH 03/45] chore: sync .mokogitea/workflows/cleanup.yml from template [skip ci] --- .mokogitea/workflows/cleanup.yml | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .mokogitea/workflows/cleanup.yml diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml new file mode 100644 index 0000000..3a81856 --- /dev/null +++ b/.mokogitea/workflows/cleanup.yml @@ -0,0 +1,87 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Maintenance +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# PATH: /.gitea/workflows/cleanup.yml +# VERSION: 01.00.00 +# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs + +name: "Universal: Repository Cleanup" + +on: + schedule: + - cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC + workflow_dispatch: + +permissions: + contents: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +jobs: + cleanup: + name: Clean Merged Branches + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GA_TOKEN }} + + - name: Delete merged branches + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + echo "=== Merged Branch Cleanup ===" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + + # List branches via API + BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ + "${API}/branches?limit=50" | jq -r '.[].name') + + DELETED=0 + for BRANCH in $BRANCHES; do + # Skip protected branches + case "$BRANCH" in + main|master|develop|release/*|hotfix/*) continue ;; + esac + + # Check if branch is merged into main + if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then + echo " Deleting merged branch: ${BRANCH}" + curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ + "${API}/branches/${BRANCH}" 2>/dev/null || true + DELETED=$((DELETED + 1)) + fi + done + + echo "Deleted ${DELETED} merged branch(es)" + + - name: Clean old workflow runs + env: + GA_TOKEN: ${{ secrets.GA_TOKEN }} + run: | + echo "=== Workflow Run Cleanup ===" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ) + + # Get old completed runs + RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ + "${API}/actions/runs?status=completed&limit=50" | \ + jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) + + DELETED=0 + for RUN_ID in $RUNS; do + curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ + "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true + DELETED=$((DELETED + 1)) + done + + echo "Deleted ${DELETED} old workflow run(s)" -- 2.52.0 From e6e01fa5a8c999a9529cbf1d7411cdbdba8b264a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:15 +0000 Subject: [PATCH 04/45] chore: sync .mokogitea/workflows/deploy-manual.yml from template [skip ci] --- .mokogitea/workflows/deploy-manual.yml | 126 +++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .mokogitea/workflows/deploy-manual.yml diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml new file mode 100644 index 0000000..bb133ed --- /dev/null +++ b/.mokogitea/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: "Universal: 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 -- 2.52.0 From 574460a102f11b21d726ff098148557e9dc4dce0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:16 +0000 Subject: [PATCH 05/45] chore: sync .mokogitea/workflows/gitleaks.yml from template [skip ci] --- .mokogitea/workflows/gitleaks.yml | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .mokogitea/workflows/gitleaks.yml diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml new file mode 100644 index 0000000..0c07612 --- /dev/null +++ b/.mokogitea/workflows/gitleaks.yml @@ -0,0 +1,96 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Security +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/gitleaks.yml.template +# VERSION: 01.00.00 +# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens +# +# +========================================================================+ +# | SECRET SCANNING | +# +========================================================================+ +# | | +# | Scans commits for leaked secrets using Gitleaks. | +# | | +# | - PR scan: only new commits in the PR | +# | - Scheduled: full repo scan weekly | +# | - Alerts via ntfy on findings | +# | | +# +========================================================================+ + +name: "Universal: Secret Scanning" + +on: + pull_request: + branches: + - main + - 'dev/**' + schedule: + - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC + workflow_dispatch: + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }} + +jobs: + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + GITLEAKS_VERSION="8.21.2" + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - name: Scan for secrets + id: scan + run: | + echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY + ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json" + + if [ "${{ github.event_name }}" = "pull_request" ]; then + # Scan only PR commits + ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY + else + echo "Full repository scan" >> $GITHUB_STEP_SUMMARY + fi + + if gitleaks detect $ARGS 2>&1; then + echo "result=clean" >> "$GITHUB_OUTPUT" + echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY + else + echo "result=found" >> "$GITHUB_OUTPUT" + FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown") + echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: Notify on findings + if: failure() && steps.scan.outputs.result == 'found' + run: | + REPO="${{ github.event.repository.name }}" + curl -sS \ + -H "Title: ${REPO} — secrets detected in code" \ + -H "Tags: rotating_light,key" \ + -H "Priority: urgent" \ + -d "Gitleaks found potential secrets. Review and rotate credentials immediately." \ + "${NTFY_URL}/${NTFY_TOPIC}" || true -- 2.52.0 From 4de1f24ab150ee2f50aa606d4c0318cd96b3769c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:16 +0000 Subject: [PATCH 06/45] chore: sync .mokogitea/workflows/notify.yml from template [skip ci] --- .mokogitea/workflows/notify.yml | 70 +++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .mokogitea/workflows/notify.yml diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml new file mode 100644 index 0000000..51dfcb5 --- /dev/null +++ b/.mokogitea/workflows/notify.yml @@ -0,0 +1,70 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Notifications +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# PATH: /.gitea/workflows/notify.yml +# VERSION: 01.00.00 +# BRIEF: Push notifications via ntfy on release success or workflow failure + +name: "Universal: Notifications" + +on: + workflow_run: + workflows: + - "Joomla Build & Release" + - "Joomla Extension CI" + - "Deploy" + types: + - completed + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }} + +jobs: + notify: + name: Send Notification + runs-on: ubuntu-latest + if: >- + github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure' + + steps: + - name: Notify on success (releases only) + if: >- + github.event.workflow_run.conclusion == 'success' && + contains(github.event.workflow_run.name, 'Release') + run: | + REPO="${{ github.event.repository.name }}" + WORKFLOW="${{ github.event.workflow_run.name }}" + URL="${{ github.event.workflow_run.html_url }}" + + curl -sS \ + -H "Title: ${REPO} released" \ + -H "Tags: white_check_mark,package" \ + -H "Priority: default" \ + -H "Click: ${URL}" \ + -d "${WORKFLOW} completed successfully." \ + "${NTFY_URL}/${NTFY_TOPIC}" + + - name: Notify on failure + if: github.event.workflow_run.conclusion == 'failure' + run: | + REPO="${{ github.event.repository.name }}" + WORKFLOW="${{ github.event.workflow_run.name }}" + URL="${{ github.event.workflow_run.html_url }}" + + curl -sS \ + -H "Title: ${REPO} workflow failed" \ + -H "Tags: x,warning" \ + -H "Priority: high" \ + -H "Click: ${URL}" \ + -d "${WORKFLOW} failed. Check the run for details." \ + "${NTFY_URL}/${NTFY_TOPIC}" -- 2.52.0 From 08bb7f5ef50906ceb7f88c71cb401568729fb733 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:17 +0000 Subject: [PATCH 07/45] chore: sync .mokogitea/workflows/pr-check.yml from template [skip ci] --- .mokogitea/workflows/pr-check.yml | 196 ++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .mokogitea/workflows/pr-check.yml diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml new file mode 100644 index 0000000..bd06c90 --- /dev/null +++ b/.mokogitea/workflows/pr-check.yml @@ -0,0 +1,196 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/universal/pr-check.yml.template +# VERSION: 05.00.00 +# BRIEF: PR gate — branch policy + code validation before merge + +name: "Universal: PR Check" + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # ── Branch Policy ────────────────────────────────────────────────────── + branch-policy: + name: Branch Policy + runs-on: ubuntu-latest + steps: + - name: Check branch merge target + run: | + HEAD="${{ github.head_ref }}" + BASE="${{ github.base_ref }}" + + echo "PR: ${HEAD} → ${BASE}" + + ALLOWED=true + REASON="" + + case "$HEAD" in + feature/*|feat/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Feature branches must target 'dev', not '${BASE}'" + fi + ;; + fix/*|bugfix/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Fix branches must target 'dev', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + alpha/*|beta/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Pre-release branches must target 'dev', not '${BASE}'" + fi + ;; + rc/*) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Release candidate branches must target 'main', not '${BASE}'" + fi + ;; + dev) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Dev branch can only merge into 'main', not '${BASE}'" + fi + ;; + esac + + if [ "$ALLOWED" = false ]; then + echo "::error::${REASON}" + echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "${REASON}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY + echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Branch policy: OK (${HEAD} → ${BASE})" + echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + 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 >/dev/null 2>&1 + fi + + - name: PHP syntax check + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0) + echo "PHP lint: ${ERRORS} error(s)" + [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "::warning::No Joomla manifest found (WaaS site)" + exit 0 + fi + echo "Manifest: ${MANIFEST}" + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; } + fi + for ELEMENT in name version description; do + grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } + done + echo "Joomla manifest valid" + ;; + dolibarr) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + if [ -z "$MOD_FILE" ]; then + echo "::error::No mod*.class.php found" + exit 1 + fi + echo "Dolibarr module: ${MOD_FILE}" + ;; + *) + echo "Generic platform — no manifest validation" + ;; + esac + + - name: Check update stream format + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + if [ -f "updates.xml" ]; then + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; } + fi + echo "updates.xml valid" + fi + ;; + dolibarr) + [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" + ;; + esac + + - name: Verify package source + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::warning::No src/ or htdocs/ directory" + exit 0 + fi + FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) + echo "Source: ${FILE_COUNT} files" + [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } -- 2.52.0 From 8c5e0702d087cb7dbd393fe662298baa7a1b7030 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:17 +0000 Subject: [PATCH 08/45] chore: sync .mokogitea/workflows/pre-release.yml from template [skip ci] --- .mokogitea/workflows/pre-release.yml | 384 +++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 .mokogitea/workflows/pre-release.yml diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 0000000..6e05d96 --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,384 @@ +# 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/MokoStandards +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 05.00.00 +# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch + +name: "Universal: Pre-Release" + +on: + workflow_dispatch: + inputs: + stability: + description: 'Pre-release channel' + required: true + type: choice + options: + - development + - alpha + - beta + - release-candidate + +permissions: + contents: write + +env: + 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 }} + +jobs: + build: + name: "Build Pre-Release (${{ inputs.stability }})" + runs-on: release + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GA_TOKEN }} + + - 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 >/dev/null 2>&1 + fi + + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" + echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT" + + - name: Resolve metadata + id: meta + run: | + STABILITY="${{ inputs.stability }}" + + case "$STABILITY" in + development) SUFFIX="-dev"; TAG="development" ;; + alpha) SUFFIX="-alpha"; TAG="alpha" ;; + beta) SUFFIX="-beta"; TAG="beta" ;; + release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; + esac + + # Read and bump patch version (with rollover) + CURRENT=$(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) + [ -z "$CURRENT" ] && CURRENT="00.00.00" + + MAJOR=$(echo "$CURRENT" | cut -d. -f1) + MINOR=$(echo "$CURRENT" | cut -d. -f2) + PATCH=$(echo "$CURRENT" | cut -d. -f3) + + # Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major + NEW_PATCH=$((10#$PATCH + 1)) + NEW_MINOR=$((10#$MINOR)) + NEW_MAJOR=$((10#$MAJOR)) + + if [ $NEW_PATCH -gt 99 ]; then + NEW_PATCH=0 + NEW_MINOR=$((NEW_MINOR + 1)) + fi + if [ $NEW_MINOR -gt 99 ]; then + NEW_MINOR=0 + NEW_MAJOR=$((NEW_MAJOR + 1)) + fi + + VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH) + TODAY=$(date +%Y-%m-%d) + + echo "Bumping: ${CURRENT} → ${VERSION} (patch)" + + # Update README.md + sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md + + # Update platform-specific manifest + PLATFORM="${{ steps.platform.outputs.platform }}" + MANIFEST="${{ steps.platform.outputs.manifest }}" + MOD_FILE="${{ steps.platform.outputs.mod_file }}" + case "$PLATFORM" in + joomla) + if [ -n "$MANIFEST" ]; then + MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) + sed -i "s|${MANIFEST_VER}|${VERSION}|" "$MANIFEST" + sed -i "s|[^<]*|${TODAY}|" "$MANIFEST" + fi + ;; + dolibarr) + if [ -n "$MOD_FILE" ]; then + sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE" + fi + ;; + *) ;; + esac + + # Commit version bump + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]" + git push origin HEAD 2>&1 + } + + # Auto-detect element (platform-aware) + case "$PLATFORM" in + joomla) + MANIFEST="${{ steps.platform.outputs.manifest }}" + EXT_ELEMENT="" + if [ -n "$MANIFEST" ]; then + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1) + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + case "$EXT_ELEMENT" in + templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; + esac + fi + else + EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + fi + ;; + dolibarr) + MOD_FILE="${{ steps.platform.outputs.mod_file }}" + if [ -n "$MOD_FILE" ]; then + MOD_BASENAME=$(basename "$MOD_FILE" .class.php) + EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]') + else + EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + fi + ;; + *) + EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + ;; + esac + + ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT" + + echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" + + - name: Build package + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::error::No src/ or htdocs/ directory" + exit 1 + fi + + mkdir -p build/package + rsync -a \ + --exclude='sftp-config*' \ + --exclude='.ftpignore' \ + --exclude='*.ppk' \ + --exclude='*.pem' \ + --exclude='*.key' \ + --exclude='.env*' \ + --exclude='*.local' \ + --exclude='.build-trigger' \ + "${SOURCE_DIR}/" build/package/ + + - name: Create ZIP + id: zip + run: | + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + cd build/package + zip -r "../${ZIP_NAME}" . + cd .. + + SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1) + echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" + echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)" + + - name: Create or replace Gitea release + id: release + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + SHA256="${{ steps.zip.outputs.sha256 }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}" + TOKEN="${{ secrets.GA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + BRANCH=$(git branch --show-current) + + BODY="## ${VERSION} ($(date +%Y-%m-%d)) + **Channel:** ${STABILITY} + **SHA-256:** \`${SHA256}\`" + + # Delete existing release + EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null) + if [ -n "$EXISTING_ID" ]; then + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API}/releases/${EXISTING_ID}" 2>/dev/null || true + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API}/tags/${TAG}" 2>/dev/null || true + fi + + # Create release + RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/releases" \ + -d "$(jq -n \ + --arg tag "$TAG" \ + --arg target "$BRANCH" \ + --arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \ + --arg body "$BODY" \ + '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}' + )" | jq -r '.id') + + echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT" + + # Upload ZIP + curl -sS -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + "${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \ + --data-binary "@build/${ZIP_NAME}" + + echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})" + + - name: Update updates.xml + if: steps.platform.outputs.platform == 'joomla' + run: | + STABILITY="${{ steps.meta.outputs.stability }}" + VERSION="${{ steps.meta.outputs.version }}" + SHA256="${{ steps.zip.outputs.sha256 }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + TAG="${{ steps.meta.outputs.tag }}" + DATE=$(date +%Y-%m-%d) + + if [ ! -f "updates.xml" ]; then + echo "No updates.xml — skipping" + exit 0 + fi + + export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \ + PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \ + PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO" + python3 << 'PYEOF' + import re, os + + stability = os.environ["PY_STABILITY"] + version = os.environ["PY_VERSION"] + sha256 = os.environ["PY_SHA256"] + zip_name = os.environ["PY_ZIP_NAME"] + tag = os.environ["PY_TAG"] + date = os.environ["PY_DATE"] + gitea_org = os.environ["PY_GITEA_ORG"] + gitea_repo = os.environ["PY_GITEA_REPO"] + download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}" + + with open("updates.xml", "r") as f: + content = f.read() + + # Map stability to XML tag name + tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"} + xml_tag = tag_map.get(stability, stability) + + pattern = r"((?:(?!).)*?" + re.escape(xml_tag) + r".*?)" + match = re.search(pattern, content, re.DOTALL) + if match: + block = match.group(1) + updated = re.sub(r"[^<]*", f"{version}", block) + updated = re.sub(r"[^<]*", f"{date}", updated) + if "" in updated: + updated = re.sub(r"[^<]*", f"{sha256}", updated) + else: + updated = updated.replace("", f"\n {sha256}") + updated = re.sub(r"(]*>)[^<]*()", rf"\g<1>{download_url}\g<2>", updated) + content = content.replace(block, updated) + print(f"Updated {xml_tag} channel: version={version}") + else: + print(f"WARNING: No {xml_tag} block in updates.xml") + + with open("updates.xml", "w") as f: + f.write(content) + PYEOF + + # Commit and push to current branch + if ! git diff --quiet updates.xml 2>/dev/null; then + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git add updates.xml + git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]" + git push origin HEAD 2>&1 || echo "WARNING: push failed" + fi + + - name: "Sync updates.xml to all branches" + if: steps.platform.outputs.platform == 'joomla' + run: | + CURRENT_BRANCH="${{ github.ref_name }}" + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + + # Sync updates.xml to main and dev (whichever isn't current) + for BRANCH in main dev; do + [ "$BRANCH" = "$CURRENT_BRANCH" ] && continue + + echo "Syncing updates.xml → ${BRANCH}" + git fetch origin "${BRANCH}" 2>/dev/null || continue + git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue + git checkout "${CURRENT_BRANCH}" -- updates.xml + if ! git diff --quiet updates.xml 2>/dev/null; then + git add updates.xml + git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]" + git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed" + fi + git checkout "${CURRENT_BRANCH}" 2>/dev/null + done + + - name: "Delete lesser pre-release channels (cascade)" + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.GA_TOKEN }}" + STABILITY="${{ steps.meta.outputs.stability }}" + + # Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing + case "$STABILITY" in + release-candidate) TAGS_TO_DELETE="beta alpha development" ;; + beta) TAGS_TO_DELETE="alpha development" ;; + alpha) TAGS_TO_DELETE="development" ;; + *) TAGS_TO_DELETE="" ;; + esac + + [ -z "$TAGS_TO_DELETE" ] && exit 0 + + for TAG in $TAGS_TO_DELETE; do + RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \ + python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/tags/${TAG}" 2>/dev/null || true + echo "Deleted: ${TAG} (id: ${RELEASE_ID})" + fi + done -- 2.52.0 From 22864806af4289fe34ccd81a2348e2f597514776 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:17 +0000 Subject: [PATCH 09/45] chore: sync .mokogitea/workflows/repo-health.yml from template [skip ci] --- .mokogitea/workflows/repo-health.yml | 766 +++++++++++++++++++++++++++ 1 file changed, 766 insertions(+) create mode 100644 .mokogitea/workflows/repo-health.yml diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml new file mode 100644 index 0000000..869267e --- /dev/null +++ b/.mokogitea/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: "Generic: 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: .gitea/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}" -- 2.52.0 From 1c85296c1ef2dbc771e83f30828a46e00f6c7e8c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:18 +0000 Subject: [PATCH 10/45] chore: sync .mokogitea/workflows/security-audit.yml from template [skip ci] --- .mokogitea/workflows/security-audit.yml | 82 +++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .mokogitea/workflows/security-audit.yml diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml new file mode 100644 index 0000000..789325a --- /dev/null +++ b/.mokogitea/workflows/security-audit.yml @@ -0,0 +1,82 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoStandards.Security +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# PATH: /.gitea/workflows/security-audit.yml +# VERSION: 01.00.00 +# BRIEF: Dependency vulnerability scanning for composer and npm packages + +name: "Universal: Security Audit" + +on: + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC + pull_request: + branches: + - main + paths: + - 'composer.json' + - 'composer.lock' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }} + +jobs: + audit: + name: Dependency Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Composer audit + if: hashFiles('composer.lock') != '' + run: | + echo "=== Composer Security Audit ===" + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1 + fi + composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt + RESULT=$? + if [ $RESULT -ne 0 ]; then + echo "::warning::Composer vulnerabilities found" + echo "composer_vulnerable=true" >> "$GITHUB_ENV" + else + echo "No known vulnerabilities in composer dependencies" + fi + + - name: NPM audit + if: hashFiles('package-lock.json') != '' + run: | + echo "=== NPM Security Audit ===" + npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true + if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then + echo "No known vulnerabilities in npm dependencies" + else + echo "::warning::NPM vulnerabilities found" + echo "npm_vulnerable=true" >> "$GITHUB_ENV" + fi + + - name: Notify on vulnerabilities + if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true' + run: | + REPO="${{ github.event.repository.name }}" + curl -sS \ + -H "Title: ${REPO} has vulnerable dependencies" \ + -H "Tags: lock,warning" \ + -H "Priority: high" \ + -d "Security audit found vulnerabilities. Review dependency updates." \ + "${NTFY_URL}/${NTFY_TOPIC}" || true -- 2.52.0 From 40d2786ccdb1a85bc0671ba5c7bf1db52a91a961 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:18 +0000 Subject: [PATCH 11/45] chore: sync .mokogitea/ISSUE_TEMPLATE/adr.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/adr.md | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/adr.md diff --git a/.mokogitea/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md new file mode 100644 index 0000000..eb40760 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/adr.md @@ -0,0 +1,110 @@ +--- +name: Architecture Decision Record (ADR) +about: Propose or document an architectural decision +title: '[ADR] ' +labels: 'architecture, decision' +assignees: '' + +--- + + +## ADR Number +ADR-XXXX + +## Status +- [ ] Proposed +- [ ] Accepted +- [ ] Deprecated +- [ ] Superseded by ADR-XXXX + +## Context +Describe the issue or problem that motivates this decision. + +## Decision +State the architecture decision and provide rationale. + +## Consequences +### Positive +- List positive consequences + +### Negative +- List negative consequences or trade-offs + +### Neutral +- List neutral aspects + +## Alternatives Considered +### Alternative 1 +- Description +- Pros +- Cons +- Why not chosen + +### Alternative 2 +- Description +- Pros +- Cons +- Why not chosen + +## Implementation Plan +1. Step 1 +2. Step 2 +3. Step 3 + +## Stakeholders +- **Decision Makers**: @user1, @user2 +- **Consulted**: @user3, @user4 +- **Informed**: team-name + +## Technical Details +### Architecture Diagram +``` +[Add diagram or link] +``` + +### Dependencies +- Dependency 1 +- Dependency 2 + +### Impact Analysis +- **Performance**: [Impact description] +- **Security**: [Impact description] +- **Scalability**: [Impact description] +- **Maintainability**: [Impact description] + +## Testing Strategy +- [ ] Unit tests +- [ ] Integration tests +- [ ] Performance tests +- [ ] Security tests + +## Documentation +- [ ] Architecture documentation updated +- [ ] API documentation updated +- [ ] Developer guide updated +- [ ] Runbook created + +## Migration Path +Describe how to migrate from current state to new architecture. + +## Rollback Plan +Describe how to rollback if issues occur. + +## Timeline +- **Proposal Date**: +- **Decision Date**: +- **Implementation Start**: +- **Expected Completion**: + +## References +- Related ADRs: +- External resources: +- RFCs: + +## Review Checklist +- [ ] Aligns with enterprise architecture principles +- [ ] Security implications reviewed +- [ ] Performance implications reviewed +- [ ] Cost implications reviewed +- [ ] Compliance requirements met +- [ ] Team consensus achieved -- 2.52.0 From 2682af6a547e7c3f4f33f69f00fe8c26b9644cd3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:18 +0000 Subject: [PATCH 12/45] chore: sync .mokogitea/ISSUE_TEMPLATE/bug_report.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/bug_report.md | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/bug_report.md diff --git a/.mokogitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..38a16a7 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug Report +about: Report a bug or issue with the project +title: '[BUG] ' +labels: 'bug' +assignees: '' + +--- + + +## Bug Description +A clear and concise description of what the bug is. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +A clear and concise description of what actually happened. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Environment +- **Project**: [e.g., MokoDoliTools, moko-cassiopeia] +- **Version**: [e.g., 1.2.3] +- **Platform**: [e.g., Dolibarr 18.0, Joomla 5.0] +- **PHP Version**: [e.g., 8.1] +- **Database**: [e.g., MySQL 8.0, PostgreSQL 14] +- **Browser** (if applicable): [e.g., Chrome 120, Firefox 121] +- **OS**: [e.g., Ubuntu 22.04, Windows 11] + +## Additional Context +Add any other context about the problem here. + +## Possible Solution +If you have suggestions on how to fix the issue, please describe them here. + +## Checklist +- [ ] I have searched for similar issues before creating this one +- [ ] I have provided all the requested information +- [ ] I have tested this on the latest stable version +- [ ] I have checked the documentation and couldn't find a solution -- 2.52.0 From dca040ae6e4944be96bc365656c66863016e548b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:19 +0000 Subject: [PATCH 13/45] chore: sync .mokogitea/ISSUE_TEMPLATE/config.yml from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/config.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/config.yml diff --git a/.mokogitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d4d49ec --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,18 @@ +--- +blank_issues_enabled: true +contact_links: + - name: 💼 Enterprise Support + url: https://mokoconsulting.tech/enterprise + about: Enterprise-level support and consultation services + - name: 💬 Ask a Question + url: https://mokoconsulting.tech/ + about: Get help or ask questions through our website + - name: 📚 MokoStandards Documentation + url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + about: View our coding standards and best practices + - name: 🔒 Report a Security Vulnerability + url: https://git.mokoconsulting.tech/mokoconsulting-tech/.github-private/security/advisories/new + about: Report security vulnerabilities privately (for critical issues) + - name: 💡 Community Discussions + url: https://github.com/orgs/mokoconsulting-tech/discussions + about: Join community discussions and Q&A -- 2.52.0 From 17c51d257ea03f3454efb914bb8854c5abd844f1 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:19 +0000 Subject: [PATCH 14/45] chore: sync .mokogitea/ISSUE_TEMPLATE/documentation.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/documentation.md | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/documentation.md diff --git a/.mokogitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..ed4dabc --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,52 @@ +--- +name: Documentation Issue +about: Report an issue with documentation +title: '[DOCS] ' +labels: 'documentation' +assignees: '' + +--- + + +## Documentation Issue + +**Location**: + + +## Issue Type + +- [ ] Typo or grammar error +- [ ] Outdated information +- [ ] Missing documentation +- [ ] Unclear explanation +- [ ] Broken links +- [ ] Missing examples +- [ ] Other (specify below) + +## Description + + +## Current Content + +``` +Current text here +``` + +## Suggested Improvement + +``` +Suggested text here +``` + +## Additional Context + + +## Standards Alignment +- [ ] Follows MokoStandards documentation guidelines +- [ ] Uses en_US/en_GB localization +- [ ] Includes proper SPDX headers where applicable + +## Checklist +- [ ] I have searched for similar documentation issues +- [ ] I have provided a clear description +- [ ] I have suggested an improvement (if applicable) -- 2.52.0 From ec6cd62e3d6f0bee03df18f1af34ac5a95339a29 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:19 +0000 Subject: [PATCH 15/45] chore: sync .mokogitea/ISSUE_TEMPLATE/feature_request.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/feature_request.md | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/feature_request.md diff --git a/.mokogitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7b76dc9 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,51 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' + +--- + + +## Feature Description +A clear and concise description of the feature you'd like to see. + +## Problem or Use Case +Describe the problem this feature would solve or the use case it addresses. +Ex. I'm always frustrated when [...] + +## Proposed Solution +A clear and concise description of what you want to happen. + +## Alternative Solutions +A clear and concise description of any alternative solutions or features you've considered. + +## Benefits +Describe how this feature would benefit users: +- Who would use this feature? +- What problems does it solve? +- What value does it add? + +## Implementation Details (Optional) +If you have ideas about how this could be implemented, share them here: +- Technical approach +- Files/components that might need changes +- Any concerns or challenges you foresee + +## Additional Context +Add any other context, mockups, or screenshots about the feature request here. + +## Relevant Standards +Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +- [ ] Accessibility (WCAG 2.1 AA) +- [ ] Localization (en_US/en_GB) +- [ ] Security best practices +- [ ] Code quality standards +- [ ] Other: [specify] + +## Checklist +- [ ] I have searched for similar feature requests before creating this one +- [ ] I have clearly described the use case and benefits +- [ ] I have considered alternative solutions +- [ ] This feature aligns with the project's goals and scope -- 2.52.0 From de9c36b9d16bf419f235c559becb9ffcd0b04216 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:20 +0000 Subject: [PATCH 16/45] chore: sync .mokogitea/ISSUE_TEMPLATE/question.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/question.md | 82 +++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/question.md diff --git a/.mokogitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..3175013 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/question.md @@ -0,0 +1,82 @@ +--- +name: Question +about: Ask a question about usage, features, or best practices +title: '[QUESTION] ' +labels: ['question'] +assignees: ['jmiller'] +--- + + +## Question + +**Your question:** + + +## Context + +**What are you trying to accomplish?** + + +**What have you already tried?** + + +**Category**: +- [ ] Script usage +- [ ] Configuration +- [ ] Workflow setup +- [ ] Documentation interpretation +- [ ] Best practices +- [ ] Integration +- [ ] Other: __________ + +## Environment (if relevant) + +**Your setup**: +- Operating System: +- Version: + +## What You've Researched + +**Documentation reviewed**: +- [ ] README.md +- [ ] Project documentation +- [ ] Other (specify): __________ + +**Similar issues/questions found**: +- # +- # + +## Expected Outcome + +**What result are you hoping for?** + + +## Code/Configuration Samples + +**Relevant code or configuration** (if applicable): + +```bash +# Your code here +``` + +## Additional Context + +**Any other relevant information:** + + +**Screenshots** (if helpful): + + +## Urgency + +- [ ] Urgent (blocking work) +- [ ] Normal (can work on other things meanwhile) +- [ ] Low priority (just curious) + +## Checklist + +- [ ] I have searched existing issues and discussions +- [ ] I have reviewed relevant documentation +- [ ] I have provided sufficient context +- [ ] I have included code/configuration samples if relevant +- [ ] This is a genuine question (not a bug report or feature request) -- 2.52.0 From 4aaf88c26ebae8e2d03e64b9a65713fe43ebf0e9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:20 +0000 Subject: [PATCH 17/45] chore: sync .mokogitea/ISSUE_TEMPLATE/rfc.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/rfc.md | 126 +++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/rfc.md diff --git a/.mokogitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md new file mode 100644 index 0000000..6f09af7 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/rfc.md @@ -0,0 +1,126 @@ +--- +name: Request for Comments (RFC) +about: Propose a significant change for community discussion +title: '[RFC] ' +labels: 'rfc, discussion' +assignees: '' + +--- + + +## RFC Summary +One-paragraph summary of the proposal. + +## Motivation +Why are we doing this? What use cases does it support? What is the expected outcome? + +## Detailed Design +### Overview +Provide a detailed explanation of the proposed change. + +### API Changes (if applicable) +```php +// Before +function oldApi($param1) { } + +// After +function newApi($param1, $param2) { } +``` + +### User Experience Changes +Describe how users will interact with this change. + +### Implementation Approach +High-level implementation strategy. + +## Drawbacks +Why should we *not* do this? + +## Alternatives +What other designs have been considered? What is the impact of not doing this? + +### Alternative 1 +- Description +- Trade-offs + +### Alternative 2 +- Description +- Trade-offs + +## Adoption Strategy +How will existing users adopt this? Is this a breaking change? + +### Migration Guide +```bash +# Steps to migrate +``` + +### Deprecation Timeline +- **Announcement**: +- **Deprecation**: +- **Removal**: + +## Unresolved Questions +- Question 1 +- Question 2 + +## Future Possibilities +What future work does this enable? + +## Impact Assessment +### Performance +Expected performance impact. + +### Security +Security considerations and implications. + +### Compatibility +- **Backward Compatible**: [Yes / No] +- **Breaking Changes**: [List] + +### Maintenance +Long-term maintenance considerations. + +## Community Input +### Stakeholders +- [ ] Core team +- [ ] Module developers +- [ ] End users +- [ ] Enterprise customers + +### Feedback Period +**Duration**: [e.g., 2 weeks] +**Deadline**: [date] + +## Implementation Timeline +### Phase 1: Design +- [ ] RFC discussion +- [ ] Design finalization +- [ ] Approval + +### Phase 2: Implementation +- [ ] Core implementation +- [ ] Tests +- [ ] Documentation + +### Phase 3: Release +- [ ] Beta release +- [ ] Feedback collection +- [ ] Stable release + +## Success Metrics +How will we measure success? +- Metric 1 +- Metric 2 + +## References +- Related RFCs: +- External documentation: +- Prior art: + +## Open Questions for Community +1. Question 1? +2. Question 2? + +--- +**Note**: This RFC is open for community discussion. Please provide feedback in the comments below. -- 2.52.0 From dcbfb4cf0c7170f6ba9545045d8375309c523a18 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:20 +0000 Subject: [PATCH 18/45] chore: sync .mokogitea/ISSUE_TEMPLATE/security.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/security.md | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/security.md diff --git a/.mokogitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md new file mode 100644 index 0000000..f57b284 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/security.md @@ -0,0 +1,51 @@ +--- +name: Security Vulnerability Report +about: Report a security vulnerability (use only for non-critical issues) +title: '[SECURITY] ' +labels: 'security' +assignees: '' + +--- + + +## ⚠️ IMPORTANT: Private Disclosure Required + +**For critical security vulnerabilities, DO NOT use this template.** +Follow the process in [SECURITY.md](../SECURITY.md) for responsible disclosure. + +Use this template only for: +- Security improvements +- Non-critical security suggestions +- Security documentation updates + +--- + +## Security Issue + +**Severity**: + + +## Description + + +## Affected Components + + +## Suggested Mitigation + + +## Standards Reference +Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)? +- [ ] SPDX license identifiers +- [ ] Secret management +- [ ] Dependency security +- [ ] Access control +- [ ] Other: [specify] + +## Additional Context + + +## Checklist +- [ ] This is NOT a critical vulnerability requiring private disclosure +- [ ] I have reviewed the SECURITY.md policy +- [ ] I have provided sufficient detail for evaluation -- 2.52.0 From 709340c5197e777309c2c28b1855dd011d385c0e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 18:59:21 +0000 Subject: [PATCH 19/45] chore: sync .mokogitea/ISSUE_TEMPLATE/version.md from template [skip ci] --- .mokogitea/ISSUE_TEMPLATE/version.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .mokogitea/ISSUE_TEMPLATE/version.md diff --git a/.mokogitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md new file mode 100644 index 0000000..6328421 --- /dev/null +++ b/.mokogitea/ISSUE_TEMPLATE/version.md @@ -0,0 +1,24 @@ +--- +name: Version Bump +about: Request or track a version change +title: '[VERSION] ' +labels: 'version, type: version' +assignees: 'jmiller' +--- + +## Version Change + +**Current version**: +**Requested version**: +**Change type**: + +## Reason + + + +## Checklist + +- [ ] README.md `VERSION:` field updated +- [ ] CHANGELOG.md entry added +- [ ] Module descriptor version updated (Dolibarr: `$this->version`, Joomla: ``) +- [ ] All file headers will be auto-propagated by `sync-version-on-merge` workflow -- 2.52.0 From e3ddcfc70d5a130285855c1298d425e2f8ee3363 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:10 +0000 Subject: [PATCH 20/45] chore: force-sync .mokogitea/workflows/auto-release.yml [skip ci] --- .mokogitea/workflows/auto-release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 1fe7aa6..84fc701 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -8,13 +8,13 @@ # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API # PATH: /templates/workflows/universal/auto-release.yml.template # VERSION: 05.00.00 -# BRIEF: Universal build & release � detects platform from .moko-platform +# BRIEF: Universal build & release � detects platform from manifest.xml # # +========================================================================+ # | UNIVERSAL BUILD & RELEASE PIPELINE | # +========================================================================+ # | | -# | Reads .moko-platform (joomla|dolibarr|generic) to branch logic. | +# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | | # | Platform-specific: | # | joomla: XML manifest, updates.xml, type-prefixed packages | @@ -80,8 +80,8 @@ jobs: id: platform run: | # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" echo "Platform detected: ${PLATFORM}" -- 2.52.0 From 9c552748dcb60f0b74e7c62821101a3b9660a64d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:11 +0000 Subject: [PATCH 21/45] chore: force-sync .mokogitea/workflows/cascade-dev.yml [skip ci] -- 2.52.0 From 2677298d815fd02e89714bb3f52897407db450a0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:11 +0000 Subject: [PATCH 22/45] chore: force-sync .mokogitea/workflows/cleanup.yml [skip ci] -- 2.52.0 From 96d92d47337ddf4af3aa1a621cbbd73164018f86 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:11 +0000 Subject: [PATCH 23/45] chore: force-sync .mokogitea/workflows/deploy-manual.yml [skip ci] -- 2.52.0 From 77858e5cdf37d154269b0164dd9ae821fa203253 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:12 +0000 Subject: [PATCH 24/45] chore: force-sync .mokogitea/workflows/gitleaks.yml [skip ci] -- 2.52.0 From 0962f50b814cc394f7224b1d33138f8e3ea3e643 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:12 +0000 Subject: [PATCH 25/45] chore: force-sync .mokogitea/workflows/notify.yml [skip ci] -- 2.52.0 From 7a989536745177bb578b59bed4134c923aec9a15 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:12 +0000 Subject: [PATCH 26/45] chore: force-sync .mokogitea/workflows/pr-check.yml [skip ci] --- .mokogitea/workflows/pr-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index bd06c90..99e063f 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -109,8 +109,8 @@ jobs: id: platform run: | # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" -- 2.52.0 From 592cbd539ff592636d67c987b7386d4f6d5f7e3c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:13 +0000 Subject: [PATCH 27/45] chore: force-sync .mokogitea/workflows/pre-release.yml [skip ci] --- .mokogitea/workflows/pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 6e05d96..c70ea7d 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -56,8 +56,8 @@ jobs: id: platform run: | # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]') + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) -- 2.52.0 From 55ebff78db7604b7691c5c1a9576d65ef069e819 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:13 +0000 Subject: [PATCH 28/45] chore: force-sync .mokogitea/workflows/repo-health.yml [skip ci] -- 2.52.0 From e4265045ebca55e936f31741de2491d7253572a6 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:13 +0000 Subject: [PATCH 29/45] chore: force-sync .mokogitea/workflows/security-audit.yml [skip ci] -- 2.52.0 From 149b2f9167aa8c008645ffc3de366ba2d21c2ef7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:14 +0000 Subject: [PATCH 30/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/adr.md [skip ci] -- 2.52.0 From 4a0326615bc4f8deb7564768544335d89fe03198 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:14 +0000 Subject: [PATCH 31/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/bug_report.md [skip ci] -- 2.52.0 From 19907ee7cb6acb06d46f15bec68cee13386b5a53 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:15 +0000 Subject: [PATCH 32/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/config.yml [skip ci] -- 2.52.0 From 436e9ec8726a75b909ccc9cab42a42ebea2e22db Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:15 +0000 Subject: [PATCH 33/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/documentation.md [skip ci] -- 2.52.0 From f24df6cd76f475b822f4d1bb8761c976d517fbfe Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:15 +0000 Subject: [PATCH 34/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/feature_request.md [skip ci] -- 2.52.0 From 4fbc38c40f5fbbaf486e384857822a8e5de5a07a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:16 +0000 Subject: [PATCH 35/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/question.md [skip ci] -- 2.52.0 From 0b13722a776638e8159b1549b2e71e7fe46f8e05 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:16 +0000 Subject: [PATCH 36/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/rfc.md [skip ci] -- 2.52.0 From e78125c9310b7db2f84c100d410e5e55ee7b9326 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:16 +0000 Subject: [PATCH 37/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/security.md [skip ci] -- 2.52.0 From 20d4cb043487740db2dd48ec015c78e0342abdf4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:17 +0000 Subject: [PATCH 38/45] chore: force-sync .mokogitea/ISSUE_TEMPLATE/version.md [skip ci] -- 2.52.0 From ee1c7f1b4bba16389848d9825db1f89226b1012b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:17 +0000 Subject: [PATCH 39/45] chore: move .mokogitea/manifest.xml to .mokogitea/ [skip ci] --- .mokogitea/manifest.xml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .mokogitea/manifest.xml diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml new file mode 100644 index 0000000..cbab803 --- /dev/null +++ b/.mokogitea/manifest.xml @@ -0,0 +1,25 @@ + + + + + moko-platform + MokoConsulting + Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories + GNU General Public License v3 + + + generic + 04.07.00 + https://git.mokoconsulting.tech/MokoConsulting/moko-platform + 2026-05-10T19:51:08+00:00 + + + HCL + generic + src/ + + -- 2.52.0 From cdf0efa6c4de574f864c55952783954963622d70 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 19:30:18 +0000 Subject: [PATCH 40/45] chore: remove .mokogitea/.moko-platform [skip ci] --- .mokogitea/.moko-platform | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .mokogitea/.moko-platform diff --git a/.mokogitea/.moko-platform b/.mokogitea/.moko-platform deleted file mode 100644 index cbab803..0000000 --- a/.mokogitea/.moko-platform +++ /dev/null @@ -1,25 +0,0 @@ - - - - - moko-platform - MokoConsulting - Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories - GNU General Public License v3 - - - generic - 04.07.00 - https://git.mokoconsulting.tech/MokoConsulting/moko-platform - 2026-05-10T19:51:08+00:00 - - - HCL - generic - src/ - - -- 2.52.0 From a365c9ec24821fca34b5dac47ca64ab2dcae739b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 20:22:45 -0500 Subject: [PATCH 41/45] fix: remove v_hidden column, rename gitea-server-setup to .mokogitea-private - Replace excludeByName hack with explicit filterFieldsByName regex in mokowaas-dashboard.json to properly hide Value #VERSION - Update EXCLUDE lists in branch-protection.yml and renovate.yml to reference renamed .mokogitea-private repo Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .mokogitea/branch-protection.yml | 2 +- .mokogitea/renovate.yml | 2 +- monitoring/grafana/mokowaas-dashboard.json | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml index 35afa23..5372dda 100644 --- a/.mokogitea/branch-protection.yml +++ b/.mokogitea/branch-protection.yml @@ -62,7 +62,7 @@ jobs: API="${GITEA_URL}/api/v1" # Platform/standards/infra repos to exclude - EXCLUDE="gitea-org-config org-profile gitea-private gitea-server-setup MokoStandards MokoStandards-API MokoTesting" + EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards MokoStandards-API MokoTesting" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" if [ -n "${{ inputs.repos }}" ]; then diff --git a/.mokogitea/renovate.yml b/.mokogitea/renovate.yml index dad4bd2..5181ff6 100644 --- a/.mokogitea/renovate.yml +++ b/.mokogitea/renovate.yml @@ -61,7 +61,7 @@ jobs: run: | API="${GITEA_URL}/api/v1" - EXCLUDE="gitea-org-config org-profile gitea-private gitea-server-setup MokoStandards MokoStandards-API MokoTesting" + EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards MokoStandards-API MokoTesting" EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate" if [ -n "${{ inputs.repos }}" ]; then diff --git a/monitoring/grafana/mokowaas-dashboard.json b/monitoring/grafana/mokowaas-dashboard.json index d6a26c5..908fd73 100644 --- a/monitoring/grafana/mokowaas-dashboard.json +++ b/monitoring/grafana/mokowaas-dashboard.json @@ -255,22 +255,18 @@ ], "transformations": [ { "id": "joinByField", "options": { "byField": "site", "mode": "outer" } }, - { "id": "filterFieldsByName", "options": { "include": { "pattern": "^(site_url|version|Value #).*" } } }, + { "id": "filterFieldsByName", "options": { "include": { "pattern": "^(site_url|version|Value #(SYSTEM|EXTUPDATES|TOTAL|ENABLED|DISABLED))$" } } }, { "id": "organize", "options": { "renameByName": { "site_url": "Site", "version": "Version", - "Value #VERSION": "v_hidden", "Value #SYSTEM": "System", "Value #EXTUPDATES": "Ext Updates", "Value #TOTAL": "Total", "Value #ENABLED": "Enabled", "Value #DISABLED": "Disabled" - }, - "excludeByName": { - "v_hidden": true } } }, -- 2.52.0 From 4ec51e262bbca75bd73adf7c1fd02603fcf96556 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 20:23:04 -0500 Subject: [PATCH 42/45] feat: add legend and tooltip options to Grafana library panels Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- monitoring/grafana/library-panels/cpu-usage.json | 4 ++++ monitoring/grafana/library-panels/docker-container-cpu.json | 4 ++++ .../grafana/library-panels/docker-container-memory.json | 4 ++++ monitoring/grafana/library-panels/memory-usage.json | 4 ++++ monitoring/grafana/library-panels/mysql-connections.json | 4 ++++ .../grafana/library-panels/mysql-queries-per-second.json | 4 ++++ monitoring/grafana/library-panels/network-traffic.json | 4 ++++ monitoring/grafana/library-panels/nginx-connections.json | 4 ++++ monitoring/grafana/library-panels/nginx-request-rate.json | 4 ++++ monitoring/grafana/library-panels/response-time.json | 2 +- monitoring/grafana/library-panels/ssl-certificate-expiry.json | 2 +- monitoring/grafana/library-panels/uptime-availability.json | 3 ++- 12 files changed, 40 insertions(+), 3 deletions(-) diff --git a/monitoring/grafana/library-panels/cpu-usage.json b/monitoring/grafana/library-panels/cpu-usage.json index c260461..16d8d6d 100644 --- a/monitoring/grafana/library-panels/cpu-usage.json +++ b/monitoring/grafana/library-panels/cpu-usage.json @@ -79,6 +79,10 @@ "refId": "B" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "CPU Usage %", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/docker-container-cpu.json b/monitoring/grafana/library-panels/docker-container-cpu.json index ef1c149..a6d4210 100644 --- a/monitoring/grafana/library-panels/docker-container-cpu.json +++ b/monitoring/grafana/library-panels/docker-container-cpu.json @@ -22,6 +22,10 @@ "refId": "A" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "CPU Usage by Container", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/docker-container-memory.json b/monitoring/grafana/library-panels/docker-container-memory.json index dd8b411..a35b2dd 100644 --- a/monitoring/grafana/library-panels/docker-container-memory.json +++ b/monitoring/grafana/library-panels/docker-container-memory.json @@ -22,6 +22,10 @@ "refId": "A" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Memory Usage by Container", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/memory-usage.json b/monitoring/grafana/library-panels/memory-usage.json index 6f33721..e2c48b6 100644 --- a/monitoring/grafana/library-panels/memory-usage.json +++ b/monitoring/grafana/library-panels/memory-usage.json @@ -38,6 +38,10 @@ "refId": "D" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Memory Usage", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/mysql-connections.json b/monitoring/grafana/library-panels/mysql-connections.json index ae16c10..d655f9d 100644 --- a/monitoring/grafana/library-panels/mysql-connections.json +++ b/monitoring/grafana/library-panels/mysql-connections.json @@ -28,6 +28,10 @@ "refId": "C" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Connections", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/mysql-queries-per-second.json b/monitoring/grafana/library-panels/mysql-queries-per-second.json index 56a97a7..0051daa 100644 --- a/monitoring/grafana/library-panels/mysql-queries-per-second.json +++ b/monitoring/grafana/library-panels/mysql-queries-per-second.json @@ -59,6 +59,10 @@ "refId": "B" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Queries per Second", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/network-traffic.json b/monitoring/grafana/library-panels/network-traffic.json index 82b994e..8250c70 100644 --- a/monitoring/grafana/library-panels/network-traffic.json +++ b/monitoring/grafana/library-panels/network-traffic.json @@ -69,6 +69,10 @@ "refId": "D" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Network Traffic", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/nginx-connections.json b/monitoring/grafana/library-panels/nginx-connections.json index d6effbf..0f186fd 100644 --- a/monitoring/grafana/library-panels/nginx-connections.json +++ b/monitoring/grafana/library-panels/nginx-connections.json @@ -33,6 +33,10 @@ "refId": "D" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Connections Over Time", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/nginx-request-rate.json b/monitoring/grafana/library-panels/nginx-request-rate.json index 0544ef6..f4c8ccb 100644 --- a/monitoring/grafana/library-panels/nginx-request-rate.json +++ b/monitoring/grafana/library-panels/nginx-request-rate.json @@ -36,6 +36,10 @@ "refId": "A" } ], + "options": { + "legend": { "displayMode": "list", "placement": "right", "calcs": [] }, + "tooltip": { "mode": "multi" } + }, "title": "Request Rate", "type": "timeseries" } diff --git a/monitoring/grafana/library-panels/response-time.json b/monitoring/grafana/library-panels/response-time.json index eded845..cc85792 100644 --- a/monitoring/grafana/library-panels/response-time.json +++ b/monitoring/grafana/library-panels/response-time.json @@ -15,7 +15,7 @@ "options": { "legend": { "displayMode": "list", - "placement": "bottom" + "placement": "right" }, "tooltip": { "mode": "multi" diff --git a/monitoring/grafana/library-panels/ssl-certificate-expiry.json b/monitoring/grafana/library-panels/ssl-certificate-expiry.json index f068ba8..f3cd4f9 100644 --- a/monitoring/grafana/library-panels/ssl-certificate-expiry.json +++ b/monitoring/grafana/library-panels/ssl-certificate-expiry.json @@ -43,7 +43,7 @@ "min" ], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "tooltip": { "mode": "multi", diff --git a/monitoring/grafana/library-panels/uptime-availability.json b/monitoring/grafana/library-panels/uptime-availability.json index 292b6ed..b59ebaa 100644 --- a/monitoring/grafana/library-panels/uptime-availability.json +++ b/monitoring/grafana/library-panels/uptime-availability.json @@ -37,7 +37,8 @@ "options": { "legend": { "displayMode": "list", - "placement": "bottom" + "placement": "right", + "calcs": [] }, "tooltip": { "mode": "single" -- 2.52.0 From 6de15810f46119546edb7ef921b6109374c80d65 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 12 May 2026 20:26:58 -0500 Subject: [PATCH 43/45] feat: add server auto-heal script with split backup management - Boot check with safe-point detection for unclean restarts - Split system (daily) and content (2h) backup schedules - 4-phase auto-heal: filesystem repair, config restore, service restart, health verify - Self-installing: creates cron jobs, systemd shutdown hook, and config - Configurable via /etc/moko/autoheal.conf Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- automation/server-autoheal.sh | 678 ++++++++++++++++++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 automation/server-autoheal.sh diff --git a/automation/server-autoheal.sh b/automation/server-autoheal.sh new file mode 100644 index 0000000..9d9019f --- /dev/null +++ b/automation/server-autoheal.sh @@ -0,0 +1,678 @@ +#!/usr/bin/env bash +# server-autoheal.sh - Auto-heal on restart + split backup management +# +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# +# DEFGROUP: MokoStandards.Automation.ServerAutoheal +# INGROUP: MokoStandards.Automation +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /automation/server-autoheal.sh +# BRIEF: Server auto-heal on unclean restart + split system/content backups +# +# Usage: +# server-autoheal.sh [options] +# +# Commands: +# boot-check Run at boot — auto-heals if no safe point exists +# set-safepoint Mark current state as safe (call before planned shutdown) +# backup-system Run a system backup (configs, packages, services) +# backup-content Run a content backup (site files, databases, uploads) +# cleanup Prune expired backups per retention policy +# status Show safe point and backup status +# +# Scheduling (cron): +# @reboot server-autoheal.sh boot-check +# 0 3 * * * server-autoheal.sh backup-system (daily at 3am) +# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours) +# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup) + +set -euo pipefail + +# ────────────────────────────────────────────── +# Configuration — override via /etc/moko/autoheal.conf +# ────────────────────────────────────────────── +CONF_FILE="/etc/moko/autoheal.conf" +[[ -f "$CONF_FILE" ]] && source "$CONF_FILE" + +BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}" +SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}" +LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}" +LOCK_DIR="${LOCK_DIR:-/var/run/moko}" + +# System backup: configs, package lists, service state, cron +SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system" +SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups + +# Content backup: web roots, databases, uploads +CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content" +CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups + +# Paths to back up — override these in /etc/moko/autoheal.conf +SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}" +CONTENT_PATHS="${CONTENT_PATHS:-/var/www}" +DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── +log() { + local level="$1"; shift + local ts + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local msg="[$ts] [$level] $*" + echo "$msg" | tee -a "$LOG_FILE" >&2 +} + +ensure_dirs() { + mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \ + "$LOCK_DIR" "$(dirname "$LOG_FILE")" +} + +acquire_lock() { + local lockfile="${LOCK_DIR}/autoheal-${1}.lock" + if [[ -f "$lockfile" ]]; then + local pid + pid=$(<"$lockfile") + if kill -0 "$pid" 2>/dev/null; then + log WARN "Another $1 operation is running (PID $pid), skipping" + exit 0 + fi + rm -f "$lockfile" + fi + echo $$ > "$lockfile" + trap "rm -f '$lockfile'" EXIT +} + +timestamp() { + date -u '+%Y%m%d_%H%M%S' +} + +# ────────────────────────────────────────────── +# Safe-point management +# ────────────────────────────────────────────── +cmd_set_safepoint() { + ensure_dirs + local ts + ts=$(timestamp) + cat > "$SAFEPOINT_FILE" </dev/null || echo "unknown") +set_by=${SUDO_USER:-$(whoami)} +EOF + log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}" +} + +cmd_clear_safepoint() { + rm -f "$SAFEPOINT_FILE" + log INFO "Safe point cleared" +} + +has_safepoint() { + [[ -f "$SAFEPOINT_FILE" ]] +} + +# ────────────────────────────────────────────── +# System backup (daily) +# ────────────────────────────────────────────── +cmd_backup_system() { + ensure_dirs + acquire_lock "system-backup" + + local ts + ts=$(timestamp) + local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz" + local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest" + + log INFO "Starting system backup → $archive" + + # Collect existing paths only + local existing_paths=() + for p in $SYSTEM_PATHS; do + [[ -e "$p" ]] && existing_paths+=("$p") + done + + if [[ ${#existing_paths[@]} -eq 0 ]]; then + log WARN "No system paths found to back up" + return 1 + fi + + # Archive configs and system files + tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true + + # Capture package list and service state as manifest + { + echo "=== PACKAGES ===" + if command -v dpkg &>/dev/null; then + dpkg --get-selections + elif command -v rpm &>/dev/null; then + rpm -qa --qf '%{NAME}\t%{VERSION}\n' + fi + echo "" + echo "=== ENABLED SERVICES ===" + if command -v systemctl &>/dev/null; then + systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true + fi + echo "" + echo "=== CRONTABS ===" + for user_home in /var/spool/cron/crontabs/*; do + [[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home" + done 2>/dev/null || true + } > "$manifest" + + local size + size=$(du -sh "$archive" 2>/dev/null | cut -f1) + log INFO "System backup complete: $archive ($size)" + + # Prune old system backups (keep $SYSTEM_BACKUP_RETAIN) + local count + count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l) + if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then + local to_remove=$((count - SYSTEM_BACKUP_RETAIN)) + find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ + | sort | head -n "$to_remove" | awk '{print $2}' \ + | while read -r f; do + rm -f "$f" "${f%.tar.gz}.manifest" + log INFO "Pruned old system backup: $f" + done + fi +} + +# ────────────────────────────────────────────── +# Content backup (every 2 hours) +# ────────────────────────────────────────────── +cmd_backup_content() { + ensure_dirs + acquire_lock "content-backup" + + local ts + ts=$(timestamp) + local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz" + local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz" + + log INFO "Starting content backup → $archive" + + # Back up web content / uploads + local existing_paths=() + for p in $CONTENT_PATHS; do + [[ -e "$p" ]] && existing_paths+=("$p") + done + + if [[ ${#existing_paths[@]} -gt 0 ]]; then + tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true + local size + size=$(du -sh "$archive" 2>/dev/null | cut -f1) + log INFO "Content files archived: $archive ($size)" + else + log WARN "No content paths found to back up" + fi + + # Database dump + if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then + local dump_cmd="mysqldump" + command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump" + + local databases=() + if [[ -n "$DB_NAMES" ]]; then + read -ra databases <<< "$DB_NAMES" + else + # Auto-detect: dump all databases except system ones + databases=($(${dump_cmd%dump} -N -e \ + "SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \ + 2>/dev/null | tr '\n' ' ')) || true + fi + + if [[ ${#databases[@]} -gt 0 ]]; then + $dump_cmd --single-transaction --routines --triggers \ + --databases "${databases[@]}" 2>/dev/null \ + | gzip > "$db_dump" + local db_size + db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1) + log INFO "Database dump complete: $db_dump ($db_size)" + else + log WARN "No databases found to dump" + fi + fi +} + +# ────────────────────────────────────────────── +# Cleanup — prune content backups older than retention +# ────────────────────────────────────────────── +cmd_cleanup() { + ensure_dirs + local before_count after_count + + # Content: keep only last 24 hours (1 day) + before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l) + find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \ + -mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true + after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l) + local removed=$((before_count - after_count)) + [[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h" + + # System: keep N most recent (handled in backup-system, but double-check here) + before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l) + local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest + if [[ "$before_count" -gt "$max_system_files" ]]; then + local excess=$((before_count - max_system_files)) + find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \ + | sort | head -n "$excess" | awk '{print $2}' \ + | xargs -r rm -f + log INFO "Pruned excess system backups" + fi + + log INFO "Cleanup complete" +} + +# ────────────────────────────────────────────── +# Boot check — the auto-heal entry point +# ────────────────────────────────────────────── +cmd_boot_check() { + ensure_dirs + acquire_lock "boot-check" + + log INFO "=== Boot check started ===" + log INFO "Hostname: $(hostname), Kernel: $(uname -r)" + + if has_safepoint; then + log INFO "Safe point found — server was shut down cleanly" + log INFO "Clearing safe point for next cycle" + cmd_clear_safepoint + log INFO "=== Boot check passed (clean restart) ===" + return 0 + fi + + log WARN "NO safe point found — server restarted without clean shutdown" + log WARN "Initiating auto-heal sequence..." + + auto_heal + local rc=$? + + # Set safe point after successful heal + if [[ $rc -eq 0 ]]; then + cmd_set_safepoint + log INFO "=== Boot check complete (healed successfully) ===" + else + log ERROR "=== Boot check FAILED — manual intervention required ===" + fi + + return $rc +} + +# ────────────────────────────────────────────── +# Auto-heal strategy +# +# TODO: This is the core decision point. Implement the recovery +# steps that match your server's architecture. See guidance below. +# +# Trade-offs to consider: +# - Restore-from-backup: safest, but content may be up to 2h stale +# - Service-restart-only: faster, keeps current data, but won't fix +# corrupted configs or broken filesystem state +# - Hybrid: restart services first, verify health, only restore if +# health checks fail — best of both worlds but more complex +# +# The function receives no arguments. Use the latest system + content +# backups to restore if needed. Return 0 on success, 1 on failure. +# ────────────────────────────────────────────── +auto_heal() { + log INFO "Phase 1: Verify and repair filesystem" + # Check for common post-crash issues + repair_filesystem + + log INFO "Phase 2: Restore system configuration if corrupted" + restore_system_if_needed + + log INFO "Phase 3: Restart core services" + restart_services + + log INFO "Phase 4: Verify health" + if ! verify_health; then + log WARN "Health check failed after service restart — restoring from backup" + restore_from_backup + restart_services + + if ! verify_health; then + log ERROR "Health check still failing after restore — giving up" + return 1 + fi + fi + + log INFO "Auto-heal completed successfully" + return 0 +} + +# ────────────────────────────────────────────── +# Heal sub-steps +# ────────────────────────────────────────────── +repair_filesystem() { + # Fix common post-crash filesystem issues + # Clear stale PID/lock/socket files that prevent services from starting + local stale_files=( + /var/run/nginx.pid + /var/run/mysqld/mysqld.pid + /var/run/php-fpm.pid + /var/lib/mysql/*.pid + ) + for f in "${stale_files[@]}"; do + for expanded in $f; do + if [[ -f "$expanded" ]]; then + local pid + pid=$(<"$expanded") 2>/dev/null || true + if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then + rm -f "$expanded" + log INFO "Removed stale PID file: $expanded" + fi + fi + done + done + + # Fix permissions on critical dirs that may get mangled + [[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true + [[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true + + # Repair tmp/cache dirs + for d in /tmp /var/tmp; do + [[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true + done +} + +restore_system_if_needed() { + # Find latest system backup + local latest_system + latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1 | awk '{print $2}') + + if [[ -z "$latest_system" ]]; then + log WARN "No system backup available to verify against" + return 0 + fi + + # Check if critical configs exist and are non-empty + local needs_restore=false + local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql") + + for cfg in "${critical_configs[@]}"; do + if [[ -e "$cfg" ]]; then + # Config exists — check if it's a file and non-empty, or a directory + if [[ -f "$cfg" && ! -s "$cfg" ]]; then + log WARN "Critical config is empty: $cfg" + needs_restore=true + break + fi + fi + done + + if $needs_restore; then + log WARN "Restoring system config from $latest_system" + tar -xzf "$latest_system" -C / 2>/dev/null || { + log ERROR "System restore failed from $latest_system" + return 1 + } + log INFO "System config restored" + else + log INFO "System configs look intact — skipping restore" + fi +} + +restart_services() { + if ! command -v systemctl &>/dev/null; then + log WARN "systemctl not available — skipping service restart" + return 0 + fi + + local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm") + + for svc in "${services[@]}"; do + if systemctl is-enabled "$svc" &>/dev/null; then + log INFO "Restarting $svc..." + systemctl restart "$svc" 2>/dev/null && \ + log INFO "$svc restarted OK" || \ + log WARN "$svc restart failed" + fi + done +} + +verify_health() { + local failures=0 + + # Check critical services are running + local services=("mysql" "mariadb" "nginx" "apache2") + for svc in "${services[@]}"; do + if systemctl is-enabled "$svc" &>/dev/null; then + if ! systemctl is-active "$svc" &>/dev/null; then + log WARN "Service not running: $svc" + ((failures++)) + fi + fi + done + + # Check if web server responds + if command -v curl &>/dev/null; then + if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then + log WARN "Local web server not responding" + ((failures++)) + fi + fi + + # Check if database accepts connections + if command -v mysqladmin &>/dev/null; then + if ! mysqladmin ping --silent 2>/dev/null; then + log WARN "Database not responding to ping" + ((failures++)) + fi + fi + + [[ $failures -eq 0 ]] +} + +restore_from_backup() { + log WARN "=== Full restore from backup ===" + + # Restore system config + local latest_system + latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1 | awk '{print $2}') + + if [[ -n "$latest_system" ]]; then + log INFO "Restoring system from $latest_system" + tar -xzf "$latest_system" -C / 2>/dev/null || \ + log ERROR "System restore failed" + fi + + # Restore content + local latest_content + latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1 | awk '{print $2}') + + if [[ -n "$latest_content" ]]; then + log INFO "Restoring content from $latest_content" + tar -xzf "$latest_content" -C / 2>/dev/null || \ + log ERROR "Content restore failed" + fi + + # Restore database + local latest_db + latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1 | awk '{print $2}') + + if [[ -n "$latest_db" ]]; then + log INFO "Restoring database from $latest_db" + local mysql_cmd="mysql" + command -v mariadb &>/dev/null && mysql_cmd="mariadb" + zcat "$latest_db" | $mysql_cmd 2>/dev/null || \ + log ERROR "Database restore failed" + fi +} + +# ────────────────────────────────────────────── +# Status +# ────────────────────────────────────────────── +cmd_status() { + echo "=== Moko Server Auto-Heal Status ===" + echo "" + + # Safe point + if has_safepoint; then + echo "Safe point: SET" + cat "$SAFEPOINT_FILE" | sed 's/^/ /' + else + echo "Safe point: NOT SET (will auto-heal on next boot)" + fi + echo "" + + # System backups + echo "System backups (${SYSTEM_BACKUP_DIR}):" + local sys_count + sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l) + echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)" + local latest_sys + latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1) + if [[ -n "$latest_sys" ]]; then + echo " Latest: $(echo "$latest_sys" | awk '{print $2}')" + echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')" + else + echo " Latest: (none)" + fi + echo "" + + # Content backups + echo "Content backups (${CONTENT_BACKUP_DIR}):" + local cnt_count + cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l) + echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)" + local latest_cnt + latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \ + 2>/dev/null | sort -r | head -1) + if [[ -n "$latest_cnt" ]]; then + echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')" + echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')" + else + echo " Latest: (none)" + fi + echo "" + + # Disk usage + echo "Backup disk usage:" + du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /' +} + +# ────────────────────────────────────────────── +# Install helper — sets up cron + systemd +# ────────────────────────────────────────────── +cmd_install() { + local script_path + script_path=$(readlink -f "$0") + + echo "Installing Moko Auto-Heal..." + + # Create config directory + mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR" + + # Write example config if none exists + if [[ ! -f "$CONF_FILE" ]]; then + cat > "$CONF_FILE" <<'CONF' +# /etc/moko/autoheal.conf — Server auto-heal configuration +# Uncomment and modify as needed + +# BACKUP_ROOT="/var/backups/moko" +# SAFEPOINT_FILE="/var/run/moko/safepoint" +# LOG_FILE="/var/log/moko/autoheal.log" + +# System backup paths (space-separated) +# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system" + +# Content backup paths (space-separated) +# CONTENT_PATHS="/var/www" + +# Database names (space-separated, empty = auto-detect all) +# DB_NAMES="" + +# Retention +# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep +# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep +CONF + echo " Created config: $CONF_FILE" + fi + + # Install cron jobs + local cron_file="/etc/cron.d/moko-autoheal" + cat > "$cron_file" < "$shutdown_hook" <&2 + echo "Run '$0 help' for usage" >&2 + exit 1 + ;; + esac +} + +main "$@" -- 2.52.0 From b46706a7a06b28a0871a1b5929ebf8738dc1cc40 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 15 May 2026 21:27:48 -0500 Subject: [PATCH 44/45] fix(monitoring): add targets volume mount to Prometheus compose The blackbox-http file_sd_configs targets directory was not mounted into the Prometheus container, causing all probe metrics to be empty and the MokoWaaS dashboard to show no data. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- monitoring/docker-compose.yml | 186 ++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 monitoring/docker-compose.yml diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..acfea36 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,186 @@ +networks: + monitoring: + driver: bridge +volumes: + prometheus_data: null + grafana_data: null +services: + prometheus: + image: prom/prometheus:latest + container_name: prometheus + restart: unless-stopped + ports: + - 127.0.0.1:9091:9090 + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./targets:/etc/prometheus/targets:ro + - prometheus_data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=90d + - --web.enable-lifecycle + extra_hosts: + - host.docker.internal:host-gateway + networks: + - monitoring + healthcheck: + test: + - CMD + - wget + - -qO- + - http://localhost:9090/-/healthy + interval: 30s + timeout: 5s + retries: 3 + node-exporter: + image: prom/node-exporter:latest + container_name: node-exporter + restart: unless-stopped + ports: + - 127.0.0.1:9100:9100 + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro + - /var/lib/prometheus/node-exporter:/textfile:ro + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/) + - --collector.netclass.ignored-devices=^(veth.*|br-.*|docker.*)$$ + - --collector.diskstats.device-exclude=^(ram|loop|fd|dm-)\d+$$ + - --collector.systemd + - --collector.systemd.unit-include=.+ + - --collector.textfile.directory=/textfile + pid: host + security_opt: + - apparmor:unconfined + networks: + - monitoring + healthcheck: + test: + - CMD + - wget + - --spider + - -q + - http://localhost:9100/metrics + interval: 30s + timeout: 5s + retries: 3 + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + container_name: cadvisor + restart: unless-stopped + ports: + - 127.0.0.1:8082:8080 + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + privileged: true + devices: + - /dev/kmsg + networks: + - monitoring + healthcheck: + test: + - CMD + - wget + - --spider + - -q + - http://localhost:8080/healthz + interval: 30s + timeout: 5s + retries: 3 + nginx-exporter: + image: nginx/nginx-prometheus-exporter:latest + container_name: nginx-exporter + restart: unless-stopped + network_mode: host + command: + - --nginx.scrape-uri=http://127.0.0.1:8888/nginx_status + - --web.listen-address=0.0.0.0:9113 + grafana: + image: grafana/grafana:latest + container_name: grafana + restart: unless-stopped + ports: + - 127.0.0.1:3001:3000 + environment: + - GF_SECURITY_ADMIN_USER=jmiller + - GF_SECURITY_ADMIN_PASSWORD=#2918HeatherfieldDrive + - GF_SERVER_ROOT_URL=https://bench.mokoconsulting.tech/ + - GF_SERVER_DOMAIN=bench.mokoconsulting.tech + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_AUTH_ANONYMOUS_ORG_ID=1 + - GF_USERS_ALLOW_SIGN_UP=false + - GF_USERS_ALLOW_ORG_CREATE=false + - GF_SECURITY_COOKIE_SECURE=true + - GF_SECURITY_STRICT_TRANSPORT_SECURITY=true + - GF_SECURITY_X_CONTENT_TYPE_OPTIONS=true + - GF_SECURITY_X_XSS_PROTECTION=true + - GF_LOG_MODE=console + - GF_LOG_LEVEL=debug + - GF_USERS_DEFAULT_THEME=dark + - GF_BRANDING_APP_TITLE=Moko Bench + - GF_BRANDING_LOGIN_TITLE=Moko Consulting + - GF_BRANDING_LOGIN_SUBTITLE=Server Performance Dashboard + - GF_DATE_FORMATS_FULL_DATE=YYYY-MM-DD HH:mm:ss + - GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-polystat-panel,yesoreyeram-infinity-datasource,natel-discrete-panel + - GF_AUTH_GOOGLE_ENABLED=true + - GF_AUTH_GOOGLE_CLIENT_ID=349391103517-oiq974b2gq4r3t9f9cf43im31gtruhml.apps.googleusercontent.com + - GF_AUTH_GOOGLE_CLIENT_SECRET=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 + - GF_AUTH_GOOGLE_SCOPES=openid email profile + - GF_AUTH_GOOGLE_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth + - GF_AUTH_GOOGLE_TOKEN_URL=https://oauth2.googleapis.com/token + - GF_AUTH_GOOGLE_ALLOWED_DOMAINS=mokoconsulting.tech + - GF_AUTH_GOOGLE_ALLOW_SIGN_UP=true + - GF_AUTH_GOOGLE_AUTO_LOGIN=false + - GF_AUTH_GOOGLE_SKIP_ORG_ROLE_SYNC=true + - GF_USERS_AUTO_ASSIGN_ORG_ROLE=Admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/custom.ini:/etc/grafana/grafana.ini:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + networks: + - monitoring + depends_on: + prometheus: + condition: service_healthy + healthcheck: + test: + - CMD + - wget + - --spider + - -q + - http://localhost:3000/api/health + interval: 30s + timeout: 5s + retries: 3 + mysqld-exporter: + image: prom/mysqld-exporter:latest + container_name: mysqld-exporter + restart: unless-stopped + network_mode: host + volumes: + - /opt/gitea-server-setup/docker/monitoring/.mysqld-exporter.cnf:/cfg/.my.cnf:ro + environment: + MYSQLD_EXPORTER_PASSWORD: exporter_moko_2026 + command: + - --config.my-cnf=/cfg/.my.cnf + - --web.listen-address=127.0.0.1:9104 + healthcheck: + test: + - CMD-SHELL + - wget -q --spider http://localhost:9104/metrics || exit 1 + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s -- 2.52.0 From f3cb93eb6534686fd55d09dff71340b78c9a9d5f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 16 May 2026 08:39:08 -0500 Subject: [PATCH 45/45] fix: remove v_hidden column, simplify probe queries in MokoWaaS dashboard - Replace excludeByName hack with explicit filterFieldsByName regex - Remove redundant `and on(site_name)` join from STATUS and API queries - Compact Performance/Backup/Uptime panel JSON formatting Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- monitoring/grafana/mokowaas-dashboard.json | 260 +++------------------ 1 file changed, 32 insertions(+), 228 deletions(-) diff --git a/monitoring/grafana/mokowaas-dashboard.json b/monitoring/grafana/mokowaas-dashboard.json index 908fd73..38d3378 100644 --- a/monitoring/grafana/mokowaas-dashboard.json +++ b/monitoring/grafana/mokowaas-dashboard.json @@ -85,7 +85,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "expr": "probe_success{site_name=~\"$site\", job=\"blackbox-http\"} and on(site_name) label_replace(joomla_site_online{site=~\"$site\"} == 1, \"site_name\", \"$1\", \"site\", \"(.+)\")", + "expr": "probe_success{site_name=~\"$site\", job=\"blackbox-http\"}", "instant": true, "format": "table", "refId": "STATUS" }, { @@ -100,7 +100,7 @@ }, { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "expr": "label_replace(joomla_site_api_reachable{site=~\"$site\"}, \"site_name\", \"$1\", \"site\", \"(.+)\") and on(site_name) label_replace(joomla_site_online{site=~\"$site\"} == 1, \"site_name\", \"$1\", \"site\", \"(.+)\")", + "expr": "label_replace(joomla_site_api_reachable{site=~\"$site\"}, \"site_name\", \"$1\", \"site\", \"(.+)\")", "instant": true, "format": "table", "refId": "API" }, { @@ -290,108 +290,32 @@ "type": "row" }, { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "fieldConfig": { - "defaults": { - "unit": "s" - } - }, - "gridPos": { - "h": 8, - "w": 16, - "x": 0, - "y": 47 - }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { "defaults": { "unit": "s" } }, + "gridPos": { "h": 8, "w": 16, "x": 0, "y": 47 }, "id": 20, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"transfer\"}", - "legendFormat": "{{site_name}} transfer" - }, - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"processing\"}", - "legendFormat": "{{site_name}} processing" - } + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"transfer\"}", "legendFormat": "{{site_name}} transfer" }, + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_http_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\", phase=\"processing\"}", "legendFormat": "{{site_name}} processing" } ], "title": "Response Time", "type": "timeseries" }, { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "fieldConfig": { - "defaults": { - "max": 10, - "min": 0, - "thresholds": { - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "yellow", - "value": 2 - }, - { - "color": "red", - "value": 5 - } - ] - }, - "unit": "s" - } - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 47 - }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { "defaults": { "max": 10, "min": 0, "thresholds": { "steps": [{ "color": "green", "value": 0 }, { "color": "yellow", "value": 2 }, { "color": "red", "value": 5 }] }, "unit": "s" } }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 47 }, "id": 22, "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "expr": "probe_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\"}", - "legendFormat": "{{site_name}}" - } + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "probe_duration_seconds{site_name=~\"$site\", job=\"blackbox-http\"}", "legendFormat": "{{site_name}}" } ], "title": "Total Duration", "type": "gauge" }, { "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 55 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 55 }, "id": 104, "title": "Backup Status", "type": "row" @@ -399,78 +323,26 @@ { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "fieldConfig": { - "defaults": { - "noValue": "—", - "custom": { "align": "center", "cellOptions": { "type": "auto" } } - }, + "defaults": { "noValue": "—", "custom": { "align": "center", "cellOptions": { "type": "auto" } } }, "overrides": [ - { - "matcher": { "id": "byName", "options": "Site" }, - "properties": [ - { "id": "custom.width", "value": 300 }, - { "id": "custom.align", "value": "left" }, - { "id": "links", "value": [{ "title": "Manage Backups", "url": "${__value.text}/administrator/index.php?option=com_akeebabackup&view=Manage", "targetBlank": true }] } - ] - }, - { - "matcher": { "id": "byName", "options": "Status" }, - "properties": [ - { "id": "mappings", "value": [{ "options": { "0": { "color": "red", "text": "FAILED" }, "1": { "color": "green", "text": "OK" } }, "type": "value" }] }, - { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, - { "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } } - ] - }, - { - "matcher": { "id": "byName", "options": "Age" }, - "properties": [ - { "id": "unit", "value": "s" }, - { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 172800 }, { "color": "red", "value": 604800 }] } }, - { "id": "custom.cellOptions", "value": { "type": "color-text" } }, - { "id": "mappings", "value": [{ "options": { "-1": { "text": "—" } }, "type": "value" }] } - ] - }, - { - "matcher": { "id": "byName", "options": "Records" }, - "properties": [ - { "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] } - ] - } + { "matcher": { "id": "byName", "options": "Site" }, "properties": [{ "id": "custom.width", "value": 300 }, { "id": "custom.align", "value": "left" }, { "id": "links", "value": [{ "title": "Manage Backups", "url": "${__value.text}/administrator/index.php?option=com_akeebabackup&view=Manage", "targetBlank": true }] }] }, + { "matcher": { "id": "byName", "options": "Status" }, "properties": [{ "id": "mappings", "value": [{ "options": { "0": { "color": "red", "text": "FAILED" }, "1": { "color": "green", "text": "OK" } }, "type": "value" }] }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, { "id": "custom.cellOptions", "value": { "type": "color-background", "mode": "basic" } }] }, + { "matcher": { "id": "byName", "options": "Age" }, "properties": [{ "id": "unit", "value": "s" }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 172800 }, { "color": "red", "value": 604800 }] } }, { "id": "custom.cellOptions", "value": { "type": "color-text" } }, { "id": "mappings", "value": [{ "options": { "-1": { "text": "—" } }, "type": "value" }] }] }, + { "matcher": { "id": "byName", "options": "Records" }, "properties": [{ "id": "mappings", "value": [{ "options": { "0": { "text": "—" } }, "type": "value" }] }] } ] }, "gridPos": { "x": 0, "y": 56, "w": 24, "h": 8 }, "id": 40, "options": { "showHeader": true, "cellHeight": "sm", "footer": { "show": false } }, "targets": [ - { - "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "expr": "label_replace(max by (site, exported_instance) (joomla_backup_status{site=~\"$site\"}), \"site_url\", \"$1\", \"exported_instance\", \"(.+)\")", - "instant": true, "format": "table", "refId": "STATUS" - }, - { - "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "expr": "joomla_backup_age_seconds{site=~\"$site\"}", - "instant": true, "format": "table", "refId": "AGE" - }, - { - "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, - "expr": "joomla_backup_records_total{site=~\"$site\"}", - "instant": true, "format": "table", "refId": "RECORDS" - } + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "label_replace(max by (site, exported_instance) (joomla_backup_status{site=~\"$site\"}), \"site_url\", \"$1\", \"exported_instance\", \"(.+)\")", "instant": true, "format": "table", "refId": "STATUS" }, + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "joomla_backup_age_seconds{site=~\"$site\"}", "instant": true, "format": "table", "refId": "AGE" }, + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "joomla_backup_records_total{site=~\"$site\"}", "instant": true, "format": "table", "refId": "RECORDS" } ], "transformations": [ { "id": "joinByField", "options": { "byField": "site", "mode": "outer" } }, { "id": "filterFieldsByName", "options": { "include": { "pattern": "^(site_url|Value #).*" } } }, - { - "id": "organize", - "options": { - "renameByName": { - "site_url": "Site", - "Value #STATUS": "Status", - "Value #AGE": "Age", - "Value #RECORDS": "Records" - } - } - }, + { "id": "organize", "options": { "renameByName": { "site_url": "Site", "Value #STATUS": "Status", "Value #AGE": "Age", "Value #RECORDS": "Records" } } }, { "id": "sortBy", "options": { "sort": [{ "field": "Site", "desc": false }] } } ], "title": "Backup Status", @@ -478,96 +350,31 @@ }, { "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 68 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 }, "id": 103, "title": "Uptime History", "type": "row" }, { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "lineWidth": 2 - }, - "max": 1, - "min": 0, - "thresholds": { - "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 0.95 - }, - { - "color": "green", - "value": 0.99 - } - ] - }, - "unit": "percentunit" - } - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 69 - }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { "defaults": { "custom": { "fillOpacity": 10, "lineWidth": 2 }, "max": 1, "min": 0, "thresholds": { "steps": [{ "color": "red", "value": 0 }, { "color": "yellow", "value": 0.95 }, { "color": "green", "value": 0.99 }] }, "unit": "percentunit" } }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 69 }, "id": 30, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, + "options": { "legend": { "displayMode": "list", "placement": "bottom" }, "tooltip": { "mode": "single" } }, "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, - "expr": "avg_over_time(probe_success{site_name=~\"$site\", job=\"blackbox-http\"}[1h])", - "legendFormat": "{{site_name}}" - } + { "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "expr": "avg_over_time(probe_success{site_name=~\"$site\", job=\"blackbox-http\"}[1h])", "legendFormat": "{{site_name}}" } ], "title": "Availability (30d)", "type": "timeseries" } ], "refresh": "5m", - "tags": [ - "mokowaas", - "joomla", - "endpoints", - "monitoring" - ], + "tags": ["mokowaas", "joomla", "endpoints", "monitoring"], "templating": { "list": [ { - "current": { - "text": "All", - "value": "$__all" - }, - "datasource": { - "type": "prometheus", - "uid": "PBFA97CFB590B2093" - }, + "current": { "text": "All", "value": "$__all" }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, "includeAll": true, "label": "Site", "multi": true, @@ -579,10 +386,7 @@ } ] }, - "time": { - "from": "now-24h", - "to": "now" - }, + "time": { "from": "now-24h", "to": "now" }, "timezone": "browser", "title": "MokoWaaS", "uid": "mokowaas", -- 2.52.0