Compare commits

..

3 Commits

Author SHA1 Message Date
gitea-actions[bot] 8fef6a3ce7 chore(version): pre-release bump to 09.38.01-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 26s
2026-06-21 06:21:25 +00:00
gitea-actions[bot] e678f31817 chore(version): auto-bump patch 09.37.08-dev [skip ci] 2026-06-21 06:21:15 +00:00
Jonathan Miller 3ef651e34d fix(version-bump): always run git tag scan, fix null log interpolation
Universal: Sync Feature Branch Versions / Sync feature branches with dev (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 38s
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: mokocli CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: mokocli CI / Gate 4: Governance (push) Has been cancelled
Platform: mokocli CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: mokocli CI / CI Summary (push) Has been cancelled
2026-06-21 01:20:58 -05:00
232 changed files with 25390 additions and 2348 deletions
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# 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
GITEA_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')
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"
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# 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
GITEA_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')
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"
+421 -467
View File
@@ -1,467 +1,421 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.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
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(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
- 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: |
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-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="${GITEA_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
if: >-
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
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-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="${GITEA_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="${GITEA_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.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="${GITEA_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 and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.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"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.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="${GITEA_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](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.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, closed]
branches:
- main
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
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && 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
- 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: |
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-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="${GITEA_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
if: >-
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
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-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"
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: Update release notes and promote changelog
run: |
API_BASE="${GITEA_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.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="${GITEA_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 and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.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"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.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="${GITEA_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](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+1 -1
View File
@@ -4,7 +4,7 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# INGROUP: MokoCli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
@@ -1,54 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Enforce branch protection rules across all org repos.
# Runs weekly and on manual dispatch.
name: "Org: Enforce Branch Protections"
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6am UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (show changes without applying)'
required: false
type: boolean
default: false
jobs:
enforce:
name: Enforce Branch Protections
runs-on: release
steps:
- name: Checkout MokoCLI
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup PHP
run: |
if ! command -v php > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-curl > /dev/null 2>&1
fi
- name: Run branch protection enforcement
run: |
DRY_RUN=""
if [ "${{ inputs.dry_run }}" = "true" ]; then
DRY_RUN="--dry-run"
fi
php cli/branch_protect_org.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--org "MokoConsulting" \
$DRY_RUN
- name: Summary
if: always()
run: |
echo "## Branch Protection Enforcement" >> $GITHUB_STEP_SUMMARY
echo "All repos checked for main, dev, rc, beta, alpha protections" >> $GITHUB_STEP_SUMMARY
echo "Push whitelist: jmiller only" >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -4,7 +4,7 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# INGROUP: MokoCli.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# INGROUP: MokoCli.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+3 -3
View File
@@ -52,15 +52,15 @@ jobs:
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version (points to GitHub mirror which Packagist can access)
# Packagist — notify of new version
- name: Notify Packagist
if: ${{ secrets.PACKAGIST_TOKEN != '' }}
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://github.com/mokoconsulting-tech/mokocli"}}' \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
+4 -4
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# INGROUP: MokoCli.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
@@ -40,7 +40,7 @@ jobs:
run: |
php -v && composer --version
- name: Setup MokoStandards tools
- name: Setup MokoCli tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
@@ -48,7 +48,7 @@ jobs:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# INGROUP: MokoCli.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 09.40.00
# VERSION: 09.38.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# INGROUP: MokoCli.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
File diff suppressed because it is too large Load Diff
-16
View File
@@ -88,20 +88,8 @@ jobs:
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
@@ -178,7 +166,6 @@ jobs:
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -189,7 +176,6 @@ jobs:
--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 }}"
@@ -226,7 +212,6 @@ jobs:
- name: Build package and upload
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
@@ -240,7 +225,6 @@ jobs:
# 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}"
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
-130
View File
@@ -1,130 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.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
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 || secrets.GA_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 "<extension" "$XML_FILE" 2>/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 '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</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
+7 -5
View File
@@ -12,12 +12,14 @@ BRIEF: Release changelog
# Changelog
## [Unreleased]
## [09.40.00] --- 2026-06-25
## [09.37.00] --- 2026-06-21
## [09.39.00] --- 2026-06-25
## [09.37.00] --- 2026-06-21
## [09.39.00] --- 2026-06-25
## [09.36.00] --- 2026-06-21
## [09.39.00] --- 2026-06-23
## [09.36.00] --- 2026-06-21
## [09.39.00] --- 2026-06-23
## [09.35.00] --- 2026-06-21
## [09.35.00] --- 2026-06-21
+1 -1
View File
@@ -6,7 +6,7 @@ DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /README.md
VERSION: 09.40.00
VERSION: 09.38.01
BRIEF: Project overview and documentation
-->
+937
View File
@@ -0,0 +1,937 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/bulk_joomla_template.php
* BRIEF: Bulk scaffold and sync Joomla template repositories
*
* USAGE
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme
* php automation/bulk_joomla_template.php --scaffold --name=MokoTheme --client=administrator
* php automation/bulk_joomla_template.php --sync --repos=MokoTheme,MokoDarkTheme
* php automation/bulk_joomla_template.php --sync --all
* php automation/bulk_joomla_template.php --list
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{
AuditLogger,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
PlatformAdapterFactory
};
/**
* Bulk Joomla Template Manager
*
* Provides three operations for Joomla template projects:
* --scaffold: Create a new template repository with the full directory structure
* --sync: Push mokocli files to existing template repositories
* --list: List all repositories tagged as joomla-template
*
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
*/
class BulkJoomlaTemplate extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '09.23.00';
private GitPlatformAdapter $adapter;
private Config $config;
protected function configure(): void
{
$this->setDescription('Bulk Joomla template management');
$this->addArgument('--org', 'Organization', self::DEFAULT_ORG);
$this->addArgument('--scaffold', 'Create new template repo', false);
$this->addArgument('--sync', 'Sync files to template repos', false);
$this->addArgument('--list', 'List template repos', false);
$this->addArgument('--name', 'Template name for scaffold', '');
$this->addArgument('--client', 'Joomla client: site or admin', 'site');
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--all', 'Sync all tagged repos', false);
$this->addArgument('--sync-updates', 'Sync updates.xml', false);
$this->addArgument('--private', 'Create as private repo', false);
$this->addArgument('--yes', 'Auto-confirm', false);
}
protected function run(): int
{
$this->log("🎨 Joomla Template Manager v" . self::VERSION, 'INFO');
$this->config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($this->config);
} catch (\Exception $e) {
$this->log("❌ Failed to initialize: " . $e->getMessage(), 'ERROR');
return 1;
}
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$platform = $this->adapter->getPlatformName();
$this->log("Platform: {$platform} | Organization: {$org}", 'INFO');
if ($this->getArgument('--list', false)) {
return $this->listTemplateRepos($org);
}
if ($this->getArgument('--scaffold', false)) {
return $this->scaffoldTemplate($org);
}
if ($this->getArgument('--sync', false)) {
return $this->syncTemplates($org);
}
if ($this->getArgument('--sync-updates', false)) {
return $this->syncUpdatesBetweenPlatforms($org);
}
$this->log("❌ Specify --scaffold, --sync, --sync-updates, or --list", 'ERROR');
return 1;
}
// ── List ─────────────────────────────────────────────────────────────
private function listTemplateRepos(string $org): int
{
$repos = $this->findTemplateRepos($org);
if (empty($repos)) {
$this->log("No joomla-template repositories found in {$org}", 'INFO');
return 0;
}
$this->log("\nJoomla template repositories in {$org}:", 'INFO');
foreach ($repos as $repo) {
$vis = ($repo['private'] ?? false) ? 'private' : 'public';
$url = $this->adapter->getRepoWebUrl($org, $repo['name']);
$this->log(" - {$repo['name']} ({$vis}) {$url}", 'INFO');
}
$this->log("\nTotal: " . count($repos), 'INFO');
return 0;
}
// ── Scaffold ─────────────────────────────────────────────────────────
private function scaffoldTemplate(string $org): int
{
$name = $this->getArgument('--name', '');
$client = $this->getArgument('--client', 'site');
$dryRun = $this->dryRun;
if (empty($name)) {
$this->log("❌ --name is required for --scaffold", 'ERROR');
$this->log(" Example: --name=MokoTheme", 'ERROR');
return 1;
}
if (!in_array($client, ['site', 'administrator'], true)) {
$this->log("❌ --client must be 'site' or 'administrator'", 'ERROR');
return 1;
}
$shortName = $this->deriveShortName($name);
$this->log("\nScaffolding Joomla template:", 'INFO');
$this->log(" Name: {$name}", 'INFO');
$this->log(" Short name: {$shortName}", 'INFO');
$this->log(" Client: {$client}", 'INFO');
$this->log(" Element: tpl_{$shortName}", 'INFO');
if ($dryRun) {
$this->log("\n[DRY RUN] Would create repository and scaffold files", 'INFO');
$this->printScaffoldPlan($shortName);
return 0;
}
// Check if repo already exists
try {
$this->adapter->getRepo($org, $name);
$this->log("❌ Repository {$org}/{$name} already exists", 'ERROR');
return 1;
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
// Confirm
if (!$this->getArgument('--yes', false)) {
echo "\nCreate repository {$org}/{$name}? [y/N]: ";
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ($handle) {
fclose($handle);
}
if (!is_string($line) || strtolower(trim($line)) !== 'y') {
$this->log("Cancelled.", 'INFO');
return 0;
}
}
// Create repository
$this->log("\nCreating repository...", 'INFO');
try {
$isPrivate = $this->getArgument('--private', false);
$this->adapter->createOrgRepo($org, $name, [
'description' => "Joomla {$client} template — {$name}",
'private' => $isPrivate,
'auto_init' => true,
]);
$this->log(" ✓ Repository created: {$org}/{$name}", 'INFO');
} catch (\Exception $e) {
$this->log("❌ Failed to create repository: " . $e->getMessage(), 'ERROR');
return 1;
}
// Set topics
try {
$this->adapter->setRepoTopics($org, $name, [
'joomla', 'joomla-template', 'template', "joomla-{$client}",
]);
$this->log(" ✓ Topics set", 'INFO');
} catch (\Exception $e) {
$this->log(" ⚠️ Could not set topics: " . $e->getMessage(), 'WARN');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
// Scaffold files
$this->log("\nScaffolding template files...", 'INFO');
$files = $this->getScaffoldFiles($name, $shortName, $client, $org);
$created = 0;
foreach ($files as $path => $content) {
try {
$this->adapter->createOrUpdateFile(
$org,
$name,
$path,
$content,
"chore: scaffold {$path}"
);
$this->log("{$path}", 'INFO');
$created++;
} catch (\Exception $e) {
$this->log("{$path}: " . $e->getMessage(), 'ERROR');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
// Apply branch protection
try {
$this->adapter->setBranchProtection($org, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
$this->log(" ✓ Branch protection applied", 'INFO');
} catch (\Exception $e) {
$this->log(" ⚠️ Branch protection: " . $e->getMessage(), 'WARN');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
$url = $this->adapter->getRepoWebUrl($org, $name);
$this->log("\n✅ Template scaffolded: {$url}", 'INFO');
$this->log(" {$created} files created", 'INFO');
return 0;
}
// ── Sync ─────────────────────────────────────────────────────────────
private function syncTemplates(string $org): int
{
$repos = [];
if ($this->getArgument('--all', false)) {
$repos = $this->findTemplateRepos($org);
} else {
$reposArg = $this->getArgument('--repos', '');
if (empty($reposArg)) {
$this->log("❌ --repos or --all required for --sync", 'ERROR');
return 1;
}
$names = array_filter(array_map('trim', explode(',', $reposArg)));
foreach ($names as $name) {
$repos[] = ['name' => $name];
}
}
if (empty($repos)) {
$this->log("No template repositories to sync", 'INFO');
return 0;
}
$this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO');
$dryRun = $this->dryRun;
$success = 0;
$failed = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$this->log("\n[{$name}]", 'INFO');
try {
$repoData = $this->adapter->getRepo($org, $name);
$shortName = $this->deriveShortName($name);
$branch = $repoData['default_branch'] ?? 'main';
$syncFiles = $this->getSyncFiles($name, $shortName);
$updated = 0;
foreach ($syncFiles as $path => $content) {
if ($dryRun) {
$this->log(" (dry-run) {$path}", 'INFO');
$updated++;
continue;
}
// Check if file exists
$existingSha = null;
try {
$existing = $this->adapter->getFileContents($org, $name, $path, $branch);
$existingSha = $existing['sha'] ?? null;
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
try {
$this->adapter->createOrUpdateFile(
$org,
$name,
$path,
$content,
"chore: update {$path} from mokocli",
$existingSha,
$branch
);
$this->log("{$path}", 'INFO');
$updated++;
} catch (\Exception $e) {
$this->log("{$path}: " . $e->getMessage(), 'ERROR');
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
$this->log(" {$updated} file(s) synced", 'INFO');
$success++;
} catch (\Exception $e) {
$this->log("{$name}: " . $e->getMessage(), 'ERROR');
$failed++;
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
$this->log("\n" . str_repeat('=', 50), 'INFO');
$this->log("Sync complete: {$success} succeeded, {$failed} failed", 'INFO');
return $failed > 0 ? 1 : 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
private function findTemplateRepos(string $org): array
{
$allRepos = $this->adapter->listOrgRepos($org, true);
$templates = [];
foreach ($allRepos as $repo) {
try {
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
if (in_array('joomla-template', $topics, true)) {
$templates[] = $repo;
}
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
return $templates;
}
private function deriveShortName(string $name): string
{
// MokoTheme → mokotheme, Moko-Dark-Theme → mokodarktheme
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name));
}
private function printScaffoldPlan(string $shortName): void
{
$files = [
'templateDetails.xml',
'updates.xml',
'src/index.php',
'src/error.php',
'src/offline.php',
'src/component.php',
'src/html/index.html',
'src/css/.gitkeep',
'src/js/.gitkeep',
'src/images/.gitkeep',
"src/language/en-GB/tpl_{$shortName}.ini",
"src/language/en-GB/tpl_{$shortName}.sys.ini",
'media/css/.gitkeep',
'media/js/.gitkeep',
'media/images/.gitkeep',
'media/scss/.gitkeep',
'.editorconfig',
];
$this->log("\nFiles that would be created:", 'INFO');
foreach ($files as $f) {
$this->log(" + {$f}", 'INFO');
}
}
/**
* Generate the full set of scaffold files for a new template.
*
* @return array<string, string> path => content
*/
private function getScaffoldFiles(string $name, string $shortName, string $client, string $org): array
{
$element = "tpl_{$shortName}";
$now = date('Y-m-d');
$files = [];
// templateDetails.xml
$files['templateDetails.xml'] = <<<XML
<?xml version="1.0" encoding="utf-8"?>
<extension type="template" client="{$client}" method="upgrade">
<name>{$name}</name>
<creationDate>{$now}</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<version>1.0.0</version>
<description>{$name} — Joomla {$client} template by Moko Consulting</description>
<files>
<filename>index.php</filename>
<filename>component.php</filename>
<filename>error.php</filename>
<filename>offline.php</filename>
<filename>templateDetails.xml</filename>
<folder>html</folder>
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>language</folder>
</files>
<media destination="templates/{$client}/{$shortName}" folder="media">
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>scss</folder>
</media>
<positions>
<position>topbar</position>
<position>navbar</position>
<position>hero</position>
<position>breadcrumbs</position>
<position>sidebar-left</position>
<position>sidebar-right</position>
<position>main-top</position>
<position>main-bottom</position>
<position>footer</position>
<position>debug</position>
</positions>
<updateservers>
<server type="extension" priority="1" name="{$name} Update Server">
https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml
</server>
<server type="extension" priority="2" name="{$name} Update Server">
https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml
</server>
</updateservers>
<config>
<fields name="params">
<fieldset name="basic">
<field name="logoFile" type="media" label="Logo" />
<field name="siteTitle" type="text" label="Site Title" default="" />
<field name="siteDescription" type="text" label="Site Description" default="" />
<field name="colorScheme" type="list" label="Color Scheme" default="light">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (system preference)</option>
</field>
</fieldset>
</fields>
</config>
</extension>
XML;
$files['templateDetails.xml'] = preg_replace('/^\t\t/m', '', $files['templateDetails.xml']);
// updates.xml — dual-platform download URLs (Gitea primary, GitHub secondary)
$files['updates.xml'] = <<<XML
<updates>
<update>
<name>{$name}</name>
<description>{$name} — Moko Consulting Joomla template</description>
<element>tpl_{$shortName}</element>
<type>template</type>
<version>1.0.0</version>
<downloads>
<downloadurl type="full" format="zip">
https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
<downloadurl type="full" format="zip">
https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
</downloads>
<targetplatform name="joomla" version="[56].*"/>
<php_minimum>8.1</php_minimum>
</update>
</updates>
XML;
$files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']);
// src/index.php
$files['src/index.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.TEMPLATE_SHORT_NAME
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
$wa = $this->getWebAssetManager();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<jdoc:include type="metas" />
<jdoc:include type="styles" />
<jdoc:include type="scripts" />
</head>
<body class="site <?php echo $this->direction === 'rtl' ? 'rtl' : 'ltr'; ?>">
<header>
<jdoc:include type="modules" name="topbar" style="none" />
<jdoc:include type="modules" name="navbar" style="none" />
</header>
<jdoc:include type="modules" name="hero" style="none" />
<jdoc:include type="modules" name="breadcrumbs" style="none" />
<main>
<jdoc:include type="modules" name="main-top" style="html5" />
<jdoc:include type="message" />
<jdoc:include type="component" />
<jdoc:include type="modules" name="main-bottom" style="html5" />
</main>
<footer>
<jdoc:include type="modules" name="footer" style="none" />
</footer>
<jdoc:include type="modules" name="debug" style="none" />
</body>
</html>
PHP;
$files['src/index.php'] = str_replace('TEMPLATE_SHORT_NAME', $shortName, $files['src/index.php']);
$files['src/index.php'] = preg_replace('/^\t\t/m', '', $files['src/index.php']);
// src/error.php
$files['src/error.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.error
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
/** @var Joomla\CMS\Document\ErrorDocument $this */
$code = $this->error->getCode();
$message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8');
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo $code; ?> — <?php echo $message; ?></title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo $code; ?></h1>
<p><?php echo $message; ?></p>
<p><a href="<?php echo $this->baseurl; ?>/">Return to homepage</a></p>
</div>
</body>
</html>
PHP;
$files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']);
// src/offline.php
$files['src/offline.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo htmlspecialchars($app->get('sitename')); ?> — Maintenance</title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo htmlspecialchars($app->get('sitename')); ?></h1>
<p><?php echo $app->get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?></p>
<jdoc:include type="message" />
<form action="<?php echo $this->baseurl; ?>/index.php" method="post">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
<input type="hidden" name="option" value="com_users" />
<input type="hidden" name="task" value="user.login" />
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div>
</body>
</html>
PHP;
$files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']);
// src/component.php
$files['src/component.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.component
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<jdoc:include type="head" />
</head>
<body class="contentpane">
<jdoc:include type="message" />
<jdoc:include type="component" />
</body>
</html>
PHP;
$files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']);
// Directory keepfiles
$files['src/html/index.html'] = '<!DOCTYPE html><title></title>';
$files['src/css/.gitkeep'] = '';
$files['src/js/.gitkeep'] = '';
$files['src/images/.gitkeep'] = '';
$files['media/css/.gitkeep'] = '';
$files['media/js/.gitkeep'] = '';
$files['media/images/.gitkeep'] = '';
$files['media/scss/.gitkeep'] = '';
// Language files
$files["src/language/en-GB/{$element}.ini"] = "; {$name} language strings\n";
$files["src/language/en-GB/{$element}.sys.ini"] =
"; {$name} system language strings\n"
. "{$element}=\"{$name}\"\n"
. "{$element}_XML_DESCRIPTION=\"{$name} Joomla template by Moko Consulting\"\n";
// .editorconfig
$repoRoot = dirname(__DIR__, 2);
$editorConfig = "{$repoRoot}/templates/configs/.editorconfig";
if (file_exists($editorConfig)) {
$files['.editorconfig'] = file_get_contents($editorConfig) ?: '';
}
return $files;
}
/**
* Get files to sync to existing template repos (standards-only, no template code).
*
* @return array<string, string> path => content
*/
private function getSyncFiles(string $name, string $shortName): array
{
$repoRoot = dirname(__DIR__, 2);
$files = [];
// Sync standards files from templates/
$standardsFiles = [
'SECURITY.md' => 'templates/docs/required/template-SECURITY.md',
'CODE_OF_CONDUCT.md' => 'templates/docs/extra/template-CODE_OF_CONDUCT.md',
'CONTRIBUTING.md' => 'templates/docs/required/template-CONTRIBUTING.md',
'.editorconfig' => 'templates/configs/.editorconfig',
];
foreach ($standardsFiles as $dest => $source) {
$fullPath = "{$repoRoot}/{$source}";
if (file_exists($fullPath)) {
$files[$dest] = file_get_contents($fullPath) ?: '';
}
}
return $files;
}
// ── Sync updates.xml between platforms ───────────────────────────────
/**
* Sync updates.xml (or updates.xml) between Gitea and GitHub for Joomla repos.
*
* Reads the file from both platforms, compares by latest <version> tag,
* and pushes the newer one to the stale platform.
*
* Designed to be called from a CI workflow via:
* php automation/bulk_joomla_template.php --sync-updates --repos=MokoCassiopeia
*/
private function syncUpdatesBetweenPlatforms(string $org): int
{
$repos = [];
if ($this->getArgument('--all', false)) {
$repos = $this->findTemplateRepos($org);
// Also include waas-component repos
$allRepos = $this->adapter->listOrgRepos($org, true);
foreach ($allRepos as $repo) {
try {
$topics = $this->adapter->getRepoTopics($org, $repo['name']);
if (in_array('joomla', $topics, true) || in_array('joomla-extension', $topics, true)) {
$repos[] = $repo;
}
} catch (\Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
// Deduplicate
$seen = [];
$repos = array_filter($repos, function ($r) use (&$seen) {
if (isset($seen[$r['name']])) {
return false;
}
$seen[$r['name']] = true;
return true;
});
} else {
$reposArg = $this->getArgument('--repos', '');
if (empty($reposArg)) {
$this->log("❌ --repos or --all required for --sync-updates", 'ERROR');
return 1;
}
$names = array_filter(array_map('trim', explode(',', $reposArg)));
foreach ($names as $name) {
$repos[] = ['name' => $name];
}
}
if (empty($repos)) {
$this->log("No Joomla repositories to sync updates for", 'INFO');
return 0;
}
// Create both platform adapters
try {
$adapters = PlatformAdapterFactory::createBoth($this->config);
} catch (\Exception $e) {
$this->log("❌ Both platform tokens required for --sync-updates: " . $e->getMessage(), 'ERROR');
return 1;
}
$gitea = $adapters['gitea'];
$github = $adapters['github'];
$dryRun = $this->dryRun;
$this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO');
$synced = 0;
$failed = 0;
foreach ($repos as $repo) {
$name = $repo['name'];
$this->log("\n[{$name}]", 'INFO');
// Try both updates.xml and updates.xml filenames
$updateFile = $this->resolveUpdateFile($gitea, $github, $org, $name);
if ($updateFile === null) {
$this->log(" ⊘ No update(s).xml found on either platform", 'INFO');
continue;
}
$fileName = $updateFile['name'];
$source = $updateFile['source']; // 'gitea' or 'github'
$content = $updateFile['content'];
$target = $source === 'gitea' ? 'github' : 'gitea';
$targetAdapter = $source === 'gitea' ? $github : $gitea;
$this->log(" Source: {$source} ({$fileName})", 'INFO');
if ($dryRun) {
$this->log(" (dry-run) Would push {$fileName} to {$target}", 'INFO');
$synced++;
continue;
}
// Push to the other platform
try {
$existingSha = null;
try {
$existing = $targetAdapter->getFileContents($org, $name, $fileName);
$existingSha = $existing['sha'] ?? null;
// Compare content — skip if identical
$existingContent = base64_decode($existing['content'] ?? '');
if (trim($existingContent) === trim($content)) {
$this->log(" ✓ Already in sync", 'INFO');
$synced++;
continue;
}
} catch (\Exception $e) {
$targetAdapter->getApiClient()->resetCircuitBreaker();
}
$targetAdapter->createOrUpdateFile(
$org,
$name,
$fileName,
$content,
"chore: sync {$fileName} from {$source}",
$existingSha
);
$this->log(" ✓ Pushed to {$target}", 'INFO');
$synced++;
} catch (\Exception $e) {
$this->log(" ✗ Failed to push to {$target}: " . $e->getMessage(), 'ERROR');
$targetAdapter->getApiClient()->resetCircuitBreaker();
$failed++;
}
}
$this->log("\n" . str_repeat('=', 50), 'INFO');
$this->log("Updates sync complete: {$synced} synced, {$failed} failed", 'INFO');
return $failed > 0 ? 1 : 0;
}
/**
* Find the updates file on both platforms, return the one with the higher version.
*
* Checks both `updates.xml` and `updates.xml` filenames.
* Returns the content from the platform with the newer <version>.
* Gitea wins ties (primary platform).
*
* @return array{name: string, source: string, content: string}|null
*/
private function resolveUpdateFile(
GitPlatformAdapter $gitea,
GitPlatformAdapter $github,
string $org,
string $name
): ?array {
$candidates = ['updates.xml', 'updates.xml'];
$found = []; // platform => [name, content, version]
foreach (['gitea' => $gitea, 'github' => $github] as $platform => $adapter) {
foreach ($candidates as $fileName) {
try {
$file = $adapter->getFileContents($org, $name, $fileName);
$content = base64_decode($file['content'] ?? '');
// Extract latest version from the XML
$version = '0.0.0';
if (preg_match('/<version>([^<]+)<\/version>/', $content, $m)) {
$version = trim($m[1]);
}
$found[$platform] = [
'name' => $fileName,
'content' => $content,
'version' => $version,
];
break; // Found one — stop checking other filenames for this platform
} catch (\Exception $e) {
$adapter->getApiClient()->resetCircuitBreaker();
}
}
}
if (empty($found)) {
return null;
}
// If only one platform has it, that's the source
if (count($found) === 1) {
$platform = array_key_first($found);
return [
'name' => $found[$platform]['name'],
'source' => $platform,
'content' => $found[$platform]['content'],
];
}
// Both have it — pick the one with the higher version (Gitea wins ties)
$giteaVer = $found['gitea']['version'];
$githubVer = $found['github']['version'];
$source = version_compare($githubVer, $giteaVer, '>') ? 'github' : 'gitea';
return [
'name' => $found[$source]['name'],
'source' => $source,
'content' => $found[$source]['content'],
];
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new BulkJoomlaTemplate();
exit($app->execute());
}
+1428
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then
usage
fi
GITEA_URL="${1%/}"
TOKEN="$2"
ORG="$3"
WORKFLOW="$4"
REF="${5:-main}"
INPUTS="${6:-{\}}"
# ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated
# ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1
LIMIT=50
ALL_REPOS=""
while true; do
RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
# ---------------------------------------------------------------------------
# Filter for client-waas repos
# ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'."
exit 0
fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo ""
# ---------------------------------------------------------------------------
# Trigger workflow for each repo
# ---------------------------------------------------------------------------
SUCCESS=0
FAIL=0
while IFS= read -r REPO; do
[ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1))
else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1))
fi
done <<< "$CLIENT_REPOS"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# =============================================================================
# enforce_tags.sh — Ensure all repos have the 5 standard release channel tags
#
# Standard tags: development, alpha, beta, release-candidate, stable
# Also removes non-standard tags (keeps vXX production tags)
#
# Usage:
# GA_TOKEN=xxx ./enforce_tags.sh [--dry-run] [--repos repo1,repo2]
#
# Called by: bulk-repo-sync.yml, infrastructure-tests/mirror-check.yml
# =============================================================================
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
ORG="${GITEA_ORG:-MokoConsulting}"
TOKEN="${GA_TOKEN:?GA_TOKEN required}"
DRY_RUN=false
FILTER_REPOS=""
STANDARD_TAGS=("development" "alpha" "beta" "release-candidate" "stable")
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--repos) FILTER_REPOS="$2"; shift 2 ;;
*) shift ;;
esac
done
api() {
local method="$1" path="$2" data="${3:-}"
local args=(-sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -X "$method")
[[ -n "$data" ]] && args+=(-d "$data")
curl "${args[@]}" "$GITEA_URL/api/v1$path" 2>/dev/null
}
# Get repos
REPOS=""
for page in 1 2 3; do
BATCH=$(api GET "/orgs/$ORG/repos?limit=50&page=$page" | python3 -c "
import sys,json
for r in json.load(sys.stdin):
if not r.get(empty) and not r.get(archived):
print(r[name])
" 2>/dev/null)
[[ -z "$BATCH" ]] && break
REPOS="$REPOS $BATCH"
done
# Filter if specified
if [[ -n "$FILTER_REPOS" ]]; then
FILTERED=""
IFS=, read -ra FILTER_ARR <<< "$FILTER_REPOS"
for repo in $REPOS; do
for f in "${FILTER_ARR[@]}"; do
[[ "$repo" == "$f" ]] && FILTERED="$FILTERED $repo"
done
done
REPOS="$FILTERED"
fi
TOTAL=$(echo $REPOS | wc -w)
ADDED=0
DELETED=0
ERRORS=0
echo "Enforcing tags on $TOTAL repos (dry_run=$DRY_RUN)"
for repo in $REPOS; do
TAGS=$(api GET "/repos/$ORG/$repo/tags?limit=50" | python3 -c "import sys,json; print( .join(t[name] for t in json.load(sys.stdin)))" 2>/dev/null)
MAIN_SHA=$(api GET "/repos/$ORG/$repo/branches/main" | python3 -c "import sys,json; print(json.load(sys.stdin)[commit][id])" 2>/dev/null)
[[ -z "$MAIN_SHA" ]] && continue
# Add missing standard tags
for st in "${STANDARD_TAGS[@]}"; do
if ! echo " $TAGS " | grep -q " $st "; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] ADD $repo: $st"
else
STATUS=$(api POST "/repos/$ORG/$repo/tags" "{\"tag_name\":\"$st\",\"target\":\"$MAIN_SHA\"}" | python3 -c "import sys,json; print(ok)" 2>/dev/null || echo "err")
[[ "$STATUS" == "ok" ]] && ADDED=$((ADDED + 1)) || ERRORS=$((ERRORS + 1))
fi
fi
done
# Remove non-standard tags
for t in $TAGS; do
IS_STD=false
for st in "${STANDARD_TAGS[@]}"; do [[ "$t" == "$st" ]] && IS_STD=true; done
# Keep vXX production tags
if [[ "$t" =~ ^v[0-9]{1,3}$ ]]; then IS_STD=true; fi
if [[ "$IS_STD" == "false" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] DEL $repo: $t"
else
# Delete release first if exists
api DELETE "/repos/$ORG/$repo/releases/tags/$t" > /dev/null 2>&1 || true
api DELETE "/repos/$ORG/$repo/tags/$t" > /dev/null 2>&1
DELETED=$((DELETED + 1))
echo " DEL $repo: $t"
fi
fi
done
done
echo "Done: $ADDED added, $DELETED deleted, $ERRORS errors (dry_run=$DRY_RUN)"
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_manifest_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class EnrichManifestXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== mokocli XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokocli')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? ManifestParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
// composer.json
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
// Scripts from Makefile + composer
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = ManifestParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichManifestXmlCli();
exit($app->execute());
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_mokostandards_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class EnrichMokostandardsXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== mokocli XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokocli')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? ManifestParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
$commitMsg = "chore: enrich .mokostandards"
. " with build/deploy/scripts\n\n"
. "Auto-detected: {$details}";
[$cr, $co] = $this->gitCmd(
$workDir,
'commit',
'-m',
$commitMsg
);
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = ManifestParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichMokostandardsXmlCli();
exit($app->execute());
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Example configuration file for file-distributor.ps1 v02.00.00",
"SourceFile": "C:\\path\\to\\your\\source\\file.txt",
"RootDirectory": "C:\\path\\to\\root\\directory",
"Depth": 1,
"DryRun": true,
"Overwrite": false,
"ConfirmEach": false,
"IncludeHidden": true,
"LogDirectory": "C:\\path\\to\\logs"
}
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /automation/index.md
BRIEF: Automation directory index
-->
# Docs Index: /api/automation
## Purpose
This index provides navigation to documentation within this folder.
## Documents
- [README-file-distributor](./README-file-distributor.md)
- [README](./README.md)
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is automatically generated by rebuild_indexes.py
## Revision History
| Date | Author | Change | Notes |
| ---------- | ------------------ | ----------------- | ------------------------------------------ |
| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation |
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
*
* USAGE
* php automation/migrate_to_gitea.php --dry-run
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
* php automation/migrate_to_gitea.php --exclude mokocli --skip-archived
* php automation/migrate_to_gitea.php --resume
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoCli\CheckpointManager;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
use MokoCli\GitHubAdapter;
use MokoCli\MokoGiteaAdapter;
/**
* Gitea Migration Script
*
* Migrates repositories from GitHub to a self-hosted Gitea instance.
* Uses Gitea's built-in migration endpoint for git history, tags, releases,
* issues, and labels. Post-migration applies branch protection, topics,
* and workflow conversion.
*/
class MigrateToGitea extends CliFramework
{
private ?GitHubAdapter $github = null;
private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null;
protected function configure(): void
{
$this->setDescription('Migrate repositories from GitHub to Gitea');
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
$this->addArgument('--resume', 'Resume from last checkpoint', false);
$this->addArgument('--github-token', 'GitHub token override', '');
$this->addArgument('--gitea-token', 'Gitea token override', '');
}
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
$skipArchived = (bool) $this->getArgument('--skip-archived');
$resume = (bool) $this->getArgument('--resume');
$config = Config::load();
// Override tokens if provided
$ghToken = (string) $this->getArgument('--github-token');
$giteaToken = (string) $this->getArgument('--gitea-token');
if ($ghToken !== '') {
$config->set('github.token', $ghToken);
}
if ($giteaToken !== '') {
$config->set('gitea.token', $giteaToken);
}
// Create both adapters
try {
$adapters = PlatformAdapterFactory::createBoth($config);
$this->github = $adapters['github'];
$this->gitea = $adapters['gitea'];
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
$org = $config->getString('github.organization', 'MokoConsulting');
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
echo "=== Gitea Migration Tool ===\n";
echo "Source: GitHub ({$org})\n";
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
// ── Phase 1: Discovery ──────────────────────────────────────────
$this->section('Phase 1: Discovery');
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
// Filter repos
if (!empty($specificRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
// Check which already exist on Gitea
$giteaRepos = [];
try {
$existing = $this->gitea->listOrgRepos($giteaOrg);
foreach ($existing as $r) {
$giteaRepos[$r['name']] = true;
}
} catch (\Exception $e) {
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
}
$toMigrate = [];
$toSkip = [];
foreach ($ghRepos as $repo) {
$name = $repo['name'];
if (isset($giteaRepos[$name])) {
$toSkip[] = $name;
} else {
$toMigrate[] = $repo;
}
}
echo "\nMigration plan:\n";
echo " Migrate: " . count($toMigrate) . " repositories\n";
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
if (!empty($toSkip)) {
echo " Skipped: " . implode(', ', $toSkip) . "\n";
}
echo "\n";
if (empty($toMigrate)) {
echo "Nothing to migrate.\n";
return 0;
}
if ($dryRun) {
echo "Repositories to migrate:\n";
foreach ($toMigrate as $repo) {
$vis = $repo['private'] ? 'private' : 'public';
echo " - {$repo['name']} ({$vis})\n";
}
echo "\nDry run complete. Use without --dry-run to execute.\n";
return 0;
}
// ── Phase 2: Migrate ────────────────────────────────────────────
$this->section('Phase 2: Migration');
$ghToken = $config->getString('github.token');
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
// Resume support
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
$startFrom = $checkpoint['last_completed'] ?? '';
$skipUntil = !empty($startFrom);
foreach ($toMigrate as $index => $repo) {
$name = $repo['name'];
if ($skipUntil) {
if ($name === $startFrom) {
$skipUntil = false;
}
echo " Skipping {$name} (already migrated)\n";
continue;
}
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
try {
// Shallow migration — copy current branch state only, no past
// commit history. This gives every repo a clean start on Gitea.
$this->gitea->migrateRepository([
'clone_addr' => "https://github.com/{$org}/{$name}.git",
'repo_name' => $name,
'repo_owner' => $giteaOrg,
'service' => 'github',
'auth_token' => $ghToken,
'mirror' => false,
'private' => $repo['private'],
'issues' => false,
'labels' => true,
'milestones' => false,
'releases' => false,
'pull_requests' => false,
'wiki' => false,
]);
echo " Migrated successfully\n";
$results['migrated'][] = $name;
// Save checkpoint after each successful migration
$this->checkpoints->saveCheckpoint('gitea_migration', [
'last_completed' => $name,
'migrated' => $results['migrated'],
'failed' => $results['failed'],
]);
} catch (\Exception $e) {
echo " FAILED: " . $e->getMessage() . "\n";
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 3: Post-migration ─────────────────────────────────────
$this->section('Phase 3: Post-migration');
foreach ($results['migrated'] as $name) {
echo " Post-processing {$name}...\n";
try {
// Apply topics from GitHub
$ghTopics = $this->github->getRepoTopics($org, $name);
if (!empty($ghTopics)) {
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
echo " Topics applied\n";
}
// Apply branch protection
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
echo " Branch protection applied\n";
} catch (\Exception $e) {
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 4: Verification ───────────────────────────────────────
$this->section('Phase 4: Verification');
$report = "## Migration Report\n\n";
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
$report .= "**Source:** GitHub ({$org})\n";
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
$report .= "### Results\n\n";
$report .= "| Status | Count |\n|--------|-------|\n";
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
$report .= "| Failed | " . count($results['failed']) . " |\n";
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
if (!empty($results['migrated'])) {
$report .= "### Migrated Repositories\n\n";
foreach ($results['migrated'] as $name) {
$report .= "- {$name}\n";
}
$report .= "\n";
}
if (!empty($results['failed'])) {
$report .= "### Failed Repositories\n\n";
foreach ($results['failed'] as $fail) {
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
}
$report .= "\n";
}
echo $report;
// Create summary issue on Gitea
try {
$this->gitea->createIssue(
$giteaOrg,
'mokocli',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
);
echo "Migration report issue created on Gitea.\n";
} catch (\Exception $e) {
echo "Could not create report issue: " . $e->getMessage() . "\n";
}
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
. count($results['failed']) . " failed, "
. count($results['skipped']) . " skipped\n";
return count($results['failed']) > 0 ? 1 : 0;
}
}
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
exit($script->execute());
+683
View File
@@ -0,0 +1,683 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_files.php
* BRIEF: Push one or more specific files to one or more remote repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{
ApiClient,
AuditLogger,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
PlatformAdapterFactory,
ProjectTypeDetector
};
/**
* Targeted File Push Tool
*
* Pushes one or more specific files from mokocli templates to one or
* more remote repositories — without running a full sync.
*
* Files are specified by their destination path as they appear in the target
* repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up
* the matching source template from the appropriate platform definition.
*
* Files may also be given as "source:destination" pairs to bypass definition
* lookup and push any arbitrary local file.
*
* Usage:
* php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
*/
class PushFiles extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '09.23.00';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private ProjectTypeDetector $typeDetector;
/**
* Setup command-line arguments
*/
protected function configure(): void
{
$this->setDescription('Push files to remote repositories');
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--files', 'Files to push (comma-separated)', '');
$this->addArgument('--message', 'Custom commit message', '');
$this->addArgument('--branch', 'Target branch for direct pushes', '');
$this->addArgument('--direct', 'Push directly instead of PR', false);
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
}
/**
* Main execution
*/
protected function run(): int
{
$this->log('📦 mokocli File Push v' . self::VERSION, 'INFO');
if (!$this->initializeComponents()) {
return 1;
}
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$reposArg = $this->getArgument('--repos', '');
$filesArg = $this->getArgument('--files', '');
$direct = $this->getArgument('--direct', false);
$autoYes = $this->getArgument('--yes', false);
// Validate required arguments
if (empty($reposArg)) {
$this->log('❌ --repos is required. Specify one or more repository names.', 'ERROR');
$this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR');
return 1;
}
if (empty($filesArg)) {
$this->log('❌ --files is required. Specify destination paths or source:destination pairs.', 'ERROR');
$this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR');
return 1;
}
$repos = $this->parseList($reposArg);
$files = $this->parseList($filesArg);
$this->log("Organisation: {$org}", 'INFO');
$this->log('Repositories: ' . implode(', ', $repos), 'INFO');
$this->log('Files: ' . implode(', ', $files), 'INFO');
$this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO');
// Resolve file mappings for each repo
$this->log("\n🔍 Resolving file mappings...", 'INFO');
$repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files);
if (empty($repoFileMaps)) {
$this->log('❌ No files could be resolved. Check file paths and platform definitions.', 'ERROR');
return 1;
}
// Confirm before proceeding
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
$this->log('❌ Cancelled.', 'INFO');
return 0;
}
// Execute pushes
$results = $this->executePushes($org, $repoFileMaps, $direct);
$this->displayResults($results);
if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) {
$this->createFailureIssue($org, $results);
}
return $results['failed'] > 0 ? 1 : 0;
}
/**
* Initialize enterprise components
*/
private function initializeComponents(): bool
{
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
$this->logger = new AuditLogger('push_files');
$this->typeDetector = new ProjectTypeDetector($this->logger);
$platform = $this->adapter->getPlatformName();
$this->log("✓ Components initialized for platform: {$platform}", 'INFO');
return true;
} catch (\Exception $e) {
$this->log('❌ Failed to initialize: ' . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Parse a comma- or space-separated list into a clean array
*/
private function parseList(string $input): array
{
return array_values(array_filter(
array_map('trim', preg_split('/[\s,]+/', $input)),
fn($v) => $v !== ''
));
}
/**
* Build per-repo file maps: repo → [ [source, destination], … ]
*
* Each entry in $files is either:
* - "destination/path" → looked up in the platform definition
* - "source/path:destination/path" → used as-is (raw mode)
*
* @param string[] $repos
* @param string[] $files
* @return array<string, list<array{source: string, destination: string}>>
*/
private function buildRepoFileMaps(string $org, array $repos, array $files): array
{
$repoRoot = dirname(__DIR__, 2);
$maps = [];
foreach ($repos as $repo) {
// Detect the repo's platform so we load the right definition
$platform = $this->detectRepoPlatform($org, $repo);
$this->log(" {$repo}: platform = {$platform}", 'INFO');
$resolved = [];
foreach ($files as $fileSpec) {
if (str_contains($fileSpec, ':')) {
// Raw source:destination pair
[$src, $dest] = explode(':', $fileSpec, 2);
} else {
// Same path as source and destination
$src = $fileSpec;
$dest = $fileSpec;
}
$dest = ltrim($dest, '/');
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
if (!file_exists($srcAbs)) {
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
continue;
}
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
$this->log("{$dest}", 'INFO');
}
if (!empty($resolved)) {
$maps[$repo] = $resolved;
}
}
return $maps;
}
/**
* Detect platform for a repo via manifest or live detection.
*/
private function detectRepoPlatform(string $org, string $repo): string
{
// Read platform from repo's .mokogitea/manifest.xml via API
try {
$fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
if (!empty($manifestData)) {
$xml = @simplexml_load_string($manifestData);
if ($xml !== false) {
$platform = (string)($xml->governance->platform ?? '');
if (!empty($platform)) {
return $platform;
}
}
}
} catch (\Exception $e) {
// Fall through to local detection
}
// Fall back to live detection
try {
$result = $this->typeDetector->detect('.');
return $result['type'] ?? 'default';
} catch (\Exception $e) {
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
return 'default';
}
}
/**
* Prompt for confirmation before pushing
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
*/
private function confirmPush(array $repoFileMaps, bool $direct): bool
{
if ($this->quiet) {
return true;
}
$totalFiles = array_sum(array_map('count', $repoFileMaps));
$totalRepos = count($repoFileMaps);
$mode = $direct ? 'direct commit' : 'PR';
echo "\n";
foreach ($repoFileMaps as $repo => $entries) {
echo " {$repo}:\n";
foreach ($entries as $entry) {
echo "{$entry['destination']}\n";
}
}
echo "\n";
echo "⚠️ About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n";
echo "Continue? [y/N]: ";
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ($handle) {
fclose($handle);
}
return is_string($line) && strtolower(trim($line)) === 'y';
}
/**
* Execute all file pushes
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
* @return array{total: int, success: int, failed: int, repos: array<string, string>}
*/
private function executePushes(string $org, array $repoFileMaps, bool $direct): array
{
$results = [
'total' => count($repoFileMaps),
'success' => 0,
'failed' => 0,
'repos' => [],
];
$customMessage = $this->getArgument('--message', '');
$targetBranch = $this->getArgument('--branch', '');
foreach ($repoFileMaps as $repo => $entries) {
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
try {
// Resolve the default branch
$repoData = $this->adapter->getRepo($org, $repo);
$defaultBranch = $repoData['default_branch'] ?? 'main';
$branch = $direct
? ($targetBranch ?: $defaultBranch)
: $this->createSyncBranch($org, $repo, $defaultBranch);
$pushed = 0;
foreach ($entries as $entry) {
if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) {
$pushed++;
$this->log("{$entry['destination']}", 'INFO');
} else {
$this->log("{$entry['destination']}", 'ERROR');
}
}
if ($pushed === 0) {
$results['failed']++;
$results['repos'][$repo] = 'failed';
continue;
}
$prNumber = null;
if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from mokocli";
$prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest(
$org,
$repo,
$prTitle,
$branch,
$defaultBranch,
$prBody,
['assignees' => ['jmiller']]
);
$prNumber = $pr['number'] ?? null;
$this->log(" 📋 PR #{$prNumber} created", 'INFO');
$results['repos'][$repo] = "pr#{$prNumber}";
} else {
$results['repos'][$repo] = 'pushed';
}
if (!isset($this->options['no-issue']) && !$this->dryRun) {
$this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null);
}
$results['success']++;
} catch (\Exception $e) {
$this->log("{$repo}: " . $e->getMessage(), 'ERROR');
$results['failed']++;
$results['repos'][$repo] = 'failed';
}
}
return $results;
}
/**
* Create a uniquely-named sync branch off the default branch
*/
private function createSyncBranch(string $org, string $repo, string $base): string
{
$branchName = 'moko/push-files-' . date('Ymd-His');
// Resolve the base branch to a commit SHA using the adapter
$sha = $this->adapter->resolveRef($org, $repo, $base);
if (empty($sha)) {
throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}");
}
$this->api->post("/repos/{$org}/{$repo}/git/refs", [
'ref' => "refs/heads/{$branchName}",
'sha' => $sha,
]);
$this->log(" 🌿 Branch created: {$branchName}", 'INFO');
return $branchName;
}
/**
* Push a single file to a repository branch via the Contents API
*
* @return bool True on success
*/
private function pushSingleFile(
string $org,
string $repo,
string $sourcePath,
string $destPath,
string $branch,
string $customMessage
): bool {
$content = file_get_contents($sourcePath);
if ($content === false) {
$this->log(" ⚠️ Cannot read source: {$sourcePath}", 'WARN');
return false;
}
$message = !empty($customMessage)
? $customMessage
: "chore: update {$destPath} from mokocli";
// Fetch existing file SHA (needed for updates)
$existingSha = null;
try {
$existing = $this->adapter->getFileContents($org, $repo, $destPath, $branch);
$existingSha = $existing['sha'] ?? null;
} catch (\Exception $e) {
// File does not exist — create it (no sha needed)
$this->adapter->getApiClient()->resetCircuitBreaker();
}
try {
$this->adapter->createOrUpdateFile(
$org,
$repo,
$destPath,
$content,
$message,
$existingSha,
$branch
);
return true;
} catch (\Exception $e) {
$this->log(" ✗ API error pushing {$destPath}: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Create a tracking issue in the target repository after a successful push.
*
* @param list<array{source: string, destination: string}> $entries
*/
private function createTargetRepoIssue(
string $org,
string $repo,
array $entries,
?int $prNumber,
?string $directBranch
): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$version = self::VERSION;
$source = $this->adapter->getRepoWebUrl($org, 'mokocli');
$title = "chore: mokocli file push tracking";
$deliveryLine = $prNumber !== null
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
: "| **Delivery** | Direct commit to `{$directBranch}` |";
$fileRows = implode("\n", array_map(
fn($e) => "- `{$e['destination']}`",
$entries
));
$body = <<<MD
## mokocli File Push
One or more files were pushed to this repository from mokocli.
| Field | Value |
|-------|-------|
| **Pushed** | {$now} |
| **Standards version** | `{$version}` |
{$deliveryLine}
| **Source** | [{$source}]({$source}) |
### Files pushed
{$fileRows}
---
*Generated automatically by [mokocli]({$source}) `push_files.php`*
MD;
$body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'mokocli', 'type: chore', 'automation'];
try {
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => 'standards-update',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
try {
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) {
/* non-fatal */
}
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? null;
$this->log(" 📋 Tracking issue #{$num} created in {$repo}", 'INFO');
}
// Cross-link: patch the sync PR body to reference the tracking issue
// so GitHub shows it in the PR's Development sidebar.
if ($prNumber !== null && is_int($num)) {
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}");
$currentBody = $pr['body'] ?? '';
$ref = "Linked to #{$num}";
if (!str_contains($currentBody, $ref)) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [
'body' => $ref . "\n\n" . $currentBody,
]);
}
} catch (\Exception $le) {
/* non-fatal */
}
}
} catch (\Exception $e) {
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
}
}
/**
* Create or update a failure issue in mokocli when repos fail to receive files.
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
*/
private function createFailureIssue(string $org, array $results): void
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$failed = $results['failed'];
$version = self::VERSION;
$failedRepos = array_keys(array_filter(
$results['repos'] ?? [],
fn($s) => $s === 'failed'
));
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
$fileArgs = $this->getArgument('--files', '');
$title = "fix: push_files failed for {$failed} repo(s) — action required";
$body = <<<MD
## File Push Failure
`push_files.php` v{$version} encountered failures pushing files on {$now}.
### Failed repositories
{$repoList}
### Files that were being pushed
```
{$fileArgs}
```
### Next steps
1. Check the output above for the specific error per repo.
2. Fix the underlying issue (API token, branch permissions, file path, etc.).
3. Re-run: `php automation/push_files.php --org={$org} --repos=<repo> --files=<files> --yes`
4. Close this issue once resolved.
---
*Auto-created by `push_files.php` — close once resolved.*
MD;
$body = preg_replace('/^ /m', '', $body);
try {
$existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'push-failure',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else {
$issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title,
'body' => $body,
'labels' => ['push-failure'],
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
}
} catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
}
}
/**
* Build a markdown PR body listing every pushed file
*
* @param list<array{source: string, destination: string}> $entries
*/
private function buildPRBody(array $entries): string
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$lines = ["## mokocli File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
foreach ($entries as $entry) {
$lines[] = "- `{$entry['destination']}`";
}
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'mokocli');
$lines[] = "\n---\n*Generated by [mokocli]({$sourceUrl}) `push_files.php`*";
return implode("\n", $lines);
}
/**
* Display final results
*
* @param array{total: int, success: int, failed: int, repos: array<string, string>} $results
*/
private function displayResults(array $results): void
{
$this->log("\n" . str_repeat('=', 60), 'INFO');
$this->log('📊 Push Complete', 'INFO');
$this->log(str_repeat('=', 60), 'INFO');
$this->log(sprintf('Total: %d repos', $results['total']), 'INFO');
$this->log(sprintf('Success: %d', $results['success']), 'INFO');
$this->log(sprintf('Failed: %d', $results['failed']), 'INFO');
if ($this->verbose) {
$this->log("\n📋 Details:", 'INFO');
foreach ($results['repos'] as $repo => $outcome) {
$icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? '✓' : '✗';
$this->log(" {$icon} {$repo}: {$outcome}", 'INFO');
}
}
$this->log(str_repeat('=', 60), 'INFO');
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new PushFiles();
exit($app->execute());
}
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_manifest_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class PushManifestXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifest.xml to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update manifest.xml';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushManifestXmlCli();
exit($app->execute());
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_mokostandards_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class PushMokostandardsXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifests to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushMokostandardsXmlCli();
exit($app->execute());
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/repo_cleanup.php
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
/**
* Enterprise Repository Cleanup
*
* Comprehensive maintenance tool for governed repositories:
* 1. Delete stale sync branches (keeps current versioned branch)
* 2. Close superseded PRs on deleted branches
* 3. Close/lock resolved tracking issues where linked PR is merged
* 4. Delete retired workflow files from repos
* 5. Clean cancelled/stale workflow runs
* 6. Delete workflow run logs older than N days
* 7. Verify and provision standard labels
* 8. Version drift detection
*/
class RepoCleanup extends CliFramework
{
private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-mokocli-';
private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
'flush-actions-cache.yml', 'mokocli-script-runner.yml', 'unified-ci.yml',
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml',
'release.yml', 'sync-changelogs.yml', 'version_branch.yml',
'publish-to-mokodolibarr.yml', 'ci.yml',
'deploy-rs.yml',
];
private ApiClient $api;
private GitPlatformAdapter $adapter;
protected bool $dryRun = false;
private float $startTime;
protected function configure(): void
{
$this->setDescription('Enterprise repository cleanup');
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repos', false);
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
$this->addArgument('--log-days', 'Days to keep logs', '30');
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
$this->addArgument('--check-labels', 'Verify labels exist', false);
$this->addArgument('--check-drift', 'Check version drift', false);
$this->addArgument('--all', 'Run all operations', false);
$this->addArgument('--yes', 'Auto-confirm', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function run(): int
{
$this->startTime = microtime(true);
$org = $this->getArgument('--org', 'MokoConsulting');
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
$runAll = (bool) $this->getArgument('--all', false);
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
} catch (\Exception $e) {
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
return 1;
}
$this->logMsg("🧹 mokocli Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) {
$this->logMsg("⚠️ DRY RUN — no changes will be made");
}
$this->logMsg('');
$repos = $this->fetchRepositories($org);
$this->logMsg("Found " . count($repos) . " repositories");
$this->logMsg('');
$results = [
'repos_processed' => 0,
'repos_cleaned' => 0,
'branches_deleted' => 0,
'prs_closed' => 0,
'issues_closed' => 0,
'issues_locked' => 0,
'workflows_deleted' => 0,
'runs_deleted' => 0,
'logs_deleted' => 0,
'labels_missing' => 0,
'version_drift' => 0,
'retired_files' => 0,
'errors' => 0,
];
foreach ($repos as $i => $repo) {
$name = $repo['name'];
$num = $i + 1;
$total = count($repos);
$this->logMsg("[{$num}/{$total}] {$name}");
$results['repos_processed']++;
try {
$this->api->resetCircuitBreaker();
$cleaned = false;
// Always: delete old sync branches + close their PRs
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
// Optional: close resolved issues
if ($runAll || $this->getArgument('--close-issues', false)) {
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
}
// Optional: lock old closed issues
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
}
// Optional: delete retired workflow files
if ($runAll || $this->getArgument('--delete-retired', false)) {
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
}
// Optional: clean workflow runs
if ($runAll || $this->getArgument('--clean-workflows', false)) {
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
}
// Optional: clean old logs
if ($runAll || $this->getArgument('--clean-logs', false)) {
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
}
// Optional: check labels
if ($runAll || $this->getArgument('--check-labels', false)) {
$this->checkLabels($org, $name, $results);
}
// Optional: check version drift
if ($runAll || $this->getArgument('--check-drift', false)) {
$this->checkVersionDrift($org, $name, $results);
}
if ($cleaned) {
$results['repos_cleaned']++;
}
} catch (\Exception $e) {
$this->errorMsg("{$name}: " . $e->getMessage());
$results['errors']++;
}
}
$duration = round(microtime(true) - $this->startTime, 1);
$this->logMsg('');
$this->logMsg('============================================================');
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
$this->logMsg('============================================================');
$this->logMsg("Repos processed: {$results['repos_processed']}");
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
$this->logMsg("PRs closed: {$results['prs_closed']}");
$this->logMsg("Issues closed: {$results['issues_closed']}");
$this->logMsg("Issues locked: {$results['issues_locked']}");
$this->logMsg("Retired files: {$results['retired_files']}");
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
$this->logMsg("Labels missing: {$results['labels_missing']}");
$this->logMsg("Version drift: {$results['version_drift']}");
$this->logMsg("Errors: {$results['errors']}");
$this->logMsg('============================================================');
if ($this->getArgument('--json', false)) {
$results['duration_seconds'] = $duration;
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
}
return $results['errors'] > 0 ? 1 : 0;
}
// ─── Repository fetching ─────────────────────────────────────────────
private function fetchRepositories(string $org): array
{
$specificRepos = trim((string) $this->getArgument('--repos', ''));
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
if (!empty($specificRepos)) {
$names = preg_split('/[\s,]+/', $specificRepos);
return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names);
}
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['mokocli', '.github-private'], true));
}
// ─── Cleanup operations ──────────────────────────────────────────────
private function cleanBranches(string $org, string $repo, array &$results): bool
{
$changed = false;
try {
$branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]);
} catch (\Exception $e) {
return false;
}
foreach ($branches as $branch) {
$name = $branch['name'] ?? '';
if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) {
continue;
}
// Close open PRs on this branch
try {
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10,
]);
foreach ($prs as $pr) {
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
}
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
$results['prs_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
if (!$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
} catch (\Exception $e) {
continue;
}
}
$this->logMsg(" 🗑️ Deleted branch: {$name}");
$results['branches_deleted']++;
$changed = true;
}
return $changed;
}
private function closeResolvedIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['standards-update', 'standards-drift'] as $label) {
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => $label, 'state' => 'open', 'per_page' => 10,
]);
} catch (\Exception $e) {
continue;
}
foreach ($issues as $issue) {
$num = $issue['number'] ?? 0;
$body = $issue['body'] ?? '';
if (preg_match('/\[#(\d+)\]/', $body, $m)) {
$prNum = (int) $m[1];
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}");
if (!empty($pr['merged_at'])) {
if (!$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [
'state' => 'closed', 'state_reason' => 'completed',
]);
}
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
$results['issues_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
}
}
}
return $changed;
}
private function lockOldIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days'));
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
]);
} catch (\Exception $e) {
return false;
}
foreach ($issues as $issue) {
$closedAt = $issue['closed_at'] ?? '';
$locked = $issue['locked'] ?? false;
$num = $issue['number'] ?? 0;
if ($locked || $closedAt > $cutoff || $num === 0) {
continue;
}
if (!$this->dryRun) {
try {
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
'lock_reason' => 'resolved',
]);
} catch (\Exception $e) {
continue;
}
}
$results['issues_locked']++;
$changed = true;
}
if ($results['issues_locked'] > 0) {
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
}
return $changed;
}
private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool
{
$changed = false;
$defaultBranch = 'main';
try {
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
} catch (\Exception $e) {
/* fallback to main */
}
// Check both workflow directories for retired workflows (supports dual-platform repos)
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
foreach (self::RETIRED_WORKFLOWS as $wf) {
foreach ($wfDirs as $wfDir) {
$path = "{$wfDir}/{$wf}";
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
$sha = $file['sha'] ?? '';
if (empty($sha)) {
continue;
}
if (!$this->dryRun) {
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
'message' => "chore: delete retired workflow {$wf}",
'sha' => $sha,
'branch' => $defaultBranch,
]);
}
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
$results['retired_files']++;
$changed = true;
} catch (\Exception $e) {
// File doesn't exist in this dir — skip
$this->api->resetCircuitBreaker();
}
}
}
return $changed;
}
private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['cancelled', 'stale'] as $status) {
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'status' => $status, 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
$results['runs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
}
if ($results['runs_deleted'] > 0) {
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
}
return $changed;
}
private function cleanOldLogs(string $org, string $repo, array &$results): bool
{
$changed = false;
$days = (int) $this->getArgument('--log-days', '30');
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'created' => "<{$cutoff}", 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
$results['logs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
if ($results['logs_deleted'] > 0) {
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
}
return $changed;
}
private function checkLabels(string $org, string $repo, array &$results): void
{
try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
} catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'mokocli' label");
$results['labels_missing']++;
$this->api->resetCircuitBreaker();
}
}
private function checkVersionDrift(string $org, string $repo, array &$results): void
{
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md");
$content = base64_decode($file['content'] ?? '');
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1];
// Check manifest.xml for the tracked mokocli version
try {
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml");
$mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) {
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
$results['version_drift']++;
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
private function logMsg(string $message): void
{
if (!$this->quiet) {
echo $message . "\n";
}
}
private function errorMsg(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$app = new RepoCleanup();
exit($app->execute());
+678
View File
@@ -0,0 +1,678 @@
#!/usr/bin/env bash
# server-autoheal.sh - Auto-heal on restart + split backup management
#
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/server-autoheal.sh
# BRIEF: Server auto-heal on unclean restart + split system/content backups
#
# Usage:
# server-autoheal.sh <command> [options]
#
# Commands:
# boot-check Run at boot — auto-heals if no safe point exists
# set-safepoint Mark current state as safe (call before planned shutdown)
# backup-system Run a system backup (configs, packages, services)
# backup-content Run a content backup (site files, databases, uploads)
# cleanup Prune expired backups per retention policy
# status Show safe point and backup status
#
# Scheduling (cron):
# @reboot server-autoheal.sh boot-check
# 0 3 * * * server-autoheal.sh backup-system (daily at 3am)
# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours)
# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup)
set -euo pipefail
# ──────────────────────────────────────────────
# Configuration — override via /etc/moko/autoheal.conf
# ──────────────────────────────────────────────
CONF_FILE="/etc/moko/autoheal.conf"
[[ -f "$CONF_FILE" ]] && source "$CONF_FILE"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}"
SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}"
LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}"
LOCK_DIR="${LOCK_DIR:-/var/run/moko}"
# System backup: configs, package lists, service state, cron
SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system"
SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups
# Content backup: web roots, databases, uploads
CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content"
CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups
# Paths to back up — override these in /etc/moko/autoheal.conf
SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}"
CONTENT_PATHS="${CONTENT_PATHS:-/var/www}"
DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
log() {
local level="$1"; shift
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local msg="[$ts] [$level] $*"
echo "$msg" | tee -a "$LOG_FILE" >&2
}
ensure_dirs() {
mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \
"$LOCK_DIR" "$(dirname "$LOG_FILE")"
}
acquire_lock() {
local lockfile="${LOCK_DIR}/autoheal-${1}.lock"
if [[ -f "$lockfile" ]]; then
local pid
pid=$(<"$lockfile")
if kill -0 "$pid" 2>/dev/null; then
log WARN "Another $1 operation is running (PID $pid), skipping"
exit 0
fi
rm -f "$lockfile"
fi
echo $$ > "$lockfile"
trap "rm -f '$lockfile'" EXIT
}
timestamp() {
date -u '+%Y%m%d_%H%M%S'
}
# ──────────────────────────────────────────────
# Safe-point management
# ──────────────────────────────────────────────
cmd_set_safepoint() {
ensure_dirs
local ts
ts=$(timestamp)
cat > "$SAFEPOINT_FILE" <<EOF
timestamp=$ts
hostname=$(hostname)
kernel=$(uname -r)
uptime=$(uptime -s 2>/dev/null || echo "unknown")
set_by=${SUDO_USER:-$(whoami)}
EOF
log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}"
}
cmd_clear_safepoint() {
rm -f "$SAFEPOINT_FILE"
log INFO "Safe point cleared"
}
has_safepoint() {
[[ -f "$SAFEPOINT_FILE" ]]
}
# ──────────────────────────────────────────────
# System backup (daily)
# ──────────────────────────────────────────────
cmd_backup_system() {
ensure_dirs
acquire_lock "system-backup"
local ts
ts=$(timestamp)
local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz"
local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest"
log INFO "Starting system backup → $archive"
# Collect existing paths only
local existing_paths=()
for p in $SYSTEM_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -eq 0 ]]; then
log WARN "No system paths found to back up"
return 1
fi
# Archive configs and system files
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
# Capture package list and service state as manifest
{
echo "=== PACKAGES ==="
if command -v dpkg &>/dev/null; then
dpkg --get-selections
elif command -v rpm &>/dev/null; then
rpm -qa --qf '%{NAME}\t%{VERSION}\n'
fi
echo ""
echo "=== ENABLED SERVICES ==="
if command -v systemctl &>/dev/null; then
systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true
fi
echo ""
echo "=== CRONTABS ==="
for user_home in /var/spool/cron/crontabs/*; do
[[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home"
done 2>/dev/null || true
} > "$manifest"
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "System backup complete: $archive ($size)"
# Prune old system backups (keep $SYSTEM_BACKUP_RETAIN)
local count
count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l)
if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then
local to_remove=$((count - SYSTEM_BACKUP_RETAIN))
find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
| sort | head -n "$to_remove" | awk '{print $2}' \
| while read -r f; do
rm -f "$f" "${f%.tar.gz}.manifest"
log INFO "Pruned old system backup: $f"
done
fi
}
# ──────────────────────────────────────────────
# Content backup (every 2 hours)
# ──────────────────────────────────────────────
cmd_backup_content() {
ensure_dirs
acquire_lock "content-backup"
local ts
ts=$(timestamp)
local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz"
local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz"
log INFO "Starting content backup → $archive"
# Back up web content / uploads
local existing_paths=()
for p in $CONTENT_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -gt 0 ]]; then
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "Content files archived: $archive ($size)"
else
log WARN "No content paths found to back up"
fi
# Database dump
if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then
local dump_cmd="mysqldump"
command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump"
local databases=()
if [[ -n "$DB_NAMES" ]]; then
read -ra databases <<< "$DB_NAMES"
else
# Auto-detect: dump all databases except system ones
databases=($(${dump_cmd%dump} -N -e \
"SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \
2>/dev/null | tr '\n' ' ')) || true
fi
if [[ ${#databases[@]} -gt 0 ]]; then
$dump_cmd --single-transaction --routines --triggers \
--databases "${databases[@]}" 2>/dev/null \
| gzip > "$db_dump"
local db_size
db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1)
log INFO "Database dump complete: $db_dump ($db_size)"
else
log WARN "No databases found to dump"
fi
fi
}
# ──────────────────────────────────────────────
# Cleanup — prune content backups older than retention
# ──────────────────────────────────────────────
cmd_cleanup() {
ensure_dirs
local before_count after_count
# Content: keep only last 24 hours (1 day)
before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \
-mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true
after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
local removed=$((before_count - after_count))
[[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h"
# System: keep N most recent (handled in backup-system, but double-check here)
before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l)
local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest
if [[ "$before_count" -gt "$max_system_files" ]]; then
local excess=$((before_count - max_system_files))
find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \
| sort | head -n "$excess" | awk '{print $2}' \
| xargs -r rm -f
log INFO "Pruned excess system backups"
fi
log INFO "Cleanup complete"
}
# ──────────────────────────────────────────────
# Boot check — the auto-heal entry point
# ──────────────────────────────────────────────
cmd_boot_check() {
ensure_dirs
acquire_lock "boot-check"
log INFO "=== Boot check started ==="
log INFO "Hostname: $(hostname), Kernel: $(uname -r)"
if has_safepoint; then
log INFO "Safe point found — server was shut down cleanly"
log INFO "Clearing safe point for next cycle"
cmd_clear_safepoint
log INFO "=== Boot check passed (clean restart) ==="
return 0
fi
log WARN "NO safe point found — server restarted without clean shutdown"
log WARN "Initiating auto-heal sequence..."
auto_heal
local rc=$?
# Set safe point after successful heal
if [[ $rc -eq 0 ]]; then
cmd_set_safepoint
log INFO "=== Boot check complete (healed successfully) ==="
else
log ERROR "=== Boot check FAILED — manual intervention required ==="
fi
return $rc
}
# ──────────────────────────────────────────────
# Auto-heal strategy
#
# TODO: This is the core decision point. Implement the recovery
# steps that match your server's architecture. See guidance below.
#
# Trade-offs to consider:
# - Restore-from-backup: safest, but content may be up to 2h stale
# - Service-restart-only: faster, keeps current data, but won't fix
# corrupted configs or broken filesystem state
# - Hybrid: restart services first, verify health, only restore if
# health checks fail — best of both worlds but more complex
#
# The function receives no arguments. Use the latest system + content
# backups to restore if needed. Return 0 on success, 1 on failure.
# ──────────────────────────────────────────────
auto_heal() {
log INFO "Phase 1: Verify and repair filesystem"
# Check for common post-crash issues
repair_filesystem
log INFO "Phase 2: Restore system configuration if corrupted"
restore_system_if_needed
log INFO "Phase 3: Restart core services"
restart_services
log INFO "Phase 4: Verify health"
if ! verify_health; then
log WARN "Health check failed after service restart — restoring from backup"
restore_from_backup
restart_services
if ! verify_health; then
log ERROR "Health check still failing after restore — giving up"
return 1
fi
fi
log INFO "Auto-heal completed successfully"
return 0
}
# ──────────────────────────────────────────────
# Heal sub-steps
# ──────────────────────────────────────────────
repair_filesystem() {
# Fix common post-crash filesystem issues
# Clear stale PID/lock/socket files that prevent services from starting
local stale_files=(
/var/run/nginx.pid
/var/run/mysqld/mysqld.pid
/var/run/php-fpm.pid
/var/lib/mysql/*.pid
)
for f in "${stale_files[@]}"; do
for expanded in $f; do
if [[ -f "$expanded" ]]; then
local pid
pid=$(<"$expanded") 2>/dev/null || true
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
rm -f "$expanded"
log INFO "Removed stale PID file: $expanded"
fi
fi
done
done
# Fix permissions on critical dirs that may get mangled
[[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true
[[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true
# Repair tmp/cache dirs
for d in /tmp /var/tmp; do
[[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true
done
}
restore_system_if_needed() {
# Find latest system backup
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -z "$latest_system" ]]; then
log WARN "No system backup available to verify against"
return 0
fi
# Check if critical configs exist and are non-empty
local needs_restore=false
local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql")
for cfg in "${critical_configs[@]}"; do
if [[ -e "$cfg" ]]; then
# Config exists — check if it's a file and non-empty, or a directory
if [[ -f "$cfg" && ! -s "$cfg" ]]; then
log WARN "Critical config is empty: $cfg"
needs_restore=true
break
fi
fi
done
if $needs_restore; then
log WARN "Restoring system config from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || {
log ERROR "System restore failed from $latest_system"
return 1
}
log INFO "System config restored"
else
log INFO "System configs look intact — skipping restore"
fi
}
restart_services() {
if ! command -v systemctl &>/dev/null; then
log WARN "systemctl not available — skipping service restart"
return 0
fi
local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
log INFO "Restarting $svc..."
systemctl restart "$svc" 2>/dev/null && \
log INFO "$svc restarted OK" || \
log WARN "$svc restart failed"
fi
done
}
verify_health() {
local failures=0
# Check critical services are running
local services=("mysql" "mariadb" "nginx" "apache2")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
if ! systemctl is-active "$svc" &>/dev/null; then
log WARN "Service not running: $svc"
((failures++))
fi
fi
done
# Check if web server responds
if command -v curl &>/dev/null; then
if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then
log WARN "Local web server not responding"
((failures++))
fi
fi
# Check if database accepts connections
if command -v mysqladmin &>/dev/null; then
if ! mysqladmin ping --silent 2>/dev/null; then
log WARN "Database not responding to ping"
((failures++))
fi
fi
[[ $failures -eq 0 ]]
}
restore_from_backup() {
log WARN "=== Full restore from backup ==="
# Restore system config
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_system" ]]; then
log INFO "Restoring system from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || \
log ERROR "System restore failed"
fi
# Restore content
local latest_content
latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_content" ]]; then
log INFO "Restoring content from $latest_content"
tar -xzf "$latest_content" -C / 2>/dev/null || \
log ERROR "Content restore failed"
fi
# Restore database
local latest_db
latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_db" ]]; then
log INFO "Restoring database from $latest_db"
local mysql_cmd="mysql"
command -v mariadb &>/dev/null && mysql_cmd="mariadb"
zcat "$latest_db" | $mysql_cmd 2>/dev/null || \
log ERROR "Database restore failed"
fi
}
# ──────────────────────────────────────────────
# Status
# ──────────────────────────────────────────────
cmd_status() {
echo "=== Moko Server Auto-Heal Status ==="
echo ""
# Safe point
if has_safepoint; then
echo "Safe point: SET"
cat "$SAFEPOINT_FILE" | sed 's/^/ /'
else
echo "Safe point: NOT SET (will auto-heal on next boot)"
fi
echo ""
# System backups
echo "System backups (${SYSTEM_BACKUP_DIR}):"
local sys_count
sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)"
local latest_sys
latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_sys" ]]; then
echo " Latest: $(echo "$latest_sys" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Content backups
echo "Content backups (${CONTENT_BACKUP_DIR}):"
local cnt_count
cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)"
local latest_cnt
latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_cnt" ]]; then
echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Disk usage
echo "Backup disk usage:"
du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /'
}
# ──────────────────────────────────────────────
# Install helper — sets up cron + systemd
# ──────────────────────────────────────────────
cmd_install() {
local script_path
script_path=$(readlink -f "$0")
echo "Installing Moko Auto-Heal..."
# Create config directory
mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR"
# Write example config if none exists
if [[ ! -f "$CONF_FILE" ]]; then
cat > "$CONF_FILE" <<'CONF'
# /etc/moko/autoheal.conf — Server auto-heal configuration
# Uncomment and modify as needed
# BACKUP_ROOT="/var/backups/moko"
# SAFEPOINT_FILE="/var/run/moko/safepoint"
# LOG_FILE="/var/log/moko/autoheal.log"
# System backup paths (space-separated)
# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system"
# Content backup paths (space-separated)
# CONTENT_PATHS="/var/www"
# Database names (space-separated, empty = auto-detect all)
# DB_NAMES=""
# Retention
# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep
# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep
CONF
echo " Created config: $CONF_FILE"
fi
# Install cron jobs
local cron_file="/etc/cron.d/moko-autoheal"
cat > "$cron_file" <<CRON
# Moko Server Auto-Heal — managed by server-autoheal.sh install
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Boot check — auto-heal if no safe point
@reboot root ${script_path} boot-check
# System backup — daily at 3:00 AM
0 3 * * * root ${script_path} backup-system
# Content backup — every 2 hours
0 */2 * * * root ${script_path} backup-content
# Cleanup expired backups — 30 min after each content backup
30 */2 * * * root ${script_path} cleanup
CRON
echo " Installed cron: $cron_file"
# Install shutdown hook to set safe point on clean shutdown
local shutdown_hook="/etc/systemd/system/moko-safepoint.service"
cat > "$shutdown_hook" <<UNIT
[Unit]
Description=Moko Safe Point — mark clean shutdown
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=${script_path} set-safepoint
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable moko-safepoint.service
echo " Installed systemd hook: $shutdown_hook"
echo ""
echo "Done! Edit $CONF_FILE to configure paths for your server."
echo "Run '${script_path} status' to verify."
}
# ──────────────────────────────────────────────
# Main dispatcher
# ──────────────────────────────────────────────
main() {
local cmd="${1:-help}"
case "$cmd" in
boot-check) cmd_boot_check ;;
set-safepoint) cmd_set_safepoint ;;
clear-safepoint) cmd_clear_safepoint ;;
backup-system) cmd_backup_system ;;
backup-content) cmd_backup_content ;;
cleanup) cmd_cleanup ;;
status) cmd_status ;;
install) cmd_install ;;
help|--help|-h)
sed -n '2,/^$/s/^# //p' "$0"
echo ""
echo "Commands: boot-check, set-safepoint, clear-safepoint,"
echo " backup-system, backup-content, cleanup, status, install"
;;
*)
echo "Unknown command: $cmd" >&2
echo "Run '$0 help' for usage" >&2
exit 1
;;
esac
}
main "$@"
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/update_dependencies.php
* VERSION: 09.38.01
* BRIEF: Cross-repo dependency update automation — scan, update, PR, auto-merge
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoCli\{
ApiClient,
AuditLogger,
CheckpointManager,
CircuitBreakerOpen,
CliFramework,
Config,
GitPlatformAdapter,
PlatformAdapterFactory,
RateLimitExceeded
};
/**
* Cross-Repo Dependency Update Automation
*
* Scans org repos for outdated Composer/npm dependencies, creates PRs with
* changelogs, and optionally auto-merges safe patch updates.
*
* @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/149
*/
class UpdateDependencies extends CliFramework
{
public const VERSION = '01.00.00';
private const BRANCH_PREFIX = 'chore/deps-update';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private CheckpointManager $checkpoints;
/** Summary counters. */
private int $reposScanned = 0;
private int $reposUpdated = 0;
private int $prsCreated = 0;
private int $autoMerged = 0;
private int $reposFailed = 0;
protected function configure(): void
{
$this->setDescription('Cross-repo dependency update automation');
$this->addArgument('--org', 'Organization to scan', 'MokoConsulting');
$this->addArgument('--repos', 'Comma-separated list of specific repos', '');
$this->addArgument('--exclude', 'Comma-separated list of repos to exclude', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', true);
$this->addArgument('--type', 'Dependency type: composer, npm, or all', 'all');
$this->addArgument('--patch-only', 'Only update patch versions (safe updates)', false);
$this->addArgument('--auto-merge', 'Auto-merge PRs with only patch updates', false);
$this->addArgument('--resume', 'Resume from checkpoint', false);
}
protected function run(): int
{
$this->log("Dependency Update Automation v" . self::VERSION, 'INFO');
if (!$this->initComponents()) {
return self::EXIT_FAILURE;
}
$org = $this->getArgument('--org', 'MokoConsulting');
$depType = strtolower($this->getArgument('--type', 'all'));
$patchOnly = $this->getArgument('--patch-only', false);
$autoMerge = $this->getArgument('--auto-merge', false);
// ── Gather repos ─────────────────────────────────────────────────
$repos = $this->gatherRepos($org);
if ($repos === null) {
return self::EXIT_FAILURE;
}
$total = count($repos);
$this->log("Found {$total} repositories to scan", 'INFO');
// ── Resume support ───────────────────────────────────────────────
$completed = [];
if ($this->getArgument('--resume', false)) {
$checkpoint = $this->checkpoints->load('deps_update');
if ($checkpoint) {
$completed = $checkpoint['completed'] ?? [];
$this->log("Resuming — skipping " . count($completed) . " already-processed repos", 'INFO');
}
}
// ── Process each repo ────────────────────────────────────────────
$this->section('Scanning repositories for outdated dependencies');
foreach ($repos as $i => $repo) {
$repoName = $repo['name'];
$this->progress($i + 1, $total, $repoName);
if (in_array($repoName, $completed, true)) {
continue;
}
try {
$this->processRepo($org, $repoName, $depType, $patchOnly, $autoMerge);
$completed[] = $repoName;
$this->checkpoints->save('deps_update', ['completed' => $completed]);
} catch (RateLimitExceeded $e) {
$this->log("Rate limit hit — checkpoint saved", 'WARNING');
break;
} catch (CircuitBreakerOpen $e) {
$this->log("Circuit breaker open — checkpoint saved", 'WARNING');
break;
} catch (\Exception $e) {
$this->log("Failed {$repoName}: {$e->getMessage()}", 'ERROR');
$this->reposFailed++;
}
}
$this->progress($total, $total, '', true);
// ── Summary ──────────────────────────────────────────────────────
$this->section('Summary');
$this->printSummary(
$this->reposScanned - $this->reposFailed,
$this->reposFailed,
$this->elapsed()
);
$this->log("Repos scanned: {$this->reposScanned}", 'INFO');
$this->log("Repos updated: {$this->reposUpdated}", 'INFO');
$this->log("PRs created: {$this->prsCreated}", 'INFO');
if ($autoMerge) {
$this->log("Auto-merged: {$this->autoMerged}", 'INFO');
}
if (count($completed) === $total) {
$this->checkpoints->clear('deps_update');
}
return $this->reposFailed > 0 ? self::EXIT_FAILURE : self::EXIT_SUCCESS;
}
// ── Component init ───────────────────────────────────────────────────
private function initComponents(): bool
{
try {
$config = new Config();
$this->api = new ApiClient($config);
$this->adapter = PlatformAdapterFactory::create($this->api, $config);
$this->logger = new AuditLogger();
$this->checkpoints = new CheckpointManager();
return true;
} catch (\Exception $e) {
$this->log("Failed to initialise: {$e->getMessage()}", 'ERROR');
return false;
}
}
// ── Repo gathering ───────────────────────────────────────────────────
private function gatherRepos(string $org): ?array
{
$specificRepos = array_filter(explode(',', $this->getArgument('--repos', '')));
$excludeRepos = array_filter(explode(',', $this->getArgument('--exclude', '')));
$skipArchived = $this->getArgument('--skip-archived', true);
// Default exclusions
$excludeRepos = array_merge($excludeRepos, [
'mokocli', '.mokogitea-private', 'org-profile',
]);
try {
$repos = $this->adapter->listOrgRepos($org, $skipArchived);
} catch (\Exception $e) {
$this->log("Failed to list repos: {$e->getMessage()}", 'ERROR');
return null;
}
if (!empty($specificRepos)) {
$repos = array_filter($repos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$repos = array_filter($repos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
return array_values($repos);
}
// ── Per-repo processing ──────────────────────────────────────────────
private function processRepo(
string $org,
string $repoName,
string $depType,
bool $patchOnly,
bool $autoMerge
): void {
$this->reposScanned++;
$hasComposer = ($depType === 'all' || $depType === 'composer');
$hasNpm = ($depType === 'all' || $depType === 'npm');
$outdated = [];
// ── Composer ─────────────────────────────────────────────────
if ($hasComposer) {
$composerOutdated = $this->scanComposer($org, $repoName, $patchOnly);
if ($composerOutdated !== null) {
$outdated['composer'] = $composerOutdated;
}
}
// ── npm ──────────────────────────────────────────────────────
if ($hasNpm) {
$npmOutdated = $this->scanNpm($org, $repoName, $patchOnly);
if ($npmOutdated !== null) {
$outdated['npm'] = $npmOutdated;
}
}
if (empty($outdated)) {
return;
}
// Check if there's already an open deps PR
if ($this->hasExistingDepsPR($org, $repoName)) {
$this->log(" {$repoName}: existing deps PR found — skipping", 'INFO');
return;
}
$this->reposUpdated++;
// ── Create PR ────────────────────────────────────────────────
$totalUpdates = 0;
$allPatchOnly = true;
foreach ($outdated as $type => $packages) {
$totalUpdates += count($packages);
foreach ($packages as $pkg) {
if (!$this->isPatchUpdate($pkg['current'] ?? '', $pkg['latest'] ?? '')) {
$allPatchOnly = false;
}
}
}
$title = "chore(deps): update {$totalUpdates} " . ($totalUpdates === 1 ? 'dependency' : 'dependencies');
$body = $this->buildPrBody($repoName, $outdated);
$branch = self::BRANCH_PREFIX . '-' . date('Y-m-d');
if ($this->dryRun) {
$this->log("[dry-run] Would create PR in {$repoName}: {$title}", 'INFO');
foreach ($outdated as $type => $packages) {
foreach ($packages as $pkg) {
$this->log(" [{$type}] {$pkg['name']}: {$pkg['current']}{$pkg['latest']}", 'INFO');
}
}
return;
}
try {
// Clone repo, run updates, push branch
$prNumber = $this->cloneUpdateAndPR($org, $repoName, $branch, $title, $body, $outdated);
if ($prNumber > 0) {
$this->prsCreated++;
$this->log(" {$repoName}: PR #{$prNumber} created", 'INFO');
// Auto-merge if all updates are patch-level
if ($autoMerge && $allPatchOnly && $prNumber > 0) {
$this->tryAutoMerge($org, $repoName, $prNumber);
}
}
} catch (\Exception $e) {
$this->log(" {$repoName}: PR creation failed — {$e->getMessage()}", 'ERROR');
}
}
// ── Composer scanning ────────────────────────────────────────────────
private function scanComposer(string $org, string $repoName, bool $patchOnly): ?array
{
// Check if repo has composer.json
try {
$this->adapter->getFileContents($org, $repoName, 'composer.json');
} catch (\Exception $e) {
return null;
}
// Check if repo has composer.lock
try {
$this->adapter->getFileContents($org, $repoName, 'composer.lock');
} catch (\Exception $e) {
return null;
}
// Clone to temp dir and run composer outdated
$tmpDir = sys_get_temp_dir() . '/moko_deps_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
$cmd = sprintf(
'git clone --depth 1 --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl),
escapeshellarg($tmpDir)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
return null;
}
// Run composer outdated
$flags = $patchOnly ? '--minor-only' : '';
$cmd = sprintf(
'composer outdated --format=json --no-interaction %s --working-dir=%s 2>/dev/null',
$flags,
escapeshellarg($tmpDir)
);
$json = shell_exec($cmd);
if ($json === null || $json === '') {
return null;
}
$data = json_decode($json, true);
$installed = $data['installed'] ?? [];
if (empty($installed)) {
return null;
}
$outdated = [];
foreach ($installed as $pkg) {
// Skip abandoned/dev packages
if (($pkg['abandoned'] ?? false) || str_starts_with($pkg['version'] ?? '', 'dev-')) {
continue;
}
$outdated[] = [
'name' => $pkg['name'] ?? '',
'current' => $pkg['version'] ?? '',
'latest' => $pkg['latest'] ?? '',
'status' => $pkg['latest-status'] ?? 'unknown',
];
}
return empty($outdated) ? null : $outdated;
} finally {
// Cleanup
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── npm scanning ─────────────────────────────────────────────────────
private function scanNpm(string $org, string $repoName, bool $patchOnly): ?array
{
// Check if repo has package.json
try {
$this->adapter->getFileContents($org, $repoName, 'package.json');
} catch (\Exception $e) {
return null;
}
// Check for lock file
$hasLock = false;
foreach (['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] as $lockFile) {
try {
$this->adapter->getFileContents($org, $repoName, $lockFile);
$hasLock = true;
break;
} catch (\Exception $e) {
// continue
}
}
if (!$hasLock) {
return null;
}
$tmpDir = sys_get_temp_dir() . '/moko_deps_npm_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
exec(sprintf('git clone --depth 1 --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl), escapeshellarg($tmpDir)));
if (!file_exists("{$tmpDir}/package.json")) {
return null;
}
// Install deps first (needed for npm outdated)
exec(sprintf('cd %s && npm install --silent 2>/dev/null', escapeshellarg($tmpDir)));
$json = shell_exec(sprintf('cd %s && npm outdated --json 2>/dev/null', escapeshellarg($tmpDir)));
if ($json === null || $json === '' || $json === '{}') {
return null;
}
$data = json_decode($json, true);
if (!is_array($data) || empty($data)) {
return null;
}
$outdated = [];
foreach ($data as $name => $info) {
$current = $info['current'] ?? '';
$wanted = $info['wanted'] ?? '';
$latest = $info['latest'] ?? '';
$target = $patchOnly ? $wanted : $latest;
if ($current === $target || $target === '') {
continue;
}
$outdated[] = [
'name' => $name,
'current' => $current,
'latest' => $target,
'status' => ($current === $wanted) ? 'up-to-date' : 'outdated',
];
}
return empty($outdated) ? null : $outdated;
} finally {
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── PR creation ──────────────────────────────────────────────────────
private function cloneUpdateAndPR(
string $org,
string $repoName,
string $branch,
string $title,
string $body,
array $outdated
): int {
$tmpDir = sys_get_temp_dir() . '/moko_deps_pr_' . $repoName . '_' . getmypid();
@mkdir($tmpDir, 0700, true);
try {
$cloneUrl = $this->adapter->getCloneUrl($org, $repoName);
exec(sprintf('git clone --quiet %s %s 2>/dev/null',
escapeshellarg($cloneUrl), escapeshellarg($tmpDir)));
// Create branch
exec(sprintf('git -C %s checkout -b %s 2>/dev/null',
escapeshellarg($tmpDir), escapeshellarg($branch)));
$updated = false;
// Run composer update if needed
if (isset($outdated['composer'])) {
$packages = array_column($outdated['composer'], 'name');
$cmd = sprintf(
'cd %s && composer update %s --no-interaction --quiet 2>/dev/null',
escapeshellarg($tmpDir),
implode(' ', array_map('escapeshellarg', $packages))
);
exec($cmd, $output, $exitCode);
if ($exitCode === 0) {
$updated = true;
}
}
// Run npm update if needed
if (isset($outdated['npm'])) {
$packages = array_column($outdated['npm'], 'name');
$cmd = sprintf(
'cd %s && npm update %s --save 2>/dev/null',
escapeshellarg($tmpDir),
implode(' ', array_map('escapeshellarg', $packages))
);
exec($cmd, $output, $exitCode);
if ($exitCode === 0) {
$updated = true;
}
}
if (!$updated) {
return 0;
}
// Commit and push
exec(sprintf('git -C %s config user.email "gitea-actions[bot]@mokoconsulting.tech"', escapeshellarg($tmpDir)));
exec(sprintf('git -C %s config user.name "gitea-actions[bot]"', escapeshellarg($tmpDir)));
exec(sprintf('git -C %s add -A', escapeshellarg($tmpDir)));
// Check if there are actual changes
exec(sprintf('git -C %s diff --cached --quiet', escapeshellarg($tmpDir)), $output, $diffExit);
if ($diffExit === 0) {
return 0; // No changes
}
exec(sprintf('git -C %s commit -m %s',
escapeshellarg($tmpDir),
escapeshellarg($title . " [skip ci]")));
exec(sprintf('git -C %s push origin %s 2>/dev/null',
escapeshellarg($tmpDir), escapeshellarg($branch)), $output, $pushExit);
if ($pushExit !== 0) {
$this->log(" {$repoName}: push failed", 'ERROR');
return 0;
}
// Create PR via API
$defaultBranch = $this->getDefaultBranch($org, $repoName);
$pr = $this->adapter->createPullRequest(
$org, $repoName, $title, $branch, $defaultBranch, $body, [
'labels' => ['dependencies'],
]
);
return (int) ($pr['number'] ?? 0);
} finally {
if (is_dir($tmpDir)) {
exec(sprintf('rm -rf %s', escapeshellarg($tmpDir)));
}
}
}
// ── Auto-merge ───────────────────────────────────────────────────────
private function tryAutoMerge(string $org, string $repoName, int $prNumber): void
{
try {
$this->api->put(
"/repos/{$org}/{$repoName}/pulls/{$prNumber}/merge",
['Do' => 'squash', 'merge_message_field' => 'chore(deps): auto-merge patch updates']
);
$this->autoMerged++;
$this->log(" {$repoName}: PR #{$prNumber} auto-merged", 'INFO');
} catch (\Exception $e) {
$this->log(" {$repoName}: auto-merge failed — {$e->getMessage()}", 'WARNING');
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private function hasExistingDepsPR(string $org, string $repoName): bool
{
try {
$prs = $this->adapter->listPullRequests($org, $repoName, ['state' => 'open']);
foreach ($prs as $pr) {
if (str_starts_with($pr['head']['ref'] ?? '', self::BRANCH_PREFIX)) {
return true;
}
}
} catch (\Exception $e) {
// Ignore — proceed with creating PR
}
return false;
}
private function getDefaultBranch(string $org, string $repoName): string
{
try {
$repo = $this->api->get("/repos/{$org}/{$repoName}");
return $repo['default_branch'] ?? 'main';
} catch (\Exception $e) {
return 'main';
}
}
private function isPatchUpdate(string $current, string $latest): bool
{
$cur = explode('.', ltrim($current, 'v'));
$lat = explode('.', ltrim($latest, 'v'));
if (count($cur) < 3 || count($lat) < 3) {
return false;
}
// Same major and minor, only patch differs
return $cur[0] === $lat[0] && $cur[1] === $lat[1] && $cur[2] !== $lat[2];
}
private function buildPrBody(string $repoName, array $outdated): string
{
$lines = [
"## Dependency Updates",
"",
"**Repository**: `{$repoName}`",
"**Scanned**: " . date('Y-m-d H:i:s'),
"",
];
foreach ($outdated as $type => $packages) {
$lines[] = "### " . ucfirst($type);
$lines[] = "";
$lines[] = "| Package | Current | Latest | Type |";
$lines[] = "|---------|---------|--------|------|";
foreach ($packages as $pkg) {
$updateType = $this->isPatchUpdate($pkg['current'], $pkg['latest']) ? 'patch' : 'minor/major';
$lines[] = "| `{$pkg['name']}` | {$pkg['current']} | {$pkg['latest']} | {$updateType} |";
}
$lines[] = "";
}
$lines[] = "---";
$lines[] = "*Auto-generated by `moko deps:update`*";
return implode("\n", $lines);
}
}
$script = new UpdateDependencies('update_dependencies', 'Cross-repo dependency update automation');
exit($script->execute());
-158
View File
@@ -1,158 +0,0 @@
<?php
/**
* @package MokoCLI
* @subpackage cli
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Enforce branch protection rules across all repos in the org.
*
* Usage:
* php cli/branch_protect_org.php --token TOKEN [--org MokoConsulting] [--dry-run]
*
* Branch flow: feature/* -> dev -> rc -> main
* main, dev, rc: push whitelist only (no direct push)
* alpha, beta: push whitelist only (pre-release)
*/
declare(strict_types=1);
$options = getopt('', ['token:', 'org:', 'api-base:', 'dry-run', 'help']);
if (isset($options['help']) || empty($options['token'])) {
echo "Usage: php cli/branch_protect_org.php --token TOKEN [--org ORG] [--api-base URL] [--dry-run]\n";
echo "\n";
echo "Options:\n";
echo " --token Gitea API token (required)\n";
echo " --org Organization name (default: MokoConsulting)\n";
echo " --api-base API base URL (default: https://git.mokoconsulting.tech/api/v1)\n";
echo " --dry-run Show what would be changed without making changes\n";
exit(0);
}
$token = $options['token'];
$org = $options['org'] ?? 'MokoConsulting';
$apiBase = rtrim($options['api-base'] ?? 'https://git.mokoconsulting.tech/api/v1', '/');
$dryRun = isset($options['dry-run']);
// Protected branches and their rules
$branchRules = [
// Primary branches (flow: feature/* -> dev -> rc -> main)
'main' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'dev' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'rc' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'beta' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'alpha' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
// Synonyms (prevent bypass via alternate names)
'master' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'develop' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'release' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'production' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'stable' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
'staging' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']],
];
function apiRequest(string $method, string $url, string $token, ?array $body = null): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: token ' . $token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [
'status' => $httpCode,
'data' => json_decode($response, true) ?: [],
];
}
// 1. List all org repos
echo "Fetching repos for {$org}...\n";
$page = 1;
$repos = [];
do {
$result = apiRequest('GET', "{$apiBase}/orgs/{$org}/repos?limit=50&page={$page}", $token);
$batch = $result['data'];
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) === 50);
echo sprintf("Found %d repos\n\n", count($repos));
$summary = ['protected' => 0, 'added' => 0, 'skipped' => 0, 'errors' => 0];
foreach ($repos as $repo) {
$repoName = $repo['name'];
if ($repo['archived'] ?? false) {
continue;
}
// Get existing protections
$existing = apiRequest('GET', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token);
$existingNames = array_map(fn($p) => $p['branch_name'] ?? '', $existing['data'] ?: []);
$added = [];
$skipped = [];
foreach ($branchRules as $branch => $rules) {
if (in_array($branch, $existingNames, true)) {
$skipped[] = $branch;
$summary['skipped']++;
continue;
}
if ($dryRun) {
$added[] = $branch;
$summary['added']++;
continue;
}
$body = array_merge($rules, ['branch_name' => $branch]);
$result = apiRequest('POST', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token, $body);
if ($result['status'] >= 200 && $result['status'] < 300) {
$added[] = $branch;
$summary['added']++;
} elseif ($result['status'] === 422) {
$skipped[] = $branch;
$summary['skipped']++;
} else {
$added[] = "{$branch}(ERR:{$result['status']})";
$summary['errors']++;
}
}
$summary['protected']++;
if (!empty($added)) {
$prefix = $dryRun ? '[DRY-RUN] ' : '';
echo sprintf(" %s%-35s added: %s\n", $prefix, $repoName, implode(', ', $added));
}
}
echo "\n";
echo sprintf("Summary: %d repos, %d rules added, %d already existed, %d errors\n",
$summary['protected'], $summary['added'], $summary['skipped'], $summary['errors']);
if ($dryRun) {
echo "\n(Dry run - no changes made)\n";
}
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/branch_rename.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_push.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Trigger a workflow across multiple repos at once
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_dashboard.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Generate unified client dashboard HTML
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_inventory.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Discover and list all client-waas repos with their server configuration status
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_provision.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Provision a new client environment end-to-end
*/
-4
View File
@@ -95,10 +95,6 @@ class CreateProjectCli extends CliFramework
$repoRoot = dirname(__DIR__, 2);
$templatesDir = "{$repoRoot}/templates/projects";
if (!is_dir($templatesDir)) {
$this->log('ERROR', "Project templates directory not found: {$templatesDir}");
return 1;
}
$repos = [];
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/grafana_dashboard.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Manage Grafana dashboards via API
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_build.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows.
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_metadata_validate.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_detect.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Auto-detect manifest fields from source files and optionally push to API
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_integrity.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Cross-check manifest API fields against repo contents across the org
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_licensing.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_read.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/platform_detect.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Auto-detect repository platform type and optionally update manifest
*/
+13 -1
View File
@@ -131,7 +131,19 @@ class ReleaseCli extends CliFramework
file_put_contents($bulkSyncFile, $bulkContent);
}
// -- Step 5: Commit changes --
// -- Step 5: Update repository-cleanup.yml current branch --
echo "Updating repository-cleanup.yml -> chore/sync-mokostandards-v{$minorVersion}\n";
if (!$this->dryRun) {
$cleanupContent = file_get_contents($cleanupFile);
$cleanupContent = preg_replace(
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
"CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"",
$cleanupContent
);
file_put_contents($cleanupFile, $cleanupContent);
}
// -- Step 6: Commit changes --
if (!$this->dryRun) {
echo "Committing...\n";
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_cascade.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Cascade release zip to all lower stability channels
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_publish.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Publish a release and create copies for all lesser stability streams.
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/scaffold_client.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/updates_xml_sync.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Sync updates.xml to target branches via Gitea API
* NOTE: Called by pre-release and auto-release workflows after updates.xml
* is modified on the current branch. Pushes the file to other branches
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/version_auto_bump.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
*/
+6 -22
View File
@@ -124,19 +124,17 @@ class VersionBumpCli extends CliFramework
$minVersion = preg_replace('/-(?:dev|alpha|beta|rc)$/', '', $minVersion);
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $minVersion)) {
if ($baseVersion === null || version_compare($minVersion, $baseVersion, '>')) {
$this->log('INFO', "Using --min-version {$minVersion} (higher than manifest {$baseVersion})");
$this->log('INFO', "Using --min-version {$minVersion} (higher than manifest " . ($baseVersion ?? '(none)') . ")");
$baseVersion = $minVersion;
}
}
}
// Auto-detect: scan git tags for higher versions from other channels
if ($baseVersion !== null) {
$gitTagVersion = $this->getHighestGitTagVersion($root);
if ($gitTagVersion !== null && version_compare($gitTagVersion, $baseVersion, '>')) {
$this->log('INFO', "Git tag version {$gitTagVersion} is higher than manifest {$baseVersion} — using as base");
$baseVersion = $gitTagVersion;
}
$gitTagVersion = $this->getHighestGitTagVersion($root);
if ($gitTagVersion !== null && ($baseVersion === null || version_compare($gitTagVersion, $baseVersion, '>'))) {
$this->log('INFO', "Git tag version {$gitTagVersion} is higher than manifest " . ($baseVersion ?? '(none)') . " — using as base");
$baseVersion = $gitTagVersion;
}
if ($baseVersion === null) {
@@ -234,20 +232,6 @@ class VersionBumpCli extends CliFramework
if (!empty($updatedFiles)) {
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
}
// Joomla schema version: create empty SQL update file if sql/updates/mysql/ exists
$sqlUpdateDirs = array_merge(
SourceResolver::globSource($root, 'packages/*/sql/updates/mysql'),
SourceResolver::globSource($root, 'sql/updates/mysql'),
glob("{$root}/sql/updates/mysql") ?: []
);
$sqlUpdateDirs = array_unique(array_filter($sqlUpdateDirs, 'is_dir'));
foreach ($sqlUpdateDirs as $sqlDir) {
$sqlFile = "{$sqlDir}/{$newBase}.sql";
if (!file_exists($sqlFile)) {
file_put_contents($sqlFile, "/* {$newBase} — no schema changes */\n");
fwrite(STDERR, "Created SQL update file: " . substr($sqlFile, strlen($root) + 1) . "\n");
}
}
$packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) {
$pkgContent = file_get_contents($packageJsonFile);
@@ -384,7 +368,7 @@ class VersionBumpCli extends CliFramework
/**
* Scan git release tags for the highest version across all channels.
*
* Checks release names like "MokoSuiteClient (VERSION: 09.40.00)" in
* Checks release names like "MokoSuiteClient (VERSION: 09.38.01)" in
* git tags (stable, release-candidate, development, etc.) to find the
* highest version that has been released on any channel.
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/version_check.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Validate version consistency across README, manifests, and sub-packages
*/
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/wiki_sync.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Sync select wiki pages from mokocli to all template repos
*/
+4 -128
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/workflow_sync.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
*/
@@ -42,13 +42,9 @@ class WorkflowSyncCli extends CliFramework
'joomla' => ['deploy-manual.yml'],
];
/** Prefix for custom workflows preserved during orphan cleanup. */
private const CUSTOM_PREFIX = 'custom-';
private int $updated = 0;
private int $created = 0;
private int $skipped = 0;
private int $deleted = 0;
private int $errors = 0;
protected function configure(): void
@@ -60,7 +56,6 @@ class WorkflowSyncCli extends CliFramework
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
$this->addArgument('--phase', 'Phase to run: all, templates, repos (default: all)', 'all');
$this->addArgument('--platform-filter', 'Only sync repos matching this platform', '');
$this->addArgument('--delete-orphans', 'Delete workflows not in template (preserves custom-* and custom/)', false);
}
protected function run(): int
@@ -119,7 +114,7 @@ class WorkflowSyncCli extends CliFramework
echo "\n";
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
. "{$this->deleted} deleted, {$this->skipped} skipped, {$this->errors} error(s).");
. "{$this->skipped} skipped, {$this->errors} error(s).");
return $this->errors > 0 ? 1 : 0;
}
@@ -280,15 +275,14 @@ class WorkflowSyncCli extends CliFramework
foreach ($workflows as $workflow) {
$filename = $workflow['name'];
$destPath = '.mokogitea/workflows/' . $filename;
$label = "{$repoFullName}/{$filename}";
// Skip platform-excluded workflows
if (in_array($filename, self::PLATFORM_EXCLUDES[$platform] ?? [], true)) {
fprintf(STDERR, "%-45s | %s\n", $label, 'EXCLUDED (platform)');
$this->skipped++;
continue;
}
$destPath = '.mokogitea/workflows/' . $filename;
$label = "{$repoFullName}/{$filename}";
// Get source content from template
$sourceContent = $this->getFileContent(
@@ -309,14 +303,6 @@ class WorkflowSyncCli extends CliFramework
$destPath, $sourceContent, $branch, $commitMsg, $label
);
}
// Delete orphan workflows if enabled
if ($this->getArgument('--delete-orphans', false)) {
$templateNames = array_map(fn($w) => $w['name'], $workflows);
$this->deleteOrphanWorkflows(
$giteaUrl, $token, $org, $repoName, $branch, $templateNames, $platform
);
}
}
echo "\n";
@@ -420,116 +406,6 @@ class WorkflowSyncCli extends CliFramework
}
}
/**
* Delete workflows in a repo that are NOT in the template and NOT custom.
*
* Protected from deletion:
* - Files matching template workflow names
* - Files with `custom-` prefix (convention for repo-specific workflows)
* - Directories named `custom` (future: subfolder discovery)
* - Platform-excluded workflows
*/
private function deleteOrphanWorkflows(
string $giteaUrl,
string $token,
string $org,
string $repoName,
string $branch,
array $templateNames,
string $platform
): void {
$repoWorkflows = $this->listWorkflows($giteaUrl, $token, $org, $repoName, $branch);
if ($repoWorkflows === null) {
return;
}
$platformExcludes = self::PLATFORM_EXCLUDES[$platform] ?? [];
foreach ($repoWorkflows as $workflow) {
$name = $workflow['name'];
// Keep if it's in the template
if (in_array($name, $templateNames, true)) {
continue;
}
// Keep if it has the custom- prefix
if (str_starts_with($name, self::CUSTOM_PREFIX)) {
$label = "{$org}/{$repoName}/{$name}";
fprintf(STDERR, "%-45s | %s\n", $label, 'KEPT (custom)');
continue;
}
// Keep if it's platform-excluded (legitimately skipped during sync)
if (in_array($name, $platformExcludes, true)) {
$label = "{$org}/{$repoName}/{$name}";
fprintf(STDERR, "%-45s | %s\n", $label, 'KEPT (platform-excluded)');
continue;
}
// Delete orphan
$filePath = '.mokogitea/workflows/' . $name;
$label = "{$org}/{$repoName}/{$name}";
if ($this->dryRun) {
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD DELETE');
$this->deleted++;
continue;
}
$deleted = $this->deleteFile($giteaUrl, $token, $org, $repoName, $filePath, $branch);
if ($deleted) {
fprintf(STDERR, "%-45s | %s\n", $label, 'DELETED');
$this->deleted++;
} else {
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (delete)');
$this->errors++;
}
}
}
/**
* Delete a file from a repo via the Gitea Contents API.
*/
private function deleteFile(
string $giteaUrl,
string $token,
string $org,
string $repoName,
string $filePath,
string $branch
): bool {
// Get SHA first
$existing = $this->apiRequest(
$giteaUrl, $token, 'GET',
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}"
);
if ($existing['code'] !== 200) {
return false;
}
$data = json_decode($existing['body'], true);
$sha = $data['sha'] ?? '';
if ($sha === '') {
return false;
}
$payload = json_encode([
'sha' => $sha,
'message' => "chore: delete orphan workflow {$filePath} [skip ci]",
'branch' => $branch,
]);
$response = $this->apiRequest(
$giteaUrl, $token, 'DELETE',
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}",
$payload
);
return $response['code'] === 200;
}
/**
* List workflow files in a repo's .mokogitea/workflows/ directory.
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/backup-before-deploy.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/deploy-dolibarr.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/health-check.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/rollback-joomla.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
*/
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/sync-joomla.php
* VERSION: 09.40.00
* VERSION: 09.38.01
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
*/
+14
View File
@@ -1083,6 +1083,20 @@ class RepositorySynchronizer
}
}
// CODEOWNERS — GitHub only; Gitea doesn't enforce it
if ($this->adapter->getPlatformName() === 'github') {
$shared[] = ['templates/mokogitea/CODEOWNERS', '.github/CODEOWNERS'];
}
// Platform-specific gitignore (merged, not replaced)
$gitignoreMap = [
'dolibarr' => 'templates/configs/gitignore.dolibarr',
'platform' => 'templates/configs/gitignore.dolibarr',
'joomla' => 'templates/configs/.gitignore.joomla',
];
$gitignoreTemplate = $gitignoreMap[$platform] ?? 'templates/configs/gitignore';
$shared[] = [$gitignoreTemplate, '.gitignore'];
// Create TODO.md stub if it doesn't exist (gitignored after first commit)
$entries[] = [
'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in "
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
VERSION: 09.40.00
VERSION: 09.38.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
PATH: /SECURITY.md
VERSION: 09.40.00
VERSION: 09.38.01
BRIEF: Security vulnerability reporting and handling policy
-->
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli-Template-Generic
VERSION: 09.40.00
VERSION: 09.38.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 09.40.00
VERSION: 09.38.01
BRIEF: Security vulnerability reporting and handling policy
-->
+35
View File
@@ -0,0 +1,35 @@
{
"name": "exampleclient",
"org": "ExampleClient",
"gitea_url": "https://git.mokoconsulting.tech",
"variables": {
"DEV_SYNC_HOST": "dev.exampleclient.com",
"DEV_SYNC_PORT": "22",
"DEV_SYNC_USER": "exampleclient",
"DEV_SYNC_PATH": "/home/exampleclient/dev.exampleclient.com",
"DEV_SITE_URL": "https://dev.exampleclient.com",
"LIVE_SSH_HOST": "iad1-shared-b7-01.dreamhost.com",
"LIVE_SSH_PORT": "22",
"LIVE_SSH_USER": "exampleclient",
"LIVE_SYNC_PATH": "/home/exampleclient/exampleclient.com",
"RS_FTP_PATH_SUFFIX": "exampleclient.com"
},
"secrets": {
"DEV_SYNC_KEY": "@keys/exampleclient-dev.pem",
"LIVE_SSH_KEY": "@keys/exampleclient-live.pem"
},
"monitoring": {
"urls": [
"https://exampleclient.com",
"https://dev.exampleclient.com"
],
"domains": [
"exampleclient.com"
],
"grafana_dashboard": "monitoring/grafana/client-joomla-dashboard.json",
"grafana_folder": "Clients"
}
}
@@ -0,0 +1,63 @@
# Enterprise Issue Management Configuration Template
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Deploy to: .github/issue-management-config.yml
version: "1.0.0"
enterprise:
organization:
name: "ORGANIZATION_NAME"
default_assignees:
- "copilot"
projects:
enabled: true
default_project_number: null
auto_add_issues: true
milestones:
enabled: true
auto_assign: true
create_if_missing: true
labels:
priority:
critical:
name: "priority/critical"
color: "d73a4a"
sla_hours: 4
high:
name: "priority/high"
color: "ff9800"
sla_hours: 24
medium:
name: "priority/medium"
color: "fbca04"
sla_hours: 72
automation:
create_on_branch: true
branch_patterns:
- "dev/**"
- "rc/**"
close_on_merge: true
close_on_branch_delete: true
link_prs_to_issues: true
create_sub_tasks_for_prs: true
sla:
enabled: true
timezone: "UTC"
audit:
enabled: true
retention_days: 365
metrics:
enabled: true
error_handling:
retry_on_failure: true
max_retries: 3
+28
View File
@@ -0,0 +1,28 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "single"],
"semi": ["error", "always"],
"no-unused-vars": ["warn"],
"no-console": ["warn", { "allow": ["warn", "error"] }],
"eqeqeq": ["error", "always"],
"curly": ["error", "all"],
"brace-style": ["error", "1tbs"],
"no-trailing-spaces": "error",
"no-multiple-empty-lines": ["error", { "max": 1 }],
"eol-last": ["error", "always"]
}
}
+220
View File
@@ -0,0 +1,220 @@
# ============================================================
# Local task tracking (not version controlled)
# ============================================================
TODO.md
# ============================================================
# Environment and secrets
# ============================================================
.env
.env.local
.env.*.local
*.local.php
*.secret.php
configuration.php
configuration.*.php
configuration.local.php
conf/conf.php
conf/conf*.php
secrets/
*.secrets.*
# ============================================================
# Logs, dumps and databases
# ============================================================
*.db
*.db-journal
*.dump
*.log
*.pid
*.seed
# ============================================================
# OS / Editor / IDE cruft
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
$RECYCLE.BIN/
System Volume Information/
*.lnk
Icon?
.idea/
.settings/
.claude/
.claude-worktree*/
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime*
.project
.buildpath
.classpath
*.bak
*.swp
*.swo
*.tmp
*.old
*.orig
# ============================================================
# Dev scripts and scratch
# ============================================================
TODO.md
todo*
*ffs*
# ============================================================
# SFTP / sync tools
# ============================================================
sftp-config*.json
sftp-config.json.template
sftp-settings.json
# ============================================================
# Sublime SFTP / FTP sync
# ============================================================
*.sublime-project
*.sublime-workspace
*.sublime-settings
.libsass.json
*.ffs*
# ============================================================
# Replit / cloud IDE
# ============================================================
.replit
replit.md
# ============================================================
# Archives / release artifacts
# ============================================================
*.7z
*.rar
*.tar
*.tar.gz
*.tgz
*.zip
artifacts/
release/
releases/
# ============================================================
# Build outputs and site generators
# ============================================================
.mkdocs-build/
.cache/
.parcel-cache/
build/
dist/
out/
/site/
*.map
*.css.map
*.js.map
*.tsbuildinfo
# ============================================================
# CI / test artifacts
# ============================================================
.coverage
.coverage.*
coverage/
coverage.xml
htmlcov/
junit.xml
reports/
test-results/
tests/_output/
.github/local/
.github/workflows/*.log
# ============================================================
# Node / JavaScript
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.yarn/
.npmrc
.eslintcache
package-lock.json
# ============================================================
# PHP / Composer tooling
# ============================================================
/vendor/
!src/media/vendor/
composer.lock
*.phar
codeception.phar
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.phpstan.cache
.phplint-cache
phpmd-cache/
.psalm/
.rector/
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*.pyc
*$py.class
*.so
.Python
.eggs/
*.egg
*.egg-info/
.installed.cfg
MANIFEST
develop-eggs/
downloads/
eggs/
parts/
sdist/
var/
wheels/
ENV/
env/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyright/
.tox/
.nox/
*.cover
*.coverage
hypothesis/
# ============================================================
# Local wiki clone (not version controlled)
# ============================================================
wiki/
# ============================================================
# Joomla runtime / development
# ============================================================
cache/
tmp/
logs/
configuration.php
user.css
user.js
colors_custom.css
modulebuilder.txt
administrator/components/com_akeebabackup/backup/
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "htmlhint:recommended",
"tagname-lowercase": true,
"attr-lowercase": true,
"attr-value-double-quotes": true,
"doctype-first": true,
"tag-pair": true,
"spec-char-escape": true,
"id-unique": true,
"src-not-empty": true,
"attr-no-duplication": true,
"title-require": true,
"alt-require": true,
"doctype-html5": true,
"style-disabled": false,
"inline-style-disabled": false,
"inline-script-disabled": false,
"space-tab-mixed-disabled": "space",
"id-class-ad-disabled": false,
"href-abs-or-rel": false,
"attr-unsafe-chars": true
}
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"arrowParens": "always",
"bracketSpacing": true
}
+92
View File
@@ -0,0 +1,92 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Pylint configuration for MokoCli projects
[MASTER]
# Python code to execute, usually for sys.path manipulation
init-hook='import sys; sys.path.append(".")'
# Use multiple processes to speed up Pylint
jobs=0
# Pickle collected data for later comparisons
persistent=yes
# List of plugins (as comma separated values of python module names)
load-plugins=
# Minimum Python version to use for version dependent checks
py-version=3.8
[MESSAGES CONTROL]
# Disable specific messages
disable=
missing-module-docstring,
missing-function-docstring,
too-few-public-methods,
too-many-arguments,
too-many-locals,
too-many-branches,
too-many-statements,
duplicate-code,
fixme
[REPORTS]
# Set the output format
output-format=text
# Tells whether to display a full report or only the messages
reports=no
# Activate the evaluation score
score=yes
[BASIC]
# Good variable names
good-names=i,j,k,ex,Run,_,id,pk
# Regular expressions for acceptable names
variable-rgx=[a-z_][a-z0-9_]{0,30}$
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
class-rgx=[A-Z_][a-zA-Z0-9]+$
function-rgx=[a-z_][a-z0-9_]{1,50}$
method-rgx=[a-z_][a-z0-9_]{1,50}$
[FORMAT]
# Maximum number of characters on a single line
max-line-length=100
# Maximum number of lines in a module
max-module-lines=1000
# String used for indentation
indent-string=' '
[DESIGN]
# Maximum number of arguments for function / method
max-args=7
# Maximum number of attributes for a class
max-attributes=10
[IMPORTS]
# Deprecated modules which should not be used
deprecated-modules=optparse,imp
[CLASSES]
# List of method names used to declare instance attributes
defining-attr-methods=__init__,__new__,setUp
# List of valid names for the first argument in a class method
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method
valid-metaclass-classmethod-first-arg=mcs
[EXCEPTIONS]
# Exceptions that will emit a warning when caught
overgeneral-exceptions=builtins.Exception
+306
View File
@@ -0,0 +1,306 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: MokoPlatform.Templates
INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/configs/README.md
BRIEF: Code quality and security tool configuration templates
-->
# Code Quality Configuration Templates
This directory contains standardized configuration files for code quality, linting, and security tools used across mokocli projects.
## Available Configurations
### PHP Tools
#### `phpcs.xml` - PHP_CodeSniffer
**Purpose**: Enforce PHP coding standards (PSR-12 based)
**Usage**:
```bash
# Copy to your project root
cp phpcs.xml /path/to/your/project/
# Run PHPCS
phpcs --standard=phpcs.xml src/
# Auto-fix issues
phpcbf --standard=phpcs.xml src/
```
**Features**:
- PSR-12 compliance
- Line length limits (120 chars)
- Forbidden functions detection (eval, var_dump, etc.)
- Commented-out code detection
#### `phpstan.neon` - PHPStan
**Purpose**: Static analysis for PHP code
**Usage**:
```bash
# Copy to your project root
cp phpstan.neon /path/to/your/project/
# Install PHPStan
composer require --dev phpstan/phpstan
# Run analysis
phpstan analyse
```
**Configuration**:
- Level 5 analysis (adjust as needed)
- Checks for type errors, dead code, and more
- Configurable ignore patterns
#### `psalm.xml` - Psalm
**Purpose**: Advanced static analysis for PHP
**Usage**:
```bash
# Copy to your project root
cp psalm.xml /path/to/your/project/
# Install Psalm
composer require --dev vimeo/psalm
# Initialize and run
psalm --init
psalm
```
**Configuration**:
- Error level 4 (balanced strictness)
- Finds unused code (optional)
- Customizable issue handlers
### JavaScript/TypeScript Tools
#### `.eslintrc.json` - ESLint
**Purpose**: Identify and fix JavaScript code issues
**Usage**:
```bash
# Copy to your project root
cp .eslintrc.json /path/to/your/project/
# Install ESLint
npm install --save-dev eslint
# Run linting
npx eslint .
# Auto-fix issues
npx eslint . --fix
```
**Features**:
- ES2021 support
- Tab indentation (2-space visual width)
- Unix line endings
- Single quotes for strings
- Semicolon enforcement
#### `.prettierrc.json` - Prettier
**Purpose**: Opinionated code formatter for JavaScript/TypeScript
**Usage**:
```bash
# Copy to your project root
cp .prettierrc.json /path/to/your/project/
# Install Prettier
npm install --save-dev prettier
# Check formatting
npx prettier --check .
# Auto-format
npx prettier --write .
```
**Configuration**:
- 100 character line width
- Single quotes
- Trailing commas (ES5)
- Tab indentation (2-space visual width)
### Python Tools
#### `.pylintrc` - Pylint
**Purpose**: Python code analysis and style checking
**Usage**:
```bash
# Copy to your project root
cp .pylintrc /path/to/your/project/
# Install Pylint
pip install pylint
# Run analysis
pylint **/*.py
```
**Features**:
- PEP 8 compliance
- 100 character line limit
- Configurable message disabling
- Custom naming conventions
#### `pyproject.toml` - Python Project Configuration
**Purpose**: Unified configuration for Black, isort, mypy, and pytest
**Usage**:
```bash
# Copy to your project root
cp pyproject.toml /path/to/your/project/
# Install tools
pip install black isort mypy pytest pytest-cov
# Run Black formatter
black .
# Sort imports with isort
isort .
# Type check with mypy
mypy src/
# Run tests with coverage
pytest --cov=src
```
**Tools Configured**:
- **Black**: Opinionated Python formatter
- **isort**: Import statement sorter
- **mypy**: Static type checker
- **pytest**: Test framework
- **coverage**: Code coverage measurement
### HTML Tools
#### `.htmlhintrc` - HTMLHint
**Purpose**: HTML5 validation and best practices
**Usage**:
```bash
# Copy to your project root
cp .htmlhintrc /path/to/your/project/
# Install HTMLHint
npm install -g htmlhint
# Run validation
htmlhint **/*.html
```
**Features**:
- HTML5 doctype validation
- Tag and attribute validation
- Accessibility checks (alt, title requirements)
- Style and script validation
## Integration with GitHub Actions
All these tools are integrated into the `code-quality.yml` workflow template. To use:
1. **Copy the workflow**:
```bash
cp templates/workflows/code-quality.yml.template .github/workflows/code-quality.yml
```
2. **Copy relevant config files**:
```bash
# For PHP projects
cp templates/configs/phpcs.xml .
cp templates/configs/phpstan.neon .
# For JavaScript projects
cp templates/configs/.eslintrc.json .
cp templates/configs/.prettierrc.json .
# For Python projects
cp templates/configs/.pylintrc .
cp templates/configs/pyproject.toml .
# For HTML projects
cp templates/configs/.htmlhintrc .
```
3. **Customize for your project**: Adjust tool configurations based on your specific requirements
## Tool Installation
### PHP
```bash
# Via Composer
composer require --dev squizlabs/php_codesniffer phpstan/phpstan vimeo/psalm
```
### JavaScript/TypeScript
```bash
# Via npm
npm install --save-dev eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin
```
### Python
```bash
# Via pip
pip install pylint black mypy isort pytest pytest-cov
```
### HTML
```bash
# Via npm (global)
npm install -g htmlhint
```
## Configuration Customization
Each configuration file can be customized for your project:
1. **Adjust severity levels**: Change error levels to match your team's standards
2. **Add ignore patterns**: Exclude specific files or directories
3. **Enable/disable rules**: Fine-tune which checks are active
4. **Set code style preferences**: Modify indentation, line length, etc.
## Security Best Practices
These configurations include security-focused rules:
- **PHP**: Forbidden functions (eval, create_function)
- **JavaScript**: No console.log in production
- **Python**: Import security patterns
- **HTML**: XSS prevention patterns
## CI/CD Integration
These tools work seamlessly with:
- GitHub Actions (see workflow templates)
- GitLab CI
- Jenkins
- CircleCI
- Travis CI
## Support and Updates
Configuration templates are maintained in the mokocli repository:
- **Repository**: https://git.mokoconsulting.tech/MokoConsulting/mokocli
- **Documentation**: https://git.mokoconsulting.tech/MokoConsulting/mokocli/tree/main/docs
- **Issues**: Report problems or suggest improvements via GitHub Issues
## Version History
- **v1.0.0** (2026-01): Initial release with PHP, JavaScript, Python, and HTML configurations
+55
View File
@@ -0,0 +1,55 @@
{
"name": "mokoconsulting-tech/{{repo_name_lower}}",
"description": "{{repo_name}} Dolibarr module by Moko Consulting",
"type": "dolibarr-module",
"version": "01.00.00",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Moko Consulting",
"email": "hello@mokoconsulting.tech"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"mokoconsulting-tech/enterprise": "dev-version/04.02.00"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.0",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://git.mokoconsulting.tech/mokoconsulting-tech/MokoCli-API"
}
],
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": "dist",
"allow-plugins": {
"composer/installers": true
}
},
"scripts": {
"validate-module": "vendor/bin/validate-module --path .",
"build": "vendor/bin/build-package --path .",
"test": "phpunit",
"phpcs": "phpcs --standard=vendor/mokoconsulting-tech/enterprise/phpcs.xml src/",
"phpstan": "phpstan analyse -c phpstan.neon src/"
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "mokoconsulting-tech/{{repo_name_lower}}",
"description": "{{repo_name}} library by Moko Consulting",
"type": "library",
"version": "01.00.00",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Moko Consulting",
"email": "hello@mokoconsulting.tech"
}
],
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"mokoconsulting-tech/enterprise": "dev-version/04.02.00"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.0",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://git.mokoconsulting.tech/mokoconsulting-tech/MokoCli-API"
}
],
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": "dist"
},
"scripts": {
"validate": "vendor/bin/validate-structure --path .",
"test": "phpunit",
"phpcs": "phpcs --standard=vendor/mokoconsulting-tech/enterprise/phpcs.xml src/",
"phpstan": "phpstan analyse -c vendor/mokoconsulting-tech/enterprise/phpstan.neon src/"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "mokoconsulting-tech/{{repo_name_lower}}",
"description": "{{repo_name}} Joomla component by Moko Consulting",
"type": "joomla-component",
"version": "01.00.00",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Moko Consulting",
"email": "hello@mokoconsulting.tech"
}
],
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"mokoconsulting-tech/enterprise": "dev-version/04.02.00"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.0",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MokoConsulting\\{{repo_name}}\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://git.mokoconsulting.tech/mokoconsulting-tech/MokoCli-API"
}
],
"config": {
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": "dist",
"allow-plugins": {
"composer/installers": true
}
},
"scripts": {
"validate-manifest": "vendor/bin/validate-manifest --path .",
"build": "vendor/bin/build-package --path .",
"test": "phpunit",
"phpcs": "phpcs --standard=vendor/mokoconsulting-tech/enterprise/phpcs.xml src/",
"phpstan": "phpstan analyse -c phpstan.neon src/"
}
}
+47
View File
@@ -0,0 +1,47 @@
# .ftpignore — FTP/SFTP upload exclusion rules
# Syntax mirrors .gitignore: blank lines and # comments are ignored,
# * matches within a path segment, ** matches across segments, ? matches one char.
# A leading / anchors to the upload root; a trailing / matches directories only.
# Negation (!) is not supported.
#
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# ── Version-control metadata ──────────────────────────────────────────────────
.git/
.gitignore
.gitkeep
.gitattributes
.gitmodules
# ── CI / editor / tooling artefacts ──────────────────────────────────────────
.github/
.editorconfig
.yamllint
.phpcs.xml
.phpstan.neon
.psalm.xml
.mokostandards
# ── Dependency directories ────────────────────────────────────────────────────
vendor/
node_modules/
# ── Build / cache / temp ──────────────────────────────────────────────────────
build/
dist/
*.log
*.tmp
*.cache
# ── Secrets & local config (HARDCODED DENY — never deploy these) ─────────────
.env
.env.*
sftp-config*.json
sftp-config.json.template
scripts/sftp-config/
scripts/keys/
*.ppk
*.pem
*.key
.ftpignore
+208
View File
@@ -0,0 +1,208 @@
# ============================================================
# Local task tracking (not version controlled)
# ============================================================
TODO.md
# ============================================================
# Environment and secrets
# ============================================================
.env
.env.local
.env.*.local
*.local.php
*.secret.php
configuration.php
configuration.*.php
configuration.local.php
conf/conf.php
conf/conf*.php
secrets/
*.secrets.*
# ============================================================
# Logs, dumps and databases
# ============================================================
*.db
*.db-journal
*.dump
*.log
*.pid
*.seed
# ============================================================
# OS / Editor / IDE cruft
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
$RECYCLE.BIN/
System Volume Information/
*.lnk
Icon?
.idea/
.settings/
.claude/
.claude-worktree*/
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime*
.project
.buildpath
.classpath
*.bak
*.swp
*.swo
*.tmp
*.old
*.orig
# ============================================================
# Dev scripts and scratch
# ============================================================
TODO.md
todo*
*ffs*
# ============================================================
# SFTP / sync tools
# ============================================================
sftp-config*.json
sftp-config.json.template
sftp-settings.json
# ============================================================
# Sublime SFTP / FTP sync
# ============================================================
*.sublime-project
*.sublime-workspace
*.sublime-settings
.libsass.json
*.ffs*
# ============================================================
# Replit / cloud IDE
# ============================================================
.replit
replit.md
# ============================================================
# Archives / release artifacts
# ============================================================
*.7z
*.rar
*.tar
*.tar.gz
*.tgz
*.zip
artifacts/
release/
releases/
# ============================================================
# Build outputs and site generators
# ============================================================
.mkdocs-build/
.cache/
.parcel-cache/
build/
dist/
out/
/site/
*.map
*.css.map
*.js.map
*.tsbuildinfo
# ============================================================
# CI / test artifacts
# ============================================================
.coverage
.coverage.*
coverage/
coverage.xml
htmlcov/
junit.xml
reports/
test-results/
tests/_output/
.github/local/
.github/workflows/*.log
# ============================================================
# Node / JavaScript
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.yarn/
.npmrc
.eslintcache
package-lock.json
# ============================================================
# PHP / Composer tooling
# ============================================================
/vendor/
!src/media/vendor/
composer.lock
*.phar
codeception.phar
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.phpstan.cache
.phplint-cache
phpmd-cache/
.psalm/
.rector/
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*.pyc
*$py.class
*.so
.Python
.eggs/
*.egg
*.egg-info/
.installed.cfg
MANIFEST
develop-eggs/
downloads/
eggs/
parts/
sdist/
var/
wheels/
ENV/
env/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyright/
.tox/
.nox/
*.cover
*.coverage
hypothesis/
# ============================================================
# Local wiki clone (not version controlled)
# ============================================================
wiki/
+225
View File
@@ -0,0 +1,225 @@
# ============================================================
# Local task tracking (not version controlled)
# ============================================================
TODO.md
# ============================================================
# Environment and secrets
# ============================================================
.env
.env.local
.env.*.local
*.local.php
*.secret.php
configuration.php
configuration.*.php
configuration.local.php
conf/conf.php
conf/conf*.php
secrets/
*.secrets.*
# ============================================================
# Logs, dumps and databases
# ============================================================
*.db
*.db-journal
*.dump
*.log
*.pid
*.seed
# ============================================================
# OS / Editor / IDE cruft
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
$RECYCLE.BIN/
System Volume Information/
*.lnk
Icon?
.idea/
.settings/
.claude/
.claude-worktree*/
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime*
.project
.buildpath
.classpath
*.bak
*.swp
*.swo
*.tmp
*.old
*.orig
# ============================================================
# Dev scripts and scratch
# ============================================================
TODO.md
todo*
*ffs*
# ============================================================
# SFTP / sync tools
# ============================================================
sftp-config*.json
sftp-config.json.template
sftp-settings.json
# ============================================================
# Sublime SFTP / FTP sync
# ============================================================
*.sublime-project
*.sublime-workspace
*.sublime-settings
.libsass.json
*.ffs*
# ============================================================
# Replit / cloud IDE
# ============================================================
.replit
replit.md
# ============================================================
# Archives / release artifacts
# ============================================================
*.7z
*.rar
*.tar
*.tar.gz
*.tgz
*.zip
artifacts/
release/
releases/
# ============================================================
# Build outputs and site generators
# ============================================================
.mkdocs-build/
.cache/
.parcel-cache/
build/
dist/
out/
site/
*.map
*.css.map
*.js.map
*.tsbuildinfo
# ============================================================
# CI / test artifacts
# ============================================================
.coverage
.coverage.*
coverage/
coverage.xml
htmlcov/
junit.xml
reports/
test-results/
tests/_output/
.github/local/
.github/workflows/*.log
# ============================================================
# Node / JavaScript
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.yarn/
.npmrc
.eslintcache
package-lock.json
# ============================================================
# PHP / Composer tooling
# ============================================================
/vendor/
!src/media/vendor/
composer.lock
*.phar
codeception.phar
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.phpstan.cache
.phplint-cache
phpmd-cache/
.psalm/
.rector/
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*.pyc
*$py.class
*.so
.Python
.eggs/
*.egg
*.egg-info/
.installed.cfg
MANIFEST
develop-eggs/
downloads/
eggs/
parts/
sdist/
var/
wheels/
ENV/
env/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyright/
.tox/
.nox/
*.cover
*.coverage
hypothesis/
# ============================================================
# Local wiki clone (not version controlled)
# ============================================================
wiki/
# ============================================================
# Dolibarr runtime / data
# ============================================================
documents/
dolibarr_documents/
uploads/
thumbs/
data/
cache/
temp/
tmp/
logs/
htdocs/documents/
htdocs/cache/
htdocs/tmp/
htdocs/logs/
conf/conf.php
+43
View File
@@ -0,0 +1,43 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Templates.Configs
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/configs/index.md
BRIEF: Configuration templates directory index
-->
# Code Quality Configuration Templates
Standardized configuration files for code quality and security tools.
## Available Configurations
### PHP
- `phpcs.xml` - PHP_CodeSniffer (PSR-12)
- `phpstan.neon` - PHPStan static analysis
- `psalm.xml` - Psalm advanced analysis
### JavaScript/TypeScript
- `.eslintrc.json` - ESLint linting
- `.prettierrc.json` - Prettier formatting
### Python
- `.pylintrc` - Pylint analysis
- `pyproject.toml` - Black, isort, mypy, pytest
### HTML
- `.htmlhintrc` - HTMLHint validation
## Quick Start
```bash
# Copy configuration for your language
cp templates/configs/.eslintrc.json .
cp templates/configs/phpcs.xml .
cp templates/configs/.pylintrc .
```
See [README.md](README.md) for detailed documentation.
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Templates.Config
INGROUP: MokoPlatform.Templates
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/configs/manifest.xml.template
BRIEF: XML manifest template — synced to .mokogitea/manifest.xml in every governed repository
NOTE: This template is a reference only. The bulk sync generates XML via MokoCliParser::generate().
mokocli Repository Manifest
Auto-generated by mokocli bulk sync.
Manual edits to <governance> and <last-synced> may be overwritten.
See: docs/standards/manifest-file-spec.md
-->
<mokocli xmlns="https://standards.mokoconsulting.tech/mokocli/1.0"
schema-version="1.0">
<identity>
<name>{{REPO_NAME}}</name>
<org>{{org}}</org>
<description>{{REPO_DESCRIPTION}}</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>{{platform}}</platform>
<standards-version>{{standards_version}}</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/mokocli</standards-source>
</governance>
<build>
<language>{{PRIMARY_LANGUAGE}}</language>
</build>
</mokocli>
+19
View File
@@ -0,0 +1,19 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: MokoPlatform.Templates.Config
# INGROUP: MokoPlatform.Templates
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/configs/manifest.yml.template
# BRIEF: Governance attachment template — synced to .mokogitea/manifest.xml in every governed repository
# NOTE: Tokens replaced at sync time: {{org}}, {{repo_name}}, {{platform}}, {{standards_version}}
#
# This file is managed automatically by mokocli bulk sync.
# Do not edit manually — changes will be overwritten on the next sync.
# To update governance settings, open a PR in mokocli instead:
# https://git.mokoconsulting.tech/MokoConsulting/mokocli
standards_source: "https://git.mokoconsulting.tech/MokoConsulting/mokocli"
standards_version: "{{standards_version}}"
platform: "{{platform}}"
governed_repo: "{{org}}/{{repo_name}}"
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Templates.Config
INGROUP: MokoPlatform.Templates
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/configs/manifest.xml.template
BRIEF: XML manifest template — synced to .mokogitea/manifest.xml in every governed repository
NOTE: This template is a reference only. The bulk sync generates XML via MokoCliParser::generate().
mokocli Repository Manifest
Auto-generated by mokocli bulk sync.
Manual edits to <governance> and <last-synced> may be overwritten.
See: docs/standards/manifest-file-spec.md
-->
<mokocli xmlns="https://git.mokoconsulting.tech/MokoConsulting/mokocli"
schema-version="1.0">
<identity>
<name>{{REPO_NAME}}</name>
<org>{{org}}</org>
<description>{{REPO_DESCRIPTION}}</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>{{platform}}</platform>
<standards-version>{{standards_version}}</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/mokocli</standards-source>
</governance>
<build>
<language>{{PRIMARY_LANGUAGE}}</language>
</build>
</mokocli>
@@ -0,0 +1,19 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: MokoPlatform.Templates.Config
# INGROUP: MokoPlatform.Templates
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/configs/moko-standards.yml.template
# BRIEF: Governance attachment template — synced to .mokostandards in every governed repository
# NOTE: Tokens replaced at sync time: {{org}}, {{repo_name}}, {{platform}}, {{standards_version}}
#
# This file is managed automatically by mokocli bulk sync.
# Do not edit manually — changes will be overwritten on the next sync.
# To update governance settings, open a PR in mokocli instead:
# https://git.mokoconsulting.tech/MokoConsulting/mokocli
standards_source: "https://git.mokoconsulting.tech/MokoConsulting/mokocli"
standards_version: "{{standards_version}}"
platform: "{{platform}}"
governed_repo: "{{org}}/{{repo_name}}"
+22
View File
@@ -0,0 +1,22 @@
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 30000,
"wait": 1000,
"ignore": [],
"chromeLaunchConfig": {
"args": [
"--no-sandbox",
"--disable-setuid-sandbox"
]
}
},
"urls": [
{
"url": "http://localhost:8080/",
"actions": []
}
],
"concurrency": 2,
"useIncognitoBrowserContext": true
}
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<ruleset name="mokocli PHP Coding Standards">
<description>PHP_CodeSniffer configuration for mokocli projects</description>
<!-- Files to check -->
<file>src</file>
<file>tests</file>
<!-- Exclude vendor and other dependencies -->
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/node_modules/*</exclude-pattern>
<exclude-pattern>*/.git/*</exclude-pattern>
<!-- Use PSR-12 as base standard -->
<rule ref="PSR12"/>
<!-- Additional rules -->
<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.CodeAnalysis.UnconditionalIfStatement"/>
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="150"/>
</properties>
</rule>
<rule ref="Generic.PHP.ForbiddenFunctions">
<properties>
<property name="forbiddenFunctions" type="array">
<element key="eval" value="null"/>
<element key="create_function" value="null"/>
<element key="var_dump" value="null"/>
<element key="print_r" value="null"/>
</property>
</properties>
</rule>
<rule ref="Squiz.PHP.CommentedOutCode"/>
<rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>
<!-- Show progress and use colors -->
<arg value="p"/>
<arg name="colors"/>
<!-- Show sniff codes in all reports -->
<arg value="s"/>
</ruleset>
+39
View File
@@ -0,0 +1,39 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# PHPStan configuration for Dolibarr module repositories.
# Extends the base MokoCli config and adds Dolibarr class stubs
# so PHPStan can resolve CommonObject, DoliDB, Conf, User, etc.
# without requiring a full Dolibarr installation.
parameters:
level: 5
paths:
- src
excludePaths:
- vendor
- node_modules
# Dolibarr class stubs — resolved via the enterprise package from vendor/
stubFiles:
- vendor/mokoconsulting-tech/enterprise/templates/stubs/dolibarr.php
# Suppress errors that are structural in Dolibarr's dynamic architecture
ignoreErrors:
# Dolibarr uses dynamic properties heavily (pre-PHP 8.2 pattern)
- '#Access to an undefined property [A-Za-z]+::\$#'
# Module descriptors use magic property assignment in __construct
- '#Variable \$[a-z]+ might not be defined\.#'
# Common Dolibarr globals declared at script entry point
dynamicConstantNames:
- DOL_DOCUMENT_ROOT
- DOL_URL_ROOT
- DOL_VERSION
- MAIN_DB_PREFIX
reportUnmatchedIgnoredErrors: false
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
+32
View File
@@ -0,0 +1,32 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# PHPStan configuration for Joomla extension repositories.
# Extends the base MokoCli config and adds Joomla framework class stubs
# so PHPStan can resolve Factory, CMSApplication, User, Table, etc.
# without requiring a full Joomla installation.
parameters:
level: 5
paths:
- src
excludePaths:
- vendor
- node_modules
# Joomla framework stubs — resolved via the enterprise package from vendor/
stubFiles:
- vendor/mokoconsulting-tech/enterprise/templates/stubs/joomla.php
# Suppress errors that are structural in Joomla's service-container architecture
ignoreErrors:
# Joomla's service-based dependency injection returns mixed from getApplication()
- '#Cannot call method .+ on Joomla\\CMS\\Application\\CMSApplication\|null#'
# Factory::getX() patterns are safe at runtime even when nullable in stubs
- '#Call to static method [a-zA-Z]+\(\) on an interface#'
reportUnmatchedIgnoredErrors: false
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
+35
View File
@@ -0,0 +1,35 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
# PHPStan configuration for MokoCli projects
parameters:
level: 5
paths:
- src
- tests
excludePaths:
- vendor
- node_modules
# Report unknown classes and functions
reportUnmatchedIgnoredErrors: false
# Check for dead code
checkMissingIterableValueType: false
checkGenericClassInNonGenericObjectType: false
# Additional checks
checkAlwaysTrueCheckTypeFunctionCall: true
checkAlwaysTrueInstanceof: true
checkAlwaysTrueStrictComparison: true
checkExplicitMixedMissingReturn: true
checkFunctionNameCase: true
checkInternalClassCaseSensitivity: true
# Ignore common patterns
ignoreErrors:
# Add project-specific ignores here
# - '#Call to an undefined method#'
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<psalm
errorLevel="4"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true"
findUnusedCode="false"
>
<projectFiles>
<directory name="src" />
<directory name="tests" />
<ignoreFiles>
<directory name="vendor" />
<directory name="node_modules" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<UnusedVariable>
<errorLevel type="suppress">
<directory name="tests" />
</errorLevel>
</UnusedVariable>
</issueHandlers>
</psalm>
+85
View File
@@ -0,0 +1,85 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Python project configuration for MokoCli projects
[tool.black]
line-length = 100
target-version = ['py38', 'py39', 'py310', 'py311']
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.venv
| venv
| \.eggs
| \.tox
| build
| dist
| __pycache__
| node_modules
)/
'''
[tool.isort]
profile = "black"
line_length = 100
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true
disallow_untyped_calls = false
disallow_untyped_decorators = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --strict-markers"
testpaths = [
"tests",
]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
[tool.coverage.run]
source = ["src"]
omit = [
"*/tests/*",
"*/__pycache__/*",
"*/venv/*",
"*/.venv/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"@abstractmethod",
]
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"schedule:weekly",
":disableDependencyDashboard"
],
"labels": ["dependencies"],
"automerge": false,
"platformAutomerge": false,
"rangeStrategy": "bump",
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchManagers": ["composer"],
"enabled": true
},
{
"matchManagers": ["npm"],
"enabled": true
}
]
}
+117
View File
@@ -0,0 +1,117 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Templates.Docs
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/docs/README.md
BRIEF: Documentation templates README
-->
# Documentation Templates
## Purpose
This directory contains governed documentation templates for the mokocli organization. These templates ensure consistency, completeness, and compliance across all documentation artifacts.
## Intended Use
Use these templates when:
- Creating new documentation files
- Establishing documentation in new repositories
- Ensuring compliance with documentation standards
- Maintaining consistency across projects
## Instructions
### Template Categories
Templates are organized into two categories:
1. **Required Templates** - `/templates/docs/required/`
- Mandatory documentation files for all repositories
- Must be present and maintained
- Subject to compliance review
2. **Extra Templates** - `/templates/docs/extra/`
- Optional documentation files
- Recommended for specific use cases
- Enhance documentation quality
### Using Templates
To use a template:
1. Navigate to appropriate template category (required or extra)
2. Copy the template file to your target location
3. Rename the file removing the `template-` prefix
4. Replace all placeholder content with actual information
5. Complete all required fields
6. Remove example sections or mark them explicitly as examples
7. Follow the template instructions section
8. Validate against Document Formatting Policy
### Template Maintenance
Templates are governed assets and must:
- Follow Document Formatting Policy requirements
- Include all required sections for templates
- Contain no production data
- Use placeholder values only
- Be reviewed per governance schedule
- Have Project task entries
## Required Fields
When using templates, ensure these fields are completed:
- All section headers and content
- Metadata fields specific to the document
- Revision history
- Purpose and scope statements
- Responsibilities and governance rules (where applicable)
## Example Usage
### Creating a New Repository README
```bash
# Copy template to target location
cp /templates/docs/required/template-README.md /path/to/repo/README.md
# Edit the file
# - Replace "[Repository Name]" with actual repository name
# - Complete all sections
# - Update metadata
# - Customize content for your repository
```
### Creating a New Policy Document
```bash
# Use policy template structure
# Follow /docs/policy/ examples
# Ensure all mandatory policy sections included
# Obtain required approvals per policy
```
## Metadata
- **Document Type:** overview
- **Document Subtype:** catalog
- **Owner Role:** Documentation Owner
- **Approval Required:** No
- **Evidence Required:** Yes
- **Review Cycle:** Annual
- **Retention:** Indefinite
- **Compliance Tags:** Governance
- **Status:** Published
## Revision History
- Initial template catalog established
- Template categories and usage instructions defined
- Template maintenance requirements documented
+200
View File
@@ -0,0 +1,200 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Templates.Docs.Extra
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/docs/extra/README.md
BRIEF: Extra documentation templates README
-->
# Extra Documentation Templates
## Purpose
This directory contains optional documentation templates that enhance repository documentation quality and completeness. These templates are recommended but not mandatory, providing additional documentation capabilities beyond baseline requirements.
## Intended Use
Use these templates when:
- Enhanced documentation is beneficial
- Specific use cases require additional documentation
- Improving documentation beyond minimum requirements
- Addressing stakeholder needs for additional information
- Meeting specific project or compliance needs
## Instructions
### Optional Templates
Extra templates provide documentation for:
1. **Code of Conduct** - Community behavior guidelines
2. **Security Policy** - Security reporting and disclosure
3. **Support Documentation** - Support channels and procedures
4. **Governance Documents** - Decision-making and authority
5. **Additional Technical Documentation** - Architecture, design, API docs
### Template Selection
Choose extra templates based on:
- **Project Size** - Larger projects benefit from comprehensive documentation
- **Community Size** - Projects with external contributors need governance docs
- **Security Requirements** - Security-sensitive projects need security policy
- **Stakeholder Needs** - Specific stakeholder requirements
- **Compliance Requirements** - Regulatory or audit requirements
### Template Usage
For each extra template:
1. Determine if template is needed for your project
2. Copy the template file from this directory
3. Rename removing the `template-` prefix
4. Place in appropriate repository location
5. Complete all sections
6. Replace all placeholder values
7. Customize for your project context
8. Create Project task if document is critical
9. Maintain per appropriate review cycle
### When to Use Extra Templates
#### Code of Conduct
Use when:
- Repository accepts external contributions
- Community interactions expected
- Need to establish behavioral expectations
- Creating inclusive environment
#### Security Policy
Use when:
- Repository contains security-sensitive code
- Security vulnerabilities may be discovered
- Need coordinated disclosure process
- Compliance requires security documentation
#### Support Documentation
Use when:
- Users need support information
- Multiple support channels exist
- Support expectations must be clear
- SLA or support tiers defined
#### Governance Documents
Use when:
- Decision-making authority must be clear
- Multiple maintainers or teams involved
- Escalation procedures needed
- Organizational governance required
## Required Fields
When using extra templates, ensure these fields are completed:
- All section headers with appropriate content
- Purpose and scope clearly defined
- Contact information where applicable
- Procedures and processes clearly documented
- Metadata (for governed documents)
- Revision history
## Example Usage
### Adding Code of Conduct
```bash
# Copy template
cp /templates/docs/extra/template-CODE_OF_CONDUCT.md ./CODE_OF_CONDUCT.md
# Edit the file
# - Complete all sections
# - Add contact information for enforcement
# - Customize behavioral expectations
# - Add reporting procedures
# Commit to repository
git add CODE_OF_CONDUCT.md
git commit -m "Add Code of Conduct"
```
### Adding Security Policy
```bash
# Copy template
cp /templates/docs/extra/template-SECURITY.md ./SECURITY.md
# Edit the file
# - Define supported versions
# - Add vulnerability reporting procedures
# - Define disclosure timelines
# - Add security contact information
# Commit to repository
git add SECURITY.md
git commit -m "Add security policy"
```
### Adding Support Documentation
```bash
# Copy template
cp /templates/docs/extra/template-SUPPORT.md ./SUPPORT.md
# Edit the file
# - List support channels
# - Define response expectations
# - Add support tiers if applicable
# - Include escalation procedures
# Commit to repository
git add SUPPORT.md
git commit -m "Add support documentation"
```
## Template List
- **template-CODE_OF_CONDUCT.md** - Community behavior guidelines template
- Additional templates as needed for specific use cases
Note: Extra templates are created on-demand based on organizational needs. Check this directory for available templates or request new templates through governance channels.
## Best Practices
When using extra templates:
- Only use templates that add value to your project
- Keep documentation current and accurate
- Follow Document Formatting Policy
- Create Project tasks for critical documents
- Review and update per appropriate schedule
- Remove unused templates to reduce maintenance burden
## Metadata
- **Document Type:** overview
- **Document Subtype:** catalog
- **Owner Role:** Documentation Owner
- **Approval Required:** No
- **Evidence Required:** Yes
- **Review Cycle:** Annual
- **Retention:** Indefinite
- **Compliance Tags:** Governance
- **Status:** Published
## Revision History
- Initial extra templates catalog established
- Template selection guidance defined
- Usage instructions and best practices documented
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Templates.Docs.Extra
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /templates/docs/extra/index.md
BRIEF: Extra documentation templates directory index
-->
# Docs Index: /templates/docs/extra
## Purpose
This index provides navigation to documentation within this folder.
## Documents
- [README](./README.md)
- [template-CODE_OF_CONDUCT](./template-CODE_OF_CONDUCT.md)
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is automatically generated by rebuild_indexes.py
## Revision History
| Change | Notes | Author |
| --- | --- | --- |
| Automated update | Generated by documentation index automation | rebuild_indexes.py |

Some files were not shown because too many files have changed in this diff Show More