diff --git a/.gitea/.moko-platform b/.gitea/.moko-platform
deleted file mode 100644
index 9bd08a5..0000000
--- a/.gitea/.moko-platform
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
- Template-MCP
- MokoConsulting
- Template repository for creating MokoStandards-compliant MCP API servers
- GNU General Public License v3
-
-
- nodejs
- 04.07.00
- https://git.mokoconsulting.tech/MokoConsulting/moko-platform
- 2026-05-10T19:51:11+00:00
-
-
- TypeScript
- mcp-server
- src/
-
-
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
deleted file mode 100644
index bfcde4f..0000000
--- a/.gitea/workflows/auto-release.yml
+++ /dev/null
@@ -1,352 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: GitHub.Workflow
-# INGROUP: MokoStandards.Release
-# REPO: https://github.com/mokoconsulting-tech/MokoStandards
-# PATH: /templates/workflows/shared/auto-release.yml.template
-# VERSION: 04.06.00
-# BRIEF: Generic build & release pipeline — version branch, platform version, badges, tag, release
-#
-# +========================================================================+
-# | BUILD & RELEASE PIPELINE |
-# +========================================================================+
-# | |
-# | Triggers on push to main (skips bot commits + [skip ci]): |
-# | |
-# | Every push: |
-# | 1. Read version from README.md |
-# | 3. Set platform version |
-# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
-# | 6. Create git tag vXX.YY.ZZ |
-# | 7a. Patch: update existing GitHub Release for this minor |
-# | |
-# | Every version change: archives main -> version/XX.YY branch |
-# | Patch 00 = development (no release). First release = patch 01. |
-# | First release only (patch == 01): |
-# | 7b. Create new GitHub Release |
-# | |
-# +========================================================================+
-
-name: Build & Release
-
-on:
- push:
- branches:
- - main
- - master
- paths:
- - 'src/**'
- - 'htdocs/**'
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-permissions:
- contents: write
-
-jobs:
- release:
- name: Build & Release Pipeline
- runs-on: ubuntu-latest
- if: >-
- !contains(github.event.head_commit.message, '[skip ci]') &&
- github.actor != 'github-actions[bot]'
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- token: ${{ secrets.GH_TOKEN || github.token }}
- fetch-depth: 0
-
- - name: Setup MokoStandards tools
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
- run: |
- git clone --depth 1 --branch version/04 --quiet \
- "https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
- /tmp/mokostandards
- cd /tmp/mokostandards
- composer install --no-dev --no-interaction --quiet
-
- # -- STEP 1: Read version -----------------------------------------------
- - name: "Step 1: Read version from README.md"
- id: version
- run: |
- VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null)
- if [ -z "$VERSION" ]; then
- echo "No VERSION in README.md — skipping release"
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- # Derive major.minor for branch naming (patches update existing branch)
- MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
- PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
-
- MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
- MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
-
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
- echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
- echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
- echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
- if [ "$PATCH" = "00" ]; then
- echo "skip=true" >> "$GITHUB_OUTPUT"
- echo "is_minor=false" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION (patch 00 = development — skipping release)"
- else
- echo "skip=false" >> "$GITHUB_OUTPUT"
- if [ "$PATCH" = "01" ]; then
- echo "is_minor=true" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION (first release — full pipeline)"
- else
- echo "is_minor=false" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION (patch — platform version + badges only)"
- fi
- fi
-
- - name: Check if already released
- if: steps.version.outputs.skip != 'true'
- id: check
- run: |
- TAG="${{ steps.version.outputs.release_tag }}"
- BRANCH="${{ steps.version.outputs.branch }}"
-
- TAG_EXISTS=false
- BRANCH_EXISTS=false
-
- git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
- git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
-
- echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
- echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
-
- if [ "$TAG_EXISTS" = "true" ] && [ "$BRANCH_EXISTS" = "true" ]; then
- echo "already_released=true" >> "$GITHUB_OUTPUT"
- else
- echo "already_released=false" >> "$GITHUB_OUTPUT"
- fi
-
- # -- SANITY CHECKS -------------------------------------------------------
- - name: "Sanity: Pre-release validation"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- ERRORS=0
-
- echo "## Pre-Release Sanity Checks" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # -- Version drift check (must pass before release) --------
- README_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP '"version"\s*:\s*"\K[^"]+' 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
-
- echo "" >> $GITHUB_STEP_SUMMARY
- if [ "$ERRORS" -gt 0 ]; then
- echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
- else
- echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- STEP 2: Create or update version/XX.YY archive branch ---------------
- # Always runs — every version change on main archives to version/XX.YY
- - name: "Step 2: Version archive branch"
- if: steps.check.outputs.already_released != 'true'
- run: |
- BRANCH="${{ steps.version.outputs.branch }}"
- IS_MINOR="${{ steps.version.outputs.is_minor }}"
- PATCH="${{ steps.version.outputs.version }}"
- PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
-
- # Check if branch exists
- if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
- git push origin HEAD:"$BRANCH" --force
- echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
- else
- git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
- git push origin "$BRANCH" --force
- echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- STEP 3: Set platform version ----------------------------------------
- - name: "Step 3: Set platform version"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- php /tmp/mokostandards/api/cli/version_set_platform.php \
- --path . --version "$VERSION" --branch main
-
- # -- STEP 4: Update version badges ----------------------------------------
- - name: "Step 4: Update version badges"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
- if grep -q '\[VERSION:' "$f" 2>/dev/null; then
- sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
- fi
- done
-
- # -- Commit all changes ---------------------------------------------------
- - name: Commit release changes
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- if git diff --quiet && git diff --cached --quiet; then
- echo "No changes to commit"
- exit 0
- fi
- VERSION="${{ steps.version.outputs.version }}"
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git add -A
- git commit -m "chore(release): build ${VERSION} [skip ci]" \
- --author="github-actions[bot] "
- git push
-
- # -- 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 GitHub Release ------------------------------
- - name: "Step 7: GitHub Release"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.tag_exists != 'true'
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- BRANCH="${{ steps.version.outputs.branch }}"
- MAJOR="${{ steps.version.outputs.major }}"
-
- NOTES=$(php /tmp/mokostandards/api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
- [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
- echo "$NOTES" > /tmp/release_notes.md
-
- # Check if the major release already exists
- EXISTING=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || true)
-
- if [ -z "$EXISTING" ]; then
- # First release for this major: create GitHub Release
- gh release create "$RELEASE_TAG" \
- --title "v${MAJOR} (latest: ${VERSION})" \
- --notes-file /tmp/release_notes.md \
- --target "$BRANCH"
- echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
- else
- # Update existing major release with new version info
- CURRENT_NOTES=$(gh release view "$RELEASE_TAG" --json body -q .body 2>/dev/null || true)
- {
- echo "$CURRENT_NOTES"
- echo ""
- echo "---"
- echo "### ${VERSION}"
- echo ""
- cat /tmp/release_notes.md
- } > /tmp/updated_notes.md
-
- gh release edit "$RELEASE_TAG" \
- --title "v${MAJOR} (latest: ${VERSION})" \
- --notes-file /tmp/updated_notes.md
- echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
- fi
-
- - name: "Step 7.5: Minify assets"
- if: >-
- steps.version.outputs.skip != 'true'
- run: |
- npm install --no-save terser clean-css 2>/dev/null || true
- MINIFY=""
- for p in "../moko-platform/build/minify.js" "scripts/minify.js"; do
- [ -f "$p" ] && MINIFY="$p" && break
- done
- if [ -n "$MINIFY" ]; then
- node "$MINIFY" src
- else
- echo "No minify script found — skipping"
- fi
-
- # -- Summary --------------------------------------------------------------
- - name: Pipeline Summary
- if: always()
- run: |
- VERSION="${{ steps.version.outputs.version }}"
- if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
- echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
- echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
- elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
- echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
- else
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "## Build & Release Complete" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
- echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
- echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Release | [View](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
- fi
diff --git a/.gitea/workflows/deploy-demo.yml b/.gitea/workflows/deploy-demo.yml
deleted file mode 100644
index 206d178..0000000
--- a/.gitea/workflows/deploy-demo.yml
+++ /dev/null
@@ -1,734 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# This file is part of a Moko Consulting project.
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-#
-# FILE INFORMATION
-# DEFGROUP: GitHub.Workflow
-# INGROUP: MokoStandards.Deploy
-# REPO: https://github.com/mokoconsulting-tech/MokoStandards
-# PATH: /templates/workflows/shared/deploy-demo.yml.template
-# VERSION: 04.06.00
-# BRIEF: SFTP deployment workflow for demo server — synced to all governed repos
-# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-demo.yml in all governed repos.
-# Port is resolved in order: DEMO_FTP_PORT variable → :port suffix in DEMO_FTP_HOST → 22.
-
-name: Deploy to Demo Server (SFTP)
-
-# Deploys the contents of the src/ directory to the demo server via SFTP.
-# Triggers on push/merge to main — deploys the production-ready build to the demo server.
-#
-# Required org-level variables: DEMO_FTP_HOST, DEMO_FTP_PATH, DEMO_FTP_USERNAME
-# Optional org-level variable: DEMO_FTP_PORT (auto-detected from host or defaults to 22)
-# Optional org/repo variable: DEMO_FTP_SUFFIX — when set, appended to DEMO_FTP_PATH to form the
-# full remote destination: DEMO_FTP_PATH/DEMO_FTP_SUFFIX
-# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
-# non-comment line is a glob pattern tested against the relative path
-# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
-# Required org-level secret: DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD
-#
-# Access control: only users with admin or maintain role on the repository may deploy.
-
-on:
- push:
- branches:
- - main
- - master
- paths:
- - 'src/**'
- - 'htdocs/**'
- pull_request:
- types: [opened, synchronize, reopened, closed]
- branches:
- - main
- - master
- paths:
- - 'src/**'
- - 'htdocs/**'
- workflow_dispatch:
- inputs:
- clear_remote:
- description: 'Delete all files inside the remote destination folder before uploading'
- required: false
- default: false
- type: boolean
-
-permissions:
- contents: read
- pull-requests: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- check-permission:
- name: Verify Deployment Permission
- runs-on: ubuntu-latest
- steps:
- - name: Check actor permission
- env:
- # Prefer the org-scoped GH_TOKEN secret (needed for the org membership
- # fallback). Falls back to the built-in github.token so the collaborator
- # endpoint still works even if GH_TOKEN is not configured.
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- run: |
- ACTOR="${{ github.actor }}"
- REPO="${{ github.repository }}"
- ORG="${{ github.repository_owner }}"
-
- METHOD=""
- AUTHORIZED="false"
-
- # Hardcoded authorized users — always allowed to deploy
- AUTHORIZED_USERS="jmiller github-actions[bot]"
- for user in $AUTHORIZED_USERS; do
- if [ "$ACTOR" = "$user" ]; then
- AUTHORIZED="true"
- METHOD="hardcoded allowlist"
- PERMISSION="admin"
- break
- fi
- done
-
- # For other actors, check repo/org permissions via API
- if [ "$AUTHORIZED" != "true" ]; then
- PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
- --jq '.permission' 2>/dev/null)
- METHOD="repo collaborator API"
-
- if [ -z "$PERMISSION" ]; then
- ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
- --jq '.role' 2>/dev/null)
- METHOD="org membership API"
- if [ "$ORG_ROLE" = "owner" ]; then
- PERMISSION="admin"
- else
- PERMISSION="none"
- fi
- fi
-
- case "$PERMISSION" in
- admin|maintain) AUTHORIZED="true" ;;
- esac
- fi
-
- # Write detailed summary
- {
- echo "## 🔐 Deploy Authorization"
- echo ""
- echo "| Field | Value |"
- echo "|-------|-------|"
- echo "| **Actor** | \`${ACTOR}\` |"
- echo "| **Repository** | \`${REPO}\` |"
- echo "| **Permission** | \`${PERMISSION}\` |"
- echo "| **Method** | ${METHOD} |"
- echo "| **Authorized** | ${AUTHORIZED} |"
- echo "| **Trigger** | \`${{ github.event_name }}\` |"
- echo "| **Branch** | \`${{ github.ref_name }}\` |"
- echo ""
- } >> "$GITHUB_STEP_SUMMARY"
-
- if [ "$AUTHORIZED" = "true" ]; then
- echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
- else
- echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
- echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
- echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
- exit 1
- fi
-
- deploy:
- name: SFTP Deploy → Demo
- runs-on: ubuntu-latest
- needs: [check-permission]
- if: >-
- !startsWith(github.head_ref || github.ref_name, 'chore/') &&
- (github.event_name == 'workflow_dispatch' ||
- github.event_name == 'push' ||
- (github.event_name == 'pull_request' &&
- (github.event.action == 'opened' ||
- github.event.action == 'synchronize' ||
- github.event.action == 'reopened' ||
- github.event.pull_request.merged == true)))
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Resolve source directory
- id: source
- run: |
- # Resolve source directory: src/ preferred, htdocs/ as fallback
- if [ -d "src" ]; then
- SRC="src"
- elif [ -d "htdocs" ]; then
- SRC="htdocs"
- else
- echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- COUNT=$(find "$SRC" -type f | wc -l)
- echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
- echo "skip=false" >> "$GITHUB_OUTPUT"
- echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
-
- - name: Preview files to deploy
- if: steps.source.outputs.skip == 'false'
- env:
- SOURCE_DIR: ${{ steps.source.outputs.dir }}
- run: |
- # ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
- ftpignore_to_regex() {
- local line="$1"
- local anchored=false
- # Strip inline comments and whitespace
- line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
- [ -z "$line" ] && return
- # Skip negation patterns (not supported)
- [[ "$line" == !* ]] && return
- # Trailing slash = directory marker; strip it
- line="${line%/}"
- # Leading slash = anchored to root; strip it
- if [[ "$line" == /* ]]; then
- anchored=true
- line="${line#/}"
- fi
- # Escape ERE special chars, then restore glob semantics
- local regex
- regex=$(printf '%s' "$line" \
- | sed 's/[.+^${}()|[\\]/\\&/g' \
- | sed 's/\\\*\\\*/\x01/g' \
- | sed 's/\\\*/[^\/]*/g' \
- | sed 's/\x01/.*/g' \
- | sed 's/\\\?/[^\/]/g')
- if $anchored; then
- printf '^%s(/|$)' "$regex"
- else
- printf '(^|/)%s(/|$)' "$regex"
- fi
- }
-
- # ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
- IGNORE_PATTERNS=()
- IGNORE_SOURCES=()
- if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
- while IFS= read -r line; do
- [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
- regex=$(ftpignore_to_regex "$line")
- [ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
- done < "${SOURCE_DIR}/.ftpignore"
- fi
-
- # ── Walk src/ and classify every file ────────────────────────────────
- WILL_UPLOAD=()
- IGNORED_FILES=()
- while IFS= read -r -d '' file; do
- rel="${file#${SOURCE_DIR}/}"
- SKIP=false
- for i in "${!IGNORE_PATTERNS[@]}"; do
- if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
- IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
- SKIP=true; break
- fi
- done
- $SKIP && continue
- WILL_UPLOAD+=("$rel")
- done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
-
- UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
- IGNORE_COUNT="${#IGNORED_FILES[@]}"
-
- echo "ℹ️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
-
- # ── Write deployment preview to step summary ──────────────────────────
- {
- echo "## 📋 Deployment Preview"
- echo ""
- echo "| Field | Value |"
- echo "|---|---|"
- echo "| Source | \`${SOURCE_DIR}/\` |"
- echo "| Files to upload | **${UPLOAD_COUNT}** |"
- echo "| Files ignored | **${IGNORE_COUNT}** |"
- echo ""
- if [ "${UPLOAD_COUNT}" -gt 0 ]; then
- echo "### 📂 Files that will be uploaded"
- echo '```'
- printf '%s\n' "${WILL_UPLOAD[@]}"
- echo '```'
- echo ""
- fi
- if [ "${IGNORE_COUNT}" -gt 0 ]; then
- echo "### ⏭️ Files excluded"
- echo "| File | Reason |"
- echo "|---|---|"
- for entry in "${IGNORED_FILES[@]}"; do
- f="${entry% | *}"; r="${entry##* | }"
- echo "| \`${f}\` | ${r} |"
- done
- echo ""
- fi
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Resolve SFTP host and port
- if: steps.source.outputs.skip == 'false'
- id: conn
- env:
- HOST_RAW: ${{ vars.DEMO_FTP_HOST }}
- PORT_VAR: ${{ vars.DEMO_FTP_PORT }}
- run: |
- HOST="$HOST_RAW"
- PORT="$PORT_VAR"
-
- if [ -z "$HOST" ]; then
- echo "⏭️ DEMO_FTP_HOST not configured — skipping demo deployment."
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- # Priority 1 — explicit DEMO_FTP_PORT variable
- if [ -n "$PORT" ]; then
- echo "ℹ️ Using explicit DEMO_FTP_PORT=${PORT}"
-
- # Priority 2 — port embedded in DEMO_FTP_HOST (host:port)
- elif [[ "$HOST" == *:* ]]; then
- PORT="${HOST##*:}"
- HOST="${HOST%:*}"
- echo "ℹ️ Extracted port ${PORT} from DEMO_FTP_HOST"
-
- # Priority 3 — SFTP default
- else
- PORT="22"
- echo "ℹ️ No port specified — defaulting to SFTP port 22"
- fi
-
- echo "host=${HOST}" >> "$GITHUB_OUTPUT"
- echo "port=${PORT}" >> "$GITHUB_OUTPUT"
- echo "SFTP target: ${HOST}:${PORT}"
-
- - name: Build remote path
- if: steps.source.outputs.skip == 'false' && steps.conn.outputs.skip != 'true'
- id: remote
- env:
- DEMO_FTP_PATH: ${{ vars.DEMO_FTP_PATH }}
- DEMO_FTP_SUFFIX: ${{ vars.DEMO_FTP_SUFFIX }}
- run: |
- BASE="$DEMO_FTP_PATH"
-
- if [ -z "$BASE" ]; then
- echo "⏭️ DEMO_FTP_PATH not configured — skipping demo deployment."
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- # DEMO_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
- # Without it we cannot safely determine the deployment target.
- if [ -z "$DEMO_FTP_SUFFIX" ]; then
- echo "⏭️ DEMO_FTP_SUFFIX variable is not set — skipping deployment."
- echo " Set DEMO_FTP_SUFFIX as a repo or org variable to enable deploy-demo."
- echo "skip=true" >> "$GITHUB_OUTPUT"
- echo "path=" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- REMOTE="${BASE%/}/${DEMO_FTP_SUFFIX#/}"
-
- # ── Platform-specific path safety guards ──────────────────────────────
- PLATFORM=""
- MOKO_FILE=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
- PLATFORM=$(grep -E '^platform:' "$MOKO_FILE" | sed 's/.*:[[:space:]]*//' | tr -d '"')
- fi
-
- if [ "$PLATFORM" = "crm-module" ]; then
- # Dolibarr modules must deploy under htdocs/custom/ — guard against
- # accidentally overwriting server root or unrelated directories.
- if [[ "$REMOTE" != *custom* ]]; then
- echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
- echo " Current path: ${REMOTE}"
- echo " Set DEMO_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
- exit 1
- fi
- fi
-
- if [ "$PLATFORM" = "waas-component" ]; then
- # Joomla extensions may only deploy to the server's tmp/ directory.
- if [[ "$REMOTE" != *tmp* ]]; then
- echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
- echo " Current path: ${REMOTE}"
- echo " Set DEMO_FTP_SUFFIX to a path under the server tmp/ directory."
- exit 1
- fi
- fi
-
- echo "ℹ️ Remote path: ${REMOTE}"
- echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
-
- - name: Detect SFTP authentication method
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- id: auth
- env:
- HAS_KEY: ${{ secrets.DEMO_FTP_KEY }}
- HAS_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
- run: |
- if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
- # Both set: key auth with password as passphrase; falls back to password-only if key fails
- echo "method=key" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
- echo "has_password=true" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Primary: SSH key + passphrase (DEMO_FTP_KEY / DEMO_FTP_PASSWORD)"
- echo "ℹ️ Fallback: password-only auth if key authentication fails"
- elif [ -n "$HAS_KEY" ]; then
- # Key only: no passphrase, no password fallback
- echo "method=key" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
- echo "has_password=false" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Using SSH key authentication (DEMO_FTP_KEY, no passphrase, no fallback)"
- elif [ -n "$HAS_PASSWORD" ]; then
- # Password only: direct SFTP password auth
- echo "method=password" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
- echo "has_password=true" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Using password authentication (DEMO_FTP_PASSWORD)"
- else
- echo "❌ No SFTP credentials configured."
- echo " Set DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD as an org-level secret."
- exit 1
- fi
-
- - name: Setup PHP
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
- with:
- php-version: '8.1'
- tools: composer
-
- - name: Setup MokoStandards deploy tools
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
- run: |
- git clone --depth 1 --branch version/04 --quiet \
- "https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
- /tmp/mokostandards
- cd /tmp/mokostandards
- composer install --no-dev --no-interaction --quiet
-
- - name: Clear remote destination folder (manual only)
- if: >-
- steps.source.outputs.skip == 'false' &&
- steps.remote.outputs.skip != 'true' &&
- inputs.clear_remote == true
- env:
- SFTP_HOST: ${{ steps.conn.outputs.host }}
- SFTP_PORT: ${{ steps.conn.outputs.port }}
- SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
- SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
- SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
- AUTH_METHOD: ${{ steps.auth.outputs.method }}
- USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
- HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
- REMOTE_PATH: ${{ steps.remote.outputs.path }}
- run: |
- cat > /tmp/moko_clear.php << 'PHPEOF'
- login($username, $key)) {
- if ($password !== '') {
- echo "⚠️ Key auth failed — falling back to password\n";
- if (!$sftp->login($username, $password)) {
- fwrite(STDERR, "❌ Both key and password authentication failed\n");
- exit(1);
- }
- echo "✅ Connected via password authentication (key fallback)\n";
- } else {
- fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
- exit(1);
- }
- } else {
- echo "✅ Connected via SSH key authentication\n";
- }
- } else {
- if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
- fwrite(STDERR, "❌ Password authentication failed\n");
- exit(1);
- }
- echo "✅ Connected via password authentication\n";
- }
-
- // ── Recursive delete ────────────────────────────────────────────
- function rmrf(SFTP $sftp, string $path): void
- {
- $entries = $sftp->nlist($path);
- if ($entries === false) {
- return; // path does not exist — nothing to clear
- }
- foreach ($entries as $name) {
- if ($name === '.' || $name === '..') {
- continue;
- }
- $entry = "{$path}/{$name}";
- if ($sftp->is_dir($entry)) {
- rmrf($sftp, $entry);
- $sftp->rmdir($entry);
- echo " 🗑️ Removed dir: {$entry}\n";
- } else {
- $sftp->delete($entry);
- echo " 🗑️ Removed file: {$entry}\n";
- }
- }
- }
-
- // ── Create remote directory tree ────────────────────────────────
- function sftpMakedirs(SFTP $sftp, string $path): void
- {
- $parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
- $current = str_starts_with($path, '/') ? '' : '';
- foreach ($parts as $part) {
- $current .= '/' . $part;
- $sftp->mkdir($current); // silently returns false if already exists
- }
- }
-
- rmrf($sftp, $remotePath);
- sftpMakedirs($sftp, $remotePath);
- echo "✅ Remote folder ready: {$remotePath}\n";
- PHPEOF
- php /tmp/moko_clear.php
-
- - name: Deploy via SFTP
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- env:
- SFTP_HOST: ${{ steps.conn.outputs.host }}
- SFTP_PORT: ${{ steps.conn.outputs.port }}
- SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
- SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
- SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
- AUTH_METHOD: ${{ steps.auth.outputs.method }}
- USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
- REMOTE_PATH: ${{ steps.remote.outputs.path }}
- SOURCE_DIR: ${{ steps.source.outputs.dir }}
- run: |
- # ── Write SSH key to temp file (key auth only) ────────────────────────
- if [ "$AUTH_METHOD" = "key" ]; then
- printf '%s' "$SFTP_KEY" > /tmp/deploy_key
- chmod 600 /tmp/deploy_key
- fi
-
- # ── Generate sftp-config.json safely via jq ───────────────────────────
- if [ "$AUTH_METHOD" = "key" ]; then
- jq -n \
- --arg host "$SFTP_HOST" \
- --argjson port "${SFTP_PORT:-22}" \
- --arg user "$SFTP_USER" \
- --arg path "$REMOTE_PATH" \
- --arg key "/tmp/deploy_key" \
- '{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
- > /tmp/sftp-config.json
- else
- jq -n \
- --arg host "$SFTP_HOST" \
- --argjson port "${SFTP_PORT:-22}" \
- --arg user "$SFTP_USER" \
- --arg path "$REMOTE_PATH" \
- --arg pass "$SFTP_PASSWORD" \
- '{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
- > /tmp/sftp-config.json
- fi
-
- # ── Write update files (demo = stable) ─────────────────────────────
- PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
- VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "unknown")
- REPO="${{ github.repository }}"
-
- if [ "$PLATFORM" = "crm-module" ]; then
- printf '%s' "$VERSION" > update.txt
- fi
-
- if [ "$PLATFORM" = "waas-component" ]; then
- MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
- if [ -n "$MANIFEST" ]; then
- EXT_NAME=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
- EXT_TYPE=$(grep -oP ']+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
- EXT_ELEMENT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
- EXT_CLIENT=$(grep -oP ']+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
- EXT_FOLDER=$(grep -oP ']+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
- TARGET_PLATFORM=$(grep -oP '/dev/null | head -1 || true)
- [ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
- [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/")
-
- CLIENT_TAG=""
- if [ -n "$EXT_CLIENT" ]; then CLIENT_TAG="${EXT_CLIENT}"; elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then CLIENT_TAG="site"; fi
- FOLDER_TAG=""
- if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then FOLDER_TAG="${EXT_FOLDER}"; fi
-
- DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
- {
- printf '%s\n' ''
- printf '%s\n' ''
- 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' ' '
- printf '%s\n' ' stable'
- printf '%s\n' ' '
- printf '%s\n' " https://github.com/${REPO}"
- printf '%s\n' ' '
- printf '%s\n' " ${DOWNLOAD_URL}"
- printf '%s\n' ' '
- printf '%s\n' " ${TARGET_PLATFORM}"
- printf '%s\n' ' Moko Consulting'
- printf '%s\n' ' https://mokoconsulting.tech'
- printf '%s\n' ' '
- printf '%s\n' ''
- } > updates.xml
- fi
- fi
-
- # ── Run deploy-sftp.php from MokoStandards ────────────────────────────
- DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
- if [ "$USE_PASSPHRASE" = "true" ]; then
- DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
- fi
-
- PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
- if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards/api/deploy/deploy-joomla.php" ]; then
- php /tmp/mokostandards/api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
- else
- php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
- fi
- # Remove temp files that should never be left behind
- rm -f /tmp/deploy_key /tmp/sftp-config.json
-
- - name: Create or update failure issue
- if: failure() && steps.remote.outputs.skip != 'true' && steps.conn.outputs.skip != 'true'
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- run: |
- REPO="${{ github.repository }}"
- RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}"
- ACTOR="${{ github.actor }}"
- BRANCH="${{ github.ref_name }}"
- EVENT="${{ github.event_name }}"
- NOW=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
- LABEL="deploy-failure"
-
- TITLE="fix: Demo deployment failed — ${REPO}"
- BODY="## Demo Deployment Failed
-
- A deployment to the demo server failed and requires attention.
-
- | Field | Value |
- |-------|-------|
- | **Repository** | \`${REPO}\` |
- | **Branch** | \`${BRANCH}\` |
- | **Trigger** | ${EVENT} |
- | **Actor** | @${ACTOR} |
- | **Failed at** | ${NOW} |
- | **Run** | [View workflow run](${RUN_URL}) |
-
- ### Next steps
- 1. Review the [workflow run log](${RUN_URL}) for the specific error.
- 2. Fix the underlying issue (credentials, SFTP connectivity, permissions).
- 3. Re-trigger the deployment via **Actions → Deploy to Demo Server → Run workflow**.
-
- ---
- *Auto-created by deploy-demo.yml — close this issue once the deployment is resolved.*"
-
- # Ensure the label exists (idempotent — no-op if already present)
- gh label create "$LABEL" \
- --repo "$REPO" \
- --color "CC0000" \
- --description "Automated deploy failure tracking" \
- --force 2>/dev/null || true
-
- # Look for an existing open deploy-failure issue
- EXISTING=$(gh api "repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" \
- --jq '.[0].number' 2>/dev/null)
-
- if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
- gh api "repos/${REPO}/issues/${EXISTING}" \
- -X PATCH \
- -f title="$TITLE" \
- -f body="$BODY" \
- -f state="open" \
- --silent
- echo "📋 Failure issue #${EXISTING} updated/reopened: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
- else
- gh issue create \
- --repo "$REPO" \
- --title "$TITLE" \
- --body "$BODY" \
- --label "$LABEL" \
- --assignee "jmiller" \
- | tee -a "$GITHUB_STEP_SUMMARY"
- fi
-
- - name: Deployment summary
- if: always()
- run: |
- if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
- echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
- elif [ "${{ job.status }}" == "success" ]; then
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "### ✅ Demo Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
- echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
- echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
- else
- echo "### ❌ Demo Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
- fi
diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml
deleted file mode 100644
index 1814ea0..0000000
--- a/.gitea/workflows/deploy-dev.yml
+++ /dev/null
@@ -1,700 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# This file is part of a Moko Consulting project.
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-#
-# FILE INFORMATION
-# DEFGROUP: GitHub.Workflow
-# INGROUP: MokoStandards.Deploy
-# REPO: https://github.com/mokoconsulting-tech/MokoStandards
-# PATH: /templates/workflows/shared/deploy-dev.yml.template
-# VERSION: 04.06.00
-# BRIEF: SFTP deployment workflow for development server — synced to all governed repos
-# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-dev.yml in all governed repos.
-# Port is resolved in order: DEV_FTP_PORT variable → :port suffix in DEV_FTP_HOST → 22.
-
-name: Deploy to Dev Server (SFTP)
-
-# Deploys the contents of the src/ directory to the development server via SFTP.
-# Triggers on every pull_request to development branches (so the dev server always
-# reflects the latest PR state) and on push/merge to main branches.
-#
-# Required org-level variables: DEV_FTP_HOST, DEV_FTP_PATH, DEV_FTP_USERNAME
-# Optional org-level variable: DEV_FTP_PORT (auto-detected from host or defaults to 22)
-# Optional org/repo variable: DEV_FTP_SUFFIX — when set, appended to DEV_FTP_PATH to form the
-# full remote destination: DEV_FTP_PATH/DEV_FTP_SUFFIX
-# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
-# non-comment line is a glob pattern tested against the relative path
-# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
-# Required org-level secret: DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD
-#
-# Access control: only users with admin or maintain role on the repository may deploy.
-
-on:
- push:
- branches:
- - 'dev/**'
- - 'rc/**'
- - develop
- - development
- paths:
- - 'src/**'
- - 'htdocs/**'
- pull_request:
- types: [opened, synchronize, reopened, closed]
- branches:
- - 'dev/**'
- - 'rc/**'
- - develop
- - development
- paths:
- - 'src/**'
- - 'htdocs/**'
- workflow_dispatch:
- inputs:
- clear_remote:
- description: 'Delete all files inside the remote destination folder before uploading'
- required: false
- default: false
- type: boolean
-
-permissions:
- contents: read
- pull-requests: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- check-permission:
- name: Verify Deployment Permission
- runs-on: ubuntu-latest
- steps:
- - name: Check actor permission
- env:
- # Prefer the org-scoped GH_TOKEN secret (needed for the org membership
- # fallback). Falls back to the built-in github.token so the collaborator
- # endpoint still works even if GH_TOKEN is not configured.
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- run: |
- ACTOR="${{ github.actor }}"
- REPO="${{ github.repository }}"
- ORG="${{ github.repository_owner }}"
-
- METHOD=""
- AUTHORIZED="false"
-
- # Hardcoded authorized users — always allowed to deploy
- AUTHORIZED_USERS="jmiller github-actions[bot]"
- for user in $AUTHORIZED_USERS; do
- if [ "$ACTOR" = "$user" ]; then
- AUTHORIZED="true"
- METHOD="hardcoded allowlist"
- PERMISSION="admin"
- break
- fi
- done
-
- # For other actors, check repo/org permissions via API
- if [ "$AUTHORIZED" != "true" ]; then
- PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
- --jq '.permission' 2>/dev/null)
- METHOD="repo collaborator API"
-
- if [ -z "$PERMISSION" ]; then
- ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
- --jq '.role' 2>/dev/null)
- METHOD="org membership API"
- if [ "$ORG_ROLE" = "owner" ]; then
- PERMISSION="admin"
- else
- PERMISSION="none"
- fi
- fi
-
- case "$PERMISSION" in
- admin|maintain) AUTHORIZED="true" ;;
- esac
- fi
-
- # Write detailed summary
- {
- echo "## 🔐 Deploy Authorization"
- echo ""
- echo "| Field | Value |"
- echo "|-------|-------|"
- echo "| **Actor** | \`${ACTOR}\` |"
- echo "| **Repository** | \`${REPO}\` |"
- echo "| **Permission** | \`${PERMISSION}\` |"
- echo "| **Method** | ${METHOD} |"
- echo "| **Authorized** | ${AUTHORIZED} |"
- echo "| **Trigger** | \`${{ github.event_name }}\` |"
- echo "| **Branch** | \`${{ github.ref_name }}\` |"
- echo ""
- } >> "$GITHUB_STEP_SUMMARY"
-
- if [ "$AUTHORIZED" = "true" ]; then
- echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
- else
- echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
- echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
- echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
- exit 1
- fi
-
- deploy:
- name: SFTP Deploy → Dev
- runs-on: ubuntu-latest
- needs: [check-permission]
- if: >-
- !startsWith(github.head_ref || github.ref_name, 'chore/') &&
- (github.event_name == 'workflow_dispatch' ||
- github.event_name == 'push' ||
- (github.event_name == 'pull_request' &&
- (github.event.action == 'opened' ||
- github.event.action == 'synchronize' ||
- github.event.action == 'reopened' ||
- github.event.pull_request.merged == true)))
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Resolve source directory
- id: source
- run: |
- # Resolve source directory: src/ preferred, htdocs/ as fallback
- if [ -d "src" ]; then
- SRC="src"
- elif [ -d "htdocs" ]; then
- SRC="htdocs"
- else
- echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- COUNT=$(find "$SRC" -type f | wc -l)
- echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
- echo "skip=false" >> "$GITHUB_OUTPUT"
- echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
-
- - name: Preview files to deploy
- if: steps.source.outputs.skip == 'false'
- env:
- SOURCE_DIR: ${{ steps.source.outputs.dir }}
- run: |
- # ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
- ftpignore_to_regex() {
- local line="$1"
- local anchored=false
- # Strip inline comments and whitespace
- line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
- [ -z "$line" ] && return
- # Skip negation patterns (not supported)
- [[ "$line" == !* ]] && return
- # Trailing slash = directory marker; strip it
- line="${line%/}"
- # Leading slash = anchored to root; strip it
- if [[ "$line" == /* ]]; then
- anchored=true
- line="${line#/}"
- fi
- # Escape ERE special chars, then restore glob semantics
- local regex
- regex=$(printf '%s' "$line" \
- | sed 's/[.+^${}()|[\\]/\\&/g' \
- | sed 's/\\\*\\\*/\x01/g' \
- | sed 's/\\\*/[^\/]*/g' \
- | sed 's/\x01/.*/g' \
- | sed 's/\\\?/[^\/]/g')
- if $anchored; then
- printf '^%s(/|$)' "$regex"
- else
- printf '(^|/)%s(/|$)' "$regex"
- fi
- }
-
- # ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
- IGNORE_PATTERNS=()
- IGNORE_SOURCES=()
- if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
- while IFS= read -r line; do
- [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
- regex=$(ftpignore_to_regex "$line")
- [ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
- done < "${SOURCE_DIR}/.ftpignore"
- fi
-
- # ── Walk src/ and classify every file ────────────────────────────────
- WILL_UPLOAD=()
- IGNORED_FILES=()
- while IFS= read -r -d '' file; do
- rel="${file#${SOURCE_DIR}/}"
- SKIP=false
- for i in "${!IGNORE_PATTERNS[@]}"; do
- if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
- IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
- SKIP=true; break
- fi
- done
- $SKIP && continue
- WILL_UPLOAD+=("$rel")
- done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
-
- UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
- IGNORE_COUNT="${#IGNORED_FILES[@]}"
-
- echo "ℹ️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
-
- # ── Write deployment preview to step summary ──────────────────────────
- {
- echo "## 📋 Deployment Preview"
- echo ""
- echo "| Field | Value |"
- echo "|---|---|"
- echo "| Source | \`${SOURCE_DIR}/\` |"
- echo "| Files to upload | **${UPLOAD_COUNT}** |"
- echo "| Files ignored | **${IGNORE_COUNT}** |"
- echo ""
- if [ "${UPLOAD_COUNT}" -gt 0 ]; then
- echo "### 📂 Files that will be uploaded"
- echo '```'
- printf '%s\n' "${WILL_UPLOAD[@]}"
- echo '```'
- echo ""
- fi
- if [ "${IGNORE_COUNT}" -gt 0 ]; then
- echo "### ⏭️ Files excluded"
- echo "| File | Reason |"
- echo "|---|---|"
- for entry in "${IGNORED_FILES[@]}"; do
- f="${entry% | *}"; r="${entry##* | }"
- echo "| \`${f}\` | ${r} |"
- done
- echo ""
- fi
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Resolve SFTP host and port
- if: steps.source.outputs.skip == 'false'
- id: conn
- env:
- HOST_RAW: ${{ vars.DEV_FTP_HOST }}
- PORT_VAR: ${{ vars.DEV_FTP_PORT }}
- run: |
- HOST="$HOST_RAW"
- PORT="$PORT_VAR"
-
- # Priority 1 — explicit DEV_FTP_PORT variable
- if [ -n "$PORT" ]; then
- echo "ℹ️ Using explicit DEV_FTP_PORT=${PORT}"
-
- # Priority 2 — port embedded in DEV_FTP_HOST (host:port)
- elif [[ "$HOST" == *:* ]]; then
- PORT="${HOST##*:}"
- HOST="${HOST%:*}"
- echo "ℹ️ Extracted port ${PORT} from DEV_FTP_HOST"
-
- # Priority 3 — SFTP default
- else
- PORT="22"
- echo "ℹ️ No port specified — defaulting to SFTP port 22"
- fi
-
- echo "host=${HOST}" >> "$GITHUB_OUTPUT"
- echo "port=${PORT}" >> "$GITHUB_OUTPUT"
- echo "SFTP target: ${HOST}:${PORT}"
-
- - name: Build remote path
- if: steps.source.outputs.skip == 'false'
- id: remote
- env:
- DEV_FTP_PATH: ${{ vars.DEV_FTP_PATH }}
- DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
- run: |
- BASE="$DEV_FTP_PATH"
-
- if [ -z "$BASE" ]; then
- echo "❌ DEV_FTP_PATH is not set."
- echo " Configure it as an org-level variable (Settings → Variables) and"
- echo " ensure this repository has been granted access to it."
- exit 1
- fi
-
- # DEV_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
- # Without it we cannot safely determine the deployment target.
- if [ -z "$DEV_FTP_SUFFIX" ]; then
- echo "⏭️ DEV_FTP_SUFFIX variable is not set — skipping deployment."
- echo " Set DEV_FTP_SUFFIX as a repo or org variable to enable deploy-dev."
- echo "skip=true" >> "$GITHUB_OUTPUT"
- echo "path=" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- REMOTE="${BASE%/}/${DEV_FTP_SUFFIX#/}"
-
- # ── Platform-specific path safety guards ──────────────────────────────
- PLATFORM=""
- MOKO_FILE=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
- PLATFORM=$(grep -oP '^platform:.*' "$MOKO_FILE" 2>/dev/null || true)
- fi
-
- if [ "$PLATFORM" = "crm-module" ]; then
- # Dolibarr modules must deploy under htdocs/custom/ — guard against
- # accidentally overwriting server root or unrelated directories.
- if [[ "$REMOTE" != *custom* ]]; then
- echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
- echo " Current path: ${REMOTE}"
- echo " Set DEV_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
- exit 1
- fi
- fi
-
- if [ "$PLATFORM" = "waas-component" ]; then
- # Joomla extensions may only deploy to the server's tmp/ directory.
- if [[ "$REMOTE" != *tmp* ]]; then
- echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
- echo " Current path: ${REMOTE}"
- echo " Set DEV_FTP_SUFFIX to a path under the server tmp/ directory."
- exit 1
- fi
- fi
-
- echo "ℹ️ Remote path: ${REMOTE}"
- echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
-
- - name: Detect SFTP authentication method
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- id: auth
- env:
- HAS_KEY: ${{ secrets.DEV_FTP_KEY }}
- HAS_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
- run: |
- if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
- # Both set: key auth with password as passphrase; falls back to password-only if key fails
- echo "method=key" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
- echo "has_password=true" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Primary: SSH key + passphrase (DEV_FTP_KEY / DEV_FTP_PASSWORD)"
- echo "ℹ️ Fallback: password-only auth if key authentication fails"
- elif [ -n "$HAS_KEY" ]; then
- # Key only: no passphrase, no password fallback
- echo "method=key" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
- echo "has_password=false" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Using SSH key authentication (DEV_FTP_KEY, no passphrase, no fallback)"
- elif [ -n "$HAS_PASSWORD" ]; then
- # Password only: direct SFTP password auth
- echo "method=password" >> "$GITHUB_OUTPUT"
- echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
- echo "has_password=true" >> "$GITHUB_OUTPUT"
- echo "ℹ️ Using password authentication (DEV_FTP_PASSWORD)"
- else
- echo "❌ No SFTP credentials configured."
- echo " Set DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD as an org-level secret."
- exit 1
- fi
-
- - name: Setup PHP
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
- with:
- php-version: '8.1'
- tools: composer
-
- - name: Setup MokoStandards deploy tools
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
- COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
- run: |
- git clone --depth 1 --branch version/04 --quiet \
- "https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
- /tmp/mokostandards
- cd /tmp/mokostandards
- composer install --no-dev --no-interaction --quiet
-
- - name: Clear remote destination folder (manual only)
- if: >-
- steps.source.outputs.skip == 'false' &&
- steps.remote.outputs.skip != 'true' &&
- inputs.clear_remote == true
- env:
- SFTP_HOST: ${{ steps.conn.outputs.host }}
- SFTP_PORT: ${{ steps.conn.outputs.port }}
- SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
- SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
- SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
- AUTH_METHOD: ${{ steps.auth.outputs.method }}
- USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
- HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
- REMOTE_PATH: ${{ steps.remote.outputs.path }}
- run: |
- cat > /tmp/moko_clear.php << 'PHPEOF'
- login($username, $key)) {
- if ($password !== '') {
- echo "⚠️ Key auth failed — falling back to password\n";
- if (!$sftp->login($username, $password)) {
- fwrite(STDERR, "❌ Both key and password authentication failed\n");
- exit(1);
- }
- echo "✅ Connected via password authentication (key fallback)\n";
- } else {
- fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
- exit(1);
- }
- } else {
- echo "✅ Connected via SSH key authentication\n";
- }
- } else {
- if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
- fwrite(STDERR, "❌ Password authentication failed\n");
- exit(1);
- }
- echo "✅ Connected via password authentication\n";
- }
-
- // ── Recursive delete ────────────────────────────────────────────
- function rmrf(SFTP $sftp, string $path): void
- {
- $entries = $sftp->nlist($path);
- if ($entries === false) {
- return; // path does not exist — nothing to clear
- }
- foreach ($entries as $name) {
- if ($name === '.' || $name === '..') {
- continue;
- }
- $entry = "{$path}/{$name}";
- if ($sftp->is_dir($entry)) {
- rmrf($sftp, $entry);
- $sftp->rmdir($entry);
- echo " 🗑️ Removed dir: {$entry}\n";
- } else {
- $sftp->delete($entry);
- echo " 🗑️ Removed file: {$entry}\n";
- }
- }
- }
-
- // ── Create remote directory tree ────────────────────────────────
- function sftpMakedirs(SFTP $sftp, string $path): void
- {
- $parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
- $current = str_starts_with($path, '/') ? '' : '';
- foreach ($parts as $part) {
- $current .= '/' . $part;
- $sftp->mkdir($current); // silently returns false if already exists
- }
- }
-
- rmrf($sftp, $remotePath);
- sftpMakedirs($sftp, $remotePath);
- echo "✅ Remote folder ready: {$remotePath}\n";
- PHPEOF
- php /tmp/moko_clear.php
-
- - name: Deploy via SFTP
- if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
- env:
- SFTP_HOST: ${{ steps.conn.outputs.host }}
- SFTP_PORT: ${{ steps.conn.outputs.port }}
- SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
- SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
- SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
- AUTH_METHOD: ${{ steps.auth.outputs.method }}
- USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
- REMOTE_PATH: ${{ steps.remote.outputs.path }}
- SOURCE_DIR: ${{ steps.source.outputs.dir }}
- run: |
- # ── Write SSH key to temp file (key auth only) ────────────────────────
- if [ "$AUTH_METHOD" = "key" ]; then
- printf '%s' "$SFTP_KEY" > /tmp/deploy_key
- chmod 600 /tmp/deploy_key
- fi
-
- # ── Generate sftp-config.json safely via jq ───────────────────────────
- if [ "$AUTH_METHOD" = "key" ]; then
- jq -n \
- --arg host "$SFTP_HOST" \
- --argjson port "${SFTP_PORT:-22}" \
- --arg user "$SFTP_USER" \
- --arg path "$REMOTE_PATH" \
- --arg key "/tmp/deploy_key" \
- '{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
- > /tmp/sftp-config.json
- else
- jq -n \
- --arg host "$SFTP_HOST" \
- --argjson port "${SFTP_PORT:-22}" \
- --arg user "$SFTP_USER" \
- --arg path "$REMOTE_PATH" \
- --arg pass "$SFTP_PASSWORD" \
- '{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
- > /tmp/sftp-config.json
- fi
-
- # Dev deploys skip minified files — use unminified sources for debugging
- echo "*.min.js" >> "${SOURCE_DIR}/.ftpignore"
- echo "*.min.css" >> "${SOURCE_DIR}/.ftpignore"
-
- # ── Run deploy-sftp.php from MokoStandards ────────────────────────────
- DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
- if [ "$USE_PASSPHRASE" = "true" ]; then
- DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
- fi
-
- # Set platform version to "development" before deploy (Dolibarr + Joomla)
- php /tmp/mokostandards/api/cli/version_set_platform.php --path . --version development
-
- # Write update files — dev/** = development, rc/** = rc
- PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
- REPO="${{ github.repository }}"
- BRANCH="${{ github.ref_name }}"
-
- # Determine stability tag from branch prefix
- STABILITY="development"
- VERSION_LABEL="development"
- if [[ "$BRANCH" == rc/* ]]; then
- STABILITY="rc"
- VERSION_LABEL=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "${BRANCH#rc/}")-rc
- fi
-
- if [ "$PLATFORM" = "crm-module" ]; then
- printf '%s' "$VERSION_LABEL" > update.txt
- fi
-
- if [ "$PLATFORM" = "waas-component" ]; then
- MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
- if [ -n "$MANIFEST" ]; then
- EXT_NAME=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
- EXT_TYPE=$(grep -oP ']+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
- EXT_ELEMENT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
- EXT_CLIENT=$(grep -oP ']+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
- EXT_FOLDER=$(grep -oP ']+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
- TARGET_PLATFORM=$(grep -oP '/dev/null | head -1 || true)
- [ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
- [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/")
-
- CLIENT_TAG=""
- if [ -n "$EXT_CLIENT" ]; then
- CLIENT_TAG="${EXT_CLIENT}"
- elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
- CLIENT_TAG="site"
- fi
-
- FOLDER_TAG=""
- if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
- FOLDER_TAG="${EXT_FOLDER}"
- fi
-
- DOWNLOAD_URL="https://github.com/${REPO}/archive/refs/heads/${BRANCH}.zip"
-
- {
- printf '%s\n' ''
- printf '%s\n' ''
- printf '%s\n' ' '
- printf '%s\n' " ${EXT_NAME}"
- printf '%s\n' " ${EXT_NAME} ${STABILITY} build"
- printf '%s\n' " ${EXT_ELEMENT}"
- printf '%s\n' " ${EXT_TYPE}"
- printf '%s\n' " ${VERSION_LABEL}"
- [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
- [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
- printf '%s\n' ' '
- printf '%s\n' " ${STABILITY}"
- printf '%s\n' ' '
- printf '%s\n' " https://github.com/${REPO}/tree/${BRANCH}"
- printf '%s\n' ' '
- printf '%s\n' " ${DOWNLOAD_URL}"
- printf '%s\n' ' '
- printf '%s\n' " ${TARGET_PLATFORM}"
- printf '%s\n' ' Moko Consulting'
- printf '%s\n' ' https://mokoconsulting.tech'
- printf '%s\n' ' '
- printf '%s\n' ''
- } > updates.xml
- sed -i '/^[[:space:]]*$/d' updates.xml
- fi
- fi
-
- # Use Joomla-aware deploy for waas-component (routes files to correct Joomla dirs)
- # Use standard SFTP deploy for everything else
- 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
- # (both scripts handle dotfile skipping and .ftpignore natively)
- # Remove temp files that should never be left behind
- rm -f /tmp/deploy_key /tmp/sftp-config.json
-
- # Dev deploys fail silently — no issue creation.
- # Demo and RS deploys create failure issues (production-facing).
-
- - name: Deployment summary
- if: always()
- run: |
- if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
- echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
- elif [ "${{ job.status }}" == "success" ]; then
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "### ✅ Dev Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
- echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
- echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
- else
- echo "### ❌ Dev Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
- fi
diff --git a/.gitea/workflows/pr-branch-check.yml b/.gitea/workflows/pr-branch-check.yml
deleted file mode 100644
index b8d9742..0000000
--- a/.gitea/workflows/pr-branch-check.yml
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# Enforces branch merge policy:
-# feature/* → dev only
-# fix/* → dev only
-# hotfix/* → dev or main (emergency)
-# dev → main only
-# alpha/* → dev only
-# beta/* → dev only
-# rc/* → main only
-
-name: Branch Policy Check
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, edited]
-
-jobs:
- check-target:
- name: Verify merge target
- runs-on: ubuntu-latest
- steps:
- - name: Check branch policy
- 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 ""
- 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
diff --git a/.moko-platform b/.moko-platform
new file mode 100644
index 0000000..6664c6d
--- /dev/null
+++ b/.moko-platform
@@ -0,0 +1 @@
+mcp
diff --git a/.gitea/workflows/auto-assign.yml b/.mokogitea/workflows/auto-assign.yml
similarity index 98%
rename from .gitea/workflows/auto-assign.yml
rename to .mokogitea/workflows/auto-assign.yml
index 1996c1c..5012d27 100644
--- a/.gitea/workflows/auto-assign.yml
+++ b/.mokogitea/workflows/auto-assign.yml
@@ -9,7 +9,7 @@
# VERSION: 04.06.00
# BRIEF: Auto-assign jmiller to unassigned issues and PRs every 15 minutes
-name: Auto-Assign Issues & PRs
+name: "Universal: Auto-Assign"
on:
issues:
diff --git a/.gitea/workflows/auto-dev-issue.yml b/.mokogitea/workflows/auto-dev-issue.yml
similarity index 99%
rename from .gitea/workflows/auto-dev-issue.yml
rename to .mokogitea/workflows/auto-dev-issue.yml
index f61e1fc..cc42e86 100644
--- a/.gitea/workflows/auto-dev-issue.yml
+++ b/.mokogitea/workflows/auto-dev-issue.yml
@@ -13,7 +13,7 @@
# BRIEF: Auto-create tracking issue with sub-issues for dev/rc branch workflow
# NOTE: Synced via bulk-repo-sync to .github/workflows/auto-dev-issue.yml in all governed repos.
-name: Dev/RC Branch Issue
+name: "Universal: Dev/RC Branch Issue"
on:
# Auto-create on RC branch creation
diff --git a/.gitea/workflows/changelog-validation.yml b/.mokogitea/workflows/changelog-validation.yml
similarity index 98%
rename from .gitea/workflows/changelog-validation.yml
rename to .mokogitea/workflows/changelog-validation.yml
index e2ec667..da9bfd1 100644
--- a/.gitea/workflows/changelog-validation.yml
+++ b/.mokogitea/workflows/changelog-validation.yml
@@ -13,7 +13,7 @@
# BRIEF: Validates CHANGELOG.md format and version consistency
# NOTE: Deployed to .github/workflows/changelog-validation.yml in governed repos.
-name: Changelog Validation
+name: "Universal: Changelog Validation"
on:
push:
diff --git a/.gitea/workflows/codeql-analysis.yml b/.mokogitea/workflows/codeql-analysis.yml
similarity index 99%
rename from .gitea/workflows/codeql-analysis.yml
rename to .mokogitea/workflows/codeql-analysis.yml
index 3abfb02..7f499a1 100644
--- a/.gitea/workflows/codeql-analysis.yml
+++ b/.mokogitea/workflows/codeql-analysis.yml
@@ -15,7 +15,7 @@
# CodeQL does not support PHP directly; JavaScript scans JSON/YAML/shell.
# For PHP-specific security scanning see standards-compliance.yml.
-name: CodeQL Security Scanning
+name: "Universal: CodeQL Analysis"
on:
push:
diff --git a/.gitea/workflows/copilot-agent.yml b/.mokogitea/workflows/copilot-agent.yml
similarity index 97%
rename from .gitea/workflows/copilot-agent.yml
rename to .mokogitea/workflows/copilot-agent.yml
index 782945b..2c4c804 100644
--- a/.gitea/workflows/copilot-agent.yml
+++ b/.mokogitea/workflows/copilot-agent.yml
@@ -4,7 +4,7 @@
# GitHub Actions workflow for Copilot coding agent
# This workflow demonstrates how to use the firewall configuration
-name: Copilot Coding Agent
+name: "MCP: Copilot Agent"
on:
pull_request:
diff --git a/.gitea/workflows/enterprise-firewall-setup.yml b/.mokogitea/workflows/enterprise-firewall-setup.yml
similarity index 99%
rename from .gitea/workflows/enterprise-firewall-setup.yml
rename to .mokogitea/workflows/enterprise-firewall-setup.yml
index 1a533fb..81866f2 100644
--- a/.gitea/workflows/enterprise-firewall-setup.yml
+++ b/.mokogitea/workflows/enterprise-firewall-setup.yml
@@ -26,7 +26,7 @@
# BRIEF: Enterprise firewall configuration — generates outbound allow-rules including SFTP deployment server
# NOTE: Reads DEV_FTP_HOST / DEV_FTP_PORT variables to include SFTP egress rules alongside HTTPS rules.
-name: Enterprise Firewall Configuration
+name: "MCP: Enterprise Firewall"
# This workflow provides firewall configuration guidance for enterprise-ready sites
# It generates firewall rules for allowing outbound access to trusted domains
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
diff --git a/.gitea/workflows/mcp-auto-release.yml b/.mokogitea/workflows/mcp-auto-release.yml
similarity index 99%
rename from .gitea/workflows/mcp-auto-release.yml
rename to .mokogitea/workflows/mcp-auto-release.yml
index 74daa33..b2b0b6e 100644
--- a/.gitea/workflows/mcp-auto-release.yml
+++ b/.mokogitea/workflows/mcp-auto-release.yml
@@ -8,7 +8,7 @@
#
# This replaces the generic auto-release.yml for MCP server repos.
-name: MCP Release
+name: "MCP: Build & Release"
on:
push:
diff --git a/.gitea/workflows/mcp-build-test.yml b/.mokogitea/workflows/mcp-build-test.yml
similarity index 98%
rename from .gitea/workflows/mcp-build-test.yml
rename to .mokogitea/workflows/mcp-build-test.yml
index 5d8b97b..1a18883 100644
--- a/.gitea/workflows/mcp-build-test.yml
+++ b/.mokogitea/workflows/mcp-build-test.yml
@@ -5,7 +5,7 @@
# Builds the MCP server, validates TypeScript compilation, and checks
# that tools are properly registered with valid Zod schemas.
-name: MCP Build & Validate
+name: "MCP: Build & Validate"
on:
push:
diff --git a/.gitea/workflows/mcp-sdk-check.yml b/.mokogitea/workflows/mcp-sdk-check.yml
similarity index 99%
rename from .gitea/workflows/mcp-sdk-check.yml
rename to .mokogitea/workflows/mcp-sdk-check.yml
index 752eccc..50125c0 100644
--- a/.gitea/workflows/mcp-sdk-check.yml
+++ b/.mokogitea/workflows/mcp-sdk-check.yml
@@ -5,7 +5,7 @@
# Weekly check for MCP SDK updates. Creates an issue when a new version
# of @modelcontextprotocol/sdk is available.
-name: MCP SDK Version Check
+name: "MCP: SDK Version Check"
on:
schedule:
diff --git a/.gitea/workflows/mcp-tool-inventory.yml b/.mokogitea/workflows/mcp-tool-inventory.yml
similarity index 98%
rename from .gitea/workflows/mcp-tool-inventory.yml
rename to .mokogitea/workflows/mcp-tool-inventory.yml
index cc4c614..c46a5f7 100644
--- a/.gitea/workflows/mcp-tool-inventory.yml
+++ b/.mokogitea/workflows/mcp-tool-inventory.yml
@@ -5,7 +5,7 @@
# Generates a tool inventory report on each push to main.
# Extracts tool names, descriptions, and parameter counts from src/index.ts.
-name: MCP Tool Inventory
+name: "MCP: Tool Inventory"
on:
push:
diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml
new file mode 100644
index 0000000..463a900
--- /dev/null
+++ b/.mokogitea/workflows/notify.yml
@@ -0,0 +1,71 @@
+# 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"
+ - "Cascade Main → Dev"
+ 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}"
diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml
new file mode 100644
index 0000000..88e1884
--- /dev/null
+++ b/.mokogitea/workflows/pr-check.yml
@@ -0,0 +1,194 @@
+# 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: |
+ PLATFORM=$(cat .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; }
diff --git a/.gitea/workflows/repository-cleanup.yml b/.mokogitea/workflows/repository-cleanup.yml
similarity index 99%
rename from .gitea/workflows/repository-cleanup.yml
rename to .mokogitea/workflows/repository-cleanup.yml
index 96c2a8c..f7abc2e 100644
--- a/.gitea/workflows/repository-cleanup.yml
+++ b/.mokogitea/workflows/repository-cleanup.yml
@@ -14,7 +14,7 @@
# NOTE: Synced via bulk-repo-sync to .github/workflows/repository-cleanup.yml in all governed repos.
# Runs on the 1st and 15th of each month at 6:00 AM UTC, and on manual dispatch.
-name: Repository Cleanup
+name: "Universal: Repository Cleanup"
on:
schedule:
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
diff --git a/.gitea/workflows/standards-compliance.yml b/.mokogitea/workflows/standards-compliance.yml
similarity index 99%
rename from .gitea/workflows/standards-compliance.yml
rename to .mokogitea/workflows/standards-compliance.yml
index 44ab47d..031e1d3 100644
--- a/.gitea/workflows/standards-compliance.yml
+++ b/.mokogitea/workflows/standards-compliance.yml
@@ -9,7 +9,7 @@
# BRIEF: MokoStandards compliance validation workflow
# NOTE: Validates repository structure, documentation, and coding standards
-name: Standards Compliance
+name: "MCP: Standards Compliance"
# ╔════════════════════════════════════════════════════════════════════════╗
# ║ MOKOSTANDARDS COMPLIANCE WORKFLOW ║
diff --git a/.gitea/workflows/sync-version-on-merge.yml b/.mokogitea/workflows/sync-version-on-merge.yml
similarity index 99%
rename from .gitea/workflows/sync-version-on-merge.yml
rename to .mokogitea/workflows/sync-version-on-merge.yml
index 60715f6..60abafc 100644
--- a/.gitea/workflows/sync-version-on-merge.yml
+++ b/.mokogitea/workflows/sync-version-on-merge.yml
@@ -14,7 +14,7 @@
# NOTE: Synced via bulk-repo-sync to .github/workflows/sync-version-on-merge.yml in all governed repos.
# README.md is the single source of truth for the repository version.
-name: Sync Version from README
+name: "Universal: Sync Version on Merge"
on:
push: