diff --git a/.gitea/manifest.xml b/.gitea/manifest.xml
deleted file mode 100644
index 33cf34b..0000000
--- a/.gitea/manifest.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
- dolibarr-api-mcp
- MokoConsulting
- MCP server for Dolibarr ERP/CRM REST API operations
- 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/pr-check.yml b/.gitea/workflows/pr-check.yml
deleted file mode 100644
index 6d540d4..0000000
--- a/.gitea/workflows/pr-check.yml
+++ /dev/null
@@ -1,194 +0,0 @@
-# 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 .mokogitea/manifest.xml 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/security-audit.yml b/.gitea/workflows/security-audit.yml
deleted file mode 100644
index ca671e5..0000000
--- a/.gitea/workflows/security-audit.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-# 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: /.mokogitea/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/ISSUE_TEMPLATE/adr.md b/.mokogitea/ISSUE_TEMPLATE/adr.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/adr.md
rename to .mokogitea/ISSUE_TEMPLATE/adr.md
diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.mokogitea/ISSUE_TEMPLATE/bug_report.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/bug_report.md
rename to .mokogitea/ISSUE_TEMPLATE/bug_report.md
diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.mokogitea/ISSUE_TEMPLATE/config.yml
similarity index 91%
rename from .gitea/ISSUE_TEMPLATE/config.yml
rename to .mokogitea/ISSUE_TEMPLATE/config.yml
index d4d49ec..71fe271 100644
--- a/.gitea/ISSUE_TEMPLATE/config.yml
+++ b/.mokogitea/ISSUE_TEMPLATE/config.yml
@@ -8,7 +8,7 @@ contact_links:
url: https://mokoconsulting.tech/
about: Get help or ask questions through our website
- name: 📚 MokoStandards Documentation
- url: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
+ url: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki
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
diff --git a/.gitea/ISSUE_TEMPLATE/documentation.md b/.mokogitea/ISSUE_TEMPLATE/documentation.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/documentation.md
rename to .mokogitea/ISSUE_TEMPLATE/documentation.md
diff --git a/.gitea/ISSUE_TEMPLATE/enterprise_support.md b/.mokogitea/ISSUE_TEMPLATE/enterprise_support.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/enterprise_support.md
rename to .mokogitea/ISSUE_TEMPLATE/enterprise_support.md
diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.mokogitea/ISSUE_TEMPLATE/feature_request.md
similarity index 96%
rename from .gitea/ISSUE_TEMPLATE/feature_request.md
rename to .mokogitea/ISSUE_TEMPLATE/feature_request.md
index 7b76dc9..984fcbe 100644
--- a/.gitea/ISSUE_TEMPLATE/feature_request.md
+++ b/.mokogitea/ISSUE_TEMPLATE/feature_request.md
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
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)?
+Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki)?
- [ ] Accessibility (WCAG 2.1 AA)
- [ ] Localization (en_US/en_GB)
- [ ] Security best practices
diff --git a/.gitea/ISSUE_TEMPLATE/firewall-request.md b/.mokogitea/ISSUE_TEMPLATE/firewall-request.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/firewall-request.md
rename to .mokogitea/ISSUE_TEMPLATE/firewall-request.md
diff --git a/.mokogitea/ISSUE_TEMPLATE/mcp_api_integration.md b/.mokogitea/ISSUE_TEMPLATE/mcp_api_integration.md
new file mode 100644
index 0000000..c91fa56
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/mcp_api_integration.md
@@ -0,0 +1,48 @@
+---
+name: API Integration Request
+about: Request integration with a new REST API or service
+title: '[API] '
+labels: 'enhancement, api-integration'
+assignees: ''
+
+---
+
+## API Integration Request
+
+### Target API
+- **Service Name**: [e.g., Akeeba Backup, Joomla Web Services]
+- **API Documentation**: [URL to API docs]
+- **API Type**: [REST / GraphQL / SOAP]
+- **Authentication**: [API Key / OAuth / Bearer Token / Basic Auth]
+
+### Proposed Tools
+List the MCP tools this integration would provide:
+
+| Tool Name | HTTP Method | Endpoint | Description |
+|---|---|---|---|
+| `service_list` | GET | `/api/items` | List all items |
+| `service_get` | GET | `/api/items/{id}` | Get single item |
+| `service_create` | POST | `/api/items` | Create item |
+
+### Multi-Connection
+- [ ] Single instance only
+- [ ] Multiple instances (production, staging, dev)
+- [ ] Multi-tenant (one connection per client)
+
+### Use Case
+Describe the workflow this integration enables for AI assistants.
+
+### Priority
+- [ ] Critical — blocking current work
+- [ ] High — needed soon
+- [ ] Medium — would improve workflow
+- [ ] Low — nice to have
+
+### Existing Alternatives
+Are there other ways to accomplish this today? If so, why is an MCP integration better?
+
+### Checklist
+- [ ] API documentation is available and accessible
+- [ ] API supports the required authentication method
+- [ ] I have tested the API endpoints manually
+- [ ] The integration follows the Template-MCP architecture pattern
diff --git a/.mokogitea/ISSUE_TEMPLATE/mcp_connection_issue.md b/.mokogitea/ISSUE_TEMPLATE/mcp_connection_issue.md
new file mode 100644
index 0000000..841decb
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/mcp_connection_issue.md
@@ -0,0 +1,67 @@
+---
+name: MCP Connection Issue
+about: Report a connection, authentication, or API communication issue
+title: '[CONNECTION] '
+labels: 'bug, mcp-connection'
+assignees: ''
+
+---
+
+## Connection Issue
+
+### Issue Type
+- [ ] Authentication failure (401/403)
+- [ ] Connection refused / timeout
+- [ ] TLS / SSL certificate error
+- [ ] Wrong connection used (wrong environment)
+- [ ] Config file not found / parse error
+- [ ] API response error (4xx / 5xx)
+
+### MCP Server
+- **Server Name**: [e.g., mcp_mokosuite]
+- **Server Version**: [e.g., 1.0.0]
+- **Node.js Version**: [e.g., 20.x]
+
+### Connection Details
+- **Connection Name**: [e.g., production, staging, default]
+- **API Base URL**: [e.g., https://api.example.com] *(do not include API keys)*
+- **Insecure Mode**: [Yes / No]
+
+### Error Message
+```
+Paste the exact error message here
+```
+
+### Steps to Reproduce
+1. Configure connection with `npm run setup`
+2. Call tool `...` with parameters `...`
+3. See error
+
+### Expected Behavior
+What should have happened.
+
+### Debugging Attempted
+- [ ] Tested API directly with curl
+- [ ] Verified API key is valid
+- [ ] Checked config file exists and is valid JSON
+- [ ] Tested with `list_connections` tool
+- [ ] Ran server manually: `node dist/index.js 2> debug.log`
+
+### Config File
+```json
+{
+ "defaultConnection": "...",
+ "connections": {
+ "connection_name": {
+ "baseUrl": "https://...",
+ "apiKey": "REDACTED"
+ }
+ }
+}
+```
+*(Redact all API keys and tokens)*
+
+### Environment
+- **OS**: [e.g., macOS 14, Ubuntu 22.04, Windows 11]
+- **Claude Code Version**: [e.g., latest]
+- **Registration**: [.mcp.json / ~/.claude.json]
diff --git a/.mokogitea/ISSUE_TEMPLATE/mcp_tool_request.md b/.mokogitea/ISSUE_TEMPLATE/mcp_tool_request.md
new file mode 100644
index 0000000..647a8a8
--- /dev/null
+++ b/.mokogitea/ISSUE_TEMPLATE/mcp_tool_request.md
@@ -0,0 +1,49 @@
+---
+name: New MCP Tool Request
+about: Request a new tool to be added to this MCP server
+title: '[TOOL] '
+labels: 'enhancement, mcp-tool'
+assignees: ''
+
+---
+
+## Tool Request
+
+### Tool Name
+Proposed tool name (snake_case): `resource_action`
+
+### Description
+What should this tool do? What API endpoint(s) does it map to?
+
+### API Endpoint(s)
+- **Method**: [GET / POST / PUT / PATCH / DELETE]
+- **Endpoint**: `/api/v1/...`
+- **Auth**: [API Key / Token / None]
+
+### Parameters
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `id` | number | Yes | Resource ID |
+| `search` | string | No | Search filter |
+
+### Expected Response
+```json
+{
+ "id": 1,
+ "name": "Example"
+}
+```
+
+### Use Case
+Describe when and why someone would use this tool from Claude or another AI assistant.
+
+### Connection Scope
+- [ ] Works with all connections
+- [ ] Specific to certain API versions
+- [ ] Requires additional permissions
+
+### Checklist
+- [ ] I have checked this tool does not already exist
+- [ ] I have verified the API endpoint exists and is documented
+- [ ] The proposed name follows the `resource_action` convention
diff --git a/.gitea/ISSUE_TEMPLATE/question.md b/.mokogitea/ISSUE_TEMPLATE/question.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/question.md
rename to .mokogitea/ISSUE_TEMPLATE/question.md
diff --git a/.gitea/ISSUE_TEMPLATE/rfc.md b/.mokogitea/ISSUE_TEMPLATE/rfc.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/rfc.md
rename to .mokogitea/ISSUE_TEMPLATE/rfc.md
diff --git a/.gitea/ISSUE_TEMPLATE/security.md b/.mokogitea/ISSUE_TEMPLATE/security.md
similarity index 95%
rename from .gitea/ISSUE_TEMPLATE/security.md
rename to .mokogitea/ISSUE_TEMPLATE/security.md
index f57b284..14d63e0 100644
--- a/.gitea/ISSUE_TEMPLATE/security.md
+++ b/.mokogitea/ISSUE_TEMPLATE/security.md
@@ -35,7 +35,7 @@ Use this template only for:
## Standards Reference
-Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards)?
+Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki)?
- [ ] SPDX license identifiers
- [ ] Secret management
- [ ] Dependency security
diff --git a/.gitea/ISSUE_TEMPLATE/version.md b/.mokogitea/ISSUE_TEMPLATE/version.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/version.md
rename to .mokogitea/ISSUE_TEMPLATE/version.md
diff --git a/.gitea/auto-assign.yml b/.mokogitea/auto-assign.yml
similarity index 100%
rename from .gitea/auto-assign.yml
rename to .mokogitea/auto-assign.yml
diff --git a/.gitea/auto-dev-issue.yml b/.mokogitea/auto-dev-issue.yml
similarity index 100%
rename from .gitea/auto-dev-issue.yml
rename to .mokogitea/auto-dev-issue.yml
diff --git a/.gitea/auto-release.yml b/.mokogitea/auto-release.yml
similarity index 100%
rename from .gitea/auto-release.yml
rename to .mokogitea/auto-release.yml
diff --git a/.mokogitea/branch-protection.yml b/.mokogitea/branch-protection.yml
new file mode 100644
index 0000000..2dff8b9
--- /dev/null
+++ b/.mokogitea/branch-protection.yml
@@ -0,0 +1,251 @@
+# Copyright (C) 2026 Moko Consulting
+# SPDX-License-Identifier: GPL-3.0-or-later
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: moko-platform.Automation
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
+# PATH: /.gitea/workflows/branch-protection.yml
+# BRIEF: Apply standardised branch protection rules to all governed repositories
+#
+# +========================================================================+
+# | BRANCH PROTECTION SETUP |
+# +========================================================================+
+# | |
+# | Applies protection rules for: main, dev, rc, beta, alpha |
+# | |
+# | main — Require PR, block rejected reviews, no force push |
+# | dev — Allow push, no force push, no delete |
+# | rc — Allow push, no force push, no delete |
+# | beta — Allow push, no force push, no delete |
+# | alpha — Allow push, no force push, no delete |
+# | |
+# | jmiller has override authority on all branches. |
+# | |
+# +========================================================================+
+
+name: Branch Protection Setup
+
+on:
+ schedule:
+ - cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Preview mode (no changes)'
+ required: false
+ type: boolean
+ default: false
+ repos:
+ description: 'Comma-separated repo names (empty = all governed repos)'
+ required: false
+ type: string
+ default: ''
+
+env:
+ GITEA_URL: https://git.mokoconsulting.tech
+ GITEA_ORG: MokoConsulting
+
+permissions:
+ contents: read
+
+jobs:
+ protect:
+ name: Apply Branch Protection Rules
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Determine target repos
+ id: repos
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${GITEA_URL}/api/v1"
+
+ # Platform/standards/infra repos to exclude
+ EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
+ EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
+
+ if [ -n "${{ inputs.repos }}" ]; then
+ # User-specified repos
+ REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
+ else
+ # Fetch all org repos
+ PAGE=1
+ REPOS=""
+ while true; do
+ BATCH=$(curl -sS \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
+ | jq -r '.[].name // empty')
+ [ -z "$BATCH" ] && break
+ REPOS="$REPOS $BATCH"
+ PAGE=$((PAGE + 1))
+ done
+
+ # Filter out excluded repos
+ FILTERED=""
+ for REPO in $REPOS; do
+ SKIP=false
+ for EX in $EXCLUDE; do
+ if [ "$REPO" = "$EX" ]; then
+ SKIP=true
+ break
+ fi
+ done
+ if [ "$SKIP" = "false" ]; then
+ FILTERED="$FILTERED $REPO"
+ fi
+ done
+ REPOS="$FILTERED"
+ fi
+
+ echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
+ COUNT=$(echo "$REPOS" | wc -w)
+ echo "📋 Target repos (${COUNT}): $REPOS"
+
+ - name: Apply protection rules
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ DRY_RUN: ${{ inputs.dry_run || 'false' }}
+ run: |
+ API="${GITEA_URL}/api/v1"
+ REPOS="${{ steps.repos.outputs.repos }}"
+
+ SUCCESS=0
+ FAILED=0
+ SKIPPED=0
+
+ # ── Rule definitions ──────────────────────────────────────
+ # Only the CI bot (jmiller token) can push directly.
+ # All human contributors must use PRs.
+ # Force push disabled on all branches.
+
+ RULE_MAIN='{
+ "rule_name": "main",
+ "enable_push": true,
+ "enable_push_whitelist": true,
+ "push_whitelist_usernames": ["jmiller"],
+ "enable_force_push": false,
+ "enable_force_push_allowlist": false,
+ "force_push_allowlist_usernames": [],
+ "enable_merge_whitelist": false,
+ "required_approvals": 0,
+ "dismiss_stale_approvals": true,
+ "block_on_rejected_reviews": true,
+ "block_on_outdated_branch": false,
+ "priority": 1
+ }'
+
+ RULE_DEV='{
+ "rule_name": "dev",
+ "enable_push": true,
+ "enable_push_whitelist": true,
+ "push_whitelist_usernames": ["jmiller"],
+ "enable_force_push": false,
+ "enable_force_push_allowlist": false,
+ "force_push_allowlist_usernames": [],
+ "enable_merge_whitelist": false,
+ "required_approvals": 0,
+ "block_on_rejected_reviews": false,
+ "priority": 2
+ }'
+
+ RULE_RC='{
+ "rule_name": "rc",
+ "enable_push": true,
+ "enable_push_whitelist": true,
+ "push_whitelist_usernames": ["jmiller"],
+ "enable_force_push": false,
+ "enable_force_push_allowlist": false,
+ "force_push_allowlist_usernames": [],
+ "enable_merge_whitelist": false,
+ "required_approvals": 0,
+ "block_on_rejected_reviews": false,
+ "priority": 3
+ }'
+
+ RULE_BETA='{
+ "rule_name": "beta",
+ "enable_push": true,
+ "enable_push_whitelist": true,
+ "push_whitelist_usernames": ["jmiller"],
+ "enable_force_push": false,
+ "enable_force_push_allowlist": false,
+ "force_push_allowlist_usernames": [],
+ "enable_merge_whitelist": false,
+ "required_approvals": 0,
+ "block_on_rejected_reviews": false,
+ "priority": 4
+ }'
+
+ RULE_ALPHA='{
+ "rule_name": "alpha",
+ "enable_push": true,
+ "enable_push_whitelist": true,
+ "push_whitelist_usernames": ["jmiller"],
+ "enable_force_push": false,
+ "enable_force_push_allowlist": false,
+ "force_push_allowlist_usernames": [],
+ "enable_merge_whitelist": false,
+ "required_approvals": 0,
+ "block_on_rejected_reviews": false,
+ "priority": 5
+ }'
+
+ RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
+ RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
+
+ # ── Apply rules to each repo ──────────────────────────────
+ for REPO in $REPOS; do
+ echo ""
+ echo "═══ ${REPO} ═══"
+
+ for i in "${!RULES[@]}"; do
+ RULE="${RULES[$i]}"
+ NAME="${RULE_NAMES[$i]}"
+
+ if [ "$DRY_RUN" = "true" ]; then
+ echo " [DRY RUN] Would apply rule: ${NAME}"
+ SKIPPED=$((SKIPPED + 1))
+ continue
+ fi
+
+ # Delete existing rule if present (idempotent recreate)
+ ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
+ curl -sS -o /dev/null -w "" \
+ -X DELETE \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
+
+ # Create rule
+ RESPONSE=$(curl -sS -w "\n%{http_code}" \
+ -X POST \
+ -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$RULE" \
+ "${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
+
+ HTTP=$(echo "$RESPONSE" | tail -1)
+ BODY=$(echo "$RESPONSE" | sed '$d')
+
+ if [ "$HTTP" = "201" ]; then
+ echo " ✅ ${NAME}"
+ SUCCESS=$((SUCCESS + 1))
+ else
+ echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
+ FAILED=$((FAILED + 1))
+ fi
+ done
+ done
+
+ # ── Summary ───────────────────────────────────────────────
+ echo ""
+ echo "════════════════════════════════════════"
+ echo " ✅ Success: ${SUCCESS}"
+ echo " ❌ Failed: ${FAILED}"
+ echo " ⏭️ Skipped: ${SKIPPED}"
+ echo "════════════════════════════════════════"
+
+ if [ "$FAILED" -gt 0 ]; then
+ echo "::warning::${FAILED} rule(s) failed to apply"
+ fi
diff --git a/.gitea/changelog-validation.yml b/.mokogitea/changelog-validation.yml
similarity index 100%
rename from .gitea/changelog-validation.yml
rename to .mokogitea/changelog-validation.yml
diff --git a/.gitea/codeql-analysis.yml b/.mokogitea/codeql-analysis.yml
similarity index 100%
rename from .gitea/codeql-analysis.yml
rename to .mokogitea/codeql-analysis.yml
diff --git a/.gitea/copilot-agent.yml b/.mokogitea/copilot-agent.yml
similarity index 100%
rename from .gitea/copilot-agent.yml
rename to .mokogitea/copilot-agent.yml
diff --git a/.gitea/deploy-demo.yml b/.mokogitea/deploy-demo.yml
similarity index 100%
rename from .gitea/deploy-demo.yml
rename to .mokogitea/deploy-demo.yml
diff --git a/.gitea/deploy-dev.yml b/.mokogitea/deploy-dev.yml
similarity index 100%
rename from .gitea/deploy-dev.yml
rename to .mokogitea/deploy-dev.yml
diff --git a/.gitea/enterprise-firewall-setup.yml b/.mokogitea/enterprise-firewall-setup.yml
similarity index 100%
rename from .gitea/enterprise-firewall-setup.yml
rename to .mokogitea/enterprise-firewall-setup.yml
diff --git a/.gitea/mcp-auto-release.yml b/.mokogitea/mcp-auto-release.yml
similarity index 100%
rename from .gitea/mcp-auto-release.yml
rename to .mokogitea/mcp-auto-release.yml
diff --git a/.gitea/mcp-build-test.yml b/.mokogitea/mcp-build-test.yml
similarity index 100%
rename from .gitea/mcp-build-test.yml
rename to .mokogitea/mcp-build-test.yml
diff --git a/.gitea/mcp-sdk-check.yml b/.mokogitea/mcp-sdk-check.yml
similarity index 100%
rename from .gitea/mcp-sdk-check.yml
rename to .mokogitea/mcp-sdk-check.yml
diff --git a/.gitea/mcp-tool-inventory.yml b/.mokogitea/mcp-tool-inventory.yml
similarity index 100%
rename from .gitea/mcp-tool-inventory.yml
rename to .mokogitea/mcp-tool-inventory.yml
diff --git a/.gitea/pr-branch-check.yml b/.mokogitea/pr-branch-check.yml
similarity index 100%
rename from .gitea/pr-branch-check.yml
rename to .mokogitea/pr-branch-check.yml
diff --git a/.gitea/repository-cleanup.yml b/.mokogitea/repository-cleanup.yml
similarity index 100%
rename from .gitea/repository-cleanup.yml
rename to .mokogitea/repository-cleanup.yml
diff --git a/.gitea/standards-compliance.yml b/.mokogitea/standards-compliance.yml
similarity index 100%
rename from .gitea/standards-compliance.yml
rename to .mokogitea/standards-compliance.yml
diff --git a/.gitea/sync-version-on-merge.yml b/.mokogitea/sync-version-on-merge.yml
similarity index 100%
rename from .gitea/sync-version-on-merge.yml
rename to .mokogitea/sync-version-on-merge.yml
diff --git a/.gitea/workflows/auto-assign.yml b/.mokogitea/workflows/auto-assign.yml
similarity index 100%
rename from .gitea/workflows/auto-assign.yml
rename to .mokogitea/workflows/auto-assign.yml
diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml
new file mode 100644
index 0000000..91e16f7
--- /dev/null
+++ b/.mokogitea/workflows/auto-bump.yml
@@ -0,0 +1,67 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Release
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/auto-bump.yml
+# VERSION: 09.02.00
+# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
+
+name: "Universal: Auto Version Bump"
+
+on:
+ push:
+ branches:
+ - dev
+ - rc
+ - 'feature/**'
+ - 'patch/**'
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+
+permissions:
+ contents: write
+
+jobs:
+ bump:
+ name: Version Bump
+ runs-on: release
+ if: >-
+ !contains(github.event.head_commit.message, '[skip ci]') &&
+ !contains(github.event.head_commit.message, '[skip bump]') &&
+ !startsWith(github.event.head_commit.message, 'Merge pull request') &&
+ !startsWith(github.event.repository.name, 'Template-')
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.MOKOGITEA_TOKEN }}
+ fetch-depth: 1
+
+ - name: Setup mokocli tools
+ run: |
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
+ fi
+ if [ -d "/opt/mokocli/cli" ]; then
+ echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
+ else
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/MokoCLI.git" \
+ /tmp/mokocli
+ cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
+ echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
+ fi
+
+ - name: Bump version
+ run: |
+ php ${MOKO_CLI}/version_auto_bump.php \
+ --path . --branch "${GITHUB_REF_NAME}" \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
+ --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
diff --git a/.gitea/workflows/auto-dev-issue.yml b/.mokogitea/workflows/auto-dev-issue.yml
similarity index 100%
rename from .gitea/workflows/auto-dev-issue.yml
rename to .mokogitea/workflows/auto-dev-issue.yml
diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml
new file mode 100644
index 0000000..a7563e0
--- /dev/null
+++ b/.mokogitea/workflows/auto-release.yml
@@ -0,0 +1,497 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Release
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/auto-release.yml
+# VERSION: 05.01.00
+# BRIEF: Universal build & release � detects platform from manifest.xml
+#
+# +=======================================================================+
+# | UNIVERSAL BUILD & RELEASE PIPELINE |
+# +=======================================================================+
+# | |
+# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
+# | |
+# | Platform-specific: |
+# | joomla: XML manifest, 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: [opened, synchronize, closed]
+ branches:
+ - main
+ paths-ignore:
+ - '.mokogitea/workflows/**'
+ - '*.md'
+ - 'wiki/**'
+ - '.editorconfig'
+ - '.gitignore'
+ - '.gitattributes'
+ - '.gitmessage'
+ - 'LICENSE'
+ workflow_dispatch:
+ inputs:
+ action:
+ description: 'Action to perform'
+ required: false
+ type: choice
+ default: release
+ options:
+ - release
+ - promote-rc
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ MOKOGITEA_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:
+ # ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
+ promote-rc:
+ name: Promote to RC
+ runs-on: release
+ # Skip on template repos (Template-*) — they scaffold other repos and do not release.
+ if: >-
+ !startsWith(github.event.repository.name, 'Template-') &&
+ (
+ (github.event.action == 'opened' && github.event.pull_request.merged != true) ||
+ (github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
+ (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
+ )
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.MOKOGITEA_TOKEN }}
+ fetch-depth: 1
+ submodules: recursive
+
+ - name: Setup mokocli tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ run: |
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
+ echo Using pre-installed /opt/mokocli
+ echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
+ else
+ echo Falling back to fresh clone
+ if ! command -v composer > /dev/null 2>&1; 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
+ rm -rf /tmp/mokocli
+ CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
+ git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
+ cd /tmp/mokocli
+ composer install --no-dev --no-interaction --quiet
+ echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
+ fi
+
+ - name: Rename branch to rc
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ AUTH="Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}"
+ FROM="${{ github.event.pull_request.head.ref || 'dev' }}"
+ PR="${{ github.event.pull_request.number }}"
+
+ # Resolve the source branch HEAD commit.
+ SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
+ || { echo "::error::Source branch ${FROM} not found"; exit 1; }
+ SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
+ [ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
+
+ # Point rc at the source commit. If rc already exists (a protected branch that
+ # cannot be deleted), force-update its ref in place instead of delete+recreate:
+ # deleting a protected branch fails, which then makes the recreate return HTTP 409.
+ if curl -sf -o /dev/null -H "$AUTH" "${API_BASE}/branches/rc"; then
+ echo "rc exists - force-updating to ${FROM} (${SRC_SHA})"
+ curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
+ "${API_BASE}/git/refs/heads/rc" -d "{\"sha\":\"${SRC_SHA}\",\"force\":true}" \
+ || { echo "::error::Failed to force-update rc (CI token needs force-push on the protected rc branch)"; exit 1; }
+ else
+ echo "Creating rc from ${FROM}"
+ curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
+ "${API_BASE}/branches" -d "{\"new_branch_name\":\"rc\",\"old_branch_name\":\"${FROM}\"}" \
+ || { echo "::error::Failed to create rc from ${FROM}"; exit 1; }
+ fi
+
+ # Repoint the PR at rc, then delete the old source branch (non-fatal).
+ if [ -n "$PR" ]; then
+ curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
+ "${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
+ fi
+ curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true
+ echo "Renamed ${FROM} -> rc"
+
+ - name: Checkout rc and configure git
+ run: |
+ git fetch origin rc
+ git checkout rc
+ git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "mokogitea-actions[bot]"
+ git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+
+ - name: Publish RC release
+ run: |
+ php ${MOKO_CLI}/release_publish.php \
+ --path . --stability rc --bump minor --branch rc \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}"
+
+ - name: Update RC release notes from CHANGELOG.md
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+
+ # Extract [Unreleased] section from changelog
+ NOTES=""
+ if [ -f "CHANGELOG.md" ]; then
+ NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
+ fi
+ [ -z "$NOTES" ] && NOTES="Release candidate"
+
+ # Find the RC release and update its body
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/release-candidate" \
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = open('/dev/stdin').read()
+ payload = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=payload, method='PATCH',
+ headers={
+ 'Authorization': 'token ${TOKEN}',
+ 'Content-Type': 'application/json'
+ })
+ urllib.request.urlopen(req)
+ " <<< "$NOTES"
+ echo "RC release notes updated from CHANGELOG.md"
+ fi
+
+ - name: Summary
+ if: always()
+ run: |
+ echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
+ echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
+
+ # ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
+ release:
+ name: Build & Release Pipeline
+ runs-on: release
+ # Skip on template repos (Template-*) — they scaffold other repos and do not release.
+ if: >-
+ !startsWith(github.event.repository.name, 'Template-') &&
+ (
+ github.event.pull_request.merged == true ||
+ (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
+ )
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.MOKOGITEA_TOKEN }}
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Configure git for bot pushes
+ run: |
+ git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "mokogitea-actions[bot]"
+ git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+
+ - name: Check for merge conflict markers
+ run: |
+ CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
+ if [ -n "$CONFLICTS" ]; then
+ echo "::error::Merge conflict markers found — aborting release"
+ echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+ echo "No conflict markers found"
+
+ - name: Setup mokocli tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
+ run: |
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
+ echo Using pre-installed /opt/mokocli
+ echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
+ else
+ echo Falling back to fresh clone
+ if ! command -v composer > /dev/null 2>&1; 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
+ rm -rf /tmp/mokocli
+ CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
+ git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
+ cd /tmp/mokocli
+ composer install --no-dev --no-interaction --quiet
+ echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
+ fi
+
+ - name: "Detect platform"
+ id: platform
+ run: |
+ php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
+ php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
+
+ - name: "Determine version bump level"
+ id: bump
+ run: |
+ # Fix/patch branches: version was already bumped by pre-release, just strip suffix
+ # Feature/dev branches: bump minor for the new stable release
+ HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
+ case "$HEAD_REF" in
+ fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
+ *) BUMP="minor" ;;
+ esac
+ echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
+ echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
+
+ - name: "Publish stable release"
+ run: |
+ BUMP_FLAG=""
+ if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
+ BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
+ fi
+ php ${MOKO_CLI}/release_publish.php \
+ --path . --stability stable ${BUMP_FLAG} --branch main \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}"
+
+ - name: "Read published version"
+ id: version
+ run: |
+ VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
+ VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
+ [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ if [[ "$PLATFORM" == joomla* ]]; then
+ echo "tag=stable" >> "$GITHUB_OUTPUT"
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
+ else
+ echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
+ fi
+ echo "branch=main" >> "$GITHUB_OUTPUT"
+ echo "Published version: ${VERSION}"
+
+ - name: "Create semver tag for non-Joomla repos"
+ id: semver
+ if: |
+ steps.version.outputs.skip != 'true' &&
+ !startsWith(steps.platform.outputs.platform, 'joomla')
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+ SEMVER_TAG="v${VERSION}"
+
+ echo "Creating semver tag: ${SEMVER_TAG}"
+
+ # Create the git tag via API
+ HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
+ -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/tags" \
+ -d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
+
+ if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
+ echo "Created semver tag: ${SEMVER_TAG}"
+ elif [ "$HTTP_CODE" = "409" ]; then
+ echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
+ else
+ echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
+ fi
+
+ echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
+
+ - name: Update release notes and promote changelog
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+
+ # Get the stable release info (version and ID)
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
+ RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
+ # Extract version from release name (e.g. "06.17.00" or "v06.17.00")
+ VERSION=$(python3 -c "
+ import json, sys, re
+ r = json.load(sys.stdin)
+ name = r.get('name', '')
+ m = re.search(r'(\d+\.\d+\.\d+)', name)
+ print(m.group(1) if m else '')
+ " <<< "$RELEASE_JSON" 2>/dev/null || true)
+
+ # Extract [Unreleased] section from changelog
+ NOTES=""
+ if [ -f "CHANGELOG.md" ]; then
+ NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
+ fi
+ [ -z "$NOTES" ] && NOTES="Stable release"
+
+ # Update release body via API
+ if [ -n "$RELEASE_ID" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = open('/dev/stdin').read()
+ payload = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=payload, method='PATCH',
+ headers={
+ 'Authorization': 'token ${TOKEN}',
+ 'Content-Type': 'application/json'
+ })
+ urllib.request.urlopen(req)
+ " <<< "$NOTES"
+ echo "Release notes updated from CHANGELOG.md"
+ fi
+
+ # Promote [Unreleased] → [version] in CHANGELOG.md and reset
+ if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
+ DATE=$(date +%Y-%m-%d)
+ python3 -c "
+ import sys
+ version, date = sys.argv[1], sys.argv[2]
+ content = open('CHANGELOG.md').read()
+ old = '## [Unreleased]'
+ new = f'## [Unreleased]\n\n## [{version}] --- {date}'
+ content = content if ('## [' + version + ']') in content else content.replace(old, new, 1)
+ open('CHANGELOG.md', 'w').write(content)
+ " "$VERSION" "$DATE"
+ git add CHANGELOG.md
+ git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
+ git push origin main || true
+ echo "Changelog promoted: [Unreleased] → [${VERSION}]"
+ fi
+
+ # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
+ - name: "Step 9: Mirror release to GitHub"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ secrets.GH_MIRROR_TOKEN != ''
+ continue-on-error: true
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ php ${MOKO_CLI}/release_mirror.php \
+ --version "$VERSION" --tag "$RELEASE_TAG" \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
+ --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
+ --branch main 2>&1 || true
+ echo "GitHub mirror updated" >> $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_MIRROR_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_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
+ git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_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"
+
+ - name: "Step 11: Delete rc branch (dev reset moved to cascade-dev.yml)"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+
+ # Delete rc branch (ephemeral — created by promote-rc)
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/branches/rc" 2>/dev/null \
+ && echo "Deleted rc branch" || echo "rc branch not found"
+
+ # dev is reset from main by the dedicated "Cascade Main -> Dev" workflow
+ # (cascade-dev.yml), which runs after this release completes.
+ echo "rc cleaned; dev reset handled by cascade-dev.yml" >> $GITHUB_STEP_SUMMARY
+
+ - name: "Step 12: Create version branch from main"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ BRANCH_NAME="version/${VERSION}"
+ MAIN_SHA=$(git rev-parse HEAD)
+
+ # Delete old version branch if it exists (same version re-release)
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
+
+ # Create version/XX.YY.ZZ from main
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
+
+ echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
+
+
+
+ # -- Dolibarr post-release: Reset dev version -----------------------------
+ - name: "Post-release: Reset dev version"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ php ${MOKO_CLI}/version_reset_dev.php \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
+ --branch dev --path . 2>&1 || true
+
+ # -- 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](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml
new file mode 100644
index 0000000..25ebae1
--- /dev/null
+++ b/.mokogitea/workflows/branch-cleanup.yml
@@ -0,0 +1,49 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: MokoStandards.Universal
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/branch-cleanup.yml
+# VERSION: 01.00.00
+# BRIEF: Delete feature branches after PR merge
+
+name: "Branch Cleanup"
+
+on:
+ pull_request:
+ types: [closed]
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ cleanup:
+ name: Delete merged branch
+ runs-on: ubuntu-latest
+ if: >-
+ github.event.pull_request.merged == true &&
+ github.event.pull_request.head.ref != 'dev' &&
+ github.event.pull_request.head.ref != 'main'
+
+ steps:
+ - name: Delete source branch
+ run: |
+ BRANCH="${{ github.event.pull_request.head.ref }}"
+ API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
+ # URL-encode the branch name's slashes (no PHP dependency on the runner)
+ ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g')
+
+ STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
+ -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
+ "${API}/${ENCODED}" 2>/dev/null || true)
+
+ if [ "$STATUS" = "204" ]; then
+ echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
+ elif [ "$STATUS" = "404" ]; then
+ echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
+ fi
diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml
new file mode 100644
index 0000000..b2eabe1
--- /dev/null
+++ b/.mokogitea/workflows/cascade-dev.yml
@@ -0,0 +1,106 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: MokoStandards.Cascade
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/cascade-dev.yml
+# VERSION: 02.00.00
+# BRIEF: Cascade main -> dev via PR; auto-merge only if conflict-free, else notify
+
+name: "Cascade Main -> Dev"
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+env:
+ MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ # ntfy destination is configured via repo or org variables (org vars are inherited).
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.CASCADE_NTFY_TOPIC || vars.NTFY_TOPIC || 'gitea-releases' }}
+
+jobs:
+ cascade:
+ name: Cascade main -> dev
+ runs-on: ubuntu-latest
+ steps:
+ - name: Open main -> dev PR (auto-merge if clean, else notify)
+ env:
+ TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -uo pipefail
+ API="${MOKOGITEA_URL}/api/v1/repos/${REPO}"
+ AUTH="Authorization: token ${TOKEN}"
+ jqnum() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null; }
+
+ # 0. dev must exist
+ if ! curl -sf -H "$AUTH" "${API}/branches/dev" >/dev/null 2>&1; then
+ echo "No dev branch - nothing to cascade."; exit 0
+ fi
+
+ # 1. is main ahead of dev?
+ AHEAD=$(curl -sf -H "$AUTH" "${API}/compare/dev...main" \
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_commits',0))" 2>/dev/null || echo 0)
+ if [ "${AHEAD:-0}" -eq 0 ]; then
+ echo "dev already up to date with main."; exit 0
+ fi
+ echo "main is ${AHEAD} commit(s) ahead of dev."
+
+ # 2. reuse an open main->dev PR, else create one
+ PR=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=dev" \
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='main'), ''))" 2>/dev/null || echo "")
+ if [ -z "$PR" ]; then
+ RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
+ -d '{"head":"main","base":"dev","title":"chore(sync): cascade main -> dev","body":"Automated cascade of main into dev. Auto-merges only if conflict-free; otherwise left open for manual resolution."}')
+ PR=$(printf '%s' "$RESP" | jqnum number)
+ if [ -z "$PR" ]; then
+ echo "::warning::Could not open cascade PR: $RESP"; exit 0
+ fi
+ echo "Opened cascade PR #${PR}"
+ else
+ echo "Reusing open cascade PR #${PR}"
+ fi
+
+ # 3. wait for MokoGitea to compute mergeability (conflict detection)
+ MERGEABLE=""
+ for _ in 1 2 3 4 5 6; do
+ MERGEABLE=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqnum mergeable)
+ case "$MERGEABLE" in True|False) break ;; esac
+ sleep 3
+ done
+ echo "mergeable=${MERGEABLE}"
+
+ notify() {
+ curl -sS \
+ -H "Title: ${REPO}: dev cascade needs manual merge" \
+ -H "Tags: warning,twisted_rightwards_arrows" \
+ -H "Priority: high" \
+ -H "Click: ${MOKOGITEA_URL}/${REPO}/pulls/${PR}" \
+ -d "main -> dev cascade PR #${PR} $1 It was NOT auto-merged; resolve it manually." \
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
+ }
+
+ # 4. auto-merge only if conflict-free; otherwise notify
+ if [ "$MERGEABLE" = "True" ]; then
+ CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
+ -X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true}')
+ if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then
+ echo "Cascade PR #${PR} merged (or scheduled to merge when checks pass)."
+ else
+ echo "::warning::Auto-merge returned HTTP ${CODE}: $(cat /tmp/merge.json)"
+ notify "could not be auto-merged (HTTP ${CODE})."
+ fi
+ else
+ echo "::warning::Cascade PR #${PR} has conflicts (mergeable=${MERGEABLE}); sending notification."
+ notify "has conflicts and cannot be merged automatically."
+ fi
diff --git a/.gitea/workflows/changelog-validation.yml b/.mokogitea/workflows/changelog-validation.yml
similarity index 100%
rename from .gitea/workflows/changelog-validation.yml
rename to .mokogitea/workflows/changelog-validation.yml
diff --git a/.mokogitea/workflows/ci-generic.yml b/.mokogitea/workflows/ci-generic.yml
new file mode 100644
index 0000000..14f81ba
--- /dev/null
+++ b/.mokogitea/workflows/ci-generic.yml
@@ -0,0 +1,203 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/ci-generic.yml
+# VERSION: 01.00.00
+# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
+
+name: "Generic: Project CI"
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev/**
+ - rc/**
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ # ── Lint & Validate ───────────────────────────────────────────────────
+ lint:
+ name: Lint & Validate
+ runs-on: ubuntu-latest
+ # Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source.
+ if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Detect toolchain
+ id: detect
+ run: |
+ HAS_PHP=false
+ HAS_NODE=false
+ [ -f "composer.json" ] && HAS_PHP=true
+ [ -f "package.json" ] && HAS_NODE=true
+ echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
+ echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
+ echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
+
+ - name: Setup PHP
+ if: steps.detect.outputs.has_php == 'true'
+ 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
+ php -v
+
+ - name: Setup Node.js
+ if: steps.detect.outputs.has_node == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install PHP dependencies
+ if: steps.detect.outputs.has_php == 'true'
+ run: |
+ if [ -f "composer.json" ]; then
+ composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
+ fi
+
+ - name: Install Node.js dependencies
+ if: steps.detect.outputs.has_node == 'true'
+ run: |
+ if [ -f "package.json" ]; then
+ npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
+ fi
+
+ - name: PHP syntax check
+ if: steps.detect.outputs.has_php == 'true'
+ run: |
+ ERRORS=0
+ while IFS= read -r -d '' file; do
+ if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
+ echo "::error file=${file}::PHP syntax error"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
+
+ echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
+ if [ "$ERRORS" -eq 0 ]; then
+ echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+ - name: TypeScript/JavaScript lint
+ if: steps.detect.outputs.has_node == 'true'
+ run: |
+ if [ -f "node_modules/.bin/eslint" ]; then
+ npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
+ echo "## ESLint" >> $GITHUB_STEP_SUMMARY
+ echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
+ elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
+ echo "::warning::ESLint config found but eslint not installed"
+ else
+ echo "No ESLint configured — skipping"
+ fi
+
+ - name: TypeScript compile check
+ if: steps.detect.outputs.has_node == 'true'
+ run: |
+ if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
+ npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
+ echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
+ echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: PHPStan static analysis
+ if: steps.detect.outputs.has_php == 'true'
+ run: |
+ if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
+ vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
+ fi
+
+ # ── Tests ─────────────────────────────────────────────────────────────
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+ # Independent job (no `needs: lint`): the Gitea Actions scheduler does not
+ # offer the dependent 2nd job of a needs-chain to runners, so it stalls in
+ # "waiting" and is reaped by ABANDONED_JOB_TIMEOUT. Guard template repos
+ # directly (same condition lint uses) instead of gating on lint's result.
+ if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Detect toolchain
+ id: detect
+ run: |
+ HAS_PHP=false
+ HAS_NODE=false
+ [ -f "composer.json" ] && HAS_PHP=true
+ [ -f "package.json" ] && HAS_NODE=true
+ echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
+ echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
+
+ - name: Setup PHP
+ if: steps.detect.outputs.has_php == 'true'
+ 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: Setup Node.js
+ if: steps.detect.outputs.has_node == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: |
+ [ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
+ [ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
+
+ - name: Run PHP tests
+ if: steps.detect.outputs.has_php == 'true'
+ run: |
+ if [ -f "vendor/bin/phpunit" ]; then
+ vendor/bin/phpunit --testdox 2>&1
+ echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
+ echo "Tests passed." >> $GITHUB_STEP_SUMMARY
+ elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
+ echo "::warning::PHPUnit config found but phpunit not installed"
+ else
+ echo "No PHPUnit configured — skipping"
+ fi
+
+ - name: Run Node.js tests
+ if: steps.detect.outputs.has_node == 'true'
+ run: |
+ if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
+ npm test 2>&1
+ echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
+ echo "Tests passed." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "No test script in package.json — skipping"
+ fi
+
+ - name: Build check
+ run: |
+ if [ -f "Makefile" ]; then
+ make build 2>&1 || echo "::warning::Build failed or not configured"
+ elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
+ npm run build 2>&1 || echo "::warning::Build failed"
+ fi
diff --git a/.mokogitea/workflows/ci-issue-reporter.yml b/.mokogitea/workflows/ci-issue-reporter.yml
new file mode 100644
index 0000000..0520237
--- /dev/null
+++ b/.mokogitea/workflows/ci-issue-reporter.yml
@@ -0,0 +1,68 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Universal
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
+# VERSION: 01.00.00
+# BRIEF: Reusable workflow — creates/updates a MokoGitea issue when a CI gate fails.
+# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
+
+name: "Universal: CI Issue Reporter"
+
+on:
+ workflow_call:
+ inputs:
+ gate:
+ description: "CI gate name (e.g. PR Validation, Repository Health)"
+ required: true
+ type: string
+ details:
+ description: "Human-readable failure description"
+ required: true
+ type: string
+ severity:
+ description: "error or warning"
+ required: false
+ type: string
+ default: "error"
+ workflow:
+ description: "Workflow name for the issue title"
+ required: false
+ type: string
+ default: ""
+ secrets:
+ MOKOGITEA_TOKEN:
+ required: true
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ report:
+ name: "Report: ${{ inputs.gate }}"
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone MokoCLI
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
+ git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
+ cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
+
+ - name: Report CI failure
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ run: |
+ chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
+ /tmp/mokocli/cli/ci_issue_reporter.sh \
+ --gate "${{ inputs.gate }}" \
+ --details "${{ inputs.details }}" \
+ --severity "${{ inputs.severity }}" \
+ --workflow "${{ inputs.workflow }}"
diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml
new file mode 100644
index 0000000..b4db32b
--- /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: MokoGitea.Workflow
+# INGROUP: MokoStandards.Maintenance
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /.mokogitea/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:
+ MOKOGITEA_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.MOKOGITEA_TOKEN }}
+
+ - name: Delete merged branches
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ echo "=== Merged Branch Cleanup ==="
+ API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
+
+ # List branches via API
+ BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
+ "${API}/branches?limit=50" | jq -r '.[].name')
+
+ DELETED=0
+ for BRANCH in $BRANCHES; do
+ # Skip protected branches
+ case "$BRANCH" in
+ main|master|dev|develop|rc|beta|alpha|release|release/*|production|stable|staging|hotfix/*|version/*) 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 ${MOKOGITEA_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:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ echo "=== Workflow Run Cleanup ==="
+ API="${MOKOGITEA_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 ${MOKOGITEA_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 ${MOKOGITEA_TOKEN}" \
+ "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
+ DELETED=$((DELETED + 1))
+ done
+
+ echo "Deleted ${DELETED} old workflow run(s)"
diff --git a/.gitea/workflows/codeql-analysis.yml b/.mokogitea/workflows/codeql-analysis.yml
similarity index 100%
rename from .gitea/workflows/codeql-analysis.yml
rename to .mokogitea/workflows/codeql-analysis.yml
diff --git a/.gitea/workflows/copilot-agent.yml b/.mokogitea/workflows/copilot-agent.yml
similarity index 100%
rename from .gitea/workflows/copilot-agent.yml
rename to .mokogitea/workflows/copilot-agent.yml
diff --git a/.gitea/workflows/enterprise-firewall-setup.yml b/.mokogitea/workflows/enterprise-firewall-setup.yml
similarity index 100%
rename from .gitea/workflows/enterprise-firewall-setup.yml
rename to .mokogitea/workflows/enterprise-firewall-setup.yml
diff --git a/.gitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml
similarity index 94%
rename from .gitea/workflows/gitleaks.yml
rename to .mokogitea/workflows/gitleaks.yml
index 0c07612..7312838 100644
--- a/.gitea/workflows/gitleaks.yml
+++ b/.mokogitea/workflows/gitleaks.yml
@@ -3,10 +3,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
+# DEFGROUP: MokoGitea.Workflow
# INGROUP: MokoStandards.Security
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/gitleaks.yml.template
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
+# PATH: /.mokogitea/workflows/gitleaks.yml
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
@@ -25,10 +25,6 @@
name: "Universal: Secret Scanning"
on:
- pull_request:
- branches:
- - main
- - 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
new file mode 100644
index 0000000..eb67f8f
--- /dev/null
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -0,0 +1,73 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Automation
+# VERSION: 01.00.00
+# BRIEF: Auto-create feature branch when an issue is opened
+
+name: "Universal: Issue Branch"
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: write
+ issues: write
+
+env:
+ MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+
+jobs:
+ create-branch:
+ name: Create feature branch
+ runs-on: ubuntu-latest
+ steps:
+ - name: Create branch and comment
+ run: |
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+ API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
+ ISSUE_NUM="${{ github.event.issue.number }}"
+ ISSUE_TITLE="${{ github.event.issue.title }}"
+
+ # Build slug from title: lowercase, replace non-alnum with dash, trim
+ SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
+ BRANCH="feature/${ISSUE_NUM}-${SLUG}"
+
+ # Check dev branch exists
+ DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
+ -H "Authorization: token ${TOKEN}" \
+ "${API}/branches/dev" 2>/dev/null || echo "000")
+
+ if [ "${DEV_EXISTS}" != "200" ]; then
+ echo "No dev branch -- skipping"
+ exit 0
+ fi
+
+ # Create branch from dev
+ HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
+ -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/branches" \
+ -d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
+
+ if [ "${HTTP}" = "201" ]; then
+ echo "Created branch: ${BRANCH}"
+
+ # Comment on issue with branch link
+ REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
+ BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
+
+ curl -sf -X POST \
+ -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/issues/${ISSUE_NUM}/comments" \
+ -d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
+
+ echo "Commented on issue #${ISSUE_NUM}"
+ else
+ echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
+ fi
diff --git a/.gitea/workflows/mcp-auto-release.yml b/.mokogitea/workflows/mcp-auto-release.yml
similarity index 100%
rename from .gitea/workflows/mcp-auto-release.yml
rename to .mokogitea/workflows/mcp-auto-release.yml
diff --git a/.gitea/workflows/mcp-build-test.yml b/.mokogitea/workflows/mcp-build-test.yml
similarity index 100%
rename from .gitea/workflows/mcp-build-test.yml
rename to .mokogitea/workflows/mcp-build-test.yml
diff --git a/.gitea/workflows/mcp-sdk-check.yml b/.mokogitea/workflows/mcp-sdk-check.yml
similarity index 100%
rename from .gitea/workflows/mcp-sdk-check.yml
rename to .mokogitea/workflows/mcp-sdk-check.yml
diff --git a/.gitea/workflows/mcp-tool-inventory.yml b/.mokogitea/workflows/mcp-tool-inventory.yml
similarity index 100%
rename from .gitea/workflows/mcp-tool-inventory.yml
rename to .mokogitea/workflows/mcp-tool-inventory.yml
diff --git a/.gitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml
similarity index 93%
rename from .gitea/workflows/notify.yml
rename to .mokogitea/workflows/notify.yml
index ce804b5..5fead53 100644
--- a/.gitea/workflows/notify.yml
+++ b/.mokogitea/workflows/notify.yml
@@ -3,7 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
+# DEFGROUP: MokoGitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.mokogitea/workflows/notify.yml
@@ -15,10 +15,9 @@ name: "Universal: Notifications"
on:
workflow_run:
workflows:
- - "Joomla Build & Release"
- - "Joomla Extension CI"
- - "Deploy"
- - "Cascade Main → Dev"
+ - "Universal: Build & Release"
+ - "Joomla: Extension CI"
+ - "Generic: Project CI"
types:
- completed
diff --git a/.mokogitea/workflows/npm-publish.yml b/.mokogitea/workflows/npm-publish.yml
new file mode 100644
index 0000000..87ffc3b
--- /dev/null
+++ b/.mokogitea/workflows/npm-publish.yml
@@ -0,0 +1,113 @@
+# Copyright (C) 2026 Moko Consulting
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+name: "Publish to npm"
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+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:
+ publish:
+ runs-on: ubuntu-latest
+ if: >-
+ !contains(github.event.head_commit.message, '[skip ci]') &&
+ !contains(github.event.head_commit.message, '[skip publish]')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Build
+ run: npm run build
+
+ - name: Auto-bump patch version
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+
+ PKG_NAME=$(node -p "require('./package.json').name")
+ CURRENT=$(node -p "require('./package.json').version")
+ PUBLISHED=$(npm view "${PKG_NAME}@latest" version 2>/dev/null || echo "0.0.0")
+
+ if [ "$CURRENT" != "$PUBLISHED" ]; then
+ echo "Version ${CURRENT} not yet published, using as-is."
+ exit 0
+ fi
+
+ # Bump locally to get the new version
+ npm version patch --no-git-tag-version
+ NEW_VER=$(node -p "require('./package.json').version")
+ echo "Bumping ${CURRENT} -> ${NEW_VER}"
+
+ # Push via Gitea API: branch + PR + merge
+ BRANCH="chore/npm-version-bump"
+ FILEPATH="package.json"
+ CONTENT=$(base64 -w 0 < package.json)
+ COMMIT_MSG="chore: bump to ${NEW_VER} [skip ci]"
+
+ # Get current file SHA on main
+ FILE_SHA=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/contents/${FILEPATH}?ref=main" \
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
+
+ # Create chore branch from main
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/branches" \
+ -d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"main\"}" 2>/dev/null || true
+
+ # Get file SHA on chore branch (may differ if branch already existed)
+ BRANCH_SHA=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/contents/${FILEPATH}?ref=${BRANCH}" \
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
+ [ -n "$BRANCH_SHA" ] && FILE_SHA="$BRANCH_SHA"
+
+ # Push package.json to chore branch
+ PAYLOAD="{\"content\":\"${CONTENT}\",\"message\":\"${COMMIT_MSG}\",\"branch\":\"${BRANCH}\",\"sha\":\"${FILE_SHA}\"}"
+ HTTP=$(curl -sf -o /dev/null -w "%{http_code}" -X PUT \
+ -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD" \
+ "${API_BASE}/contents/${FILEPATH}" 2>/dev/null || echo "ERR")
+ echo "File push: HTTP ${HTTP}"
+
+ # Create PR
+ PR_NUM=$(curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/pulls" \
+ -d "{\"title\":\"${COMMIT_MSG}\",\"head\":\"${BRANCH}\",\"base\":\"main\"}" \
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" 2>/dev/null || true)
+
+ if [ -n "$PR_NUM" ]; then
+ # Merge PR
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/pulls/${PR_NUM}/merge" \
+ -d "{\"Do\":\"merge\",\"merge_message_field\":\"${COMMIT_MSG}\"}" 2>/dev/null
+ echo "Version bumped via PR #${PR_NUM} (merged)"
+ else
+ echo "::warning::Could not create PR for version bump — publishing with current version"
+ fi
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Publish
+ run: npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml
new file mode 100644
index 0000000..c7c2e8f
--- /dev/null
+++ b/.mokogitea/workflows/pr-check.yml
@@ -0,0 +1,602 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.CI
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/pr-check.yml
+# VERSION: 09.23.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" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
+ fi
+ ;;
+ patch/*)
+ if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
+ fi
+ ;;
+ hotfix/*)
+ if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
+ fi
+ ;;
+ rc)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="RC branch can only merge into '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\` or \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $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
+
+ # ── Docs Update Gate (main PRs) ─────────────────────────────────────────
+ require-docs:
+ name: Require Docs Update
+ runs-on: ubuntu-latest
+ # Enforce only on PRs merging into main: README.md + CHANGELOG.md must both be updated.
+ if: ${{ github.base_ref == 'main' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Require README.md and CHANGELOG.md in the PR diff
+ run: |
+ BASE="${{ github.event.pull_request.base.sha }}"
+ HEAD="${{ github.event.pull_request.head.sha }}"
+ CHANGED="$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || true)"
+ if [ -z "$CHANGED" ]; then
+ git fetch -q origin "${{ github.base_ref }}" 2>/dev/null || true
+ CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" 2>/dev/null || true)"
+ fi
+ echo "Changed files in PR:"
+ echo "$CHANGED"
+ MISSING=""
+ echo "$CHANGED" | grep -qxE 'README\.md' || MISSING="README.md"
+ echo "$CHANGED" | grep -qxE 'CHANGELOG\.md' || MISSING="${MISSING:+$MISSING, }CHANGELOG.md"
+ if [ -n "$MISSING" ]; then
+ echo "::error::PRs into main must update: ${MISSING}"
+ {
+ echo "## Docs Update Required"
+ echo ""
+ echo "PRs merging into \`main\` must update both **README.md** and **CHANGELOG.md**."
+ echo ""
+ echo "Not updated in this PR: **${MISSING}**"
+ } >> "$GITHUB_STEP_SUMMARY"
+ exit 1
+ fi
+ echo "Docs update present (README.md + CHANGELOG.md)"
+ echo "## Docs Update: Passed" >> "$GITHUB_STEP_SUMMARY"
+
+ # ── Wiki Update Reminder (main PRs, non-blocking) ───────────────────────
+ wiki-reminder:
+ name: Wiki Update Reminder
+ runs-on: ubuntu-latest
+ if: ${{ github.base_ref == 'main' }}
+ steps:
+ - name: Remind to update the wiki
+ env:
+ TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ set -uo pipefail
+ {
+ echo "## Wiki Update Reminder"
+ echo ""
+ echo "Docs are **wiki-first** at MokoConsulting. If this change affects behavior, usage, configuration, or standards, update the repo wiki:"
+ echo ""
+ echo "- ${SERVER}/${REPO}/wiki"
+ echo ""
+ echo "_Non-blocking reminder._"
+ } >> "$GITHUB_STEP_SUMMARY"
+ # Post a single PR comment (idempotent via hidden marker); best-effort, never fails.
+ API="${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments"
+ if [ -n "${TOKEN:-}" ] && [ -n "${PR:-}" ]; then
+ existing="$(curl -sf -H "Authorization: token ${TOKEN}" "$API" 2>/dev/null | grep -c 'wiki-reminder' || true)"
+ if [ "${existing:-0}" -eq 0 ]; then
+ curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -X POST "$API" \
+ -d '{"body":"\n\n**Wiki reminder:** docs are wiki-first -- if this PR changes behavior, usage, config, or standards, please update the repo wiki before/after merge. _(non-blocking)_"}' >/dev/null 2>&1 || true
+ fi
+ fi
+ echo "Wiki reminder emitted (non-blocking)."
+
+ # ── Secret Scanning ──────────────────────────────────────────────────
+ gitleaks:
+ name: 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
+
+ - name: Scan PR commits for secrets
+ run: |
+ if gitleaks detect --source . --verbose \
+ --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
+ echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "::error::Potential secrets detected in PR commits"
+ exit 1
+ fi
+
+ # ── Code Validation ────────────────────────────────────────────────────
+ validate:
+ name: Validate PR
+ runs-on: ubuntu-latest
+ # Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
+ if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Check for merge conflict markers
+ run: |
+ CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --exclude-dir='.git' --exclude-dir='.mokogitea' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
+ if [ -n "$CONFLICTS" ]; then
+ echo "::error::Merge conflict markers found in source files"
+ echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+ echo "No conflict markers found"
+
+ - name: Detect platform
+ id: platform
+ run: |
+ # Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
+ API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
+ PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
+ [ -z "$PLATFORM" ] && PLATFORM="generic"
+ echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
+ echo "Detected platform: $PLATFORM"
+
+ - 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: Joomla JEXEC guard check
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ ERRORS=0
+ while IFS= read -r -d '' file; do
+ # Skip vendor, node_modules, and index.html stub files
+ case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
+ # Check first 10 lines for JEXEC or JPATH guard
+ if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
+ echo "::error file=${file}::Missing JEXEC guard: ${file}"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
+ echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
+ echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+ echo "JEXEC guard: OK"
+
+ - name: Joomla directory listing protection
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ MISSING=0
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && exit 0
+ while IFS= read -r dir; do
+ if [ ! -f "${dir}/index.html" ]; then
+ echo "::warning::Missing index.html in ${dir} (directory listing protection)"
+ MISSING=$((MISSING + 1))
+ fi
+ done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
+ if [ "$MISSING" -gt 0 ]; then
+ echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
+ echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "Directory protection: ${MISSING} missing (advisory)"
+
+ - name: Joomla script file and asset checks
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ ERRORS=0
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ [ -z "$MANIFEST" ] && exit 0
+ MANIFEST_DIR=$(dirname "$MANIFEST")
+
+ # Check scriptfile exists if declared
+ SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
+ if [ -n "$SCRIPTFILE" ]; then
+ if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
+ echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
+ fi
+ fi
+
+ # Require joomla.asset.json and validate it
+ ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
+ if [ -z "$ASSET_JSON" ]; then
+ echo "::error::joomla.asset.json not found — Joomla asset system is required"
+ ERRORS=$((ERRORS + 1))
+ else
+ if command -v php &> /dev/null; then
+ php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
+ echo "::error::joomla.asset.json is not valid JSON"
+ ERRORS=$((ERRORS + 1))
+ }
+ fi
+ echo "joomla.asset.json: valid"
+ fi
+
+ # Validate all XML files in src/ are well-formed
+ XML_ERRORS=0
+ if command -v php &> /dev/null; then
+ while IFS= read -r -d '' xmlfile; do
+ if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
+ XML_ERRORS=$((XML_ERRORS + 1))
+ fi
+ done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
+ fi
+ if [ "$XML_ERRORS" -gt 0 ]; then
+ echo "::error::${XML_ERRORS} XML file(s) are malformed"
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "XML well-formedness: OK"
+ fi
+
+ [ "$ERRORS" -gt 0 ] && exit 1
+ echo "Joomla asset checks: OK"
+
+ - name: Validate platform manifest
+ run: |
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ 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 (MokoSuite 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
+ # Block legacy raw/branch update server URLs on MokoGitea
+ RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
+ if [ -n "$RAW_URLS" ]; then
+ echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the MokoGitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
+ echo "$RAW_URLS"
+ exit 1
+ fi
+ echo "Joomla manifest valid"
+ ;;
+ dolibarr)
+ 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: Validate Joomla language files
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ ERRORS=0
+ WARNINGS=0
+
+ # Require both en-GB and en-US language directories
+ LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
+ if [ -z "$LANG_ROOT" ]; then
+ echo "No language/ directory found — skipping"
+ exit 0
+ fi
+
+ if [ ! -d "$LANG_ROOT/en-GB" ]; then
+ echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
+ ERRORS=$((ERRORS + 1))
+ fi
+ if [ ! -d "$LANG_ROOT/en-US" ]; then
+ echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ # Check that en-GB and en-US have matching .ini files
+ if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
+ for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
+ [ ! -f "$GB_INI" ] && continue
+ US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
+ if [ ! -f "$US_INI" ]; then
+ echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done
+ for US_INI in "$LANG_ROOT/en-US"/*.ini; do
+ [ ! -f "$US_INI" ] && continue
+ GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
+ if [ ! -f "$GB_INI" ]; then
+ echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done
+ fi
+
+ # Find all .ini language files
+ INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
+ if [ -z "$INI_FILES" ]; then
+ echo "No .ini language files found"
+ [ "$ERRORS" -gt 0 ] && exit 1
+ exit 0
+ fi
+
+ echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
+
+ for FILE in $INI_FILES; do
+ FNAME=$(basename "$FILE")
+ LINENUM=0
+ SEEN_KEYS=""
+
+ while IFS= read -r line || [ -n "$line" ]; do
+ LINENUM=$((LINENUM + 1))
+
+ # Skip empty lines and comments
+ [ -z "$line" ] && continue
+ echo "$line" | grep -qE '^\s*;' && continue
+ echo "$line" | grep -qE '^\s*$' && continue
+
+ # Must match KEY="VALUE" format
+ if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
+ echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
+ ERRORS=$((ERRORS + 1))
+ continue
+ fi
+
+ # Extract key and check for duplicates
+ KEY=$(echo "$line" | sed 's/=.*//')
+ if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
+ echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
+ ERRORS=$((ERRORS + 1))
+ fi
+ SEEN_KEYS="${SEEN_KEYS}
+ ${KEY}"
+ done < "$FILE"
+
+ echo " ${FILE}: checked ${LINENUM} lines"
+ done
+
+ # Cross-check en-GB vs en-US key consistency
+ GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
+ US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
+
+ if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
+ for GB_FILE in "$GB_DIR"/*.ini; do
+ [ ! -f "$GB_FILE" ] && continue
+ FNAME=$(basename "$GB_FILE")
+ US_FILE="$US_DIR/$FNAME"
+ [ ! -f "$US_FILE" ] && continue
+
+ GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
+ US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
+
+ # Keys in en-GB but not en-US
+ MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
+ if [ -n "$MISSING_US" ]; then
+ echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
+ echo "$MISSING_US" | while read -r k; do echo " - $k"; done
+ WARNINGS=$((WARNINGS + 1))
+ fi
+
+ # Keys in en-US but not en-GB
+ MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
+ if [ -n "$MISSING_GB" ]; then
+ echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
+ echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
+ WARNINGS=$((WARNINGS + 1))
+ fi
+ done
+ fi
+
+ {
+ echo "### Language File Validation"
+ echo "| Metric | Count |"
+ echo "|---|---|"
+ echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
+ echo "| Errors | ${ERRORS} |"
+ echo "| Warnings | ${WARNINGS} |"
+ } >> $GITHUB_STEP_SUMMARY
+
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "::error::Language validation failed with ${ERRORS} error(s)"
+ exit 1
+ fi
+ echo "Language files: OK (${WARNINGS} warning(s))"
+
+ - name: Check changelog has unreleased entry
+ run: |
+ if [ ! -f "CHANGELOG.md" ]; then
+ echo "::warning::No CHANGELOG.md found"
+ exit 0
+ fi
+ # Check for content under [Unreleased] section
+ if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
+ echo "::error::CHANGELOG.md missing [Unreleased] section"
+ exit 1
+ fi
+ # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
+ UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
+ if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
+ echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
+ echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
+ echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
+ echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+ echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
+
+ - 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; }
+
+ # ── Pre-Release RC Build ─────────────────────────────────────────────────
+ pre-release:
+ name: Build RC Package
+ runs-on: ubuntu-latest
+ needs: [branch-policy, validate]
+ # Run only when both gates succeeded; always() forces evaluation so a skipped
+ # validate (e.g. template repos) skips this job cleanly instead of hanging.
+ if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
+
+ steps:
+ - name: Trigger RC pre-release
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ REPO: ${{ github.repository }}
+ BRANCH: ${{ github.head_ref }}
+ MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ run: |
+ curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
+ echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
+ echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
+
+ # ── Issue Reporter ──────────────────────────────────────────────────────
+ report-issues:
+ name: Report Issues
+ needs: [branch-policy, validate]
+ if: >-
+ always() &&
+ needs.validate.result == 'failure'
+ uses: ./.mokogitea/workflows/ci-issue-reporter.yml
+ with:
+ gate: "PR Validation"
+ workflow: "PR Check"
+ severity: error
+ details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+ secrets: inherit
diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml
new file mode 100644
index 0000000..b212772
--- /dev/null
+++ b/.mokogitea/workflows/pre-release.yml
@@ -0,0 +1,283 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Release
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/pre-release.yml
+# VERSION: 05.02.01
+# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
+
+name: "Universal: Pre-Release"
+
+on:
+ push:
+ branches:
+ - dev
+ - 'fix/**'
+ - 'patch/**'
+ - 'hotfix/**'
+ - 'bugfix/**'
+ - 'chore/**'
+ - alpha
+ - beta
+ - rc
+ 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 || github.ref_name }})"
+ runs-on: release
+ # Skip on template repos (Template-*) — they scaffold other repos and do not release.
+ if: >-
+ !startsWith(github.event.repository.name, 'Template-') &&
+ (
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'push'
+ )
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.MOKOGITEA_TOKEN }}
+ ref: ${{ github.ref_name }}
+ submodules: recursive
+
+ - name: Update submodules to main
+ run: |
+ git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
+
+ - name: Setup mokocli tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ run: |
+ # Use pre-installed /opt/mokocli if available (updated by cron every 6h)
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
+ echo Using pre-installed /opt/mokocli
+ echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
+ else
+ echo Falling back to fresh clone
+ if ! command -v composer > /dev/null 2>&1; 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
+ rm -rf /tmp/mokocli
+ CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoCLI.git
+ git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
+ cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
+ echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
+ fi
+
+ - name: Detect platform
+ id: platform
+ run: |
+ # Auto-detect and update platform if not set in manifest
+ php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
+ php ${MOKO_CLI}/manifest_read.php --path . --github-output
+
+ - name: Check platform eligibility (Joomla only)
+ id: eligibility
+ run: |
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
+ echo "proceed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "proceed=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
+ fi
+
+ - name: Resolve metadata and bump version
+ id: meta
+ if: steps.eligibility.outputs.proceed == 'true'
+ run: |
+ # Auto-detect stability from branch name on push, or use input on dispatch
+ if [ "${{ github.event_name }}" = "push" ]; then
+ case "${{ github.ref_name }}" in
+ rc) STABILITY="release-candidate" ;;
+ alpha) STABILITY="alpha" ;;
+ beta) STABILITY="beta" ;;
+ *) STABILITY="development" ;;
+ esac
+ else
+ STABILITY="${{ inputs.stability || 'development' }}"
+ fi
+
+ 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
+
+ # Bump version via CLI: patch for dev/alpha/beta, minor for RC
+ case "$STABILITY" in
+ release-candidate) BUMP="minor" ;;
+ *) BUMP="patch" ;;
+ esac
+
+ php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
+
+ # Set stability suffix and verify consistency
+ VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
+ VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
+
+ php ${MOKO_CLI}/version_set_platform.php \
+ --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
+ php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
+
+ # Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
+ php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
+
+ # Append suffix for output
+ if [ -n "$SUFFIX" ]; then
+ VERSION="${VERSION}${SUFFIX}"
+ fi
+
+ # Commit version bump
+ git config --local user.email "mokogitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "mokogitea-actions[bot]"
+ git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
+ # Push the bump commit, but do NOT fail the release if the target branch
+ # is protected and the release identity is not on the push allowlist.
+ # The build proceeds from the in-tree bumped version regardless; if the
+ # push is rejected, the next run simply re-bumps from the same base.
+ if ! git push origin HEAD 2>&1; then
+ echo "::warning::Version-bump commit could not be pushed (protected branch?). Building from in-tree version ${VERSION} anyway."
+ fi
+ }
+
+ # Auto-detect element via manifest_element.php
+ php ${MOKO_CLI}/manifest_element.php \
+ --path . --version "$VERSION" --stability "$STABILITY" \
+ --repo "${GITEA_REPO}" --github-output
+
+ # Read back element outputs
+ EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
+ ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.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 "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
+
+ - name: Create release
+ id: release
+ if: steps.eligibility.outputs.proceed == 'true'
+ run: |
+ TAG="${{ steps.meta.outputs.tag }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ php ${MOKO_CLI}/release_create.php \
+ --path . --version "$VERSION" --tag "$TAG" \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
+ --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
+
+ - name: Update release notes from CHANGELOG.md
+ if: steps.eligibility.outputs.proceed == 'true'
+ run: |
+ TAG="${{ steps.meta.outputs.tag }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
+ if [ -f "CHANGELOG.md" ]; then
+ NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+ else
+ NOTES="Release ${VERSION}"
+ fi
+
+ # Update release body via API
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = open('/dev/stdin').read()
+ payload = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=payload, method='PATCH',
+ headers={
+ 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
+ 'Content-Type': 'application/json'
+ })
+ urllib.request.urlopen(req)
+ " <<< "$NOTES"
+ echo "Release notes updated from CHANGELOG.md"
+ fi
+
+ - name: Build package and upload
+ id: package
+ if: steps.eligibility.outputs.proceed == 'true'
+ run: |
+ VERSION="${{ steps.meta.outputs.version }}"
+ TAG="${{ steps.meta.outputs.tag }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ php ${MOKO_CLI}/release_package.php \
+ --path . --version "$VERSION" --tag "$TAG" \
+ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
+ --repo "${GITEA_REPO}" --output /tmp || true
+
+ # updates.xml is generated dynamically by MokoGitea license server
+ # No need to build, commit, or sync updates.xml from workflows
+
+ - name: "Delete lesser pre-release channels (cascade)"
+ if: steps.eligibility.outputs.proceed == 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
+
+ php ${MOKO_CLI}/release_cascade.php \
+ --stability "${{ steps.meta.outputs.stability }}" \
+ --token "${TOKEN}" \
+ --api-base "${API_BASE}"
+
+ - name: Summary
+ if: always()
+ run: |
+ VERSION="${{ steps.meta.outputs.version }}"
+ STABILITY="${{ steps.meta.outputs.stability }}"
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ SHA256="${{ steps.package.outputs.sha256_zip }}"
+ echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml
new file mode 100644
index 0000000..57934ea
--- /dev/null
+++ b/.mokogitea/workflows/rc-revert.yml
@@ -0,0 +1,72 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Universal
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/rc-revert.yml
+# VERSION: 09.23.00
+# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
+
+name: "RC Revert"
+
+on:
+ pull_request:
+ types: [closed]
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ revert:
+ name: Rename rc/ back to dev/
+ runs-on: ubuntu-latest
+ if: >-
+ github.event.pull_request.merged == false &&
+ startsWith(github.event.pull_request.head.ref, 'rc/') &&
+ !startsWith(github.event.repository.name, 'Template-')
+
+ steps:
+ - name: Rename branch
+ env:
+ BRANCH: ${{ github.event.pull_request.head.ref }}
+ REPO: ${{ github.repository }}
+ GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+ TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ set -euo pipefail
+ # BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
+ if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
+ echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
+ fi
+ SUFFIX="${BRANCH#rc/}"
+ DEV_BRANCH="dev/${SUFFIX}"
+ API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
+
+ # Create dev/ branch from rc/ branch
+ STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
+ -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
+ "${API}" 2>/dev/null || true)
+ if [ "$STATUS" = "201" ]; then
+ echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
+ fi
+
+ # Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
+ ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
+ STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
+ -H "Authorization: token ${TOKEN}" \
+ "${API}/${ENCODED}" 2>/dev/null || true)
+ if [ "$STATUS" = "204" ]; then
+ echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
+ fi
+
+ echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
+ echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml
new file mode 100644
index 0000000..54d7cf9
--- /dev/null
+++ b/.mokogitea/workflows/repo-health.yml
@@ -0,0 +1,700 @@
+# ============================================================================
+# 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: MokoGitea.Workflow
+# INGROUP: mokocli.Validation
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/repo-health.yml
+# VERSION: 09.23.00
+# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
+# ============================================================================
+
+name: "Generic: Repo Health"
+
+defaults:
+ run:
+ shell: bash
+
+on:
+ workflow_dispatch:
+ inputs:
+ profile:
+ description: 'Validation profile: all, scripts, or repo'
+ required: true
+ default: all
+ type: choice
+ options:
+ - all
+ - scripts
+ - repo
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+env:
+ # 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,.mokogitea/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: .mokogitea/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.MOKOGITEA_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|mokogitea-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
+
+ 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|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${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
+
+ if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
+ 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|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${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
+
+ IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
+ IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
+ if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
+ IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
+
+ missing_required=()
+ missing_optional=()
+
+ # Source directory: src/ or htdocs/ (either is valid for extension repos)
+ SOURCE_DIR=""
+ if [ -d "src" ]; then
+ SOURCE_DIR="src"
+ elif [ -d "htdocs" ]; then
+ SOURCE_DIR="htdocs"
+ elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
+ # Platform/tooling repos don't need src/
+ SOURCE_DIR=""
+ else
+ missing_required+=("src/ or htdocs/ (source directory required)")
+ fi
+
+ 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 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
+ missing_required+=("dev or dev/* branch")
+ 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=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
+
+ {
+ 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
+
+ if [ -n "${SOURCE_DIR}" ]; then
+ 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
+ fi
+
+ 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=""
+ while IFS= read -r docline; do
+ for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
+ case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
+ linkpath="${link%%#*}"
+ linkpath="${linkpath%%\?*}"
+ [ -z "$linkpath" ] && continue
+ if [ "${linkpath:0:1}" = "/" ]; then
+ testpath="${linkpath#/}"
+ else
+ testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
+ fi
+ [ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
+ done
+ done < "${DOCS_INDEX}"
+ 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:'
+ for bl in ${missing_links}; do
+ printf '%s\n' "- ${bl}"
+ done
+ 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 policy | N/A | Releases handled by MokoGitea |'
+ 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}"
+
+
+ site-health:
+ name: Site Health
+ runs-on: ubuntu-latest
+ if: github.event_name == 'workflow_dispatch'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+
+ - name: Uptime check
+ if: env.URLS != ''
+ run: |
+ echo "$URLS" > /tmp/urls.txt
+ php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
+ rm -f /tmp/urls.txt
+ env:
+ URLS: ${{ vars.MONITORED_URLS }}
+
+ - name: SSL certificate check
+ if: env.DOMAINS != ''
+ run: |
+ echo "$DOMAINS" > /tmp/domains.txt
+ php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
+ rm -f /tmp/domains.txt
+ env:
+ DOMAINS: ${{ vars.MONITORED_DOMAINS }}
+
+ - name: Summary
+ if: always()
+ run: |
+ echo "### Site Health" >> $GITHUB_STEP_SUMMARY
+ echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
+
+ # ═══════════════════════════════════════════════════════════════════════
+ # Issue Reporter — file issues for failed gates
+ # ═══════════════════════════════════════════════════════════════════════
+ report-scripts:
+ name: "Report: Scripts Governance"
+ needs: [access_check, scripts_governance]
+ if: >-
+ always() &&
+ needs.scripts_governance.result == 'failure'
+ uses: ./.mokogitea/workflows/ci-issue-reporter.yml
+ with:
+ gate: "Scripts Governance"
+ workflow: "Repo Health"
+ severity: error
+ details: "Scripts directory policy violations detected. Review required and allowed directories."
+ secrets: inherit
+
+ report-health:
+ name: "Report: Repository Health"
+ needs: [access_check, repo_health]
+ if: >-
+ always() &&
+ needs.repo_health.result == 'failure'
+ uses: ./.mokogitea/workflows/ci-issue-reporter.yml
+ with:
+ gate: "Repository Health"
+ workflow: "Repo Health"
+ severity: error
+ details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+ secrets: inherit
diff --git a/.gitea/workflows/repository-cleanup.yml b/.mokogitea/workflows/repository-cleanup.yml
similarity index 100%
rename from .gitea/workflows/repository-cleanup.yml
rename to .mokogitea/workflows/repository-cleanup.yml
diff --git a/.gitea/workflows/standards-compliance.yml b/.mokogitea/workflows/standards-compliance.yml
similarity index 100%
rename from .gitea/workflows/standards-compliance.yml
rename to .mokogitea/workflows/standards-compliance.yml
diff --git a/.mokogitea/workflows/sync-on-merge.yml b/.mokogitea/workflows/sync-on-merge.yml
new file mode 100644
index 0000000..e362224
--- /dev/null
+++ b/.mokogitea/workflows/sync-on-merge.yml
@@ -0,0 +1,32 @@
+name: Sync Workflows to Repos
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - '.mokogitea/workflows/**'
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ if: ${{ startsWith(github.event.repository.name, 'Template-') }}
+ steps:
+ - name: Checkout mokocli
+ uses: actions/checkout@v4
+ with:
+ repository: MokoConsulting/MokoCLI
+ token: ${{ secrets.MOKOGITEA_TOKEN }}
+
+ - name: Setup PHP
+ uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1
+ with:
+ php-version: '8.1'
+
+ - name: Install dependencies
+ run: composer install --no-dev --no-interaction
+
+ - name: Sync workflows to generic repos
+ run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}"
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
diff --git a/.gitea/workflows/sync-version-on-merge.yml b/.mokogitea/workflows/sync-version-on-merge.yml
similarity index 100%
rename from .gitea/workflows/sync-version-on-merge.yml
rename to .mokogitea/workflows/sync-version-on-merge.yml
diff --git a/.mokogitea/workflows/version-set.yml b/.mokogitea/workflows/version-set.yml
new file mode 100644
index 0000000..eed4192
--- /dev/null
+++ b/.mokogitea/workflows/version-set.yml
@@ -0,0 +1,131 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow.Template
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
+# PATH: /.mokogitea/workflows/version-set.yml
+# VERSION: 01.00.00
+# BRIEF: Set or reset the extension version across all version-bearing files
+
+name: "Joomla: Set Version"
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version number (e.g. 01.00.00)"
+ required: true
+ type: string
+ branch:
+ description: "Branch to update (default: current)"
+ required: false
+ type: string
+
+permissions:
+ contents: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ set-version:
+ name: Set Version to ${{ inputs.version }}
+ runs-on: ubuntu-latest
+ if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
+
+ steps:
+ - name: Validate version format
+ run: |
+ VERSION="${{ inputs.version }}"
+ if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
+ echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
+ exit 1
+ fi
+ echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
+ ref: ${{ inputs.branch || github.ref }}
+ fetch-depth: 1
+
+ - name: Update manifest version
+ run: |
+ MANIFEST=""
+ for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
+ if grep -q "/dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "::warning::No Joomla extension manifest found — skipping manifest update"
+ else
+ OLD_VER=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1)
+ sed -i "s|${OLD_VER}|${VERSION}|" "$MANIFEST"
+ echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
+ fi
+
+ - name: Update README.md version
+ run: |
+ if [ -f "README.md" ]; then
+ if grep -qP '^\s*VERSION:\s*\d' README.md; then
+ sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
+ echo "README.md version updated to ${VERSION}"
+ else
+ echo "::warning::No VERSION line found in README.md — skipping"
+ fi
+ fi
+
+ - name: Update CHANGELOG.md
+ run: |
+ if [ -f "CHANGELOG.md" ]; then
+ DATE=$(date +%Y-%m-%d)
+ # Check if this version already has an entry
+ if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
+ echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
+ else
+ # Insert new version entry after [Unreleased] or at the top after header
+ if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
+ sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
+ else
+ sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
+ fi
+ echo "CHANGELOG.md: added entry for ${VERSION}"
+ fi
+ else
+ echo "::warning::No CHANGELOG.md found — skipping"
+ fi
+
+ - name: Update FILE INFORMATION blocks
+ run: |
+ # Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
+ find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
+ -not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
+ while IFS= read -r -d '' FILE; do
+ if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
+ sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
+ echo "Updated FILE INFORMATION VERSION in ${FILE}"
+ fi
+ done
+
+ - name: Commit and push
+ run: |
+ git config user.name "Moko Consulting [bot]"
+ git config user.email "hello@mokoconsulting.tech"
+ git add -A
+ if git diff --cached --quiet; then
+ echo "No version changes detected — nothing to commit"
+ else
+ git commit -m "chore: set version to ${VERSION} [skip bump]
+
+Authored-by: Moko Consulting"
+ git push
+ echo "### Version Set" >> $GITHUB_STEP_SUMMARY
+ echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
+ fi
diff --git a/.mokogitea/workflows/workflow-sync-trigger.yml b/.mokogitea/workflows/workflow-sync-trigger.yml
new file mode 100644
index 0000000..69b00c4
--- /dev/null
+++ b/.mokogitea/workflows/workflow-sync-trigger.yml
@@ -0,0 +1,82 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: MokoGitea.Workflow
+# INGROUP: mokocli.Universal
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
+# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
+# VERSION: 01.01.00
+# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
+
+name: "Universal: Workflow Sync Trigger"
+
+on:
+ workflow_dispatch:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ sync:
+ name: Sync workflows to live repos
+ runs-on: ubuntu-latest
+ if: >-
+ startsWith(github.event.repository.name, 'Template-') &&
+ (github.event_name == 'workflow_dispatch' ||
+ (github.event.pull_request.merged == true &&
+ !contains(github.event.pull_request.title, '[skip sync]')))
+
+ steps:
+ - name: Determine platform from repo name
+ id: platform
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ case "$REPO" in
+ Template-Joomla) PLATFORM="joomla" ;;
+ Template-Dolibarr) PLATFORM="dolibarr" ;;
+ Template-Go) PLATFORM="go" ;;
+ Template-MCP) PLATFORM="mcp" ;;
+ Template-Generic) PLATFORM="" ;;
+ *) PLATFORM="" ;;
+ esac
+ echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
+ echo "Platform: ${PLATFORM:-all}"
+
+ - name: Clone mokocli
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
+ git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
+
+ - name: Install PHP
+ run: |
+ if ! command -v php &> /dev/null; then
+ apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
+ fi
+
+ - name: Install dependencies
+ run: |
+ cd /tmp/mokocli
+ composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+
+ - name: Run workflow sync
+ env:
+ MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
+ run: |
+ ARGS="--token ${MOKOGITEA_TOKEN}"
+ ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
+ ARGS="${ARGS} --phase repos"
+
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ if [ -n "$PLATFORM" ]; then
+ ARGS="${ARGS} --platform-filter ${PLATFORM}"
+ fi
+
+ php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aec4340..37d8ded 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+- Migrated all workflow and template paths from `.github/` to `.mokogitea/`
+- Template source paths updated: `templates/gitea/` to `templates/mokogitea/`
+- HCL definition files removed -- Template repos are now the canonical source
+
+### Added
+- `branch-cleanup.yml`: auto-delete merged feature branches after PR merge
+
## [0.0.1] - 2026-05-07
### Added
@@ -53,7 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SQL filter builder (`buildSqlFilter`, `searchFilter`) for safe query construction
- Full documentation: README, INSTALLATION, ARCHITECTURE, API reference
- MokoStandards-compliant project structure
-- 12 Gitea Actions CI/CD workflows
+- 12 MokoGitea Actions CI/CD workflows
## Revision History
diff --git a/CLAUDE.md b/CLAUDE.md
index 95b96a9..160488f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code when working with this repository.
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Wiki** | [dolibarr-api-mcp Wiki](https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp/wiki) |
-| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
+| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki) |
## Common Commands
@@ -33,9 +33,11 @@ This is an MCP (Model Context Protocol) server. Key files:
## Rules
+- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
+
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev`, merge to `main` for release
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
-- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
-- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
+- **Wiki**: documentation lives in the MokoGitea wiki, not in `docs/` files
+- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5f21ca0..73f36d9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,161 +1,161 @@
-
-
-# Contributing to dolibarr-api-mcp
-
-We appreciate your interest in contributing to this project! This document provides guidelines for contributing.
-
-## Table of Contents
-
-- [Code of Conduct](#code-of-conduct)
-- [Getting Started](#getting-started)
-- [How to Contribute](#how-to-contribute)
-- [Development Workflow](#development-workflow)
-- [Commit Messages](#commit-messages)
-- [Pull Request Process](#pull-request-process)
-
-## Code of Conduct
-
-This project adheres to the Contributor Covenant Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to hello@mokoconsulting.tech.
-
-## Getting Started
-
-1. Fork the repository
-2. Clone your fork locally
-3. Install dependencies: `npm install`
-4. Build: `npm run build`
-5. Create a new branch for your work
-
-## How to Contribute
-
-### Reporting Bugs
-
-- Use the Gitea issue tracker
-- Describe the bug clearly with steps to reproduce
-- Include the Dolibarr version you're connecting to
-- Include relevant logs or error messages
-
-### Adding New Tools
-
-If you want to add support for a Dolibarr API endpoint not yet covered:
-
-1. Check the [Dolibarr API Explorer](https://your-dolibarr.com/api/index.php/explorer) for endpoint details
-2. Add the tool registration in `src/index.ts` following the existing patterns
-3. Update `docs/API.md` with the new tool's parameter table
-4. Update `README.md` tool listing
-5. Update `CHANGELOG.md`
-
-### Contributing Code
-
-- Pick an issue or create one
-- Fork the repository and create a branch
-- Make your changes following the project conventions
-- Submit a pull request
-
-## Development Workflow
-
-1. Ensure your fork is up to date with the main repository
-2. Create a feature branch from `main`
-3. Make your changes
-4. Test against a Dolibarr instance (use `npm run setup` to configure a dev connection)
-5. Build with `npm run build` to catch TypeScript errors
-6. Commit your changes with clear messages
-7. Push to your fork
-8. Create a pull request
-
-## Commit Messages
-
-Follow the conventional commit format:
-
-```
-():
-
-
-
-