20 Commits

Author SHA1 Message Date
gitea-actions[bot] 6d8e09827d chore: promote changelog [Unreleased] → [09.38.00] 2026-06-21 23:22:28 +00:00
gitea-actions[bot] 6190be6ed2 chore(release): build 09.38.00 [skip ci]
Publish to Composer / Publish Package (release) Waiting to run
2026-06-21 23:22:24 +00:00
jmiller 58590c4f7f Merge pull request 'chore: remove automation directory' (#309) from fix/remove-automation into main
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 1m15s
2026-06-21 23:10:01 +00:00
Jonathan Miller b74e3f0f83 chore: remove automation directory
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokocli CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: PR Check / Secret Scan (pull_request) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
Platform: mokocli CI / Gate 1: Code Quality (pull_request) Failing after 1m16s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 25s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8s
2026-06-21 18:03:06 -05:00
jmiller 7904a17355 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:02:21 +00:00
jmiller 65ade5dc57 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:09 +00:00
jmiller 173e2c4c35 chore: sync security-audit.yml from Template-Generic [skip ci] 2026-06-21 06:34:41 +00:00
jmiller 798094bab9 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 06:34:40 +00:00
jmiller 665a4fec63 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 06:34:39 +00:00
jmiller eda5875718 chore: sync notify.yml from Template-Generic [skip ci] 2026-06-21 06:34:38 +00:00
jmiller cd02979860 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 06:34:37 +00:00
jmiller 62428b1c9a chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-21 06:34:36 +00:00
jmiller 47be9257d3 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-21 06:34:34 +00:00
jmiller f12347594b chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-21 06:34:33 +00:00
jmiller a4c1364eb2 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-21 06:34:32 +00:00
jmiller 1cd3aff4ce chore: sync branch-cleanup.yml from Template-Generic [skip ci] 2026-06-21 06:34:31 +00:00
jmiller fccc9313e3 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 06:34:30 +00:00
gitea-actions[bot] f19fb7fb0d chore: promote changelog [Unreleased] → [09.38.00] 2026-06-21 06:19:19 +00:00
gitea-actions[bot] 733de92d19 chore(release): build 09.38.00 [skip ci]
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Publish to Composer / Publish Package (release) Successful in 34s
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 42s
2026-06-21 06:19:16 +00:00
jmiller fa0862b224 Merge pull request 'fix(package): only zip sub-extensions listed in package manifest (#300)' (#305) from dev into main
Platform: mokocli CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: mokocli CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: mokocli CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: mokocli CI / Gate 4: Governance (push) Blocked by required conditions
Platform: mokocli CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: mokocli CI / CI Summary (push) Blocked by required conditions
Platform: mokocli CI / Gate 1: Code Quality (push) Failing after 49s
2026-06-21 06:18:59 +00:00
66 changed files with 1431 additions and 8722 deletions
+43 -7
View File
@@ -10,9 +10,9 @@
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +=======================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
@@ -21,7 +21,7 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +=======================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
@@ -51,7 +51,7 @@ permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
@@ -149,7 +149,7 @@ jobs:
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $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) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release: release:
name: Build & Release Pipeline name: Build & Release Pipeline
runs-on: release runs-on: release
@@ -241,11 +241,47 @@ jobs:
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT" [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=stable" >> "$GITHUB_OUTPUT" PLATFORM="${{ steps.platform.outputs.platform }}"
echo "release_tag=stable" >> "$GITHUB_OUTPUT" 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 "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}" 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 - name: Update release notes and promote changelog
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+1 -1
View File
@@ -4,7 +4,7 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Universal # INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
+1 -1
View File
@@ -4,7 +4,7 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.CI # INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml # PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Maintenance # INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+4 -4
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Deploy # INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template # PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00 # VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos # BRIEF: Manual SFTP deploy to dev server for Joomla repos
@@ -40,7 +40,7 @@ jobs:
run: | run: |
php -v && composer --version php -v && composer --version
- name: Setup MokoCli tools - name: Setup MokoStandards tools
env: env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_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 }}"}}' COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: | run: |
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true /tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then 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 cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 09.37.07 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Notifications # INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.CI # INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template # PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge # BRIEF: PR gate — branch policy + code validation before merge
+16
View File
@@ -88,8 +88,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 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 - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
@@ -166,6 +178,7 @@ jobs:
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -176,6 +189,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -212,6 +226,7 @@ jobs:
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -225,6 +240,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoCli.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
+8 -8
View File
@@ -12,14 +12,14 @@ BRIEF: Release changelog
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [09.37.00] --- 2026-06-21 ## [09.38.00] --- 2026-06-21
## [09.38.00] --- 2026-06-21
## [09.38.00] --- 2026-06-21
## [09.38.00] --- 2026-06-21
## [09.37.00] --- 2026-06-21 ## [09.37.00] --- 2026-06-21
## [09.36.00] --- 2026-06-21 ## [09.37.00] --- 2026-06-21
## [09.36.00] --- 2026-06-21
## [09.35.00] --- 2026-06-21
## [09.35.00] --- 2026-06-21
+1 -1
View File
@@ -6,7 +6,7 @@ DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /README.md PATH: /README.md
VERSION: 09.37.07 VERSION: 09.38.00
BRIEF: Project overview and documentation BRIEF: Project overview and documentation
--> -->
-937
View File
@@ -1,937 +0,0 @@
#!/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());
}
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org
set -euo pipefail
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") GITEA_URL TOKEN ORG WORKFLOW [REF] [INPUTS]
Arguments:
GITEA_URL Base URL of the Gitea instance (e.g. https://git.mokoconsulting.tech)
TOKEN Gitea API token with repo/action permissions
ORG Organisation or user that owns the repos
WORKFLOW Workflow filename to trigger (e.g. dependency-audit.yml)
REF Branch ref to run against (default: main)
INPUTS Optional JSON object of workflow inputs (e.g. '{"dry_run":"true"}')
Example:
$(basename "$0") https://git.mokoconsulting.tech abc123 MokoConsulting dependency-audit.yml main '{"notify":"true"}'
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ $# -lt 4 ]; then
usage
fi
GITEA_URL="${1%/}"
TOKEN="$2"
ORG="$3"
WORKFLOW="$4"
REF="${5:-main}"
INPUTS="${6:-{\}}"
# ---------------------------------------------------------------------------
# Fetch all repos in the org, paginated
# ---------------------------------------------------------------------------
echo "Fetching repos for org '${ORG}' on ${GITEA_URL} ..."
PAGE=1
LIMIT=50
ALL_REPOS=""
while true; do
RESPONSE=$(curl -s \
-H "Authorization: token ${TOKEN}" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/orgs/${ORG}/repos?page=${PAGE}&limit=${LIMIT}")
# Break if empty array
COUNT=$(echo "$RESPONSE" | jq -r 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
NAMES=$(echo "$RESPONSE" | jq -r '.[].name')
ALL_REPOS="${ALL_REPOS}${NAMES}"$'\n'
if [ "$COUNT" -lt "$LIMIT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
# ---------------------------------------------------------------------------
# Filter for client-waas repos
# ---------------------------------------------------------------------------
CLIENT_REPOS=$(echo "$ALL_REPOS" | grep 'client-waas' | sort || true)
if [ -z "$CLIENT_REPOS" ]; then
echo "No client-waas repos found in org '${ORG}'."
exit 0
fi
TOTAL=$(echo "$CLIENT_REPOS" | wc -l | tr -d ' ')
echo "Found ${TOTAL} client-waas repo(s). Triggering workflow '${WORKFLOW}' (ref: ${REF}) ..."
echo ""
# ---------------------------------------------------------------------------
# Trigger workflow for each repo
# ---------------------------------------------------------------------------
SUCCESS=0
FAIL=0
while IFS= read -r REPO; do
[ -z "$REPO" ] && continue
PAYLOAD=$(jq -n --arg ref "$REF" --argjson inputs "$INPUTS" '{ref: $ref, inputs: $inputs}')
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${ORG}/${REPO}/actions/workflows/${WORKFLOW}/dispatches")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 201 ]; then
echo " [OK] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
SUCCESS=$((SUCCESS + 1))
else
echo " [FAIL] ${ORG}/${REPO} (HTTP ${HTTP_CODE})"
FAIL=$((FAIL + 1))
fi
done <<< "$CLIENT_REPOS"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Done. Success: ${SUCCESS} | Failed: ${FAIL} | Total: ${TOTAL}"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
-237
View File
@@ -1,237 +0,0 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: mokocli.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# enforce_tags.sh — Ensure all repos have the 5 standard release channel tags
#
# Standard tags: development, alpha, beta, release-candidate, stable
# Also removes non-standard tags (keeps vXX production tags)
#
# Usage:
# GA_TOKEN=xxx ./enforce_tags.sh [--dry-run] [--repos repo1,repo2]
#
# Called by: bulk-repo-sync.yml, infrastructure-tests/mirror-check.yml
# =============================================================================
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
ORG="${GITEA_ORG:-MokoConsulting}"
TOKEN="${GA_TOKEN:?GA_TOKEN required}"
DRY_RUN=false
FILTER_REPOS=""
STANDARD_TAGS=("development" "alpha" "beta" "release-candidate" "stable")
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--repos) FILTER_REPOS="$2"; shift 2 ;;
*) shift ;;
esac
done
api() {
local method="$1" path="$2" data="${3:-}"
local args=(-sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -X "$method")
[[ -n "$data" ]] && args+=(-d "$data")
curl "${args[@]}" "$GITEA_URL/api/v1$path" 2>/dev/null
}
# Get repos
REPOS=""
for page in 1 2 3; do
BATCH=$(api GET "/orgs/$ORG/repos?limit=50&page=$page" | python3 -c "
import sys,json
for r in json.load(sys.stdin):
if not r.get(empty) and not r.get(archived):
print(r[name])
" 2>/dev/null)
[[ -z "$BATCH" ]] && break
REPOS="$REPOS $BATCH"
done
# Filter if specified
if [[ -n "$FILTER_REPOS" ]]; then
FILTERED=""
IFS=, read -ra FILTER_ARR <<< "$FILTER_REPOS"
for repo in $REPOS; do
for f in "${FILTER_ARR[@]}"; do
[[ "$repo" == "$f" ]] && FILTERED="$FILTERED $repo"
done
done
REPOS="$FILTERED"
fi
TOTAL=$(echo $REPOS | wc -w)
ADDED=0
DELETED=0
ERRORS=0
echo "Enforcing tags on $TOTAL repos (dry_run=$DRY_RUN)"
for repo in $REPOS; do
TAGS=$(api GET "/repos/$ORG/$repo/tags?limit=50" | python3 -c "import sys,json; print( .join(t[name] for t in json.load(sys.stdin)))" 2>/dev/null)
MAIN_SHA=$(api GET "/repos/$ORG/$repo/branches/main" | python3 -c "import sys,json; print(json.load(sys.stdin)[commit][id])" 2>/dev/null)
[[ -z "$MAIN_SHA" ]] && continue
# Add missing standard tags
for st in "${STANDARD_TAGS[@]}"; do
if ! echo " $TAGS " | grep -q " $st "; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] ADD $repo: $st"
else
STATUS=$(api POST "/repos/$ORG/$repo/tags" "{\"tag_name\":\"$st\",\"target\":\"$MAIN_SHA\"}" | python3 -c "import sys,json; print(ok)" 2>/dev/null || echo "err")
[[ "$STATUS" == "ok" ]] && ADDED=$((ADDED + 1)) || ERRORS=$((ERRORS + 1))
fi
fi
done
# Remove non-standard tags
for t in $TAGS; do
IS_STD=false
for st in "${STANDARD_TAGS[@]}"; do [[ "$t" == "$st" ]] && IS_STD=true; done
# Keep vXX production tags
if [[ "$t" =~ ^v[0-9]{1,3}$ ]]; then IS_STD=true; fi
if [[ "$IS_STD" == "false" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] DEL $repo: $t"
else
# Delete release first if exists
api DELETE "/repos/$ORG/$repo/releases/tags/$t" > /dev/null 2>&1 || true
api DELETE "/repos/$ORG/$repo/tags/$t" > /dev/null 2>&1
DELETED=$((DELETED + 1))
echo " DEL $repo: $t"
fi
fi
done
done
echo "Done: $ADDED added, $DELETED deleted, $ERRORS errors (dry_run=$DRY_RUN)"
-481
View File
@@ -1,481 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/enrich_manifest_xml.php
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
*
* Note: This script uses proc_open for shell commands. All arguments are escaped
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class EnrichManifestXmlCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Enrich XML manifests with repo-specific build and deploy details');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
echo "=== mokocli XML Manifest Enrichment ===\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if (!empty($skipRepos)) {
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " {$name} ... SKIP (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
$stats['skipped']++;
continue;
}
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} ... ";
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch)
. ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
$stats['failed']++;
continue;
}
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokocli')) {
echo "SKIP (no XML manifest)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
$existingXml = file_get_contents($manifestPath);
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
$enrichment = $this->inspectRepo($workDir, $platform);
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? ManifestParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
$dc = count($enrichment['deploy'] ?? []);
$sc = count($enrichment['scripts'] ?? []);
$details = "deploy={$dc} scripts={$sc}";
if ($this->dryRun) {
echo "WOULD ENRICH [{$details}]\n";
$stats['enriched']++;
$this->rmTree($workDir);
continue;
}
file_put_contents($manifestPath, $enrichedXml);
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}");
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
[$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pr !== 0) {
echo "FAIL (push)\n";
$stats['failed']++;
} else {
echo "ENRICHED [{$details}]\n";
$stats['enriched']++;
}
$this->rmTree($workDir);
}
@rmdir($tmpBase);
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
return 0;
}
private function inspectRepo(string $workDir, string $platform): array
{
$enrichment = [];
$build = [];
// Detect entry point
if (is_dir("{$workDir}/src")) {
foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) {
$c = file_get_contents($xf);
if (str_contains($c, '<extension') || str_contains($c, '<install')) {
$build['entry_point'] = 'src/' . basename($xf);
break;
}
}
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
break;
}
}
// composer.json
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$phpReq = $composer['require']['php'] ?? null;
if ($phpReq) {
$build['runtime'] = "php:{$phpReq}";
}
$deps = [];
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
if (isset($composer['require'][$pd])) {
$deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform'];
}
}
if (isset($composer['require']['mokoconsulting-tech/enterprise'])) {
$deps[] = [
'name' => 'mokoconsulting-tech/enterprise',
'version' => $composer['require']['mokoconsulting-tech/enterprise'],
'type' => 'composer',
];
}
if (!empty($deps)) {
$build['dependencies'] = $deps;
}
}
// Artifact from Makefile
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) {
$build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]];
}
}
if (!empty($build)) {
$enrichment['build'] = $build;
}
// Deploy targets from workflows
$targets = [];
$wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows";
if (is_dir($wfDir)) {
foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) {
$wf = "{$wfDir}/{$dn}.yml";
if (!file_exists($wf)) {
continue;
}
$wc = file_get_contents($wf);
$t = ['name' => str_replace('deploy-', '', $dn)];
if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) {
$t['method'] = 'sftp';
} elseif (str_contains($wc, 'rsync')) {
$t['method'] = 'rsync';
}
if (str_contains($wc, 'src/')) {
$t['src_dir'] = 'src/';
}
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
$t['branch'] = $m[1];
}
$targets[] = $t;
}
}
if (!empty($targets)) {
$enrichment['deploy'] = $targets;
}
// Scripts from Makefile + composer
$scripts = [];
if (file_exists("{$workDir}/Makefile")) {
$mk = file_get_contents("{$workDir}/Makefile");
$known = [
'build' => 'build', 'test' => 'test', 'lint' => 'lint',
'clean' => 'build', 'package' => 'build',
'validate' => 'validate', 'release' => 'release',
];
if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) {
foreach ($matches[1] as $tgt) {
$tl = strtolower($tgt);
if (isset($known[$tl])) {
$scripts[] = [
'name' => $tl, 'phase' => $known[$tl],
'command' => "make {$tgt}",
'desc' => ucfirst($tl) . ' via make',
'runner' => 'make',
];
}
}
}
}
if (file_exists("{$workDir}/composer.json")) {
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
$km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate'];
foreach ($composer['scripts'] ?? [] as $sn => $cmd) {
$sl = strtolower($sn);
foreach ($km as $match => $phase) {
if (str_contains($sl, $match)) {
$exists = false;
foreach ($scripts as $s) {
if ($s['name'] === $sl) {
$exists = true;
break;
}
}
if (!$exists) {
$scripts[] = [
'name' => $sn, 'phase' => $phase,
'command' => "composer run {$sn}",
'desc' => is_string($cmd) ? $cmd : "Run {$sn}",
'runner' => 'composer',
];
}
break;
}
}
}
}
if (!empty($scripts)) {
$enrichment['scripts'] = $scripts;
}
return $enrichment;
}
private function enrichManifestXml(string $xml, array $enrichment): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
return $xml;
}
$ns = ManifestParser::NAMESPACE_URI;
$root = $dom->documentElement;
foreach (['build', 'deploy', 'scripts'] as $tag) {
$toRemove = [];
$existing = $root->getElementsByTagNameNS($ns, $tag);
for ($i = 0; $i < $existing->length; $i++) {
$toRemove[] = $existing->item($i);
}
foreach ($toRemove as $node) {
$root->removeChild($node);
}
}
if (!empty($enrichment['build'])) {
$buildEl = $dom->createElementNS($ns, 'build');
$b = $enrichment['build'];
foreach (['language', 'runtime'] as $f) {
if (isset($b[$f])) {
$buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
}
}
if (isset($b['package_type'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
}
if (isset($b['entry_point'])) {
$buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1)));
}
if (isset($b['artifact'])) {
$art = $dom->createElementNS($ns, 'artifact');
foreach (['format','path','filename'] as $af) {
if (isset($b['artifact'][$af])) {
$art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1)));
}
}
$buildEl->appendChild($art);
}
if (isset($b['dependencies'])) {
$deps = $dom->createElementNS($ns, 'dependencies');
foreach ($b['dependencies'] as $d) {
$req = $dom->createElementNS($ns, 'requires', '');
$req->setAttribute('name', $d['name']);
if (isset($d['version'])) {
$req->setAttribute('version', $d['version']);
}
if (isset($d['type'])) {
$req->setAttribute('type', $d['type']);
}
$deps->appendChild($req);
}
$buildEl->appendChild($deps);
}
$root->appendChild($buildEl);
}
if (!empty($enrichment['deploy'])) {
$deploy = $dom->createElementNS($ns, 'deploy');
foreach ($enrichment['deploy'] as $t) {
$target = $dom->createElementNS($ns, 'target');
$target->setAttribute('name', $t['name']);
$target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}'));
$target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}'));
if (isset($t['method'])) {
$target->appendChild($dom->createElementNS($ns, 'method', $t['method']));
}
if (isset($t['branch'])) {
$target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1)));
}
if (isset($t['src_dir'])) {
$target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1)));
}
$deploy->appendChild($target);
}
$root->appendChild($deploy);
}
if (!empty($enrichment['scripts'])) {
$scriptsEl = $dom->createElementNS($ns, 'scripts');
foreach ($enrichment['scripts'] as $s) {
$script = $dom->createElementNS($ns, 'script');
$script->setAttribute('name', $s['name']);
if (isset($s['phase'])) {
$script->setAttribute('phase', $s['phase']);
}
$script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1)));
if (isset($s['desc'])) {
$script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1)));
}
if (isset($s['runner'])) {
$script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1)));
}
$scriptsEl->appendChild($script);
}
$root->appendChild($scriptsEl);
}
return $dom->saveXML();
}
/** @return array{int, string} */
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd);
if (!is_resource($proc)) {
return [1, "proc_open failed"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/** @return array{int, string} */
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new EnrichManifestXmlCli();
exit($app->execute());
-484
View File
@@ -1,484 +0,0 @@
#!/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());
@@ -1,12 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Example configuration file for file-distributor.ps1 v02.00.00",
"SourceFile": "C:\\path\\to\\your\\source\\file.txt",
"RootDirectory": "C:\\path\\to\\root\\directory",
"Depth": 1,
"DryRun": true,
"Overwrite": false,
"ConfirmEach": false,
"IncludeHidden": true,
"LogDirectory": "C:\\path\\to\\logs"
}
-32
View File
@@ -1,32 +0,0 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: MokoPlatform.Index
INGROUP: MokoPlatform.Automation
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
PATH: /automation/index.md
BRIEF: Automation directory index
-->
# Docs Index: /api/automation
## Purpose
This index provides navigation to documentation within this folder.
## Documents
- [README-file-distributor](./README-file-distributor.md)
- [README](./README.md)
## Metadata
- **Document Type:** index
- **Auto-generated:** This file is automatically generated by rebuild_indexes.py
## Revision History
| Date | Author | Change | Notes |
| ---------- | ------------------ | ----------------- | ------------------------------------------ |
| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation |
-300
View File
@@ -1,300 +0,0 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/migrate_to_gitea.php
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
*
* USAGE
* php automation/migrate_to_gitea.php --dry-run
* php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods
* php automation/migrate_to_gitea.php --exclude mokocli --skip-archived
* php automation/migrate_to_gitea.php --resume
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use MokoCli\CheckpointManager;
use MokoCli\CliFramework;
use MokoCli\Config;
use MokoCli\PlatformAdapterFactory;
use MokoCli\GitHubAdapter;
use MokoCli\MokoGiteaAdapter;
/**
* Gitea Migration Script
*
* Migrates repositories from GitHub to a self-hosted Gitea instance.
* Uses Gitea's built-in migration endpoint for git history, tags, releases,
* issues, and labels. Post-migration applies branch protection, topics,
* and workflow conversion.
*/
class MigrateToGitea extends CliFramework
{
private ?GitHubAdapter $github = null;
private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null;
protected function configure(): void
{
$this->setDescription('Migrate repositories from GitHub to Gitea');
$this->addArgument('--dry-run', 'Show what would be migrated without making changes', false);
$this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', '');
$this->addArgument('--exclude', 'Repositories to exclude (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repositories', false);
$this->addArgument('--resume', 'Resume from last checkpoint', false);
$this->addArgument('--github-token', 'GitHub token override', '');
$this->addArgument('--gitea-token', 'Gitea token override', '');
}
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos')));
$excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude')));
$skipArchived = (bool) $this->getArgument('--skip-archived');
$resume = (bool) $this->getArgument('--resume');
$config = Config::load();
// Override tokens if provided
$ghToken = (string) $this->getArgument('--github-token');
$giteaToken = (string) $this->getArgument('--gitea-token');
if ($ghToken !== '') {
$config->set('github.token', $ghToken);
}
if ($giteaToken !== '') {
$config->set('gitea.token', $giteaToken);
}
// Create both adapters
try {
$adapters = PlatformAdapterFactory::createBoth($config);
$this->github = $adapters['github'];
$this->gitea = $adapters['gitea'];
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$this->checkpoints = new CheckpointManager('.checkpoints/migration');
$org = $config->getString('github.organization', 'MokoConsulting');
$giteaOrg = $config->getString('gitea.organization', 'MokoConsulting');
echo "=== Gitea Migration Tool ===\n";
echo "Source: GitHub ({$org})\n";
echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n";
echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n";
// ── Phase 1: Discovery ──────────────────────────────────────────
$this->section('Phase 1: Discovery');
$ghRepos = $this->github->listOrgRepos($org, $skipArchived);
echo "Found " . count($ghRepos) . " repositories on GitHub\n";
// Filter repos
if (!empty($specificRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true));
}
if (!empty($excludeRepos)) {
$ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true));
}
// Check which already exist on Gitea
$giteaRepos = [];
try {
$existing = $this->gitea->listOrgRepos($giteaOrg);
foreach ($existing as $r) {
$giteaRepos[$r['name']] = true;
}
} catch (\Exception $e) {
echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n";
}
$toMigrate = [];
$toSkip = [];
foreach ($ghRepos as $repo) {
$name = $repo['name'];
if (isset($giteaRepos[$name])) {
$toSkip[] = $name;
} else {
$toMigrate[] = $repo;
}
}
echo "\nMigration plan:\n";
echo " Migrate: " . count($toMigrate) . " repositories\n";
echo " Skip: " . count($toSkip) . " (already on Gitea)\n";
if (!empty($toSkip)) {
echo " Skipped: " . implode(', ', $toSkip) . "\n";
}
echo "\n";
if (empty($toMigrate)) {
echo "Nothing to migrate.\n";
return 0;
}
if ($dryRun) {
echo "Repositories to migrate:\n";
foreach ($toMigrate as $repo) {
$vis = $repo['private'] ? 'private' : 'public';
echo " - {$repo['name']} ({$vis})\n";
}
echo "\nDry run complete. Use without --dry-run to execute.\n";
return 0;
}
// ── Phase 2: Migrate ────────────────────────────────────────────
$this->section('Phase 2: Migration');
$ghToken = $config->getString('github.token');
$results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip];
// Resume support
$checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null;
$startFrom = $checkpoint['last_completed'] ?? '';
$skipUntil = !empty($startFrom);
foreach ($toMigrate as $index => $repo) {
$name = $repo['name'];
if ($skipUntil) {
if ($name === $startFrom) {
$skipUntil = false;
}
echo " Skipping {$name} (already migrated)\n";
continue;
}
echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n";
try {
// Shallow migration — copy current branch state only, no past
// commit history. This gives every repo a clean start on Gitea.
$this->gitea->migrateRepository([
'clone_addr' => "https://github.com/{$org}/{$name}.git",
'repo_name' => $name,
'repo_owner' => $giteaOrg,
'service' => 'github',
'auth_token' => $ghToken,
'mirror' => false,
'private' => $repo['private'],
'issues' => false,
'labels' => true,
'milestones' => false,
'releases' => false,
'pull_requests' => false,
'wiki' => false,
]);
echo " Migrated successfully\n";
$results['migrated'][] = $name;
// Save checkpoint after each successful migration
$this->checkpoints->saveCheckpoint('gitea_migration', [
'last_completed' => $name,
'migrated' => $results['migrated'],
'failed' => $results['failed'],
]);
} catch (\Exception $e) {
echo " FAILED: " . $e->getMessage() . "\n";
$results['failed'][] = ['name' => $name, 'error' => $e->getMessage()];
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 3: Post-migration ─────────────────────────────────────
$this->section('Phase 3: Post-migration');
foreach ($results['migrated'] as $name) {
echo " Post-processing {$name}...\n";
try {
// Apply topics from GitHub
$ghTopics = $this->github->getRepoTopics($org, $name);
if (!empty($ghTopics)) {
$this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics);
echo " Topics applied\n";
}
// Apply branch protection
$this->gitea->setBranchProtection($giteaOrg, $name, 'main', [
'required_reviews' => 1,
'dismiss_stale' => true,
'block_on_rejected' => true,
]);
echo " Branch protection applied\n";
} catch (\Exception $e) {
echo " Warning: post-processing issue: " . $e->getMessage() . "\n";
$this->gitea->getApiClient()->resetCircuitBreaker();
}
}
// ── Phase 4: Verification ───────────────────────────────────────
$this->section('Phase 4: Verification');
$report = "## Migration Report\n\n";
$report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n";
$report .= "**Source:** GitHub ({$org})\n";
$report .= "**Destination:** Gitea ({$giteaOrg})\n\n";
$report .= "### Results\n\n";
$report .= "| Status | Count |\n|--------|-------|\n";
$report .= "| Migrated | " . count($results['migrated']) . " |\n";
$report .= "| Failed | " . count($results['failed']) . " |\n";
$report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n";
if (!empty($results['migrated'])) {
$report .= "### Migrated Repositories\n\n";
foreach ($results['migrated'] as $name) {
$report .= "- {$name}\n";
}
$report .= "\n";
}
if (!empty($results['failed'])) {
$report .= "### Failed Repositories\n\n";
foreach ($results['failed'] as $fail) {
$report .= "- **{$fail['name']}**: {$fail['error']}\n";
}
$report .= "\n";
}
echo $report;
// Create summary issue on Gitea
try {
$this->gitea->createIssue(
$giteaOrg,
'mokocli',
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
$report,
['labels' => ['automation', 'type: chore']]
);
echo "Migration report issue created on Gitea.\n";
} catch (\Exception $e) {
echo "Could not create report issue: " . $e->getMessage() . "\n";
}
echo "\nMigration complete: " . count($results['migrated']) . " migrated, "
. count($results['failed']) . " failed, "
. count($results['skipped']) . " skipped\n";
return count($results['failed']) > 0 ? 1 : 0;
}
}
$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea');
exit($script->execute());
-683
View File
@@ -1,683 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_files.php
* BRIEF: Push one or more specific files to one or more remote repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{
ApiClient,
AuditLogger,
CliFramework,
Config,
GitPlatformAdapter,
MetricsCollector,
PlatformAdapterFactory,
ProjectTypeDetector
};
/**
* Targeted File Push Tool
*
* Pushes one or more specific files from mokocli templates to one or
* more remote repositories — without running a full sync.
*
* Files are specified by their destination path as they appear in the target
* repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up
* the matching source template from the appropriate platform definition.
*
* Files may also be given as "source:destination" pairs to bypass definition
* lookup and push any arbitrary local file.
*
* Usage:
* php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM
* php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent
* php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct
*/
class PushFiles extends CliFramework
{
public const DEFAULT_ORG = 'MokoConsulting';
public const VERSION = '09.23.00';
private ApiClient $api;
private GitPlatformAdapter $adapter;
private AuditLogger $logger;
private ProjectTypeDetector $typeDetector;
/**
* Setup command-line arguments
*/
protected function configure(): void
{
$this->setDescription('Push files to remote repositories');
$this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG);
$this->addArgument('--repos', 'Target repos (comma-separated)', '');
$this->addArgument('--files', 'Files to push (comma-separated)', '');
$this->addArgument('--message', 'Custom commit message', '');
$this->addArgument('--branch', 'Target branch for direct pushes', '');
$this->addArgument('--direct', 'Push directly instead of PR', false);
$this->addArgument('--yes', 'Auto-confirm without prompting', false);
$this->addArgument('--no-issue', 'Skip creating tracking issue', false);
}
/**
* Main execution
*/
protected function run(): int
{
$this->log('📦 mokocli File Push v' . self::VERSION, 'INFO');
if (!$this->initializeComponents()) {
return 1;
}
$org = $this->getArgument('--org', self::DEFAULT_ORG);
$reposArg = $this->getArgument('--repos', '');
$filesArg = $this->getArgument('--files', '');
$direct = $this->getArgument('--direct', false);
$autoYes = $this->getArgument('--yes', false);
// Validate required arguments
if (empty($reposArg)) {
$this->log('❌ --repos is required. Specify one or more repository names.', 'ERROR');
$this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR');
return 1;
}
if (empty($filesArg)) {
$this->log('❌ --files is required. Specify destination paths or source:destination pairs.', 'ERROR');
$this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR');
return 1;
}
$repos = $this->parseList($reposArg);
$files = $this->parseList($filesArg);
$this->log("Organisation: {$org}", 'INFO');
$this->log('Repositories: ' . implode(', ', $repos), 'INFO');
$this->log('Files: ' . implode(', ', $files), 'INFO');
$this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO');
// Resolve file mappings for each repo
$this->log("\n🔍 Resolving file mappings...", 'INFO');
$repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files);
if (empty($repoFileMaps)) {
$this->log('❌ No files could be resolved. Check file paths and platform definitions.', 'ERROR');
return 1;
}
// Confirm before proceeding
if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) {
$this->log('❌ Cancelled.', 'INFO');
return 0;
}
// Execute pushes
$results = $this->executePushes($org, $repoFileMaps, $direct);
$this->displayResults($results);
if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) {
$this->createFailureIssue($org, $results);
}
return $results['failed'] > 0 ? 1 : 0;
}
/**
* Initialize enterprise components
*/
private function initializeComponents(): bool
{
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
$this->logger = new AuditLogger('push_files');
$this->typeDetector = new ProjectTypeDetector($this->logger);
$platform = $this->adapter->getPlatformName();
$this->log("✓ Components initialized for platform: {$platform}", 'INFO');
return true;
} catch (\Exception $e) {
$this->log('❌ Failed to initialize: ' . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Parse a comma- or space-separated list into a clean array
*/
private function parseList(string $input): array
{
return array_values(array_filter(
array_map('trim', preg_split('/[\s,]+/', $input)),
fn($v) => $v !== ''
));
}
/**
* Build per-repo file maps: repo → [ [source, destination], … ]
*
* Each entry in $files is either:
* - "destination/path" → looked up in the platform definition
* - "source/path:destination/path" → used as-is (raw mode)
*
* @param string[] $repos
* @param string[] $files
* @return array<string, list<array{source: string, destination: string}>>
*/
private function buildRepoFileMaps(string $org, array $repos, array $files): array
{
$repoRoot = dirname(__DIR__, 2);
$maps = [];
foreach ($repos as $repo) {
// Detect the repo's platform so we load the right definition
$platform = $this->detectRepoPlatform($org, $repo);
$this->log(" {$repo}: platform = {$platform}", 'INFO');
$resolved = [];
foreach ($files as $fileSpec) {
if (str_contains($fileSpec, ':')) {
// Raw source:destination pair
[$src, $dest] = explode(':', $fileSpec, 2);
} else {
// Same path as source and destination
$src = $fileSpec;
$dest = $fileSpec;
}
$dest = ltrim($dest, '/');
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
if (!file_exists($srcAbs)) {
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
continue;
}
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
$this->log("{$dest}", 'INFO');
}
if (!empty($resolved)) {
$maps[$repo] = $resolved;
}
}
return $maps;
}
/**
* Detect platform for a repo via manifest or live detection.
*/
private function detectRepoPlatform(string $org, string $repo): string
{
// Read platform from repo's .mokogitea/manifest.xml via API
try {
$fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
if (!empty($manifestData)) {
$xml = @simplexml_load_string($manifestData);
if ($xml !== false) {
$platform = (string)($xml->governance->platform ?? '');
if (!empty($platform)) {
return $platform;
}
}
}
} catch (\Exception $e) {
// Fall through to local detection
}
// Fall back to live detection
try {
$result = $this->typeDetector->detect('.');
return $result['type'] ?? 'default';
} catch (\Exception $e) {
$this->log(" ⚠️ Could not detect platform for {$repo}, using 'default'", 'WARN');
return 'default';
}
}
/**
* Prompt for confirmation before pushing
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
*/
private function confirmPush(array $repoFileMaps, bool $direct): bool
{
if ($this->quiet) {
return true;
}
$totalFiles = array_sum(array_map('count', $repoFileMaps));
$totalRepos = count($repoFileMaps);
$mode = $direct ? 'direct commit' : 'PR';
echo "\n";
foreach ($repoFileMaps as $repo => $entries) {
echo " {$repo}:\n";
foreach ($entries as $entry) {
echo "{$entry['destination']}\n";
}
}
echo "\n";
echo "⚠️ About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n";
echo "Continue? [y/N]: ";
$handle = fopen('php://stdin', 'r');
$line = fgets($handle);
if ($handle) {
fclose($handle);
}
return is_string($line) && strtolower(trim($line)) === 'y';
}
/**
* Execute all file pushes
*
* @param array<string, list<array{source: string, destination: string}>> $repoFileMaps
* @return array{total: int, success: int, failed: int, repos: array<string, string>}
*/
private function executePushes(string $org, array $repoFileMaps, bool $direct): array
{
$results = [
'total' => count($repoFileMaps),
'success' => 0,
'failed' => 0,
'repos' => [],
];
$customMessage = $this->getArgument('--message', '');
$targetBranch = $this->getArgument('--branch', '');
foreach ($repoFileMaps as $repo => $entries) {
$this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO');
try {
// Resolve the default branch
$repoData = $this->adapter->getRepo($org, $repo);
$defaultBranch = $repoData['default_branch'] ?? 'main';
$branch = $direct
? ($targetBranch ?: $defaultBranch)
: $this->createSyncBranch($org, $repo, $defaultBranch);
$pushed = 0;
foreach ($entries as $entry) {
if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) {
$pushed++;
$this->log("{$entry['destination']}", 'INFO');
} else {
$this->log("{$entry['destination']}", 'ERROR');
}
}
if ($pushed === 0) {
$results['failed']++;
$results['repos'][$repo] = 'failed';
continue;
}
$prNumber = null;
if (!$direct) {
$prTitle = "chore: push " . count($entries) . " file(s) from mokocli";
$prBody = $this->buildPRBody($entries);
$pr = $this->adapter->createPullRequest(
$org,
$repo,
$prTitle,
$branch,
$defaultBranch,
$prBody,
['assignees' => ['jmiller']]
);
$prNumber = $pr['number'] ?? null;
$this->log(" 📋 PR #{$prNumber} created", 'INFO');
$results['repos'][$repo] = "pr#{$prNumber}";
} else {
$results['repos'][$repo] = 'pushed';
}
if (!isset($this->options['no-issue']) && !$this->dryRun) {
$this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null);
}
$results['success']++;
} catch (\Exception $e) {
$this->log("{$repo}: " . $e->getMessage(), 'ERROR');
$results['failed']++;
$results['repos'][$repo] = 'failed';
}
}
return $results;
}
/**
* Create a uniquely-named sync branch off the default branch
*/
private function createSyncBranch(string $org, string $repo, string $base): string
{
$branchName = 'moko/push-files-' . date('Ymd-His');
// Resolve the base branch to a commit SHA using the adapter
$sha = $this->adapter->resolveRef($org, $repo, $base);
if (empty($sha)) {
throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}");
}
$this->api->post("/repos/{$org}/{$repo}/git/refs", [
'ref' => "refs/heads/{$branchName}",
'sha' => $sha,
]);
$this->log(" 🌿 Branch created: {$branchName}", 'INFO');
return $branchName;
}
/**
* Push a single file to a repository branch via the Contents API
*
* @return bool True on success
*/
private function pushSingleFile(
string $org,
string $repo,
string $sourcePath,
string $destPath,
string $branch,
string $customMessage
): bool {
$content = file_get_contents($sourcePath);
if ($content === false) {
$this->log(" ⚠️ Cannot read source: {$sourcePath}", 'WARN');
return false;
}
$message = !empty($customMessage)
? $customMessage
: "chore: update {$destPath} from mokocli";
// Fetch existing file SHA (needed for updates)
$existingSha = null;
try {
$existing = $this->adapter->getFileContents($org, $repo, $destPath, $branch);
$existingSha = $existing['sha'] ?? null;
} catch (\Exception $e) {
// File does not exist — create it (no sha needed)
$this->adapter->getApiClient()->resetCircuitBreaker();
}
try {
$this->adapter->createOrUpdateFile(
$org,
$repo,
$destPath,
$content,
$message,
$existingSha,
$branch
);
return true;
} catch (\Exception $e) {
$this->log(" ✗ API error pushing {$destPath}: " . $e->getMessage(), 'ERROR');
return false;
}
}
/**
* Create a tracking issue in the target repository after a successful push.
*
* @param list<array{source: string, destination: string}> $entries
*/
private function createTargetRepoIssue(
string $org,
string $repo,
array $entries,
?int $prNumber,
?string $directBranch
): void {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$version = self::VERSION;
$source = $this->adapter->getRepoWebUrl($org, 'mokocli');
$title = "chore: mokocli file push tracking";
$deliveryLine = $prNumber !== null
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
: "| **Delivery** | Direct commit to `{$directBranch}` |";
$fileRows = implode("\n", array_map(
fn($e) => "- `{$e['destination']}`",
$entries
));
$body = <<<MD
## mokocli File Push
One or more files were pushed to this repository from mokocli.
| Field | Value |
|-------|-------|
| **Pushed** | {$now} |
| **Standards version** | `{$version}` |
{$deliveryLine}
| **Source** | [{$source}]({$source}) |
### Files pushed
{$fileRows}
---
*Generated automatically by [mokocli]({$source}) `push_files.php`*
MD;
$body = preg_replace('/^ /m', '', $body);
$labels = ['standards-update', 'mokocli', 'type: chore', 'automation'];
try {
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => 'standards-update',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch);
try {
$this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) {
/* non-fatal */
}
$this->log(" 📋 Tracking issue #{$num} updated in {$repo}", 'INFO');
} else {
$issue = $this->api->post("/repos/{$org}/{$repo}/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? null;
$this->log(" 📋 Tracking issue #{$num} created in {$repo}", 'INFO');
}
// Cross-link: patch the sync PR body to reference the tracking issue
// so GitHub shows it in the PR's Development sidebar.
if ($prNumber !== null && is_int($num)) {
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}");
$currentBody = $pr['body'] ?? '';
$ref = "Linked to #{$num}";
if (!str_contains($currentBody, $ref)) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [
'body' => $ref . "\n\n" . $currentBody,
]);
}
} catch (\Exception $le) {
/* non-fatal */
}
}
} catch (\Exception $e) {
$this->log(" ⚠️ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN');
}
}
/**
* Create or update a failure issue in mokocli when repos fail to receive files.
* Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate.
*/
private function createFailureIssue(string $org, array $results): void
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$failed = $results['failed'];
$version = self::VERSION;
$failedRepos = array_keys(array_filter(
$results['repos'] ?? [],
fn($s) => $s === 'failed'
));
$repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos));
$fileArgs = $this->getArgument('--files', '');
$title = "fix: push_files failed for {$failed} repo(s) — action required";
$body = <<<MD
## File Push Failure
`push_files.php` v{$version} encountered failures pushing files on {$now}.
### Failed repositories
{$repoList}
### Files that were being pushed
```
{$fileArgs}
```
### Next steps
1. Check the output above for the specific error per repo.
2. Fix the underlying issue (API token, branch permissions, file path, etc.).
3. Re-run: `php automation/push_files.php --org={$org} --repos=<repo> --files=<files> --yes`
4. Close this issue once resolved.
---
*Auto-created by `push_files.php` — close once resolved.*
MD;
$body = preg_replace('/^ /m', '', $body);
try {
$existing = $this->api->get("/repos/{$org}/mokocli/issues", [
'labels' => 'push-failure',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
$existing = array_values($existing);
if (!empty($existing) && isset($existing[0]['number'])) {
$num = $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch);
$this->log("🚨 Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN');
} else {
$issue = $this->api->post("/repos/{$org}/mokocli/issues", [
'title' => $title,
'body' => $body,
'labels' => ['push-failure'],
'assignees' => ['jmiller'],
]);
$num = $issue['number'] ?? '?';
$this->log("🚨 Failure issue created: {$org}/mokocli#{$num}", 'WARN');
}
} catch (\Exception $e) {
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
}
}
/**
* Build a markdown PR body listing every pushed file
*
* @param list<array{source: string, destination: string}> $entries
*/
private function buildPRBody(array $entries): string
{
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$lines = ["## mokocli File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
foreach ($entries as $entry) {
$lines[] = "- `{$entry['destination']}`";
}
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'mokocli');
$lines[] = "\n---\n*Generated by [mokocli]({$sourceUrl}) `push_files.php`*";
return implode("\n", $lines);
}
/**
* Display final results
*
* @param array{total: int, success: int, failed: int, repos: array<string, string>} $results
*/
private function displayResults(array $results): void
{
$this->log("\n" . str_repeat('=', 60), 'INFO');
$this->log('📊 Push Complete', 'INFO');
$this->log(str_repeat('=', 60), 'INFO');
$this->log(sprintf('Total: %d repos', $results['total']), 'INFO');
$this->log(sprintf('Success: %d', $results['success']), 'INFO');
$this->log(sprintf('Failed: %d', $results['failed']), 'INFO');
if ($this->verbose) {
$this->log("\n📋 Details:", 'INFO');
foreach ($results['repos'] as $repo => $outcome) {
$icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? '✓' : '✗';
$this->log(" {$icon} {$repo}: {$outcome}", 'INFO');
}
}
$this->log(str_repeat('=', 60), 'INFO');
}
}
// Execute if run directly
if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
$app = new PushFiles();
exit($app->execute());
}
-345
View File
@@ -1,345 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_manifest_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class PushManifestXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifest.xml to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update manifest.xml';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushManifestXmlCli();
exit($app->execute());
-345
View File
@@ -1,345 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/push_mokostandards_xml.php
* BRIEF: Push XML manifests to all governed repositories
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\CliFramework;
use MokoCli\ManifestParser;
class PushMokostandardsXmlCli extends CliFramework
{
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
protected function configure(): void
{
$this->setDescription('Push XML manifests to all governed repositories');
$this->addArgument('--repo', 'Filter to a single repo name', '');
$this->addArgument('--skip', 'Comma-separated list of repos to skip', '');
$this->addArgument('--force', 'Force overwrite even if already XML', false);
}
protected function run(): int
{
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
$force = $this->getArgument('--force');
$repoFilter = $this->getArgument('--repo') ?: null;
$skipStr = $this->getArgument('--skip');
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
$parser = new ManifestParser();
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
echo "=== mokocli XML Manifest Push ===\n";
echo "Org: {$giteaOrg}\n";
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
if ($repoFilter) {
echo "Filter: {$repoFilter}\n";
}
echo "\n";
if (empty($token)) {
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
return 1;
}
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
echo "Found " . count($repos) . " repositories\n\n";
$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0];
foreach ($repos as $repo) {
$name = $repo['name'];
if ($repoFilter && $name !== $repoFilter) {
continue;
}
if (in_array($name, $skipRepos, true)) {
echo " SKIP {$name} (excluded)\n";
$stats['skipped']++;
continue;
}
if ($repo['archived'] ?? false) {
echo " SKIP {$name} (archived)\n";
$stats['skipped']++;
continue;
}
$platform = $this->detectPlatform($repo);
$defaultBranch = $repo['default_branch'] ?? 'main';
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
echo " {$name} [{$platform}] ... ";
// Generate XML manifest
$xmlContent = $parser->generate([
'name' => $name,
'org' => $giteaOrg,
'platform' => $platform,
'standards_version' => '04.07.00',
'description' => $repo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repo['topics'] ?? [],
'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform),
'package_type' => ManifestParser::platformPackageType($platform),
'last_synced' => date('c'),
]);
if ($this->dryRun) {
echo "WOULD WRITE ({$platform})\n";
$stats['created']++;
continue;
}
// Clone shallow via HTTPS (token-authed)
$workDir = "{$tmpBase}/{$name}";
@mkdir($workDir, 0755, true);
[$ret, $out] = $this->safeExec(
'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' '
. escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir)
);
if ($ret !== 0) {
echo "FAIL (clone)\n";
fprintf(STDERR, " %s\n", $out);
$stats['failed']++;
continue;
}
// Check if already XML and up-to-date
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokocli');
if ($existingIsXml && !$force) {
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
if ($existingPlatform === $platform) {
echo "SKIP (already XML)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
}
// Write manifest
@mkdir("{$workDir}/.gitea", 0755, true);
file_put_contents($manifestPath, $xmlContent);
// Delete legacy files if present
$legacyDeleted = [];
foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) {
$legacyPath = "{$workDir}/{$legacy}";
if (file_exists($legacyPath)) {
unlink($legacyPath);
$legacyDeleted[] = $legacy;
}
}
// Commit
$isNew = !$existingIsXml;
$commitMsg = $isNew
? 'chore: add XML manifest.xml'
: 'chore: update .mokostandards to XML format';
if (!empty($legacyDeleted)) {
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
}
$this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
foreach ($legacyDeleted as $lf) {
$this->gitCmd($workDir, 'add', $lf);
}
[$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg);
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
echo "SKIP (no changes)\n";
$stats['skipped']++;
$this->rmTree($workDir);
continue;
}
if ($commitRet !== 0) {
echo "FAIL (commit)\n";
fprintf(STDERR, " %s\n", $commitOut);
$stats['failed']++;
$this->rmTree($workDir);
continue;
}
[$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch);
if ($pushRet !== 0) {
echo "FAIL (push)\n";
fprintf(STDERR, " %s\n", $pushOut);
$stats['failed']++;
} else {
$action = $isNew ? 'CREATED' : 'UPDATED';
echo "{$action}\n";
$stats[$isNew ? 'created' : 'updated']++;
}
// Cleanup
$this->rmTree($workDir);
}
// Cleanup tmp base
@rmdir($tmpBase);
echo "\n=== Summary ===\n";
echo "Created: {$stats['created']}\n";
echo "Updated: {$stats['updated']}\n";
echo "Skipped: {$stats['skipped']}\n";
echo "Failed: {$stats['failed']}\n";
return 0;
}
private function detectPlatform(array $repo): string
{
$name = $repo['name'] ?? '';
$nameLower = strtolower($name);
$description = strtolower($repo['description'] ?? '');
$topics = $repo['topics'] ?? [];
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
return 'crm-platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'crm-platform';
}
if (in_array('joomla-template', $topics)) {
return 'joomla-template';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'waas-component';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'crm-module';
}
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla-template';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'waas-component';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'crm-module';
}
if (str_contains($description, 'joomla template')) {
return 'joomla-template';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'waas-component';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'crm-module';
}
if (str_contains($nameLower, 'standard')) {
return 'standards-repository';
}
return 'default-repository';
}
/**
* @return array{int, string}
*/
private function safeExec(string $command, string $cwd = '.'): array
{
$proc = proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd
);
if (!is_resource($proc)) {
return [1, "proc_open failed for: {$command}"];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
return [$code, trim($stdout . "\n" . $stderr)];
}
private function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
@rmdir($file->getPathname());
} else {
@chmod($file->getPathname(), 0777);
@unlink($file->getPathname());
}
}
@rmdir($dir);
}
/**
* @return array{int, string}
*/
private function gitCmd(string $workDir, string ...$args): array
{
$cmd = 'git';
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
return $this->safeExec($cmd, $workDir);
}
private function fetchRepos(string $url, string $org, string $token): array
{
$repos = [];
$page = 1;
do {
$ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
$this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}");
break;
}
$batch = json_decode($body, true);
if (empty($batch)) {
break;
}
$repos = array_merge($repos, $batch);
$page++;
} while (count($batch) >= 50);
return $repos;
}
}
$app = new PushMokostandardsXmlCli();
exit($app->execute());
-517
View File
@@ -1,517 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Automation
* INGROUP: MokoPlatform.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /automation/repo_cleanup.php
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
/**
* Enterprise Repository Cleanup
*
* Comprehensive maintenance tool for governed repositories:
* 1. Delete stale sync branches (keeps current versioned branch)
* 2. Close superseded PRs on deleted branches
* 3. Close/lock resolved tracking issues where linked PR is merged
* 4. Delete retired workflow files from repos
* 5. Clean cancelled/stale workflow runs
* 6. Delete workflow run logs older than N days
* 7. Verify and provision standard labels
* 8. Version drift detection
*/
class RepoCleanup extends CliFramework
{
private const VERSION = '09.23.00';
private const SYNC_PREFIX = 'chore/sync-mokocli-';
private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00';
/** Workflow files that have been retired and should be deleted from governed repos. */
private const RETIRED_WORKFLOWS = [
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
'flush-actions-cache.yml', 'mokocli-script-runner.yml', 'unified-ci.yml',
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml',
'release.yml', 'sync-changelogs.yml', 'version_branch.yml',
'publish-to-mokodolibarr.yml', 'ci.yml',
'deploy-rs.yml',
];
private ApiClient $api;
private GitPlatformAdapter $adapter;
protected bool $dryRun = false;
private float $startTime;
protected function configure(): void
{
$this->setDescription('Enterprise repository cleanup');
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
$this->addArgument('--skip-archived', 'Skip archived repos', false);
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
$this->addArgument('--log-days', 'Days to keep logs', '30');
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
$this->addArgument('--check-labels', 'Verify labels exist', false);
$this->addArgument('--check-drift', 'Check version drift', false);
$this->addArgument('--all', 'Run all operations', false);
$this->addArgument('--yes', 'Auto-confirm', false);
$this->addArgument('--json', 'Output as JSON', false);
}
protected function run(): int
{
$this->startTime = microtime(true);
$org = $this->getArgument('--org', 'MokoConsulting');
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
$runAll = (bool) $this->getArgument('--all', false);
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
$this->api = $this->adapter->getApiClient();
} catch (\Exception $e) {
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
return 1;
}
$this->logMsg("🧹 mokocli Repository Cleanup v" . self::VERSION);
$this->logMsg("Organization: {$org}");
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
if ($this->dryRun) {
$this->logMsg("⚠️ DRY RUN — no changes will be made");
}
$this->logMsg('');
$repos = $this->fetchRepositories($org);
$this->logMsg("Found " . count($repos) . " repositories");
$this->logMsg('');
$results = [
'repos_processed' => 0,
'repos_cleaned' => 0,
'branches_deleted' => 0,
'prs_closed' => 0,
'issues_closed' => 0,
'issues_locked' => 0,
'workflows_deleted' => 0,
'runs_deleted' => 0,
'logs_deleted' => 0,
'labels_missing' => 0,
'version_drift' => 0,
'retired_files' => 0,
'errors' => 0,
];
foreach ($repos as $i => $repo) {
$name = $repo['name'];
$num = $i + 1;
$total = count($repos);
$this->logMsg("[{$num}/{$total}] {$name}");
$results['repos_processed']++;
try {
$this->api->resetCircuitBreaker();
$cleaned = false;
// Always: delete old sync branches + close their PRs
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
// Optional: close resolved issues
if ($runAll || $this->getArgument('--close-issues', false)) {
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
}
// Optional: lock old closed issues
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
}
// Optional: delete retired workflow files
if ($runAll || $this->getArgument('--delete-retired', false)) {
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
}
// Optional: clean workflow runs
if ($runAll || $this->getArgument('--clean-workflows', false)) {
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
}
// Optional: clean old logs
if ($runAll || $this->getArgument('--clean-logs', false)) {
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
}
// Optional: check labels
if ($runAll || $this->getArgument('--check-labels', false)) {
$this->checkLabels($org, $name, $results);
}
// Optional: check version drift
if ($runAll || $this->getArgument('--check-drift', false)) {
$this->checkVersionDrift($org, $name, $results);
}
if ($cleaned) {
$results['repos_cleaned']++;
}
} catch (\Exception $e) {
$this->errorMsg("{$name}: " . $e->getMessage());
$results['errors']++;
}
}
$duration = round(microtime(true) - $this->startTime, 1);
$this->logMsg('');
$this->logMsg('============================================================');
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
$this->logMsg('============================================================');
$this->logMsg("Repos processed: {$results['repos_processed']}");
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
$this->logMsg("PRs closed: {$results['prs_closed']}");
$this->logMsg("Issues closed: {$results['issues_closed']}");
$this->logMsg("Issues locked: {$results['issues_locked']}");
$this->logMsg("Retired files: {$results['retired_files']}");
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
$this->logMsg("Labels missing: {$results['labels_missing']}");
$this->logMsg("Version drift: {$results['version_drift']}");
$this->logMsg("Errors: {$results['errors']}");
$this->logMsg('============================================================');
if ($this->getArgument('--json', false)) {
$results['duration_seconds'] = $duration;
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
}
return $results['errors'] > 0 ? 1 : 0;
}
// ─── Repository fetching ─────────────────────────────────────────────
private function fetchRepositories(string $org): array
{
$specificRepos = trim((string) $this->getArgument('--repos', ''));
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
if (!empty($specificRepos)) {
$names = preg_split('/[\s,]+/', $specificRepos);
return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names);
}
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['mokocli', '.github-private'], true));
}
// ─── Cleanup operations ──────────────────────────────────────────────
private function cleanBranches(string $org, string $repo, array &$results): bool
{
$changed = false;
try {
$branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]);
} catch (\Exception $e) {
return false;
}
foreach ($branches as $branch) {
$name = $branch['name'] ?? '';
if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) {
continue;
}
// Close open PRs on this branch
try {
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10,
]);
foreach ($prs as $pr) {
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
}
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
$results['prs_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
if (!$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
} catch (\Exception $e) {
continue;
}
}
$this->logMsg(" 🗑️ Deleted branch: {$name}");
$results['branches_deleted']++;
$changed = true;
}
return $changed;
}
private function closeResolvedIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['standards-update', 'standards-drift'] as $label) {
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'labels' => $label, 'state' => 'open', 'per_page' => 10,
]);
} catch (\Exception $e) {
continue;
}
foreach ($issues as $issue) {
$num = $issue['number'] ?? 0;
$body = $issue['body'] ?? '';
if (preg_match('/\[#(\d+)\]/', $body, $m)) {
$prNum = (int) $m[1];
try {
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}");
if (!empty($pr['merged_at'])) {
if (!$this->dryRun) {
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [
'state' => 'closed', 'state_reason' => 'completed',
]);
}
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
$results['issues_closed']++;
$changed = true;
}
} catch (\Exception $e) {
/* non-fatal */
}
}
}
}
return $changed;
}
private function lockOldIssues(string $org, string $repo, array &$results): bool
{
$changed = false;
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days'));
try {
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
]);
} catch (\Exception $e) {
return false;
}
foreach ($issues as $issue) {
$closedAt = $issue['closed_at'] ?? '';
$locked = $issue['locked'] ?? false;
$num = $issue['number'] ?? 0;
if ($locked || $closedAt > $cutoff || $num === 0) {
continue;
}
if (!$this->dryRun) {
try {
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
'lock_reason' => 'resolved',
]);
} catch (\Exception $e) {
continue;
}
}
$results['issues_locked']++;
$changed = true;
}
if ($results['issues_locked'] > 0) {
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
}
return $changed;
}
private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool
{
$changed = false;
$defaultBranch = 'main';
try {
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
} catch (\Exception $e) {
/* fallback to main */
}
// Check both workflow directories for retired workflows (supports dual-platform repos)
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
foreach (self::RETIRED_WORKFLOWS as $wf) {
foreach ($wfDirs as $wfDir) {
$path = "{$wfDir}/{$wf}";
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
$sha = $file['sha'] ?? '';
if (empty($sha)) {
continue;
}
if (!$this->dryRun) {
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
'message' => "chore: delete retired workflow {$wf}",
'sha' => $sha,
'branch' => $defaultBranch,
]);
}
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
$results['retired_files']++;
$changed = true;
} catch (\Exception $e) {
// File doesn't exist in this dir — skip
$this->api->resetCircuitBreaker();
}
}
}
return $changed;
}
private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool
{
$changed = false;
foreach (['cancelled', 'stale'] as $status) {
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'status' => $status, 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
$results['runs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
}
if ($results['runs_deleted'] > 0) {
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
}
return $changed;
}
private function cleanOldLogs(string $org, string $repo, array &$results): bool
{
$changed = false;
$days = (int) $this->getArgument('--log-days', '30');
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
try {
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
'created' => "<{$cutoff}", 'per_page' => 100,
]);
foreach (($runs['workflow_runs'] ?? []) as $run) {
$id = $run['id'] ?? 0;
if ($id > 0 && !$this->dryRun) {
try {
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
$results['logs_deleted']++;
$changed = true;
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
}
} catch (\Exception $e) {
/* non-fatal */
}
if ($results['logs_deleted'] > 0) {
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
}
return $changed;
}
private function checkLabels(string $org, string $repo, array &$results): void
{
try {
$this->api->get("/repos/{$org}/{$repo}/labels/mokocli");
} catch (\Exception $e) {
$this->logMsg(" ⚠️ Missing 'mokocli' label");
$results['labels_missing']++;
$this->api->resetCircuitBreaker();
}
}
private function checkVersionDrift(string $org, string $repo, array &$results): void
{
try {
$file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md");
$content = base64_decode($file['content'] ?? '');
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$version = $m[1];
// Check manifest.xml for the tracked mokocli version
try {
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml");
$mokoContent = base64_decode($mokoFile['content'] ?? '');
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
if ($vm[1] !== self::VERSION) {
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
$results['version_drift']++;
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
} catch (\Exception $e) {
$this->api->resetCircuitBreaker();
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
private function logMsg(string $message): void
{
if (!$this->quiet) {
echo $message . "\n";
}
}
private function errorMsg(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$app = new RepoCleanup();
exit($app->execute());
-678
View File
@@ -1,678 +0,0 @@
#!/usr/bin/env bash
# server-autoheal.sh - Auto-heal on restart + split backup management
#
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
# INGROUP: MokoPlatform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /automation/server-autoheal.sh
# BRIEF: Server auto-heal on unclean restart + split system/content backups
#
# Usage:
# server-autoheal.sh <command> [options]
#
# Commands:
# boot-check Run at boot — auto-heals if no safe point exists
# set-safepoint Mark current state as safe (call before planned shutdown)
# backup-system Run a system backup (configs, packages, services)
# backup-content Run a content backup (site files, databases, uploads)
# cleanup Prune expired backups per retention policy
# status Show safe point and backup status
#
# Scheduling (cron):
# @reboot server-autoheal.sh boot-check
# 0 3 * * * server-autoheal.sh backup-system (daily at 3am)
# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours)
# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup)
set -euo pipefail
# ──────────────────────────────────────────────
# Configuration — override via /etc/moko/autoheal.conf
# ──────────────────────────────────────────────
CONF_FILE="/etc/moko/autoheal.conf"
[[ -f "$CONF_FILE" ]] && source "$CONF_FILE"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}"
SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}"
LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}"
LOCK_DIR="${LOCK_DIR:-/var/run/moko}"
# System backup: configs, package lists, service state, cron
SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system"
SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups
# Content backup: web roots, databases, uploads
CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content"
CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups
# Paths to back up — override these in /etc/moko/autoheal.conf
SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}"
CONTENT_PATHS="${CONTENT_PATHS:-/var/www}"
DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
log() {
local level="$1"; shift
local ts
ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local msg="[$ts] [$level] $*"
echo "$msg" | tee -a "$LOG_FILE" >&2
}
ensure_dirs() {
mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \
"$LOCK_DIR" "$(dirname "$LOG_FILE")"
}
acquire_lock() {
local lockfile="${LOCK_DIR}/autoheal-${1}.lock"
if [[ -f "$lockfile" ]]; then
local pid
pid=$(<"$lockfile")
if kill -0 "$pid" 2>/dev/null; then
log WARN "Another $1 operation is running (PID $pid), skipping"
exit 0
fi
rm -f "$lockfile"
fi
echo $$ > "$lockfile"
trap "rm -f '$lockfile'" EXIT
}
timestamp() {
date -u '+%Y%m%d_%H%M%S'
}
# ──────────────────────────────────────────────
# Safe-point management
# ──────────────────────────────────────────────
cmd_set_safepoint() {
ensure_dirs
local ts
ts=$(timestamp)
cat > "$SAFEPOINT_FILE" <<EOF
timestamp=$ts
hostname=$(hostname)
kernel=$(uname -r)
uptime=$(uptime -s 2>/dev/null || echo "unknown")
set_by=${SUDO_USER:-$(whoami)}
EOF
log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}"
}
cmd_clear_safepoint() {
rm -f "$SAFEPOINT_FILE"
log INFO "Safe point cleared"
}
has_safepoint() {
[[ -f "$SAFEPOINT_FILE" ]]
}
# ──────────────────────────────────────────────
# System backup (daily)
# ──────────────────────────────────────────────
cmd_backup_system() {
ensure_dirs
acquire_lock "system-backup"
local ts
ts=$(timestamp)
local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz"
local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest"
log INFO "Starting system backup → $archive"
# Collect existing paths only
local existing_paths=()
for p in $SYSTEM_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -eq 0 ]]; then
log WARN "No system paths found to back up"
return 1
fi
# Archive configs and system files
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
# Capture package list and service state as manifest
{
echo "=== PACKAGES ==="
if command -v dpkg &>/dev/null; then
dpkg --get-selections
elif command -v rpm &>/dev/null; then
rpm -qa --qf '%{NAME}\t%{VERSION}\n'
fi
echo ""
echo "=== ENABLED SERVICES ==="
if command -v systemctl &>/dev/null; then
systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true
fi
echo ""
echo "=== CRONTABS ==="
for user_home in /var/spool/cron/crontabs/*; do
[[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home"
done 2>/dev/null || true
} > "$manifest"
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "System backup complete: $archive ($size)"
# Prune old system backups (keep $SYSTEM_BACKUP_RETAIN)
local count
count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l)
if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then
local to_remove=$((count - SYSTEM_BACKUP_RETAIN))
find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
| sort | head -n "$to_remove" | awk '{print $2}' \
| while read -r f; do
rm -f "$f" "${f%.tar.gz}.manifest"
log INFO "Pruned old system backup: $f"
done
fi
}
# ──────────────────────────────────────────────
# Content backup (every 2 hours)
# ──────────────────────────────────────────────
cmd_backup_content() {
ensure_dirs
acquire_lock "content-backup"
local ts
ts=$(timestamp)
local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz"
local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz"
log INFO "Starting content backup → $archive"
# Back up web content / uploads
local existing_paths=()
for p in $CONTENT_PATHS; do
[[ -e "$p" ]] && existing_paths+=("$p")
done
if [[ ${#existing_paths[@]} -gt 0 ]]; then
tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true
local size
size=$(du -sh "$archive" 2>/dev/null | cut -f1)
log INFO "Content files archived: $archive ($size)"
else
log WARN "No content paths found to back up"
fi
# Database dump
if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then
local dump_cmd="mysqldump"
command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump"
local databases=()
if [[ -n "$DB_NAMES" ]]; then
read -ra databases <<< "$DB_NAMES"
else
# Auto-detect: dump all databases except system ones
databases=($(${dump_cmd%dump} -N -e \
"SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \
2>/dev/null | tr '\n' ' ')) || true
fi
if [[ ${#databases[@]} -gt 0 ]]; then
$dump_cmd --single-transaction --routines --triggers \
--databases "${databases[@]}" 2>/dev/null \
| gzip > "$db_dump"
local db_size
db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1)
log INFO "Database dump complete: $db_dump ($db_size)"
else
log WARN "No databases found to dump"
fi
fi
}
# ──────────────────────────────────────────────
# Cleanup — prune content backups older than retention
# ──────────────────────────────────────────────
cmd_cleanup() {
ensure_dirs
local before_count after_count
# Content: keep only last 24 hours (1 day)
before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \
-mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true
after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l)
local removed=$((before_count - after_count))
[[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h"
# System: keep N most recent (handled in backup-system, but double-check here)
before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l)
local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest
if [[ "$before_count" -gt "$max_system_files" ]]; then
local excess=$((before_count - max_system_files))
find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \
| sort | head -n "$excess" | awk '{print $2}' \
| xargs -r rm -f
log INFO "Pruned excess system backups"
fi
log INFO "Cleanup complete"
}
# ──────────────────────────────────────────────
# Boot check — the auto-heal entry point
# ──────────────────────────────────────────────
cmd_boot_check() {
ensure_dirs
acquire_lock "boot-check"
log INFO "=== Boot check started ==="
log INFO "Hostname: $(hostname), Kernel: $(uname -r)"
if has_safepoint; then
log INFO "Safe point found — server was shut down cleanly"
log INFO "Clearing safe point for next cycle"
cmd_clear_safepoint
log INFO "=== Boot check passed (clean restart) ==="
return 0
fi
log WARN "NO safe point found — server restarted without clean shutdown"
log WARN "Initiating auto-heal sequence..."
auto_heal
local rc=$?
# Set safe point after successful heal
if [[ $rc -eq 0 ]]; then
cmd_set_safepoint
log INFO "=== Boot check complete (healed successfully) ==="
else
log ERROR "=== Boot check FAILED — manual intervention required ==="
fi
return $rc
}
# ──────────────────────────────────────────────
# Auto-heal strategy
#
# TODO: This is the core decision point. Implement the recovery
# steps that match your server's architecture. See guidance below.
#
# Trade-offs to consider:
# - Restore-from-backup: safest, but content may be up to 2h stale
# - Service-restart-only: faster, keeps current data, but won't fix
# corrupted configs or broken filesystem state
# - Hybrid: restart services first, verify health, only restore if
# health checks fail — best of both worlds but more complex
#
# The function receives no arguments. Use the latest system + content
# backups to restore if needed. Return 0 on success, 1 on failure.
# ──────────────────────────────────────────────
auto_heal() {
log INFO "Phase 1: Verify and repair filesystem"
# Check for common post-crash issues
repair_filesystem
log INFO "Phase 2: Restore system configuration if corrupted"
restore_system_if_needed
log INFO "Phase 3: Restart core services"
restart_services
log INFO "Phase 4: Verify health"
if ! verify_health; then
log WARN "Health check failed after service restart — restoring from backup"
restore_from_backup
restart_services
if ! verify_health; then
log ERROR "Health check still failing after restore — giving up"
return 1
fi
fi
log INFO "Auto-heal completed successfully"
return 0
}
# ──────────────────────────────────────────────
# Heal sub-steps
# ──────────────────────────────────────────────
repair_filesystem() {
# Fix common post-crash filesystem issues
# Clear stale PID/lock/socket files that prevent services from starting
local stale_files=(
/var/run/nginx.pid
/var/run/mysqld/mysqld.pid
/var/run/php-fpm.pid
/var/lib/mysql/*.pid
)
for f in "${stale_files[@]}"; do
for expanded in $f; do
if [[ -f "$expanded" ]]; then
local pid
pid=$(<"$expanded") 2>/dev/null || true
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
rm -f "$expanded"
log INFO "Removed stale PID file: $expanded"
fi
fi
done
done
# Fix permissions on critical dirs that may get mangled
[[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true
[[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true
# Repair tmp/cache dirs
for d in /tmp /var/tmp; do
[[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true
done
}
restore_system_if_needed() {
# Find latest system backup
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -z "$latest_system" ]]; then
log WARN "No system backup available to verify against"
return 0
fi
# Check if critical configs exist and are non-empty
local needs_restore=false
local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql")
for cfg in "${critical_configs[@]}"; do
if [[ -e "$cfg" ]]; then
# Config exists — check if it's a file and non-empty, or a directory
if [[ -f "$cfg" && ! -s "$cfg" ]]; then
log WARN "Critical config is empty: $cfg"
needs_restore=true
break
fi
fi
done
if $needs_restore; then
log WARN "Restoring system config from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || {
log ERROR "System restore failed from $latest_system"
return 1
}
log INFO "System config restored"
else
log INFO "System configs look intact — skipping restore"
fi
}
restart_services() {
if ! command -v systemctl &>/dev/null; then
log WARN "systemctl not available — skipping service restart"
return 0
fi
local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
log INFO "Restarting $svc..."
systemctl restart "$svc" 2>/dev/null && \
log INFO "$svc restarted OK" || \
log WARN "$svc restart failed"
fi
done
}
verify_health() {
local failures=0
# Check critical services are running
local services=("mysql" "mariadb" "nginx" "apache2")
for svc in "${services[@]}"; do
if systemctl is-enabled "$svc" &>/dev/null; then
if ! systemctl is-active "$svc" &>/dev/null; then
log WARN "Service not running: $svc"
((failures++))
fi
fi
done
# Check if web server responds
if command -v curl &>/dev/null; then
if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then
log WARN "Local web server not responding"
((failures++))
fi
fi
# Check if database accepts connections
if command -v mysqladmin &>/dev/null; then
if ! mysqladmin ping --silent 2>/dev/null; then
log WARN "Database not responding to ping"
((failures++))
fi
fi
[[ $failures -eq 0 ]]
}
restore_from_backup() {
log WARN "=== Full restore from backup ==="
# Restore system config
local latest_system
latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_system" ]]; then
log INFO "Restoring system from $latest_system"
tar -xzf "$latest_system" -C / 2>/dev/null || \
log ERROR "System restore failed"
fi
# Restore content
local latest_content
latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_content" ]]; then
log INFO "Restoring content from $latest_content"
tar -xzf "$latest_content" -C / 2>/dev/null || \
log ERROR "Content restore failed"
fi
# Restore database
local latest_db
latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1 | awk '{print $2}')
if [[ -n "$latest_db" ]]; then
log INFO "Restoring database from $latest_db"
local mysql_cmd="mysql"
command -v mariadb &>/dev/null && mysql_cmd="mariadb"
zcat "$latest_db" | $mysql_cmd 2>/dev/null || \
log ERROR "Database restore failed"
fi
}
# ──────────────────────────────────────────────
# Status
# ──────────────────────────────────────────────
cmd_status() {
echo "=== Moko Server Auto-Heal Status ==="
echo ""
# Safe point
if has_safepoint; then
echo "Safe point: SET"
cat "$SAFEPOINT_FILE" | sed 's/^/ /'
else
echo "Safe point: NOT SET (will auto-heal on next boot)"
fi
echo ""
# System backups
echo "System backups (${SYSTEM_BACKUP_DIR}):"
local sys_count
sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)"
local latest_sys
latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_sys" ]]; then
echo " Latest: $(echo "$latest_sys" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Content backups
echo "Content backups (${CONTENT_BACKUP_DIR}):"
local cnt_count
cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l)
echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)"
local latest_cnt
latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \
2>/dev/null | sort -r | head -1)
if [[ -n "$latest_cnt" ]]; then
echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')"
echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')"
else
echo " Latest: (none)"
fi
echo ""
# Disk usage
echo "Backup disk usage:"
du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /'
}
# ──────────────────────────────────────────────
# Install helper — sets up cron + systemd
# ──────────────────────────────────────────────
cmd_install() {
local script_path
script_path=$(readlink -f "$0")
echo "Installing Moko Auto-Heal..."
# Create config directory
mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR"
# Write example config if none exists
if [[ ! -f "$CONF_FILE" ]]; then
cat > "$CONF_FILE" <<'CONF'
# /etc/moko/autoheal.conf — Server auto-heal configuration
# Uncomment and modify as needed
# BACKUP_ROOT="/var/backups/moko"
# SAFEPOINT_FILE="/var/run/moko/safepoint"
# LOG_FILE="/var/log/moko/autoheal.log"
# System backup paths (space-separated)
# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system"
# Content backup paths (space-separated)
# CONTENT_PATHS="/var/www"
# Database names (space-separated, empty = auto-detect all)
# DB_NAMES=""
# Retention
# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep
# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep
CONF
echo " Created config: $CONF_FILE"
fi
# Install cron jobs
local cron_file="/etc/cron.d/moko-autoheal"
cat > "$cron_file" <<CRON
# Moko Server Auto-Heal — managed by server-autoheal.sh install
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Boot check — auto-heal if no safe point
@reboot root ${script_path} boot-check
# System backup — daily at 3:00 AM
0 3 * * * root ${script_path} backup-system
# Content backup — every 2 hours
0 */2 * * * root ${script_path} backup-content
# Cleanup expired backups — 30 min after each content backup
30 */2 * * * root ${script_path} cleanup
CRON
echo " Installed cron: $cron_file"
# Install shutdown hook to set safe point on clean shutdown
local shutdown_hook="/etc/systemd/system/moko-safepoint.service"
cat > "$shutdown_hook" <<UNIT
[Unit]
Description=Moko Safe Point — mark clean shutdown
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=${script_path} set-safepoint
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable moko-safepoint.service
echo " Installed systemd hook: $shutdown_hook"
echo ""
echo "Done! Edit $CONF_FILE to configure paths for your server."
echo "Run '${script_path} status' to verify."
}
# ──────────────────────────────────────────────
# Main dispatcher
# ──────────────────────────────────────────────
main() {
local cmd="${1:-help}"
case "$cmd" in
boot-check) cmd_boot_check ;;
set-safepoint) cmd_set_safepoint ;;
clear-safepoint) cmd_clear_safepoint ;;
backup-system) cmd_backup_system ;;
backup-content) cmd_backup_content ;;
cleanup) cmd_cleanup ;;
status) cmd_status ;;
install) cmd_install ;;
help|--help|-h)
sed -n '2,/^$/s/^# //p' "$0"
echo ""
echo "Commands: boot-check, set-safepoint, clear-safepoint,"
echo " backup-system, backup-content, cleanup, status, install"
;;
*)
echo "Unknown command: $cmd" >&2
echo "Run '$0 help' for usage" >&2
exit 1
;;
esac
}
main "$@"
-633
View File
@@ -1,633 +0,0 @@
#!/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.07
* 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 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/branch_rename.php * PATH: /cli/branch_rename.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old) * BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_push.php * PATH: /cli/bulk_workflow_push.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/bulk_workflow_trigger.php * PATH: /cli/bulk_workflow_trigger.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Trigger a workflow across multiple repos at once * BRIEF: Trigger a workflow across multiple repos at once
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_dashboard.php * PATH: /cli/client_dashboard.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Generate unified client dashboard HTML * BRIEF: Generate unified client dashboard HTML
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_inventory.php * PATH: /cli/client_inventory.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Discover and list all client-waas repos with their server configuration status * BRIEF: Discover and list all client-waas repos with their server configuration status
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/client_provision.php * PATH: /cli/client_provision.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Provision a new client environment end-to-end * BRIEF: Provision a new client environment end-to-end
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/grafana_dashboard.php * PATH: /cli/grafana_dashboard.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Manage Grafana dashboards via API * BRIEF: Manage Grafana dashboards via API
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_build.php * PATH: /cli/joomla_build.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported * BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows. * NOTE: Called by pre-release and auto-release workflows.
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/joomla_metadata_validate.php * PATH: /cli/joomla_metadata_validate.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML * BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_detect.php * PATH: /cli/manifest_detect.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Auto-detect manifest fields from source files and optionally push to API * BRIEF: Auto-detect manifest fields from source files and optionally push to API
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_integrity.php * PATH: /cli/manifest_integrity.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Cross-check manifest API fields against repo contents across the org * BRIEF: Cross-check manifest API fields against repo contents across the org
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_licensing.php * PATH: /cli/manifest_licensing.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests * BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/manifest_read.php * PATH: /cli/manifest_read.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest * BRIEF: Read repo metadata from Gitea manifest API, auto-detect the rest
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/platform_detect.php * PATH: /cli/platform_detect.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Auto-detect repository platform type and optionally update manifest * BRIEF: Auto-detect repository platform type and optionally update manifest
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_cascade.php * PATH: /cli/release_cascade.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Cascade release zip to all lower stability channels * BRIEF: Cascade release zip to all lower stability channels
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/release_publish.php * PATH: /cli/release_publish.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Publish a release and create copies for all lesser stability streams. * BRIEF: Publish a release and create copies for all lesser stability streams.
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/scaffold_client.php * PATH: /cli/scaffold_client.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/updates_xml_sync.php * PATH: /cli/updates_xml_sync.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Sync updates.xml to target branches via Gitea API * BRIEF: Sync updates.xml to target branches via Gitea API
* NOTE: Called by pre-release and auto-release workflows after updates.xml * NOTE: Called by pre-release and auto-release workflows after updates.xml
* is modified on the current branch. Pushes the file to other branches * is modified on the current branch. Pushes the file to other branches
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/version_auto_bump.php * PATH: /cli/version_auto_bump.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash * BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
*/ */
+1 -1
View File
@@ -370,7 +370,7 @@ class VersionBumpCli extends CliFramework
/** /**
* Scan git release tags for the highest version across all channels. * Scan git release tags for the highest version across all channels.
* *
* Checks release names like "MokoSuiteClient (VERSION: 09.37.07)" in * Checks release names like "MokoSuiteClient (VERSION: 09.38.00)" in
* git tags (stable, release-candidate, development, etc.) to find the * git tags (stable, release-candidate, development, etc.) to find the
* highest version that has been released on any channel. * highest version that has been released on any channel.
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/version_check.php * PATH: /cli/version_check.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Validate version consistency across README, manifests, and sub-packages * BRIEF: Validate version consistency across README, manifests, and sub-packages
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/wiki_sync.php * PATH: /cli/wiki_sync.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Sync select wiki pages from mokocli to all template repos * BRIEF: Sync select wiki pages from mokocli to all template repos
*/ */
+1 -1
View File
@@ -10,7 +10,7 @@
* INGROUP: mokocli * INGROUP: mokocli
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /cli/workflow_sync.php * PATH: /cli/workflow_sync.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform * BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/backup-before-deploy.php * PATH: /deploy/backup-before-deploy.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Snapshot Joomla directories before deployment for rollback capability * BRIEF: Snapshot Joomla directories before deployment for rollback capability
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/deploy-dolibarr.php * PATH: /deploy/deploy-dolibarr.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/health-check.php * PATH: /deploy/health-check.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly * BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/rollback-joomla.php * PATH: /deploy/rollback-joomla.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
*/ */
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /deploy/sync-joomla.php * PATH: /deploy/sync-joomla.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH * BRIEF: Sync Joomla site directories between two servers via rsync over SSH
*/ */
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: dolibarr-api-mcp.Documentation DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
VERSION: 09.37.07 VERSION: 09.38.00
PATH: ./CONTRIBUTING.md PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project BRIEF: Contribution guidelines for the project
--> -->
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 09.37.07 VERSION: 09.38.00
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: DEFGROUP:
INGROUP: Project.Documentation INGROUP: Project.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli-Template-Generic REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoCli-Template-Generic
VERSION: 09.37.07 VERSION: 09.38.00
PATH: ./CONTRIBUTING.md PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project BRIEF: Contribution guidelines for the project
--> -->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL] REPO: [REPOSITORY_URL]
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 09.37.07 VERSION: 09.38.00
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
+1 -1
View File
@@ -63,7 +63,7 @@ class VersionBumpTest extends TestCase
{ {
file_put_contents( file_put_contents(
"{$this->tmpDir}/README.md", "{$this->tmpDir}/README.md",
"<!-- VERSION: 09.37.07 -->\nSome content\n" "<!-- VERSION: 09.38.00 -->\nSome content\n"
); );
$this->execute(); $this->execute();
+2 -2
View File
@@ -34,7 +34,7 @@ class VersionReadTest extends TestCase
{ {
file_put_contents( file_put_contents(
"{$this->tmpDir}/README.md", "{$this->tmpDir}/README.md",
"# Test\n<!-- VERSION: 09.37.07 -->\n" "# Test\n<!-- VERSION: 09.38.00 -->\n"
); );
$this->assertSame('02.03.04', trim($this->runScript())); $this->assertSame('02.03.04', trim($this->runScript()));
@@ -68,7 +68,7 @@ class VersionReadTest extends TestCase
{ {
file_put_contents( file_put_contents(
"{$this->tmpDir}/README.md", "{$this->tmpDir}/README.md",
"<!-- VERSION: 09.37.07 -->\n" "<!-- VERSION: 09.38.00 -->\n"
); );
mkdir("{$this->tmpDir}/src", 0755, true); mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents( file_put_contents(
+1 -1
View File
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform * INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
* PATH: /validate/check_file_integrity.php * PATH: /validate/check_file_integrity.php
* VERSION: 09.37.07 * VERSION: 09.38.00
* BRIEF: Compare deployed files on a remote server against the local repository to detect drift * BRIEF: Compare deployed files on a remote server against the local repository to detect drift
*/ */