Public Access
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c4776eef |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 09.40.00
|
||||
# VERSION: 09.37.06
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -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
|
||||
|
||||
+534
-534
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
+6
-6
@@ -12,14 +12,14 @@ BRIEF: Release changelog
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [09.40.00] --- 2026-06-25
|
||||
## [09.37.00] --- 2026-06-21
|
||||
|
||||
## [09.40.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-25
|
||||
## [09.36.00] --- 2026-06-21
|
||||
|
||||
## [09.39.00] --- 2026-06-23
|
||||
## [09.35.00] --- 2026-06-21
|
||||
|
||||
## [09.39.00] --- 2026-06-23
|
||||
## [09.35.00] --- 2026-06-21
|
||||
|
||||
@@ -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.37.06
|
||||
BRIEF: Project overview and documentation
|
||||
-->
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Executable
+1428
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
Executable
+108
@@ -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)"
|
||||
@@ -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());
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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 "$@"
|
||||
@@ -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.37.06
|
||||
* 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());
|
||||
@@ -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";
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/branch_rename.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.37.06
|
||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.37.06
|
||||
* BRIEF: Trigger a workflow across multiple repos at once
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/client_dashboard.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Generate unified client dashboard HTML
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/client_inventory.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/client_provision.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Provision a new client environment end-to-end
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/grafana_dashboard.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Manage Grafana dashboards via API
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/joomla_build.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||
* NOTE: Called by pre-release and auto-release workflows.
|
||||
*/
|
||||
|
||||
@@ -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.37.06
|
||||
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/manifest_detect.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Auto-detect manifest fields from source files and optionally push to API
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/manifest_integrity.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Cross-check manifest API fields against repo contents across the org
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/manifest_licensing.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/manifest_read.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/platform_detect.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Auto-detect repository platform type and optionally update manifest
|
||||
*/
|
||||
|
||||
|
||||
+13
-1
@@ -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]\"");
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/release_cascade.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Cascade release zip to all lower stability channels
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/release_publish.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/scaffold_client.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||
*/
|
||||
|
||||
|
||||
@@ -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.37.06
|
||||
* 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
|
||||
|
||||
@@ -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.37.06
|
||||
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
||||
*/
|
||||
|
||||
|
||||
+1
-15
@@ -234,20 +234,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 +370,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.37.06)" in
|
||||
* git tags (stable, release-candidate, development, etc.) to find the
|
||||
* highest version that has been released on any channel.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/version_check.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*/
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/wiki_sync.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Sync select wiki pages from mokocli to all template repos
|
||||
*/
|
||||
|
||||
|
||||
+4
-128
@@ -10,7 +10,7 @@
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/workflow_sync.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -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.37.06
|
||||
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /deploy/deploy-dolibarr.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /deploy/health-check.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /deploy/rollback-joomla.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /deploy/sync-joomla.php
|
||||
* VERSION: 09.40.00
|
||||
* VERSION: 09.37.06
|
||||
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.37.06
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: Contribution guidelines for the project
|
||||
-->
|
||||
|
||||
@@ -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.37.06
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
DEFGROUP:
|
||||
INGROUP: Project.Documentation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli-Template-Generic
|
||||
VERSION: 09.40.00
|
||||
VERSION: 09.37.06
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: Contribution guidelines for the project
|
||||
-->
|
||||
|
||||
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 09.40.00
|
||||
VERSION: 09.37.06
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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}}"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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#'
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user