Public Access
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b850a227a | |||
| 89c4795755 | |||
| a51f0bfb2f | |||
| c7b6f98f93 | |||
| 2dc43de160 | |||
| ea760bb75b | |||
| d065eaf0fd | |||
| e4d9bce5d0 | |||
| e933e7b651 | |||
| 157e87279e | |||
| 7850721f86 | |||
| 8949f69699 | |||
| af2313d936 | |||
| 2e5446ff5e | |||
| ab05bb7008 | |||
| 6bd26698c4 | |||
| 19b504526b | |||
| e7bdf7cbc7 | |||
| ff5794d0cc | |||
| bfba45e8b5 | |||
| 78ea05233b | |||
| ae0d54310d | |||
| 9df59836bf | |||
| 6e40707223 | |||
| ca55e5d2d2 | |||
| 9526d006c4 | |||
| c90a5671bd | |||
| 048a7d71d1 | |||
| c57b5724ac | |||
| 78affd37ff | |||
| b3062c6559 | |||
| 9dab9f1ef6 | |||
| c61d32709c | |||
| 2b137f9041 | |||
| f3f356ae54 | |||
| 85d863be08 | |||
| a83eda5798 | |||
| 54a27c0a8f | |||
| 5754fae5a8 | |||
| ab3c0a3a8d | |||
| eb3689cff6 | |||
| 631b44e1a3 | |||
| 7338a3da2e | |||
| 0a0e1f11e0 | |||
| c3a3ab3f62 | |||
| 79631d77bb | |||
| 556ac85a63 | |||
| c1a145480c | |||
| 4d06e3828e | |||
| ab7b6cfba1 | |||
| e135a0ff8b | |||
| 2d6155d655 | |||
| 65215cdc4c | |||
| 86db53d2ac | |||
| 8a4e1ab60f | |||
| 8c87cf1e74 | |||
| 505013c6f1 | |||
| 2f6845c5c0 | |||
| 45233fb9d2 | |||
| ecf6615383 | |||
| 59d3524615 | |||
| 8058baef95 | |||
| df2efa4838 | |||
| 76bc91a383 | |||
| b53846f6f4 | |||
| 11eb1e2649 | |||
| cb2debc437 | |||
| 1ecd9239ed | |||
| 66e728b078 | |||
| ae2860c3b5 | |||
| 34ab5c43ee | |||
| 154d6911f9 | |||
| b3d9ee8255 | |||
| 4da7ecd38e | |||
| 2e1eb9b8f9 | |||
| 59720f1533 | |||
| 930776a6ff | |||
| 4453a2e127 | |||
| 0363597c85 | |||
| 547fc5ead8 | |||
| af77e9d361 | |||
| 7565ef2171 | |||
| f724eaa26e | |||
| 7f1307bf05 | |||
| d0d778fae8 | |||
| c4aaf5bd2c | |||
| d96c5ac420 | |||
| a8ef5f1090 | |||
| a9147bb7a4 |
@@ -0,0 +1,76 @@
|
||||
# moko-platform
|
||||
|
||||
Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Version** | 09.01.00 |
|
||||
| **Branch** | develop on `dev`, merge to `main` (protected) |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
composer check # Run all checks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `templates/` | Universal templates, configs, governance schema |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — `php bin/moko <command>` |
|
||||
| `monitoring/sites.json` | Sites list for mcp_mokomonitor |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (`lib/Enterprise/CliFramework.php`).
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`.
|
||||
After adding a CLI tool, register it in `bin/moko` COMMAND_MAP.
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
- `MokoGiteaAdapter` — git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic in `lib/Enterprise/Plugins/`. Each implements `ProjectPluginInterface` with health checks, validation, build commands, config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|---|---|---|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 (advisory) | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M`. CI enforces PHPCS errors; PHPStan is `continue-on-error`.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
|
||||
- **Attribution**: `Authored-by: Moko Consulting`
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
@@ -42,7 +42,7 @@ Suggested text here
|
||||
<!-- Add any other context, screenshots, or references -->
|
||||
|
||||
## Standards Alignment
|
||||
- [ ] Follows MokoStandards documentation guidelines
|
||||
- [ ] Follows moko-platform documentation guidelines
|
||||
- [ ] Uses en_US/en_GB localization
|
||||
- [ ] Includes proper SPDX headers where applicable
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ If you have ideas about how this could be implemented, share them here:
|
||||
Add any other context, mockups, or screenshots about the feature request here.
|
||||
|
||||
## Relevant Standards
|
||||
Does this relate to any standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
Does this relate to any standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
- [ ] Accessibility (WCAG 2.1 AA)
|
||||
- [ ] Localization (en_US/en_GB)
|
||||
- [ ] Security best practices
|
||||
|
||||
@@ -35,7 +35,7 @@ Use this template only for:
|
||||
<!-- Describe how this could be addressed -->
|
||||
|
||||
## Standards Reference
|
||||
Does this relate to security standards in [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
Does this relate to security standards in [moko-platform](https://git.mokoconsulting.tech/MokoConsulting/moko-platform)?
|
||||
- [ ] SPDX license identifiers
|
||||
- [ ] Secret management
|
||||
- [ ] Dependency security
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
# Platform/standards/infra repos to exclude
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1"
|
||||
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
|
||||
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private moko-platform MokoTesting"
|
||||
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
|
||||
|
||||
if [ -n "${{ inputs.repos }}" ]; then
|
||||
|
||||
@@ -1,66 +1,68 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokoplatform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokoplatform tools
|
||||
run: |
|
||||
if [ -f "/opt/mokoplatform/cli/version_bump.php" ] && [ -f "/opt/mokoplatform/vendor/autoload.php" ]; then
|
||||
echo "Using pre-installed /opt/mokoplatform"
|
||||
echo "MOKO_CLI=/opt/mokoplatform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/mokoplatform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokoplatform.git" \
|
||||
/tmp/mokoplatform-api
|
||||
cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokoplatform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
@@ -71,20 +71,25 @@ jobs:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
@@ -100,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
@@ -108,7 +113,7 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
@@ -131,31 +136,80 @@ jobs:
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
@@ -167,7 +221,7 @@ jobs:
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
@@ -241,7 +295,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Universal
|
||||
# INGROUP: MokoPlatform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
@@ -1,213 +1,10 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
||||
# VERSION: 02.00.00
|
||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
||||
#
|
||||
# +========================================================================+
|
||||
# | CASCADE MAIN → ALL BRANCHES |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
||||
# | |
|
||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
||||
# | 3. On conflict: leave PR open for manual resolution |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Cascade Main → Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
# DISABLED — auto-release Step 11 recreates dev from main after every release.
|
||||
# Cascade-dev is redundant and causes version conflicts when both main and dev
|
||||
# have different version numbers in templateDetails.xml / manifest.xml.
|
||||
name: "Cascade Main → Dev (DISABLED)"
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
cascade:
|
||||
name: Cascade main → branches
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
||||
|
||||
steps:
|
||||
- name: Discover target branches
|
||||
id: branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Fetch all branches (paginated)
|
||||
PAGE=1
|
||||
ALL_BRANCHES=""
|
||||
while true; do
|
||||
BATCH=$(curl -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/branches?page=${PAGE}&limit=50" \
|
||||
| jq -r '.[].name // empty')
|
||||
[ -z "$BATCH" ] && break
|
||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
||||
TARGETS=""
|
||||
for BRANCH in $ALL_BRANCHES; do
|
||||
case "$BRANCH" in
|
||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
||||
TARGETS="$TARGETS $BRANCH"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
||||
|
||||
if [ -z "$TARGETS" ]; then
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No cascade target branches found"
|
||||
else
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
COUNT=$(echo "$TARGETS" | wc -w)
|
||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
||||
fi
|
||||
|
||||
- name: Cascade to all target branches
|
||||
if: steps.branches.outputs.targets != ''
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
||||
|
||||
SUCCESS=0
|
||||
CONFLICTS=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
for BRANCH in $TARGETS; do
|
||||
echo ""
|
||||
echo "═══ main → ${BRANCH} ═══"
|
||||
|
||||
# Check if branch is already up to date
|
||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
||||
RESPONSE=$(curl -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
||||
|
||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
||||
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo " ✅ Already up to date"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
||||
|
||||
# Check for existing cascade PR
|
||||
EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
||||
|
||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
||||
PR_NUMBER=""
|
||||
|
||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
||||
else
|
||||
# Create cascade PR
|
||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
||||
\"head\": \"main\",
|
||||
\"base\": \"${BRANCH}\"
|
||||
}" \
|
||||
"${API}/pulls")
|
||||
|
||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✅ Created PR #${PR_NUMBER}"
|
||||
fi
|
||||
|
||||
# Try auto-merge
|
||||
PR_DATA=$(curl -sS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/pulls/${PR_NUMBER}")
|
||||
|
||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
||||
|
||||
if [ "$MERGEABLE" != "true" ]; then
|
||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"Do\": \"merge\",
|
||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
||||
\"delete_branch_after_merge\": false
|
||||
}" \
|
||||
"${API}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
||||
|
||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
||||
CONFLICTS=$((CONFLICTS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo " ✅ Merged: ${SUCCESS}"
|
||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
||||
echo " ❌ Failed: ${FAILED}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
- run: echo "Cascade disabled — auto-release handles dev recreation"
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/ci-platform.yml
|
||||
# VERSION: 01.00.00
|
||||
# PATH: /.mokogitea/workflows/ci-platform.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: moko-platform CI — the standards engine validates itself
|
||||
#
|
||||
# +========================================================================+
|
||||
# | MOKOSTANDARDS PLATFORM CI |
|
||||
# | MOKO-PLATFORM CI |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | This is NOT a generic CI workflow. This is the self-validation |
|
||||
@@ -41,7 +41,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'wiki/**'
|
||||
- '.gitea/ISSUE_TEMPLATE/**'
|
||||
- '.mokogitea/ISSUE_TEMPLATE/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
echo "::error file=${file}::PHP syntax error"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ automation/ cli/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ automation/ cli/ source/ src/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### PHP Syntax"
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
echo "::warning file=${file}::Missing SPDX header"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### License Headers"
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
echo "::error file=${file}::Potential hardcoded secret detected"
|
||||
FOUND=$((FOUND + 1))
|
||||
fi
|
||||
done < <(find lib/ validate/ cli/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
done < <(find lib/ validate/ cli/ source/ src/ automation/ deploy/ -name "*.php" -print0 2>/dev/null)
|
||||
|
||||
{
|
||||
echo "### Secret Detection"
|
||||
@@ -412,6 +412,12 @@ jobs:
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check gate results
|
||||
run: |
|
||||
{
|
||||
@@ -437,3 +443,46 @@ jobs:
|
||||
echo "::error::One or more CI gates failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.code-quality.result == 'failure' ||
|
||||
needs.tests.result == 'failure' ||
|
||||
needs.self-health.result == 'failure' ||
|
||||
needs.governance.result == 'failure' ||
|
||||
needs.templates.result == 'failure')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Platform CI"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Code Quality" \
|
||||
"${{ needs.code-quality.result }}" \
|
||||
"PHPCS (PSR-12), PHPStan, or PHP syntax checks failed. Run \`composer check\` locally to reproduce."
|
||||
|
||||
report_gate "Unit Tests" \
|
||||
"${{ needs.tests.result }}" \
|
||||
"PHPUnit tests failed on one or more PHP versions (8.1, 8.2, 8.3). Run \`vendor/bin/phpunit --testdox\` locally."
|
||||
|
||||
report_gate "Self-Health" \
|
||||
"${{ needs.self-health.result }}" \
|
||||
"Self-health score fell below the 80% threshold. Run \`php bin/moko health -- --path .\` locally."
|
||||
|
||||
report_gate "Governance" \
|
||||
"${{ needs.governance.result }}" \
|
||||
"Governance checks failed (license headers, secrets, or version consistency). Check the CI run summary for specifics."
|
||||
|
||||
report_gate "Template Integrity" \
|
||||
"${{ needs.templates.result }}" \
|
||||
"Workflow or gitignore templates failed YAML validation or are missing required entries."
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Maintenance
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/cleanup.yml
|
||||
# VERSION: 01.00.00
|
||||
# PATH: /.mokogitea/workflows/cleanup.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||
|
||||
name: "Universal: Repository Cleanup"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/gitleaks.yml.template
|
||||
# VERSION: 01.00.00
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||
#
|
||||
# +========================================================================+
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 09.21.00
|
||||
# VERSION: 09.25.04
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Notifications
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/notify.yml
|
||||
# VERSION: 01.00.00
|
||||
# PATH: /.mokogitea/workflows/notify.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||
|
||||
name: "Universal: Notifications"
|
||||
|
||||
+510
-236
@@ -1,236 +1,510 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found in source files"
|
||||
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in source/src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="source"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No source/, src/, or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -8,15 +8,22 @@
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- 'fix/**'
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- 'chore/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -39,11 +46,11 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,32 +58,50 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
|
||||
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
|
||||
echo Using pre-installed /opt/moko-platform
|
||||
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
|
||||
else
|
||||
echo Falling back to fresh clone
|
||||
if ! command -v composer > /dev/null 2>&1; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
|
||||
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Auto-detect and update platform if not set in manifest
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
# Auto-detect stability from branch name on push, or use input on dispatch
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
case "${{ github.ref_name }}" in
|
||||
rc) STABILITY="release-candidate" ;;
|
||||
alpha) STABILITY="alpha" ;;
|
||||
beta) STABILITY="beta" ;;
|
||||
*) STABILITY="development" ;;
|
||||
esac
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
@@ -85,20 +110,26 @@ jobs:
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Read current version (bump already handled by push workflow)
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
# Strip any existing suffix from version before applying stability
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
|
||||
# Verify version consistency across all files
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Update VERSION variable with suffix
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
@@ -142,7 +173,42 @@ jobs:
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
@@ -155,55 +221,8 @@ jobs:
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
echo "Syncing updates.xml -> ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# PATH: /.mokogitea/workflows/security-audit.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
#
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
#
|
||||
# Joomla filters update entries by the user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'dev/**'
|
||||
- 'alpha/**'
|
||||
- 'beta/**'
|
||||
- 'rc/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Stability tag'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- stable
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update Server
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Configure git for bot pushes
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
# Auto-bump patch version
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Strip any existing suffix before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
STABILITY="rc"
|
||||
elif [[ "$BRANCH" == beta/* ]]; then
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
else
|
||||
STABILITY="development"
|
||||
fi
|
||||
|
||||
# Version suffix per stability stream
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
*) SUFFIX=""; TAG="stable" ;;
|
||||
esac
|
||||
|
||||
# Propagate version with stability suffix to all manifest files
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Re-read version (now includes suffix from version_set_platform)
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
fi
|
||||
|
||||
- name: SFTP deploy to dev server
|
||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
||||
env:
|
||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# Permission check: admin or maintain role required
|
||||
ACTOR="${{ github.actor }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
||||
case "$PERMISSION" in
|
||||
admin|maintain|write) ;;
|
||||
*)
|
||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
||||
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
|
||||
PORT="${DEV_PORT:-22}"
|
||||
REMOTE="${DEV_PATH%/}"
|
||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
||||
if [ -n "$DEV_KEY" ]; then
|
||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
fi
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${{ steps.meta.outputs.display_version }}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"generated_at": "2026-03-10T19:51:42.238134Z",
|
||||
"repository": "mokoconsulting-tech/MokoStandards",
|
||||
"repository": "MokoConsulting/moko-platform",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"scripts": [
|
||||
|
||||
+27
-5
@@ -12,12 +12,34 @@ BRIEF: Release changelog
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [09.21.00] --- 2026-05-30
|
||||
### Added
|
||||
- `workflow_sync.php` — cascading workflow sync from Generic → platform templates → live repos based on manifest.platform
|
||||
- `platform_detect.php` — auto-detect repo platform type (joomla/dolibarr/go/mcp/platform/generic) from file structure, optionally update manifest
|
||||
- Version prefix support in `version_read.php` and `version_bump.php` — repos with `<version_prefix>` in manifest (e.g. MokoGitea: `1.26.1+moko.`) get prefix-aware version scanning and bumping
|
||||
- Platform types: joomla, dolibarr, go, mcp, platform, generic
|
||||
- Template-Go and Template-MCP repos created
|
||||
|
||||
## [09.20.00] --- 2026-05-30
|
||||
### Changed
|
||||
- `auto-release.yml` — patch branches (fix/*, patch/*, hotfix/*, bugfix/*) use `--bump none` (pre-release already bumped); feature/dev branches bump minor
|
||||
- `pre-release.yml` — triggers on push to dev, fix/**, patch/**, hotfix/**, bugfix/**, alpha, beta, rc branches
|
||||
- Version format standardized: `[prefix]XX.YY.ZZ` in source files, suffix (`-dev`, `-rc`) added by release system only
|
||||
|
||||
## [09.19.00] --- 2026-05-30
|
||||
## [09.25.00] --- 2026-06-04
|
||||
|
||||
## [09.18.00] --- 2026-05-30
|
||||
## [09.23] --- 2026-05-31
|
||||
|
||||
## [09.17.00] --- 2026-05-30
|
||||
## [09.22] --- 2026-05-31
|
||||
|
||||
### Changed
|
||||
- **refactor(cli):** migrate 64 legacy scripts to CliFramework (#235) — all tools in cli/, automation/, maintenance/, deploy/, release/ now extend CliFramework with free --help, --verbose, --quiet, --dry-run, --json, banners, and coloured logging
|
||||
|
||||
### Fixed
|
||||
- fix: auto-detect org/repo in updates_xml_build from manifest and git remote
|
||||
- fix: restore hyphen in version suffixes
|
||||
- fix: release names use standardized format
|
||||
- fix: remove lesser stream copies, each stream updates independently
|
||||
- fix: sort updates.xml entries dev first, stable last
|
||||
|
||||
## [09.21] --- 2026-05-30
|
||||
|
||||
## [09.20] --- 2026-05-30
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**moko-platform** — Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Language** | PHP 8.1+ |
|
||||
| **Default branch** | main |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
| **Version** | 09.01.00 |
|
||||
| **Wiki** | [moko-platform Wiki](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki) |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
composer install # Install PHP dependencies
|
||||
php bin/moko health --path . # Run repo health check
|
||||
php bin/moko check:syntax --path . # PHP syntax check
|
||||
php bin/moko drift --org MokoConsulting # Scan for standards drift
|
||||
php bin/moko dashboard --token $TOKEN -o dashboard.html # Generate client dashboard
|
||||
|
||||
# Code quality
|
||||
php vendor/bin/phpcs --standard=phpcs.xml -n lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpcbf --standard=phpcs.xml lib/ validate/ automation/ cli/
|
||||
php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=512M
|
||||
|
||||
# Run all checks
|
||||
composer check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `cli/` | 32 standalone CLI tools (version, release, build, repo management) |
|
||||
| `validate/` | 20 validation scripts (syntax, structure, manifests, drift) |
|
||||
| `automation/` | 7 bulk operations (sync, push files, templates, cleanup) |
|
||||
| `lib/Enterprise/` | Core library — CliFramework, ApiClient, adapters, validators |
|
||||
| `lib/Enterprise/Plugins/` | 11 platform plugins (Joomla, Dolibarr, Node.js, Python, etc.) |
|
||||
| `deploy/` | SFTP deployment scripts (Joomla, Dolibarr, health checks) |
|
||||
| `templates/` | Universal templates, configs, governance schema |
|
||||
| `.mokogitea/workflows/` | CI/CD workflows (Gitea Actions) |
|
||||
| `bin/moko` | Unified CLI dispatcher — runs any tool via `php bin/moko <command>` |
|
||||
|
||||
### CLI Framework
|
||||
|
||||
All CLI tools extend `MokoEnterprise\CliFramework` (defined in `lib/Enterprise/CliFramework.php`).
|
||||
|
||||
Pattern for new tools:
|
||||
```php
|
||||
class MyTool extends CliFramework {
|
||||
protected function configure(): void {
|
||||
$this->setDescription('What this tool does');
|
||||
$this->addArgument('--name', 'Description', 'default');
|
||||
}
|
||||
protected function run(): int {
|
||||
$name = $this->getArgument('--name');
|
||||
// ... business logic ...
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
$app = new MyTool();
|
||||
exit($app->execute());
|
||||
```
|
||||
|
||||
Built-in flags: `--help`, `--verbose`, `--quiet`, `--dry-run`
|
||||
|
||||
### Platform Adapters
|
||||
|
||||
Git operations are abstracted via `GitPlatformAdapter` interface:
|
||||
- `MokoGiteaAdapter` — for git.mokoconsulting.tech (primary)
|
||||
- `GitHubAdapter` — for github.com mirrors
|
||||
|
||||
### Plugin System
|
||||
|
||||
Platform-specific logic lives in `lib/Enterprise/Plugins/`. Each plugin implements `ProjectPluginInterface` with methods for health checks, validation, build commands, and config schemas.
|
||||
|
||||
## Code Quality
|
||||
|
||||
| Tool | Level | Config |
|
||||
|------|-------|--------|
|
||||
| PHPCS | PSR-12 (errors only) | `phpcs.xml` |
|
||||
| PHPStan | Level 2 | `phpstan.neon` |
|
||||
|
||||
PHPStan runs with `--memory-limit=512M` due to large codebase. CI enforces PHPCS errors; PHPStan is advisory (`continue-on-error`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
|
||||
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits
|
||||
- **Branch strategy**: develop on `dev`, merge to `main` for release
|
||||
- **Minification**: handled at build time (CI) and runtime (MokoMinifyHelper for Joomla templates)
|
||||
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
|
||||
- **New CLI tools**: extend `CliFramework`, not `CLIApp` (legacy)
|
||||
- **After adding a CLI tool**: register it in `bin/moko` COMMAND_MAP
|
||||
+161
-161
@@ -1,161 +1,161 @@
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
# Contributing to Moko Consulting Projects
|
||||
|
||||
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
|
||||
|
||||
## Branching Workflow
|
||||
|
||||
```
|
||||
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
|
||||
```
|
||||
|
||||
### Step by step
|
||||
|
||||
1. **Create a feature branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull
|
||||
git checkout -b feature/my-change
|
||||
```
|
||||
|
||||
2. **Work and commit** on your feature branch. Push to origin.
|
||||
|
||||
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
|
||||
|
||||
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
|
||||
- This automatically renames the source branch to `rc` (release candidate)
|
||||
- An RC pre-release is built and uploaded
|
||||
|
||||
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
|
||||
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
|
||||
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
|
||||
- When the draft PR is created, the branch is renamed to `rc`
|
||||
|
||||
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
|
||||
|
||||
7. **Merging to main** triggers the stable release pipeline:
|
||||
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
|
||||
- Stability suffix stripped (clean version)
|
||||
- Gitea release created with ZIP/tar.gz packages
|
||||
- `updates.xml` updated (Joomla extensions)
|
||||
- `dev` branch recreated from `main`
|
||||
|
||||
### Branch summary
|
||||
|
||||
| Branch | Purpose | Created by |
|
||||
|--------|---------|-----------|
|
||||
| `feature/*` | New features and fixes | Developer |
|
||||
| `dev` | Integration branch | Auto-recreated after release |
|
||||
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
|
||||
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
|
||||
| `rc` | Release candidate | Auto-renamed on draft PR to main |
|
||||
| `main` | Stable releases | Protected, merge only |
|
||||
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
|
||||
|
||||
### Protected branches
|
||||
|
||||
| Branch | Direct push | Merge via |
|
||||
|--------|------------|-----------|
|
||||
| `main` | Blocked (CI bot whitelisted) | PR merge only |
|
||||
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
|
||||
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
|
||||
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
|
||||
| `feature/*` | Open | N/A (source branch) |
|
||||
|
||||
## Version Policy
|
||||
|
||||
### Format
|
||||
|
||||
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
|
||||
|
||||
- **XX** — Major version (breaking changes)
|
||||
- **YY** — Minor version (new features, bumped on release to main)
|
||||
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
|
||||
|
||||
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
|
||||
|
||||
### Stability suffixes
|
||||
|
||||
Each branch appends a suffix to indicate stability:
|
||||
|
||||
| Branch | Suffix | Example |
|
||||
|--------|--------|---------|
|
||||
| `main` | (none) | `02.09.00` |
|
||||
| `dev` | `-dev` | `02.09.01-dev` |
|
||||
| `feature/*` | `-dev` | `02.09.01-dev` |
|
||||
| `alpha` | `-alpha` | `02.09.01-alpha` |
|
||||
| `beta` | `-beta` | `02.09.01-beta` |
|
||||
| `rc` | `-rc` | `02.09.01-rc` |
|
||||
|
||||
### Auto version bump
|
||||
|
||||
On every push to `dev`, `feature/*`, or `patch/*`:
|
||||
|
||||
1. Patch version incremented
|
||||
2. Stability suffix `-dev` applied
|
||||
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
|
||||
4. Commit created with `[skip ci]` to avoid loops
|
||||
|
||||
### Release version flow
|
||||
|
||||
Version bumps happen at specific release events:
|
||||
|
||||
| Event | Bump | Example |
|
||||
|-------|------|---------|
|
||||
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
|
||||
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
|
||||
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
|
||||
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
|
||||
|
||||
### Release stream copies
|
||||
|
||||
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
|
||||
|
||||
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
|
||||
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
|
||||
|
||||
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
|
||||
|
||||
### Version files
|
||||
|
||||
The version tools update all files containing version stamps:
|
||||
|
||||
- `.mokogitea/manifest.xml` (canonical source)
|
||||
- Joomla XML manifests (`<version>` tag)
|
||||
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
|
||||
- `package.json`, `pyproject.toml`
|
||||
- Any text file with a `VERSION: XX.YY.ZZ` label
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
- **Copyright**: all files must include the Moko Consulting copyright header
|
||||
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
|
||||
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Optional body with context.
|
||||
|
||||
Authored-by: Moko Consulting
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
|
||||
|
||||
Special flags in commit messages:
|
||||
- `[skip ci]` — skip all CI workflows
|
||||
- `[skip bump]` — skip auto version bump only
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the repository's issue tracker with the appropriate template.
|
||||
|
||||
---
|
||||
|
||||
*Moko Consulting <hello@mokoconsulting.tech>*
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
DEFGROUP: MokoPlatform.Root
|
||||
INGROUP: MokoPlatform
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /PLUGIN_SCRIPTS.md
|
||||
BRIEF: Plugin system CLI documentation
|
||||
@@ -11,7 +11,7 @@ BRIEF: Plugin system CLI documentation
|
||||
|
||||
# Plugin System CLI Scripts
|
||||
|
||||
Command-line scripts for validating, health checking, and managing projects using the MokoStandards plugin system.
|
||||
Command-line scripts for validating, health checking, and managing projects using the moko-platform plugin system.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Root
|
||||
INGROUP: MokoStandards
|
||||
DEFGROUP: MokoPlatform.Root
|
||||
INGROUP: MokoPlatform
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /README.md
|
||||
VERSION: 09.21.00
|
||||
VERSION: 09.25.04
|
||||
BRIEF: Project overview and documentation
|
||||
-->
|
||||
|
||||
# MokoStandards Enterprise API
|
||||
# moko-platform Enterprise API
|
||||
|
||||
  
|
||||
|
||||
PHP implementation of MokoStandards — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
||||
PHP implementation of moko-platform — enterprise standards, automation framework, workflow templates, and bulk sync tooling.
|
||||
|
||||
> **Primary platform**: [Gitea — git.mokoconsulting.tech](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API)
|
||||
> **Backup mirror**: [GitHub](https://github.com/MokoConsulting/MokoStandards-API) *(read-only mirror)*
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Index
|
||||
INGROUP: MokoStandards.Analysis
|
||||
DEFGROUP: MokoPlatform.Index
|
||||
INGROUP: MokoPlatform.Analysis
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /analysis/index.md
|
||||
BRIEF: Analysis directory index
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/bulk_joomla_template.php
|
||||
* BRIEF: Bulk scaffold and sync Joomla template repositories
|
||||
@@ -42,7 +42,7 @@ use MokoEnterprise\{
|
||||
*
|
||||
* Provides three operations for Joomla template projects:
|
||||
* --scaffold: Create a new template repository with the full directory structure
|
||||
* --sync: Push MokoStandards files to existing template repositories
|
||||
* --sync: Push moko-platform files to existing template repositories
|
||||
* --list: List all repositories tagged as joomla-template
|
||||
*
|
||||
* Works with both GitHub and Gitea via the PlatformAdapterFactory.
|
||||
@@ -50,7 +50,7 @@ use MokoEnterprise\{
|
||||
class BulkJoomlaTemplate extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.10';
|
||||
public const VERSION = '09.23.00';
|
||||
|
||||
private GitPlatformAdapter $adapter;
|
||||
private Config $config;
|
||||
@@ -318,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
|
||||
$name,
|
||||
$path,
|
||||
$content,
|
||||
"chore: update {$path} from MokoStandards",
|
||||
"chore: update {$path} from moko-platform",
|
||||
$existingSha,
|
||||
$branch
|
||||
);
|
||||
|
||||
+42
-42
@@ -9,8 +9,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/bulk_sync.php
|
||||
* BRIEF: Enterprise-grade bulk repository synchronization
|
||||
@@ -42,7 +42,7 @@ use MokoEnterprise\{
|
||||
/**
|
||||
* Bulk Repository Synchronization Tool
|
||||
*
|
||||
* Synchronizes MokoStandards files across multiple repositories using
|
||||
* Synchronizes moko-platform files across multiple repositories using
|
||||
* the Enterprise library for robust, audited operations.
|
||||
*/
|
||||
class BulkSync extends CliFramework
|
||||
@@ -57,7 +57,7 @@ class BulkSync extends CliFramework
|
||||
* Script version number
|
||||
* Public to allow script instantiation with class constants
|
||||
*/
|
||||
public const VERSION = '04.06.00';
|
||||
public const VERSION = '09.23.00';
|
||||
public const VERSION_MINOR = '04.05';
|
||||
|
||||
private ApiClient $api;
|
||||
@@ -95,7 +95,7 @@ class BulkSync extends CliFramework
|
||||
*/
|
||||
protected function run(): int
|
||||
{
|
||||
$this->log("🚀 MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO');
|
||||
$this->log("🚀 moko-platform Bulk Synchronization v" . self::VERSION, 'INFO');
|
||||
|
||||
// Initialize enterprise components
|
||||
if (!$this->initializeComponents()) {
|
||||
@@ -180,7 +180,7 @@ class BulkSync extends CliFramework
|
||||
$results['health'] = $this->runHealthChecksAll($org, $repositories);
|
||||
}
|
||||
|
||||
// Create/update tracking issue in MokoStandards
|
||||
// Create/update tracking issue in moko-platform
|
||||
$this->createSyncIssue($org, $results);
|
||||
|
||||
// Create/update a failure issue when any repos failed
|
||||
@@ -244,7 +244,7 @@ class BulkSync extends CliFramework
|
||||
* Filter repositories based on include/exclude lists
|
||||
*/
|
||||
/** Repositories that are permanently excluded from bulk sync. */
|
||||
private const ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
|
||||
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||
|
||||
private function filterRepositories(array $repositories, array $include, array $exclude): array
|
||||
{
|
||||
@@ -426,7 +426,7 @@ class BulkSync extends CliFramework
|
||||
$this->log("", 'ERROR');
|
||||
$this->log("Required Implementation:", 'ERROR');
|
||||
$this->log(" 1. Clone/fetch target repository", 'ERROR');
|
||||
$this->log(" 2. Apply file updates based on MokoStandards configuration", 'ERROR');
|
||||
$this->log(" 2. Apply file updates based on moko-platform configuration", 'ERROR');
|
||||
$this->log(" 3. Create pull request with changes", 'ERROR');
|
||||
$this->log(" 4. Handle merge conflicts and validation", 'ERROR');
|
||||
$this->log("", 'ERROR');
|
||||
@@ -837,7 +837,7 @@ class BulkSync extends CliFramework
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all standard MokoStandards labels exist on a target repository.
|
||||
* Ensure all standard moko-platform labels exist on a target repository.
|
||||
*
|
||||
* Fetches existing labels first (GET) and only POSTs the ones that are
|
||||
* missing. This avoids the 422 "already exists" responses that would
|
||||
@@ -872,7 +872,7 @@ class BulkSync extends CliFramework
|
||||
|
||||
// Workflow / Process
|
||||
['automation', '8B4513', 'Automated processes or scripts'],
|
||||
['mokostandards', 'B60205', 'MokoStandards compliance'],
|
||||
['moko-platform', 'B60205', 'moko-platform compliance'],
|
||||
['needs-review', 'FBCA04', 'Awaiting code review'],
|
||||
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
|
||||
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
|
||||
@@ -912,8 +912,8 @@ class BulkSync extends CliFramework
|
||||
['health: poor', 'FF6B6B', 'Health score below 50'],
|
||||
|
||||
// Sync / Automation (used by bulk_sync, scan_drift, check_repo_health)
|
||||
['standards-update', 'B60205', 'MokoStandards sync update'],
|
||||
['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'],
|
||||
['standards-update', 'B60205', 'moko-platform sync update'],
|
||||
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
|
||||
['sync-report', '0075CA', 'Bulk sync run report'],
|
||||
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
|
||||
['push-failure', 'D73A4A', 'File push failure requiring attention'],
|
||||
@@ -925,10 +925,10 @@ class BulkSync extends CliFramework
|
||||
['type: version', '0E8A16', 'Version-related change'],
|
||||
];
|
||||
|
||||
// Quick check: if the repo already has the 'mokostandards' label, it was
|
||||
// Quick check: if the repo already has the 'moko-platform' label, it was
|
||||
// provisioned previously — skip the expensive full label provisioning.
|
||||
try {
|
||||
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||
$probe = $this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
|
||||
if (!empty($probe['name'])) {
|
||||
return; // already provisioned
|
||||
}
|
||||
@@ -1024,7 +1024,7 @@ class BulkSync extends CliFramework
|
||||
*/
|
||||
private function updateOpenBranches(string $org, string $repo): void
|
||||
{
|
||||
$syncBranchPrefix = 'chore/sync-mokostandards-';
|
||||
$syncBranchPrefix = 'chore/sync-moko-platform-';
|
||||
|
||||
try {
|
||||
$defaultBranch = 'main';
|
||||
@@ -1055,7 +1055,7 @@ class BulkSync extends CliFramework
|
||||
$this->api->post("/repos/{$org}/{$repo}/merges", [
|
||||
'base' => $branch,
|
||||
'head' => $defaultBranch,
|
||||
'commit_message' => "chore: merge {$defaultBranch} into {$branch} (MokoStandards sync)",
|
||||
'commit_message' => "chore: merge {$defaultBranch} into {$branch} (moko-platform sync)",
|
||||
]);
|
||||
$this->log(" 🔀 Merged {$defaultBranch} → {$branch} (PR #{$prNum})", 'INFO');
|
||||
} catch (\Exception $e) {
|
||||
@@ -1076,7 +1076,7 @@ class BulkSync extends CliFramework
|
||||
|
||||
/**
|
||||
* Records which sync run touched the repo, the PR number, and the
|
||||
* MokoStandards version that was applied — giving each repo a clear audit
|
||||
* moko-platform version that was applied — giving each repo a clear audit
|
||||
* trail of what was changed and why.
|
||||
*/
|
||||
/**
|
||||
@@ -1119,16 +1119,16 @@ class BulkSync extends CliFramework
|
||||
$minor = self::VERSION_MINOR;
|
||||
$force = isset($this->options['force']) ? ' *(--force)*' : '';
|
||||
$prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber);
|
||||
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards');
|
||||
$branchName = 'chore/sync-mokostandards-v' . $minor;
|
||||
$source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
|
||||
$branchName = 'chore/sync-moko-platform-v' . $minor;
|
||||
$branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName);
|
||||
|
||||
$title = "chore: MokoStandards v{$minor} sync tracking";
|
||||
$title = "chore: moko-platform v{$minor} sync tracking";
|
||||
|
||||
$body = <<<MD
|
||||
## MokoStandards Sync Applied
|
||||
## moko-platform Sync Applied
|
||||
|
||||
A MokoStandards bulk sync run has updated files in this repository.
|
||||
A moko-platform bulk sync run has updated files in this repository.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -1144,13 +1144,13 @@ class BulkSync extends CliFramework
|
||||
Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten.
|
||||
|
||||
---
|
||||
*Updated automatically by [MokoStandards]({$source}) `bulk_sync.php`*
|
||||
*Updated automatically by [moko-platform]({$source}) `bulk_sync.php`*
|
||||
MD;
|
||||
|
||||
// Dedent heredoc
|
||||
$body = preg_replace('/^ /m', '', $body);
|
||||
|
||||
$labelNames = ['standards-update', 'mokostandards', 'type: chore', 'automation'];
|
||||
$labelNames = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
|
||||
$labels = $this->resolveLabelIds($org, $repo, $labelNames);
|
||||
|
||||
try {
|
||||
@@ -1213,7 +1213,7 @@ class BulkSync extends CliFramework
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tracking issue in MokoStandards for this sync run.
|
||||
* Create a tracking issue in moko-platform for this sync run.
|
||||
*/
|
||||
private function createSyncIssue(string $org, array $results): void
|
||||
{
|
||||
@@ -1232,7 +1232,7 @@ class BulkSync extends CliFramework
|
||||
$issues = $results['issues'] ?? [];
|
||||
|
||||
// Stable title — no timestamp so repeated runs update a single issue
|
||||
$title = "sync: MokoStandards v" . self::VERSION_MINOR . " bulk sync report";
|
||||
$title = "sync: moko-platform v" . self::VERSION_MINOR . " bulk sync report";
|
||||
|
||||
$protection = $results['protection'] ?? [];
|
||||
$hasProtect = !empty($protection);
|
||||
@@ -1281,7 +1281,7 @@ class BulkSync extends CliFramework
|
||||
: "|---|---|---|---|";
|
||||
|
||||
$body = <<<MD
|
||||
## MokoStandards Bulk Sync Report
|
||||
## moko-platform Bulk Sync Report
|
||||
|
||||
**Organisation:** `{$org}`
|
||||
**Triggered:** {$now}{$force}
|
||||
@@ -1301,7 +1301,7 @@ class BulkSync extends CliFramework
|
||||
|
||||
try {
|
||||
// Search for existing issue by label — any state so we can reopen closed ones
|
||||
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
|
||||
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
|
||||
'labels' => 'sync-report',
|
||||
'state' => 'all',
|
||||
'per_page' => 1,
|
||||
@@ -1309,8 +1309,8 @@ class BulkSync extends CliFramework
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
$labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation'];
|
||||
$labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames);
|
||||
$labelNames = ['sync-report', 'moko-platform', 'type: chore', 'automation'];
|
||||
$labels = $this->resolveLabelIds($org, 'moko-platform', $labelNames);
|
||||
$existing = array_values($existing);
|
||||
|
||||
if (!empty($existing) && isset($existing[0]['number'])) {
|
||||
@@ -1319,22 +1319,22 @@ class BulkSync extends CliFramework
|
||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||
$patch['state'] = 'open';
|
||||
}
|
||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch);
|
||||
$this->api->patch("/repos/{$org}/moko-platform/issues/{$issueNumber}", $patch);
|
||||
try {
|
||||
$this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]);
|
||||
$this->api->post("/repos/{$org}/moko-platform/issues/{$issueNumber}/labels", ['labels' => $labels]);
|
||||
} catch (\Exception $le) {
|
||||
/* non-fatal */
|
||||
}
|
||||
$this->log("📋 Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
||||
$this->log("📋 Sync report issue updated: {$org}/moko-platform#{$issueNumber}", 'INFO');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'labels' => $labels,
|
||||
'assignees' => ['jmiller'],
|
||||
]);
|
||||
$issueNumber = $issue['number'] ?? '?';
|
||||
$this->log("📋 Sync report issue created: {$org}/MokoStandards#{$issueNumber}", 'INFO');
|
||||
$this->log("📋 Sync report issue created: {$org}/moko-platform#{$issueNumber}", 'INFO');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log("⚠️ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN');
|
||||
@@ -1342,7 +1342,7 @@ class BulkSync extends CliFramework
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a failure issue in MokoStandards when repos fail to sync.
|
||||
* Create or update a failure issue in moko-platform when repos fail to sync.
|
||||
* Uses the 'sync-failure' label so it is distinct from the run-report issue.
|
||||
* Reopens a closed issue rather than creating a duplicate.
|
||||
*/
|
||||
@@ -1388,7 +1388,7 @@ class BulkSync extends CliFramework
|
||||
$body = preg_replace('/^ /m', '', $body);
|
||||
|
||||
try {
|
||||
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
|
||||
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
|
||||
'labels' => 'sync-failure',
|
||||
'state' => 'all',
|
||||
'per_page' => 1,
|
||||
@@ -1403,17 +1403,17 @@ class BulkSync extends CliFramework
|
||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||
$patch['state'] = 'open';
|
||||
}
|
||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
|
||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
|
||||
$this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
|
||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']),
|
||||
'labels' => $this->resolveLabelIds($org, 'moko-platform', ['sync-failure']),
|
||||
'assignees' => ['jmiller'],
|
||||
]);
|
||||
$num = $issue['number'] ?? '?';
|
||||
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN');
|
||||
$this->log("🚨 Failure issue created: {$org}/moko-platform#{$num}", 'WARN');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# 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
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* 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 MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
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 MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
||||
|
||||
echo "=== moko-platform 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), '<moko-platform')) {
|
||||
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']
|
||||
?? MokoStandardsParser::platformLanguage($platform);
|
||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::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 = MokoStandardsParser::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());
|
||||
@@ -6,20 +6,12 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/enrich_mokostandards_xml.php
|
||||
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
|
||||
*
|
||||
* Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details.
|
||||
*
|
||||
* Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents,
|
||||
* and updates the manifest with discovered build/deploy/scripts config.
|
||||
*
|
||||
* Usage:
|
||||
* php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME]
|
||||
*
|
||||
* Note: This script uses proc_open for shell commands. All arguments are escaped
|
||||
* via escapeshellarg(). No user-supplied input reaches the shell unescaped.
|
||||
*/
|
||||
@@ -27,448 +19,466 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$repoFilter = null;
|
||||
$skipRepos = [];
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoFilter = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
||||
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
||||
|
||||
function safeExec(string $command, string $cwd = '.'): array
|
||||
class EnrichMokostandardsXmlCli extends CliFramework
|
||||
{
|
||||
$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)];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
}
|
||||
return safeExec($cmd, $workDir);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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', '');
|
||||
}
|
||||
|
||||
// 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}";
|
||||
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 MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
||||
|
||||
echo "=== moko-platform 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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
$repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
|
||||
// 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]];
|
||||
}
|
||||
}
|
||||
$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
|
||||
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)) {
|
||||
foreach ($repos as $repo) {
|
||||
$name = $repo['name'];
|
||||
if ($repoFilter && $name !== $repoFilter) {
|
||||
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 (in_array($name, $skipRepos, true)) {
|
||||
echo " {$name} ... SKIP (excluded)\n";
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
if (str_contains($wc, 'src/')) {
|
||||
$t['src_dir'] = 'src/';
|
||||
if ($repo['archived'] ?? false) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) {
|
||||
$t['branch'] = $m[1];
|
||||
|
||||
$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;
|
||||
}
|
||||
$targets[] = $t;
|
||||
|
||||
$manifestPath = "{$workDir}/.mokogitea/manifest.xml";
|
||||
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<moko-platform')) {
|
||||
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']
|
||||
?? MokoStandardsParser::platformLanguage($platform);
|
||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::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);
|
||||
}
|
||||
}
|
||||
if (!empty($targets)) {
|
||||
$enrichment['deploy'] = $targets;
|
||||
|
||||
@rmdir($tmpBase);
|
||||
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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',
|
||||
];
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($scripts)) {
|
||||
$enrichment['scripts'] = $scripts;
|
||||
}
|
||||
|
||||
return $enrichment;
|
||||
}
|
||||
|
||||
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 = MokoStandardsParser::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'])) {
|
||||
$build = $dom->createElementNS($ns, 'build');
|
||||
$b = $enrichment['build'];
|
||||
foreach (['language', 'runtime'] as $f) {
|
||||
if (isset($b[$f])) {
|
||||
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
|
||||
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
|
||||
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isset($b['package_type'])) {
|
||||
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
|
||||
}
|
||||
if (isset($b['entry_point'])) {
|
||||
$build->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)));
|
||||
|
||||
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'];
|
||||
}
|
||||
}
|
||||
$build->appendChild($art);
|
||||
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 (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 (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;
|
||||
}
|
||||
if (isset($d['type'])) {
|
||||
$req->setAttribute('type', $d['type']);
|
||||
$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';
|
||||
}
|
||||
$deps->appendChild($req);
|
||||
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;
|
||||
}
|
||||
$build->appendChild($deps);
|
||||
}
|
||||
$root->appendChild($build);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!empty($targets)) {
|
||||
$enrichment['deploy'] = $targets;
|
||||
}
|
||||
$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']);
|
||||
$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',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$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);
|
||||
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;
|
||||
}
|
||||
|
||||
return $dom->saveXML();
|
||||
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 = MokoStandardsParser::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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
echo "=== MokoStandards XML Manifest Enrichment ===\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if (!empty($skipRepos)) {
|
||||
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if (empty($token)) {
|
||||
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$repos = 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] = 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/.mokostandards";
|
||||
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
|
||||
echo "SKIP (no XML manifest)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingXml = file_get_contents($manifestPath);
|
||||
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
|
||||
$enrichment = inspectRepo($workDir, $platform);
|
||||
|
||||
if (!isset($enrichment['build'])) {
|
||||
$enrichment['build'] = [];
|
||||
}
|
||||
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
||||
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
||||
|
||||
$enrichedXml = enrichManifestXml($existingXml, $enrichment);
|
||||
$dc = count($enrichment['deploy'] ?? []);
|
||||
$sc = count($enrichment['scripts'] ?? []);
|
||||
$details = "deploy={$dc} scripts={$sc}";
|
||||
|
||||
if ($dryRun) {
|
||||
echo "WOULD ENRICH [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($manifestPath, $enrichedXml);
|
||||
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
||||
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
||||
|
||||
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
|
||||
if ($cr !== 0) {
|
||||
echo "SKIP (no diff)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
||||
if ($pr !== 0) {
|
||||
echo "FAIL (push)\n";
|
||||
$stats['failed']++;
|
||||
} else {
|
||||
echo "ENRICHED [{$details}]\n";
|
||||
$stats['enriched']++;
|
||||
}
|
||||
|
||||
rmTree($workDir);
|
||||
}
|
||||
|
||||
@rmdir($tmpBase);
|
||||
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
|
||||
$app = new EnrichMokostandardsXmlCli();
|
||||
exit($app->execute());
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
FILE INFORMATION
|
||||
DEFGROUP: MokoStandards.Index
|
||||
INGROUP: MokoStandards.Automation
|
||||
DEFGROUP: MokoPlatform.Index
|
||||
INGROUP: MokoPlatform.Automation
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
PATH: /automation/index.md
|
||||
BRIEF: Automation directory index
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/migrate_to_gitea.php
|
||||
* BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance
|
||||
@@ -17,7 +17,7 @@
|
||||
* 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 MokoStandards --skip-archived
|
||||
* php automation/migrate_to_gitea.php --exclude moko-platform --skip-archived
|
||||
* php automation/migrate_to_gitea.php --resume
|
||||
*/
|
||||
|
||||
@@ -278,7 +278,7 @@ class MigrateToGitea extends CliFramework
|
||||
try {
|
||||
$this->gitea->createIssue(
|
||||
$giteaOrg,
|
||||
'MokoStandards',
|
||||
'moko-platform',
|
||||
'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated',
|
||||
$report,
|
||||
['labels' => ['automation', 'type: chore']]
|
||||
|
||||
+24
-23
@@ -9,8 +9,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/push_files.php
|
||||
* BRIEF: Push one or more specific files to one or more remote repositories
|
||||
@@ -35,7 +35,7 @@ use MokoEnterprise\{
|
||||
/**
|
||||
* Targeted File Push Tool
|
||||
*
|
||||
* Pushes one or more specific files from MokoStandards templates to one or
|
||||
* Pushes one or more specific files from moko-platform 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
|
||||
@@ -53,7 +53,7 @@ use MokoEnterprise\{
|
||||
class PushFiles extends CliFramework
|
||||
{
|
||||
public const DEFAULT_ORG = 'MokoConsulting';
|
||||
public const VERSION = '04.06.00';
|
||||
public const VERSION = '09.23.00';
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
@@ -81,7 +81,7 @@ class PushFiles extends CliFramework
|
||||
*/
|
||||
protected function run(): int
|
||||
{
|
||||
$this->log('📦 MokoStandards File Push v' . self::VERSION, 'INFO');
|
||||
$this->log('📦 moko-platform File Push v' . self::VERSION, 'INFO');
|
||||
|
||||
if (!$this->initializeComponents()) {
|
||||
return 1;
|
||||
@@ -230,7 +230,8 @@ class PushFiles extends CliFramework
|
||||
{
|
||||
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||
try {
|
||||
$manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$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) {
|
||||
@@ -336,7 +337,7 @@ class PushFiles extends CliFramework
|
||||
|
||||
$prNumber = null;
|
||||
if (!$direct) {
|
||||
$prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards";
|
||||
$prTitle = "chore: push " . count($entries) . " file(s) from moko-platform";
|
||||
$prBody = $this->buildPRBody($entries);
|
||||
$pr = $this->adapter->createPullRequest(
|
||||
$org,
|
||||
@@ -413,7 +414,7 @@ class PushFiles extends CliFramework
|
||||
|
||||
$message = !empty($customMessage)
|
||||
? $customMessage
|
||||
: "chore: update {$destPath} from MokoStandards";
|
||||
: "chore: update {$destPath} from moko-platform";
|
||||
|
||||
// Fetch existing file SHA (needed for updates)
|
||||
$existingSha = null;
|
||||
@@ -456,9 +457,9 @@ class PushFiles extends CliFramework
|
||||
): void {
|
||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$version = self::VERSION;
|
||||
$source = $this->adapter->getRepoWebUrl($org, 'MokoStandards');
|
||||
$source = $this->adapter->getRepoWebUrl($org, 'moko-platform');
|
||||
|
||||
$title = "chore: MokoStandards file push tracking";
|
||||
$title = "chore: moko-platform file push tracking";
|
||||
|
||||
$deliveryLine = $prNumber !== null
|
||||
? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |"
|
||||
@@ -470,9 +471,9 @@ class PushFiles extends CliFramework
|
||||
));
|
||||
|
||||
$body = <<<MD
|
||||
## MokoStandards File Push
|
||||
## moko-platform File Push
|
||||
|
||||
One or more files were pushed to this repository from MokoStandards.
|
||||
One or more files were pushed to this repository from moko-platform.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -486,12 +487,12 @@ class PushFiles extends CliFramework
|
||||
{$fileRows}
|
||||
|
||||
---
|
||||
*Generated automatically by [MokoStandards]({$source}) `push_files.php`*
|
||||
*Generated automatically by [moko-platform]({$source}) `push_files.php`*
|
||||
MD;
|
||||
|
||||
$body = preg_replace('/^ /m', '', $body);
|
||||
|
||||
$labels = ['standards-update', 'mokostandards', 'type: chore', 'automation'];
|
||||
$labels = ['standards-update', 'moko-platform', 'type: chore', 'automation'];
|
||||
|
||||
try {
|
||||
$existing = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
||||
@@ -549,7 +550,7 @@ class PushFiles extends CliFramework
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a failure issue in MokoStandards when repos fail to receive files.
|
||||
* Create or update a failure issue in moko-platform 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
|
||||
@@ -597,7 +598,7 @@ class PushFiles extends CliFramework
|
||||
$body = preg_replace('/^ /m', '', $body);
|
||||
|
||||
try {
|
||||
$existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [
|
||||
$existing = $this->api->get("/repos/{$org}/moko-platform/issues", [
|
||||
'labels' => 'push-failure',
|
||||
'state' => 'all',
|
||||
'per_page' => 1,
|
||||
@@ -612,17 +613,17 @@ class PushFiles extends CliFramework
|
||||
if (($existing[0]['state'] ?? 'open') === 'closed') {
|
||||
$patch['state'] = 'open';
|
||||
}
|
||||
$this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch);
|
||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN');
|
||||
$this->api->patch("/repos/{$org}/moko-platform/issues/{$num}", $patch);
|
||||
$this->log("🚨 Failure issue #{$num} updated: {$org}/moko-platform#{$num}", 'WARN');
|
||||
} else {
|
||||
$issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [
|
||||
$issue = $this->api->post("/repos/{$org}/moko-platform/issues", [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'labels' => ['push-failure'],
|
||||
'assignees' => ['jmiller'],
|
||||
]);
|
||||
$num = $issue['number'] ?? '?';
|
||||
$this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN');
|
||||
$this->log("🚨 Failure issue created: {$org}/moko-platform#{$num}", 'WARN');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log("⚠️ Could not create/update failure issue: " . $e->getMessage(), 'WARN');
|
||||
@@ -637,14 +638,14 @@ class PushFiles extends CliFramework
|
||||
private function buildPRBody(array $entries): string
|
||||
{
|
||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$lines = ["## MokoStandards File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
|
||||
$lines = ["## moko-platform File Push\n", "**Pushed:** {$now}\n", '### Files\n'];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$lines[] = "- `{$entry['destination']}`";
|
||||
}
|
||||
|
||||
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards');
|
||||
$lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*";
|
||||
$sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'moko-platform');
|
||||
$lines[] = "\n---\n*Generated by [moko-platform]({$sourceUrl}) `push_files.php`*";
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* 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 MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
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 MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||
|
||||
echo "=== moko-platform 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'] ?? MokoStandardsParser::platformLanguage($platform),
|
||||
'package_type' => MokoStandardsParser::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), '<moko-platform');
|
||||
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());
|
||||
@@ -6,348 +6,340 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/push_mokostandards_xml.php
|
||||
* BRIEF: Push XML manifests to all governed repositories
|
||||
*
|
||||
* Push XML .mokostandards manifest to all governed repositories.
|
||||
*
|
||||
* Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks
|
||||
* API requests to paths containing ".mokogitea".
|
||||
*
|
||||
* Usage:
|
||||
* php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force]
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\MokoStandardsParser;
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────
|
||||
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
||||
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
||||
$sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222';
|
||||
|
||||
// ── CLI args ─────────────────────────────────────────────────────────────
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
$force = in_array('--force', $argv, true);
|
||||
$repoFilter = null;
|
||||
$skipRepos = [];
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoFilter = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
||||
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||
|
||||
// ── Platform detection heuristics (mirrors RepositorySynchronizer) ───────
|
||||
$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
|
||||
|
||||
function detectPlatform(array $repo): string
|
||||
class PushMokostandardsXmlCli extends CliFramework
|
||||
{
|
||||
global $CRM_PLATFORM_REPOS;
|
||||
$name = $repo['name'] ?? '';
|
||||
$nameLower = strtolower($name);
|
||||
$description = strtolower($repo['description'] ?? '');
|
||||
$topics = $repo['topics'] ?? [];
|
||||
private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods'];
|
||||
|
||||
if (in_array($name, $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';
|
||||
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);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
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') ?: '';
|
||||
|
||||
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';
|
||||
}
|
||||
$force = $this->getArgument('--force');
|
||||
$repoFilter = $this->getArgument('--repo') ?: null;
|
||||
$skipStr = $this->getArgument('--skip');
|
||||
$skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : [];
|
||||
|
||||
if (str_contains($nameLower, 'standard')) {
|
||||
return 'standards-repository';
|
||||
}
|
||||
return 'default-repository';
|
||||
}
|
||||
$parser = new MokoStandardsParser();
|
||||
$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid();
|
||||
|
||||
/**
|
||||
* Safe shell execution — uses proc_open with explicit arguments to avoid injection.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
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)];
|
||||
}
|
||||
|
||||
/** Recursively remove a directory (cross-platform). */
|
||||
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 {
|
||||
// Clear read-only flag (git objects on Windows)
|
||||
@chmod($file->getPathname(), 0777);
|
||||
@unlink($file->getPathname());
|
||||
echo "=== moko-platform XML Manifest Push ===\n";
|
||||
echo "Org: {$giteaOrg}\n";
|
||||
echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if ($repoFilter) {
|
||||
echo "Filter: {$repoFilter}\n";
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
/**
|
||||
* Run a git command safely in a given working directory.
|
||||
* @return array{int, string}
|
||||
*/
|
||||
function gitCmd(string $workDir, string ...$args): array
|
||||
{
|
||||
$cmd = 'git';
|
||||
foreach ($args as $a) {
|
||||
$cmd .= ' ' . escapeshellarg($a);
|
||||
}
|
||||
return safeExec($cmd, $workDir);
|
||||
}
|
||||
|
||||
// ── Fetch all repos via API ──────────────────────────────────────────────
|
||||
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) {
|
||||
fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page);
|
||||
break;
|
||||
if (empty($token)) {
|
||||
$this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$batch = json_decode($body, true);
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
$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'] ?? MokoStandardsParser::platformLanguage($platform),
|
||||
'package_type' => MokoStandardsParser::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), '<moko-platform');
|
||||
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);
|
||||
}
|
||||
$repos = array_merge($repos, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 50);
|
||||
|
||||
return $repos;
|
||||
}
|
||||
// Cleanup tmp base
|
||||
@rmdir($tmpBase);
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────
|
||||
echo "=== MokoStandards XML Manifest Push ===\n";
|
||||
echo "Org: {$giteaOrg}\n";
|
||||
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
||||
if ($repoFilter) {
|
||||
echo "Filter: {$repoFilter}\n";
|
||||
}
|
||||
echo "\n";
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Created: {$stats['created']}\n";
|
||||
echo "Updated: {$stats['updated']}\n";
|
||||
echo "Skipped: {$stats['skipped']}\n";
|
||||
echo "Failed: {$stats['failed']}\n";
|
||||
|
||||
if (empty($token)) {
|
||||
fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$repos = 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;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$platform = detectPlatform($repo);
|
||||
$defaultBranch = $repo['default_branch'] ?? 'main';
|
||||
// Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH
|
||||
$httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git";
|
||||
// Embed token in HTTPS URL for push auth
|
||||
$authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl);
|
||||
private function detectPlatform(array $repo): string
|
||||
{
|
||||
$name = $repo['name'] ?? '';
|
||||
$nameLower = strtolower($name);
|
||||
$description = strtolower($repo['description'] ?? '');
|
||||
$topics = $repo['topics'] ?? [];
|
||||
|
||||
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'] ?? MokoStandardsParser::platformLanguage($platform),
|
||||
'package_type' => MokoStandardsParser::platformPackageType($platform),
|
||||
'last_synced' => date('c'),
|
||||
]);
|
||||
|
||||
if ($dryRun) {
|
||||
echo "WOULD WRITE ({$platform})\n";
|
||||
$stats['created']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clone shallow via HTTPS (token-authed)
|
||||
$workDir = "{$tmpBase}/{$name}";
|
||||
@mkdir($workDir, 0755, true);
|
||||
|
||||
[$ret, $out] = 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/.mokostandards";
|
||||
$existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), '<mokostandards');
|
||||
if ($existingIsXml && !$force) {
|
||||
$existingPlatform = $parser->extractPlatform(file_get_contents($manifestPath));
|
||||
if ($existingPlatform === $platform) {
|
||||
echo "SKIP (already XML)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
if (in_array($name, self::CRM_PLATFORM_REPOS, true)) {
|
||||
return 'crm-platform';
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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';
|
||||
}
|
||||
|
||||
// Commit
|
||||
$isNew = !$existingIsXml;
|
||||
$commitMsg = $isNew
|
||||
? 'chore: add XML .mokostandards manifest'
|
||||
: 'chore: update .mokostandards to XML format';
|
||||
if (!empty($legacyDeleted)) {
|
||||
$commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted);
|
||||
/**
|
||||
* @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)];
|
||||
}
|
||||
|
||||
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
||||
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
||||
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
||||
foreach ($legacyDeleted as $lf) {
|
||||
gitCmd($workDir, 'add', $lf);
|
||||
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);
|
||||
}
|
||||
|
||||
[$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg);
|
||||
if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) {
|
||||
echo "SKIP (no changes)\n";
|
||||
$stats['skipped']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
}
|
||||
if ($commitRet !== 0) {
|
||||
echo "FAIL (commit)\n";
|
||||
fprintf(STDERR, " %s\n", $commitOut);
|
||||
$stats['failed']++;
|
||||
rmTree($workDir);
|
||||
continue;
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
[$pushRet, $pushOut] = 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']++;
|
||||
}
|
||||
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);
|
||||
|
||||
// Cleanup
|
||||
rmTree($workDir);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
$app = new PushMokostandardsXmlCli();
|
||||
exit($app->execute());
|
||||
|
||||
+12
-12
@@ -9,8 +9,8 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Automation
|
||||
* INGROUP: MokoStandards.Scripts
|
||||
* DEFGROUP: MokoPlatform.Automation
|
||||
* INGROUP: MokoPlatform.Scripts
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /automation/repo_cleanup.php
|
||||
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
|
||||
@@ -38,15 +38,15 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAda
|
||||
*/
|
||||
class RepoCleanup extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
||||
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00';
|
||||
private const VERSION = '09.23.00';
|
||||
private const SYNC_PREFIX = 'chore/sync-moko-platform-';
|
||||
private const CURRENT_BRANCH = 'chore/sync-moko-platform-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', 'mokostandards-script-runner.yml', 'unified-ci.yml',
|
||||
'flush-actions-cache.yml', 'moko-platform-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',
|
||||
@@ -98,7 +98,7 @@ class RepoCleanup extends CliFramework
|
||||
}
|
||||
|
||||
|
||||
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
||||
$this->logMsg("🧹 moko-platform Repository Cleanup v" . self::VERSION);
|
||||
$this->logMsg("Organization: {$org}");
|
||||
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
||||
if ($this->dryRun) {
|
||||
@@ -225,7 +225,7 @@ class RepoCleanup extends CliFramework
|
||||
}
|
||||
|
||||
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
|
||||
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['MokoStandards', '.github-private'], true));
|
||||
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['moko-platform', '.github-private'], true));
|
||||
}
|
||||
|
||||
// ─── Cleanup operations ──────────────────────────────────────────────
|
||||
@@ -463,9 +463,9 @@ class RepoCleanup extends CliFramework
|
||||
private function checkLabels(string $org, string $repo, array &$results): void
|
||||
{
|
||||
try {
|
||||
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
||||
$this->api->get("/repos/{$org}/{$repo}/labels/moko-platform");
|
||||
} catch (\Exception $e) {
|
||||
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
||||
$this->logMsg(" ⚠️ Missing 'moko-platform' label");
|
||||
$results['labels_missing']++;
|
||||
$this->api->resetCircuitBreaker();
|
||||
}
|
||||
@@ -479,9 +479,9 @@ class RepoCleanup extends CliFramework
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$version = $m[1];
|
||||
|
||||
// Check .mokostandards for the tracked MokoStandards version
|
||||
// Check manifest.xml for the tracked moko-platform version
|
||||
try {
|
||||
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokostandards");
|
||||
$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) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# DEFGROUP: MokoStandards.Automation.ServerAutoheal
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# DEFGROUP: MokoPlatform.Automation.ServerAutoheal
|
||||
# INGROUP: MokoPlatform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/server-autoheal.sh
|
||||
# BRIEF: Server auto-heal on unclean restart + split system/content backups
|
||||
|
||||
@@ -19,26 +19,22 @@
|
||||
* php bin/moko <command> [options] (all platforms)
|
||||
* ./bin/moko <command> [options] (Unix, after: chmod +x bin/moko)
|
||||
*
|
||||
* COMMANDS
|
||||
* sync Bulk-sync MokoStandards to organisation repos
|
||||
* health Full repository health check (runs most validators)
|
||||
* inventory Refresh docs/reference/REPOSITORY_INVENTORY.md
|
||||
* COMMANDS (run `php bin/moko list` for the full list — 97 commands)
|
||||
*
|
||||
* check:syntax PHP syntax check (php -l) on all tracked .php files
|
||||
* check:version Verify VERSION fields and badges match composer.json
|
||||
* check:changelog Validate CHANGELOG.md format
|
||||
* check:structure Verify required root files and directories
|
||||
* check:headers Check SPDX-License-Identifier presence in source files
|
||||
* check:secrets Scan for leaked credentials / API keys
|
||||
* check:tabs Detect tab characters in YAML files
|
||||
* check:paths Detect backslash path separators in PHP source
|
||||
* check:xml Validate XML files are well-formed
|
||||
* check:enterprise Full enterprise-readiness check (headers, strict types, PSR-12)
|
||||
* check:dolibarr Validate Dolibarr module directory structure
|
||||
* check:joomla Validate Joomla XML manifest
|
||||
* check:language Validate Joomla/Dolibarr .ini language files
|
||||
* detect Auto-detect repository platform type
|
||||
* drift Scan org repos for drift from MokoStandards templates
|
||||
* Automation sync, automation:cleanup, automation:migrate-gitea
|
||||
* Validation health, detect, drift, check:syntax, check:version, ...
|
||||
* Release release, release:joomla, release:create, release:publish, ...
|
||||
* Version version:read, version:bump, version:auto-bump, ...
|
||||
* Build build:package, build:joomla, build:updates-xml, ...
|
||||
* Deploy deploy:joomla, deploy:dolibarr, deploy:sftp, deploy:rollback, ...
|
||||
* Repository repo:create, repo:archive, repo:rename-branch, repo:reset-dev, ...
|
||||
* Bulk Operations bulk:push-workflow, bulk:push-manifest, bulk:template-joomla, ...
|
||||
* Maintenance maintenance:labels, maintenance:rotate-secrets, maintenance:pin-shas, ...
|
||||
* Fix fix:line-endings, fix:tabs, fix:trailing, fix:permissions
|
||||
* Monitoring dashboard, grafana, client:inventory, client:health-check
|
||||
* Platform platform:detect, manifest:read, manifest:element
|
||||
* Wiki wiki:sync
|
||||
* Badges badge:update
|
||||
*
|
||||
* COMMON OPTIONS (passed through to each script)
|
||||
* --path <dir> Repository root to check (default: .)
|
||||
@@ -88,11 +84,22 @@ require_once $autoloader;
|
||||
* All paths are relative to the repo root.
|
||||
*/
|
||||
const COMMAND_MAP = [
|
||||
// Audit
|
||||
'audit:query' => 'cli/audit_query.php',
|
||||
|
||||
// Automation
|
||||
'sync' => 'automation/bulk_sync.php',
|
||||
'sync' => 'automation/bulk_sync.php',
|
||||
'automation:cleanup' => 'automation/repo_cleanup.php',
|
||||
'automation:migrate-gitea' => 'automation/migrate_to_gitea.php',
|
||||
|
||||
// Maintenance
|
||||
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||
'inventory' => 'maintenance/update_repo_inventory.php',
|
||||
'maintenance:pin-shas' => 'maintenance/pin_action_shas.php',
|
||||
'maintenance:inventory' => 'maintenance/repo_inventory.php',
|
||||
'maintenance:rotate-secrets' => 'maintenance/rotate_secrets.php',
|
||||
'maintenance:labels' => 'maintenance/setup_labels.php',
|
||||
'maintenance:sync-dolibarr' => 'maintenance/sync_dolibarr_readmes.php',
|
||||
'maintenance:update-shas' => 'maintenance/update_sha_hashes.php',
|
||||
|
||||
// Validation — general
|
||||
'health' => 'validate/check_repo_health.php',
|
||||
@@ -108,11 +115,13 @@ const COMMAND_MAP = [
|
||||
'check:enterprise' => 'validate/check_enterprise_readiness.php',
|
||||
|
||||
// Validation — platform-specific
|
||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||
'check:language' => 'validate/check_language_structure.php',
|
||||
'check:client' => 'validate/check_client_theme.php',
|
||||
'check:wiki' => 'validate/check_wiki_health.php',
|
||||
'check:dolibarr' => 'validate/check_dolibarr_module.php',
|
||||
'check:joomla' => 'validate/check_joomla_manifest.php',
|
||||
'check:joomla-compat' => 'cli/joomla_compat_check.php',
|
||||
'check:language' => 'validate/check_language_structure.php',
|
||||
'check:client' => 'validate/check_client_theme.php',
|
||||
'check:theme' => 'cli/theme_lint.php',
|
||||
'check:wiki' => 'validate/check_wiki_health.php',
|
||||
|
||||
// Detection
|
||||
'detect' => 'validate/auto_detect_platform.php',
|
||||
@@ -124,13 +133,18 @@ const COMMAND_MAP = [
|
||||
'release' => 'cli/release.php',
|
||||
'release:notes' => 'cli/release_notes.php',
|
||||
'release:validate' => 'cli/release_validate.php',
|
||||
'manifest:element' => 'cli/manifest_element.php',
|
||||
'release:cascade' => 'cli/release_cascade.php',
|
||||
'release:promote' => 'cli/release_promote.php',
|
||||
'release:create' => 'cli/release_create.php',
|
||||
'release:manage' => 'cli/release_manage.php',
|
||||
'release:mirror' => 'cli/release_mirror.php',
|
||||
'release:package' => 'cli/release_package.php',
|
||||
'release:joomla' => 'cli/joomla_release.php',
|
||||
'release:body-update' => 'cli/release_body_update.php',
|
||||
'release:publish' => 'cli/release_publish.php',
|
||||
'release:verify' => 'cli/release_verify.php',
|
||||
'release:gen-dolibarr' => 'release/generate_dolibarr_version_txt.php',
|
||||
'release:gen-joomla' => 'release/generate_joomla_update_xml.php',
|
||||
|
||||
// Changelog
|
||||
'changelog:promote' => 'cli/changelog_promote.php',
|
||||
@@ -143,31 +157,71 @@ const COMMAND_MAP = [
|
||||
'version:propagate' => 'maintenance/update_version_from_readme.php',
|
||||
'version:set-platform' => 'cli/version_set_platform.php',
|
||||
'version:reset-dev' => 'cli/version_reset_dev.php',
|
||||
'version:auto-bump' => 'cli/version_auto_bump.php',
|
||||
'version:bump-remote' => 'cli/version_bump_remote.php',
|
||||
|
||||
// Build & package
|
||||
'build:package' => 'cli/package_build.php',
|
||||
'build:joomla' => 'cli/joomla_build.php',
|
||||
'build:updates-xml' => 'cli/updates_xml_build.php',
|
||||
'build:package' => 'cli/package_build.php',
|
||||
'build:joomla' => 'cli/joomla_build.php',
|
||||
'build:updates-xml' => 'cli/updates_xml_build.php',
|
||||
'build:updates-xml-sync' => 'cli/updates_xml_sync.php',
|
||||
|
||||
// Platform detection
|
||||
// Platform detection & manifest
|
||||
'platform:detect' => 'cli/platform_detect.php',
|
||||
'manifest:read' => 'cli/manifest_read.php',
|
||||
'manifest:element' => 'cli/manifest_element.php',
|
||||
|
||||
// Repository management
|
||||
'repo:create' => 'cli/create_repo.php',
|
||||
'repo:create-project' => 'cli/create_project.php',
|
||||
'repo:archive' => 'cli/archive_repo.php',
|
||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||
'repo:provision' => 'cli/client_provision.php',
|
||||
'repo:rename-branch' => 'cli/branch_rename.php',
|
||||
'repo:reset-dev' => 'cli/dev_branch_reset.php',
|
||||
|
||||
// Bulk operations
|
||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
|
||||
'bulk:push-workflow' => 'cli/bulk_workflow_push.php',
|
||||
'bulk:trigger' => 'cli/bulk_workflow_trigger.php',
|
||||
'bulk:sync-rulesets' => 'cli/sync_rulesets.php',
|
||||
'bulk:push-files' => 'automation/push_files.php',
|
||||
'bulk:push-manifest' => 'automation/push_manifest_xml.php',
|
||||
'bulk:push-mokostandards' => 'automation/push_mokostandards_xml.php',
|
||||
'bulk:enrich-manifest' => 'automation/enrich_manifest_xml.php',
|
||||
'bulk:enrich-mokostandards' => 'automation/enrich_mokostandards_xml.php',
|
||||
'bulk:template-joomla' => 'automation/bulk_joomla_template.php',
|
||||
|
||||
// Deploy
|
||||
'deploy:joomla' => 'cli/deploy_joomla.php',
|
||||
'deploy:joomla-legacy' => 'deploy/deploy-joomla.php',
|
||||
'deploy:dolibarr' => 'deploy/deploy-dolibarr.php',
|
||||
'deploy:sftp' => 'deploy/deploy-sftp.php',
|
||||
'deploy:backup' => 'deploy/backup-before-deploy.php',
|
||||
'deploy:health-check' => 'deploy/health-check.php',
|
||||
'deploy:rollback' => 'deploy/rollback-joomla.php',
|
||||
'deploy:sync' => 'deploy/sync-joomla.php',
|
||||
|
||||
// Fix / auto-remediation
|
||||
'fix:line-endings' => 'fix/fix_line_endings.php',
|
||||
'fix:tabs' => 'fix/fix_tabs.php',
|
||||
'fix:trailing' => 'fix/fix_trailing_spaces.php',
|
||||
'fix:permissions' => 'fix/fix_permissions.php',
|
||||
|
||||
// Monitoring & dashboards
|
||||
'dashboard' => 'cli/client_dashboard.php',
|
||||
'grafana' => 'cli/grafana_dashboard.php',
|
||||
'client:inventory' => 'cli/client_inventory.php',
|
||||
'client:health-check' => 'cli/client_health_check.php',
|
||||
|
||||
// Badge & wiki
|
||||
'badge:update' => 'cli/badge_update.php',
|
||||
'wiki:sync' => 'cli/wiki_sync.php',
|
||||
|
||||
// Licensing
|
||||
'license' => 'cli/license_manage.php',
|
||||
|
||||
// Shell completion
|
||||
'completion' => 'cli/completion.php',
|
||||
|
||||
// Module validation
|
||||
'validate:module' => 'bin/validate-module',
|
||||
@@ -197,16 +251,28 @@ if ($command === 'list' || $command === 'commands') {
|
||||
|
||||
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!array_key_exists($command, COMMAND_MAP)) {
|
||||
$scriptRelative = null;
|
||||
|
||||
if (array_key_exists($command, COMMAND_MAP)) {
|
||||
$scriptRelative = COMMAND_MAP[$command];
|
||||
} else {
|
||||
// Fall back to plugin-provided commands before giving up.
|
||||
$pluginCommands = loadPluginCommands();
|
||||
if (isset($pluginCommands[$command]) && !empty($pluginCommands[$command]['script'])) {
|
||||
$scriptRelative = $pluginCommands[$command]['script'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($scriptRelative === null) {
|
||||
fwrite(STDERR, "Error: Unknown command '{$command}'\n\n");
|
||||
printCommandList();
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$scriptPath = $repoRoot . '/' . COMMAND_MAP[$command];
|
||||
$scriptPath = $repoRoot . '/' . $scriptRelative;
|
||||
|
||||
if (!is_file($scriptPath)) {
|
||||
fwrite(STDERR, "Error: Script not found: " . COMMAND_MAP[$command] . "\n");
|
||||
fwrite(STDERR, "Error: Script not found: {$scriptRelative}\n");
|
||||
fwrite(STDERR, "Ensure the repository is complete and run: composer install\n");
|
||||
exit(2);
|
||||
}
|
||||
@@ -268,6 +334,12 @@ function printCommandList(): void
|
||||
'bulk' => 'Bulk Operations',
|
||||
'client' => 'Client Management',
|
||||
'validate' => 'Module Validation',
|
||||
'deploy' => 'Deploy',
|
||||
'fix' => 'Fix / Auto-remediation',
|
||||
'maintenance' => 'Maintenance',
|
||||
'automation' => 'Automation',
|
||||
'badge' => 'Badges',
|
||||
'wiki' => 'Wiki',
|
||||
default => ucfirst($prefix),
|
||||
};
|
||||
} else {
|
||||
@@ -277,6 +349,8 @@ function printCommandList(): void
|
||||
'health' => 'Validation',
|
||||
'detect', 'drift' => 'Validation',
|
||||
'dashboard', 'grafana' => 'Monitoring',
|
||||
'release' => 'Release',
|
||||
'license' => 'Licensing',
|
||||
default => 'Other',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Build Index: /api/build
|
||||
|
||||
## Purpose
|
||||
|
||||
This folder contains build system management and compilation scripts.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [README](./README.md) - Build scripts documentation
|
||||
|
||||
## Scripts
|
||||
|
||||
- [moko-make](./moko-make) - Build system wrapper
|
||||
- [resolve_makefile.py](./resolve_makefile.py) - Makefile resolution
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Document Type:** index
|
||||
- **Auto-generated:** This file is manually maintained for ignored directory
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Moko Build Wrapper
|
||||
# Automatically finds and uses appropriate Makefile from MokoStandards
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
COLOR_RESET="\033[0m"
|
||||
COLOR_GREEN="\033[32m"
|
||||
COLOR_BLUE="\033[34m"
|
||||
COLOR_RED="\033[31m"
|
||||
|
||||
# Find MokoStandards root
|
||||
find_mokostandards() {
|
||||
# Check environment variable
|
||||
if [ -n "$MOKOSTANDARDS_ROOT" ] && [ -d "$MOKOSTANDARDS_ROOT/templates/build" ]; then
|
||||
echo "$MOKOSTANDARDS_ROOT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check adjacent directories
|
||||
if [ -d "../MokoStandards/templates/build" ]; then
|
||||
echo "$(cd ../MokoStandards && pwd)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "../../MokoStandards/templates/build" ]; then
|
||||
echo "$(cd ../../MokoStandards && pwd)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check home directory
|
||||
if [ -d "$HOME/.mokostandards/templates/build" ]; then
|
||||
echo "$HOME/.mokostandards"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check system location
|
||||
if [ -d "/opt/mokostandards/templates/build" ]; then
|
||||
echo "/opt/mokostandards"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find appropriate Makefile
|
||||
find_makefile() {
|
||||
# Check for local Makefile
|
||||
if [ -f "Makefile" ]; then
|
||||
echo "Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for .moko/Makefile
|
||||
if [ -f ".moko/Makefile" ]; then
|
||||
echo ".moko/Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find MokoStandards
|
||||
MOKO_ROOT=$(find_mokostandards)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${COLOR_RED}✗${COLOR_RESET} MokoStandards repository not found" >&2
|
||||
echo -e "${COLOR_BLUE}Hint:${COLOR_RESET} Set MOKOSTANDARDS_ROOT or clone adjacent" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Detect project type
|
||||
if [ -d "core/modules" ] && ls core/modules/mod*.class.php >/dev/null 2>&1; then
|
||||
echo "$MOKO_ROOT/templates/build/dolibarr/Makefile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Joomla XML files
|
||||
shopt -s nullglob # Prevent glob expansion if no matches
|
||||
for xml in *.xml; do
|
||||
if [ -f "$xml" ]; then
|
||||
if grep -q 'type="component"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.component"
|
||||
return 0
|
||||
elif grep -q 'type="module"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.module"
|
||||
return 0
|
||||
elif grep -q 'type="plugin"' "$xml" 2>/dev/null; then
|
||||
echo "$MOKO_ROOT/templates/build/joomla/Makefile.plugin"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
echo -e "${COLOR_RED}✗${COLOR_RESET} Could not detect project type" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main execution
|
||||
MAKEFILE=$(find_makefile)
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show which Makefile we're using
|
||||
if [[ "$MAKEFILE" == *"MokoStandards"* ]] || [[ "$MAKEFILE" == *".mokostandards"* ]]; then
|
||||
echo -e "${COLOR_BLUE}ℹ${COLOR_RESET} Using MokoStandards template"
|
||||
fi
|
||||
|
||||
# Run make with the found Makefile
|
||||
exec make -f "$MAKEFILE" "$@"
|
||||
+142
-118
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -12,134 +13,157 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/archive_repo.php
|
||||
* BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def
|
||||
*
|
||||
* USAGE
|
||||
* php cli/archive_repo.php --repo MokoOldModule
|
||||
* php cli/archive_repo.php --repo MokoOldModule --dry-run
|
||||
* php cli/archive_repo.php --repo MokoOldModule --skip-close # Archive only, keep issues open
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\Config;
|
||||
use MokoEnterprise\PlatformAdapterFactory;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$skipClose = in_array('--skip-close', $argv);
|
||||
class ArchiveRepoCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Gracefully retire a governed repository — archive, close issues/PRs, remove sync def');
|
||||
$this->addArgument('--repo', 'Repository name to archive', '');
|
||||
$this->addArgument('--skip-close', 'Archive only, keep issues open', false);
|
||||
}
|
||||
|
||||
$repoName = null;
|
||||
protected function run(): int
|
||||
{
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$skipClose = $this->getArgument('--skip-close');
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
|
||||
if (empty($repoName)) {
|
||||
$this->log('ERROR', 'Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]');
|
||||
return 2;
|
||||
}
|
||||
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString(
|
||||
$adapter->getPlatformName() . '.organization',
|
||||
'mokoconsulting-tech'
|
||||
);
|
||||
|
||||
$platformName = $adapter->getPlatformName();
|
||||
|
||||
echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n";
|
||||
|
||||
// -- Step 1: Verify repo exists --
|
||||
echo "Step 1: Verifying repository...\n";
|
||||
try {
|
||||
$repoData = $adapter->getRepo($org, $repoName);
|
||||
} catch (\Exception $e) {
|
||||
$this->log('ERROR', "Repository {$org}/{$repoName} not found: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
if ($repoData['archived'] ?? false) {
|
||||
echo " Already archived — nothing to do\n";
|
||||
return 0;
|
||||
}
|
||||
echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n";
|
||||
|
||||
// -- Step 2: Close all open PRs --
|
||||
if (!$skipClose) {
|
||||
echo "Step 2: Closing open pull requests...\n";
|
||||
$prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']);
|
||||
$prCount = count($prs);
|
||||
echo " Found {$prCount} open PRs\n";
|
||||
|
||||
foreach ($prs as $pr) {
|
||||
$num = $pr['number'];
|
||||
if (!$this->dryRun) {
|
||||
$adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']);
|
||||
$adapter->addIssueComment(
|
||||
$org,
|
||||
$repoName,
|
||||
$num,
|
||||
"Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*"
|
||||
);
|
||||
}
|
||||
echo " Closed PR #{$num}: {$pr['title']}\n";
|
||||
}
|
||||
|
||||
// -- Step 3: Close all open issues --
|
||||
echo "Step 3: Closing open issues...\n";
|
||||
$issues = $adapter->listIssues($org, $repoName, ['state' => 'open']);
|
||||
$issues = array_filter($issues, fn($i) => !isset($i['pull_request']));
|
||||
$issueCount = count($issues);
|
||||
echo " Found {$issueCount} open issues\n";
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$num = $issue['number'];
|
||||
if (!$this->dryRun) {
|
||||
$adapter->closeIssue($org, $repoName, $num);
|
||||
$adapter->addIssueComment(
|
||||
$org,
|
||||
$repoName,
|
||||
$num,
|
||||
"Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*"
|
||||
);
|
||||
}
|
||||
echo " Closed issue #{$num}: {$issue['title']}\n";
|
||||
}
|
||||
} else {
|
||||
echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n";
|
||||
}
|
||||
|
||||
// -- Step 4: Archive the repository --
|
||||
echo "Step 4: Archiving repository...\n";
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$adapter->archiveRepo($org, $repoName);
|
||||
echo " Repository archived\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Failed to archive: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would archive {$org}/{$repoName}\n";
|
||||
}
|
||||
|
||||
// -- Step 5: (removed — sync definitions no longer used) --
|
||||
|
||||
// -- Step 6: Create archival record --
|
||||
echo "Step 6: Creating archival record...\n";
|
||||
if (!$this->dryRun) {
|
||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
try {
|
||||
$issue = $adapter->createIssue(
|
||||
$org,
|
||||
'moko-platform',
|
||||
"chore: archived repository {$repoName}",
|
||||
"## Repository Archived\n\n"
|
||||
. "**Repository:** `{$org}/{$repoName}`\n"
|
||||
. "**Archived:** {$now}\n"
|
||||
. "**Platform:** {$platformName}\n"
|
||||
. "**Sync definition removed:** yes\n\n"
|
||||
. "---\n"
|
||||
. "*Auto-created by `archive_repo.php`*\n",
|
||||
[
|
||||
'labels' => ['type: chore', 'automation', 'archived'],
|
||||
'assignees' => ['jmiller'],
|
||||
]
|
||||
);
|
||||
if (isset($issue['number'])) {
|
||||
echo " Archival record: moko-platform#{$issue['number']}\n";
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create archival record issue\n";
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat('-', 50) . "\n";
|
||||
echo "Repository {$org}/{$repoName} archived successfully\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$repoName) {
|
||||
fwrite(STDERR, "Usage: php archive_repo.php --repo <RepoName> [--skip-close] [--dry-run]\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString(
|
||||
$adapter->getPlatformName() . '.organization',
|
||||
'mokoconsulting-tech'
|
||||
);
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$platformName = $adapter->getPlatformName();
|
||||
|
||||
echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n";
|
||||
|
||||
// ── Step 1: Verify repo exists ──────────────────────────────────────────
|
||||
echo "Step 1: Verifying repository...\n";
|
||||
try {
|
||||
$repoData = $adapter->getRepo($org, $repoName);
|
||||
} catch (\Exception $e) {
|
||||
fwrite(STDERR, " Repository {$org}/{$repoName} not found: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
if ($repoData['archived'] ?? false) {
|
||||
echo " Already archived — nothing to do\n";
|
||||
exit(0);
|
||||
}
|
||||
echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n";
|
||||
|
||||
// ── Step 2: Close all open PRs ──────────────────────────────────────────
|
||||
if (!$skipClose) {
|
||||
echo "Step 2: Closing open pull requests...\n";
|
||||
$prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']);
|
||||
$prCount = count($prs);
|
||||
echo " Found {$prCount} open PRs\n";
|
||||
|
||||
foreach ($prs as $pr) {
|
||||
$num = $pr['number'];
|
||||
if (!$dryRun) {
|
||||
$adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']);
|
||||
$adapter->addIssueComment($org, $repoName, $num,
|
||||
"Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*"
|
||||
);
|
||||
}
|
||||
echo " Closed PR #{$num}: {$pr['title']}\n";
|
||||
}
|
||||
|
||||
// ── Step 3: Close all open issues ───────────────────────────────────
|
||||
echo "Step 3: Closing open issues...\n";
|
||||
$issues = $adapter->listIssues($org, $repoName, ['state' => 'open']);
|
||||
$issues = array_filter($issues, fn($i) => !isset($i['pull_request']));
|
||||
$issueCount = count($issues);
|
||||
echo " Found {$issueCount} open issues\n";
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$num = $issue['number'];
|
||||
if (!$dryRun) {
|
||||
$adapter->closeIssue($org, $repoName, $num);
|
||||
$adapter->addIssueComment($org, $repoName, $num,
|
||||
"Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*"
|
||||
);
|
||||
}
|
||||
echo " Closed issue #{$num}: {$issue['title']}\n";
|
||||
}
|
||||
} else {
|
||||
echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n";
|
||||
}
|
||||
|
||||
// ── Step 4: Archive the repository ──────────────────────────────────────
|
||||
echo "Step 4: Archiving repository...\n";
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
$adapter->archiveRepo($org, $repoName);
|
||||
echo " Repository archived\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Failed to archive: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would archive {$org}/{$repoName}\n";
|
||||
}
|
||||
|
||||
// ── Step 5: (removed — sync definitions no longer used) ─────────────────
|
||||
|
||||
// ── Step 6: Create archival record ──────────────────────────────────────
|
||||
echo "Step 6: Creating archival record...\n";
|
||||
if (!$dryRun) {
|
||||
$now = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
try {
|
||||
$issue = $adapter->createIssue($org, 'MokoStandards',
|
||||
"chore: archived repository {$repoName}",
|
||||
"## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n",
|
||||
[
|
||||
'labels' => ['type: chore', 'automation', 'archived'],
|
||||
'assignees' => ['jmiller'],
|
||||
]
|
||||
);
|
||||
if (isset($issue['number'])) { echo " Archival record: MokoStandards#{$issue['number']}\n"; }
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: could not create archival record: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create archival record issue\n";
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat('-', 50) . "\n";
|
||||
echo "Repository {$org}/{$repoName} archived successfully\n";
|
||||
$app = new ArchiveRepoCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
#!/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
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoPlatform.Enterprise.CLI
|
||||
* INGROUP: MokoPlatform.Enterprise
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/audit_query.php
|
||||
* BRIEF: Search, filter, and export audit logs
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
/**
|
||||
* CLI tool to search, filter, and export audit logs.
|
||||
*
|
||||
* Reads JSONL audit log files from var/logs/audit/ and provides
|
||||
* filtering by service, user, event type, level, and date range.
|
||||
*
|
||||
* @since 09.01.00
|
||||
*/
|
||||
class AuditQueryCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Search, filter, and export audit logs');
|
||||
$this->addArgument('--path', 'Repository root (for var/logs/audit/)', '.');
|
||||
$this->addArgument('--log-dir', 'Custom log directory', '');
|
||||
$this->addArgument('--service', 'Filter by service name', '');
|
||||
$this->addArgument('--user', 'Filter by user', '');
|
||||
$this->addArgument('--event', 'Filter by event type', '');
|
||||
$this->addArgument('--level', 'Filter by log level (info/warning/error)', '');
|
||||
$this->addArgument('--since', 'Show entries since date (YYYY-MM-DD)', '');
|
||||
$this->addArgument('--until', 'Show entries until date (YYYY-MM-DD)', '');
|
||||
$this->addArgument('--limit', 'Max entries to show', '50');
|
||||
$this->addArgument('--format', 'Output format: table, json, jsonl', 'table');
|
||||
$this->addArgument('--tail', 'Show last N entries (like tail)', false);
|
||||
$this->addArgument('--stats', 'Show summary statistics instead of entries', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$logDir = $this->resolveLogDir();
|
||||
|
||||
if ($logDir === null) {
|
||||
return self::EXIT_NOT_FOUND;
|
||||
}
|
||||
|
||||
$files = $this->findLogFiles($logDir);
|
||||
|
||||
if (empty($files)) {
|
||||
$this->log('WARNING', 'No audit log files found in ' . $logDir);
|
||||
return self::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
$this->log('DEBUG', sprintf('Found %d log file(s) in %s', count($files), $logDir));
|
||||
|
||||
$entries = $this->loadEntries($files);
|
||||
$entries = $this->filterEntries($entries);
|
||||
|
||||
// Sort by timestamp descending (newest first).
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return ($b['timestamp'] ?? '') <=> ($a['timestamp'] ?? '');
|
||||
});
|
||||
|
||||
// Stats mode — show aggregated counts.
|
||||
if ($this->getArgument('--stats')) {
|
||||
return $this->showStats($entries);
|
||||
}
|
||||
|
||||
// Apply limit.
|
||||
$limit = (int) $this->getArgument('--limit', '50');
|
||||
if ($limit > 0 && count($entries) > $limit) {
|
||||
$entries = array_slice($entries, 0, $limit);
|
||||
}
|
||||
|
||||
if (empty($entries)) {
|
||||
$this->log('INFO', 'No entries match the given filters.');
|
||||
return self::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
return $this->outputEntries($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the audit log directory path.
|
||||
*
|
||||
* @return string|null Resolved directory path or null if not found.
|
||||
*/
|
||||
private function resolveLogDir(): ?string
|
||||
{
|
||||
$customDir = $this->getArgument('--log-dir');
|
||||
|
||||
if ($customDir !== '' && $customDir !== null) {
|
||||
$logDir = (string) $customDir;
|
||||
} else {
|
||||
$repoPath = (string) $this->getArgument('--path', '.');
|
||||
$logDir = rtrim($repoPath, '/\\') . '/var/logs/audit';
|
||||
}
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
$this->log('ERROR', 'Audit log directory not found: ' . $logDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $logDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find audit log files matching date range filter.
|
||||
*
|
||||
* @param string $logDir Path to audit log directory.
|
||||
* @return string[] Array of file paths sorted by name.
|
||||
*/
|
||||
private function findLogFiles(string $logDir): array
|
||||
{
|
||||
$pattern = $logDir . '/audit_*.jsonl';
|
||||
$allFiles = glob($pattern) ?: [];
|
||||
|
||||
$serviceFilter = (string) $this->getArgument('--service');
|
||||
$sinceDate = (string) $this->getArgument('--since');
|
||||
$untilDate = (string) $this->getArgument('--until');
|
||||
|
||||
$filtered = [];
|
||||
|
||||
foreach ($allFiles as $file) {
|
||||
$basename = basename($file);
|
||||
|
||||
// Parse service and date from filename: audit_<service>_<YYYYMMDD>.jsonl
|
||||
if (!preg_match('/^audit_(.+)_(\d{8})\.jsonl$/', $basename, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileService = $matches[1];
|
||||
$fileDate = $matches[2];
|
||||
|
||||
// Filter by service name from filename (efficient pre-filter).
|
||||
if ($serviceFilter !== '' && $fileService !== $serviceFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by date range from filename (efficient pre-filter).
|
||||
if ($sinceDate !== '') {
|
||||
$sinceCompact = str_replace('-', '', $sinceDate);
|
||||
if ($fileDate < $sinceCompact) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($untilDate !== '') {
|
||||
$untilCompact = str_replace('-', '', $untilDate);
|
||||
if ($fileDate > $untilCompact) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$filtered[] = $file;
|
||||
}
|
||||
|
||||
sort($filtered);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse JSONL entries from log files.
|
||||
*
|
||||
* @param string[] $files Array of file paths.
|
||||
* @return array<int, array<string, mixed>> Parsed entries.
|
||||
*/
|
||||
private function loadEntries(array $files): array
|
||||
{
|
||||
$entries = [];
|
||||
$lineCount = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$handle = fopen($file, 'r');
|
||||
if ($handle === false) {
|
||||
$this->log('WARNING', 'Cannot open file: ' . $file);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = json_decode($line, true);
|
||||
if (!is_array($entry)) {
|
||||
$lineCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $entry;
|
||||
$lineCount++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
$this->log('DEBUG', sprintf('Parsed %d entries from %d lines', count($entries), $lineCount));
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply user/event/level/date filters to entries.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Raw entries.
|
||||
* @return array<int, array<string, mixed>> Filtered entries.
|
||||
*/
|
||||
private function filterEntries(array $entries): array
|
||||
{
|
||||
$userFilter = (string) $this->getArgument('--user');
|
||||
$eventFilter = (string) $this->getArgument('--event');
|
||||
$levelFilter = (string) $this->getArgument('--level');
|
||||
$serviceFilter = (string) $this->getArgument('--service');
|
||||
$sinceDate = (string) $this->getArgument('--since');
|
||||
$untilDate = (string) $this->getArgument('--until');
|
||||
|
||||
$filtered = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
// Filter by service (in case filename pre-filter was not exact).
|
||||
if ($serviceFilter !== '' && ($entry['service'] ?? '') !== $serviceFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by user.
|
||||
if ($userFilter !== '' && ($entry['user'] ?? '') !== $userFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by event type (matches event_type or event_subtype).
|
||||
if ($eventFilter !== '') {
|
||||
$eventType = $entry['event_type'] ?? '';
|
||||
$eventSubtype = $entry['event_subtype'] ?? '';
|
||||
if ($eventType !== $eventFilter && $eventSubtype !== $eventFilter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by level.
|
||||
if ($levelFilter !== '' && ($entry['level'] ?? '') !== $levelFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by timestamp (precise, within-file filtering).
|
||||
$timestamp = $entry['timestamp'] ?? '';
|
||||
if ($timestamp !== '' && $sinceDate !== '') {
|
||||
$entryDate = substr($timestamp, 0, 10); // YYYY-MM-DD from ISO 8601
|
||||
if ($entryDate < $sinceDate) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($timestamp !== '' && $untilDate !== '') {
|
||||
$entryDate = substr($timestamp, 0, 10);
|
||||
if ($entryDate > $untilDate) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$filtered[] = $entry;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output entries in the requested format.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Filtered entries.
|
||||
* @return int Exit code.
|
||||
*/
|
||||
private function outputEntries(array $entries): int
|
||||
{
|
||||
$format = (string) $this->getArgument('--format', 'table');
|
||||
|
||||
$this->section('Audit Log Results');
|
||||
$this->log('INFO', sprintf('Showing %d entries', count($entries)));
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
echo json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
break;
|
||||
|
||||
case 'jsonl':
|
||||
foreach ($entries as $entry) {
|
||||
echo json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'table':
|
||||
default:
|
||||
$this->renderTable($entries);
|
||||
break;
|
||||
}
|
||||
|
||||
return self::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render entries as a formatted table.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Entries to display.
|
||||
*/
|
||||
private function renderTable(array $entries): void
|
||||
{
|
||||
$headers = ['Time', 'Service', 'User', 'Event', 'Message'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$timestamp = $entry['timestamp'] ?? '';
|
||||
// Shorten timestamp to YYYY-MM-DD HH:MM:SS.
|
||||
if (strlen($timestamp) >= 19) {
|
||||
$time = substr($timestamp, 0, 19);
|
||||
$time = str_replace('T', ' ', $time);
|
||||
} else {
|
||||
$time = $timestamp;
|
||||
}
|
||||
|
||||
$service = $entry['service'] ?? '';
|
||||
$user = $entry['user'] ?? '';
|
||||
|
||||
// Build event string from event_type + event_subtype.
|
||||
$eventParts = [];
|
||||
if (!empty($entry['event_type'])) {
|
||||
$eventParts[] = $entry['event_type'];
|
||||
}
|
||||
if (!empty($entry['event_subtype'])) {
|
||||
$eventParts[] = $entry['event_subtype'];
|
||||
}
|
||||
$event = implode('/', $eventParts);
|
||||
|
||||
// Build message from message field or data summary.
|
||||
$message = $entry['message'] ?? '';
|
||||
if ($message === '' && !empty($entry['data']) && is_array($entry['data'])) {
|
||||
$dataParts = [];
|
||||
foreach ($entry['data'] as $key => $value) {
|
||||
if (is_scalar($value)) {
|
||||
$dataParts[] = "{$key}={$value}";
|
||||
}
|
||||
}
|
||||
$message = implode(', ', array_slice($dataParts, 0, 3));
|
||||
if (count($dataParts) > 3) {
|
||||
$message .= '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long messages.
|
||||
if (strlen($message) > 60) {
|
||||
$message = substr($message, 0, 57) . '...';
|
||||
}
|
||||
|
||||
$rows[] = [$time, $service, $user, $event, $message];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show aggregate statistics from filtered entries.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $entries Filtered entries.
|
||||
* @return int Exit code.
|
||||
*/
|
||||
private function showStats(array $entries): int
|
||||
{
|
||||
$this->section('Audit Log Statistics');
|
||||
|
||||
$total = count($entries);
|
||||
if ($total === 0) {
|
||||
$this->log('INFO', 'No entries match the given filters.');
|
||||
return self::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
// Aggregate counts.
|
||||
$byService = [];
|
||||
$byUser = [];
|
||||
$byEventType = [];
|
||||
$byLevel = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$service = $entry['service'] ?? 'unknown';
|
||||
$user = $entry['user'] ?? 'unknown';
|
||||
$eventType = $entry['event_type'] ?? 'unknown';
|
||||
$level = $entry['level'] ?? '-';
|
||||
|
||||
$byService[$service] = ($byService[$service] ?? 0) + 1;
|
||||
$byUser[$user] = ($byUser[$user] ?? 0) + 1;
|
||||
$byEventType[$eventType] = ($byEventType[$eventType] ?? 0) + 1;
|
||||
$byLevel[$level] = ($byLevel[$level] ?? 0) + 1;
|
||||
}
|
||||
|
||||
arsort($byService);
|
||||
arsort($byUser);
|
||||
arsort($byEventType);
|
||||
arsort($byLevel);
|
||||
|
||||
// Build summary rows.
|
||||
$rows = ['Total entries' => $total];
|
||||
|
||||
// Top services.
|
||||
$i = 0;
|
||||
foreach ($byService as $name => $count) {
|
||||
if ($i >= 5) {
|
||||
break;
|
||||
}
|
||||
$rows["Service: {$name}"] = $count;
|
||||
$i++;
|
||||
}
|
||||
|
||||
// Top users.
|
||||
$i = 0;
|
||||
foreach ($byUser as $name => $count) {
|
||||
if ($i >= 5) {
|
||||
break;
|
||||
}
|
||||
$rows["User: {$name}"] = $count;
|
||||
$i++;
|
||||
}
|
||||
|
||||
// Event types.
|
||||
foreach ($byEventType as $name => $count) {
|
||||
$rows["Event: {$name}"] = $count;
|
||||
}
|
||||
|
||||
// Levels.
|
||||
foreach ($byLevel as $name => $count) {
|
||||
$rows["Level: {$name}"] = $count;
|
||||
}
|
||||
|
||||
$this->printSummaryBox($rows);
|
||||
|
||||
return self::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new AuditQueryCli();
|
||||
exit($app->execute());
|
||||
+61
-49
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,59 +11,70 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/badge_update.php
|
||||
* BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files
|
||||
*
|
||||
* Usage:
|
||||
* php badge_update.php --path /repo --version 04.01.00
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class BadgeUpdateCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Update VERSION badges in all markdown files');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
|
||||
if (empty($version)) {
|
||||
$this->log('ERROR', 'Usage: badge_update.php --path . --version XX.YY.ZZ');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
|
||||
$replacement = "[VERSION: {$version}]";
|
||||
$updated = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/\.md$/i', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if (preg_match($pattern, $content)) {
|
||||
$newContent = preg_replace($pattern, $replacement, $content);
|
||||
if ($newContent !== $content) {
|
||||
if (!$this->dryRun) {
|
||||
file_put_contents($filePath, $newContent);
|
||||
}
|
||||
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
|
||||
$this->log('INFO', "Updated: {$relative}");
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->success("Updated {$updated} file(s) to {$replacement}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/';
|
||||
$replacement = "[VERSION: {$version}]";
|
||||
$updated = 0;
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Skip .git and vendor directories
|
||||
if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process markdown files
|
||||
if (!preg_match('/\.md$/i', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if (preg_match($pattern, $content)) {
|
||||
$newContent = preg_replace($pattern, $replacement, $content);
|
||||
if ($newContent !== $content) {
|
||||
file_put_contents($filePath, $newContent);
|
||||
$relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
|
||||
echo "Updated: {$relative}\n";
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} file(s) to {$replacement}\n";
|
||||
exit(0);
|
||||
$app = new BadgeUpdateCli();
|
||||
exit($app->execute());
|
||||
|
||||
+122
-112
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,130 +10,139 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/branch_rename.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
||||
*
|
||||
* Usage:
|
||||
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL [--pr 42]
|
||||
* php branch_rename.php --from dev --to rc --token TOKEN --api-base URL --pr 42 --dry-run
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$from = '';
|
||||
$to = '';
|
||||
$token = '';
|
||||
$apiBase = '';
|
||||
$prNum = '';
|
||||
$dryRun = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
|
||||
if ($arg === '--to' && isset($argv[$i + 1])) $to = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--pr' && isset($argv[$i + 1])) $prNum = $argv[$i + 1];
|
||||
if ($arg === '--dry-run') $dryRun = true;
|
||||
}
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
|
||||
fwrite(STDERR, "Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
echo "Source and target are the same ({$from}) — nothing to do\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Make an API request.
|
||||
*/
|
||||
function apiRequest(string $method, string $url, array $headers, ?array $body = null): array
|
||||
class BranchRenameCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Rename a git branch via Gitea API (create new, update PR, delete old)');
|
||||
$this->addArgument('--from', 'Source branch name', '');
|
||||
$this->addArgument('--to', 'Target branch name', '');
|
||||
$this->addArgument('--token', 'API token', '');
|
||||
$this->addArgument('--api-base', 'API base URL', '');
|
||||
$this->addArgument('--pr', 'PR number to update head branch', '');
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
protected function run(): int
|
||||
{
|
||||
$from = $this->getArgument('--from');
|
||||
$to = $this->getArgument('--to');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$prNum = $this->getArgument('--pr');
|
||||
|
||||
return [
|
||||
'code' => $httpCode,
|
||||
'body' => json_decode($response ?: '{}', true) ?: [],
|
||||
];
|
||||
}
|
||||
|
||||
// Step 1: Verify source branch exists
|
||||
echo "Checking source branch: {$from}\n";
|
||||
$check = apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
|
||||
if ($check['code'] !== 200) {
|
||||
fwrite(STDERR, "Source branch '{$from}' not found (HTTP {$check['code']})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Step 2: Delete target branch if it already exists
|
||||
$targetCheck = apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
|
||||
if ($targetCheck['code'] === 200) {
|
||||
echo "Target branch '{$to}' already exists — deleting\n";
|
||||
if (!$dryRun) {
|
||||
apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create new branch from source
|
||||
echo "Creating branch: {$to} (from {$from})\n";
|
||||
if (!$dryRun) {
|
||||
$create = apiRequest('POST', "{$apiBase}/branches", $headers, [
|
||||
'new_branch_name' => $to,
|
||||
'old_branch_name' => $from,
|
||||
]);
|
||||
if ($create['code'] < 200 || $create['code'] >= 300) {
|
||||
fwrite(STDERR, "Failed to create branch '{$to}': HTTP {$create['code']}\n");
|
||||
fwrite(STDERR, json_encode($create['body']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Update PR head branch if PR number provided
|
||||
if (!empty($prNum)) {
|
||||
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
|
||||
if (!$dryRun) {
|
||||
$update = apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
|
||||
'head' => $to,
|
||||
]);
|
||||
if ($update['code'] < 200 || $update['code'] >= 300) {
|
||||
fwrite(STDERR, "Warning: Could not update PR head branch (HTTP {$update['code']})\n");
|
||||
// Non-fatal — the PR may need manual update
|
||||
if (empty($from) || empty($to) || empty($token) || empty($apiBase)) {
|
||||
$this->log('ERROR', 'Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
echo "Source and target are the same ({$from}) — nothing to do\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
// Step 1: Verify source branch exists
|
||||
echo "Checking source branch: {$from}\n";
|
||||
$check = $this->apiRequest('GET', "{$apiBase}/branches/{$from}", $headers);
|
||||
if ($check['code'] !== 200) {
|
||||
$this->log('ERROR', "Source branch '{$from}' not found (HTTP {$check['code']})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2: Delete target branch if it already exists
|
||||
$targetCheck = $this->apiRequest('GET', "{$apiBase}/branches/{$to}", $headers);
|
||||
if ($targetCheck['code'] === 200) {
|
||||
echo "Target branch '{$to}' already exists — deleting\n";
|
||||
if (!$this->dryRun) {
|
||||
$this->apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create new branch from source
|
||||
echo "Creating branch: {$to} (from {$from})\n";
|
||||
if (!$this->dryRun) {
|
||||
$create = $this->apiRequest('POST', "{$apiBase}/branches", $headers, [
|
||||
'new_branch_name' => $to,
|
||||
'old_branch_name' => $from,
|
||||
]);
|
||||
if ($create['code'] < 200 || $create['code'] >= 300) {
|
||||
$this->log('ERROR', "Failed to create branch '{$to}': HTTP {$create['code']}");
|
||||
$this->log('ERROR', json_encode($create['body']));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Update PR head branch if PR number provided
|
||||
if (!empty($prNum)) {
|
||||
echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n";
|
||||
if (!$this->dryRun) {
|
||||
$update = $this->apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [
|
||||
'head' => $to,
|
||||
]);
|
||||
if ($update['code'] < 200 || $update['code'] >= 300) {
|
||||
$this->log('ERROR', "Warning: Could not update PR head branch (HTTP {$update['code']})");
|
||||
// Non-fatal — the PR may need manual update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Delete old source branch
|
||||
echo "Deleting old branch: {$from}\n";
|
||||
if (!$this->dryRun) {
|
||||
$delete = $this->apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
|
||||
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
|
||||
$this->log('ERROR', "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})");
|
||||
// Non-fatal — branch protection may prevent deletion
|
||||
}
|
||||
}
|
||||
|
||||
echo "Renamed: {$from} -> {$to}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request.
|
||||
*/
|
||||
private function apiRequest(string $method, string $url, array $headers, ?array $body = null): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'code' => $httpCode,
|
||||
'body' => json_decode($response ?: '{}', true) ?: [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Delete old source branch
|
||||
echo "Deleting old branch: {$from}\n";
|
||||
if (!$dryRun) {
|
||||
$delete = apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers);
|
||||
if ($delete['code'] !== 204 && $delete['code'] !== 200) {
|
||||
fwrite(STDERR, "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})\n");
|
||||
// Non-fatal — branch protection may prevent deletion
|
||||
}
|
||||
}
|
||||
|
||||
echo "Renamed: {$from} -> {$to}\n";
|
||||
exit(0);
|
||||
$app = new BranchRenameCli();
|
||||
exit($app->execute());
|
||||
|
||||
+87
-165
@@ -12,110 +12,125 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_push.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BulkWorkflowPush
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = '';
|
||||
private string $workflowFile = '';
|
||||
private string $destPath = '';
|
||||
private string $branch = 'main';
|
||||
private bool $dryRun = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class BulkWorkflowPushCli extends CliFramework
|
||||
{
|
||||
private int $updated = 0;
|
||||
private int $created = 0;
|
||||
private int $skipped = 0;
|
||||
private int $errors = 0;
|
||||
|
||||
public function run(): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->parseArgs();
|
||||
$this->setDescription('Push a workflow file to all governed repos via the Gitea Contents API');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--org', 'Target organization', '');
|
||||
$this->addArgument('--file', 'Local workflow file to push', '');
|
||||
$this->addArgument('--dest', 'Destination path in repos (default: .mokogitea/workflows/<filename>)', '');
|
||||
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
protected function run(): int
|
||||
{
|
||||
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$token = $this->getArgument('--token');
|
||||
$org = $this->getArgument('--org');
|
||||
$workflowFile = $this->getArgument('--file');
|
||||
$destPath = $this->getArgument('--dest');
|
||||
$branch = $this->getArgument('--branch');
|
||||
|
||||
if ($token === '') {
|
||||
$this->log('ERROR', '--token is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->workflowFile === '') {
|
||||
$this->log('ERROR: --file is required.');
|
||||
$this->printUsage();
|
||||
if ($workflowFile === '') {
|
||||
$this->log('ERROR', '--file is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->workflowFile)) {
|
||||
$this->log("ERROR: File not found: {$this->workflowFile}");
|
||||
if (!file_exists($workflowFile)) {
|
||||
$this->log('ERROR', "File not found: {$workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->org === '') {
|
||||
$this->log('ERROR: --org is required.');
|
||||
$this->printUsage();
|
||||
if ($org === '') {
|
||||
$this->log('ERROR', '--org is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->destPath === '') {
|
||||
$this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile);
|
||||
if ($destPath === '') {
|
||||
$destPath = '.mokogitea/workflows/' . basename($workflowFile);
|
||||
}
|
||||
|
||||
$localContent = file_get_contents($this->workflowFile);
|
||||
$localContent = file_get_contents($workflowFile);
|
||||
|
||||
if ($localContent === false) {
|
||||
$this->log("ERROR: Could not read file: {$this->workflowFile}");
|
||||
$this->log('ERROR', "Could not read file: {$workflowFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Pushing: {$this->workflowFile}");
|
||||
$this->log(" -> {$this->destPath} (branch: {$this->branch})");
|
||||
$this->log(" -> Org: {$this->org} @ {$this->giteaUrl}");
|
||||
$this->log('INFO', "Pushing: {$workflowFile}");
|
||||
$this->log('INFO', " -> {$destPath} (branch: {$branch})");
|
||||
$this->log('INFO', " -> Org: {$org} @ {$giteaUrl}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('[DRY RUN] No changes will be made.');
|
||||
$this->log('INFO', '[DRY RUN] No changes will be made.');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
echo "\n";
|
||||
|
||||
$repos = $this->fetchOrgRepos();
|
||||
$repos = $this->fetchOrgRepos($giteaUrl, $token, $org);
|
||||
|
||||
if ($repos === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\".");
|
||||
$this->log('');
|
||||
$this->log(sprintf('%-45s | %s', 'Repo', 'Status'));
|
||||
$this->log(str_repeat('-', 70));
|
||||
$this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\".");
|
||||
echo "\n";
|
||||
fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status');
|
||||
fprintf(STDERR, "%s\n", str_repeat('-', 70));
|
||||
|
||||
$encodedContent = base64_encode($localContent);
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$this->pushToRepo($repo, $encodedContent, $localContent);
|
||||
$this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch);
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log("Done: {$this->created} created, {$this->updated} updated, "
|
||||
echo "\n";
|
||||
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
|
||||
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
||||
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function pushToRepo(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $repoFullName,
|
||||
string $encodedContent,
|
||||
string $localContent
|
||||
string $localContent,
|
||||
string $destPath,
|
||||
string $branch
|
||||
): void {
|
||||
[$owner, $repoName] = explode('/', $repoFullName, 2);
|
||||
|
||||
$existing = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. "{$this->destPath}?ref={$this->branch}"
|
||||
. "{$destPath}?ref={$branch}"
|
||||
);
|
||||
|
||||
if ($existing['code'] === 200) {
|
||||
@@ -124,21 +139,13 @@ final class BulkWorkflowPush
|
||||
$remoteContent = base64_decode($data['content'] ?? '');
|
||||
|
||||
if ($remoteContent === $localContent) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'IDENTICAL (skipped)'
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)');
|
||||
$this->skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD UPDATE'
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE');
|
||||
$this->updated++;
|
||||
return;
|
||||
}
|
||||
@@ -146,100 +153,82 @@ final class BulkWorkflowPush
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'sha' => $remoteSha,
|
||||
'message' => "chore: sync {$this->destPath} "
|
||||
'message' => "chore: sync {$destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'PUT',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
. $destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'UPDATED'
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED');
|
||||
$this->updated++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
} elseif ($existing['code'] === 404) {
|
||||
if ($this->dryRun) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'WOULD CREATE'
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE');
|
||||
$this->created++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'message' => "chore: add {$this->destPath} "
|
||||
'message' => "chore: add {$destPath} "
|
||||
. "from moko-platform [skip ci]",
|
||||
'branch' => $this->branch,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/contents/"
|
||||
. $this->destPath,
|
||||
. $destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 201) {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
'CREATED'
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED');
|
||||
$this->created++;
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$response['code']})"
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
} else {
|
||||
$this->log(sprintf(
|
||||
'%-45s | %s',
|
||||
$repoFullName,
|
||||
"ERROR (HTTP {$existing['code']})"
|
||||
));
|
||||
fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchOrgRepos(): ?array
|
||||
private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array
|
||||
{
|
||||
$this->log("Fetching repos from org: {$this->org}");
|
||||
$this->log('INFO', "Fetching repos from org: {$org}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
while (true) {
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/orgs/{$this->org}/repos?"
|
||||
"/api/v1/orgs/{$org}/repos?"
|
||||
. "limit=50&page={$page}"
|
||||
);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
if ($page === 1) {
|
||||
$this->log("ERROR: Could not fetch repos "
|
||||
$this->log('ERROR', "Could not fetch repos "
|
||||
. "(HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
@@ -271,76 +260,14 @@ final class BulkWorkflowPush
|
||||
return $repos;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->workflowFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dest':
|
||||
$this->destPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--branch':
|
||||
$this->branch = $args[++$i] ?? 'main';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log(
|
||||
'Usage: bulk_workflow_push.php '
|
||||
. '--token <token> --file <path> --org <org> [options]'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log(
|
||||
'Push a workflow file from moko-platform '
|
||||
. 'to all governed repos.'
|
||||
);
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL '
|
||||
. '(default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --org <org> Target organization');
|
||||
$this->log(' --file <path> Local workflow file to push');
|
||||
$this->log(' --dest <path> Destination path in repos '
|
||||
. '(default: .mokogitea/workflows/<filename>)');
|
||||
$this->log(' --branch <branch> Target branch (default: main)');
|
||||
$this->log(' --dry-run Show what would be done');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
$url = $giteaUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
@@ -349,7 +276,7 @@ final class BulkWorkflowPush
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
"Authorization: token {$token}",
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
@@ -376,12 +303,7 @@ final class BulkWorkflowPush
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new BulkWorkflowPush();
|
||||
exit($app->run());
|
||||
$app = new BulkWorkflowPushCli();
|
||||
exit($app->execute());
|
||||
|
||||
+175
-249
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -11,309 +12,234 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/bulk_workflow_trigger.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Trigger a workflow across multiple repos at once
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BulkWorkflowTrigger
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class BulkWorkflowTriggerCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $reposFile = '';
|
||||
private string $org = '';
|
||||
private string $workflow = '';
|
||||
private string $ref = 'main';
|
||||
private string $inputs = '';
|
||||
private bool $dryRun = false;
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $reposFile = '';
|
||||
private string $org = '';
|
||||
private string $workflow = '';
|
||||
private string $ref = 'main';
|
||||
private string $inputs = '';
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Trigger a workflow across multiple repos at once');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--repos', 'File with newline-separated owner/repo list', '');
|
||||
$this->addArgument('--org', 'Trigger on all repos in an org', '');
|
||||
$this->addArgument('--workflow', 'Workflow file (e.g., "sync-servers.yml")', '');
|
||||
$this->addArgument('--ref', 'Branch ref (default: "main")', 'main');
|
||||
$this->addArgument('--inputs', 'Workflow inputs as JSON string', '');
|
||||
}
|
||||
|
||||
if ($this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$this->token = $this->getArgument('--token');
|
||||
$this->reposFile = $this->getArgument('--repos');
|
||||
$this->org = $this->getArgument('--org');
|
||||
$this->workflow = $this->getArgument('--workflow');
|
||||
$this->ref = $this->getArgument('--ref');
|
||||
$this->inputs = $this->getArgument('--inputs');
|
||||
|
||||
if ($this->workflow === '')
|
||||
{
|
||||
$this->log('ERROR: --workflow is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR', '--token is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->reposFile === '' && $this->org === '')
|
||||
{
|
||||
$this->log('ERROR: Either --repos <file> or --org <org> is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
if ($this->workflow === '') {
|
||||
$this->log('ERROR', '--workflow is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Build repo list
|
||||
$repos = $this->buildRepoList();
|
||||
if ($this->reposFile === '' && $this->org === '') {
|
||||
$this->log('ERROR', 'Either --repos <file> or --org <org> is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($repos === null || count($repos) === 0)
|
||||
{
|
||||
$this->log('ERROR: No repos found to process.');
|
||||
return 1;
|
||||
}
|
||||
// Build repo list
|
||||
$repos = $this->buildRepoList();
|
||||
|
||||
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
|
||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
||||
if ($repos === null || count($repos) === 0) {
|
||||
$this->log('ERROR', 'No repos found to process.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log('[DRY RUN] No requests will be sent.');
|
||||
}
|
||||
$this->log('INFO', "Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
|
||||
$this->log('INFO', "Gitea URL: {$this->giteaUrl}");
|
||||
|
||||
$this->log('');
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', '[DRY RUN] No requests will be sent.');
|
||||
}
|
||||
|
||||
// Parse inputs
|
||||
$inputsDecoded = null;
|
||||
$this->log('INFO', '');
|
||||
|
||||
if ($this->inputs !== '')
|
||||
{
|
||||
$inputsDecoded = json_decode($this->inputs, true);
|
||||
// Parse inputs
|
||||
$inputsDecoded = null;
|
||||
|
||||
if (!is_array($inputsDecoded))
|
||||
{
|
||||
$this->log('ERROR: --inputs must be valid JSON.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if ($this->inputs !== '') {
|
||||
$inputsDecoded = json_decode($this->inputs, true);
|
||||
|
||||
// Print header
|
||||
$this->log(sprintf('%-40s | %s', 'Repo', 'Status'));
|
||||
$this->log(str_repeat('-', 60));
|
||||
if (!is_array($inputsDecoded)) {
|
||||
$this->log('ERROR', '--inputs must be valid JSON.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$failCount = 0;
|
||||
// Print header
|
||||
$this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status'));
|
||||
$this->log('INFO', str_repeat('-', 60));
|
||||
|
||||
foreach ($repos as $repo)
|
||||
{
|
||||
$repo = trim($repo);
|
||||
$failCount = 0;
|
||||
|
||||
if ($repo === '' || strpos($repo, '/') === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach ($repos as $repo) {
|
||||
$repo = trim($repo);
|
||||
|
||||
[$owner, $repoName] = explode('/', $repo, 2);
|
||||
if ($repo === '' || strpos($repo, '/') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
|
||||
continue;
|
||||
}
|
||||
[$owner, $repoName] = explode('/', $repo, 2);
|
||||
|
||||
$payload = ['ref' => $this->ref];
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inputsDecoded !== null)
|
||||
{
|
||||
$payload['inputs'] = $inputsDecoded;
|
||||
}
|
||||
$payload = ['ref' => $this->ref];
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
|
||||
json_encode($payload)
|
||||
);
|
||||
if ($inputsDecoded !== null) {
|
||||
$payload['inputs'] = $inputsDecoded;
|
||||
}
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$status = 'TRIGGERED';
|
||||
}
|
||||
elseif ($response['code'] === 404)
|
||||
{
|
||||
$status = 'FAILED (not found)';
|
||||
$failCount++;
|
||||
}
|
||||
elseif ($response['code'] === 422)
|
||||
{
|
||||
$status = 'SKIPPED (unprocessable)';
|
||||
}
|
||||
else
|
||||
{
|
||||
$status = "FAILED (HTTP {$response['code']})";
|
||||
$failCount++;
|
||||
}
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
|
||||
json_encode($payload)
|
||||
);
|
||||
|
||||
$this->log(sprintf('%-40s | %s', $repo, $status));
|
||||
}
|
||||
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||
$status = 'TRIGGERED';
|
||||
} elseif ($response['code'] === 404) {
|
||||
$status = 'FAILED (not found)';
|
||||
$failCount++;
|
||||
} elseif ($response['code'] === 422) {
|
||||
$status = 'SKIPPED (unprocessable)';
|
||||
} else {
|
||||
$status = "FAILED (HTTP {$response['code']})";
|
||||
$failCount++;
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
|
||||
$this->log('INFO', sprintf('%-40s | %s', $repo, $status));
|
||||
}
|
||||
|
||||
return $failCount > 0 ? 1 : 0;
|
||||
}
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
return $failCount > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
for ($i = 1; $i < $count; $i++)
|
||||
{
|
||||
switch ($args[$i])
|
||||
{
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--repos':
|
||||
$this->reposFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--workflow':
|
||||
$this->workflow = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--ref':
|
||||
$this->ref = $args[++$i] ?? 'main';
|
||||
break;
|
||||
case '--inputs':
|
||||
$this->inputs = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private function buildRepoList(): ?array
|
||||
{
|
||||
if ($this->reposFile !== '') {
|
||||
if (!file_exists($this->reposFile)) {
|
||||
$this->log('ERROR', "Repos file not found: {$this->reposFile}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --repos <file> File with newline-separated owner/repo list');
|
||||
$this->log(' --org <org> Trigger on all repos in an org');
|
||||
$this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")');
|
||||
$this->log(' --ref <branch> Branch ref (default: "main")');
|
||||
$this->log(' --inputs <json> Workflow inputs as JSON string');
|
||||
$this->log(' --dry-run Show what would be done without triggering');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
$content = file_get_contents($this->reposFile);
|
||||
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
|
||||
return $line !== '' && $line[0] !== '#';
|
||||
});
|
||||
|
||||
private function buildRepoList(): ?array
|
||||
{
|
||||
if ($this->reposFile !== '')
|
||||
{
|
||||
if (!file_exists($this->reposFile))
|
||||
{
|
||||
$this->log("ERROR: Repos file not found: {$this->reposFile}");
|
||||
return null;
|
||||
}
|
||||
return array_values($lines);
|
||||
}
|
||||
|
||||
$content = file_get_contents($this->reposFile);
|
||||
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
|
||||
return $line !== '' && $line[0] !== '#';
|
||||
});
|
||||
// Fetch all repos from org
|
||||
$this->log('INFO', "Fetching repos from org: {$this->org}");
|
||||
|
||||
return array_values($lines);
|
||||
}
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
// Fetch all repos from org
|
||||
$this->log("Fetching repos from org: {$this->org}");
|
||||
while (true) {
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
if ($page === 1) {
|
||||
$this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
|
||||
break;
|
||||
}
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
if ($page === 1)
|
||||
{
|
||||
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
break;
|
||||
}
|
||||
if (!is_array($data) || count($data) === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
foreach ($data as $repo) {
|
||||
$fullName = $repo['full_name'] ?? '';
|
||||
|
||||
if (!is_array($data) || count($data) === 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if ($fullName !== '') {
|
||||
$repos[] = $fullName;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data as $repo)
|
||||
{
|
||||
$fullName = $repo['full_name'] ?? '';
|
||||
$page++;
|
||||
}
|
||||
|
||||
if ($fullName !== '')
|
||||
{
|
||||
$repos[] = $fullName;
|
||||
}
|
||||
}
|
||||
$this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
|
||||
|
||||
$page++;
|
||||
}
|
||||
return $repos;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
|
||||
return $repos;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if ($body !== null)
|
||||
{
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
if (curl_errno($ch))
|
||||
{
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
}
|
||||
|
||||
$app = new BulkWorkflowTrigger();
|
||||
exit($app->run());
|
||||
$app = new BulkWorkflowTriggerCli();
|
||||
exit($app->execute());
|
||||
|
||||
+74
-63
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,73 +11,83 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/changelog_promote.php
|
||||
* BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry
|
||||
*
|
||||
* Usage:
|
||||
* php changelog_promote.php --path /repo --version 04.01.00
|
||||
* php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$date = date('Y-m-d');
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1];
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ChangelogPromoteCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Promote [Unreleased] CHANGELOG section to a versioned entry');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
|
||||
$this->addArgument('--date', 'Release date YYYY-MM-DD', date('Y-m-d'));
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$date = $this->getArgument('--date');
|
||||
|
||||
if (empty($version)) {
|
||||
$this->log('ERROR', 'Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$content = file_get_contents($changelog);
|
||||
|
||||
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
|
||||
$this->log('ERROR', 'No [Unreleased] section found in CHANGELOG.md');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Replace [Unreleased] with versioned entry
|
||||
$content = preg_replace(
|
||||
'/## \[Unreleased\]/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
$content = preg_replace(
|
||||
'/## Unreleased/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
// Insert new [Unreleased] section after the first heading line
|
||||
$lines = explode("\n", $content);
|
||||
$inserted = false;
|
||||
$result = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$result[] = $line;
|
||||
if (!$inserted && preg_match('/^# /', $line)) {
|
||||
$result[] = '';
|
||||
$result[] = '## [Unreleased]';
|
||||
$result[] = '';
|
||||
$inserted = true;
|
||||
}
|
||||
}
|
||||
|
||||
$content = implode("\n", $result);
|
||||
file_put_contents($changelog, $content);
|
||||
$this->success("CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$content = file_get_contents($changelog);
|
||||
|
||||
// Check if [Unreleased] section exists
|
||||
if (!preg_match('/## \[?Unreleased\]?/i', $content)) {
|
||||
fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Replace [Unreleased] with versioned entry
|
||||
$content = preg_replace(
|
||||
'/## \[Unreleased\]/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
$content = preg_replace(
|
||||
'/## Unreleased/i',
|
||||
"## [{$version}] --- {$date}",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
|
||||
// Insert new [Unreleased] section after the first heading line (# Changelog)
|
||||
$lines = explode("\n", $content);
|
||||
$inserted = false;
|
||||
$result = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$result[] = $line;
|
||||
if (!$inserted && preg_match('/^# /', $line)) {
|
||||
$result[] = '';
|
||||
$result[] = '## [Unreleased]';
|
||||
$result[] = '';
|
||||
$inserted = true;
|
||||
}
|
||||
}
|
||||
|
||||
$content = implode("\n", $result);
|
||||
file_put_contents($changelog, $content);
|
||||
echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n";
|
||||
exit(0);
|
||||
$app = new ChangelogPromoteCli();
|
||||
exit($app->execute());
|
||||
|
||||
+104
-103
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,125 +11,125 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/changelog_prune.php
|
||||
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
|
||||
*
|
||||
* Usage:
|
||||
* php changelog_prune.php --path /repo --keep 5
|
||||
* php changelog_prune.php --path /repo --keep 3 --dry-run
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$keep = 5;
|
||||
$dryRun = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1];
|
||||
if ($arg === '--dry-run') $dryRun = true;
|
||||
if ($arg === '--help') {
|
||||
echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n";
|
||||
echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n";
|
||||
echo "Options:\n";
|
||||
echo " --path Repository path (default: .)\n";
|
||||
echo " --keep Number of versioned releases to keep (default: 5)\n";
|
||||
echo " --dry-run Preview without writing\n";
|
||||
exit(0);
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ChangelogPruneCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases');
|
||||
$this->addArgument('--path', 'Repository path', '.');
|
||||
$this->addArgument('--keep', 'Number of versioned releases to keep', '5');
|
||||
}
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
fwrite(STDERR, "No CHANGELOG.md found at {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$keep = (int) $this->getArgument('--keep');
|
||||
|
||||
$content = file_get_contents($changelog);
|
||||
$lines = explode("\n", $content);
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
$this->log('ERROR', "No CHANGELOG.md found at {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Split into sections by ## headings
|
||||
$sections = [];
|
||||
$current = [];
|
||||
$currentHeading = null;
|
||||
$content = file_get_contents($changelog);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^## /', $line)) {
|
||||
// Split into sections by ## headings
|
||||
$sections = [];
|
||||
$current = [];
|
||||
$currentHeading = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^## /', $line)) {
|
||||
if ($currentHeading !== null) {
|
||||
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
|
||||
}
|
||||
$currentHeading = $line;
|
||||
$current = [$line];
|
||||
} else {
|
||||
$current[] = $line;
|
||||
}
|
||||
}
|
||||
if ($currentHeading !== null) {
|
||||
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
|
||||
}
|
||||
$currentHeading = $line;
|
||||
$current = [$line];
|
||||
} else {
|
||||
$current[] = $line;
|
||||
}
|
||||
}
|
||||
if ($currentHeading !== null) {
|
||||
$sections[] = ['heading' => $currentHeading, 'lines' => $current];
|
||||
}
|
||||
|
||||
// Find the header (everything before the first ## section)
|
||||
$header = [];
|
||||
$contentLines = explode("\n", $content);
|
||||
foreach ($contentLines as $line) {
|
||||
if (preg_match('/^## /', $line)) {
|
||||
break;
|
||||
}
|
||||
$header[] = $line;
|
||||
}
|
||||
// Find the header (everything before the first ## section)
|
||||
$header = [];
|
||||
$contentLines = explode("\n", $content);
|
||||
foreach ($contentLines as $line) {
|
||||
if (preg_match('/^## /', $line)) {
|
||||
break;
|
||||
}
|
||||
$header[] = $line;
|
||||
}
|
||||
|
||||
// Separate [Unreleased] from versioned sections
|
||||
$unreleased = null;
|
||||
$versioned = [];
|
||||
// Separate [Unreleased] from versioned sections
|
||||
$unreleased = null;
|
||||
$versioned = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
|
||||
$unreleased = $section;
|
||||
} else {
|
||||
$versioned[] = $section;
|
||||
foreach ($sections as $section) {
|
||||
if (preg_match('/\[Unreleased\]/i', $section['heading'])) {
|
||||
$unreleased = $section;
|
||||
} else {
|
||||
$versioned[] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
$totalVersioned = count($versioned);
|
||||
$pruned = $totalVersioned - $keep;
|
||||
|
||||
if ($pruned <= 0) {
|
||||
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Keep only the first N versioned sections
|
||||
$keptVersioned = array_slice($versioned, 0, $keep);
|
||||
$droppedVersioned = array_slice($versioned, $keep);
|
||||
|
||||
// Report
|
||||
echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
|
||||
echo " Keeping: {$keep} most recent\n";
|
||||
echo " Pruning: {$pruned} old entries\n";
|
||||
|
||||
foreach ($droppedVersioned as $section) {
|
||||
$heading = trim($section['heading']);
|
||||
echo " - {$heading}\n";
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
echo "\n(dry-run) No changes written\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Rebuild the file
|
||||
$output = implode("\n", $header);
|
||||
|
||||
if ($unreleased !== null) {
|
||||
$output .= implode("\n", $unreleased['lines']) . "\n";
|
||||
}
|
||||
|
||||
foreach ($keptVersioned as $section) {
|
||||
$output .= implode("\n", $section['lines']) . "\n";
|
||||
}
|
||||
|
||||
// Clean up excessive blank lines at end
|
||||
$output = rtrim($output) . "\n";
|
||||
|
||||
file_put_contents($changelog, $output);
|
||||
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$totalVersioned = count($versioned);
|
||||
$pruned = $totalVersioned - $keep;
|
||||
|
||||
if ($pruned <= 0) {
|
||||
echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Keep only the first N versioned sections
|
||||
$keptVersioned = array_slice($versioned, 0, $keep);
|
||||
$droppedVersioned = array_slice($versioned, $keep);
|
||||
|
||||
// Report
|
||||
echo "CHANGELOG: {$totalVersioned} versioned entries found\n";
|
||||
echo " Keeping: {$keep} most recent\n";
|
||||
echo " Pruning: {$pruned} old entries\n";
|
||||
|
||||
foreach ($droppedVersioned as $section) {
|
||||
$heading = trim($section['heading']);
|
||||
echo " - {$heading}\n";
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
echo "\n(dry-run) No changes written\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Rebuild the file
|
||||
$output = implode("\n", $header);
|
||||
|
||||
if ($unreleased !== null) {
|
||||
$output .= implode("\n", $unreleased['lines']) . "\n";
|
||||
}
|
||||
|
||||
foreach ($keptVersioned as $section) {
|
||||
$output .= implode("\n", $section['lines']) . "\n";
|
||||
}
|
||||
|
||||
// Clean up excessive blank lines at end
|
||||
$output = rtrim($output) . "\n";
|
||||
|
||||
file_put_contents($changelog, $output);
|
||||
echo "\nCHANGELOG pruned: removed {$pruned} old entries\n";
|
||||
exit(0);
|
||||
$app = new ChangelogPruneCli();
|
||||
exit($app->execute());
|
||||
|
||||
+35
-78
@@ -12,13 +12,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_dashboard.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Generate unified client dashboard HTML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientDashboard
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ClientDashboardCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
@@ -29,29 +33,47 @@ final class ClientDashboard
|
||||
private int $sslWarnDays = 30;
|
||||
private int $httpTimeout = 10;
|
||||
|
||||
public function run(): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->parseArgs();
|
||||
$this->setDescription('Generate unified client dashboard HTML');
|
||||
$this->addArgument('--token', 'Gitea token (or MOKOGITEA_TOKEN)', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--org', 'Primary org (default: MokoConsulting)', 'MokoConsulting');
|
||||
$this->addArgument('--output', 'Output HTML file (default: stdout)', '');
|
||||
$this->addArgument('-o', 'Output HTML file (alias)', '');
|
||||
$this->addArgument('--no-ssl', 'Skip SSL checks', false);
|
||||
$this->addArgument('--no-uptime', 'Skip HTTP uptime checks', false);
|
||||
$this->addArgument('--ssl-warn-days', 'SSL warning days (default: 30)', '30');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$this->token = $this->getArgument('--token');
|
||||
$this->org = $this->getArgument('--org');
|
||||
$this->outputFile = $this->getArgument('--output') ?: $this->getArgument('-o');
|
||||
$this->checkSsl = !$this->getArgument('--no-ssl');
|
||||
$this->checkUptime = !$this->getArgument('--no-uptime');
|
||||
$this->sslWarnDays = (int) $this->getArgument('--ssl-warn-days');
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('MOKOGITEA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token or MOKOGITEA_TOKEN required.');
|
||||
$this->printUsage();
|
||||
$this->log('ERROR', '--token or MOKOGITEA_TOKEN required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Gathering client data...');
|
||||
$this->log('INFO', 'Gathering client data...');
|
||||
$clients = $this->discoverClients();
|
||||
|
||||
if ($clients === null) {
|
||||
$this->log('ERROR: Could not fetch client repos.');
|
||||
$this->log('ERROR', 'Could not fetch client repos.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('Found ' . count($clients) . ' client(s).');
|
||||
$this->log('INFO', 'Found ' . count($clients) . ' client(s).');
|
||||
|
||||
foreach ($clients as &$client) {
|
||||
$this->enrichClient($client);
|
||||
@@ -63,7 +85,7 @@ final class ClientDashboard
|
||||
|
||||
if ($this->outputFile !== '') {
|
||||
file_put_contents($this->outputFile, $html);
|
||||
$this->log("Dashboard: {$this->outputFile}");
|
||||
$this->log('INFO', "Dashboard: {$this->outputFile}");
|
||||
} else {
|
||||
fwrite(STDOUT, $html);
|
||||
}
|
||||
@@ -151,9 +173,8 @@ final class ClientDashboard
|
||||
private function enrichClient(array &$client): void
|
||||
{
|
||||
$repo = $client['repo'];
|
||||
$this->log(" Checking {$client['name']}...");
|
||||
$this->log('INFO', " Checking {$client['name']}...");
|
||||
|
||||
// Fetch variables
|
||||
$resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables");
|
||||
$vars = [];
|
||||
|
||||
@@ -185,7 +206,6 @@ final class ClientDashboard
|
||||
}
|
||||
}
|
||||
|
||||
// SSL
|
||||
$client['ssl_expiry'] = null;
|
||||
$client['ssl_days'] = null;
|
||||
$client['ssl_status'] = 'unknown';
|
||||
@@ -212,7 +232,6 @@ final class ClientDashboard
|
||||
}
|
||||
}
|
||||
|
||||
// Last release
|
||||
$client['last_release'] = '';
|
||||
$client['last_release_date'] = '';
|
||||
$relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1");
|
||||
@@ -461,69 +480,7 @@ CARD;
|
||||
curl_close($ch);
|
||||
return ['code' => $code, 'body' => $body];
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--output':
|
||||
case '-o':
|
||||
$this->outputFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-ssl':
|
||||
$this->checkSsl = false;
|
||||
break;
|
||||
case '--no-uptime':
|
||||
$this->checkUptime = false;
|
||||
break;
|
||||
case '--ssl-warn-days':
|
||||
$this->sslWarnDays = (int) ($args[++$i] ?? 30);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_dashboard.php --token TOKEN [options]');
|
||||
$this->log('');
|
||||
$this->log('Generate unified client status dashboard (HTML).');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --token <token> Gitea token (or MOKOGITEA_TOKEN)');
|
||||
$this->log(' --gitea-url <url> Gitea URL');
|
||||
$this->log(' --org <org> Primary org (default: MokoConsulting)');
|
||||
$this->log(' -o, --output <file> Output HTML file (default: stdout)');
|
||||
$this->log(' --no-ssl Skip SSL checks');
|
||||
$this->log(' --no-uptime Skip HTTP uptime checks');
|
||||
$this->log(' --ssl-warn-days <n> SSL warning days (default: 30)');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientDashboard();
|
||||
exit($app->run());
|
||||
$app = new ClientDashboardCli();
|
||||
exit($app->execute());
|
||||
|
||||
+179
-169
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,179 +11,188 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_health_check.php
|
||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
||||
*
|
||||
* Usage:
|
||||
* php client_health_check.php --update-url URL
|
||||
* php client_health_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads update server URL from manifest)
|
||||
* --update-url Update server XML URL (overrides manifest)
|
||||
* --site-url Live site URL for version checking via Joomla API (optional)
|
||||
* --api-token Joomla API token for site-url (optional)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$updateUrl = null;
|
||||
$siteUrl = null;
|
||||
$apiToken = null;
|
||||
$ghOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1];
|
||||
if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1];
|
||||
if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ClientHealthCheckCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Verify a client site\'s update server, installed version, and release availability');
|
||||
$this->addArgument('--path', 'Repository root (reads update server URL from manifest)', '.');
|
||||
$this->addArgument('--update-url', 'Update server XML URL (overrides manifest)', '');
|
||||
$this->addArgument('--site-url', 'Live site URL for version checking via Joomla API', '');
|
||||
$this->addArgument('--api-token', 'Joomla API token for site-url', '');
|
||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$updateUrl = $this->getArgument('--update-url');
|
||||
$siteUrl = $this->getArgument('--site-url');
|
||||
$apiToken = $this->getArgument('--api-token');
|
||||
$ghOutput = $this->getArgument('--github-output');
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$checks = [];
|
||||
|
||||
// -- Resolve update server URL from manifest --
|
||||
if ($updateUrl === '') {
|
||||
$updateUrl = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
||||
$updateUrl = trim($m[1]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updateUrl === null || $updateUrl === '') {
|
||||
$this->log('ERROR', 'No update server URL found. Use --update-url or provide a manifest with <updateservers>.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
echo "Update server: {$updateUrl}\n\n";
|
||||
|
||||
// -- Check 1: Update server accessible --
|
||||
echo "--- Update Server ---\n";
|
||||
$ch = curl_init($updateUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && !empty($response)) {
|
||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||
$checks['update_server'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$httpCode}\n";
|
||||
$checks['update_server'] = 'fail';
|
||||
}
|
||||
|
||||
// -- Check 2: Parse updates.xml for stable version --
|
||||
$stableVersion = null;
|
||||
$downloadUrl = null;
|
||||
|
||||
if (!empty($response)) {
|
||||
$sections = preg_split('/<update>/', $response);
|
||||
foreach ($sections as $section) {
|
||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||
$downloadUrl = trim($m[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Stable Release ---\n";
|
||||
if ($stableVersion !== null) {
|
||||
echo " Version: {$stableVersion}\n";
|
||||
$checks['stable_version'] = $stableVersion;
|
||||
} else {
|
||||
echo " FAIL: Could not parse stable version\n";
|
||||
$checks['stable_version'] = 'fail';
|
||||
}
|
||||
|
||||
// -- Check 3: Download URL accessible --
|
||||
if ($downloadUrl !== null) {
|
||||
echo "\n--- Download URL ---\n";
|
||||
$ch = curl_init($downloadUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
|
||||
if ($dlCode === 200) {
|
||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||
$checks['download'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$dlCode}\n";
|
||||
$checks['download'] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// -- Check 4: Site version (optional) --
|
||||
if ($siteUrl !== '' && $apiToken !== '') {
|
||||
echo "\n--- Site Version ---\n";
|
||||
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"X-Joomla-Token: {$apiToken}",
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$siteResponse = curl_exec($ch);
|
||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($siteCode === 200) {
|
||||
echo " API accessible (HTTP {$siteCode})\n";
|
||||
$checks['site_api'] = 'pass';
|
||||
} else {
|
||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
||||
$checks['site_api'] = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// -- Summary --
|
||||
echo "\n=== Health Check Summary ===\n";
|
||||
$failed = 0;
|
||||
foreach ($checks as $name => $result) {
|
||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||
if ($result === 'fail') {
|
||||
$failed++;
|
||||
}
|
||||
echo " {$icon}: {$name} = {$result}\n";
|
||||
}
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
return $failed > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$checks = [];
|
||||
|
||||
// ── Resolve update server URL from manifest ─────────────────────────────
|
||||
if ($updateUrl === null) {
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (preg_match('/<server[^>]*>([^<]+)<\/server>/', $xml, $m)) {
|
||||
$updateUrl = trim($m[1]);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updateUrl === null) {
|
||||
fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with <updateservers>.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Update server: {$updateUrl}\n\n";
|
||||
|
||||
// ── Check 1: Update server accessible ───────────────────────────────────
|
||||
echo "--- Update Server ---\n";
|
||||
$ch = curl_init($updateUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && !empty($response)) {
|
||||
echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n";
|
||||
$checks['update_server'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$httpCode}\n";
|
||||
$checks['update_server'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 2: Parse updates.xml for stable version ───────────────────────
|
||||
$stableVersion = null;
|
||||
$downloadUrl = null;
|
||||
|
||||
if (!empty($response)) {
|
||||
$sections = preg_split('/<update>/', $response);
|
||||
foreach ($sections as $section) {
|
||||
if (strpos($section, '<tag>stable</tag>') !== false) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $section, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
if (preg_match('/<downloadurl[^>]*>([^<]+)<\/downloadurl>/', $section, $m)) {
|
||||
$downloadUrl = trim($m[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($stableVersion === null && preg_match('/<version>([^<]+)<\/version>/', $response, $m)) {
|
||||
$stableVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Stable Release ---\n";
|
||||
if ($stableVersion !== null) {
|
||||
echo " Version: {$stableVersion}\n";
|
||||
$checks['stable_version'] = $stableVersion;
|
||||
} else {
|
||||
echo " FAIL: Could not parse stable version\n";
|
||||
$checks['stable_version'] = 'fail';
|
||||
}
|
||||
|
||||
// ── Check 3: Download URL accessible ────────────────────────────────────
|
||||
if ($downloadUrl !== null) {
|
||||
echo "\n--- Download URL ---\n";
|
||||
$ch = curl_init($downloadUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
|
||||
curl_close($ch);
|
||||
|
||||
if ($dlCode === 200) {
|
||||
$sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size';
|
||||
echo " PASS: HTTP {$dlCode}, {$sizeKb}\n";
|
||||
$checks['download'] = 'pass';
|
||||
} else {
|
||||
echo " FAIL: HTTP {$dlCode}\n";
|
||||
$checks['download'] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: Site version (optional) ────────────────────────────────────
|
||||
if ($siteUrl !== null && $apiToken !== null) {
|
||||
echo "\n--- Site Version ---\n";
|
||||
$apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file';
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"X-Joomla-Token: {$apiToken}",
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$siteResponse = curl_exec($ch);
|
||||
$siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($siteCode === 200) {
|
||||
echo " API accessible (HTTP {$siteCode})\n";
|
||||
$checks['site_api'] = 'pass';
|
||||
} else {
|
||||
echo " WARN: Site API returned HTTP {$siteCode}\n";
|
||||
$checks['site_api'] = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Health Check Summary ===\n";
|
||||
$failed = 0;
|
||||
foreach ($checks as $name => $result) {
|
||||
$icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK');
|
||||
if ($result === 'fail') $failed++;
|
||||
echo " {$icon}: {$name} = {$result}\n";
|
||||
}
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
$app = new ClientHealthCheckCli();
|
||||
exit($app->execute());
|
||||
|
||||
+199
-256
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -11,324 +12,266 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_inventory.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientInventory
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ClientInventoryCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private bool $jsonOutput = false;
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private bool $jsonOutput = false;
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Discover and list all client-waas repos with their server configuration status');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--json', 'Output results as JSON', false);
|
||||
}
|
||||
|
||||
if ($this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$this->token = $this->getArgument('--token');
|
||||
$this->jsonOutput = (bool) $this->getArgument('--json');
|
||||
|
||||
$this->log("Scanning Gitea instance: {$this->giteaUrl}");
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR', '--token is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 1: List all orgs
|
||||
$orgs = $this->fetchOrgs();
|
||||
$this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}");
|
||||
|
||||
if ($orgs === null)
|
||||
{
|
||||
$this->log('ERROR: Failed to fetch organizations.');
|
||||
return 1;
|
||||
}
|
||||
// Step 1: List all orgs
|
||||
$orgs = $this->fetchOrgs();
|
||||
|
||||
$this->log('Found ' . count($orgs) . ' organization(s).');
|
||||
if ($orgs === null) {
|
||||
$this->log('ERROR', 'Failed to fetch organizations.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2 & 3: For each org, find client-waas repos
|
||||
$inventory = [];
|
||||
$this->log('INFO', 'Found ' . count($orgs) . ' organization(s).');
|
||||
|
||||
foreach ($orgs as $org)
|
||||
{
|
||||
$orgName = $org['username'] ?? $org['name'] ?? '';
|
||||
// Step 2 & 3: For each org, find client-waas repos
|
||||
$inventory = [];
|
||||
|
||||
if ($orgName === '')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach ($orgs as $org) {
|
||||
$orgName = $org['username'] ?? $org['name'] ?? '';
|
||||
|
||||
$repos = $this->fetchOrgRepos($orgName);
|
||||
if ($orgName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($repos === null)
|
||||
{
|
||||
$this->log("WARNING: Could not fetch repos for org: {$orgName}");
|
||||
continue;
|
||||
}
|
||||
$repos = $this->fetchOrgRepos($orgName);
|
||||
|
||||
foreach ($repos as $repo)
|
||||
{
|
||||
$repoName = $repo['name'] ?? '';
|
||||
if ($repos === null) {
|
||||
$this->log('WARNING', "Could not fetch repos for org: {$orgName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($repoName, 'client-waas') === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach ($repos as $repo) {
|
||||
$repoName = $repo['name'] ?? '';
|
||||
|
||||
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
|
||||
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
|
||||
if (strpos($repoName, 'client-waas') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastPush = $repo['updated_at'] ?? 'unknown';
|
||||
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
|
||||
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
|
||||
|
||||
if ($lastPush !== 'unknown')
|
||||
{
|
||||
$lastPush = substr($lastPush, 0, 19);
|
||||
}
|
||||
$lastPush = $repo['updated_at'] ?? 'unknown';
|
||||
|
||||
$status = 'OK';
|
||||
if ($lastPush !== 'unknown') {
|
||||
$lastPush = substr($lastPush, 0, 19);
|
||||
}
|
||||
|
||||
if (!$hasDevConfig && !$hasLiveConfig)
|
||||
{
|
||||
$status = 'UNCONFIGURED';
|
||||
}
|
||||
elseif (!$hasDevConfig)
|
||||
{
|
||||
$status = 'NO DEV';
|
||||
}
|
||||
elseif (!$hasLiveConfig)
|
||||
{
|
||||
$status = 'NO LIVE';
|
||||
}
|
||||
$status = 'OK';
|
||||
|
||||
$inventory[] = [
|
||||
'org' => $orgName,
|
||||
'repo' => $repoName,
|
||||
'has_dev_config' => $hasDevConfig,
|
||||
'has_live_config' => $hasLiveConfig,
|
||||
'last_push' => $lastPush,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$hasDevConfig && !$hasLiveConfig) {
|
||||
$status = 'UNCONFIGURED';
|
||||
} elseif (!$hasDevConfig) {
|
||||
$status = 'NO DEV';
|
||||
} elseif (!$hasLiveConfig) {
|
||||
$status = 'NO LIVE';
|
||||
}
|
||||
|
||||
// Output results
|
||||
if ($this->jsonOutput)
|
||||
{
|
||||
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
return 0;
|
||||
}
|
||||
$inventory[] = [
|
||||
'org' => $orgName,
|
||||
'repo' => $repoName,
|
||||
'has_dev_config' => $hasDevConfig,
|
||||
'has_live_config' => $hasLiveConfig,
|
||||
'last_push' => $lastPush,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($inventory) === 0)
|
||||
{
|
||||
$this->log('No client-waas repos found.');
|
||||
return 0;
|
||||
}
|
||||
// Output results
|
||||
if ($this->jsonOutput) {
|
||||
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Print table
|
||||
$this->log('');
|
||||
$this->log(sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
|
||||
));
|
||||
$this->log(str_repeat('-', 120));
|
||||
if (count($inventory) === 0) {
|
||||
$this->log('INFO', 'No client-waas repos found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($inventory as $entry)
|
||||
{
|
||||
$this->log(sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
$entry['org'],
|
||||
$entry['repo'],
|
||||
$entry['has_dev_config'] ? 'Yes' : 'No',
|
||||
$entry['has_live_config'] ? 'Yes' : 'No',
|
||||
$entry['last_push'],
|
||||
$entry['status']
|
||||
));
|
||||
}
|
||||
// Print table
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
'Org',
|
||||
'Repo',
|
||||
'Dev Config',
|
||||
'Live Config',
|
||||
'Last Push',
|
||||
'Status'
|
||||
));
|
||||
$this->log('INFO', str_repeat('-', 120));
|
||||
|
||||
$this->log('');
|
||||
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).');
|
||||
foreach ($inventory as $entry) {
|
||||
$this->log('INFO', sprintf(
|
||||
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
|
||||
$entry['org'],
|
||||
$entry['repo'],
|
||||
$entry['has_dev_config'] ? 'Yes' : 'No',
|
||||
$entry['has_live_config'] ? 'Yes' : 'No',
|
||||
$entry['last_push'],
|
||||
$entry['status']
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', 'Total: ' . count($inventory) . ' client-waas repo(s).');
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for ($i = 1; $i < $count; $i++)
|
||||
{
|
||||
switch ($args[$i])
|
||||
{
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--json':
|
||||
$this->jsonOutput = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private function fetchOrgs(): ?array
|
||||
{
|
||||
// Try admin endpoint first, fall back to user-visible orgs
|
||||
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_inventory.php --token <token> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --json Output results as JSON');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
private function fetchOrgs(): ?array
|
||||
{
|
||||
// Try admin endpoint first, fall back to user-visible orgs
|
||||
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
|
||||
if (is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$data = json_decode($response['body'], true);
|
||||
$this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...');
|
||||
|
||||
if (is_array($data))
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
|
||||
|
||||
$this->log('Admin orgs endpoint unavailable, falling back to user orgs...');
|
||||
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
|
||||
if (is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$data = json_decode($response['body'], true);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($data))
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
private function fetchOrgRepos(string $org): ?array
|
||||
{
|
||||
$page = 1;
|
||||
$allRepos = [];
|
||||
|
||||
return null;
|
||||
}
|
||||
while (true) {
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
|
||||
|
||||
private function fetchOrgRepos(string $org): ?array
|
||||
{
|
||||
$page = 1;
|
||||
$allRepos = [];
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
return $page === 1 ? null : $allRepos;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
return $page === 1 ? null : $allRepos;
|
||||
}
|
||||
if (!is_array($data) || count($data) === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
$allRepos = array_merge($allRepos, $data);
|
||||
$page++;
|
||||
}
|
||||
|
||||
if (!is_array($data) || count($data) === 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
return $allRepos;
|
||||
}
|
||||
|
||||
$allRepos = array_merge($allRepos, $data);
|
||||
$page++;
|
||||
}
|
||||
private function checkVariables(string $org, string $repo, array $requiredVars): bool
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
|
||||
|
||||
return $allRepos;
|
||||
}
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private function checkVariables(string $org, string $repo, array $requiredVars): bool
|
||||
{
|
||||
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
$existingVars = [];
|
||||
|
||||
if (!is_array($data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach ($data as $variable) {
|
||||
if (isset($variable['name'])) {
|
||||
$existingVars[] = $variable['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$existingVars = [];
|
||||
foreach ($requiredVars as $var) {
|
||||
if (!in_array($var, $existingVars, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data as $variable)
|
||||
{
|
||||
if (isset($variable['name']))
|
||||
{
|
||||
$existingVars[] = $variable['name'];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($requiredVars as $var)
|
||||
{
|
||||
if (!in_array($var, $existingVars, true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
|
||||
return true;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if ($body !== null)
|
||||
{
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
if (curl_errno($ch))
|
||||
{
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientInventory();
|
||||
exit($app->run());
|
||||
$app = new ClientInventoryCli();
|
||||
exit($app->execute());
|
||||
|
||||
+68
-111
@@ -12,13 +12,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_provision.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Provision a new client environment end-to-end
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ClientProvision
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ClientProvisionCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $giteaToken = '';
|
||||
@@ -26,24 +30,30 @@ final class ClientProvision
|
||||
private string $grafanaToken = '';
|
||||
private string $configFile = '';
|
||||
private string $step = '';
|
||||
private bool $dryRun = false;
|
||||
/** @var array<string, mixed> */
|
||||
private array $config = [];
|
||||
private string $org = '';
|
||||
private string $repoName = '';
|
||||
|
||||
public function run(): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->parseArgs();
|
||||
$this->setDescription('Provision a new client environment end-to-end');
|
||||
$this->addArgument('--config', 'Client config JSON', '');
|
||||
$this->addArgument('--step', 'Run one step: repo, variables, secrets, monitoring, summary', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$this->configFile = $this->getArgument('--config');
|
||||
$this->step = $this->getArgument('--step');
|
||||
|
||||
if ($this->configFile === '') {
|
||||
$this->log('ERROR: --config is required.');
|
||||
$this->printUsage();
|
||||
$this->log('ERROR', '--config is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->configFile)) {
|
||||
$this->log("ERROR: Not found: {$this->configFile}");
|
||||
$this->log('ERROR', "Not found: {$this->configFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -51,7 +61,7 @@ final class ClientProvision
|
||||
$this->config = json_decode($json, true);
|
||||
|
||||
if (!is_array($this->config)) {
|
||||
$this->log('ERROR: Invalid JSON in config file.');
|
||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -65,7 +75,7 @@ final class ClientProvision
|
||||
?? $this->giteaUrl;
|
||||
|
||||
if ($this->giteaToken === '') {
|
||||
$this->log('ERROR: gitea_token or MOKOGITEA_TOKEN required.');
|
||||
$this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -73,21 +83,21 @@ final class ClientProvision
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
if ($this->org === '' || $clientName === '') {
|
||||
$this->log('ERROR: "org" and "name" required in config.');
|
||||
$this->log('ERROR', '"org" and "name" required in config.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->repoName = 'client-waas-' . $clientName;
|
||||
|
||||
$this->log("=== Client Provisioning: {$clientName} ===");
|
||||
$this->log(" Org: {$this->org}");
|
||||
$this->log(" Repo: {$this->repoName}");
|
||||
$this->log('INFO', "=== Client Provisioning: {$clientName} ===");
|
||||
$this->log('INFO', " Org: {$this->org}");
|
||||
$this->log('INFO', " Repo: {$this->repoName}");
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(' Mode: DRY RUN');
|
||||
$this->log('INFO', ' Mode: DRY RUN');
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
echo "\n";
|
||||
|
||||
$steps = [
|
||||
'repo' => 'createRepo',
|
||||
@@ -116,7 +126,7 @@ final class ClientProvision
|
||||
|
||||
private function createRepo(): int
|
||||
{
|
||||
$this->log('[1/5] Creating repository...');
|
||||
$this->log('INFO', '[1/5] Creating repository...');
|
||||
|
||||
$check = $this->giteaApi(
|
||||
'GET',
|
||||
@@ -124,14 +134,12 @@ final class ClientProvision
|
||||
);
|
||||
|
||||
if ($check['code'] === 200) {
|
||||
$this->log(" SKIP: repo already exists");
|
||||
$this->log('INFO', ' SKIP: repo already exists');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(
|
||||
" WOULD CREATE: {$this->org}/{$this->repoName}"
|
||||
);
|
||||
$this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -153,11 +161,11 @@ final class ClientProvision
|
||||
);
|
||||
|
||||
if ($resp['code'] < 200 || $resp['code'] >= 300) {
|
||||
$this->log(" ERROR: HTTP {$resp['code']}");
|
||||
$this->log('ERROR', "HTTP {$resp['code']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log(' OK: Repo created');
|
||||
$this->log('INFO', ' OK: Repo created');
|
||||
|
||||
$this->giteaApi(
|
||||
'POST',
|
||||
@@ -168,19 +176,19 @@ final class ClientProvision
|
||||
])
|
||||
);
|
||||
|
||||
$this->log(' OK: dev branch created');
|
||||
$this->log('INFO', ' OK: dev branch created');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setVariables(): int
|
||||
{
|
||||
$this->log('[2/5] Setting repo variables...');
|
||||
$this->log('INFO', '[2/5] Setting repo variables...');
|
||||
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
|
||||
if (empty($vars)) {
|
||||
$this->log(' SKIP: No variables in config');
|
||||
$this->log('INFO', ' SKIP: No variables in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -192,16 +200,16 @@ final class ClientProvision
|
||||
if ($this->dryRun) {
|
||||
$display = strlen($value) > 40
|
||||
? substr($value, 0, 37) . '...' : $value;
|
||||
$this->log(" WOULD SET: {$name} = {$display}");
|
||||
$this->log('INFO', " WOULD SET: {$name} = {$display}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->setOrCreateVariable($api, $name, $value);
|
||||
|
||||
if ($ok) {
|
||||
$this->log(" OK: {$name}");
|
||||
$this->log('INFO', " OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name}");
|
||||
$this->log('ERROR', " {$name}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
@@ -211,12 +219,12 @@ final class ClientProvision
|
||||
|
||||
private function setSecrets(): int
|
||||
{
|
||||
$this->log('[3/5] Setting repo secrets...');
|
||||
$this->log('INFO', '[3/5] Setting repo secrets...');
|
||||
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
|
||||
if (empty($secrets)) {
|
||||
$this->log(' SKIP: No secrets in config');
|
||||
$this->log('INFO', ' SKIP: No secrets in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -229,7 +237,7 @@ final class ClientProvision
|
||||
$keyPath = substr($value, 1);
|
||||
|
||||
if (!file_exists($keyPath)) {
|
||||
$this->log(" ERROR: {$name} file not found: {$keyPath}");
|
||||
$this->log('ERROR', " {$name} file not found: {$keyPath}");
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
@@ -238,7 +246,7 @@ final class ClientProvision
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")");
|
||||
$this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -249,9 +257,9 @@ final class ClientProvision
|
||||
);
|
||||
|
||||
if ($resp['code'] >= 200 && $resp['code'] < 300) {
|
||||
$this->log(" OK: {$name}");
|
||||
$this->log('INFO', " OK: {$name}");
|
||||
} else {
|
||||
$this->log(" ERROR: {$name} (HTTP {$resp['code']})");
|
||||
$this->log('ERROR', " {$name} (HTTP {$resp['code']})");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
@@ -261,12 +269,12 @@ final class ClientProvision
|
||||
|
||||
private function setupMonitoring(): int
|
||||
{
|
||||
$this->log('[4/5] Setting up monitoring...');
|
||||
$this->log('INFO', '[4/5] Setting up monitoring...');
|
||||
|
||||
$mon = $this->config['monitoring'] ?? [];
|
||||
|
||||
if (empty($mon)) {
|
||||
$this->log(' SKIP: No monitoring config');
|
||||
$this->log('INFO', ' SKIP: No monitoring config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -291,10 +299,10 @@ final class ClientProvision
|
||||
$urlStr = implode("\n", $urls);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_URLS");
|
||||
$this->log('INFO', ' WOULD SET: MONITORED_URLS');
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr);
|
||||
$this->log(' OK: MONITORED_URLS');
|
||||
$this->log('INFO', ' OK: MONITORED_URLS');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,10 +310,10 @@ final class ClientProvision
|
||||
$domainStr = implode("\n", $domains);
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD SET: MONITORED_DOMAINS");
|
||||
$this->log('INFO', ' WOULD SET: MONITORED_DOMAINS');
|
||||
} else {
|
||||
$this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr);
|
||||
$this->log(' OK: MONITORED_DOMAINS');
|
||||
$this->log('INFO', ' OK: MONITORED_DOMAINS');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,19 +323,19 @@ final class ClientProvision
|
||||
private function pushGrafanaDashboard(string $file, string $folder): void
|
||||
{
|
||||
if (!file_exists($file)) {
|
||||
$this->log(" WARN: Dashboard not found: {$file}");
|
||||
$this->warning("Dashboard not found: {$file}");
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log(" WOULD PUSH: dashboard to \"{$folder}\"");
|
||||
$this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\"");
|
||||
return;
|
||||
}
|
||||
|
||||
$dashboard = json_decode(file_get_contents($file), true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log(' ERROR: Invalid dashboard JSON');
|
||||
$this->log('ERROR', 'Invalid dashboard JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,9 +354,9 @@ final class ClientProvision
|
||||
|
||||
if ($resp['code'] === 200) {
|
||||
$data = json_decode($resp['body'], true);
|
||||
$this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
||||
$this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")");
|
||||
} else {
|
||||
$this->log(" ERROR: Dashboard push (HTTP {$resp['code']})");
|
||||
$this->log('ERROR', " Dashboard push (HTTP {$resp['code']})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,20 +387,19 @@ final class ClientProvision
|
||||
{
|
||||
$vars = $this->config['variables'] ?? [];
|
||||
$secrets = $this->config['secrets'] ?? [];
|
||||
$clientName = $this->config['name'] ?? '';
|
||||
|
||||
$this->log('');
|
||||
$this->log('[5/5] Provisioning summary');
|
||||
$this->log(str_repeat('=', 60));
|
||||
$this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}");
|
||||
$this->log(' Variables: ' . count($vars) . ' set');
|
||||
$this->log(' Secrets: ' . count($secrets) . ' set');
|
||||
$this->log('');
|
||||
$this->log('Next steps:');
|
||||
$this->log(' 1. Clone and customize the Joomla template');
|
||||
$this->log(' 2. Push to dev to trigger dev deployment');
|
||||
$this->log(' 3. Merge dev -> main for production release');
|
||||
$this->log(str_repeat('=', 60));
|
||||
echo "\n";
|
||||
$this->log('INFO', '[5/5] Provisioning summary');
|
||||
echo str_repeat('=', 60) . "\n";
|
||||
echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n";
|
||||
echo ' Variables: ' . count($vars) . " set\n";
|
||||
echo ' Secrets: ' . count($secrets) . " set\n";
|
||||
echo "\n";
|
||||
echo "Next steps:\n";
|
||||
echo " 1. Clone and customize the Joomla template\n";
|
||||
echo " 2. Push to dev to trigger dev deployment\n";
|
||||
echo " 3. Merge dev -> main for production release\n";
|
||||
echo str_repeat('=', 60) . "\n";
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -419,51 +426,6 @@ final class ClientProvision
|
||||
return $resp['code'] >= 200 && $resp['code'] < 300;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--config':
|
||||
$this->configFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--step':
|
||||
$this->step = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown arg: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: client_provision.php --config <file.json> [options]');
|
||||
$this->log('');
|
||||
$this->log('Provision a new client environment end-to-end.');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --config <file> Client config JSON');
|
||||
$this->log(' --step <name> Run one step: repo, variables, secrets, monitoring, summary');
|
||||
$this->log(' --dry-run Preview without changes');
|
||||
$this->log(' --help, -h Show this help');
|
||||
$this->log('');
|
||||
$this->log('Environment variables:');
|
||||
$this->log(' MOKOGITEA_TOKEN Gitea API token');
|
||||
$this->log(' GRAFANA_URL Grafana instance URL');
|
||||
$this->log(' GRAFANA_TOKEN Grafana API token');
|
||||
}
|
||||
|
||||
private function giteaApi(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
@@ -523,12 +485,7 @@ final class ClientProvision
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ClientProvision();
|
||||
exit($app->run());
|
||||
$app = new ClientProvisionCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/completion.php
|
||||
* BRIEF: Generate bash/zsh tab completion scripts for bin/moko
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class CompletionCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Generate bash/zsh tab completion scripts for bin/moko');
|
||||
$this->addArgument('--shell', 'Shell type: bash or zsh', 'bash');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$shell = $this->getArgument('--shell');
|
||||
|
||||
// Also accept positional-style: check raw argv for bash/zsh
|
||||
global $argv;
|
||||
foreach ($argv as $arg) {
|
||||
if (in_array($arg, ['bash', 'zsh'], true)) {
|
||||
$shell = $arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract command names from bin/moko COMMAND_MAP using regex (no eval).
|
||||
$mokoFile = dirname(__DIR__) . '/bin/moko';
|
||||
$content = file_get_contents($mokoFile);
|
||||
|
||||
// Isolate the COMMAND_MAP block, then extract keys.
|
||||
if (!preg_match('/const COMMAND_MAP\s*=\s*\[(.+?)\];/s', $content, $block)) {
|
||||
$this->log('ERROR', 'Could not find COMMAND_MAP in bin/moko');
|
||||
return 1;
|
||||
}
|
||||
// Match 'command-name' => 'path' entries within the block.
|
||||
if (!preg_match_all("/'([a-z][a-z0-9:_-]*)'\s*=>/m", $block[1], $matches)) {
|
||||
$this->log('ERROR', 'Could not parse command names from COMMAND_MAP');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$commandNames = array_unique($matches[1]);
|
||||
sort($commandNames);
|
||||
|
||||
// Common flags supported by CliFramework.
|
||||
$commonFlags = ['--help', '--verbose', '--quiet', '--dry-run', '--json', '--no-color', '--path'];
|
||||
|
||||
if ($shell === 'zsh') {
|
||||
$this->generateZsh($commandNames, $commonFlags);
|
||||
} else {
|
||||
$this->generateBash($commandNames, $commonFlags);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -- Generators --
|
||||
|
||||
private function generateBash(array $commands, array $flags): void
|
||||
{
|
||||
$cmdList = implode(' ', $commands);
|
||||
$flagList = implode(' ', $flags);
|
||||
|
||||
echo <<<BASH
|
||||
# moko bash completion — generated by: php bin/moko completion bash
|
||||
_moko_complete() {
|
||||
local cur prev commands flags
|
||||
COMPREPLY=()
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
commands="{$cmdList}"
|
||||
flags="{$flagList}"
|
||||
|
||||
# Complete commands (first argument after 'moko')
|
||||
if [[ \$COMP_CWORD -eq 1 ]] || [[ \$COMP_CWORD -eq 2 && "\${COMP_WORDS[1]}" == "php" ]]; then
|
||||
COMPREPLY=( \$(compgen -W "\$commands list help" -- "\$cur") )
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Complete flags
|
||||
if [[ "\$cur" == -* ]]; then
|
||||
COMPREPLY=( \$(compgen -W "\$flags" -- "\$cur") )
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Complete --path with directories
|
||||
if [[ "\$prev" == "--path" ]]; then
|
||||
COMPREPLY=( \$(compgen -d -- "\$cur") )
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Register for both direct and php invocation
|
||||
complete -F _moko_complete moko
|
||||
complete -F _moko_complete ./bin/moko
|
||||
complete -F _moko_complete bin/moko
|
||||
|
||||
BASH;
|
||||
}
|
||||
|
||||
private function generateZsh(array $commands, array $flags): void
|
||||
{
|
||||
$cmdLines = '';
|
||||
foreach ($commands as $cmd) {
|
||||
$cmdLines .= " '{$cmd}'\n";
|
||||
}
|
||||
$flagLines = '';
|
||||
foreach ($flags as $flag) {
|
||||
$desc = match ($flag) {
|
||||
'--help' => 'Show help for the command',
|
||||
'--verbose' => 'Show detailed output',
|
||||
'--quiet' => 'Suppress non-error output',
|
||||
'--dry-run' => 'Preview changes without writing',
|
||||
'--json' => 'Machine-readable JSON output',
|
||||
'--no-color' => 'Disable ANSI colour output',
|
||||
'--path' => 'Repository root path',
|
||||
default => $flag,
|
||||
};
|
||||
$flagLines .= " '{$flag}[{$desc}]'\n";
|
||||
}
|
||||
|
||||
echo <<<ZSH
|
||||
#compdef moko bin/moko
|
||||
# moko zsh completion — generated by: php bin/moko completion zsh
|
||||
|
||||
_moko() {
|
||||
local -a commands flags
|
||||
|
||||
commands=(
|
||||
{$cmdLines} 'list'
|
||||
'help'
|
||||
)
|
||||
|
||||
flags=(
|
||||
{$flagLines} )
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' commands
|
||||
else
|
||||
_arguments '*:flags:_values "flag" \${flags[@]}'
|
||||
fi
|
||||
}
|
||||
|
||||
compdef _moko moko
|
||||
compdef _moko bin/moko
|
||||
|
||||
ZSH;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new CompletionCli();
|
||||
exit($app->execute());
|
||||
+417
-454
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -12,469 +13,431 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/create_project.php
|
||||
* BRIEF: Create baseline GitHub Projects for repositories with standard fields and views
|
||||
*
|
||||
* USAGE
|
||||
* php cli/create_project.php --repo MokoCRM # Auto-detect type, create project
|
||||
* php cli/create_project.php --repo MokoCRM --type dolibarr # Force type
|
||||
* php cli/create_project.php --org mokoconsulting-tech --all # All repos without projects
|
||||
* php cli/create_project.php --repo MokoCRM --dry-run # Preview without changes
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$allMode = in_array('--all', $argv);
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$org = 'mokoconsulting-tech';
|
||||
$repoName = null;
|
||||
$typeOverride = null;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) {
|
||||
$org = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--type' && isset($argv[$i + 1])) {
|
||||
$typeOverride = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$repoName && !$allMode) {
|
||||
fwrite(STDERR, "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]\n");
|
||||
fwrite(STDERR, " php create_project.php --all [--org <org>] [--dry-run]\n");
|
||||
fwrite(STDERR, "\nTypes: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$config = \MokoEnterprise\Config::load();
|
||||
$platform = $config->getString('platform', 'gitea');
|
||||
try {
|
||||
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
||||
$api = $adapter->getApiClient();
|
||||
} catch (\Exception $e) {
|
||||
fwrite(STDERR, "Platform initialization failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$token = $platform === 'gitea'
|
||||
? $config->getString('gitea.token', '')
|
||||
: $config->getString('github.token', '');
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$templatesDir = "{$repoRoot}/templates/projects";
|
||||
|
||||
// ── Always-exclude list (no project needed) ─────────────────────────────
|
||||
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
|
||||
|
||||
// ── Platform type map ───────────────────────────────────────────────────
|
||||
$PLATFORM_TO_TYPE = [
|
||||
'crm-module' => 'dolibarr',
|
||||
'crm-platform' => 'dolibarr',
|
||||
'waas-component' => 'joomla',
|
||||
'waas-library' => 'joomla',
|
||||
'waas-plugin' => 'joomla',
|
||||
'waas-package' => 'joomla',
|
||||
'nodejs' => 'nodejs',
|
||||
'terraform' => 'terraform',
|
||||
'python' => 'python',
|
||||
'wordpress' => 'wordpress',
|
||||
'mobile' => 'mobile-app',
|
||||
'api' => 'api',
|
||||
'documentation' => 'documentation',
|
||||
];
|
||||
|
||||
// ── Template file map ───────────────────────────────────────────────────
|
||||
$TYPE_TO_TEMPLATE = [
|
||||
'generic' => 'generic-project-definition.tf',
|
||||
'dolibarr' => 'dolibarr-project-definition.tf',
|
||||
'joomla' => 'joomla-project-definition.tf',
|
||||
'nodejs' => 'nodejs-project-definition.tf',
|
||||
'terraform' => 'terraform-project-definition.tf',
|
||||
'python' => 'python-project-definition.tf',
|
||||
'wordpress' => 'wordpress-project-definition.tf',
|
||||
'mobile-app' => 'mobile-app-project-definition.tf',
|
||||
'api' => 'api-project-definition.tf',
|
||||
'documentation' => 'documentation-project-definition.tf',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute a GraphQL query (GitHub only — Gitea does not support GraphQL).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array
|
||||
class CreateProjectCli extends CliFramework
|
||||
{
|
||||
if ($platformName !== 'github') {
|
||||
return [];
|
||||
}
|
||||
$payload = json_encode(['query' => $query, 'variables' => $variables]);
|
||||
$ch = curl_init('https://api.github.com/graphql');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: bearer ' . $token,
|
||||
'Content-Type: application/json',
|
||||
'User-Agent: MokoStandards-CreateProject',
|
||||
],
|
||||
]);
|
||||
$body = (string) curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
/** @var string[] */
|
||||
private array $ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||
|
||||
if ($status !== 200) {
|
||||
fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n");
|
||||
return [];
|
||||
}
|
||||
/** @var array<string, string> */
|
||||
private array $PLATFORM_TO_TYPE = [
|
||||
'crm-module' => 'dolibarr',
|
||||
'crm-platform' => 'dolibarr',
|
||||
'waas-component' => 'joomla',
|
||||
'waas-library' => 'joomla',
|
||||
'waas-plugin' => 'joomla',
|
||||
'waas-package' => 'joomla',
|
||||
'nodejs' => 'nodejs',
|
||||
'terraform' => 'terraform',
|
||||
'python' => 'python',
|
||||
'wordpress' => 'wordpress',
|
||||
'mobile' => 'mobile-app',
|
||||
'api' => 'api',
|
||||
'documentation' => 'documentation',
|
||||
];
|
||||
|
||||
$data = json_decode($body, true) ?? [];
|
||||
if (!empty($data['errors'])) {
|
||||
foreach ($data['errors'] as $err) {
|
||||
fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n");
|
||||
}
|
||||
}
|
||||
/** @var array<string, string> */
|
||||
private array $TYPE_TO_TEMPLATE = [
|
||||
'generic' => 'generic-project-definition.tf',
|
||||
'dolibarr' => 'dolibarr-project-definition.tf',
|
||||
'joomla' => 'joomla-project-definition.tf',
|
||||
'nodejs' => 'nodejs-project-definition.tf',
|
||||
'terraform' => 'terraform-project-definition.tf',
|
||||
'python' => 'python-project-definition.tf',
|
||||
'wordpress' => 'wordpress-project-definition.tf',
|
||||
'mobile-app' => 'mobile-app-project-definition.tf',
|
||||
'api' => 'api-project-definition.tf',
|
||||
'documentation' => 'documentation-project-definition.tf',
|
||||
];
|
||||
|
||||
return $data['data'] ?? [];
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Create baseline GitHub Projects for repositories with standard fields and views');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
$this->addArgument('--org', 'Organization (default: mokoconsulting-tech)', 'mokoconsulting-tech');
|
||||
$this->addArgument('--type', 'Force project type', '');
|
||||
$this->addArgument('--all', 'Process all repos without projects', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$repoName = $this->getArgument('--repo') ?: null;
|
||||
$org = $this->getArgument('--org');
|
||||
$typeOverride = $this->getArgument('--type') ?: null;
|
||||
$allMode = $this->getArgument('--all');
|
||||
|
||||
if (!$repoName && !$allMode) {
|
||||
$this->log('ERROR', "Usage: php create_project.php --repo <name> [--type <type>] [--dry-run]");
|
||||
$this->log('ERROR', " php create_project.php --all [--org <org>] [--dry-run]");
|
||||
$this->log('ERROR', "Types: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation");
|
||||
return 2;
|
||||
}
|
||||
|
||||
$config = \MokoEnterprise\Config::load();
|
||||
$platformName = $config->getString('platform', 'gitea');
|
||||
try {
|
||||
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
|
||||
$api = $adapter->getApiClient();
|
||||
} catch (\Exception $e) {
|
||||
$this->log('ERROR', "Platform initialization failed: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
$token = $platformName === 'gitea'
|
||||
? $config->getString('gitea.token', '')
|
||||
: $config->getString('github.token', '');
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$templatesDir = "{$repoRoot}/templates/projects";
|
||||
|
||||
$repos = [];
|
||||
|
||||
if ($allMode) {
|
||||
echo "Fetching repositories from {$org}...\n";
|
||||
$page = 1;
|
||||
do {
|
||||
$batch = $this->restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token, $api);
|
||||
foreach ($batch as $r) {
|
||||
if (!$r['archived'] && !in_array($r['name'], $this->ALWAYS_EXCLUDE, true)) {
|
||||
$repos[] = $r['name'];
|
||||
}
|
||||
}
|
||||
$page++;
|
||||
} while (count($batch) === 100);
|
||||
|
||||
sort($repos);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
} else {
|
||||
$repos = [$repoName];
|
||||
}
|
||||
|
||||
$ownerId = $this->getOrgNodeId($org, $token);
|
||||
if (empty($ownerId)) {
|
||||
$this->log('ERROR', "Could not resolve org node ID for {$org}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
echo "Processing {$repo}...\n";
|
||||
|
||||
[$hasProject, $existingTitle] = $this->repoHasProject($org, $repo, $token);
|
||||
if ($hasProject) {
|
||||
echo " Already has project: {$existingTitle} -- skipping\n";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $typeOverride;
|
||||
if (!$type) {
|
||||
$platform = $this->detectRepoPlatform($org, $repo, $token, $api);
|
||||
$type = $this->PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
||||
echo " Platform: {$platform} -> type: {$type}\n";
|
||||
}
|
||||
|
||||
$templateFile = $this->TYPE_TO_TEMPLATE[$type] ?? $this->TYPE_TO_TEMPLATE['generic'];
|
||||
$template = $this->parseTemplate("{$templatesDir}/{$templateFile}");
|
||||
|
||||
$repoId = $this->getRepoNodeId($org, $repo, $token);
|
||||
if (empty($repoId)) {
|
||||
$this->log('ERROR', " Could not resolve repo node ID for {$repo}");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->createProject($org, $repo, $ownerId, $repoId, $template, $token);
|
||||
if ($ok) {
|
||||
$created++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat('-', 50) . "\n";
|
||||
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
|
||||
return $failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array
|
||||
{
|
||||
if ($platformName !== 'github') {
|
||||
return [];
|
||||
}
|
||||
$payload = json_encode(['query' => $query, 'variables' => $variables]);
|
||||
$ch = curl_init('https://api.github.com/graphql');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: bearer ' . $token,
|
||||
'Content-Type: application/json',
|
||||
'User-Agent: moko-platform-CreateProject',
|
||||
],
|
||||
]);
|
||||
$body = (string) curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200) {
|
||||
$this->log('ERROR', "GraphQL request failed (HTTP {$status}): {$body}");
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($body, true) ?? [];
|
||||
if (!empty($data['errors'])) {
|
||||
foreach ($data['errors'] as $err) {
|
||||
$this->log('ERROR', " GraphQL error: " . ($err['message'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
return $data['data'] ?? [];
|
||||
}
|
||||
|
||||
private function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
|
||||
{
|
||||
if ($apiClient !== null) {
|
||||
try {
|
||||
return $apiClient->get("/{$path}");
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
{
|
||||
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
|
||||
$data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
|
||||
if (!empty($data['content'])) {
|
||||
$content = base64_decode($data['content']);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
return trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function getRepoNodeId(string $org, string $repo, string $token): string
|
||||
{
|
||||
$data = $this->graphql(
|
||||
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }',
|
||||
['owner' => $org, 'name' => $repo],
|
||||
$token
|
||||
);
|
||||
return $data['repository']['id'] ?? '';
|
||||
}
|
||||
|
||||
private function getOrgNodeId(string $org, string $token): string
|
||||
{
|
||||
$data = $this->graphql(
|
||||
'query($login: String!) { organization(login: $login) { id } }',
|
||||
['login' => $org],
|
||||
$token
|
||||
);
|
||||
return $data['organization']['id'] ?? '';
|
||||
}
|
||||
|
||||
/** @return array{bool, string} */
|
||||
private function repoHasProject(string $org, string $repo, string $token): array
|
||||
{
|
||||
$data = $this->graphql(
|
||||
'query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
projectsV2(first: 1) { nodes { id title } totalCount }
|
||||
}
|
||||
}',
|
||||
['owner' => $org, 'name' => $repo],
|
||||
$token
|
||||
);
|
||||
|
||||
$count = $data['repository']['projectsV2']['totalCount'] ?? 0;
|
||||
$title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? '';
|
||||
return [$count > 0, $title];
|
||||
}
|
||||
|
||||
/** @return array{name: string, fields: array, views: array} */
|
||||
private function parseTemplate(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return ['name' => 'Development Board', 'fields' => [], 'views' => []];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$result = ['name' => 'Development Board', 'fields' => [], 'views' => []];
|
||||
|
||||
if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) {
|
||||
$result['name'] = $m[1];
|
||||
}
|
||||
|
||||
$fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"'
|
||||
. '\s*description\s*=\s*"([^"]+)"'
|
||||
. '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s';
|
||||
if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$field = [
|
||||
'name' => $match[1],
|
||||
'type' => $match[2],
|
||||
'description' => $match[3],
|
||||
];
|
||||
if (!empty($match[4])) {
|
||||
$field['options'] = array_map(
|
||||
fn($o) => trim($o, " \t\n\r\"'"),
|
||||
explode(',', $match[4])
|
||||
);
|
||||
$field['options'] = array_filter($field['options']);
|
||||
}
|
||||
$result['fields'][] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function createProject(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $ownerId,
|
||||
string $repoId,
|
||||
array $template,
|
||||
string $token
|
||||
): bool {
|
||||
$title = "{$repo} -- {$template['name']}";
|
||||
|
||||
if ($this->dryRun) {
|
||||
echo " (dry-run) would create project: {$title}\n";
|
||||
echo " (dry-run) fields: " . count($template['fields']) . "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
echo " Creating project: {$title}\n";
|
||||
$data = $this->graphql(
|
||||
'mutation($ownerId: ID!, $title: String!) {
|
||||
createProjectV2(input: { ownerId: $ownerId, title: $title }) {
|
||||
projectV2 { id number url }
|
||||
}
|
||||
}',
|
||||
['ownerId' => $ownerId, 'title' => $title],
|
||||
$token
|
||||
);
|
||||
|
||||
$projectId = $data['createProjectV2']['projectV2']['id'] ?? '';
|
||||
$projectUrl = $data['createProjectV2']['projectV2']['url'] ?? '';
|
||||
|
||||
if (empty($projectId)) {
|
||||
$this->log('ERROR', " Failed to create project for {$repo}");
|
||||
return false;
|
||||
}
|
||||
|
||||
echo " Project created: {$projectUrl}\n";
|
||||
|
||||
$this->graphql(
|
||||
'mutation($projectId: ID!, $repositoryId: ID!) {
|
||||
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
|
||||
repository { id }
|
||||
}
|
||||
}',
|
||||
['projectId' => $projectId, 'repositoryId' => $repoId],
|
||||
$token
|
||||
);
|
||||
echo " Linked to {$org}/{$repo}\n";
|
||||
|
||||
$fieldCount = 0;
|
||||
foreach ($template['fields'] as $field) {
|
||||
$fieldType = match ($field['type']) {
|
||||
'single_select' => 'SINGLE_SELECT',
|
||||
'text' => 'TEXT',
|
||||
'number' => 'NUMBER',
|
||||
'date' => 'DATE',
|
||||
'iteration' => 'ITERATION',
|
||||
default => 'TEXT',
|
||||
};
|
||||
|
||||
$vars = [
|
||||
'projectId' => $projectId,
|
||||
'name' => $field['name'],
|
||||
'dataType' => $fieldType,
|
||||
];
|
||||
|
||||
if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) {
|
||||
$optionInputs = array_map(
|
||||
fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'],
|
||||
$field['options']
|
||||
);
|
||||
$vars['singleSelectOptions'] = $optionInputs;
|
||||
|
||||
$this->graphql(
|
||||
'mutation($projectId: ID!, $name: String!,'
|
||||
. ' $dataType: ProjectV2CustomFieldType!,'
|
||||
. ' $singleSelectOptions:'
|
||||
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
|
||||
createProjectV2Field(input: {
|
||||
projectId: $projectId,
|
||||
dataType: $dataType,
|
||||
name: $name,
|
||||
singleSelectOptions: $singleSelectOptions
|
||||
}) {
|
||||
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
|
||||
}
|
||||
}',
|
||||
$vars,
|
||||
$token
|
||||
);
|
||||
} else {
|
||||
$this->graphql(
|
||||
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {
|
||||
createProjectV2Field(input: {
|
||||
projectId: $projectId,
|
||||
dataType: $dataType,
|
||||
name: $name
|
||||
}) {
|
||||
projectV2Field { ... on ProjectV2Field { id name } }
|
||||
}
|
||||
}',
|
||||
$vars,
|
||||
$token
|
||||
);
|
||||
}
|
||||
|
||||
$fieldCount++;
|
||||
}
|
||||
|
||||
echo " Created {$fieldCount} custom fields\n";
|
||||
|
||||
$this->graphql(
|
||||
'mutation($projectId: ID!, $shortDescription: String!) {
|
||||
updateProjectV2(input: {
|
||||
projectId: $projectId,
|
||||
shortDescription: $shortDescription,
|
||||
readme: "Managed by moko-platform. Run `php cli/create_project.php` to regenerate."
|
||||
}) {
|
||||
projectV2 { id }
|
||||
}
|
||||
}',
|
||||
[
|
||||
'projectId' => $projectId,
|
||||
'shortDescription' => "Standard project board for {$repo}. Auto-created by moko-platform.",
|
||||
],
|
||||
$token
|
||||
);
|
||||
|
||||
echo " Project setup complete\n";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a REST API GET call via the platform adapter's ApiClient.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array
|
||||
{
|
||||
if ($apiClient !== null) {
|
||||
try {
|
||||
return $apiClient->get("/{$path}");
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform type from .mokostandards file in the repo.
|
||||
*/
|
||||
function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string
|
||||
{
|
||||
// Try platform metadata dir first, then root
|
||||
foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) {
|
||||
$data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient);
|
||||
if (!empty($data['content'])) {
|
||||
$content = base64_decode($data['content']);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
return trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub node ID for a repository.
|
||||
*/
|
||||
function getRepoNodeId(string $org, string $repo, string $token): string
|
||||
{
|
||||
$data = graphql(
|
||||
'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }',
|
||||
['owner' => $org, 'name' => $repo],
|
||||
$token
|
||||
);
|
||||
return $data['repository']['id'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub node ID for the organization owner.
|
||||
*/
|
||||
function getOrgNodeId(string $org, string $token): string
|
||||
{
|
||||
$data = graphql(
|
||||
'query($login: String!) { organization(login: $login) { id } }',
|
||||
['login' => $org],
|
||||
$token
|
||||
);
|
||||
return $data['organization']['id'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repo already has a GitHub Project linked.
|
||||
*
|
||||
* @return array{bool, string} [hasProject, projectTitle]
|
||||
*/
|
||||
function repoHasProject(string $org, string $repo, string $token): array
|
||||
{
|
||||
$data = graphql(
|
||||
'query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
projectsV2(first: 1) { nodes { id title } totalCount }
|
||||
}
|
||||
}',
|
||||
['owner' => $org, 'name' => $repo],
|
||||
$token
|
||||
);
|
||||
|
||||
$count = $data['repository']['projectsV2']['totalCount'] ?? 0;
|
||||
$title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? '';
|
||||
return [$count > 0, $title];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .tf template file to extract custom fields.
|
||||
*
|
||||
* @return array{name: string, fields: array, views: array}
|
||||
*/
|
||||
function parseTemplate(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return ['name' => 'Development Board', 'fields' => [], 'views' => []];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$result = ['name' => 'Development Board', 'fields' => [], 'views' => []];
|
||||
|
||||
// Extract project name
|
||||
if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) {
|
||||
$result['name'] = $m[1];
|
||||
}
|
||||
|
||||
// Extract custom fields
|
||||
if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$field = [
|
||||
'name' => $match[1],
|
||||
'type' => $match[2],
|
||||
'description' => $match[3],
|
||||
];
|
||||
if (!empty($match[4])) {
|
||||
$field['options'] = array_map(
|
||||
fn($o) => trim($o, " \t\n\r\"'"),
|
||||
explode(',', $match[4])
|
||||
);
|
||||
$field['options'] = array_filter($field['options']);
|
||||
}
|
||||
$result['fields'][] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub Project V2 for a repository.
|
||||
*/
|
||||
function createProject(
|
||||
string $org,
|
||||
string $repo,
|
||||
string $ownerId,
|
||||
string $repoId,
|
||||
array $template,
|
||||
string $token,
|
||||
bool $dryRun
|
||||
): bool {
|
||||
$title = "{$repo} — {$template['name']}";
|
||||
|
||||
if ($dryRun) {
|
||||
echo " (dry-run) would create project: {$title}\n";
|
||||
echo " (dry-run) fields: " . count($template['fields']) . "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Step 1: Create the project
|
||||
echo " Creating project: {$title}\n";
|
||||
$data = graphql(
|
||||
'mutation($ownerId: ID!, $title: String!) {
|
||||
createProjectV2(input: { ownerId: $ownerId, title: $title }) {
|
||||
projectV2 { id number url }
|
||||
}
|
||||
}',
|
||||
['ownerId' => $ownerId, 'title' => $title],
|
||||
$token
|
||||
);
|
||||
|
||||
$projectId = $data['createProjectV2']['projectV2']['id'] ?? '';
|
||||
$projectUrl = $data['createProjectV2']['projectV2']['url'] ?? '';
|
||||
|
||||
if (empty($projectId)) {
|
||||
fwrite(STDERR, " Failed to create project for {$repo}\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
echo " Project created: {$projectUrl}\n";
|
||||
|
||||
// Step 2: Link the project to the repository
|
||||
graphql(
|
||||
'mutation($projectId: ID!, $repositoryId: ID!) {
|
||||
linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) {
|
||||
repository { id }
|
||||
}
|
||||
}',
|
||||
['projectId' => $projectId, 'repositoryId' => $repoId],
|
||||
$token
|
||||
);
|
||||
echo " Linked to {$org}/{$repo}\n";
|
||||
|
||||
// Step 3: Create custom fields
|
||||
$fieldCount = 0;
|
||||
foreach ($template['fields'] as $field) {
|
||||
$fieldType = match ($field['type']) {
|
||||
'single_select' => 'SINGLE_SELECT',
|
||||
'text' => 'TEXT',
|
||||
'number' => 'NUMBER',
|
||||
'date' => 'DATE',
|
||||
'iteration' => 'ITERATION',
|
||||
default => 'TEXT',
|
||||
};
|
||||
|
||||
$vars = [
|
||||
'projectId' => $projectId,
|
||||
'name' => $field['name'],
|
||||
'dataType' => $fieldType,
|
||||
];
|
||||
|
||||
// Single select fields need options created with the field
|
||||
if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) {
|
||||
$optionInputs = array_map(
|
||||
fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'],
|
||||
$field['options']
|
||||
);
|
||||
$vars['singleSelectOptions'] = $optionInputs;
|
||||
|
||||
graphql(
|
||||
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
|
||||
createProjectV2Field(input: {
|
||||
projectId: $projectId,
|
||||
dataType: $dataType,
|
||||
name: $name,
|
||||
singleSelectOptions: $singleSelectOptions
|
||||
}) {
|
||||
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
|
||||
}
|
||||
}',
|
||||
$vars,
|
||||
$token
|
||||
);
|
||||
} else {
|
||||
graphql(
|
||||
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {
|
||||
createProjectV2Field(input: {
|
||||
projectId: $projectId,
|
||||
dataType: $dataType,
|
||||
name: $name
|
||||
}) {
|
||||
projectV2Field { ... on ProjectV2Field { id name } }
|
||||
}
|
||||
}',
|
||||
$vars,
|
||||
$token
|
||||
);
|
||||
}
|
||||
|
||||
$fieldCount++;
|
||||
}
|
||||
|
||||
echo " Created {$fieldCount} custom fields\n";
|
||||
|
||||
// Step 4: Update project description and README
|
||||
graphql(
|
||||
'mutation($projectId: ID!, $shortDescription: String!) {
|
||||
updateProjectV2(input: {
|
||||
projectId: $projectId,
|
||||
shortDescription: $shortDescription,
|
||||
readme: "Managed by MokoStandards. Run `php cli/create_project.php` to regenerate."
|
||||
}) {
|
||||
projectV2 { id }
|
||||
}
|
||||
}',
|
||||
[
|
||||
'projectId' => $projectId,
|
||||
'shortDescription' => "Standard project board for {$repo}. Auto-created by MokoStandards.",
|
||||
],
|
||||
$token
|
||||
);
|
||||
|
||||
echo " Project setup complete\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
$repos = [];
|
||||
|
||||
if ($allMode) {
|
||||
echo "Fetching repositories from {$org}...\n";
|
||||
$page = 1;
|
||||
do {
|
||||
$batch = restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token);
|
||||
foreach ($batch as $r) {
|
||||
if (!$r['archived'] && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
|
||||
$repos[] = $r['name'];
|
||||
}
|
||||
}
|
||||
$page++;
|
||||
} while (count($batch) === 100);
|
||||
|
||||
sort($repos);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
} else {
|
||||
$repos = [$repoName];
|
||||
}
|
||||
|
||||
$ownerId = getOrgNodeId($org, $token);
|
||||
if (empty($ownerId)) {
|
||||
fwrite(STDERR, "Could not resolve org node ID for {$org}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
echo "Processing {$repo}...\n";
|
||||
|
||||
// Check if project already exists
|
||||
[$hasProject, $existingTitle] = repoHasProject($org, $repo, $token);
|
||||
if ($hasProject) {
|
||||
echo " Already has project: {$existingTitle} — skipping\n";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect project type
|
||||
$type = $typeOverride;
|
||||
if (!$type) {
|
||||
$platform = detectRepoPlatform($org, $repo, $token);
|
||||
$type = $PLATFORM_TO_TYPE[$platform] ?? 'generic';
|
||||
echo " Platform: {$platform} → type: {$type}\n";
|
||||
}
|
||||
|
||||
// Load template
|
||||
$templateFile = $TYPE_TO_TEMPLATE[$type] ?? $TYPE_TO_TEMPLATE['generic'];
|
||||
$template = parseTemplate("{$templatesDir}/{$templateFile}");
|
||||
|
||||
// Get repo node ID
|
||||
$repoId = getRepoNodeId($org, $repo, $token);
|
||||
if (empty($repoId)) {
|
||||
fwrite(STDERR, " Could not resolve repo node ID for {$repo}\n");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the project
|
||||
$ok = createProject($org, $repo, $ownerId, $repoId, $template, $token, $dryRun);
|
||||
if ($ok) {
|
||||
$created++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat('-', 50) . "\n";
|
||||
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
$app = new CreateProjectCli();
|
||||
exit($app->execute());
|
||||
|
||||
+201
-227
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -11,243 +12,216 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/create_repo.php
|
||||
* BRIEF: Scaffold a new governed repository with full MokoStandards baseline
|
||||
*
|
||||
* USAGE
|
||||
* php cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module"
|
||||
* php cli/create_repo.php --name MokoNewModule --type joomla --private
|
||||
* php cli/create_repo.php --name MokoNewModule --type generic --dry-run
|
||||
* BRIEF: Scaffold a new governed repository with full moko-platform baseline
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\Config;
|
||||
use MokoEnterprise\PlatformAdapterFactory;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$private = in_array('--private', $argv);
|
||||
class CreateRepoCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
|
||||
$this->addArgument('--name', 'Repository name', null);
|
||||
$this->addArgument('--type', 'Project type', null);
|
||||
$this->addArgument('--description', 'Repository description', '');
|
||||
$this->addArgument('--private', 'Create as private', false);
|
||||
}
|
||||
|
||||
$name = null;
|
||||
$type = null;
|
||||
$description = '';
|
||||
protected function run(): int
|
||||
{
|
||||
$name = $this->getArgument('--name');
|
||||
$type = $this->getArgument('--type');
|
||||
$description = $this->getArgument('--description');
|
||||
$private = (bool) $this->getArgument('--private');
|
||||
if (!$name || !$type) {
|
||||
$this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
|
||||
return 2;
|
||||
}
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$TYPE_TO_PLATFORM = [
|
||||
'dolibarr' => 'crm-module',
|
||||
'dolibarr-platform' => 'crm-platform',
|
||||
'joomla' => 'waas-component',
|
||||
'nodejs' => 'nodejs',
|
||||
'terraform' => 'terraform',
|
||||
'python' => 'python',
|
||||
'wordpress' => 'wordpress',
|
||||
'generic' => 'generic',
|
||||
];
|
||||
$TYPE_TO_TOPICS = [
|
||||
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'moko-platform'],
|
||||
'joomla' => ['joomla', 'cms', 'php', 'moko-platform'],
|
||||
'nodejs' => ['nodejs', 'javascript', 'typescript', 'moko-platform'],
|
||||
'terraform' => ['terraform', 'infrastructure', 'iac', 'moko-platform'],
|
||||
'python' => ['python', 'moko-platform'],
|
||||
'wordpress' => ['wordpress', 'php', 'cms', 'moko-platform'],
|
||||
'generic' => ['moko-platform'],
|
||||
];
|
||||
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
|
||||
$topics = $TYPE_TO_TOPICS[$type] ?? ['moko-platform'];
|
||||
$platformName = $adapter->getPlatformName();
|
||||
$vis = $private ? 'private' : 'public';
|
||||
echo "Scaffolding new repository: {$org}/{$name}"
|
||||
. " (on {$platformName})\n"
|
||||
. " Type: {$type} (platform: {$platform})\n"
|
||||
. " Visibility: {$vis}\n";
|
||||
if ($description) {
|
||||
echo " Description: {$description}\n";
|
||||
} echo "\n";
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; }
|
||||
if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; }
|
||||
if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; }
|
||||
echo "Step 1: Creating repository...\n";
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$data = $adapter->createOrgRepo($org, $name, [
|
||||
'description' => $description ?: "Managed by moko-platform ({$type})",
|
||||
'private' => $private,
|
||||
'has_issues' => true,
|
||||
'has_projects' => true,
|
||||
'has_wiki' => false,
|
||||
'auto_init' => true,
|
||||
'delete_branch_on_merge' => true,
|
||||
'allow_squash_merge' => true,
|
||||
'allow_merge_commit' => false,
|
||||
'allow_rebase_merge' => false,
|
||||
]);
|
||||
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
|
||||
echo " Repository already exists -- continuing with setup\n";
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to create repo: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create {$org}/{$name}\n";
|
||||
}
|
||||
|
||||
echo "Step 2: Setting topics...\n";
|
||||
if (!$this->dryRun) {
|
||||
$adapter->setRepoTopics($org, $name, $topics);
|
||||
echo " Topics: " . implode(', ', $topics) . "\n";
|
||||
} else {
|
||||
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
|
||||
}
|
||||
|
||||
echo "Step 3: Creating .mokogitea/manifest.xml...\n";
|
||||
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
$adapter->createOrUpdateFile(
|
||||
$org,
|
||||
$name,
|
||||
'.mokogitea/manifest.xml',
|
||||
$mokoContent,
|
||||
'chore: add manifest.xml platform config [skip ci]'
|
||||
);
|
||||
echo " manifest.xml created\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create .mokogitea/manifest.xml\n";
|
||||
}
|
||||
|
||||
echo "Step 4: Creating README.md...\n";
|
||||
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
|
||||
$repoUrl = "{$baseUrl}/{$org}/{$name}";
|
||||
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
|
||||
$readmeContent = "<!--\n"
|
||||
. "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
|
||||
. "SPDX-License-Identifier: GPL-3.0-or-later\n"
|
||||
. "DEFGROUP: {$name}\n"
|
||||
. "INGROUP: moko-platform\n"
|
||||
. "REPO: {$repoUrl}\n"
|
||||
. "PATH: /README.md\n"
|
||||
. "BRIEF: {$description}\n"
|
||||
. "-->\n\n"
|
||||
. "# {$name}\n\n"
|
||||
. "{$description}\n\n"
|
||||
. "## Getting Started\n\n"
|
||||
. "This repository is governed by"
|
||||
. " [moko-platform]({$standardsUrl}).\n\n"
|
||||
. "## License\n\n"
|
||||
. "GPL-3.0-or-later. See [LICENSE](LICENSE)"
|
||||
. " for details.\n";
|
||||
if (!$this->dryRun) {
|
||||
$sha = null;
|
||||
try {
|
||||
$existing = $adapter->getFileContents($org, $name, 'README.md');
|
||||
$sha = $existing['sha'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
$adapter->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
$adapter->createOrUpdateFile(
|
||||
$org,
|
||||
$name,
|
||||
'README.md',
|
||||
$readmeContent,
|
||||
'docs: initialize README with moko-platform header [skip ci]',
|
||||
$sha
|
||||
);
|
||||
echo " README.md created\n";
|
||||
} else {
|
||||
echo " (dry-run) would create README.md\n";
|
||||
}
|
||||
|
||||
echo "Step 5: Provisioning labels...\n";
|
||||
if (!$this->dryRun) {
|
||||
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
|
||||
if (file_exists($labelScript)) {
|
||||
$exitCode = 0;
|
||||
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
|
||||
} else {
|
||||
echo " Labels will be provisioned on next sync\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would provision standard labels\n";
|
||||
}
|
||||
|
||||
echo "Step 6: Running initial sync...\n";
|
||||
if (!$this->dryRun) {
|
||||
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
|
||||
if (file_exists($syncScript)) {
|
||||
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
||||
} else {
|
||||
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would run initial sync\n";
|
||||
}
|
||||
|
||||
echo "Step 7: Creating Project...\n";
|
||||
if (!$this->dryRun) {
|
||||
$projectScript = "{$repoRoot}/api/cli/create_project.php";
|
||||
if (file_exists($projectScript)) {
|
||||
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
||||
} else {
|
||||
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create Project\n";
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat('-', 50) . "\n"
|
||||
. "Repository {$org}/{$name} scaffolded successfully\n"
|
||||
. " URL: {$repoUrl}\n"
|
||||
. " Platform: {$platform} ({$platformName})\n"
|
||||
. " Next: verify the sync and merge any PRs\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$name || !$type) {
|
||||
fwrite(STDERR, "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]\n");
|
||||
fwrite(STDERR, "\nTypes: generic, dolibarr, dolibarr-platform, joomla, nodejs, terraform, python, wordpress\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString(
|
||||
$adapter->getPlatformName() . '.organization',
|
||||
'mokoconsulting-tech'
|
||||
);
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
|
||||
$TYPE_TO_PLATFORM = [
|
||||
'dolibarr' => 'crm-module',
|
||||
'dolibarr-platform' => 'crm-platform',
|
||||
'joomla' => 'waas-component',
|
||||
'nodejs' => 'nodejs',
|
||||
'terraform' => 'terraform',
|
||||
'python' => 'python',
|
||||
'wordpress' => 'wordpress',
|
||||
'generic' => 'generic',
|
||||
];
|
||||
|
||||
$TYPE_TO_TOPICS = [
|
||||
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'],
|
||||
'joomla' => ['joomla', 'cms', 'php', 'mokostandards'],
|
||||
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'],
|
||||
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'],
|
||||
'python' => ['python', 'mokostandards'],
|
||||
'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'],
|
||||
'generic' => ['mokostandards'],
|
||||
];
|
||||
|
||||
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
|
||||
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
|
||||
$platformName = $adapter->getPlatformName();
|
||||
|
||||
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n";
|
||||
echo " Type: {$type} (platform: {$platform})\n";
|
||||
echo " Visibility: " . ($private ? 'private' : 'public') . "\n";
|
||||
if ($description) { echo " Description: {$description}\n"; }
|
||||
echo "\n";
|
||||
|
||||
// ── Step 1: Create the repository ───────────────────────────────────────
|
||||
echo "Step 1: Creating repository...\n";
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
$data = $adapter->createOrgRepo($org, $name, [
|
||||
'description' => $description ?: "Managed by MokoStandards ({$type})",
|
||||
'private' => $private,
|
||||
'has_issues' => true,
|
||||
'has_projects' => true,
|
||||
'has_wiki' => false,
|
||||
'auto_init' => true,
|
||||
'delete_branch_on_merge' => true,
|
||||
'allow_squash_merge' => true,
|
||||
'allow_merge_commit' => false,
|
||||
'allow_rebase_merge' => false,
|
||||
]);
|
||||
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
|
||||
} catch (\Exception $e) {
|
||||
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
|
||||
echo " Repository already exists — continuing with setup\n";
|
||||
} else {
|
||||
fwrite(STDERR, " Failed to create repo: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create {$org}/{$name}\n";
|
||||
}
|
||||
|
||||
// ── Step 2: Set topics ──────────────────────────────────────────────────
|
||||
echo "Step 2: Setting topics...\n";
|
||||
if (!$dryRun) {
|
||||
$adapter->setRepoTopics($org, $name, $topics);
|
||||
echo " Topics: " . implode(', ', $topics) . "\n";
|
||||
} else {
|
||||
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
|
||||
}
|
||||
|
||||
// ── Step 3: Create .mokostandards file ──────────────────────────────────
|
||||
echo "Step 3: Creating .github/.mokostandards...\n";
|
||||
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
$adapter->createOrUpdateFile(
|
||||
$org, $name, '.github/.mokostandards', $mokoContent,
|
||||
'chore: add .mokostandards platform config [skip ci]'
|
||||
);
|
||||
echo " .mokostandards created\n";
|
||||
} catch (\Exception $e) {
|
||||
echo " Warning: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create .github/.mokostandards\n";
|
||||
}
|
||||
|
||||
// ── Step 4: Create initial README.md ────────────────────────────────────
|
||||
echo "Step 4: Creating README.md...\n";
|
||||
|
||||
// Determine the repo base URL based on platform
|
||||
$baseUrl = $platformName === 'gitea'
|
||||
? $config->getString('gitea.url', 'https://git.mokoconsulting.tech')
|
||||
: 'https://github.com';
|
||||
$repoUrl = "{$baseUrl}/{$org}/{$name}";
|
||||
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
|
||||
|
||||
$readmeContent = <<<MD
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: {$name}
|
||||
INGROUP: moko-platform
|
||||
REPO: {$repoUrl}
|
||||
PATH: /README.md
|
||||
BRIEF: {$description}
|
||||
-->
|
||||
|
||||
# {$name}
|
||||
|
||||
[]({$standardsUrl})
|
||||
[]({$repoUrl})
|
||||
|
||||
{$description}
|
||||
|
||||
## Getting Started
|
||||
|
||||
This repository is governed by [MokoStandards]({$standardsUrl}).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GPL-3.0-or-later license. See [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
*This file is part of the Moko Consulting ecosystem. All rights reserved.*
|
||||
*This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.*
|
||||
MD;
|
||||
|
||||
if (!$dryRun) {
|
||||
// Get existing README sha (auto_init creates one)
|
||||
$sha = null;
|
||||
try {
|
||||
$existing = $adapter->getFileContents($org, $name, 'README.md');
|
||||
$sha = $existing['sha'] ?? null;
|
||||
} catch (\Exception $e) {
|
||||
$adapter->getApiClient()->resetCircuitBreaker();
|
||||
}
|
||||
|
||||
$adapter->createOrUpdateFile(
|
||||
$org, $name, 'README.md', $readmeContent,
|
||||
'docs: initialize README with MokoStandards header [skip ci]',
|
||||
$sha
|
||||
);
|
||||
echo " README.md created\n";
|
||||
} else {
|
||||
echo " (dry-run) would create README.md\n";
|
||||
}
|
||||
|
||||
// ── Step 5: Provision labels ────────────────────────────────────────────
|
||||
echo "Step 5: Provisioning labels...\n";
|
||||
if (!$dryRun) {
|
||||
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
|
||||
if (file_exists($labelScript)) {
|
||||
$exitCode = 0;
|
||||
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
|
||||
echo $exitCode === 0 ? " Labels provisioned\n" : " Label provisioning had issues\n";
|
||||
} else {
|
||||
echo " Labels will be provisioned on next sync\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would provision standard labels\n";
|
||||
}
|
||||
|
||||
// ── Step 6: Run first sync ──────────────────────────────────────────────
|
||||
echo "Step 6: Running initial sync...\n";
|
||||
if (!$dryRun) {
|
||||
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
|
||||
if (file_exists($syncScript)) {
|
||||
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
|
||||
} else {
|
||||
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would run initial sync\n";
|
||||
}
|
||||
|
||||
// ── Step 7: Create Project ──────────────────────────────────────────────
|
||||
echo "Step 7: Creating Project...\n";
|
||||
if (!$dryRun) {
|
||||
$projectScript = "{$repoRoot}/api/cli/create_project.php";
|
||||
if (file_exists($projectScript)) {
|
||||
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
|
||||
} else {
|
||||
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
|
||||
}
|
||||
} else {
|
||||
echo " (dry-run) would create Project\n";
|
||||
}
|
||||
|
||||
echo "\n" . str_repeat('-', 50) . "\n";
|
||||
echo "Repository {$org}/{$name} scaffolded successfully\n";
|
||||
echo " URL: {$repoUrl}\n";
|
||||
echo " Platform: {$platform} ({$platformName})\n";
|
||||
echo " Next: verify the sync and merge any PRs\n";
|
||||
$app = new CreateRepoCli();
|
||||
exit($app->execute());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+81
-78
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,88 +11,90 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/dev_branch_reset.php
|
||||
* BRIEF: Delete and recreate dev branch from main via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php dev_branch_reset.php --token TOKEN --api-base URL
|
||||
* php dev_branch_reset.php --token TOKEN --api-base URL --branch dev --from main
|
||||
*
|
||||
* Options:
|
||||
* --token Gitea API token (required)
|
||||
* --api-base Gitea API base URL (required)
|
||||
* --branch Branch to reset (default: dev)
|
||||
* --from Source branch (default: main)
|
||||
* --output-summary Write to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'dev';
|
||||
$from = 'main';
|
||||
$outputSummary = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class DevBranchResetCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Delete and recreate dev branch from main via Gitea API');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', '');
|
||||
$this->addArgument('--branch', 'Branch to reset', 'dev');
|
||||
$this->addArgument('--from', 'Source branch', 'main');
|
||||
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$token = $this->getArgument('--token') ?: getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$from = $this->getArgument('--from');
|
||||
$outputSummary = $this->getArgument('--output-summary');
|
||||
|
||||
if (empty($token) || empty($apiBase)) {
|
||||
$this->log('ERROR', 'Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Delete branch (tolerate 404)
|
||||
$ch = curl_init("{$apiBase}/branches/{$branch}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($delCode === 204 || $delCode === 200) {
|
||||
$this->log('INFO', "Deleted branch '{$branch}'");
|
||||
} elseif ($delCode === 404) {
|
||||
$this->log('INFO', "Branch '{$branch}' did not exist (skipped delete)");
|
||||
} else {
|
||||
$this->warning("Delete branch returned HTTP {$delCode}");
|
||||
}
|
||||
|
||||
// Create branch from source
|
||||
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
|
||||
$ch = curl_init("{$apiBase}/branches");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($createCode === 201) {
|
||||
$this->success("Recreated '{$branch}' from '{$from}'");
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
|
||||
if ($token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Delete branch (tolerate 404)
|
||||
$ch = curl_init("{$apiBase}/branches/{$branch}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($delCode === 204 || $delCode === 200) {
|
||||
echo "Deleted branch '{$branch}'\n";
|
||||
} elseif ($delCode === 404) {
|
||||
echo "Branch '{$branch}' did not exist (skipped delete)\n";
|
||||
} else {
|
||||
fwrite(STDERR, "WARNING: Delete branch returned HTTP {$delCode}\n");
|
||||
}
|
||||
|
||||
// Create branch from source
|
||||
$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]);
|
||||
$ch = curl_init("{$apiBase}/branches");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($createCode === 201) {
|
||||
echo "Recreated '{$branch}' from '{$from}'\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new DevBranchResetCli();
|
||||
exit($app->execute());
|
||||
|
||||
+78
-175
@@ -12,13 +12,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/grafana_dashboard.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Manage Grafana dashboards via API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GrafanaDashboard
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class GrafanaDashboardCli extends CliFramework
|
||||
{
|
||||
private string $grafanaUrl = '';
|
||||
private string $token = '';
|
||||
@@ -29,24 +33,52 @@ final class GrafanaDashboard
|
||||
private string $folderTitle = '';
|
||||
private bool $overwrite = true;
|
||||
|
||||
public function run(): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->parseArgs();
|
||||
$this->setDescription('Manage Grafana dashboards via API');
|
||||
$this->addArgument('--url', 'Grafana URL (or GRAFANA_URL)', '');
|
||||
$this->addArgument('--token', 'API token (or GRAFANA_TOKEN)', '');
|
||||
$this->addArgument('--uid', 'Dashboard UID (delete/export)', '');
|
||||
$this->addArgument('--file', 'JSON file (push/export)', '');
|
||||
$this->addArgument('--folder', 'Folder name (push/list)', '');
|
||||
$this->addArgument('--folder-id', 'Folder ID (push/list)', '0');
|
||||
$this->addArgument('--no-overwrite', 'Fail if dashboard exists', false);
|
||||
$this->addArgument('--command', 'Command: push, delete, list, export', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
// Parse positional command from raw argv
|
||||
$rawArgs = $_SERVER['argv'] ?? [];
|
||||
foreach ($rawArgs as $arg) {
|
||||
if (in_array($arg, ['push', 'delete', 'list', 'export'], true)) {
|
||||
$this->command = $arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($this->command === '' && $this->getArgument('--command') !== '') {
|
||||
$this->command = $this->getArgument('--command');
|
||||
}
|
||||
|
||||
$this->grafanaUrl = $this->getArgument('--url');
|
||||
$this->token = $this->getArgument('--token');
|
||||
$this->uid = $this->getArgument('--uid');
|
||||
$this->file = $this->getArgument('--file');
|
||||
$this->folderTitle = $this->getArgument('--folder');
|
||||
$this->folderId = (int) $this->getArgument('--folder-id');
|
||||
$this->overwrite = !$this->getArgument('--no-overwrite');
|
||||
|
||||
if ($this->grafanaUrl === '') {
|
||||
$this->grafanaUrl = getenv('GRAFANA_URL') ?: '';
|
||||
}
|
||||
$this->grafanaUrl = rtrim($this->grafanaUrl, '/');
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->token = getenv('GRAFANA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->grafanaUrl === '' || $this->token === '') {
|
||||
$this->log(
|
||||
'ERROR: --url and --token are required '
|
||||
. '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).'
|
||||
);
|
||||
$this->printUsage();
|
||||
$this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -62,12 +94,12 @@ final class GrafanaDashboard
|
||||
private function pushDashboard(): int
|
||||
{
|
||||
if ($this->file === '') {
|
||||
$this->log('ERROR: --file is required for push.');
|
||||
$this->log('ERROR', '--file is required for push.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file_exists($this->file)) {
|
||||
$this->log("ERROR: File not found: {$this->file}");
|
||||
$this->log('ERROR', "File not found: {$this->file}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -75,14 +107,12 @@ final class GrafanaDashboard
|
||||
$dashboard = json_decode($json, true);
|
||||
|
||||
if (!is_array($dashboard)) {
|
||||
$this->log('ERROR: Invalid JSON in dashboard file.');
|
||||
$this->log('ERROR', 'Invalid JSON in dashboard file.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->folderTitle !== '' && $this->folderId === 0) {
|
||||
$this->folderId = $this->resolveFolderId(
|
||||
$this->folderTitle
|
||||
);
|
||||
$this->folderId = $this->resolveFolderId($this->folderTitle);
|
||||
|
||||
if ($this->folderId < 0) {
|
||||
return 1;
|
||||
@@ -97,29 +127,23 @@ final class GrafanaDashboard
|
||||
'overwrite' => $this->overwrite,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
'/api/dashboards/db',
|
||||
$payload
|
||||
);
|
||||
$response = $this->apiRequest('POST', '/api/dashboards/db', $payload);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$data = json_decode($response['body'], true);
|
||||
$uid = $data['uid'] ?? '?';
|
||||
$url = $data['url'] ?? '';
|
||||
$status = $data['status'] ?? 'success';
|
||||
$this->log("OK: {$status} (uid: {$uid})");
|
||||
$this->log('INFO', "OK: {$status} (uid: {$uid})");
|
||||
|
||||
if ($url !== '') {
|
||||
$this->log("URL: {$this->grafanaUrl}{$url}");
|
||||
$this->log('INFO', "URL: {$this->grafanaUrl}{$url}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Push failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->log('ERROR', "Push failed (HTTP {$response['code']})");
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
@@ -128,30 +152,23 @@ final class GrafanaDashboard
|
||||
private function deleteDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for delete.');
|
||||
$this->log('ERROR', '--uid is required for delete.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'DELETE',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
$response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}");
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
$this->log("OK: Deleted dashboard {$this->uid}");
|
||||
$this->log('INFO', "OK: Deleted dashboard {$this->uid}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($response['code'] === 404) {
|
||||
$this->log(
|
||||
"WARN: Dashboard {$this->uid} not found."
|
||||
);
|
||||
$this->warning("Dashboard {$this->uid} not found.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"ERROR: Delete failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->log('ERROR', "Delete failed (HTTP {$response['code']})");
|
||||
$this->logApiError($response['body']);
|
||||
|
||||
return 1;
|
||||
@@ -176,42 +193,33 @@ final class GrafanaDashboard
|
||||
$response = $this->apiRequest('GET', $query);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: List failed (HTTP {$response['code']})"
|
||||
);
|
||||
$this->log('ERROR', "List failed (HTTP {$response['code']})");
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dashboards = json_decode($response['body'], true);
|
||||
|
||||
if (
|
||||
!is_array($dashboards)
|
||||
|| count($dashboards) === 0
|
||||
) {
|
||||
$this->log('No dashboards found.');
|
||||
if (!is_array($dashboards) || count($dashboards) === 0) {
|
||||
$this->log('INFO', 'No dashboards found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
'Title',
|
||||
'UID',
|
||||
'Folder'
|
||||
));
|
||||
$this->log(str_repeat('-', 75));
|
||||
fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder');
|
||||
fprintf(STDERR, "%s\n", str_repeat('-', 75));
|
||||
|
||||
foreach ($dashboards as $d) {
|
||||
$this->log(sprintf(
|
||||
'%-30s | %-20s | %s',
|
||||
fprintf(
|
||||
STDERR,
|
||||
"%-30s | %-20s | %s\n",
|
||||
substr($d['title'] ?? '', 0, 30),
|
||||
$d['uid'] ?? '',
|
||||
$d['folderTitle'] ?? 'General'
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log(count($dashboards) . ' dashboard(s).');
|
||||
echo "\n";
|
||||
$this->log('INFO', count($dashboards) . ' dashboard(s).');
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -219,20 +227,14 @@ final class GrafanaDashboard
|
||||
private function exportDashboard(): int
|
||||
{
|
||||
if ($this->uid === '') {
|
||||
$this->log('ERROR: --uid is required for export.');
|
||||
$this->log('ERROR', '--uid is required for export.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'GET',
|
||||
"/api/dashboards/uid/{$this->uid}"
|
||||
);
|
||||
$response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}");
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Export failed "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
$this->log('ERROR', "Export failed (HTTP {$response['code']})");
|
||||
$this->logApiError($response['body']);
|
||||
return 1;
|
||||
}
|
||||
@@ -241,9 +243,7 @@ final class GrafanaDashboard
|
||||
$dashboard = $data['dashboard'] ?? null;
|
||||
|
||||
if ($dashboard === null) {
|
||||
$this->log(
|
||||
'ERROR: No dashboard data in response.'
|
||||
);
|
||||
$this->log('ERROR', 'No dashboard data in response.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -254,9 +254,7 @@ final class GrafanaDashboard
|
||||
|
||||
if ($this->file !== '') {
|
||||
file_put_contents($this->file, $output);
|
||||
$this->log(
|
||||
"Exported {$this->uid} to {$this->file}"
|
||||
);
|
||||
$this->log('INFO', "Exported {$this->uid} to {$this->file}");
|
||||
} else {
|
||||
fwrite(STDOUT, $output);
|
||||
}
|
||||
@@ -269,10 +267,7 @@ final class GrafanaDashboard
|
||||
$response = $this->apiRequest('GET', '/api/folders');
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
$this->log(
|
||||
"ERROR: Could not fetch folders "
|
||||
. "(HTTP {$response['code']})"
|
||||
);
|
||||
$this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -283,106 +278,22 @@ final class GrafanaDashboard
|
||||
}
|
||||
|
||||
foreach ($folders as $f) {
|
||||
if (
|
||||
strcasecmp(
|
||||
$f['title'] ?? '',
|
||||
$title
|
||||
) === 0
|
||||
) {
|
||||
if (strcasecmp($f['title'] ?? '', $title) === 0) {
|
||||
return (int) ($f['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(
|
||||
"WARN: Folder \"{$title}\" not found, "
|
||||
. "using General."
|
||||
);
|
||||
$this->warning("Folder \"{$title}\" not found, using General.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function noCommand(): int
|
||||
{
|
||||
$this->log('ERROR: No command specified.');
|
||||
$this->printUsage();
|
||||
$this->log('ERROR', 'No command specified. Use: push, delete, list, export');
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case 'push':
|
||||
case 'delete':
|
||||
case 'list':
|
||||
case 'export':
|
||||
$this->command = $args[$i];
|
||||
break;
|
||||
case '--url':
|
||||
$this->grafanaUrl = rtrim(
|
||||
$args[++$i] ?? '',
|
||||
'/'
|
||||
);
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--uid':
|
||||
$this->uid = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--file':
|
||||
$this->file = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--folder-id':
|
||||
$this->folderId = (int) (
|
||||
$args[++$i] ?? 0
|
||||
);
|
||||
break;
|
||||
case '--folder':
|
||||
$this->folderTitle = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--no-overwrite':
|
||||
$this->overwrite = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log(
|
||||
"WARNING: Unknown arg: {$args[$i]}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$u = 'Usage: grafana_dashboard.php <command> '
|
||||
. '--url <url> --token <token> [options]';
|
||||
$this->log($u);
|
||||
$this->log('');
|
||||
$this->log('Commands:');
|
||||
$this->log(' push Create/update dashboard from JSON');
|
||||
$this->log(' delete Delete a dashboard by UID');
|
||||
$this->log(' list List dashboards (optionally by folder)');
|
||||
$this->log(' export Export dashboard JSON by UID');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --url <url> Grafana URL (or GRAFANA_URL)');
|
||||
$this->log(' --token <token> API token (or GRAFANA_TOKEN)');
|
||||
$this->log(' --uid <uid> Dashboard UID (delete/export)');
|
||||
$this->log(' --file <path> JSON file (push/export)');
|
||||
$this->log(' --folder <name> Folder name (push/list)');
|
||||
$this->log(' --folder-id <id> Folder ID (push/list)');
|
||||
$this->log(' --no-overwrite Fail if dashboard exists');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $method,
|
||||
string $endpoint,
|
||||
@@ -405,10 +316,7 @@ final class GrafanaDashboard
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo(
|
||||
$ch,
|
||||
CURLINFO_HTTP_CODE
|
||||
);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
@@ -430,15 +338,10 @@ final class GrafanaDashboard
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (is_array($data) && isset($data['message'])) {
|
||||
$this->log(" Grafana: {$data['message']}");
|
||||
$this->log('ERROR', " Grafana: {$data['message']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new GrafanaDashboard();
|
||||
exit($app->run());
|
||||
$app = new GrafanaDashboardCli();
|
||||
exit($app->execute());
|
||||
|
||||
+327
-285
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,299 +10,340 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_build.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||
* NOTE: Called by pre-release and auto-release workflows.
|
||||
*
|
||||
* USAGE
|
||||
* php joomla_build.php --path . --version 02.01.24
|
||||
* php joomla_build.php --path . --version 02.01.24 --suffix -dev
|
||||
* php joomla_build.php --path . --version 02.01.24 --output build --github-output
|
||||
*
|
||||
* Supports: plugin, module, component, template, package, library, file
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────
|
||||
$path = '.';
|
||||
$version = '';
|
||||
$suffix = '';
|
||||
$outputDir = 'build';
|
||||
$ghOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1];
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
if ($version === '') {
|
||||
fwrite(STDERR, "::error::--version is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$path = realpath($path) ?: $path;
|
||||
|
||||
// ── Find source directory ──────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Find manifest ──────────────────────────────────────────────────────
|
||||
$manifest = findManifest($srcDir);
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "Manifest: {$manifest}\n");
|
||||
|
||||
// ── Parse manifest ─────────────────────────────────────────────────────
|
||||
$meta = parseManifest($manifest);
|
||||
|
||||
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
|
||||
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
|
||||
$resolved = resolveLanguageKey($srcDir, $meta['name']);
|
||||
if ($resolved !== null) { $meta['name'] = $resolved; }
|
||||
}
|
||||
|
||||
$prefix = typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
|
||||
fwrite(STDERR, "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===\n");
|
||||
fwrite(STDERR, " Type: {$meta['type']}\n");
|
||||
fwrite(STDERR, " Element: {$meta['element']}\n");
|
||||
fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n");
|
||||
fwrite(STDERR, " Name: {$meta['name']}\n");
|
||||
fwrite(STDERR, " Output: {$zipName}\n");
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────
|
||||
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
|
||||
|
||||
if ($meta['type'] === 'package') {
|
||||
buildPackageZip($srcDir, $zipPath);
|
||||
} else {
|
||||
buildZip($srcDir, $zipPath);
|
||||
}
|
||||
|
||||
$sha256 = hash_file('sha256', $zipPath);
|
||||
$size = filesize($zipPath);
|
||||
|
||||
fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n");
|
||||
|
||||
// ── Output variables ───────────────────────────────────────────────────
|
||||
$vars = [
|
||||
'zip_name' => $zipName,
|
||||
'zip_path' => $zipPath,
|
||||
'sha256' => $sha256,
|
||||
'ext_type' => $meta['type'],
|
||||
'ext_element' => $meta['element'],
|
||||
'ext_name' => $meta['name'],
|
||||
'ext_group' => $meta['group'],
|
||||
'type_prefix' => $prefix,
|
||||
];
|
||||
|
||||
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
|
||||
$fh = fopen($ghFile, 'a');
|
||||
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
|
||||
fclose($fh);
|
||||
fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
|
||||
}
|
||||
|
||||
exit(0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function findManifest(string $dir): ?string
|
||||
class JoomlaBuildCli extends CliFramework
|
||||
{
|
||||
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
|
||||
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; }
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; }
|
||||
}
|
||||
// Broader nested search
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if ($item->isFile() && $item->getExtension() === 'xml') {
|
||||
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) {
|
||||
return $item->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Build a Joomla extension ZIP from manifest');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Version string (required)', '');
|
||||
$this->addArgument('--suffix', 'Version suffix (e.g. -dev)', '');
|
||||
$this->addArgument('--output', 'Output directory', 'build');
|
||||
$this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$suffix = $this->getArgument('--suffix');
|
||||
$outputDir = $this->getArgument('--output');
|
||||
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||
|
||||
if ($version === '') {
|
||||
$this->log('ERROR', '::error::--version is required');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$path = realpath($path) ?: $path;
|
||||
|
||||
// ── Find source directory ──────────────────────────────────────────────
|
||||
$srcDir = SourceResolver::resolveAbsolute($path);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', "::error::No source/ or src/ directory in {$path}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($path);
|
||||
|
||||
// ── Find manifest ──────────────────────────────────────────────────────
|
||||
$manifest = $this->findManifest($srcDir);
|
||||
if ($manifest === null) {
|
||||
$this->log('ERROR', "::error::No Joomla manifest found in {$srcDir}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Manifest: {$manifest}");
|
||||
|
||||
// ── Parse manifest ─────────────────────────────────────────────────────
|
||||
$meta = $this->parseManifest($manifest);
|
||||
|
||||
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
|
||||
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
|
||||
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
|
||||
if ($resolved !== null) {
|
||||
$meta['name'] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = $this->typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
|
||||
$this->log('INFO', "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===");
|
||||
$this->log('INFO', " Type: {$meta['type']}");
|
||||
$this->log('INFO', " Element: {$meta['element']}");
|
||||
$this->log('INFO', " Group: " . ($meta['group'] ?: 'n/a'));
|
||||
$this->log('INFO', " Name: {$meta['name']}");
|
||||
$this->log('INFO', " Output: {$zipName}");
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
if ($meta['type'] === 'package') {
|
||||
$this->buildPackageZip($srcDir, $zipPath);
|
||||
} else {
|
||||
$this->buildZip($srcDir, $zipPath);
|
||||
}
|
||||
|
||||
$sha256 = hash_file('sha256', $zipPath);
|
||||
$size = filesize($zipPath);
|
||||
|
||||
$this->log('INFO', "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)");
|
||||
|
||||
// ── Output variables ───────────────────────────────────────────────────
|
||||
$vars = [
|
||||
'zip_name' => $zipName,
|
||||
'zip_path' => $zipPath,
|
||||
'sha256' => $sha256,
|
||||
'ext_type' => $meta['type'],
|
||||
'ext_element' => $meta['element'],
|
||||
'ext_name' => $meta['name'],
|
||||
'ext_group' => $meta['group'],
|
||||
'type_prefix' => $prefix,
|
||||
];
|
||||
|
||||
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
|
||||
$fh = fopen($ghFile, 'a');
|
||||
foreach ($vars as $k => $v) {
|
||||
fwrite($fh, "{$k}={$v}\n");
|
||||
}
|
||||
fclose($fh);
|
||||
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
|
||||
} else {
|
||||
foreach ($vars as $k => $v) {
|
||||
echo "{$k}={$v}\n";
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Private methods
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private function findManifest(string $dir): ?string
|
||||
{
|
||||
// Priority: pkg_*.xml (packages), then any *.xml with <extension>
|
||||
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) {
|
||||
return $f;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (str_contains((string) file_get_contents($f), '<extension')) {
|
||||
return $f;
|
||||
}
|
||||
}
|
||||
// Broader nested search
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if ($item->isFile() && $item->getExtension() === 'xml') {
|
||||
if (str_contains((string) file_get_contents($item->getPathname()), '<extension')) {
|
||||
return $item->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseManifest(string $file): array
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
$name = (string) ($xml->name ?? '');
|
||||
$type = (string) ($xml->attributes()->type ?? 'component');
|
||||
$element = (string) ($xml->element ?? '');
|
||||
$group = (string) ($xml->attributes()->group ?? '');
|
||||
|
||||
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
|
||||
if ($type === 'package' && $element === '') {
|
||||
$packageName = (string) ($xml->packagename ?? '');
|
||||
if ($packageName !== '') {
|
||||
$element = $packageName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback element detection
|
||||
if ($element === '') {
|
||||
$element = (string) ($xml->attributes()->plugin ?? '');
|
||||
}
|
||||
if ($element === '') {
|
||||
$element = (string) ($xml->attributes()->module ?? '');
|
||||
}
|
||||
if ($element === '') {
|
||||
$element = strtolower(basename($file, '.xml'));
|
||||
if (in_array($element, ['templatedetails', 'manifest'], true)) {
|
||||
$element = strtolower(basename(dirname($file)));
|
||||
}
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
|
||||
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
||||
|
||||
if ($name === '') {
|
||||
$name = $element;
|
||||
}
|
||||
|
||||
return compact('name', 'type', 'element', 'group');
|
||||
}
|
||||
|
||||
private function typePrefix(array $meta): string
|
||||
{
|
||||
return match ($meta['type']) {
|
||||
'plugin' => "plg_{$meta['group']}_",
|
||||
'module' => 'mod_',
|
||||
'component' => 'com_',
|
||||
'template' => 'tpl_',
|
||||
'package' => 'pkg_',
|
||||
'library' => 'lib_',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveLanguageKey(string $srcDir, string $key): ?string
|
||||
{
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
|
||||
foreach (file($item->getPathname()) as $line) {
|
||||
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isExcluded(string $name): bool
|
||||
{
|
||||
if ($name === '.ftpignore') {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($name, 'sftp-config')) {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($name, '.env')) {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($name, '.build-trigger')) {
|
||||
return true;
|
||||
}
|
||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
|
||||
}
|
||||
|
||||
private function buildZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "::error::Cannot create ZIP: {$outPath}");
|
||||
return;
|
||||
}
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $file) {
|
||||
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
|
||||
if ($this->isExcluded(basename($local))) {
|
||||
continue;
|
||||
}
|
||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
private function buildPackageZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$this->log('INFO', "Building Joomla package (multi-extension)...");
|
||||
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
|
||||
mkdir($staging, 0755, true);
|
||||
|
||||
// 1. Zip each sub-extension in packages/
|
||||
$packagesDir = "{$srcDir}/packages";
|
||||
if (is_dir($packagesDir)) {
|
||||
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
|
||||
$subManifest = $this->findManifest($extDir);
|
||||
if ($subManifest) {
|
||||
$sub = $this->parseManifest($subManifest);
|
||||
$subPrefix = $this->typePrefix($sub);
|
||||
$subZipName = "{$subPrefix}{$sub['element']}.zip";
|
||||
} else {
|
||||
$subZipName = basename($extDir) . '.zip';
|
||||
}
|
||||
|
||||
$this->log('INFO', " Sub-extension: {$subZipName}");
|
||||
$this->buildZip($extDir, "{$staging}/{$subZipName}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Copy package-level files (manifest, script, language)
|
||||
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
|
||||
copy($f, "{$staging}/" . basename($f));
|
||||
}
|
||||
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
|
||||
copy($f, "{$staging}/" . basename($f));
|
||||
}
|
||||
foreach (['language', 'administrator'] as $d) {
|
||||
if (is_dir("{$srcDir}/{$d}")) {
|
||||
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create outer zip
|
||||
$this->buildZip($staging, $outPath);
|
||||
|
||||
// Cleanup
|
||||
$this->rmTree($staging);
|
||||
}
|
||||
|
||||
private function copyTree(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) {
|
||||
mkdir($dst, 0755, true);
|
||||
}
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$target = "{$dst}/" . $iter->getSubPathname();
|
||||
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
|
||||
private function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
function parseManifest(string $file): array
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
$name = (string) ($xml->name ?? '');
|
||||
$type = (string) ($xml->attributes()->type ?? 'component');
|
||||
$element = (string) ($xml->element ?? '');
|
||||
$group = (string) ($xml->attributes()->group ?? '');
|
||||
|
||||
// For packages, prefer <packagename> as the clean element (avoids pkg_pkg_ duplication)
|
||||
if ($type === 'package' && $element === '') {
|
||||
$packageName = (string) ($xml->packagename ?? '');
|
||||
if ($packageName !== '') {
|
||||
$element = $packageName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback element detection
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
|
||||
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
|
||||
if ($element === '') {
|
||||
$element = strtolower(basename($file, '.xml'));
|
||||
if (in_array($element, ['templatedetails', 'manifest'], true)) {
|
||||
$element = strtolower(basename(dirname($file)));
|
||||
}
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
|
||||
|
||||
if ($name === '') { $name = $element; }
|
||||
|
||||
return compact('name', 'type', 'element', 'group');
|
||||
}
|
||||
|
||||
function typePrefix(array $meta): string
|
||||
{
|
||||
return match ($meta['type']) {
|
||||
'plugin' => "plg_{$meta['group']}_",
|
||||
'module' => 'mod_',
|
||||
'component' => 'com_',
|
||||
'template' => 'tpl_',
|
||||
'package' => 'pkg_',
|
||||
'library' => 'lib_',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLanguageKey(string $srcDir, string $key): ?string
|
||||
{
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) {
|
||||
foreach (file($item->getPathname()) as $line) {
|
||||
if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExcluded(string $name): bool
|
||||
{
|
||||
if ($name === '.ftpignore') return true;
|
||||
if (str_starts_with($name, 'sftp-config')) return true;
|
||||
if (str_starts_with($name, '.env')) return true;
|
||||
if (str_starts_with($name, '.build-trigger')) return true;
|
||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
|
||||
}
|
||||
|
||||
function buildZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $file) {
|
||||
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
|
||||
if (isExcluded(basename($local))) continue;
|
||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
function buildPackageZip(string $srcDir, string $outPath): void
|
||||
{
|
||||
fwrite(STDERR, "Building Joomla package (multi-extension)...\n");
|
||||
$staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid();
|
||||
mkdir($staging, 0755, true);
|
||||
|
||||
// 1. Zip each sub-extension in packages/
|
||||
$packagesDir = "{$srcDir}/packages";
|
||||
if (is_dir($packagesDir)) {
|
||||
foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) {
|
||||
$subManifest = findManifest($extDir);
|
||||
if ($subManifest) {
|
||||
$sub = parseManifest($subManifest);
|
||||
$subPrefix = typePrefix($sub);
|
||||
$subZipName = "{$subPrefix}{$sub['element']}.zip";
|
||||
} else {
|
||||
$subZipName = basename($extDir) . '.zip';
|
||||
}
|
||||
|
||||
fwrite(STDERR, " Sub-extension: {$subZipName}\n");
|
||||
buildZip($extDir, "{$staging}/{$subZipName}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Copy package-level files (manifest, script, language)
|
||||
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
||||
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
|
||||
foreach (['language', 'administrator'] as $d) {
|
||||
if (is_dir("{$srcDir}/{$d}")) {
|
||||
copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create outer zip
|
||||
buildZip($staging, $outPath);
|
||||
|
||||
// Cleanup
|
||||
rmTree($staging);
|
||||
}
|
||||
|
||||
function copyTree(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) mkdir($dst, 0755, true);
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$target = "{$dst}/" . $iter->getSubPathname();
|
||||
$item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
|
||||
function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) return;
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
$app = new JoomlaBuildCli();
|
||||
exit($app->execute());
|
||||
|
||||
+125
-117
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,127 +11,134 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/joomla_compat_check.php
|
||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
||||
*
|
||||
* Usage:
|
||||
* php joomla_compat_check.php --path /repo
|
||||
* php joomla_compat_check.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$ghOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class JoomlaCompatCheckCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Check if extension targetplatform regex matches the latest Joomla version');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$ghOutput = $this->getArgument('--github-output');
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- Find manifest and extract targetplatform --
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
$this->log('ERROR', 'No manifest with targetplatform found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$xml = file_get_contents($manifest);
|
||||
$relManifest = str_replace($root . '/', '', $manifest);
|
||||
|
||||
// Extract targetplatform version regex
|
||||
$targetRegex = '';
|
||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||
$targetRegex = $m[1];
|
||||
}
|
||||
|
||||
if (empty($targetRegex)) {
|
||||
echo "No targetplatform version found in {$relManifest}\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
echo "Manifest: {$relManifest}\n";
|
||||
echo "Target regex: {$targetRegex}\n";
|
||||
|
||||
// -- Fetch latest Joomla version --
|
||||
$joomlaVersions = [];
|
||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
|
||||
if ($updateXml === false) {
|
||||
// Fallback: try the LTS feed
|
||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
}
|
||||
|
||||
if ($updateXml !== false) {
|
||||
// Parse all version entries
|
||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||
$joomlaVersions = $matches[1] ?? [];
|
||||
}
|
||||
|
||||
if (empty($joomlaVersions)) {
|
||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||
echo "Tested URL: {$updateUrl}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sort and get latest
|
||||
usort($joomlaVersions, 'version_compare');
|
||||
$latestJoomla = end($joomlaVersions);
|
||||
|
||||
echo "Latest Joomla: {$latestJoomla}\n";
|
||||
|
||||
// -- Test compatibility --
|
||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||
|
||||
if ($compatible === false) {
|
||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||
$result = 'error';
|
||||
} elseif ($compatible === 1) {
|
||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||
$result = 'pass';
|
||||
} else {
|
||||
// Check which major versions are supported
|
||||
$supported = [];
|
||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
||||
$supported[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
|
||||
echo "Supported versions: " . implode(', ', $supported) . "\n";
|
||||
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
|
||||
$result = 'warn';
|
||||
}
|
||||
|
||||
// -- Export --
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
return $result === 'error' ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Find manifest and extract targetplatform ────────────────────────────
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false && strpos($xml, 'targetplatform') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No manifest with targetplatform found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$xml = file_get_contents($manifest);
|
||||
$relManifest = str_replace($root . '/', '', $manifest);
|
||||
|
||||
// Extract targetplatform version regex
|
||||
$targetRegex = '';
|
||||
if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) {
|
||||
$targetRegex = $m[1];
|
||||
}
|
||||
|
||||
if (empty($targetRegex)) {
|
||||
echo "No targetplatform version found in {$relManifest}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Manifest: {$relManifest}\n";
|
||||
echo "Target regex: {$targetRegex}\n";
|
||||
|
||||
// ── Fetch latest Joomla version ─────────────────────────────────────────
|
||||
$joomlaVersions = [];
|
||||
$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
|
||||
if ($updateXml === false) {
|
||||
// Fallback: try the LTS feed
|
||||
$updateUrl = 'https://update.joomla.org/core/list.xml';
|
||||
$updateXml = @file_get_contents($updateUrl);
|
||||
}
|
||||
|
||||
if ($updateXml !== false) {
|
||||
// Parse all version entries
|
||||
preg_match_all('/<version>([^<]+)<\/version>/', $updateXml, $matches);
|
||||
$joomlaVersions = $matches[1] ?? [];
|
||||
}
|
||||
|
||||
if (empty($joomlaVersions)) {
|
||||
echo "WARNING: Could not fetch Joomla versions from update server\n";
|
||||
echo "Tested URL: {$updateUrl}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Sort and get latest
|
||||
usort($joomlaVersions, 'version_compare');
|
||||
$latestJoomla = end($joomlaVersions);
|
||||
|
||||
echo "Latest Joomla: {$latestJoomla}\n";
|
||||
|
||||
// ── Test compatibility ──────────────────────────────────────────────────
|
||||
// The targetplatform regex uses Joomla's regex format
|
||||
// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))"
|
||||
$compatible = @preg_match("/{$targetRegex}/", $latestJoomla);
|
||||
|
||||
if ($compatible === false) {
|
||||
echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n";
|
||||
$result = 'error';
|
||||
} elseif ($compatible === 1) {
|
||||
echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n";
|
||||
$result = 'pass';
|
||||
} else {
|
||||
// Check which major versions are supported
|
||||
$supported = [];
|
||||
foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) {
|
||||
if (@preg_match("/{$targetRegex}/", $v)) {
|
||||
$supported[] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n";
|
||||
echo "Supported versions: " . implode(', ', $supported) . "\n";
|
||||
echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n";
|
||||
$result = 'warn';
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($result === 'error' ? 1 : 0);
|
||||
$app = new JoomlaCompatCheckCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/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.CLI
|
||||
* INGROUP: mokoplatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||
* PATH: /cli/joomla_metadata_validate.php
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class JoomlaMetadataValidateCli extends CliFramework
|
||||
{
|
||||
/** Joomla element prefix map — must match MokoGitea's cleanJoomlaElement() */
|
||||
private const JOOMLA_PREFIX = [
|
||||
'package' => 'pkg_',
|
||||
'component' => 'com_',
|
||||
'module' => 'mod_',
|
||||
'template' => 'tpl_',
|
||||
'library' => 'lib_',
|
||||
'file' => 'file_',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Validate MokoGitea repo metadata against Joomla extension manifest XML');
|
||||
$this->addArgument('--path', 'Repo root path (default: current directory)', '.');
|
||||
$this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
|
||||
$this->addArgument('--org', 'Gitea org', 'MokoConsulting');
|
||||
$this->addArgument('--repo', 'Repo name (auto-detected from git if empty)', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
|
||||
$this->addArgument('--ci', 'CI mode: exit 1 on any error', false);
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
|
||||
$token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
|
||||
$org = $this->getArgument('--org');
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$apiBase = rtrim($this->getArgument('--api-base'), '/');
|
||||
$ciMode = (bool) $this->getArgument('--ci');
|
||||
$jsonMode = (bool) $this->getArgument('--json');
|
||||
|
||||
if (!is_dir($path)) {
|
||||
$this->log('ERROR', "Path does not exist: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($repoName === '') {
|
||||
$repoName = $this->detectRepoName($path);
|
||||
}
|
||||
|
||||
// ── Step 1: Find the Joomla extension manifest XML ──────────
|
||||
$joomlaXml = $this->findJoomlaManifest($path);
|
||||
|
||||
if ($joomlaXml === null) {
|
||||
$this->log('ERROR', 'No Joomla extension manifest XML found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Joomla manifest: {$joomlaXml['path']}");
|
||||
|
||||
// ── Step 2: Load MokoGitea metadata ─────────────────────────
|
||||
$metadata = $this->loadMetadata($path, $org, $repoName, $token, $apiBase);
|
||||
|
||||
if ($metadata === null) {
|
||||
$this->log('ERROR', 'Could not load MokoGitea metadata');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Step 3: Compare ─────────────────────────────────────────
|
||||
$results = $this->compare($metadata, $joomlaXml, $path);
|
||||
|
||||
// ── Step 4: Output ──────────────────────────────────────────
|
||||
if ($jsonMode) {
|
||||
echo json_encode([
|
||||
'repo' => $repoName,
|
||||
'results' => $results,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
} else {
|
||||
$this->printResults($repoName, $results);
|
||||
}
|
||||
|
||||
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
|
||||
|
||||
return ($ciMode && $errors > 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Find Joomla manifest XML
|
||||
// =================================================================
|
||||
|
||||
private function findJoomlaManifest(string $root): ?array
|
||||
{
|
||||
// Search common locations for a Joomla extension manifest
|
||||
$candidates = [];
|
||||
|
||||
// Package manifest: source/pkg_*.xml
|
||||
foreach (glob("{$root}/source/pkg_*.xml") as $file) {
|
||||
$candidates[] = $file;
|
||||
}
|
||||
|
||||
// Component manifest: source/packages/com_*/[name].xml
|
||||
foreach (glob("{$root}/source/packages/com_*/*.xml") as $file) {
|
||||
$basename = basename($file);
|
||||
// Skip access.xml, config.xml, etc.
|
||||
if (in_array($basename, ['access.xml', 'config.xml'], true)) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = $file;
|
||||
}
|
||||
|
||||
// Direct source/*.xml
|
||||
foreach (glob("{$root}/source/*.xml") as $file) {
|
||||
if (basename($file) !== 'pkg_mokosuitebackup.xml') {
|
||||
// Already caught above
|
||||
}
|
||||
$candidates[] = $file;
|
||||
}
|
||||
|
||||
// src/ fallback
|
||||
foreach (glob("{$root}/src/pkg_*.xml") as $file) {
|
||||
$candidates[] = $file;
|
||||
}
|
||||
|
||||
// Find the first one that has <extension type="...">
|
||||
foreach (array_unique($candidates) as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/<extension\s[^>]*type=["\']([^"\']+)["\']/', $content, $typeMatch)) {
|
||||
$xml = @simplexml_load_string($content);
|
||||
if ($xml === false) {
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
$this->log('WARN', "Skipping {$relPath}: malformed XML");
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = strtolower($typeMatch[1]);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
|
||||
return [
|
||||
'path' => $relPath,
|
||||
'type' => $type,
|
||||
'xml' => $xml,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Load metadata (from API)
|
||||
// =================================================================
|
||||
|
||||
private function loadMetadata(string $root, string $org, string $repoName, string $token, string $apiBase): ?array
|
||||
{
|
||||
if ($token === '') {
|
||||
$this->log('ERROR', 'No API token provided (use --token or set GITEA_TOKEN env var)');
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = "{$apiBase}/repos/{$org}/{$repoName}/metadata";
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$body = file_get_contents($url, false, $ctx);
|
||||
|
||||
// Extract HTTP status from response headers
|
||||
$httpCode = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
||||
$httpCode = (int) $m[0];
|
||||
}
|
||||
|
||||
if ($body === false) {
|
||||
$this->log('ERROR', "Failed to connect to {$url} — check network or TLS configuration");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode === 404) {
|
||||
$this->log('ERROR', "API endpoint not found: {$url}");
|
||||
$this->log('ERROR', 'Server may need MokoGitea-Fork >= #650 (metadata endpoint rename)');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode === 401 || $httpCode === 403) {
|
||||
$this->log('ERROR', "Authentication failed (HTTP {$httpCode}) — check your API token");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$this->log('ERROR', "API returned HTTP {$httpCode}: " . substr($body, 0, 200));
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
if (!is_array($data)) {
|
||||
$this->log('ERROR', "API returned invalid JSON from {$url}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$data['source'] = 'api';
|
||||
return $data;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compare metadata against Joomla manifest
|
||||
// =================================================================
|
||||
|
||||
private function compare(array $metadata, array $joomlaXml, string $root): array
|
||||
{
|
||||
$results = [];
|
||||
$xml = $joomlaXml['xml'];
|
||||
$type = $joomlaXml['type'];
|
||||
|
||||
// 1. Extension type
|
||||
$metaType = $this->normalizeExtensionType(
|
||||
$metadata['extension_type'] ?? $metadata['package_type'] ?? ''
|
||||
);
|
||||
$results[] = [
|
||||
'field' => 'extension_type',
|
||||
'metadata' => $metaType,
|
||||
'joomla' => $type,
|
||||
'status' => ($metaType === $type) ? 'ok' : 'error',
|
||||
'message' => ($metaType === $type)
|
||||
? "matches <extension type=\"{$type}\">"
|
||||
: "metadata has \"{$metaType}\" but Joomla manifest has \"{$type}\"",
|
||||
];
|
||||
|
||||
// 2. Element name
|
||||
$metaName = strtolower($metadata['name'] ?? '');
|
||||
$metaElement = $this->deriveElement($metaType, $metaName);
|
||||
$joomlaElement = $this->extractJoomlaElement($xml, $type);
|
||||
|
||||
$elementMatch = ($metaElement === $joomlaElement);
|
||||
$results[] = [
|
||||
'field' => 'element',
|
||||
'metadata' => $metaElement,
|
||||
'joomla' => $joomlaElement,
|
||||
'status' => $elementMatch ? 'ok' : 'error',
|
||||
'message' => $elementMatch
|
||||
? "derived correctly"
|
||||
: "metadata derives \"{$metaElement}\" but Joomla uses \"{$joomlaElement}\"",
|
||||
];
|
||||
|
||||
// 3. Version
|
||||
$metaVersion = $metadata['version'] ?? '';
|
||||
$joomlaVersion = (string) ($xml->version ?? '');
|
||||
|
||||
if ($metaVersion !== '' && $joomlaVersion !== '') {
|
||||
// Strip dev/rc suffixes for comparison (CI bumps these)
|
||||
$metaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $metaVersion);
|
||||
$joomlaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $joomlaVersion);
|
||||
$versionMatch = ($metaBase === $joomlaBase);
|
||||
|
||||
$results[] = [
|
||||
'field' => 'version',
|
||||
'metadata' => $metaVersion,
|
||||
'joomla' => $joomlaVersion,
|
||||
'status' => $versionMatch ? 'ok' : 'warn',
|
||||
'message' => $versionMatch
|
||||
? 'matches (base version)'
|
||||
: "metadata has \"{$metaVersion}\" but Joomla has \"{$joomlaVersion}\"",
|
||||
];
|
||||
}
|
||||
|
||||
// 4. PHP minimum (from composer.json)
|
||||
$composerPhp = $this->readComposerPhpRequirement($root);
|
||||
$metaPhp = $metadata['php_minimum'] ?? '';
|
||||
|
||||
if ($composerPhp !== '' && $metaPhp !== '') {
|
||||
$phpMatch = ($metaPhp === $composerPhp);
|
||||
$results[] = [
|
||||
'field' => 'php_minimum',
|
||||
'metadata' => $metaPhp,
|
||||
'joomla' => $composerPhp . ' (composer.json)',
|
||||
'status' => $phpMatch ? 'ok' : 'warn',
|
||||
'message' => $phpMatch
|
||||
? 'matches composer.json'
|
||||
: "metadata has \"{$metaPhp}\" but composer.json requires \"{$composerPhp}\"",
|
||||
];
|
||||
}
|
||||
|
||||
// 5. Description
|
||||
$metaDesc = $metadata['description'] ?? '';
|
||||
$joomlaDesc = (string) ($xml->description ?? '');
|
||||
|
||||
// Joomla descriptions are often language keys, skip those
|
||||
if ($metaDesc !== '' && $joomlaDesc !== '' && !str_starts_with($joomlaDesc, 'COM_') && !str_starts_with($joomlaDesc, 'PKG_')) {
|
||||
$descMatch = ($metaDesc === $joomlaDesc);
|
||||
$results[] = [
|
||||
'field' => 'description',
|
||||
'metadata' => substr($metaDesc, 0, 60) . (strlen($metaDesc) > 60 ? '...' : ''),
|
||||
'joomla' => substr($joomlaDesc, 0, 60) . (strlen($joomlaDesc) > 60 ? '...' : ''),
|
||||
'status' => $descMatch ? 'ok' : 'info',
|
||||
'message' => $descMatch ? 'matches' : 'descriptions differ (informational)',
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Normalize extension_type — map MokoGitea types to Joomla types.
|
||||
*/
|
||||
private function normalizeExtensionType(string $type): string
|
||||
{
|
||||
return match (strtolower($type)) {
|
||||
'joomla-extension' => 'package', // legacy mapping
|
||||
default => strtolower($type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Joomla element name from type + name.
|
||||
* Replicates MokoGitea's cleanJoomlaElement() + prefix logic.
|
||||
*/
|
||||
private function deriveElement(string $type, string $name): string
|
||||
{
|
||||
// Clean: lowercase, strip non-alphanumeric except . _ -
|
||||
$clean = strtolower($name);
|
||||
$clean = preg_replace('/[^a-z0-9._-]/', '', $clean);
|
||||
|
||||
$prefix = self::JOOMLA_PREFIX[$type] ?? '';
|
||||
|
||||
return $prefix . $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the element name from a Joomla manifest XML.
|
||||
* Follows the same logic as Joomla's InstallerAdapter::getElement().
|
||||
*/
|
||||
private function extractJoomlaElement(\SimpleXMLElement $xml, string $type): string
|
||||
{
|
||||
switch ($type) {
|
||||
case 'package':
|
||||
$packagename = (string) ($xml->packagename ?? '');
|
||||
if ($packagename !== '') {
|
||||
return 'pkg_' . strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $packagename));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'component':
|
||||
$element = (string) ($xml->element ?? '');
|
||||
if ($element !== '') {
|
||||
$element = strtolower($element);
|
||||
return str_starts_with($element, 'com_') ? $element : 'com_' . $element;
|
||||
}
|
||||
$name = (string) ($xml->name ?? '');
|
||||
$name = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
|
||||
return str_starts_with($name, 'com_') ? $name : 'com_' . $name;
|
||||
|
||||
case 'module':
|
||||
$element = (string) ($xml->element ?? '');
|
||||
if ($element !== '') {
|
||||
return strtolower($element);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plugin':
|
||||
// Plugins derive element from the file attribute
|
||||
if (isset($xml->files)) {
|
||||
foreach ($xml->files->children() as $file) {
|
||||
$plugin = (string) ($file->attributes()->plugin ?? '');
|
||||
if ($plugin !== '') {
|
||||
return strtolower($plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'library':
|
||||
$libname = (string) ($xml->libraryname ?? '');
|
||||
if ($libname !== '') {
|
||||
return strtolower($libname);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: use <name> tag
|
||||
$name = (string) ($xml->name ?? '');
|
||||
return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PHP version requirement from composer.json.
|
||||
*/
|
||||
private function readComposerPhpRequirement(string $root): string
|
||||
{
|
||||
$composerFile = "{$root}/composer.json";
|
||||
|
||||
if (!is_file($composerFile)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($composerFile), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$phpReq = $data['require']['php'] ?? '';
|
||||
|
||||
// Extract version number from constraint like ">=8.1"
|
||||
if (preg_match('/(\d+\.\d+)/', $phpReq, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function detectRepoName(string $root): string
|
||||
{
|
||||
$gitConfig = "{$root}/.git/config";
|
||||
|
||||
if (!file_exists($gitConfig)) {
|
||||
return basename($root);
|
||||
}
|
||||
|
||||
$content = file_get_contents($gitConfig);
|
||||
|
||||
if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return basename($root);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Output
|
||||
// =================================================================
|
||||
|
||||
private function printResults(string $repoName, array $results): void
|
||||
{
|
||||
$errors = count(array_filter($results, fn($r) => $r['status'] === 'error'));
|
||||
$warns = count(array_filter($results, fn($r) => $r['status'] === 'warn'));
|
||||
$oks = count(array_filter($results, fn($r) => $r['status'] === 'ok'));
|
||||
|
||||
$this->log('INFO', "Validating {$repoName} Joomla metadata...\n");
|
||||
|
||||
foreach ($results as $r) {
|
||||
$icon = match ($r['status']) {
|
||||
'ok' => "\xE2\x9C\x93", // ✓
|
||||
'error' => "\xE2\x9C\x97", // ✗
|
||||
'warn' => "\xE2\x9A\xA0", // ⚠
|
||||
default => "\xE2\x84\xB9", // ℹ
|
||||
};
|
||||
|
||||
$line = sprintf(
|
||||
" %s %-16s %s",
|
||||
$icon,
|
||||
$r['field'],
|
||||
$r['message']
|
||||
);
|
||||
|
||||
$this->log(
|
||||
match ($r['status']) {
|
||||
'error' => 'ERROR',
|
||||
'warn' => 'WARN',
|
||||
'ok' => 'OK',
|
||||
default => 'INFO',
|
||||
},
|
||||
$line
|
||||
);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
if ($errors > 0) {
|
||||
$this->log('ERROR', "{$errors} error(s) — update delivery will fail");
|
||||
} elseif ($warns > 0) {
|
||||
$this->log('WARN', "All critical checks passed, {$warns} warning(s)");
|
||||
} else {
|
||||
$this->log('OK', "All {$oks} checks passed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$app = new JoomlaMetadataValidateCli();
|
||||
exit($app->execute());
|
||||
+50
-22
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -24,7 +25,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory};
|
||||
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver};
|
||||
|
||||
/**
|
||||
* Joomla Release Manager
|
||||
@@ -36,7 +37,7 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapte
|
||||
*/
|
||||
class JoomlaRelease extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const VERSION = '09.23.00';
|
||||
private const ORG = 'MokoConsulting';
|
||||
|
||||
private const STABILITY_TAGS = [
|
||||
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
|
||||
'stable' => '',
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private ApiClient $api;
|
||||
private \MokoEnterprise\GitPlatformAdapter $adapter;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
|
||||
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
||||
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
||||
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
|
||||
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
|
||||
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
|
||||
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
||||
$this->addArgument('--verbose', 'Show detailed output', false);
|
||||
$this->addArgument('--dry-run', 'Preview without making changes', false);
|
||||
$this->addArgument('--verbose', 'Show detailed output', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
|
||||
|
||||
if ($repo !== '') {
|
||||
$path = $this->cloneRepo($repo);
|
||||
if ($path === null) { return 1; }
|
||||
if ($path === null) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$path = rtrim($path, '/\\');
|
||||
|
||||
@@ -118,11 +121,12 @@ class JoomlaRelease extends CliFramework
|
||||
$this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}");
|
||||
|
||||
// ── Step 3: Build packages ────────────────────────────────────
|
||||
$srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null);
|
||||
$srcDir = SourceResolver::resolveAbsolute($path);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', 'No src/ or htdocs/ directory');
|
||||
$this->log('ERROR', 'No source/ or src/ directory');
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($path);
|
||||
|
||||
$prefix = $this->typePrefix($meta);
|
||||
$zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip";
|
||||
@@ -191,7 +195,9 @@ class JoomlaRelease extends CliFramework
|
||||
private function findManifest(string $path): ?string
|
||||
{
|
||||
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
|
||||
if (!is_dir($dir)) { continue; }
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") as $file) {
|
||||
if (str_contains((string) file_get_contents($file), '<extension')) {
|
||||
return $file;
|
||||
@@ -235,7 +241,9 @@ class JoomlaRelease extends CliFramework
|
||||
private function readVersion(string $path): ?string
|
||||
{
|
||||
$readme = "{$path}/README.md";
|
||||
if (!is_file($readme)) { return null; }
|
||||
if (!is_file($readme)) {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
@@ -301,8 +309,12 @@ class JoomlaRelease extends CliFramework
|
||||
}
|
||||
|
||||
// 2. Copy package-level files (manifest, script, language)
|
||||
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
||||
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); }
|
||||
foreach (glob("{$srcDir}/*.xml") as $f) {
|
||||
copy($f, "{$staging}/" . basename($f));
|
||||
}
|
||||
foreach (glob("{$srcDir}/*.php") as $f) {
|
||||
copy($f, "{$staging}/" . basename($f));
|
||||
}
|
||||
foreach (['language', 'administrator'] as $d) {
|
||||
if (is_dir("{$srcDir}/{$d}")) {
|
||||
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
|
||||
@@ -321,7 +333,9 @@ class JoomlaRelease extends CliFramework
|
||||
*/
|
||||
private function copyDir(string $src, string $dst): void
|
||||
{
|
||||
if (!is_dir($dst)) { mkdir($dst, 0755, true); }
|
||||
if (!is_dir($dst)) {
|
||||
mkdir($dst, 0755, true);
|
||||
}
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
@@ -342,7 +356,9 @@ class JoomlaRelease extends CliFramework
|
||||
);
|
||||
foreach ($iter as $file) {
|
||||
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
|
||||
if ($this->isExcluded(basename($local))) { continue; }
|
||||
if ($this->isExcluded(basename($local))) {
|
||||
continue;
|
||||
}
|
||||
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
|
||||
}
|
||||
$zip->close();
|
||||
@@ -359,17 +375,29 @@ class JoomlaRelease extends CliFramework
|
||||
|
||||
private function isExcluded(string $name): bool
|
||||
{
|
||||
if ($name === '.ftpignore') { return true; }
|
||||
if (str_starts_with($name, 'sftp-config')) { return true; }
|
||||
if (str_starts_with($name, '.env')) { return true; }
|
||||
if ($name === '.ftpignore') {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($name, 'sftp-config')) {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with($name, '.env')) {
|
||||
return true;
|
||||
}
|
||||
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||
return in_array($ext, ['ppk', 'pem', 'key'], true);
|
||||
}
|
||||
|
||||
// ── GitHub Release ───────────────────────────────────────────────
|
||||
|
||||
private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void
|
||||
{
|
||||
private function ensureRelease(
|
||||
string $repo,
|
||||
string $tag,
|
||||
string $version,
|
||||
string $stability,
|
||||
string $extName = '',
|
||||
string $packageName = ''
|
||||
): void {
|
||||
$releaseName = $extName !== ''
|
||||
? "{$extName} {$version} ({$packageName})"
|
||||
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
|
||||
@@ -379,7 +407,7 @@ class JoomlaRelease extends CliFramework
|
||||
$this->api->post("/repos/{$repo}/releases", [
|
||||
'tag_name' => $tag,
|
||||
'name' => $releaseName,
|
||||
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.",
|
||||
'body' => "## {$version}\n\nCreated by moko-platform release pipeline.",
|
||||
'prerelease' => ($stability !== 'stable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/license_manage.php
|
||||
* BRIEF: Manage license packages and keys via MokoGitea licensing API
|
||||
*
|
||||
* Usage:
|
||||
* php bin/moko license:list --org MokoConsulting
|
||||
* php bin/moko license:create-package --org MokoConsulting --name "Pro Annual" --duration 365 --max-sites 5
|
||||
* php bin/moko license:issue --org MokoConsulting --package-id 1 --licensee "Client Inc" --email client@example.com
|
||||
* php bin/moko license:revoke --org MokoConsulting --key-id 42
|
||||
* php bin/moko license:renew --org MokoConsulting --key-id 42 --days 365
|
||||
* php bin/moko license:validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
|
||||
* php bin/moko license:usage --org MokoConsulting --key-id 42
|
||||
* php bin/moko license:master-key --org MokoConsulting
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class LicenseManage extends CliFramework
|
||||
{
|
||||
private string $apiBase = '';
|
||||
private string $token = '';
|
||||
private string $subcommand = '';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Manage license packages and keys via MokoGitea licensing API');
|
||||
$this->addArgument('--org', 'Organization name', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', '');
|
||||
$this->addArgument('--token', 'API token (or set GH_TOKEN env)', '');
|
||||
|
||||
// Package args
|
||||
$this->addArgument('--name', 'Package name (for create-package)', '');
|
||||
$this->addArgument('--description', 'Package description', '');
|
||||
$this->addArgument('--duration', 'Duration in days (0 = lifetime)', '0');
|
||||
$this->addArgument('--max-sites', 'Max sites per key (0 = unlimited)', '0');
|
||||
$this->addArgument('--repo-scope', 'Repo scope: all or comma-separated repo IDs', 'all');
|
||||
$this->addArgument('--channels', 'Allowed channels: JSON array or comma-separated', '');
|
||||
|
||||
// Key args
|
||||
$this->addArgument('--package-id', 'License package ID', '');
|
||||
$this->addArgument('--key-id', 'License key ID', '');
|
||||
$this->addArgument('--key', 'Raw license key string (for validate)', '');
|
||||
$this->addArgument('--licensee', 'Licensee name', '');
|
||||
$this->addArgument('--email', 'Licensee email', '');
|
||||
$this->addArgument('--domain', 'Domain restriction or validation domain', '');
|
||||
$this->addArgument('--domains', 'Comma-separated allowed domains', '');
|
||||
$this->addArgument('--payment-ref', 'Payment reference (idempotency key)', '');
|
||||
$this->addArgument('--days', 'Days to extend (for renew)', '365');
|
||||
$this->addArgument('--custom-key', 'Use a custom key string instead of auto-generated', '');
|
||||
|
||||
// Output
|
||||
$this->addArgument('--json', 'Output as JSON', false);
|
||||
}
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Resolve API base
|
||||
$this->apiBase = $this->getArgument('--api-base')
|
||||
?: getenv('GITEA_URL')
|
||||
?: 'https://git.mokoconsulting.tech';
|
||||
$this->apiBase = rtrim($this->apiBase, '/');
|
||||
|
||||
// Resolve token
|
||||
$this->token = $this->getArgument('--token')
|
||||
?: getenv('GH_TOKEN')
|
||||
?: getenv('GITHUB_TOKEN')
|
||||
?: '';
|
||||
|
||||
if (empty($this->token)) {
|
||||
$ghToken = trim((string) @shell_exec('gh auth token 2>/dev/null'));
|
||||
if (!empty($ghToken)) {
|
||||
$this->token = $ghToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine subcommand from argv
|
||||
global $argv;
|
||||
foreach ($argv as $arg) {
|
||||
if (
|
||||
in_array($arg, [
|
||||
'list', 'create-package', 'update-package', 'delete-package',
|
||||
'issue', 'revoke', 'activate', 'renew', 'validate',
|
||||
'usage', 'master-key', 'keys', 'packages',
|
||||
], true)
|
||||
) {
|
||||
$this->subcommand = $arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
if (empty($this->token)) {
|
||||
$this->log('No API token found. Set GH_TOKEN or pass --token.', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
return match ($this->subcommand) {
|
||||
'packages', 'list' => $this->listPackages(),
|
||||
'create-package' => $this->createPackage(),
|
||||
'update-package' => $this->updatePackage(),
|
||||
'delete-package' => $this->deletePackage(),
|
||||
'keys' => $this->listKeys(),
|
||||
'issue' => $this->issueKey(),
|
||||
'revoke' => $this->revokeKey(),
|
||||
'activate' => $this->activateKey(),
|
||||
'renew' => $this->renewKey(),
|
||||
'validate' => $this->validateKey(),
|
||||
'usage' => $this->viewUsage(),
|
||||
'master-key' => $this->ensureMasterKey(),
|
||||
default => $this->showSubcommandHelp(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Subcommand help ──────────────────────────────────────────────────
|
||||
|
||||
private function showSubcommandHelp(): int
|
||||
{
|
||||
$this->section('License Management — Subcommands');
|
||||
echo <<<HELP
|
||||
|
||||
Package Management:
|
||||
packages List all license packages for an org
|
||||
create-package Create a new license package
|
||||
update-package Update a license package (--package-id required)
|
||||
delete-package Delete a license package (--package-id required)
|
||||
|
||||
Key Management:
|
||||
keys List all license keys for an org
|
||||
issue Issue a new license key (--package-id required)
|
||||
revoke Deactivate a license key (--key-id required)
|
||||
activate Re-activate a revoked key (--key-id required)
|
||||
renew Extend key expiration (--key-id, --days required)
|
||||
validate Validate a raw key string (--key required)
|
||||
master-key Ensure master key exists for org
|
||||
|
||||
Analytics:
|
||||
usage View usage logs for a key (--key-id required)
|
||||
|
||||
Examples:
|
||||
php bin/moko license packages --org MokoConsulting
|
||||
php bin/moko license create-package --org MokoConsulting --name "Pro Annual" --duration 365
|
||||
php bin/moko license issue --org MokoConsulting --package-id 1 --licensee "Client"
|
||||
php bin/moko license validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
|
||||
php bin/moko license renew --org MokoConsulting --key-id 42 --days 365
|
||||
|
||||
HELP;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Package operations ───────────────────────────────────────────────
|
||||
|
||||
private function listPackages(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
if ($org === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = $this->apiGet("/orgs/{$org}/license-packages");
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->section("License Packages — {$org}");
|
||||
if (empty($result)) {
|
||||
$this->log('No packages found.', 'WARN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($result as $pkg) {
|
||||
$duration = ($pkg['duration_days'] ?? 0) === 0 ? 'lifetime' : ($pkg['duration_days'] . ' days');
|
||||
$sites = ($pkg['max_sites'] ?? 0) === 0 ? 'unlimited' : (string)$pkg['max_sites'];
|
||||
$active = ($pkg['is_active'] ?? true) ? 'active' : 'inactive';
|
||||
$this->status(
|
||||
sprintf('#%d %s', $pkg['id'] ?? 0, $pkg['name'] ?? ''),
|
||||
true,
|
||||
sprintf('%s | %s sites | %s', $duration, $sites, $active)
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createPackage(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
if ($org === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$name = $this->getArgument('--name');
|
||||
if (empty($name)) {
|
||||
$this->log('--name is required for create-package', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$channels = $this->getArgument('--channels');
|
||||
if (!empty($channels) && $channels[0] !== '[') {
|
||||
$channels = json_encode(explode(',', $channels));
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $this->getArgument('--description') ?: '',
|
||||
'duration_days' => (int) $this->getArgument('--duration'),
|
||||
'max_sites' => (int) $this->getArgument('--max-sites'),
|
||||
'repo_scope' => $this->getArgument('--repo-scope'),
|
||||
'allowed_channels' => $channels ?: '',
|
||||
];
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log('Would create package: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPost("/orgs/{$org}/license-packages", $data);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
} else {
|
||||
$this->log(sprintf('Created package #%d: %s', $result['id'] ?? 0, $name), 'OK');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function updatePackage(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$pkgId = $this->getArgument('--package-id');
|
||||
if ($org === null || empty($pkgId)) {
|
||||
$this->log('--org and --package-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$data = array_filter([
|
||||
'name' => $this->getArgument('--name') ?: null,
|
||||
'description' => $this->getArgument('--description') ?: null,
|
||||
'duration_days' => $this->getArgument('--duration') !== '0' ? (int)$this->getArgument('--duration') : null,
|
||||
'max_sites' => $this->getArgument('--max-sites') !== '0' ? (int)$this->getArgument('--max-sites') : null,
|
||||
], fn($v) => $v !== null);
|
||||
|
||||
if (empty($data)) {
|
||||
$this->log('No fields to update. Pass --name, --description, --duration, or --max-sites.', 'WARN');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log("Would update package #{$pkgId}: " . json_encode($data), 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPatch("/orgs/{$org}/license-packages/{$pkgId}", $data);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Updated package #{$pkgId}", 'OK');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function deletePackage(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$pkgId = $this->getArgument('--package-id');
|
||||
if ($org === null || empty($pkgId)) {
|
||||
$this->log('--org and --package-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log("Would delete package #{$pkgId}", 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiDelete("/orgs/{$org}/license-packages/{$pkgId}");
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Deleted package #{$pkgId}", 'OK');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Key operations ───────────────────────────────────────────────────
|
||||
|
||||
private function listKeys(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
if ($org === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$pkgId = $this->getArgument('--package-id');
|
||||
$endpoint = $pkgId
|
||||
? "/orgs/{$org}/license-packages/{$pkgId}/keys"
|
||||
: "/orgs/{$org}/license-keys";
|
||||
|
||||
$result = $this->apiGet($endpoint);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->section("License Keys — {$org}" . ($pkgId ? " (Package #{$pkgId})" : ''));
|
||||
if (empty($result)) {
|
||||
$this->log('No keys found.', 'WARN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($result as $key) {
|
||||
$prefix = $key['key_prefix'] ?? '???';
|
||||
$licensee = $key['licensee_name'] ?? 'N/A';
|
||||
$active = ($key['is_active'] ?? true) ? 'active' : 'revoked';
|
||||
$internal = ($key['is_internal'] ?? false) ? ' [MASTER]' : '';
|
||||
$domains = $key['domain_restriction'] ?? '';
|
||||
$expires = ($key['expires_unix'] ?? 0) > 0
|
||||
? date('Y-m-d', (int) $key['expires_unix'])
|
||||
: 'never';
|
||||
|
||||
$this->status(
|
||||
sprintf('#%d %s', $key['id'] ?? 0, $prefix),
|
||||
$key['is_active'] ?? true,
|
||||
sprintf('%s | %s | expires: %s | domains: %s%s', $licensee, $active, $expires, $domains ?: 'any', $internal)
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function issueKey(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$pkgId = $this->getArgument('--package-id');
|
||||
if ($org === null || empty($pkgId)) {
|
||||
$this->log('--org and --package-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'package_id' => (int) $pkgId,
|
||||
'licensee_name' => $this->getArgument('--licensee') ?: '',
|
||||
'licensee_email' => $this->getArgument('--email') ?: '',
|
||||
'domain_restriction' => $this->getArgument('--domains') ?: '',
|
||||
'max_sites' => (int) $this->getArgument('--max-sites'),
|
||||
'payment_ref' => $this->getArgument('--payment-ref') ?: '',
|
||||
];
|
||||
|
||||
$customKey = $this->getArgument('--custom-key');
|
||||
if (!empty($customKey)) {
|
||||
$data['custom_key'] = $customKey;
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log('Would issue key: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPost("/orgs/{$org}/license-keys", $data);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
} else {
|
||||
$rawKey = $result['raw_key'] ?? '';
|
||||
$this->section('License Key Issued');
|
||||
if (!empty($rawKey)) {
|
||||
echo "\n";
|
||||
$this->log("Raw Key: {$rawKey}", 'OK');
|
||||
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
|
||||
echo "\n";
|
||||
}
|
||||
$this->log(sprintf('Key ID: #%d | Prefix: %s', $result['id'] ?? 0, $result['key_prefix'] ?? ''), 'INFO');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function revokeKey(): int
|
||||
{
|
||||
return $this->toggleKey(false);
|
||||
}
|
||||
|
||||
private function activateKey(): int
|
||||
{
|
||||
return $this->toggleKey(true);
|
||||
}
|
||||
|
||||
private function toggleKey(bool $activate): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$keyId = $this->getArgument('--key-id');
|
||||
if ($org === null || empty($keyId)) {
|
||||
$this->log('--org and --key-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$action = $activate ? 'activate' : 'revoke';
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log("Would {$action} key #{$keyId}", 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPatch("/orgs/{$org}/license-keys/{$keyId}", [
|
||||
'is_active' => $activate,
|
||||
]);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$label = $activate ? 'Activated' : 'Revoked';
|
||||
$this->log("{$label} key #{$keyId}", 'OK');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function renewKey(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$keyId = $this->getArgument('--key-id');
|
||||
$days = (int) $this->getArgument('--days');
|
||||
if ($org === null || empty($keyId)) {
|
||||
$this->log('--org and --key-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log("Would renew key #{$keyId} by {$days} days", 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPost("/orgs/{$org}/license-keys/{$keyId}/renew", [
|
||||
'days' => $days,
|
||||
]);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$newExpiry = isset($result['expires_unix']) && $result['expires_unix'] > 0
|
||||
? date('Y-m-d', (int) $result['expires_unix'])
|
||||
: 'never';
|
||||
$this->log("Renewed key #{$keyId} — new expiry: {$newExpiry}", 'OK');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function validateKey(): int
|
||||
{
|
||||
$rawKey = $this->getArgument('--key');
|
||||
if (empty($rawKey)) {
|
||||
$this->log('--key is required for validate', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$data = ['key' => $rawKey];
|
||||
$domain = $this->getArgument('--domain');
|
||||
if (!empty($domain)) {
|
||||
$data['domain'] = $domain;
|
||||
}
|
||||
|
||||
$result = $this->apiPost('/license-keys/validate', $data);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$valid = $result['valid'] ?? false;
|
||||
if ($valid) {
|
||||
$this->status('License Valid', true, sprintf(
|
||||
'Package: %s | Expires: %s | Sites: %s',
|
||||
$result['package_name'] ?? 'N/A',
|
||||
isset($result['expires_unix']) && $result['expires_unix'] > 0
|
||||
? date('Y-m-d', (int) $result['expires_unix']) : 'never',
|
||||
$result['max_sites'] ?? 'unlimited'
|
||||
));
|
||||
return 0;
|
||||
} else {
|
||||
$this->status('License Invalid', false, $result['error'] ?? 'Unknown reason');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function viewUsage(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
$keyId = $this->getArgument('--key-id');
|
||||
if ($org === null || empty($keyId)) {
|
||||
$this->log('--org and --key-id are required', 'ERROR');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = $this->apiGet("/orgs/{$org}/license-keys/{$keyId}/usage");
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->section("Usage — Key #{$keyId}");
|
||||
$entries = $result['entries'] ?? $result;
|
||||
if (empty($entries)) {
|
||||
$this->log('No usage recorded.', 'WARN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($entries as $u) {
|
||||
$date = isset($u['created_unix']) ? date('Y-m-d H:i', (int) $u['created_unix']) : 'N/A';
|
||||
$domain = $u['domain'] ?? '';
|
||||
$ip = $u['ip_address'] ?? '';
|
||||
$from = $u['version_from'] ?? '';
|
||||
$this->log(sprintf('%s | %s | %s | from %s', $date, $domain ?: 'no domain', $ip, $from ?: 'unknown'), 'INFO');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function ensureMasterKey(): int
|
||||
{
|
||||
$org = $this->requireOrg();
|
||||
if ($org === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->isDryRun()) {
|
||||
$this->log("Would ensure master key for {$org}", 'DRY-RUN');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $this->apiPost("/orgs/{$org}/license-keys/master", []);
|
||||
if ($result === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->getArgument('--json')) {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rawKey = $result['raw_key'] ?? '';
|
||||
if (!empty($rawKey)) {
|
||||
$this->section('Master Key Created');
|
||||
echo "\n";
|
||||
$this->log("Raw Key: {$rawKey}", 'OK');
|
||||
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
|
||||
echo "\n";
|
||||
} else {
|
||||
$this->log('Master key already exists.', 'INFO');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function requireOrg(): ?string
|
||||
{
|
||||
$org = $this->getArgument('--org');
|
||||
if (empty($org)) {
|
||||
// Try to detect from git remote
|
||||
$remote = trim((string) @shell_exec('git remote get-url origin 2>/dev/null'));
|
||||
if (preg_match('#[/:]([^/]+)/[^/]+?(?:\.git)?$#', $remote, $m)) {
|
||||
$org = $m[1];
|
||||
}
|
||||
}
|
||||
if (empty($org)) {
|
||||
$this->log('--org is required (or must be detectable from git remote)', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
return $org;
|
||||
}
|
||||
|
||||
private function apiGet(string $path): ?array
|
||||
{
|
||||
return $this->apiRequest('GET', $path);
|
||||
}
|
||||
|
||||
private function apiPost(string $path, array $data): ?array
|
||||
{
|
||||
return $this->apiRequest('POST', $path, $data);
|
||||
}
|
||||
|
||||
private function apiPatch(string $path, array $data): ?array
|
||||
{
|
||||
return $this->apiRequest('PATCH', $path, $data);
|
||||
}
|
||||
|
||||
private function apiDelete(string $path): ?array
|
||||
{
|
||||
return $this->apiRequest('DELETE', $path);
|
||||
}
|
||||
|
||||
private function apiRequest(string $method, string $path, ?array $data = null): ?array
|
||||
{
|
||||
$url = $this->apiBase . '/api/v1' . $path;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: token ' . $this->token,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
|
||||
if ($this->getArgument('--verbose')) {
|
||||
$this->log("{$method} {$url}", 'DEBUG');
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if (!empty($error)) {
|
||||
$this->log("API error: {$error}", 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode === 404) {
|
||||
$this->log("API endpoint not found: {$path}", 'ERROR');
|
||||
$this->log('The licensing API may not be deployed yet. Check MokoGitea version.', 'WARN');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode === 204) {
|
||||
return []; // success, no content
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$body = json_decode((string) $response, true);
|
||||
$msg = $body['message'] ?? $response;
|
||||
$this->log("API error ({$httpCode}): {$msg}", 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response, true);
|
||||
if ($decoded === null && !empty($response)) {
|
||||
$this->log('Failed to parse API response', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$app = new LicenseManage();
|
||||
exit($app->execute());
|
||||
+163
-210
@@ -11,228 +11,181 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/manifest_element.php
|
||||
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
|
||||
*
|
||||
* Usage:
|
||||
* php manifest_element.php --path .
|
||||
* php manifest_element.php --path . --version 09.01.00 --stability dev --github-output
|
||||
*
|
||||
* Detects platform (joomla, dolibarr, generic) and resolves:
|
||||
* ext_element — canonical element name (e.g. mokojgdpc)
|
||||
* ext_type — extension type (plugin, module, component, package, etc.)
|
||||
* ext_folder — group/folder for plugins (e.g. system)
|
||||
* ext_name — human-readable name (e.g. "Moko JGDPC")
|
||||
* type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.)
|
||||
* zip_name — computed ZIP filename
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$stability = 'stable';
|
||||
$githubOutput = false;
|
||||
$repoName = '';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ManifestElementCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--version', 'Version string', null);
|
||||
$this->addArgument('--stability', 'Stability level', 'stable');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) {
|
||||
$stability = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Detect platform from manifest.xml ────────────────────────────────────────
|
||||
$platform = 'generic';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find extension manifest (Joomla XML) ─────────────────────────────────────
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find Dolibarr module file ────────────────────────────────────────────────
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract metadata ─────────────────────────────────────────────────────────
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
|
||||
switch (true) {
|
||||
// Joomla platforms
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
|
||||
// Extension type and folder
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name: <element>, module= attribute, plugin= attribute, <packagename>, or filename
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
|
||||
$extElement = $mm[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||
$extElement = $pm[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$stability = $this->getArgument('--stability');
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$githubOutput = (bool) $this->getArgument('--github-output');
|
||||
$root = realpath($path) ?: $path;
|
||||
$platform = 'generic';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable name
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// Dolibarr platforms
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
||||
|
||||
$modContent = file_get_contents($modFile);
|
||||
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
|
||||
$extName = $nm[1];
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// Generic / fallback
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Strip existing type prefix from element to prevent duplication ────────────
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
// ── Compute type prefix ──────────────────────────────────────────────────────
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Compute ZIP name ─────────────────────────────────────────────────────────
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
$suffix = $suffixMap[$stability] ?? '';
|
||||
$zipName = '';
|
||||
if ($version !== null) {
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
|
||||
}
|
||||
|
||||
// Fallback name
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName ?: basename($root);
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────────
|
||||
$outputs = [
|
||||
'platform' => $platform,
|
||||
'ext_element' => $extElement,
|
||||
'ext_type' => $extType,
|
||||
'ext_folder' => $extFolder,
|
||||
'ext_name' => $extName,
|
||||
'type_prefix' => $typePrefix,
|
||||
'zip_name' => $zipName,
|
||||
];
|
||||
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [];
|
||||
foreach ($outputs as $key => $value) {
|
||||
$lines[] = "{$key}={$value}";
|
||||
}
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
} else {
|
||||
// Fallback: echo ::set-output (legacy)
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "::set-output name={$key}::{$value}\n";
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
switch (true) {
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
|
||||
$extElement = $mm[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||
$extElement = $pm2[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
}
|
||||
}
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
}
|
||||
break;
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
|
||||
$modContent = file_get_contents($modFile);
|
||||
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
|
||||
$extName = $nm[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "{$key}={$value}\n";
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
$suffix = $suffixMap[$stability] ?? '';
|
||||
$zipName = '';
|
||||
if ($version !== null) {
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
|
||||
}
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName ?: basename($root);
|
||||
}
|
||||
$outputs = [
|
||||
'platform' => $platform,
|
||||
'ext_element' => $extElement,
|
||||
'ext_type' => $extType,
|
||||
'ext_folder' => $extFolder,
|
||||
'ext_name' => $extName,
|
||||
'type_prefix' => $typePrefix,
|
||||
'zip_name' => $zipName,
|
||||
];
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [];
|
||||
foreach ($outputs as $key => $value) {
|
||||
$lines[] = "{$key}={$value}";
|
||||
}
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
} else {
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "::set-output name={$key}::{$value}\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($outputs as $key => $value) {
|
||||
echo "{$key}={$value}\n";
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new ManifestElementCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/manifest_licensing.php
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
/**
|
||||
* Reads the <licensing> block from .mokogitea/manifest.xml and ensures that the
|
||||
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
|
||||
*
|
||||
* manifest.xml licensing block example:
|
||||
*
|
||||
* <licensing>
|
||||
* <enabled>true</enabled>
|
||||
* <dlid>true</dlid>
|
||||
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||
* <update-server-name>MyExtension Updates</update-server-name>
|
||||
* </licensing>
|
||||
*
|
||||
* Supports {org} and {repo} placeholders in update-server URL, resolved from
|
||||
* the manifest's <identity> block or git remote.
|
||||
*/
|
||||
class ManifestLicensingCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
|
||||
$this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
|
||||
$fix = (bool) $this->getArgument('--fix');
|
||||
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||
|
||||
// ── 1. Read manifest.xml ──────────────────────────────────────────
|
||||
$manifestFile = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestFile)) {
|
||||
$this->log('WARN', "No manifest.xml found at {$manifestFile}");
|
||||
$this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml === false) {
|
||||
$this->log('ERROR', "Failed to parse {$manifestFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── 2. Check if licensing is enabled ──────────────────────────────
|
||||
if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
|
||||
$this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
|
||||
$this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$licensingNode = $xml->licensing;
|
||||
$dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
|
||||
$updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
|
||||
$updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
|
||||
|
||||
// ── 3. Resolve placeholders ───────────────────────────────────────
|
||||
$org = (string) ($xml->identity->org ?? '');
|
||||
$repo = (string) ($xml->identity->name ?? '');
|
||||
|
||||
// Fallback to git remote if manifest doesn't have org/name
|
||||
if (empty($org) || empty($repo)) {
|
||||
$remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
|
||||
|
||||
if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
||||
if (empty($org)) {
|
||||
$org = $m[1];
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$repo = $m[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default update server URL if not specified
|
||||
if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
|
||||
$updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
|
||||
}
|
||||
|
||||
// Resolve {org} and {repo} placeholders
|
||||
$updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
|
||||
|
||||
// Default server name from display-name or repo name
|
||||
if (empty($updateServerName)) {
|
||||
$displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
|
||||
$updateServerName = $displayName . ' Updates';
|
||||
}
|
||||
|
||||
if (empty($updateServerUrl)) {
|
||||
$this->log('ERROR', 'Cannot determine update server URL — set <update-server> in manifest.xml or ensure org/repo are available');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
|
||||
$this->log('INFO', "Update server: {$updateServerUrl}");
|
||||
$this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
|
||||
|
||||
// ── 4. Find Joomla extension manifests ────────────────────────────
|
||||
$xmlFiles = array_merge(
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
$packageManifest = null;
|
||||
|
||||
foreach ($xmlFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
|
||||
if (!str_contains($content, '<extension')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the package manifest (type="package") or the main extension manifest
|
||||
if (str_contains($content, 'type="package"')) {
|
||||
$packageManifest = $file;
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: first extension manifest found
|
||||
if ($packageManifest === null) {
|
||||
$packageManifest = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if ($packageManifest === null) {
|
||||
$this->log('WARN', 'No Joomla extension manifest found');
|
||||
$this->outputResult($ghOutput, 'skipped', 'No extension manifest');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
|
||||
$this->log('INFO', "Package manifest: {$relPath}");
|
||||
|
||||
// ── 5. Check and fix the manifest ─────────────────────────────────
|
||||
$content = file_get_contents($packageManifest);
|
||||
$original = $content;
|
||||
$changes = [];
|
||||
|
||||
// --- 5a. Ensure <updateservers> block with correct URL ---
|
||||
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
|
||||
// Empty updateservers block — inject the server
|
||||
$replacement = "<updateservers>\n"
|
||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||
. " </updateservers>";
|
||||
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
|
||||
$changes[] = 'Added update server URL to empty <updateservers>';
|
||||
} elseif (!str_contains($content, '<updateservers>')) {
|
||||
// No updateservers at all — add before </extension>
|
||||
$serverBlock = "\n <updateservers>\n"
|
||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||
. " </updateservers>\n";
|
||||
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
|
||||
$changes[] = 'Added <updateservers> block';
|
||||
} else {
|
||||
// updateservers exists — verify URL is correct
|
||||
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
|
||||
if ($m[1] !== $updateServerUrl) {
|
||||
$content = preg_replace(
|
||||
'#(<server[^>]*>)[^<]+(</server>)#',
|
||||
"\${1}{$updateServerUrl}\${2}",
|
||||
$content
|
||||
);
|
||||
$changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5b. Ensure <dlid> tag if required ---
|
||||
if ($dlidEnabled) {
|
||||
if (!str_contains($content, '<dlid')) {
|
||||
// Add before <updateservers> if present, otherwise before </extension>
|
||||
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
|
||||
|
||||
if (str_contains($content, '<updateservers>')) {
|
||||
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
|
||||
} else {
|
||||
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
|
||||
}
|
||||
|
||||
$changes[] = 'Added <dlid> tag';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5c. Ensure <blockChildUninstall> for packages ---
|
||||
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
|
||||
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
|
||||
|
||||
if (str_contains($content, '<dlid')) {
|
||||
// Add after <dlid>
|
||||
$content = preg_replace(
|
||||
'#(<dlid[^/]*/>\s*\n)#',
|
||||
"\${1}{$blockTag}",
|
||||
$content
|
||||
);
|
||||
} elseif (str_contains($content, '<updateservers>')) {
|
||||
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
|
||||
} else {
|
||||
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
|
||||
}
|
||||
|
||||
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
|
||||
}
|
||||
|
||||
// ── 6. Report and apply ───────────────────────────────────────────
|
||||
if (empty($changes)) {
|
||||
$this->log('INFO', 'All licensing tags are correct — no changes needed');
|
||||
$this->outputResult($ghOutput, 'ok', 'No changes needed');
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
|
||||
}
|
||||
|
||||
if ($fix) {
|
||||
file_put_contents($packageManifest, $content);
|
||||
$this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
|
||||
$this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
|
||||
} else {
|
||||
$this->log('WARN', 'Run with --fix to apply changes');
|
||||
$this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write result to $GITHUB_OUTPUT if requested.
|
||||
*/
|
||||
private function outputResult(bool $ghOutput, string $status, string $detail): void
|
||||
{
|
||||
if (!$ghOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
|
||||
if ($outputFile === false || $outputFile === '') {
|
||||
echo "licensing_status={$status}\n";
|
||||
echo "licensing_detail={$detail}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$fh = fopen($outputFile, 'a');
|
||||
fwrite($fh, "licensing_status={$status}\n");
|
||||
fwrite($fh, "licensing_detail={$detail}\n");
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ManifestLicensingCli();
|
||||
exit($app->execute());
|
||||
+139
-144
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,167 +10,161 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/manifest_read.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
|
||||
*
|
||||
* Usage:
|
||||
* php manifest_read.php --path /repo --field platform
|
||||
* php manifest_read.php --path /repo --field entry-point
|
||||
* php manifest_read.php --path /repo --all
|
||||
* php manifest_read.php --path /repo --github-output
|
||||
*
|
||||
* Fields: name, org, description, license, license-spdx, platform,
|
||||
* standards-version, standards-source, language, package-type, entry-point,
|
||||
* source-dir, remote-subdir, excludes, dev-host, demo-host
|
||||
*
|
||||
* --all Print all fields as KEY=VALUE lines
|
||||
* --github-output Append all fields to $GITHUB_OUTPUT (for Gitea/GitHub Actions)
|
||||
* --json Output all fields as JSON
|
||||
* --field <name> Print a single field value (no key, just value)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// -- Argument parsing ---------------------------------------------------------
|
||||
$path = '.';
|
||||
$field = null;
|
||||
$mode = 'field'; // field | all | github-output | json
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--field' && isset($argv[$i + 1])) $field = $argv[$i + 1];
|
||||
if ($arg === '--all') $mode = 'all';
|
||||
if ($arg === '--github-output') $mode = 'github-output';
|
||||
if ($arg === '--json') $mode = 'json';
|
||||
}
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
// -- Locate manifest ----------------------------------------------------------
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFile = null;
|
||||
|
||||
// Priority: manifest.xml (current standard)
|
||||
$candidates = [
|
||||
"{$root}/.mokogitea/manifest.xml",
|
||||
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
|
||||
"{$root}/.mokogitea/.moko-platform", // legacy v4
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (file_exists($candidate)) {
|
||||
$manifestFile = $candidate;
|
||||
break;
|
||||
class ManifestReadCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--field', 'Single field name to output', '');
|
||||
$this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
|
||||
$this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
|
||||
$this->addArgument('--json', 'Output all fields as JSON', false);
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifestFile === null) {
|
||||
fwrite(STDERR, "No manifest found in {$root}
|
||||
");
|
||||
exit(1);
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$field = $this->getArgument('--field');
|
||||
$showAll = $this->getArgument('--all');
|
||||
$ghOutput = $this->getArgument('--github-output');
|
||||
$jsonMode = $this->getArgument('--json');
|
||||
|
||||
// -- Parse XML ----------------------------------------------------------------
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml === false) {
|
||||
// Fallback: try YAML format (.mokostandards legacy)
|
||||
$content = file_get_contents($manifestFile);
|
||||
$fields = [];
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$fields['platform'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
|
||||
$fields['standards-version'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
|
||||
$fields['name'] = trim($m[1], "
|
||||
|
||||
\"'");
|
||||
}
|
||||
} else {
|
||||
// Register namespace for XPath (optional, simple path works without)
|
||||
$fields = [
|
||||
'name' => (string)($xml->identity->name ?? ''),
|
||||
'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
|
||||
'org' => (string)($xml->identity->org ?? ''),
|
||||
'description' => (string)($xml->identity->description ?? ''),
|
||||
'license' => (string)($xml->identity->license ?? ''),
|
||||
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
|
||||
'platform' => (string)($xml->governance->platform ?? ''),
|
||||
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
|
||||
'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
|
||||
'language' => (string)($xml->build->language ?? ''),
|
||||
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
|
||||
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
|
||||
'version' => (string)($xml->identity->version ?? ''),
|
||||
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||
'excludes' => (string)($xml->deploy->excludes ?? ''),
|
||||
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
|
||||
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
|
||||
'manifest-file' => $manifestFile,
|
||||
];
|
||||
}
|
||||
|
||||
// Strip empty values for cleaner output
|
||||
$fields = array_filter($fields, fn($v) => $v !== '');
|
||||
|
||||
// -- Output -------------------------------------------------------------------
|
||||
switch ($mode) {
|
||||
case 'field':
|
||||
if ($field === null) {
|
||||
fwrite(STDERR, "Usage: manifest_read.php --path <dir> --field <name>
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --all
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --json
|
||||
");
|
||||
fwrite(STDERR, " manifest_read.php --path <dir> --github-output
|
||||
");
|
||||
exit(2);
|
||||
// Determine mode
|
||||
if ($ghOutput) {
|
||||
$mode = 'github-output';
|
||||
} elseif ($showAll) {
|
||||
$mode = 'all';
|
||||
} elseif ($jsonMode) {
|
||||
$mode = 'json';
|
||||
} else {
|
||||
$mode = 'field';
|
||||
}
|
||||
echo ($fields[$field] ?? '') . "
|
||||
";
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
foreach ($fields as $k => $v) {
|
||||
echo "{$k}={$v}
|
||||
";
|
||||
// -- Locate manifest --
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFile = null;
|
||||
|
||||
// Priority: manifest.xml (current standard)
|
||||
$candidates = [
|
||||
"{$root}/.mokogitea/manifest.xml",
|
||||
"{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
|
||||
"{$root}/.mokogitea/.moko-platform", // legacy v4
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (file_exists($candidate)) {
|
||||
$manifestFile = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'json':
|
||||
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "
|
||||
";
|
||||
break;
|
||||
if ($manifestFile === null) {
|
||||
$this->log('ERROR', "No manifest found in {$root}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
case 'github-output':
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile === false || $outputFile === '') {
|
||||
fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead
|
||||
");
|
||||
foreach ($fields as $k => $v) {
|
||||
// Convert field-name to FIELD_NAME for env var style
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
echo "{$envKey}={$v}
|
||||
";
|
||||
// -- Parse XML --
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml === false) {
|
||||
// Fallback: try YAML format (.mokostandards legacy)
|
||||
$content = file_get_contents($manifestFile);
|
||||
$fields = [];
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$fields['platform'] = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
|
||||
$fields['standards-version'] = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
|
||||
$fields['name'] = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
} else {
|
||||
$fh = fopen($outputFile, 'a');
|
||||
foreach ($fields as $k => $v) {
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
fwrite($fh, "{$envKey}={$v}
|
||||
");
|
||||
}
|
||||
fclose($fh);
|
||||
fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT
|
||||
");
|
||||
// Register namespace for XPath (optional, simple path works without)
|
||||
$fields = [
|
||||
'name' => (string)($xml->identity->name ?? ''),
|
||||
'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
|
||||
'org' => (string)($xml->identity->org ?? ''),
|
||||
'description' => (string)($xml->identity->description ?? ''),
|
||||
'license' => (string)($xml->identity->license ?? ''),
|
||||
'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
|
||||
'platform' => (string)($xml->governance->platform ?? ''),
|
||||
'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
|
||||
'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
|
||||
'language' => (string)($xml->build->language ?? ''),
|
||||
'package-type' => (string)($xml->build->{"package-type"} ?? ''),
|
||||
'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
|
||||
'version' => (string)($xml->identity->version ?? ''),
|
||||
'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
|
||||
'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
|
||||
'excludes' => (string)($xml->deploy->excludes ?? ''),
|
||||
'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
|
||||
'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
|
||||
'manifest-file' => $manifestFile,
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
// Strip empty values for cleaner output
|
||||
$fields = array_filter($fields, fn($v) => $v !== '');
|
||||
|
||||
// -- Output --
|
||||
switch ($mode) {
|
||||
case 'field':
|
||||
if ($field === '') {
|
||||
$this->log('ERROR', "Usage: manifest_read.php --path <dir> --field <name>");
|
||||
$this->log('ERROR', " manifest_read.php --path <dir> --all");
|
||||
$this->log('ERROR', " manifest_read.php --path <dir> --json");
|
||||
$this->log('ERROR', " manifest_read.php --path <dir> --github-output");
|
||||
return 2;
|
||||
}
|
||||
echo ($fields[$field] ?? '') . "\n";
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
foreach ($fields as $k => $v) {
|
||||
echo "{$k}={$v}\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case 'json':
|
||||
echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
break;
|
||||
|
||||
case 'github-output':
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile === false || $outputFile === '') {
|
||||
$this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead');
|
||||
foreach ($fields as $k => $v) {
|
||||
// Convert field-name to FIELD_NAME for env var style
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
echo "{$envKey}={$v}\n";
|
||||
}
|
||||
} else {
|
||||
$fh = fopen($outputFile, 'a');
|
||||
foreach ($fields as $k => $v) {
|
||||
$envKey = str_replace('-', '_', $k);
|
||||
fwrite($fh, "{$envKey}={$v}\n");
|
||||
}
|
||||
fclose($fh);
|
||||
$this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new ManifestReadCli();
|
||||
exit($app->execute());
|
||||
|
||||
+299
-314
@@ -12,344 +12,329 @@
|
||||
* PATH: /cli/package_build.php
|
||||
* BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects
|
||||
*
|
||||
* Usage:
|
||||
* php package_build.php --path /repo --version 04.01.00
|
||||
* php package_build.php --path /repo --version 04.01.00 --output-dir /tmp
|
||||
* php package_build.php --path /repo --version 04.01.00 --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Version string (required)
|
||||
* --output-dir Directory for built packages (default: /tmp)
|
||||
* --type-prefix Override type prefix (e.g. plg_system_)
|
||||
* --element Override element name
|
||||
* --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT
|
||||
*
|
||||
* NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$outputDir = '/tmp';
|
||||
$typePrefixOverride = null;
|
||||
$elementOverride = null;
|
||||
$githubOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class PackageBuildCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects');
|
||||
$this->addArgument('--path', 'Repository root (default: .)', '.');
|
||||
$this->addArgument('--version', 'Version string (required)', '');
|
||||
$this->addArgument('--output-dir', 'Directory for built packages (default: /tmp)', '/tmp');
|
||||
$this->addArgument('--type-prefix', 'Override type prefix (e.g. plg_system_)', '');
|
||||
$this->addArgument('--element', 'Override element name', '');
|
||||
$this->addArgument('--github-output', 'Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output-dir' && isset($argv[$i + 1])) {
|
||||
$outputDir = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--type-prefix' && isset($argv[$i + 1])) {
|
||||
$typePrefixOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--element' && isset($argv[$i + 1])) {
|
||||
$elementOverride = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n");
|
||||
exit(1);
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$outputDir = $this->getArgument('--output-dir');
|
||||
$typePrefixOverride = $this->getArgument('--type-prefix') ?: null;
|
||||
$elementOverride = $this->getArgument('--element') ?: null;
|
||||
$githubOutput = $this->getArgument('--github-output');
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
// -- Determine source directory -----------------------------------------------
|
||||
$sourceDir = null;
|
||||
foreach (['src', 'htdocs'] as $candidate) {
|
||||
if (is_dir("{$root}/{$candidate}")) {
|
||||
$sourceDir = "{$root}/{$candidate}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Determine element and type prefix from manifest --------------------------
|
||||
$extElement = $elementOverride;
|
||||
$typePrefix = $typePrefixOverride ?? '';
|
||||
$extType = '';
|
||||
$isPackage = false;
|
||||
|
||||
if ($extElement === null || $typePrefixOverride === null) {
|
||||
// Find manifest
|
||||
$manifest = null;
|
||||
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
if ($version === '') {
|
||||
$this->log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
// -- Determine source directory -----------------------------------------------
|
||||
$sourceDir = SourceResolver::resolveAbsolute($root);
|
||||
|
||||
if ($sourceDir === null) {
|
||||
$this->log('ERROR', "No source/ or src/ directory found in {$root}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
|
||||
// -- Determine element and type prefix from manifest --------------------------
|
||||
$extElement = $elementOverride;
|
||||
$typePrefix = $typePrefixOverride ?? '';
|
||||
$extType = '';
|
||||
$isPackage = false;
|
||||
|
||||
if ($extElement === null || $typePrefixOverride === null) {
|
||||
// Find manifest
|
||||
$manifest = null;
|
||||
foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
if ($extElement === null) {
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} else {
|
||||
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
|
||||
if ($typePrefixOverride === null) {
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
if ($extElement === null) {
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} elseif (preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
} else {
|
||||
$extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
$extElement = strtolower(basename($root));
|
||||
}
|
||||
|
||||
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
||||
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
||||
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
||||
}
|
||||
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
|
||||
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
$tarPath = "{$outputDir}/{$tarName}";
|
||||
|
||||
// -- Exclude patterns ---------------------------------------------------------
|
||||
$excludePatterns = [
|
||||
'.ftpignore',
|
||||
'sftp-config*',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
];
|
||||
|
||||
// -- Build packages -----------------------------------------------------------
|
||||
if ($isPackage) {
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
|
||||
|
||||
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
|
||||
$packagesDir = "{$stagingDir}/packages";
|
||||
mkdir($packagesDir, 0755, true);
|
||||
|
||||
// ZIP each sub-extension into packages/
|
||||
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
|
||||
$subName = basename($extDir);
|
||||
echo " Packaging sub-extension: {$subName}\n";
|
||||
|
||||
$subZip = new \ZipArchive();
|
||||
$subZipPath = "{$packagesDir}/{$subName}.zip";
|
||||
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create ZIP for {$subName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
|
||||
if ($typePrefixOverride === null) {
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
// Copy package-level files (manifest, script.php, etc.)
|
||||
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||
copy($f, "{$stagingDir}/" . basename($f));
|
||||
}
|
||||
}
|
||||
|
||||
$isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
}
|
||||
}
|
||||
|
||||
if ($extElement === null) {
|
||||
$extElement = strtolower(basename($root));
|
||||
}
|
||||
|
||||
// Prevent double prefix (e.g. pkg_pkg_mokogallery)
|
||||
if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) {
|
||||
$extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1);
|
||||
}
|
||||
|
||||
$zipName = "{$typePrefix}{$extElement}-{$version}.zip";
|
||||
$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz";
|
||||
$zipPath = "{$outputDir}/{$zipName}";
|
||||
$tarPath = "{$outputDir}/{$tarName}";
|
||||
|
||||
// -- Exclude patterns ---------------------------------------------------------
|
||||
$excludePatterns = [
|
||||
'.ftpignore',
|
||||
'sftp-config*',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
];
|
||||
|
||||
// -- Build packages -----------------------------------------------------------
|
||||
if ($isPackage) {
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ===\n";
|
||||
|
||||
$stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid();
|
||||
$packagesDir = "{$stagingDir}/packages";
|
||||
mkdir($packagesDir, 0755, true);
|
||||
|
||||
// ZIP each sub-extension into packages/
|
||||
foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) {
|
||||
$subName = basename($extDir);
|
||||
echo " Packaging sub-extension: {$subName}\n";
|
||||
|
||||
$subZip = new ZipArchive();
|
||||
$subZipPath = "{$packagesDir}/{$subName}.zip";
|
||||
if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP for {$subName}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
addDirectoryToZip($subZip, $extDir, '', $excludePatterns);
|
||||
$subZip->close();
|
||||
echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n";
|
||||
}
|
||||
|
||||
// Copy package-level files (manifest, script.php, etc.)
|
||||
foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) {
|
||||
copy($f, "{$stagingDir}/" . basename($f));
|
||||
}
|
||||
|
||||
// Copy language directory if present
|
||||
if (is_dir("{$sourceDir}/language")) {
|
||||
$langDest = "{$stagingDir}/language";
|
||||
mkdir($langDest, 0755, true);
|
||||
$langIterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($langIterator as $item) {
|
||||
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
|
||||
if ($item->isDir()) {
|
||||
mkdir($target, 0755, true);
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
// Copy language directory if present
|
||||
if (is_dir("{$sourceDir}/language")) {
|
||||
$langDest = "{$stagingDir}/language";
|
||||
mkdir($langDest, 0755, true);
|
||||
$langIterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator("{$sourceDir}/language", \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($langIterator as $item) {
|
||||
$target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1);
|
||||
if ($item->isDir()) {
|
||||
mkdir($target, 0755, true);
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ZIP from staging
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $stagingDir, '', []);
|
||||
$zip->close();
|
||||
|
||||
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($stagingDir)
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
|
||||
// Cleanup staging
|
||||
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||
passthru($cleanCmd);
|
||||
} else {
|
||||
echo "=== Building standard extension package ===\n";
|
||||
|
||||
// ZIP
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n");
|
||||
exit(1);
|
||||
}
|
||||
addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
|
||||
// tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$excludeArgs = '';
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s%s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($sourceDir),
|
||||
$excludeArgs
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
}
|
||||
|
||||
// -- Calculate SHA-256 --------------------------------------------------------
|
||||
$sha256Zip = hash_file('sha256', $zipPath);
|
||||
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
|
||||
|
||||
$zipSize = filesize($zipPath);
|
||||
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
|
||||
|
||||
echo "\n";
|
||||
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Zip}\n";
|
||||
if ($tarSize > 0) {
|
||||
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Tar}\n";
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT --------------------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"zip_name={$zipName}",
|
||||
"tar_name={$tarName}",
|
||||
"zip_path={$zipPath}",
|
||||
"tar_path={$tarPath}",
|
||||
"sha256_zip={$sha256Zip}",
|
||||
"sha256_tar={$sha256Tar}",
|
||||
"type_prefix={$typePrefix}",
|
||||
"ext_element={$extElement}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
|
||||
// =============================================================================
|
||||
// Helper: recursively add directory contents to a ZipArchive
|
||||
// =============================================================================
|
||||
function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
|
||||
|
||||
// Check excludes
|
||||
$basename = basename($filePath);
|
||||
$skip = false;
|
||||
foreach ($excludes as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
$skip = true;
|
||||
break;
|
||||
// Create ZIP from staging
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create ZIP: {$zipPath}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
$this->addDirectoryToZip($zip, $stagingDir, '', []);
|
||||
$zip->close();
|
||||
|
||||
// Normalize path separators for ZIP
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
// Create tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($stagingDir)
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
// Cleanup staging
|
||||
$cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir));
|
||||
passthru($cleanCmd);
|
||||
} else {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
echo "=== Building standard extension package ===\n";
|
||||
|
||||
// ZIP
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create ZIP: {$zipPath}");
|
||||
return 1;
|
||||
}
|
||||
$this->addDirectoryToZip($zip, $sourceDir, '', $excludePatterns);
|
||||
$zip->close();
|
||||
|
||||
// tar.gz — all arguments are escaped via escapeshellarg()
|
||||
$excludeArgs = '';
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
$excludeArgs .= ' --exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
$tarCmd = sprintf(
|
||||
'tar -czf %s -C %s%s .',
|
||||
escapeshellarg($tarPath),
|
||||
escapeshellarg($sourceDir),
|
||||
$excludeArgs
|
||||
);
|
||||
passthru($tarCmd, $tarReturn);
|
||||
}
|
||||
|
||||
// -- Calculate SHA-256 --------------------------------------------------------
|
||||
$sha256Zip = hash_file('sha256', $zipPath);
|
||||
$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : '';
|
||||
|
||||
$zipSize = filesize($zipPath);
|
||||
$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0;
|
||||
|
||||
echo "\n";
|
||||
echo "ZIP: {$zipName} ({$zipSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Zip}\n";
|
||||
if ($tarSize > 0) {
|
||||
echo "TAR: {$tarName} ({$tarSize} bytes)\n";
|
||||
echo " SHA-256: {$sha256Tar}\n";
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT --------------------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"zip_name={$zipName}",
|
||||
"tar_name={$tarName}",
|
||||
"zip_path={$zipPath}",
|
||||
"tar_path={$tarPath}",
|
||||
"sha256_zip={$sha256Zip}",
|
||||
"sha256_tar={$sha256Tar}",
|
||||
"type_prefix={$typePrefix}",
|
||||
"ext_element={$extElement}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
$this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add directory contents to a ZipArchive.
|
||||
*/
|
||||
private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$filePath = $file->getPathname();
|
||||
$relativePath = $prefix . substr($filePath, strlen($dir) + 1);
|
||||
|
||||
// Check excludes
|
||||
$basename = basename($filePath);
|
||||
$skip = false;
|
||||
foreach ($excludes as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for ZIP
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
} else {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$app = new PackageBuildCli();
|
||||
exit($app->execute());
|
||||
|
||||
+183
-23
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,32 +10,191 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/platform_detect.php
|
||||
* BRIEF: Detect platform from .mokostandards file — outputs platform string
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Auto-detect repository platform type and optionally update manifest
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class PlatformDetectCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Auto-detect repository platform type and optionally update manifest');
|
||||
$this->addArgument('--path', 'Local repo path to scan (default: .)', '.');
|
||||
$this->addArgument('--token', 'Gitea API token for updating manifest', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--owner', 'Repo owner for API update', '');
|
||||
$this->addArgument('--repo', 'Repo name for API update', '');
|
||||
$this->addArgument('--update', 'Update manifest.platform via API (flag)', 'false');
|
||||
$this->addArgument('--github-output', 'Append platform=xxx to $GITHUB_OUTPUT (flag)', 'false');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
$token = $this->getArgument('--token');
|
||||
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$owner = $this->getArgument('--owner');
|
||||
$repo = $this->getArgument('--repo');
|
||||
$doUpdate = $this->isFlagSet('--update');
|
||||
$githubOutput = $this->isFlagSet('--github-output');
|
||||
|
||||
$platform = $this->detectPlatform($root);
|
||||
|
||||
$this->log('INFO', "Detected platform: {$platform}");
|
||||
echo $platform . "\n";
|
||||
|
||||
// Append to $GITHUB_OUTPUT if requested
|
||||
if ($githubOutput) {
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
|
||||
if ($outputFile !== false && $outputFile !== '') {
|
||||
file_put_contents($outputFile, "platform={$platform}\n", FILE_APPEND);
|
||||
$this->log('INFO', "Appended platform={$platform} to \$GITHUB_OUTPUT");
|
||||
} else {
|
||||
$this->log('WARN', '$GITHUB_OUTPUT is not set; skipping output append.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update manifest via API if requested
|
||||
if ($doUpdate) {
|
||||
if ($token === '' || $owner === '' || $repo === '') {
|
||||
$this->log('ERROR', '--update requires --token, --owner, and --repo.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', "[DRY RUN] Would update manifest.platform to \"{$platform}\" "
|
||||
. "for {$owner}/{$repo}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Updating manifest.platform for {$owner}/{$repo} to \"{$platform}\"...");
|
||||
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'PATCH',
|
||||
"/api/v1/repos/{$owner}/{$repo}/metadata",
|
||||
json_encode(['platform' => $platform])
|
||||
);
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||
$this->log('INFO', "Manifest updated successfully (HTTP {$response['code']}).");
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to update manifest (HTTP {$response['code']}): "
|
||||
. $response['body']);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function detectPlatform(string $root): string
|
||||
{
|
||||
// 1. Joomla — has pkg_*.xml or Joomla-style extension manifest
|
||||
$joomlaIndicators = array_merge(
|
||||
glob("{$root}/source/pkg_*.xml") ?: [],
|
||||
glob("{$root}/pkg_*.xml") ?: [],
|
||||
glob("{$root}/source/packages/*/services/provider.php") ?: [],
|
||||
glob("{$root}/**/templateDetails.xml") ?: [],
|
||||
);
|
||||
if (!empty($joomlaIndicators)) {
|
||||
return 'joomla';
|
||||
}
|
||||
|
||||
// 2. Dolibarr — has mod*.class.php or dolibarr module descriptor
|
||||
$doliIndicators = array_merge(
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/class/*.class.php") ?: [],
|
||||
);
|
||||
if (!empty($doliIndicators) && file_exists("{$root}/langs")) {
|
||||
return 'dolibarr';
|
||||
}
|
||||
|
||||
// 3. Go — has go.mod
|
||||
if (file_exists("{$root}/go.mod")) {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
// 4. MCP — has package.json with mcp-related content or dist/index.js pattern
|
||||
if (file_exists("{$root}/package.json")) {
|
||||
$pkg = json_decode(file_get_contents("{$root}/package.json"), true);
|
||||
$name = $pkg['name'] ?? '';
|
||||
if (str_contains($name, 'mcp') || isset($pkg['dependencies']['@modelcontextprotocol/sdk'])) {
|
||||
return 'mcp';
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Platform — is mokoplatform itself or org-config
|
||||
$repoName = basename($root);
|
||||
if (in_array($repoName, ['mokoplatform', 'mokogitea-org-config'])) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
// 6. Default
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
private function isFlagSet(string $flag): bool
|
||||
{
|
||||
$value = $this->getArgument($flag);
|
||||
|
||||
return $value === 'true' || $value === '1' || $value === 'yes';
|
||||
}
|
||||
|
||||
private function apiRequest(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $giteaUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$token}",
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo(
|
||||
$ch,
|
||||
CURLINFO_HTTP_CODE
|
||||
);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'body' => "cURL error: {$error}",
|
||||
];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
// Check .github/.mokostandards first, fallback to root
|
||||
$file = "{$root}/.github/.mokostandards";
|
||||
if (!file_exists($file)) {
|
||||
$file = "{$root}/.mokostandards";
|
||||
}
|
||||
if (!file_exists($file)) {
|
||||
echo "unknown\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
echo trim($m[1], " \t\n\r\"'") . "\n";
|
||||
} else {
|
||||
echo "unknown\n";
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new PlatformDetectCli();
|
||||
exit($app->execute());
|
||||
|
||||
+171
-150
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,163 +10,183 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release.php
|
||||
* BRIEF: Automate the MokoStandards version branch release flow
|
||||
*
|
||||
* USAGE
|
||||
* php cli/release.php # Release current version
|
||||
* php cli/release.php --bump minor # Bump minor, then release
|
||||
* php cli/release.php --bump major # Bump major, then release
|
||||
* php cli/release.php --dry-run # Preview without changes
|
||||
* BRIEF: Automate the moko-platform version branch release flow
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$bumpType = null;
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--bump' && isset($argv[$i + 1])) {
|
||||
$bumpType = $argv[$i + 1]; // patch | minor | major
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Automate the moko-platform version branch release flow');
|
||||
$this->addArgument('--bump', 'Bump type: patch, minor, or major', '');
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
|
||||
// Check both workflow directories for the bulk-repo-sync workflow
|
||||
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
|
||||
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
|
||||
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
|
||||
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
|
||||
|
||||
// ── Step 1: Read current version ────────────────────────────────────────
|
||||
$readme = "{$repoRoot}/README.md";
|
||||
$content = file_get_contents($readme);
|
||||
if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
|
||||
fwrite(STDERR, "No VERSION found in README.md\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$m[1];
|
||||
$minor = (int)$m[2];
|
||||
$patch = (int)$m[3];
|
||||
$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
// ── Step 2: Bump version if requested ───────────────────────────────────
|
||||
if ($bumpType) {
|
||||
switch ($bumpType) {
|
||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor': $minor++; $patch = 0; break;
|
||||
case 'patch': $patch++; break;
|
||||
default:
|
||||
fwrite(STDERR, "Invalid bump type: {$bumpType} (use patch/minor/major)\n");
|
||||
exit(1);
|
||||
}
|
||||
$newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "Bumping: {$currentVersion} → {$newVersion}\n";
|
||||
|
||||
if (!$dryRun) {
|
||||
// Update README.md
|
||||
$content = preg_replace(
|
||||
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $newVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $content);
|
||||
|
||||
// Propagate to all files
|
||||
echo "Propagating version to all files...\n";
|
||||
passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}");
|
||||
}
|
||||
$currentVersion = $newVersion;
|
||||
} else {
|
||||
echo "Version: {$currentVersion}\n";
|
||||
}
|
||||
|
||||
// Derive major.minor for branch naming (patches update existing branch)
|
||||
$versionParts = explode('.', $currentVersion);
|
||||
$minorVersion = $versionParts[0] . '.' . $versionParts[1];
|
||||
$branch = "version/{$minorVersion}";
|
||||
|
||||
// ── Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants ────────
|
||||
echo "Updating STANDARDS_VERSION → {$currentVersion}\n";
|
||||
echo "Updating STANDARDS_MINOR → {$minorVersion}\n";
|
||||
if (!$dryRun) {
|
||||
$syncContent = file_get_contents($syncFile);
|
||||
$syncContent = preg_replace(
|
||||
"/STANDARDS_VERSION\s*=\s*'[^']+'/",
|
||||
"STANDARDS_VERSION = '{$currentVersion}'",
|
||||
$syncContent
|
||||
);
|
||||
$syncContent = preg_replace(
|
||||
"/STANDARDS_MINOR\s*=\s*'[^']+'/",
|
||||
"STANDARDS_MINOR = '{$minorVersion}'",
|
||||
$syncContent
|
||||
);
|
||||
file_put_contents($syncFile, $syncContent);
|
||||
}
|
||||
|
||||
// ── Step 4: Update bulk-repo-sync.yml checkout ref ──────────────────────
|
||||
echo "Updating bulk-repo-sync.yml → {$branch}\n";
|
||||
if (!$dryRun) {
|
||||
$bulkContent = file_get_contents($bulkSyncFile);
|
||||
$bulkContent = preg_replace(
|
||||
'/ref:\s*version\/[\d.]+/',
|
||||
"ref: {$branch}",
|
||||
$bulkContent
|
||||
);
|
||||
file_put_contents($bulkSyncFile, $bulkContent);
|
||||
}
|
||||
|
||||
// ── Step 5: Update repository-cleanup.yml current branch ────────────────
|
||||
echo "Updating repository-cleanup.yml → chore/sync-mokostandards-v{$minorVersion}\n";
|
||||
if (!$dryRun) {
|
||||
$cleanupContent = file_get_contents($cleanupFile);
|
||||
$cleanupContent = preg_replace(
|
||||
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
|
||||
"CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"",
|
||||
$cleanupContent
|
||||
);
|
||||
file_put_contents($cleanupFile, $cleanupContent);
|
||||
}
|
||||
|
||||
// ── Step 6: Commit changes ──────────────────────────────────────────────
|
||||
if (!$dryRun) {
|
||||
echo "Committing...\n";
|
||||
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
|
||||
passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push");
|
||||
}
|
||||
|
||||
// ── Step 7: Create or update version branch ─────────────────────────────
|
||||
$isPatch = ($versionParts[2] ?? '00') !== '00';
|
||||
if ($isPatch) {
|
||||
echo "Updating version branch: {$branch} (patch update)\n";
|
||||
if (!$dryRun) {
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
|
||||
}
|
||||
} else {
|
||||
echo "Creating version branch: {$branch} (minor release)\n";
|
||||
if (!$dryRun) {
|
||||
$exitCode = 0;
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
echo "Branch {$branch} already exists — force updating\n";
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
|
||||
protected function run(): int
|
||||
{
|
||||
$bumpType = $this->getArgument('--bump');
|
||||
if (empty($bumpType)) {
|
||||
$bumpType = null;
|
||||
}
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php";
|
||||
// Check both workflow directories for the bulk-repo-sync workflow
|
||||
$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml")
|
||||
? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml"
|
||||
: "{$repoRoot}/.github/workflows/bulk-repo-sync.yml";
|
||||
$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template";
|
||||
|
||||
// -- Step 1: Read current version --
|
||||
$readme = "{$repoRoot}/README.md";
|
||||
$content = file_get_contents($readme);
|
||||
if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) {
|
||||
$this->log('ERROR', 'No VERSION found in README.md');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$major = (int)$m[1];
|
||||
$minor = (int)$m[2];
|
||||
$patch = (int)$m[3];
|
||||
$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
// -- Step 2: Bump version if requested --
|
||||
if ($bumpType) {
|
||||
switch ($bumpType) {
|
||||
case 'major':
|
||||
$major++;
|
||||
$minor = 0;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
$minor++;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
$patch++;
|
||||
break;
|
||||
default:
|
||||
$this->log('ERROR', "Invalid bump type: {$bumpType} (use patch/minor/major)");
|
||||
return 1;
|
||||
}
|
||||
$newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "Bumping: {$currentVersion} -> {$newVersion}\n";
|
||||
|
||||
if (!$this->dryRun) {
|
||||
// Update README.md
|
||||
$content = preg_replace(
|
||||
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $newVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $content);
|
||||
|
||||
// Propagate to all files
|
||||
echo "Propagating version to all files...\n";
|
||||
passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}");
|
||||
}
|
||||
$currentVersion = $newVersion;
|
||||
} else {
|
||||
echo "Version: {$currentVersion}\n";
|
||||
}
|
||||
|
||||
// Derive major.minor for branch naming (patches update existing branch)
|
||||
$versionParts = explode('.', $currentVersion);
|
||||
$minorVersion = $versionParts[0] . '.' . $versionParts[1];
|
||||
$branch = "version/{$minorVersion}";
|
||||
|
||||
// -- Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants --
|
||||
echo "Updating STANDARDS_VERSION -> {$currentVersion}\n";
|
||||
echo "Updating STANDARDS_MINOR -> {$minorVersion}\n";
|
||||
if (!$this->dryRun) {
|
||||
$syncContent = file_get_contents($syncFile);
|
||||
$syncContent = preg_replace(
|
||||
"/STANDARDS_VERSION\s*=\s*'[^']+'/",
|
||||
"STANDARDS_VERSION = '{$currentVersion}'",
|
||||
$syncContent
|
||||
);
|
||||
$syncContent = preg_replace(
|
||||
"/STANDARDS_MINOR\s*=\s*'[^']+'/",
|
||||
"STANDARDS_MINOR = '{$minorVersion}'",
|
||||
$syncContent
|
||||
);
|
||||
file_put_contents($syncFile, $syncContent);
|
||||
}
|
||||
|
||||
// -- Step 4: Update bulk-repo-sync.yml checkout ref --
|
||||
echo "Updating bulk-repo-sync.yml -> {$branch}\n";
|
||||
if (!$this->dryRun) {
|
||||
$bulkContent = file_get_contents($bulkSyncFile);
|
||||
$bulkContent = preg_replace(
|
||||
'/ref:\s*version\/[\d.]+/',
|
||||
"ref: {$branch}",
|
||||
$bulkContent
|
||||
);
|
||||
file_put_contents($bulkSyncFile, $bulkContent);
|
||||
}
|
||||
|
||||
// -- Step 5: Update repository-cleanup.yml current branch --
|
||||
echo "Updating repository-cleanup.yml -> chore/sync-mokostandards-v{$minorVersion}\n";
|
||||
if (!$this->dryRun) {
|
||||
$cleanupContent = file_get_contents($cleanupFile);
|
||||
$cleanupContent = preg_replace(
|
||||
'/CURRENT="chore\/sync-mokostandards-v[^"]*"/',
|
||||
"CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"",
|
||||
$cleanupContent
|
||||
);
|
||||
file_put_contents($cleanupFile, $cleanupContent);
|
||||
}
|
||||
|
||||
// -- Step 6: Commit changes --
|
||||
if (!$this->dryRun) {
|
||||
echo "Committing...\n";
|
||||
passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\"");
|
||||
passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push");
|
||||
}
|
||||
|
||||
// -- Step 7: Create or update version branch --
|
||||
$isPatch = ($versionParts[2] ?? '00') !== '00';
|
||||
if ($isPatch) {
|
||||
echo "Updating version branch: {$branch} (patch update)\n";
|
||||
if (!$this->dryRun) {
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
|
||||
}
|
||||
} else {
|
||||
echo "Creating version branch: {$branch} (minor release)\n";
|
||||
if (!$this->dryRun) {
|
||||
$exitCode = 0;
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
echo "Branch {$branch} already exists — force updating\n";
|
||||
passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 8: Create git tag (never overwrite existing) --
|
||||
$tag = "v{$currentVersion}";
|
||||
echo "Creating tag {$tag}\n";
|
||||
if (!$this->dryRun) {
|
||||
$exitCode = 0;
|
||||
passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
echo "Tag {$tag} already exists — skipping\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nRelease {$currentVersion} complete\n";
|
||||
echo " Branch: {$branch}\n";
|
||||
echo " Tag: {$tag}\n";
|
||||
echo " Next: run bulk sync to push to all repos\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8: Create git tag (never overwrite existing) ───────────────────
|
||||
$tag = "v{$currentVersion}";
|
||||
echo "Creating tag {$tag}\n";
|
||||
if (!$dryRun) {
|
||||
$exitCode = 0;
|
||||
passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
echo "⚠️ Tag {$tag} already exists — skipping\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n✅ Release {$currentVersion} complete\n";
|
||||
echo " Branch: {$branch}\n";
|
||||
echo " Tag: {$tag}\n";
|
||||
echo " Next: run bulk sync to push to all repos\n";
|
||||
$app = new ReleaseCli();
|
||||
exit($app->execute());
|
||||
|
||||
+139
-133
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,143 +11,148 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_body_update.php
|
||||
* BRIEF: Update Gitea release body with changelog extract and checksums
|
||||
*
|
||||
* Usage:
|
||||
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL
|
||||
* php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL --zip-name pkg.zip --zip-sha abc123
|
||||
*
|
||||
* Options:
|
||||
* --path Repo root for CHANGELOG.md (default: .)
|
||||
* --version Version string (required)
|
||||
* --release-tag Gitea release tag (required)
|
||||
* --token Gitea API token (required)
|
||||
* --api-base Gitea API base URL (required)
|
||||
* --zip-name ZIP filename for checksum table
|
||||
* --tar-name tar.gz filename for checksum table
|
||||
* --zip-sha SHA256 of ZIP
|
||||
* --tar-sha SHA256 of tar.gz
|
||||
* --output-summary Write to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$releaseTag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$zipName = null;
|
||||
$tarName = null;
|
||||
$zipSha = null;
|
||||
$tarSha = null;
|
||||
$outputSummary = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1];
|
||||
if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1];
|
||||
if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1];
|
||||
if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseBodyUpdateCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Update Gitea release body with changelog extract and checksums');
|
||||
$this->addArgument('--path', 'Repo root for CHANGELOG.md', '.');
|
||||
$this->addArgument('--version', 'Version string', '');
|
||||
$this->addArgument('--release-tag', 'Gitea release tag', '');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', '');
|
||||
$this->addArgument('--zip-name', 'ZIP filename for checksum table', '');
|
||||
$this->addArgument('--tar-name', 'tar.gz filename for checksum table', '');
|
||||
$this->addArgument('--zip-sha', 'SHA256 of ZIP', '');
|
||||
$this->addArgument('--tar-sha', 'SHA256 of tar.gz', '');
|
||||
$this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$releaseTag = $this->getArgument('--release-tag');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$zipName = $this->getArgument('--zip-name');
|
||||
$tarName = $this->getArgument('--tar-name');
|
||||
$zipSha = $this->getArgument('--zip-sha');
|
||||
$tarSha = $this->getArgument('--tar-sha');
|
||||
$outputSummary = $this->getArgument('--output-summary');
|
||||
|
||||
if (empty($token)) {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if (empty($version) || empty($releaseTag) || empty($token) || empty($apiBase)) {
|
||||
$this->log('ERROR', 'Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Extract changelog section for this version
|
||||
$changelog = '';
|
||||
$clFile = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($clFile)) {
|
||||
$lines = file($clFile, FILE_IGNORE_NEW_LINES);
|
||||
$capturing = false;
|
||||
$clLines = [];
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
|
||||
$capturing = true;
|
||||
continue;
|
||||
}
|
||||
if ($capturing && preg_match('/^## /', $line)) {
|
||||
break;
|
||||
}
|
||||
if ($capturing) {
|
||||
$clLines[] = $line;
|
||||
}
|
||||
}
|
||||
$changelog = trim(implode("\n", $clLines));
|
||||
}
|
||||
|
||||
// Build release body
|
||||
$body = "## {$version} (" . date('Y-m-d') . ")\n\n";
|
||||
if (!empty($changelog)) {
|
||||
$body .= "{$changelog}\n\n";
|
||||
}
|
||||
|
||||
if (!empty($zipSha) || !empty($tarSha)) {
|
||||
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
|
||||
if (!empty($zipName) && !empty($zipSha)) {
|
||||
$body .= "| `{$zipName}` | `{$zipSha}` |\n";
|
||||
}
|
||||
if (!empty($tarName) && !empty($tarSha)) {
|
||||
$body .= "| `{$tarName}` | `{$tarSha}` |\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Get release ID by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
$this->log('ERROR', "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$release = json_decode($response, true);
|
||||
$releaseId = $release['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
$this->log('ERROR', "No release ID found for tag '{$releaseTag}'");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// PATCH release body
|
||||
$payload = json_encode(['body' => $body]);
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$this->log('ERROR', "Failed to update release body (HTTP {$httpCode})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
|
||||
if ($version === null || $releaseTag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Extract changelog section for this version
|
||||
$changelog = '';
|
||||
$clFile = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($clFile)) {
|
||||
$lines = file($clFile, FILE_IGNORE_NEW_LINES);
|
||||
$capturing = false;
|
||||
$clLines = [];
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
|
||||
$capturing = true;
|
||||
continue;
|
||||
}
|
||||
if ($capturing && preg_match('/^## /', $line)) break;
|
||||
if ($capturing) $clLines[] = $line;
|
||||
}
|
||||
$changelog = trim(implode("\n", $clLines));
|
||||
}
|
||||
|
||||
// Build release body
|
||||
$body = "## {$version} (" . date('Y-m-d') . ")\n\n";
|
||||
if (!empty($changelog)) {
|
||||
$body .= "{$changelog}\n\n";
|
||||
}
|
||||
|
||||
if ($zipSha !== null || $tarSha !== null) {
|
||||
$body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n";
|
||||
if ($zipName !== null && $zipSha !== null) {
|
||||
$body .= "| `{$zipName}` | `{$zipSha}` |\n";
|
||||
}
|
||||
if ($tarName !== null && $tarSha !== null) {
|
||||
$body .= "| `{$tarName}` | `{$tarSha}` |\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Get release ID by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($response)) {
|
||||
fwrite(STDERR, "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$release = json_decode($response, true);
|
||||
$releaseId = $release['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
fwrite(STDERR, "No release ID found for tag '{$releaseTag}'\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// PATCH release body
|
||||
$payload = json_encode(['body' => $body]);
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
fwrite(STDERR, "Failed to update release body (HTTP {$httpCode})\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Release body updated for {$releaseTag} (release #{$releaseId})\n";
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new ReleaseBodyUpdateCli();
|
||||
exit($app->execute());
|
||||
|
||||
+24
-3
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,9 +10,29 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_cascade.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
||||
*/
|
||||
|
||||
echo "release_cascade.php: No-op (cascade behavior removed — each stream is independent)\n";
|
||||
exit(0);
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseCascadeCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('DEPRECATED — cascade behavior removed');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$this->log('INFO', 'No-op (cascade behavior removed — each stream is independent)');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ReleaseCascadeCli();
|
||||
exit($app->execute());
|
||||
|
||||
+274
-303
@@ -11,328 +11,299 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_create.php
|
||||
* BRIEF: Create or overwrite a Gitea release with proper naming
|
||||
*
|
||||
* Usage:
|
||||
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL
|
||||
* php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease
|
||||
* php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo
|
||||
*
|
||||
* Replaces the inline bash in auto-release.yml Step 7b.
|
||||
* Detects extension metadata from manifest, builds a proper release name,
|
||||
* generates release notes, and creates (or overwrites) a Gitea release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$tag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'main';
|
||||
$repoName = '';
|
||||
$prerelease = false;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||
$tag = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repoName = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--prerelease') {
|
||||
$prerelease = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$envToken = getenv('MOKOGITEA_TOKEN');
|
||||
if ($envToken === false || $envToken === '') {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
}
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null || $tag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n");
|
||||
fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n");
|
||||
fwrite(STDERR, " --branch main Target commitish (default: main)\n");
|
||||
fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n");
|
||||
fwrite(STDERR, " --prerelease Mark release as prerelease\n");
|
||||
fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Helper: Gitea API request ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a request to the Gitea API.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Authorization token
|
||||
* @param string $method HTTP method (GET, POST, DELETE, etc.)
|
||||
* @param string|null $body JSON request body
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
class ReleaseCreateCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
|
||||
return null;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Create or overwrite a Gitea release with proper naming');
|
||||
$this->addArgument('--path', 'Repo root for manifest detection (default: .)', '.');
|
||||
$this->addArgument('--version', 'Version string (required)', '');
|
||||
$this->addArgument('--tag', 'Release tag name (required)', '');
|
||||
$this->addArgument('--token', 'Gitea API token (required)', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
|
||||
$this->addArgument('--branch', 'Target commitish (default: main)', 'main');
|
||||
$this->addArgument('--repo', 'Repo name for fallback element detection', '');
|
||||
$this->addArgument('--prerelease', 'Mark release as prerelease', false);
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$tag = $this->getArgument('--tag');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$prerelease = (bool) $this->getArgument('--prerelease');
|
||||
|
||||
// ── Detect element metadata ─────────────────────────────────────────────────
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
$typePrefix = '';
|
||||
|
||||
// Detect platform and display name from manifest.xml
|
||||
$platform = 'generic';
|
||||
$prettyName = '';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if ($content !== false) {
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
// <display-name> is the human-friendly name; <name> is the element/repo name
|
||||
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
|
||||
$prettyName = trim($dn[1]);
|
||||
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
|
||||
$prettyName = trim($nm[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find extension manifest (Joomla XML)
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Dolibarr module file
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
glob("{$root}/src/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata based on platform
|
||||
switch (true) {
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
if ($xml === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name: <element>, plugin= attribute, <packagename>, or filename
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||
$extElement = $pm2[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
// Allow token from environment
|
||||
if ($token === '') {
|
||||
$envToken = getenv('MOKOGITEA_TOKEN');
|
||||
if ($envToken === false || $envToken === '') {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
}
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable name
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
|
||||
$this->log('ERROR', "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]");
|
||||
$this->log('ERROR', " --path . Repo root for manifest detection (default: .)");
|
||||
$this->log('ERROR', " --branch main Target commitish (default: main)");
|
||||
$this->log('ERROR', " --repo REPO Repo name for fallback element detection");
|
||||
$this->log('ERROR', " --prerelease Mark release as prerelease");
|
||||
$this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
|
||||
// ── Detect element metadata ─────────────────────────────────────────────
|
||||
|
||||
$modContent = file_get_contents($modFile);
|
||||
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
|
||||
$extName = $nm2[1];
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$extName = '';
|
||||
$typePrefix = '';
|
||||
|
||||
// Detect platform and display name from manifest.xml
|
||||
$platform = 'generic';
|
||||
$prettyName = '';
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$content = file_get_contents($manifestXml);
|
||||
if ($content !== false) {
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
if (preg_match('/<display-name>([^<]+)<\/display-name>/', $content, $dn)) {
|
||||
$prettyName = trim($dn[1]);
|
||||
} elseif (preg_match('/<name>([^<]+)<\/name>/', $content, $nm)) {
|
||||
$prettyName = trim($nm[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip existing type prefix from element to prevent duplication
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
|
||||
|
||||
// Compute type prefix
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback name
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName !== '' ? $repoName : basename($root);
|
||||
}
|
||||
|
||||
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
|
||||
|
||||
// ── Build release name ──────────────────────────────────────────────────────
|
||||
// Use display-name from manifest.xml if available, otherwise fall back to extName
|
||||
$displayName = !empty($prettyName) ? $prettyName : $extName;
|
||||
$releaseName = "{$displayName} {$version} ({$typePrefix}{$extElement}-{$version})";
|
||||
echo "Release name: {$releaseName}\n";
|
||||
|
||||
// ── Generate release notes ──────────────────────────────────────────────────
|
||||
|
||||
$releaseNotes = "Release {$version}";
|
||||
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
|
||||
if (file_exists($releaseNotesScript)) {
|
||||
$cmd = sprintf(
|
||||
'php %s --path %s --version %s',
|
||||
escapeshellarg($releaseNotesScript),
|
||||
escapeshellarg($root),
|
||||
escapeshellarg($version)
|
||||
);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode === 0 && count($output) > 0) {
|
||||
$notes = implode("\n", $output);
|
||||
if (trim($notes) !== '') {
|
||||
$releaseNotes = $notes;
|
||||
echo "Release notes: generated from CHANGELOG.md\n";
|
||||
// Find extension manifest (Joomla XML)
|
||||
$extManifest = null;
|
||||
$manifestFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Dolibarr module file
|
||||
$modFile = null;
|
||||
$modFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||
);
|
||||
foreach ($modFiles as $file) {
|
||||
$c = file_get_contents($file);
|
||||
if ($c !== false && strpos($c, 'extends DolibarrModules') !== false) {
|
||||
$modFile = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata based on platform
|
||||
switch (true) {
|
||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||
$xml = file_get_contents($extManifest);
|
||||
if ($xml === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
|
||||
$extElement = $pm2[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) {
|
||||
$extName = trim($nm[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
|
||||
$extType = 'dolibarr-module';
|
||||
$modBasename = basename($modFile, '.class.php');
|
||||
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename);
|
||||
|
||||
$modContent = file_get_contents($modFile);
|
||||
if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) {
|
||||
$extName = $nm2[1];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
$extType = 'generic';
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip existing type prefix from element to prevent duplication
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement;
|
||||
|
||||
// Compute type prefix
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback name
|
||||
if (empty($extName)) {
|
||||
$extName = $repoName !== '' ? $repoName : basename($root);
|
||||
}
|
||||
|
||||
echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n";
|
||||
|
||||
// ── Build release name ──────────────────────────────────────────────────────
|
||||
$displayName = !empty($prettyName) ? $prettyName : $extName;
|
||||
$releaseName = "{$displayName} (VERSION: {$version})";
|
||||
echo "Release name: {$releaseName}\n";
|
||||
|
||||
// ── Generate release notes ──────────────────────────────────────────────────
|
||||
|
||||
$releaseNotes = "Release {$version}";
|
||||
$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php';
|
||||
if (file_exists($releaseNotesScript)) {
|
||||
$cmd = sprintf(
|
||||
'php %s --path %s --version %s',
|
||||
escapeshellarg($releaseNotesScript),
|
||||
escapeshellarg($root),
|
||||
escapeshellarg($version)
|
||||
);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode === 0 && count($output) > 0) {
|
||||
$notes = implode("\n", $output);
|
||||
if (trim($notes) !== '') {
|
||||
$releaseNotes = $notes;
|
||||
echo "Release notes: generated from CHANGELOG.md\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete existing release at tag (if present) ─────────────────────────────
|
||||
|
||||
$existing = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if ($existing !== null && !empty($existing['id'])) {
|
||||
$existingId = $existing['id'];
|
||||
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
|
||||
|
||||
// Delete release
|
||||
$this->giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
|
||||
|
||||
// Delete tag
|
||||
$this->giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Create new release ──────────────────────────────────────────────────────
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseNotes,
|
||||
'prerelease' => $prerelease,
|
||||
]);
|
||||
|
||||
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
|
||||
if ($newRelease === null || empty($newRelease['id'])) {
|
||||
$this->log('ERROR', "Failed to create release at tag: {$tag}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$releaseId = $newRelease['id'];
|
||||
echo "Created release: {$tag} (id: {$releaseId})\n";
|
||||
|
||||
// Output release_id to stdout for CI consumption
|
||||
echo "release_id={$releaseId}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete existing release at tag (if present) ─────────────────────────────
|
||||
|
||||
$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if ($existing !== null && !empty($existing['id'])) {
|
||||
$existingId = $existing['id'];
|
||||
echo "Deleting existing release: {$tag} (id: {$existingId})\n";
|
||||
|
||||
// Delete release
|
||||
giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE');
|
||||
|
||||
// Delete tag
|
||||
giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Create new release ──────────────────────────────────────────────────────
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseNotes,
|
||||
'prerelease' => $prerelease,
|
||||
]);
|
||||
|
||||
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}');
|
||||
if ($newRelease === null || empty($newRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create release at tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$releaseId = $newRelease['id'];
|
||||
echo "Created release: {$tag} (id: {$releaseId})\n";
|
||||
|
||||
// Output release_id to stdout for CI consumption
|
||||
echo "release_id={$releaseId}\n";
|
||||
exit(0);
|
||||
$app = new ReleaseCreateCli();
|
||||
exit($app->execute());
|
||||
|
||||
+151
-215
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,230 +11,165 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_manage.php
|
||||
* BRIEF: Create/update Gitea releases, upload assets, update release body
|
||||
*
|
||||
* Usage:
|
||||
* # Create a release
|
||||
* php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \
|
||||
* --body "Release notes" --target main --token TOKEN --api-base URL
|
||||
*
|
||||
* # Upload assets to a release
|
||||
* php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \
|
||||
* --token TOKEN --api-base URL
|
||||
*
|
||||
* # Update release body (e.g. add SHA checksums)
|
||||
* php release_manage.php --action update-body --tag stable --body "New body" \
|
||||
* --token TOKEN --api-base URL
|
||||
*
|
||||
* # Delete a release and its tag
|
||||
* php release_manage.php --action delete --tag stable --token TOKEN --api-base URL
|
||||
*
|
||||
* Options:
|
||||
* --action create | upload | update-body | delete (required)
|
||||
* --tag Release tag name (required)
|
||||
* --name Release name/title (for create)
|
||||
* --body Release body/description (for create, update-body)
|
||||
* --body-file Read body from file instead of --body
|
||||
* --target Target branch/commitish (for create, default: main)
|
||||
* --files Comma-separated file paths to upload (for upload)
|
||||
* --token Gitea API token (or MOKOGITEA_TOKEN/GITEA_TOKEN env var)
|
||||
* --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo)
|
||||
*
|
||||
* NOTE: This script uses PHP curl for all HTTP operations (no shell calls).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$action = null;
|
||||
$tag = null;
|
||||
$name = null;
|
||||
$body = null;
|
||||
$bodyFile = null;
|
||||
$target = 'main';
|
||||
$files = [];
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1];
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1];
|
||||
if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1];
|
||||
if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1];
|
||||
if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1];
|
||||
if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1];
|
||||
if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1]));
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
}
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
|
||||
// Read body from file if specified
|
||||
if ($bodyFile !== null && file_exists($bodyFile)) {
|
||||
$body = file_get_contents($bodyFile);
|
||||
}
|
||||
|
||||
if ($action === null || $tag === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a Gitea API request using curl
|
||||
*/
|
||||
function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||
class ReleaseManageCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$headers = ["Authorization: token {$token}"];
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
|
||||
$this->addArgument('--action', 'create | upload | update-body | delete', null);
|
||||
$this->addArgument('--tag', 'Release tag name', null);
|
||||
$this->addArgument('--name', 'Release name/title', null);
|
||||
$this->addArgument('--body', 'Release body/description', null);
|
||||
$this->addArgument('--body-file', 'Read body from file', null);
|
||||
$this->addArgument('--target', 'Target branch/commitish', 'main');
|
||||
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
|
||||
$this->addArgument('--token', 'Gitea API token', null);
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', null);
|
||||
}
|
||||
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
];
|
||||
protected function run(): int
|
||||
{
|
||||
$action = $this->getArgument('--action');
|
||||
$tag = $this->getArgument('--tag');
|
||||
$name = $this->getArgument('--name');
|
||||
$body = $this->getArgument('--body');
|
||||
$bodyFile = $this->getArgument('--body-file');
|
||||
$target = $this->getArgument('--target');
|
||||
$filesArg = $this->getArgument('--files');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
|
||||
if ($token === null) {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
if ($bodyFile !== null && file_exists($bodyFile)) {
|
||||
$body = file_get_contents($bodyFile);
|
||||
}
|
||||
if ($action === null || $tag === null || $token === null || $apiBase === null) {
|
||||
$this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
|
||||
return 1;
|
||||
}
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
$existingId = $existing['id'];
|
||||
$this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
|
||||
}
|
||||
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
|
||||
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
$releaseId = $result['data']['id'] ?? 'unknown';
|
||||
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'upload':
|
||||
if (empty($files)) {
|
||||
$this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
|
||||
return 1;
|
||||
}
|
||||
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
$this->log('ERROR', "No release found for tag: {$tag}");
|
||||
return 1;
|
||||
}
|
||||
$releaseId = $release['id'];
|
||||
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
foreach ($files as $filePath) {
|
||||
$filePath = trim($filePath);
|
||||
if (!file_exists($filePath)) {
|
||||
$this->log('ERROR', "File not found: {$filePath}");
|
||||
continue;
|
||||
}
|
||||
$fileName = basename($filePath);
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (($asset['name'] ?? '') === $fileName) {
|
||||
$this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||
echo "Deleted existing asset: {$fileName}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
||||
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Uploaded: {$fileName}\n";
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'update-body':
|
||||
$release = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
$this->log('ERROR', "No release found for tag: {$tag}");
|
||||
return 1;
|
||||
}
|
||||
$payload = json_encode(['body' => $body ?? '']);
|
||||
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Release body updated for tag: {$tag}\n";
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to update body: HTTP {$result['code']}");
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
$this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted: {$tag} (id: {$existing['id']})\n";
|
||||
} else {
|
||||
echo "No release found for tag: {$tag}\n";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$this->log('ERROR', "Unknown action: {$action}");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($jsonBody !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
||||
} elseif ($filePath !== null) {
|
||||
$headers[] = 'Content-Type: application/octet-stream';
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
|
||||
}
|
||||
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$headers = ["Authorization: token {$token}"];
|
||||
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
|
||||
if ($jsonBody !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
|
||||
} elseif ($filePath !== null) {
|
||||
$headers[] = 'Content-Type: application/octet-stream';
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
|
||||
}
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
|
||||
}
|
||||
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response ?: '{}', true) ?: [];
|
||||
return ['code' => $httpCode, 'data' => $data];
|
||||
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||
{
|
||||
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get release by tag
|
||||
*/
|
||||
function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
|
||||
{
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
|
||||
if ($result['code'] === 200 && isset($result['data']['id'])) {
|
||||
return $result['data'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Action dispatch ----------------------------------------------------------
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
// Delete existing release if present
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
$existingId = $existing['id'];
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'name' => $name ?? $tag,
|
||||
'body' => $body ?? '',
|
||||
'target_commitish' => $target,
|
||||
]);
|
||||
|
||||
$result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
$releaseId = $result['data']['id'] ?? 'unknown';
|
||||
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n");
|
||||
fwrite(STDERR, json_encode($result['data']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'upload':
|
||||
if (empty($files)) {
|
||||
fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$release = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
fwrite(STDERR, "No release found for tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
$releaseId = $release['id'];
|
||||
|
||||
// Get existing assets to avoid duplicates
|
||||
$assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
$filePath = trim($filePath);
|
||||
if (!file_exists($filePath)) {
|
||||
fwrite(STDERR, "File not found: {$filePath}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = basename($filePath);
|
||||
|
||||
// Delete existing asset with same name
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (($asset['name'] ?? '') === $fileName) {
|
||||
releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
|
||||
echo "Deleted existing asset: {$fileName}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
|
||||
$result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Uploaded: {$fileName}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update-body':
|
||||
$release = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($release === null) {
|
||||
fwrite(STDERR, "No release found for tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
$releaseId = $release['id'];
|
||||
|
||||
$payload = json_encode(['body' => $body ?? '']);
|
||||
$result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload);
|
||||
if ($result['code'] >= 200 && $result['code'] < 300) {
|
||||
echo "Release body updated for tag: {$tag}\n";
|
||||
} else {
|
||||
fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n");
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$existing = getReleaseByTag($apiBase, $tag, $token);
|
||||
if ($existing !== null) {
|
||||
releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
|
||||
releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
|
||||
echo "Deleted: {$tag} (id: {$existing['id']})\n";
|
||||
} else {
|
||||
echo "No release found for tag: {$tag}\n";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
fwrite(STDERR, "Unknown action: {$action}\n");
|
||||
fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new ReleaseManageCli();
|
||||
exit($app->execute());
|
||||
|
||||
+220
-273
@@ -11,290 +11,237 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_mirror.php
|
||||
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
|
||||
*
|
||||
* Usage:
|
||||
* php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \
|
||||
* --gh-token GH_MIRROR_TOKEN --gh-repo MokoConsulting/MokoWaaS
|
||||
*
|
||||
* Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release.
|
||||
* If the GitHub release already exists at the same tag, its title is updated via PATCH.
|
||||
* All assets from the Gitea release are downloaded and uploaded to the GitHub release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$version = null;
|
||||
$tag = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$ghToken = null;
|
||||
$ghRepo = null;
|
||||
$branch = 'main';
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--tag' && isset($argv[$i + 1])) {
|
||||
$tag = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gh-token' && isset($argv[$i + 1])) {
|
||||
$ghToken = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gh-repo' && isset($argv[$i + 1])) {
|
||||
$ghRepo = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Allow tokens from environment
|
||||
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: null);
|
||||
|
||||
if (
|
||||
$version === null || $tag === null || $token === null || $apiBase === null
|
||||
|| $ghToken === null || $ghRepo === null
|
||||
) {
|
||||
fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
|
||||
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]\n");
|
||||
fwrite(STDERR, " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)\n");
|
||||
fwrite(STDERR, " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a request to the Gitea API.
|
||||
*
|
||||
* @param string $url Full Gitea API URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||
* @param string|null $body JSON request body or null
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
class ReleaseMirrorCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from Gitea to a local path.
|
||||
*
|
||||
* @param string $url Download URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $dest Local destination path
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the GitHub API.
|
||||
*
|
||||
* @param string $url Full GitHub API URL
|
||||
* @param string $token GitHub personal access token
|
||||
* @param string $method HTTP method (GET, POST, PATCH, DELETE)
|
||||
* @param string|null $body JSON request body or null
|
||||
*
|
||||
* @return array<string, mixed>|null Decoded response or null on failure
|
||||
*/
|
||||
function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a binary asset to a GitHub release.
|
||||
*
|
||||
* @param string $uploadUrl GitHub upload URL (uploads.github.com)
|
||||
* @param string $token GitHub personal access token
|
||||
* @param string $filePath Local file path to upload
|
||||
* @param string $name Asset filename for GitHub
|
||||
*
|
||||
* @return int HTTP status code
|
||||
*/
|
||||
function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
|
||||
{
|
||||
$url = $uploadUrl . '?name=' . urlencode($name);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($filePath),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return $httpCode;
|
||||
}
|
||||
|
||||
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
|
||||
|
||||
echo "Fetching Gitea release: {$tag}\n";
|
||||
$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if (!$giteaRelease || empty($giteaRelease['id'])) {
|
||||
fwrite(STDERR, "No Gitea release found with tag: {$tag}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$giteaId = $giteaRelease['id'];
|
||||
$releaseName = $giteaRelease['name'] ?? "{$version}";
|
||||
$releaseBody = $giteaRelease['body'] ?? '';
|
||||
$assets = $giteaRelease['assets'] ?? [];
|
||||
|
||||
echo " Name: {$releaseName}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Step 2: Check / create GitHub release ────────────────────────────────────
|
||||
|
||||
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
|
||||
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
|
||||
|
||||
echo "Checking GitHub release: {$tag}\n";
|
||||
$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
|
||||
|
||||
if ($ghRelease && !empty($ghRelease['id'])) {
|
||||
// Update existing release title
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
|
||||
$patchPayload = json_encode([
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
]);
|
||||
githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
|
||||
} else {
|
||||
// Create new release
|
||||
echo " Creating GitHub release\n";
|
||||
$createPayload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
'draft' => false,
|
||||
'prerelease' => ($tag !== 'stable'),
|
||||
]);
|
||||
$ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
|
||||
if (!$ghRelease || empty($ghRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create GitHub release\n");
|
||||
exit(1);
|
||||
}
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " Created GitHub release (id: {$ghReleaseId})\n";
|
||||
}
|
||||
|
||||
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'] ?? '';
|
||||
$downloadUrl = $asset['browser_download_url'] ?? '';
|
||||
if ($name === '' || $downloadUrl === '') {
|
||||
continue;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository');
|
||||
$this->addArgument('--version', 'Version string (required)', '');
|
||||
$this->addArgument('--tag', 'Release tag name (required)', '');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', '');
|
||||
$this->addArgument('--gh-token', 'GitHub personal access token', '');
|
||||
$this->addArgument('--gh-repo', 'GitHub org/repo (required)', '');
|
||||
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
|
||||
}
|
||||
|
||||
$localPath = "{$tmpDir}/{$name}";
|
||||
echo " Downloading: {$name}\n";
|
||||
protected function run(): int
|
||||
{
|
||||
$version = $this->getArgument('--version');
|
||||
$tag = $this->getArgument('--tag');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$ghToken = $this->getArgument('--gh-token');
|
||||
$ghRepo = $this->getArgument('--gh-repo');
|
||||
$branch = $this->getArgument('--branch');
|
||||
|
||||
if (!giteaDownload($downloadUrl, $token, $localPath)) {
|
||||
fwrite(STDERR, " Failed to download: {$name}\n");
|
||||
continue;
|
||||
// Allow tokens from environment
|
||||
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''));
|
||||
$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: '');
|
||||
|
||||
if ($version === '' || $tag === '' || $token === '' || $apiBase === '' || $ghToken === '' || $ghRepo === '') {
|
||||
$this->log('ERROR', "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " .
|
||||
"--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]");
|
||||
$this->log('ERROR', " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)");
|
||||
$this->log('ERROR', " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Step 1: Get Gitea release by tag ─────────────────────────────────────────
|
||||
|
||||
echo "Fetching Gitea release: {$tag}\n";
|
||||
$giteaRelease = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if (!$giteaRelease || empty($giteaRelease['id'])) {
|
||||
$this->log('ERROR', "No Gitea release found with tag: {$tag}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$giteaId = $giteaRelease['id'];
|
||||
$releaseName = $giteaRelease['name'] ?? "{$version}";
|
||||
$releaseBody = $giteaRelease['body'] ?? '';
|
||||
$assets = $giteaRelease['assets'] ?? [];
|
||||
|
||||
echo " Name: {$releaseName}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Step 2: Check / create GitHub release ────────────────────────────────────
|
||||
|
||||
$ghApiBase = "https://api.github.com/repos/{$ghRepo}";
|
||||
$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}";
|
||||
|
||||
echo "Checking GitHub release: {$tag}\n";
|
||||
$ghRelease = $this->githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken);
|
||||
|
||||
if ($ghRelease && !empty($ghRelease['id'])) {
|
||||
// Update existing release title
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " GitHub release exists (id: {$ghReleaseId}), updating title\n";
|
||||
$patchPayload = json_encode([
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
]);
|
||||
$this->githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload);
|
||||
} else {
|
||||
// Create new release
|
||||
echo " Creating GitHub release\n";
|
||||
$createPayload = json_encode([
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $releaseName,
|
||||
'body' => $releaseBody,
|
||||
'draft' => false,
|
||||
'prerelease' => ($tag !== 'stable'),
|
||||
]);
|
||||
$ghRelease = $this->githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload);
|
||||
if (!$ghRelease || empty($ghRelease['id'])) {
|
||||
$this->log('ERROR', 'Failed to create GitHub release');
|
||||
return 1;
|
||||
}
|
||||
$ghReleaseId = $ghRelease['id'];
|
||||
echo " Created GitHub release (id: {$ghReleaseId})\n";
|
||||
}
|
||||
|
||||
// ── Step 3: Download assets from Gitea ───────────────────────────────────────
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets";
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'] ?? '';
|
||||
$downloadUrl = $asset['browser_download_url'] ?? '';
|
||||
if ($name === '' || $downloadUrl === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localPath = "{$tmpDir}/{$name}";
|
||||
echo " Downloading: {$name}\n";
|
||||
|
||||
if (!$this->giteaDownload($downloadUrl, $token, $localPath)) {
|
||||
$this->log('ERROR', " Failed to download: {$name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
|
||||
echo " Uploading: {$name}\n";
|
||||
$code = $this->githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " {$status}\n";
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
|
||||
echo " Version: {$version}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Step 4: Upload asset to GitHub ───────────────────────────────────────
|
||||
echo " Uploading: {$name}\n";
|
||||
$code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name);
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " {$status}\n";
|
||||
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
private function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
|
||||
private function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
private function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int
|
||||
{
|
||||
$url = $uploadUrl . '?name=' . urlencode($name);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/vnd.github+json',
|
||||
'User-Agent: moko-platform',
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($filePath),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return $httpCode;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n";
|
||||
echo " Version: {$version}\n";
|
||||
echo " Assets: " . count($assets) . " file(s)\n";
|
||||
exit(0);
|
||||
$app = new ReleaseMirrorCli();
|
||||
exit($app->execute());
|
||||
|
||||
+62
-45
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -14,53 +15,69 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
}
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
if ($version === null) {
|
||||
// Read from README.md
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$version = $m[1];
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseNotesCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Extract release notes from CHANGELOG.md for a given version');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Version to extract notes for', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version') ?: null;
|
||||
|
||||
if ($version === null || $version === '') {
|
||||
// Read from README.md
|
||||
$readme = realpath($path) . '/README.md';
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$version = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null || $version === '') {
|
||||
$this->log('ERROR', 'Usage: release_notes.php --path . --version XX.YY.ZZ');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
echo "Release {$version}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lines = file($changelog, FILE_IGNORE_NEW_LINES);
|
||||
$notes = [];
|
||||
$capturing = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
|
||||
$capturing = true;
|
||||
continue;
|
||||
}
|
||||
if ($capturing && preg_match('/^## /', $line)) {
|
||||
break;
|
||||
}
|
||||
if ($capturing) {
|
||||
$notes[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$result = trim(implode("\n", $notes));
|
||||
echo $result ?: "Release {$version}";
|
||||
echo "\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$changelog = realpath($path) . '/CHANGELOG.md';
|
||||
if (!file_exists($changelog)) {
|
||||
echo "Release {$version}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$lines = file($changelog, FILE_IGNORE_NEW_LINES);
|
||||
$notes = [];
|
||||
$capturing = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) {
|
||||
$capturing = true;
|
||||
continue;
|
||||
}
|
||||
if ($capturing && preg_match('/^## /', $line)) {
|
||||
break; // Next version heading — stop
|
||||
}
|
||||
if ($capturing) {
|
||||
$notes[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
$result = trim(implode("\n", $notes));
|
||||
echo $result ?: "Release {$version}";
|
||||
echo "\n";
|
||||
exit(0);
|
||||
$app = new ReleaseNotesCli();
|
||||
exit($app->execute());
|
||||
|
||||
+493
-532
File diff suppressed because it is too large
Load Diff
+277
-283
@@ -11,306 +11,300 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_promote.php
|
||||
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
|
||||
*
|
||||
* Usage:
|
||||
* php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL
|
||||
* php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path .
|
||||
*
|
||||
* When promoting to stable, --path detects extension type prefix for asset renaming.
|
||||
* When --from is "auto", checks beta > alpha > development and uses the first found.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$from = null;
|
||||
$to = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$path = '.';
|
||||
$branch = 'main';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--from' && isset($argv[$i + 1])) {
|
||||
$from = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--to' && isset($argv[$i + 1])) {
|
||||
$to = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
|
||||
if ($to === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]\n");
|
||||
fwrite(STDERR, " --from auto: checks beta > alpha > development\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Suffix maps ──────────────────────────────────────────────────────────────
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
|
||||
// ── Channel hierarchy (highest first) ────────────────────────────────────────
|
||||
$channelOrder = ['beta', 'alpha', 'development'];
|
||||
|
||||
// ── Helper: Gitea API request ────────────────────────────────────────────────
|
||||
/** @return array<string, mixed>|null */
|
||||
function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
class ReleasePromoteCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Promote a Gitea release from one channel to another');
|
||||
$this->addArgument('--from', 'Source channel (or "auto")', '');
|
||||
$this->addArgument('--to', 'Target channel (required)', '');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
|
||||
$this->addArgument('--path', 'Repository root for type prefix detection', '.');
|
||||
$this->addArgument('--branch', 'Target branch', 'main');
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$from = $this->getArgument('--from') ?: null;
|
||||
$to = $this->getArgument('--to') ?: null;
|
||||
$token = $this->getArgument('--token') ?: null;
|
||||
$apiBase = $this->getArgument('--api-base') ?: null;
|
||||
$path = $this->getArgument('--path');
|
||||
$branch = $this->getArgument('--branch');
|
||||
|
||||
function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
|
||||
// ── Resolve --from auto ──────────────────────────────────────────────────────
|
||||
if ($from === 'auto') {
|
||||
foreach ($channelOrder as $candidate) {
|
||||
$data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
|
||||
if ($data && !empty($data['id'])) {
|
||||
$from = $candidate;
|
||||
echo "Auto-detected source channel: {$from}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($from === 'auto') {
|
||||
echo "No pre-release found to promote\n";
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Find source release ──────────────────────────────────────────────────────
|
||||
$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token);
|
||||
if (!$sourceRelease || empty($sourceRelease['id'])) {
|
||||
fwrite(STDERR, "No release found with tag: {$from}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$sourceId = $sourceRelease['id'];
|
||||
$sourceName = $sourceRelease['name'] ?? '';
|
||||
$sourceBody = $sourceRelease['body'] ?? '';
|
||||
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
|
||||
|
||||
// ── Get source assets ────────────────────────────────────────────────────────
|
||||
$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
|
||||
echo "Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Download assets to temp ──────────────────────────────────────────────────
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'];
|
||||
$downloadUrl = $asset['browser_download_url'];
|
||||
echo " Downloading: {$name}\n";
|
||||
giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
|
||||
}
|
||||
|
||||
// ── Detect type prefix for stable promotion ──────────────────────────────────
|
||||
$typePrefix = '';
|
||||
if ($to === 'stable') {
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false) {
|
||||
continue;
|
||||
if ($to === null || $token === null || $apiBase === null) {
|
||||
$this->log('ERROR', "Usage: release_promote.php --from <channel|auto> --to <channel> --token TOKEN --api-base URL [--path .]");
|
||||
$this->log('ERROR', " --from auto: checks beta > alpha > development");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
// ── Suffix maps ──────────────────────────────────────────────────────────────
|
||||
$suffixMap = [
|
||||
'development' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'release-candidate' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
|
||||
// ── Channel hierarchy (highest first) ────────────────────────────────────────
|
||||
$channelOrder = ['beta', 'alpha', 'development'];
|
||||
|
||||
// ── Resolve --from auto ──────────────────────────────────────────────────────
|
||||
if ($from === 'auto') {
|
||||
foreach ($channelOrder as $candidate) {
|
||||
$data = $this->giteaApi("{$apiBase}/releases/tags/{$candidate}", $token);
|
||||
if ($data && !empty($data['id'])) {
|
||||
$from = $candidate;
|
||||
echo "Auto-detected source channel: {$from}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($from === 'auto') {
|
||||
echo "No pre-release found to promote\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
// ── Find source release ──────────────────────────────────────────────────────
|
||||
$sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token);
|
||||
if (!$sourceRelease || empty($sourceRelease['id'])) {
|
||||
$this->log('ERROR', "No release found with tag: {$from}");
|
||||
return 1;
|
||||
}
|
||||
if ($typePrefix !== '') {
|
||||
break;
|
||||
|
||||
$sourceId = $sourceRelease['id'];
|
||||
$sourceName = $sourceRelease['name'] ?? '';
|
||||
$sourceBody = $sourceRelease['body'] ?? '';
|
||||
echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n";
|
||||
|
||||
// ── Get source assets ────────────────────────────────────────────────────────
|
||||
$assets = $this->giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: [];
|
||||
echo "Assets: " . count($assets) . " file(s)\n";
|
||||
|
||||
// ── Download assets to temp ──────────────────────────────────────────────────
|
||||
$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid();
|
||||
@mkdir($tmpDir, 0755, true);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$name = $asset['name'];
|
||||
$downloadUrl = $asset['browser_download_url'];
|
||||
echo " Downloading: {$name}\n";
|
||||
$this->giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}");
|
||||
}
|
||||
|
||||
// ── Detect type prefix for stable promotion ──────────────────────────────────
|
||||
$typePrefix = '';
|
||||
if ($to === 'stable') {
|
||||
$root = realpath($path) ?: $path;
|
||||
$manifestFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
if ($typePrefix !== '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename assets ────────────────────────────────────────────────────────────
|
||||
$oldSuffix = $suffixMap[$from] ?? '';
|
||||
$newSuffix = $suffixMap[$to] ?? '';
|
||||
|
||||
$renamedAssets = [];
|
||||
foreach ($assets as $asset) {
|
||||
$oldName = $asset['name'];
|
||||
$newName = $oldName;
|
||||
|
||||
// Strip old suffix
|
||||
if ($oldSuffix !== '') {
|
||||
$newName = str_replace($oldSuffix, '', $newName);
|
||||
}
|
||||
|
||||
// Add type prefix for stable (if not already prefixed)
|
||||
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
|
||||
// Strip any existing type prefix to prevent duplication
|
||||
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
|
||||
$newName = $typePrefix . $newName;
|
||||
}
|
||||
|
||||
// Add new suffix (for non-stable targets)
|
||||
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
|
||||
// Insert before extension
|
||||
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
|
||||
}
|
||||
|
||||
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
|
||||
if ($oldName !== $newName) {
|
||||
echo " Rename: {$oldName} → {$newName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete source release + tag ──────────────────────────────────────────────
|
||||
$this->giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
|
||||
$this->giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
|
||||
echo "Deleted source: {$from} release + tag\n";
|
||||
|
||||
// ── Delete existing target release + tag (if any) ────────────────────────────
|
||||
$existingTarget = $this->giteaApi("{$apiBase}/releases/tags/{$to}", $token);
|
||||
if ($existingTarget && !empty($existingTarget['id'])) {
|
||||
$this->giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
|
||||
$this->giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
|
||||
echo "Deleted existing target: {$to} release + tag\n";
|
||||
}
|
||||
|
||||
// ── Create target release ────────────────────────────────────────────────────
|
||||
$isPrerelease = ($to !== 'stable');
|
||||
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
|
||||
if ($newName === $sourceName) {
|
||||
$newName = str_ireplace($from, $to, $sourceName);
|
||||
}
|
||||
|
||||
$newBody = str_ireplace($from, $to, $sourceBody);
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $to,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $newName,
|
||||
'body' => $newBody,
|
||||
'prerelease' => $isPrerelease,
|
||||
]);
|
||||
|
||||
$newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
|
||||
if (!$newRelease || empty($newRelease['id'])) {
|
||||
$this->log('ERROR', "Failed to create {$to} release");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$newId = $newRelease['id'];
|
||||
echo "Created: {$to} release (id: {$newId})\n";
|
||||
|
||||
// ── Upload renamed assets ────────────────────────────────────────────────────
|
||||
foreach ($renamedAssets as $entry) {
|
||||
$localFile = "{$tmpDir}/{$entry['old']}";
|
||||
if (!file_exists($localFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadName = urlencode($entry['new']);
|
||||
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($localFile),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " Upload: {$entry['new']} — {$status}\n";
|
||||
}
|
||||
|
||||
// ── Cleanup temp ─────────────────────────────────────────────────────────────
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
echo "Promoted: {$from} → {$to}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || empty($response)) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
private function giteaDownload(string $url, string $token, string $dest): bool
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($dest, 'wb');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_FILE => $fp,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
return $httpCode >= 200 && $httpCode < 300;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename assets ────────────────────────────────────────────────────────────
|
||||
$oldSuffix = $suffixMap[$from] ?? '';
|
||||
$newSuffix = $suffixMap[$to] ?? '';
|
||||
|
||||
$renamedAssets = [];
|
||||
foreach ($assets as $asset) {
|
||||
$oldName = $asset['name'];
|
||||
$newName = $oldName;
|
||||
|
||||
// Strip old suffix
|
||||
if ($oldSuffix !== '') {
|
||||
$newName = str_replace($oldSuffix, '', $newName);
|
||||
}
|
||||
|
||||
// Add type prefix for stable (if not already prefixed)
|
||||
if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) {
|
||||
// Strip any existing type prefix to prevent duplication
|
||||
$newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName);
|
||||
$newName = $typePrefix . $newName;
|
||||
}
|
||||
|
||||
// Add new suffix (for non-stable targets)
|
||||
if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) {
|
||||
// Insert before extension
|
||||
$newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName);
|
||||
}
|
||||
|
||||
$renamedAssets[] = ['old' => $oldName, 'new' => $newName];
|
||||
if ($oldName !== $newName) {
|
||||
echo " Rename: {$oldName} → {$newName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete source release + tag ──────────────────────────────────────────────
|
||||
giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE');
|
||||
giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE');
|
||||
echo "Deleted source: {$from} release + tag\n";
|
||||
|
||||
// ── Delete existing target release + tag (if any) ────────────────────────────
|
||||
$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token);
|
||||
if ($existingTarget && !empty($existingTarget['id'])) {
|
||||
giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE');
|
||||
giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE');
|
||||
echo "Deleted existing target: {$to} release + tag\n";
|
||||
}
|
||||
|
||||
// ── Create target release ────────────────────────────────────────────────────
|
||||
$isPrerelease = ($to !== 'stable');
|
||||
$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName);
|
||||
if ($newName === $sourceName) {
|
||||
$newName = str_ireplace($from, $to, $sourceName);
|
||||
}
|
||||
|
||||
$newBody = str_ireplace($from, $to, $sourceBody);
|
||||
|
||||
$payload = json_encode([
|
||||
'tag_name' => $to,
|
||||
'target_commitish' => $branch,
|
||||
'name' => $newName,
|
||||
'body' => $newBody,
|
||||
'prerelease' => $isPrerelease,
|
||||
]);
|
||||
|
||||
$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload);
|
||||
if (!$newRelease || empty($newRelease['id'])) {
|
||||
fwrite(STDERR, "Failed to create {$to} release\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$newId = $newRelease['id'];
|
||||
echo "Created: {$to} release (id: {$newId})\n";
|
||||
|
||||
// ── Upload renamed assets ────────────────────────────────────────────────────
|
||||
foreach ($renamedAssets as $entry) {
|
||||
$localFile = "{$tmpDir}/{$entry['old']}";
|
||||
if (!file_exists($localFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadName = urlencode($entry['new']);
|
||||
$url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => file_get_contents($localFile),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})";
|
||||
echo " Upload: {$entry['new']} — {$status}\n";
|
||||
}
|
||||
|
||||
// ── Cleanup temp ─────────────────────────────────────────────────────────────
|
||||
array_map('unlink', glob("{$tmpDir}/*") ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
echo "Promoted: {$from} → {$to}\n";
|
||||
exit(0);
|
||||
$app = new ReleasePromoteCli();
|
||||
exit($app->execute());
|
||||
|
||||
+347
-318
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,341 +10,369 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_publish.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
||||
*
|
||||
* When a release is published at a given stability, copies are created for all
|
||||
* lower stability streams with the same base version and their respective suffix.
|
||||
* updates.xml is updated for ALL streams and synced to ALL branches.
|
||||
*
|
||||
* Usage:
|
||||
* php release_publish.php --path . --stability stable --token TOKEN
|
||||
* php release_publish.php --path . --stability rc --token TOKEN --bump minor
|
||||
* php release_publish.php --path . --stability dev --token TOKEN --bump patch
|
||||
* php release_publish.php --path . --stability stable --token TOKEN --dry-run
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --stability Target stability: dev|alpha|beta|rc|stable (required)
|
||||
* --token Gitea API token (required)
|
||||
* --bump Version bump type before release: patch|minor|none (default: none)
|
||||
* --branch Current branch (default: auto-detect)
|
||||
* --gitea-url Gitea URL (default: env GITEA_URL)
|
||||
* --org Organization (default: env GITEA_ORG)
|
||||
* --repo Repository name (default: env GITEA_REPO)
|
||||
* --dry-run Preview without making changes
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$stability = '';
|
||||
$token = '';
|
||||
$bumpType = 'none';
|
||||
$branch = '';
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
$dryRun = false;
|
||||
$repoUrl = '';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1];
|
||||
if ($arg === '--dry-run') $dryRun = true;
|
||||
}
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
if (empty($stability) || empty($token)) {
|
||||
fwrite(STDERR, "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$cli = __DIR__;
|
||||
$php = '"' . PHP_BINARY . '"';
|
||||
$giteaUrl = rtrim($giteaUrl, '/');
|
||||
|
||||
// Resolve path early for shell commands (Windows needs native paths)
|
||||
$resolvedPath = realpath($path) ?: $path;
|
||||
|
||||
// Auto-detect org/repo from git remote if not set
|
||||
if (empty($org) || empty($repo)) {
|
||||
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null"));
|
||||
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
||||
if (empty($org)) $org = $m[1];
|
||||
if (empty($repo)) $repo = $m[2];
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-construct repo URL for git auth if not provided
|
||||
if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) {
|
||||
$host = preg_replace('#^https?://#', '', $giteaUrl);
|
||||
$repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git";
|
||||
}
|
||||
|
||||
// Auto-detect branch
|
||||
if (empty($branch)) {
|
||||
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null"));
|
||||
}
|
||||
|
||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||
|
||||
// Stability ordering and suffix mapping
|
||||
$allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable'];
|
||||
$suffixMap = [
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
$releaseTagMap = [
|
||||
'dev' => 'development',
|
||||
'alpha' => 'alpha',
|
||||
'beta' => 'beta',
|
||||
'rc' => 'release-candidate',
|
||||
'stable' => 'stable',
|
||||
];
|
||||
|
||||
$stabilityIndex = array_search($stability, $allStabilities);
|
||||
if ($stabilityIndex === false) {
|
||||
fwrite(STDERR, "Invalid stability: {$stability}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "=== Release Publish ===\n";
|
||||
echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n";
|
||||
echo "Repo: {$org}/{$repo}\n";
|
||||
|
||||
// -- Step 1: Version bump (if requested) --
|
||||
if ($bumpType !== 'none') {
|
||||
$bumpFlag = $bumpType === 'minor' ? '--minor' : '';
|
||||
echo "\n--- Step 1: Version bump ({$bumpType}) ---\n";
|
||||
if (!$dryRun) {
|
||||
passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 2: Read version and set stability suffix --
|
||||
echo "\n--- Step 2: Set version suffix ---\n";
|
||||
$versionOutput = [];
|
||||
$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput);
|
||||
$version = trim($versionOutput[0] ?? '');
|
||||
if (empty($version)) {
|
||||
fwrite(STDERR, "No version found\n");
|
||||
exit(1);
|
||||
}
|
||||
// Strip existing suffix to get base version
|
||||
$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||
|
||||
if (!$dryRun) {
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>&1");
|
||||
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
|
||||
}
|
||||
|
||||
$releaseVersion = $baseVersion . $suffixMap[$stability];
|
||||
echo "Release version: {$releaseVersion}\n";
|
||||
|
||||
// -- Step 2b: Update badges and changelog --
|
||||
if (!$dryRun) {
|
||||
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
||||
|
||||
$changelogFile = realpath($path) . '/CHANGELOG.md';
|
||||
if (file_exists($changelogFile)) {
|
||||
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
||||
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 2c: Commit version changes before building --
|
||||
$root = realpath($path) ?: $path;
|
||||
if (!$dryRun) {
|
||||
// Configure git
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
|
||||
if (!empty($repoUrl)) {
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
|
||||
class ReleasePublishCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Publish a release and update stability streams');
|
||||
$this->addArgument('--path', 'Repository root (default: .)', '.');
|
||||
$this->addArgument('--stability', 'Target stability: dev|alpha|beta|rc|stable (required)', '');
|
||||
$this->addArgument('--token', 'Gitea API token (required)', '');
|
||||
$this->addArgument('--bump', 'Version bump type: patch|minor|none (default: none)', 'none');
|
||||
$this->addArgument('--branch', 'Current branch (default: auto-detect)', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL', '');
|
||||
$this->addArgument('--org', 'Organization', '');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
$this->addArgument('--repo-url', 'Repository URL for git auth', '');
|
||||
$this->addArgument('--skip-update-stream', 'Skip updates.xml generation and sync (managed externally)', false);
|
||||
}
|
||||
|
||||
// Ensure we're on the actual branch (not detached HEAD from PR merge)
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null");
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$stability = $this->getArgument('--stability');
|
||||
$token = $this->getArgument('--token');
|
||||
$bumpType = $this->getArgument('--bump');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
|
||||
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
|
||||
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
|
||||
$repoUrl = $this->getArgument('--repo-url');
|
||||
$skipUpdateStream = $this->getArgument('--skip-update-stream');
|
||||
|
||||
// Re-apply version changes on the checked-out branch
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
|
||||
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
|
||||
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
|
||||
|
||||
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]")
|
||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
||||
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
||||
echo " Committed release changes\n";
|
||||
echo " Push: " . trim($pushResult ?? '') . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 3: Build release package --
|
||||
echo "\n--- Step 3: Build and upload release ---\n";
|
||||
$releaseTag = $releaseTagMap[$stability];
|
||||
$sha256 = '';
|
||||
|
||||
if (!$dryRun) {
|
||||
// Create release
|
||||
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --tag " . escapeshellarg($releaseTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --branch " . escapeshellarg($branch) . " 2>&1");
|
||||
|
||||
// Build and upload package
|
||||
$packageOutput = [];
|
||||
exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --tag " . escapeshellarg($releaseTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --output /tmp 2>&1", $packageOutput);
|
||||
foreach ($packageOutput as $line) {
|
||||
echo $line . "\n";
|
||||
// Extract SHA from output
|
||||
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) {
|
||||
$sha256 = $m[1];
|
||||
if (empty($stability) || empty($token)) {
|
||||
$this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
// Also check GITHUB_OUTPUT
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput && file_exists($ghOutput)) {
|
||||
$ghContent = file_get_contents($ghOutput);
|
||||
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) {
|
||||
$sha256 = $m[1];
|
||||
|
||||
$cli = __DIR__;
|
||||
$php = '"' . PHP_BINARY . '"';
|
||||
$giteaUrl = rtrim($giteaUrl, '/');
|
||||
|
||||
// Resolve path early for shell commands (Windows needs native paths)
|
||||
$resolvedPath = realpath($path) ?: $path;
|
||||
|
||||
// Auto-detect org/repo from git remote if not set
|
||||
if (empty($org) || empty($repo)) {
|
||||
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$remote = trim((string) @shell_exec(
|
||||
$cd . escapeshellarg($resolvedPath)
|
||||
. " && git remote get-url origin 2>/dev/null"
|
||||
));
|
||||
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
|
||||
if (empty($org)) {
|
||||
$org = $m[1];
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$repo = $m[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
|
||||
}
|
||||
|
||||
// -- Step 4: Build separate packages for all lesser stability streams --
|
||||
// Each stream gets its own ZIP with the correct version INSIDE templateDetails.xml.
|
||||
// Joomla reads version from the ZIP after install, so it must match.
|
||||
echo "\n--- Step 4: Build packages for lesser streams ---\n";
|
||||
for ($i = 0; $i < $stabilityIndex; $i++) {
|
||||
$lesserStability = $allStabilities[$i];
|
||||
$lesserTag = $releaseTagMap[$lesserStability];
|
||||
$lesserVersion = $baseVersion . $suffixMap[$lesserStability];
|
||||
// Auto-construct repo URL for git auth if not provided
|
||||
if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) {
|
||||
$host = preg_replace('#^https?://#', '', $giteaUrl);
|
||||
$repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git";
|
||||
}
|
||||
|
||||
echo " Building {$lesserStability} release: {$lesserVersion}\n";
|
||||
// Auto-detect branch
|
||||
if (empty($branch)) {
|
||||
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$branch = getenv('GITHUB_REF_NAME')
|
||||
?: trim((string) @shell_exec(
|
||||
$cdCmd . escapeshellarg($resolvedPath)
|
||||
. " && git rev-parse --abbrev-ref HEAD 2>/dev/null"
|
||||
));
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
// Set version to lesser stream's suffixed version in source files
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($lesserStability)
|
||||
. " --stability " . escapeshellarg($lesserStability) . " 2>/dev/null");
|
||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||
|
||||
// Create release tag
|
||||
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($lesserVersion)
|
||||
. " --tag " . escapeshellarg($lesserTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --branch " . escapeshellarg($branch) . " 2>&1");
|
||||
// Stability ordering and suffix mapping
|
||||
$allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable'];
|
||||
$suffixMap = [
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'stable' => '',
|
||||
];
|
||||
$releaseTagMap = [
|
||||
'dev' => 'development',
|
||||
'alpha' => 'alpha',
|
||||
'beta' => 'beta',
|
||||
'rc' => 'release-candidate',
|
||||
'stable' => 'stable',
|
||||
];
|
||||
|
||||
// Build and upload package (ZIP will contain the lesser version)
|
||||
passthru("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($lesserVersion)
|
||||
. " --tag " . escapeshellarg($lesserTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --output /tmp 2>&1");
|
||||
} else {
|
||||
echo " [DRY-RUN] Would build {$lesserVersion} ZIP and upload to {$lesserTag}\n";
|
||||
$stabilityIndex = array_search($stability, $allStabilities);
|
||||
if ($stabilityIndex === false) {
|
||||
$this->log('ERROR', "Invalid stability: {$stability}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
echo "=== Release Publish ===\n";
|
||||
echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n";
|
||||
echo "Repo: {$org}/{$repo}\n";
|
||||
|
||||
// -- Step 1: Version bump (if requested) --
|
||||
if ($bumpType !== 'none') {
|
||||
$bumpFlag = $bumpType === 'minor' ? '--minor' : '';
|
||||
echo "\n--- Step 1: Version bump ({$bumpType}) ---\n";
|
||||
if (!$this->dryRun) {
|
||||
passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 2: Read version and set stability suffix --
|
||||
echo "\n--- Step 2: Set version suffix ---\n";
|
||||
$versionOutput = [];
|
||||
$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput);
|
||||
$version = trim($versionOutput[0] ?? '');
|
||||
if (empty($version)) {
|
||||
$this->log('ERROR', 'No version found');
|
||||
return 1;
|
||||
}
|
||||
// Strip existing suffix to get base version
|
||||
$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||
|
||||
if (!$this->dryRun) {
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>&1");
|
||||
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
|
||||
}
|
||||
|
||||
$releaseVersion = $baseVersion . $suffixMap[$stability];
|
||||
echo "Release version: {$releaseVersion}\n";
|
||||
|
||||
// -- Step 2b: Update badges and changelog --
|
||||
if (!$this->dryRun) {
|
||||
passthru(
|
||||
"{$php} {$cli}/badge_update.php --path "
|
||||
. escapeshellarg($path) . " --version "
|
||||
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||
);
|
||||
|
||||
$changelogFile = realpath($path) . '/CHANGELOG.md';
|
||||
if (file_exists($changelogFile)) {
|
||||
passthru(
|
||||
"{$php} {$cli}/changelog_promote.php --path "
|
||||
. escapeshellarg($path) . " --version "
|
||||
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||
);
|
||||
passthru(
|
||||
"{$php} {$cli}/changelog_prune.php --path "
|
||||
. escapeshellarg($path) . " --keep 5 2>/dev/null"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 2c: Commit version changes before building --
|
||||
$root = realpath($path) ?: $path;
|
||||
if (!$this->dryRun) {
|
||||
// Configure git
|
||||
$cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$cdR = $cdPfx . escapeshellarg($root);
|
||||
@shell_exec(
|
||||
$cdR . " && git config --local user.email"
|
||||
. " \"gitea-actions[bot]@mokoconsulting.tech\""
|
||||
);
|
||||
@shell_exec(
|
||||
$cdR . " && git config --local user.name"
|
||||
. " \"gitea-actions[bot]\""
|
||||
);
|
||||
if (!empty($repoUrl)) {
|
||||
@shell_exec(
|
||||
$cdR . " && git remote set-url origin "
|
||||
. escapeshellarg($repoUrl)
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we're on the actual branch (not detached HEAD from PR merge)
|
||||
@shell_exec(
|
||||
$cdR . " && git fetch origin "
|
||||
. escapeshellarg($branch) . " 2>/dev/null"
|
||||
);
|
||||
@shell_exec(
|
||||
$cdR . " && git checkout -B "
|
||||
. escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"
|
||||
);
|
||||
|
||||
// Re-apply version changes on the checked-out branch
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
|
||||
passthru(
|
||||
"{$php} {$cli}/version_check.php --path "
|
||||
. escapeshellarg($path) . " --fix 2>/dev/null"
|
||||
);
|
||||
passthru(
|
||||
"{$php} {$cli}/badge_update.php --path "
|
||||
. escapeshellarg($path) . " --version "
|
||||
. escapeshellarg($baseVersion) . " 2>/dev/null"
|
||||
);
|
||||
|
||||
$diffCheck = trim((string) @shell_exec(
|
||||
$cdR . " && git diff --quiet"
|
||||
. " && git diff --cached --quiet"
|
||||
. " 2>&1 && echo clean || echo dirty"
|
||||
));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec($cdR . " && git add -A");
|
||||
$commitMsg = "chore(release): build"
|
||||
. " {$releaseVersion} [skip ci]";
|
||||
@shell_exec(
|
||||
$cdR . " && git commit -m "
|
||||
. escapeshellarg($commitMsg)
|
||||
. " --author=\"gitea-actions[bot]"
|
||||
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||
);
|
||||
$pushResult = @shell_exec(
|
||||
$cdR . " && git push origin "
|
||||
. escapeshellarg($branch) . " 2>&1"
|
||||
);
|
||||
echo " Committed release changes\n";
|
||||
echo " Push: " . trim($pushResult ?? '') . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 3: Build release package --
|
||||
echo "\n--- Step 3: Build and upload release ---\n";
|
||||
$releaseTag = $releaseTagMap[$stability];
|
||||
$sha256 = '';
|
||||
|
||||
if (!$this->dryRun) {
|
||||
// Create release
|
||||
passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --tag " . escapeshellarg($releaseTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --branch " . escapeshellarg($branch) . " 2>&1");
|
||||
|
||||
// Build and upload package
|
||||
$packageOutput = [];
|
||||
exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --tag " . escapeshellarg($releaseTag)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --api-base " . escapeshellarg($apiBase)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " --output /tmp 2>&1", $packageOutput);
|
||||
foreach ($packageOutput as $line) {
|
||||
echo $line . "\n";
|
||||
// Extract SHA from output
|
||||
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) {
|
||||
$sha256 = $m[1];
|
||||
}
|
||||
}
|
||||
// Also check GITHUB_OUTPUT
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput && file_exists($ghOutput)) {
|
||||
$ghContent = file_get_contents($ghOutput);
|
||||
if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) {
|
||||
$sha256 = $m[1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n";
|
||||
}
|
||||
|
||||
// -- Step 4: No lesser stream copies --
|
||||
echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n";
|
||||
|
||||
if ($skipUpdateStream) {
|
||||
echo "\n--- Step 5: Skipped (--skip-update-stream) ---\n";
|
||||
echo "\n--- Step 6: Skipped (--skip-update-stream) ---\n";
|
||||
} else {
|
||||
// -- Step 5: Update ONLY this stream in updates.xml --
|
||||
echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n";
|
||||
$streamsToWrite = [$stability];
|
||||
|
||||
foreach ($streamsToWrite as $stream) {
|
||||
$streamVersion = $releaseVersion;
|
||||
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
|
||||
|
||||
echo " Writing {$stream} stream: {$streamVersion}\n";
|
||||
if (!$this->dryRun) {
|
||||
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($streamVersion)
|
||||
. " --stability " . escapeshellarg($stream)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " {$shaFlag} 2>&1");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 6: Commit updates.xml and sync to all branches --
|
||||
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
if (!$this->dryRun) {
|
||||
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$cdRt = $cdX . escapeshellarg($root);
|
||||
$diffCheck = trim((string) @shell_exec(
|
||||
$cdRt . " && git diff --quiet updates.xml"
|
||||
. " 2>&1 && echo clean || echo dirty"
|
||||
));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec($cdRt . " && git add updates.xml");
|
||||
$chMsg = "chore: update channels for"
|
||||
. " {$releaseVersion} [skip ci]";
|
||||
@shell_exec(
|
||||
$cdRt . " && git commit -m "
|
||||
. escapeshellarg($chMsg)
|
||||
. " --author=\"gitea-actions[bot]"
|
||||
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||
);
|
||||
@shell_exec(
|
||||
$cdRt . " && git push origin "
|
||||
. escapeshellarg($branch) . " 2>&1"
|
||||
);
|
||||
echo " Committed updates.xml\n";
|
||||
}
|
||||
|
||||
// Sync to all branches
|
||||
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
|
||||
. " --current " . escapeshellarg($branch) . " --all"
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo) . " 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Release published: {$releaseVersion} ===\n";
|
||||
|
||||
// Output for CI
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore primary release version in source files
|
||||
if (!$dryRun && $stabilityIndex > 0) {
|
||||
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($baseVersion)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
|
||||
}
|
||||
|
||||
// -- Step 5: Update ALL streams in updates.xml --
|
||||
echo "\n--- Step 5: Update updates.xml for ALL streams ---\n";
|
||||
// Write entry for the primary stream and all lesser streams
|
||||
$streamsToWrite = array_slice($allStabilities, 0, $stabilityIndex + 1);
|
||||
|
||||
foreach ($streamsToWrite as $stream) {
|
||||
$streamVersion = $baseVersion . $suffixMap[$stream];
|
||||
$shaFlag = !empty($sha256) ? "--sha {$sha256}" : '';
|
||||
|
||||
echo " Writing {$stream} stream: {$streamVersion}\n";
|
||||
if (!$dryRun) {
|
||||
passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($streamVersion)
|
||||
. " --stability " . escapeshellarg($stream)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo)
|
||||
. " {$shaFlag} 2>&1");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Step 6: Commit updates.xml and sync to all branches --
|
||||
echo "\n--- Step 6: Commit and sync updates.xml ---\n";
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
if (!$dryRun) {
|
||||
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty"));
|
||||
if ($diffCheck === 'dirty') {
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]")
|
||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
||||
echo " Committed updates.xml\n";
|
||||
}
|
||||
|
||||
// Sync to all branches
|
||||
passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path)
|
||||
. " --current " . escapeshellarg($branch) . " --all"
|
||||
. " --version " . escapeshellarg($releaseVersion)
|
||||
. " --token " . escapeshellarg($token)
|
||||
. " --gitea-url " . escapeshellarg($giteaUrl)
|
||||
. " --org " . escapeshellarg($org)
|
||||
. " --repo " . escapeshellarg($repo) . " 2>&1");
|
||||
} else {
|
||||
echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n";
|
||||
}
|
||||
|
||||
echo "\n=== Release published: {$releaseVersion} ===\n";
|
||||
|
||||
// Output for CI
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new ReleasePublishCli();
|
||||
exit($app->execute());
|
||||
|
||||
+207
-224
@@ -10,248 +10,231 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_validate.php
|
||||
* BRIEF: Pre-release validation — version consistency, required files, manifest checks
|
||||
*
|
||||
* Usage:
|
||||
* php release_validate.php --path /repo --version 04.01.00
|
||||
* php release_validate.php --path /repo --version 04.01.00 --platform joomla --output-summary
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Expected version string (required)
|
||||
* --platform joomla|dolibarr|generic (default: joomla)
|
||||
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
|
||||
* BRIEF: Pre-release validation -- version consistency, required files, manifest checks
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$platform = null;
|
||||
$outputSummary = false;
|
||||
$githubOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) {
|
||||
$platform = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output-summary') {
|
||||
$outputSummary = true;
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Auto-detect platform from manifest.xml if not specified
|
||||
if ($platform === null) {
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$mContent = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
// Normalize platform aliases
|
||||
if (in_array($platform, ['waas-component'], true)) {
|
||||
$platform = 'joomla';
|
||||
}
|
||||
if (in_array($platform, ['crm-module'], true)) {
|
||||
$platform = 'dolibarr';
|
||||
}
|
||||
if ($platform === null) {
|
||||
$platform = 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$warn = 0;
|
||||
/** @var array<int, array{check: string, status: string, details: string}> */
|
||||
$results = [];
|
||||
|
||||
/**
|
||||
* Record a validation result.
|
||||
*
|
||||
* @param string $check Check name
|
||||
* @param string $status PASS, FAIL, or WARN
|
||||
* @param string $details Human-readable details
|
||||
*/
|
||||
function addResult(string $check, string $status, string $details): void
|
||||
class ReleaseValidateCli extends CliFramework
|
||||
{
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') {
|
||||
$pass++;
|
||||
} elseif ($status === 'FAIL') {
|
||||
$fail++;
|
||||
} elseif ($status === 'WARN') {
|
||||
$warn++;
|
||||
private int $pass = 0;
|
||||
private int $fail = 0;
|
||||
private int $warn = 0;
|
||||
private array $results = [];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--version', 'Expected version string', null);
|
||||
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
|
||||
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
|
||||
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Source directory check
|
||||
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
|
||||
if ($hasSource) {
|
||||
addResult('Source directory', 'PASS', 'src/ or htdocs/ found');
|
||||
} else {
|
||||
addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory');
|
||||
}
|
||||
|
||||
// 1. README.md exists and contains VERSION
|
||||
if (!file_exists("{$root}/README.md")) {
|
||||
addResult('README.md', 'FAIL', 'Not found');
|
||||
} else {
|
||||
$readme = file_get_contents("{$root}/README.md");
|
||||
if (
|
||||
preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) ||
|
||||
strpos($readme, $version) !== false
|
||||
) {
|
||||
addResult('README.md version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('README.md version', 'FAIL', "`{$version}` not found in README.md");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CHANGELOG.md exists with matching section
|
||||
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||
addResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||
} else {
|
||||
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||
if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) {
|
||||
addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found");
|
||||
} else {
|
||||
addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. LICENSE file exists
|
||||
$licenseFound = false;
|
||||
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||
if (file_exists("{$root}/{$lf}")) {
|
||||
$licenseFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||
|
||||
// 4. Platform-specific checks
|
||||
if ($platform === 'joomla') {
|
||||
// Find XML manifest
|
||||
$manifest = null;
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$platform = $this->getArgument('--platform');
|
||||
$outputSummary = (bool) $this->getArgument('--output-summary');
|
||||
$githubOutput = (bool) $this->getArgument('--github-output');
|
||||
if ($version === null) {
|
||||
$this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
|
||||
return 1;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
$root = realpath($path) ?: $path;
|
||||
if ($platform === null) {
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$mContent = file_get_contents($manifestXml);
|
||||
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
|
||||
$platform = trim($pm[1]);
|
||||
}
|
||||
}
|
||||
if (in_array($platform, ['waas-component'], true)) {
|
||||
$platform = 'joomla';
|
||||
}
|
||||
if (in_array($platform, ['crm-module'], true)) {
|
||||
$platform = 'dolibarr';
|
||||
}
|
||||
if ($platform === null) {
|
||||
$platform = 'generic';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
addResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||
} else {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$mVer = trim($m[1]);
|
||||
if ($mVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$mVer}` matches");
|
||||
$hasSource = SourceResolver::resolveAbsolute($root) !== null;
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$srcDirName = SourceResolver::resolve($root);
|
||||
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory');
|
||||
if (!file_exists("{$root}/README.md")) {
|
||||
$this->addVResult('README.md', 'FAIL', 'Not found');
|
||||
} else {
|
||||
$readme = file_get_contents("{$root}/README.md");
|
||||
$quotedVer = preg_quote($version, '/');
|
||||
$readmeHasVer = preg_match(
|
||||
'/VERSION:\s*' . $quotedVer . '/',
|
||||
$readme
|
||||
) || strpos($readme, $version) !== false;
|
||||
$this->addVResult(
|
||||
'README.md version',
|
||||
$readmeHasVer ? 'PASS' : 'FAIL',
|
||||
$readmeHasVer
|
||||
? "`{$version}` found"
|
||||
: "`{$version}` not found"
|
||||
);
|
||||
}
|
||||
if (!file_exists("{$root}/CHANGELOG.md")) {
|
||||
$this->addVResult('CHANGELOG.md', 'WARN', 'Not found');
|
||||
} else {
|
||||
$cl = file_get_contents("{$root}/CHANGELOG.md");
|
||||
$clHasVer = preg_match(
|
||||
'/^##\s.*' . preg_quote($version, '/') . '/m',
|
||||
$cl
|
||||
);
|
||||
$this->addVResult(
|
||||
'CHANGELOG.md version',
|
||||
$clHasVer ? 'PASS' : 'WARN',
|
||||
$clHasVer ? "Section found" : "No section header"
|
||||
);
|
||||
}
|
||||
$licenseFound = false;
|
||||
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
|
||||
if (file_exists("{$root}/{$lf}")) {
|
||||
$licenseFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
|
||||
if ($platform === 'joomla') {
|
||||
$manifest = null;
|
||||
$srcAbs = SourceResolver::resolveAbsolute($root);
|
||||
foreach (array_filter([$srcAbs, $root]) as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
} foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($manifest === null) {
|
||||
$this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found');
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
|
||||
$manifestContent = file_get_contents($manifest);
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', $manifestContent, $m)) {
|
||||
$mVer = trim($m[1]);
|
||||
$this->addVResult(
|
||||
'Manifest version',
|
||||
$mVer === $version ? 'PASS' : 'FAIL',
|
||||
$mVer === $version
|
||||
? "`{$mVer}` matches"
|
||||
: "`{$mVer}` != `{$version}`"
|
||||
);
|
||||
} else {
|
||||
$this->addVResult('Manifest version', 'FAIL', 'No <version> tag');
|
||||
}
|
||||
}
|
||||
if (!file_exists("{$root}/updates.xml")) {
|
||||
$this->addVResult('updates.xml', 'WARN', 'Not found');
|
||||
} else {
|
||||
$ux = file_get_contents("{$root}/updates.xml");
|
||||
$uxHasVer = preg_match(
|
||||
'/<version>' . preg_quote($version, '/')
|
||||
. '<\/version>/',
|
||||
$ux
|
||||
);
|
||||
$this->addVResult(
|
||||
'updates.xml version',
|
||||
$uxHasVer ? 'PASS' : 'FAIL',
|
||||
$uxHasVer
|
||||
? "`{$version}` found"
|
||||
: "`{$version}` not found"
|
||||
);
|
||||
}
|
||||
} elseif ($platform === 'dolibarr') {
|
||||
$modFile = null;
|
||||
foreach (SourceResolver::getCandidates() as $sd) {
|
||||
$matches = glob("{$root}/{$sd}/mod*.class.php");
|
||||
if (!empty($matches)) {
|
||||
$modFile = $matches[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($modFile === null) {
|
||||
$this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||
} else {
|
||||
$mc = file_get_contents($modFile);
|
||||
$dolPattern = "/\\\$this->version\s*=\s*'"
|
||||
. preg_quote($version, '/') . "'/";
|
||||
$dolMatch = preg_match($dolPattern, $mc);
|
||||
$this->addVResult(
|
||||
'Dolibarr version',
|
||||
$dolMatch ? 'PASS' : 'FAIL',
|
||||
$dolMatch
|
||||
? "`{$version}` matches"
|
||||
: "`{$version}` not found"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
|
||||
}
|
||||
if (file_exists("{$root}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||
if (isset($composer['version'])) {
|
||||
$compMatch = $composer['version'] === $version;
|
||||
$this->addVResult(
|
||||
'composer.json version',
|
||||
$compMatch ? 'PASS' : 'WARN',
|
||||
$compMatch
|
||||
? "`{$version}` matches"
|
||||
: "`{$composer['version']}` != `{$version}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($this->results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
|
||||
echo $table;
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput) {
|
||||
file_put_contents(
|
||||
$ghOutput,
|
||||
"validation_pass={$this->pass}\n"
|
||||
. "validation_fail={$this->fail}\n"
|
||||
. "validation_warn={$this->warn}\n"
|
||||
. "validation_platform={$platform}\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
}
|
||||
return $this->fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// updates.xml
|
||||
if (!file_exists("{$root}/updates.xml")) {
|
||||
addResult('updates.xml', 'WARN', 'Not found');
|
||||
} else {
|
||||
$ux = file_get_contents("{$root}/updates.xml");
|
||||
if (preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux)) {
|
||||
addResult('updates.xml version', 'PASS', "`{$version}` found");
|
||||
} else {
|
||||
addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml");
|
||||
}
|
||||
}
|
||||
} elseif ($platform === 'dolibarr') {
|
||||
$modFile = null;
|
||||
foreach (['src', 'htdocs'] as $sd) {
|
||||
$pattern = "{$root}/{$sd}/mod*.class.php";
|
||||
$matches = glob($pattern);
|
||||
if (!empty($matches)) {
|
||||
$modFile = $matches[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($modFile === null) {
|
||||
addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found');
|
||||
} else {
|
||||
$mc = file_get_contents($modFile);
|
||||
if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) {
|
||||
addResult('Dolibarr version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile));
|
||||
private function addVResult(string $check, string $status, string $details): void
|
||||
{
|
||||
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') {
|
||||
$this->pass++;
|
||||
} elseif ($status === 'FAIL') {
|
||||
$this->fail++;
|
||||
} elseif ($status === 'WARN') {
|
||||
$this->warn++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. composer.json version (if present)
|
||||
if (file_exists("{$root}/composer.json")) {
|
||||
$composer = json_decode(file_get_contents("{$root}/composer.json"), true);
|
||||
if (isset($composer['version'])) {
|
||||
if ($composer['version'] === $version) {
|
||||
addResult('composer.json version', 'PASS', "`{$version}` matches");
|
||||
} else {
|
||||
addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"validation_pass={$pass}",
|
||||
"validation_fail={$fail}",
|
||||
"validation_warn={$warn}",
|
||||
"validation_platform={$platform}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
$app = new ReleaseValidateCli();
|
||||
exit($app->execute());
|
||||
|
||||
+190
-169
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,179 +11,199 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_verify.php
|
||||
* BRIEF: Verify a built release artifact — version, SHA256, disallowed files
|
||||
*
|
||||
* Usage:
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --updates-xml updates.xml
|
||||
* php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --output-summary
|
||||
*
|
||||
* Options:
|
||||
* --zip-path Path to ZIP file (required)
|
||||
* --version Expected version string (required)
|
||||
* --platform joomla|dolibarr|generic (default: joomla)
|
||||
* --updates-xml Path to updates.xml for SHA256 comparison
|
||||
* --github-output Export verify_pass, verify_fail to $GITHUB_OUTPUT
|
||||
* --output-summary Write markdown table to $GITHUB_STEP_SUMMARY
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$zipPath = null;
|
||||
$version = null;
|
||||
$platform = 'joomla';
|
||||
$updatesXml = null;
|
||||
$githubOutput = false;
|
||||
$outputSummary = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
||||
if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $githubOutput = true;
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseVerifyCli extends CliFramework
|
||||
{
|
||||
private int $pass = 0;
|
||||
private int $fail = 0;
|
||||
private int $warn = 0;
|
||||
private array $results = [];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Verify a built release artifact — version, SHA256, disallowed files');
|
||||
$this->addArgument('--zip-path', 'Path to ZIP file (required)', '');
|
||||
$this->addArgument('--version', 'Expected version string (required)', '');
|
||||
$this->addArgument('--platform', 'joomla|dolibarr|generic', 'joomla');
|
||||
$this->addArgument('--updates-xml', 'Path to updates.xml for SHA256 comparison', '');
|
||||
$this->addArgument('--github-output', 'Export verify_pass, verify_fail to $GITHUB_OUTPUT', false);
|
||||
$this->addArgument('--output-summary', 'Write markdown table to $GITHUB_STEP_SUMMARY', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$zipPath = $this->getArgument('--zip-path');
|
||||
$version = $this->getArgument('--version');
|
||||
$platform = $this->getArgument('--platform');
|
||||
$updatesXml = $this->getArgument('--updates-xml');
|
||||
$githubOutput = $this->getArgument('--github-output');
|
||||
$outputSummary = $this->getArgument('--output-summary');
|
||||
|
||||
if ($zipPath === '' || $version === '') {
|
||||
$this->log('ERROR', 'Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1. ZIP exists and is readable
|
||||
if (!file_exists($zipPath) || !is_readable($zipPath)) {
|
||||
$this->addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
|
||||
} else {
|
||||
$this->addResult('ZIP exists', 'PASS', basename($zipPath));
|
||||
|
||||
// 2. Extract ZIP
|
||||
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
|
||||
mkdir($tmpDir, 0755, true);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
$this->addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
|
||||
} else {
|
||||
$zip->extractTo($tmpDir);
|
||||
$zip->close();
|
||||
$this->addResult('ZIP extract', 'PASS', 'Extracted successfully');
|
||||
|
||||
// 3. Manifest version check (Joomla)
|
||||
if ($platform === 'joomla') {
|
||||
$manifest = null;
|
||||
foreach (glob("{$tmpDir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest !== null) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$manifestVer = trim($m[1]);
|
||||
if ($manifestVer === $version) {
|
||||
$this->addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
|
||||
} else {
|
||||
$this->addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
$this->addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
|
||||
}
|
||||
} else {
|
||||
$this->addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SHA256 vs updates.xml
|
||||
$zipSha = hash_file('sha256', $zipPath);
|
||||
if ($updatesXml !== '' && file_exists($updatesXml)) {
|
||||
$uxContent = file_get_contents($updatesXml);
|
||||
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
|
||||
$expectedSha = trim($m[1]);
|
||||
if ($zipSha === $expectedSha) {
|
||||
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
|
||||
} else {
|
||||
$this->addResult(
|
||||
'SHA256 vs updates.xml',
|
||||
'FAIL',
|
||||
"ZIP=`" . substr($zipSha, 0, 16)
|
||||
. "...` updates.xml=`"
|
||||
. substr($expectedSha, 0, 16) . "...`"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Disallowed files
|
||||
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
|
||||
$found = [];
|
||||
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$name = $file->getFilename();
|
||||
if (in_array($name, $disallowed, true)) {
|
||||
$found[] = $name;
|
||||
}
|
||||
}
|
||||
if (count($found) > 0) {
|
||||
$this->addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
|
||||
} else {
|
||||
$this->addResult('Disallowed files', 'PASS', 'None found');
|
||||
}
|
||||
|
||||
// 6. Non-vendor .min files
|
||||
$minCount = 0;
|
||||
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$rel = str_replace($tmpDir . '/', '', $file->getPathname());
|
||||
if (strpos($rel, 'vendor/') !== false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
|
||||
$minCount++;
|
||||
}
|
||||
}
|
||||
if ($minCount > 0) {
|
||||
$this->addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
|
||||
} else {
|
||||
$this->addResult('Non-vendor .min files', 'PASS', 'None shipped');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$rit = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator(
|
||||
$tmpDir,
|
||||
\RecursiveDirectoryIterator::SKIP_DOTS
|
||||
),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($rit as $file) {
|
||||
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($this->results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Verification: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($githubOutput) {
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile) {
|
||||
file_put_contents($outputFile, "verify_pass={$this->pass}\nverify_fail={$this->fail}\nverify_warn={$this->warn}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function addResult(string $check, string $status, string $details): void
|
||||
{
|
||||
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') {
|
||||
$this->pass++;
|
||||
} elseif ($status === 'FAIL') {
|
||||
$this->fail++;
|
||||
} elseif ($status === 'WARN') {
|
||||
$this->warn++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($zipPath === null || $version === null) {
|
||||
fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$warn = 0;
|
||||
$results = [];
|
||||
|
||||
function addResult(string $check, string $status, string $details): void {
|
||||
global $pass, $fail, $warn, $results;
|
||||
$results[] = ['check' => $check, 'status' => $status, 'details' => $details];
|
||||
if ($status === 'PASS') $pass++;
|
||||
elseif ($status === 'FAIL') $fail++;
|
||||
elseif ($status === 'WARN') $warn++;
|
||||
}
|
||||
|
||||
// 1. ZIP exists and is readable
|
||||
if (!file_exists($zipPath) || !is_readable($zipPath)) {
|
||||
addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}");
|
||||
} else {
|
||||
addResult('ZIP exists', 'PASS', basename($zipPath));
|
||||
|
||||
// 2. Extract ZIP
|
||||
$tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid();
|
||||
mkdir($tmpDir, 0755, true);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file');
|
||||
} else {
|
||||
$zip->extractTo($tmpDir);
|
||||
$zip->close();
|
||||
addResult('ZIP extract', 'PASS', 'Extracted successfully');
|
||||
|
||||
// 3. Manifest version check (Joomla)
|
||||
if ($platform === 'joomla') {
|
||||
$manifest = null;
|
||||
foreach (glob("{$tmpDir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($manifest !== null) {
|
||||
if (preg_match('/<version>([^<]+)<\/version>/', file_get_contents($manifest), $m)) {
|
||||
$manifestVer = trim($m[1]);
|
||||
if ($manifestVer === $version) {
|
||||
addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'WARN', 'No <version> tag in manifest');
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SHA256 vs updates.xml
|
||||
$zipSha = hash_file('sha256', $zipPath);
|
||||
if ($updatesXml !== null && file_exists($updatesXml)) {
|
||||
$uxContent = file_get_contents($updatesXml);
|
||||
if (preg_match('/<sha256>([^<]+)<\/sha256>/', $uxContent, $m)) {
|
||||
$expectedSha = trim($m[1]);
|
||||
if ($zipSha === $expectedSha) {
|
||||
addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
|
||||
} else {
|
||||
addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`");
|
||||
}
|
||||
} else {
|
||||
addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Disallowed files
|
||||
$disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env'];
|
||||
$found = [];
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$name = $file->getFilename();
|
||||
if (in_array($name, $disallowed, true)) {
|
||||
$found[] = $name;
|
||||
}
|
||||
}
|
||||
if (count($found) > 0) {
|
||||
addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found)));
|
||||
} else {
|
||||
addResult('Disallowed files', 'PASS', 'None found');
|
||||
}
|
||||
|
||||
// 6. Non-vendor .min files
|
||||
$minCount = 0;
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS));
|
||||
foreach ($rit as $file) {
|
||||
$rel = str_replace($tmpDir . '/', '', $file->getPathname());
|
||||
if (strpos($rel, 'vendor/') !== false) continue;
|
||||
if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) {
|
||||
$minCount++;
|
||||
}
|
||||
}
|
||||
if ($minCount > 0) {
|
||||
addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime");
|
||||
} else {
|
||||
addResult('Non-vendor .min files', 'PASS', 'None shipped');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($rit as $file) {
|
||||
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
|
||||
foreach ($results as $r) {
|
||||
$table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n";
|
||||
}
|
||||
$table .= "\n**Verification: {$pass} passed, {$fail} failed, {$warn} warnings**\n";
|
||||
|
||||
echo $table;
|
||||
|
||||
if ($outputSummary) {
|
||||
$summaryFile = getenv('GITHUB_STEP_SUMMARY');
|
||||
if ($summaryFile) {
|
||||
file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($githubOutput) {
|
||||
$outputFile = getenv('GITHUB_OUTPUT');
|
||||
if ($outputFile) {
|
||||
file_put_contents($outputFile, "verify_pass={$pass}\nverify_fail={$fail}\nverify_warn={$warn}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
$app = new ReleaseVerifyCli();
|
||||
exit($app->execute());
|
||||
|
||||
+111
-226
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -11,240 +12,124 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/scaffold_client.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ScaffoldClient
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ScaffoldClientCli extends CliFramework
|
||||
{
|
||||
private string $name = '';
|
||||
private string $org = '';
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private bool $dryRun = false;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
|
||||
$this->addArgument('--name', 'Client name', '');
|
||||
$this->addArgument('--org', 'Gitea organization', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
protected function run(): int
|
||||
{
|
||||
$name = $this->getArgument('--name');
|
||||
$org = $this->getArgument('--org');
|
||||
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$token = $this->getArgument('--token');
|
||||
if ($name === '' || $org === '' || $token === '') {
|
||||
$this->log('ERROR', '--name, --org, and --token are required.');
|
||||
return 1;
|
||||
}
|
||||
$repoName = 'client-waas-' . $name;
|
||||
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
|
||||
$this->log('INFO', "Gitea URL: {$giteaUrl}");
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
||||
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
|
||||
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
||||
return 0;
|
||||
}
|
||||
$this->log('INFO', 'Step 1: Creating repo from template...');
|
||||
$createPayload = json_encode([
|
||||
'owner' => $org,
|
||||
'name' => $repoName,
|
||||
'description' => "{$name} WaaS site",
|
||||
'private' => true,
|
||||
'git_content' => true,
|
||||
'topics' => true,
|
||||
'labels' => true,
|
||||
]);
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
|
||||
$giteaUrl,
|
||||
$token,
|
||||
$createPayload
|
||||
);
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
$this->log('ERROR', "Failed to create repo (HTTP {$response['code']}).");
|
||||
return 1;
|
||||
}
|
||||
$this->log('INFO', "Repo created: {$org}/{$repoName}");
|
||||
$this->log('INFO', 'Step 2: Updating repo description...');
|
||||
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
|
||||
$this->log('INFO', 'Step 3: Creating dev branch from main...');
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$org}/{$repoName}/branches",
|
||||
$giteaUrl,
|
||||
$token,
|
||||
json_encode([
|
||||
'new_branch_name' => 'dev',
|
||||
'old_branch_name' => 'main',
|
||||
])
|
||||
);
|
||||
if ($response['code'] >= 200 && $response['code'] < 300) {
|
||||
$this->log('INFO', 'Branch "dev" created from "main".');
|
||||
} else {
|
||||
$this->log('WARN', "Could not create dev branch (HTTP {$response['code']}).");
|
||||
}
|
||||
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
|
||||
$this->log('INFO', 'Scaffold complete.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->name === '' || $this->org === '' || $this->token === '')
|
||||
{
|
||||
$this->log('ERROR: --name, --org, and --token are required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
|
||||
{
|
||||
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
|
||||
. "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
|
||||
. "Set REPO VARIABLES:\n"
|
||||
. " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n"
|
||||
. " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n"
|
||||
. "Set REPO SECRETS:\n"
|
||||
. " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n"
|
||||
. "================================\n");
|
||||
}
|
||||
|
||||
$repoName = 'client-waas-' . $this->name;
|
||||
|
||||
$this->log("Scaffolding client repo: {$this->org}/{$repoName}");
|
||||
$this->log("Gitea URL: {$this->giteaUrl}");
|
||||
|
||||
if ($this->dryRun)
|
||||
{
|
||||
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
|
||||
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
|
||||
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
|
||||
$this->log('[DRY RUN] Would create dev branch from main');
|
||||
$this->printPostSetupInstructions($repoName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 1: Create repo from template
|
||||
$this->log('Step 1: Creating repo from template...');
|
||||
|
||||
$createPayload = json_encode([
|
||||
'owner' => $this->org,
|
||||
'name' => $repoName,
|
||||
'description' => "{$this->name} WaaS site",
|
||||
'private' => true,
|
||||
'git_content' => true,
|
||||
'topics' => true,
|
||||
'labels' => true,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
|
||||
$createPayload
|
||||
);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300)
|
||||
{
|
||||
$this->log("ERROR: Failed to create repo (HTTP {$response['code']}).");
|
||||
$this->log("Response: {$response['body']}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Repo created: {$this->org}/{$repoName}");
|
||||
|
||||
// Step 2: Set repo description (already set via generate, but confirm)
|
||||
$this->log('Step 2: Updating repo description...');
|
||||
|
||||
$updatePayload = json_encode([
|
||||
'description' => "{$this->name} WaaS site",
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'PATCH',
|
||||
"/api/v1/repos/{$this->org}/{$repoName}",
|
||||
$updatePayload
|
||||
);
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$this->log('Description updated.');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->log("WARNING: Could not update description (HTTP {$response['code']}).");
|
||||
}
|
||||
|
||||
// Step 3: Create dev branch from main
|
||||
$this->log('Step 3: Creating dev branch from main...');
|
||||
|
||||
$branchPayload = json_encode([
|
||||
'new_branch_name' => 'dev',
|
||||
'old_branch_name' => 'main',
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
'POST',
|
||||
"/api/v1/repos/{$this->org}/{$repoName}/branches",
|
||||
$branchPayload
|
||||
);
|
||||
|
||||
if ($response['code'] >= 200 && $response['code'] < 300)
|
||||
{
|
||||
$this->log('Branch "dev" created from "main".');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->log("WARNING: Could not create dev branch (HTTP {$response['code']}).");
|
||||
$this->log("Response: {$response['body']}");
|
||||
}
|
||||
|
||||
// Step 4: Print post-setup instructions
|
||||
$this->printPostSetupInstructions($repoName);
|
||||
|
||||
$this->log('Scaffold complete.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++)
|
||||
{
|
||||
switch ($args[$i])
|
||||
{
|
||||
case '--name':
|
||||
$this->name = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--gitea-url':
|
||||
$this->giteaUrl = rtrim($args[++$i] ?? '', '/');
|
||||
break;
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --name <name> Client name (e.g., "clarksvillefurs")');
|
||||
$this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")');
|
||||
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
|
||||
$this->log(' --token <token> Gitea API token');
|
||||
$this->log(' --dry-run Show what would be done without making changes');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function printPostSetupInstructions(string $repoName): void
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('=== POST-SETUP INSTRUCTIONS ===');
|
||||
$this->log('');
|
||||
$this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings");
|
||||
$this->log('');
|
||||
$this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):');
|
||||
$this->log(' DEV_SYNC_HOST - Dev server hostname or IP');
|
||||
$this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)');
|
||||
$this->log(' DEV_SYNC_USER - Dev server SSH username');
|
||||
$this->log(' DEV_SYNC_PATH - Dev server deploy path');
|
||||
$this->log(' LIVE_SSH_HOST - Live server hostname or IP');
|
||||
$this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)');
|
||||
$this->log(' LIVE_SSH_USER - Live server SSH username');
|
||||
$this->log(' LIVE_SYNC_PATH - Live server deploy path');
|
||||
$this->log('');
|
||||
$this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):');
|
||||
$this->log(' DEV_SYNC_KEY - Private SSH key for dev server');
|
||||
$this->log(' LIVE_SSH_KEY - Private SSH key for live server');
|
||||
$this->log('');
|
||||
$this->log('================================');
|
||||
}
|
||||
|
||||
private function apiRequest(string $method, string $endpoint, ?string $body = null): array
|
||||
{
|
||||
$url = $this->giteaUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$this->token}",
|
||||
]);
|
||||
|
||||
if ($body !== null)
|
||||
{
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch))
|
||||
{
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['code' => 0, 'body' => "cURL error: {$error}"];
|
||||
}
|
||||
curl_close($ch);
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ScaffoldClient();
|
||||
exit($app->run());
|
||||
$app = new ScaffoldClientCli();
|
||||
exit($app->execute());
|
||||
|
||||
+159
-154
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -12,170 +13,174 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/sync_rulesets.php
|
||||
* BRIEF: Apply branch protection rules to all repos via platform adapter
|
||||
*
|
||||
* USAGE
|
||||
* php cli/sync_rulesets.php # Apply to all repos
|
||||
* php cli/sync_rulesets.php --repo MokoCRM # Single repo
|
||||
* php cli/sync_rulesets.php --dry-run # Preview only
|
||||
* php cli/sync_rulesets.php --delete # Remove then re-apply
|
||||
*
|
||||
* NOTE: On GitHub, this creates rulesets via the rulesets API.
|
||||
* On Gitea, this creates branch_protections via the branch protection API.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
use MokoEnterprise\Config;
|
||||
use MokoEnterprise\PlatformAdapterFactory;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$deleteOld = in_array('--delete', $argv);
|
||||
class SyncRulesetsCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Apply branch protection rules to all repos via platform adapter');
|
||||
$this->addArgument('--repo', 'Single repository name (default: all repos)', '');
|
||||
$this->addArgument('--delete', 'Remove existing protections before re-applying', false);
|
||||
}
|
||||
|
||||
$repoName = null;
|
||||
protected function run(): int
|
||||
{
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$deleteOld = $this->getArgument('--delete');
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; }
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString(
|
||||
$adapter->getPlatformName() . '.organization',
|
||||
'mokoconsulting-tech'
|
||||
);
|
||||
|
||||
$platformName = $adapter->getPlatformName();
|
||||
$ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
|
||||
|
||||
// -- Protection rules (platform-agnostic format) --
|
||||
$PROTECTIONS = [
|
||||
[
|
||||
'name' => 'MAIN — protect default branch',
|
||||
'branch' => 'main',
|
||||
'rules' => [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'enforce_admins' => true,
|
||||
'block_on_rejected' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'VERSION — immutable snapshots',
|
||||
'branch' => 'version/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'DEV — prevent branch deletion',
|
||||
'branch' => 'dev/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'RC — prevent branch deletion',
|
||||
'branch' => 'rc/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// -- Build repo list --
|
||||
$repos = [];
|
||||
if ($repoName !== '') {
|
||||
$repos = [$repoName];
|
||||
} else {
|
||||
echo "Fetching repositories from {$org} ({$platformName})...\n";
|
||||
$allRepos = $adapter->listOrgRepos($org, true); // skip archived
|
||||
foreach ($allRepos as $r) {
|
||||
if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
|
||||
$repos[] = $r['name'];
|
||||
}
|
||||
}
|
||||
sort($repos);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
echo "Processing {$repo}...\n";
|
||||
|
||||
// Check existing protections
|
||||
$existing = $adapter->listBranchProtections($org, $repo);
|
||||
$existingNames = [];
|
||||
if (is_array($existing)) {
|
||||
foreach ($existing as $bp) {
|
||||
$bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? '';
|
||||
$bpId = $bp['id'] ?? null;
|
||||
if ($bpName !== '') {
|
||||
$existingNames[$bpName] = $bpId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($PROTECTIONS as $protection) {
|
||||
$pName = $protection['name'];
|
||||
|
||||
if ($deleteOld && isset($existingNames[$pName])) {
|
||||
if (!$this->dryRun) {
|
||||
try {
|
||||
// Platform-specific deletion via raw API
|
||||
$adapter->getApiClient()->delete(
|
||||
"/repos/{$org}/{$repo}/" .
|
||||
($platformName === 'github' ? 'rulesets' : 'branch_protections') .
|
||||
"/{$existingNames[$pName]}"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
/* ignore delete errors */
|
||||
}
|
||||
}
|
||||
echo " Deleted: {$pName}\n";
|
||||
unset($existingNames[$pName]);
|
||||
}
|
||||
|
||||
if (isset($existingNames[$pName])) {
|
||||
echo " Exists: {$pName}\n";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
echo " (dry-run) would create: {$pName}\n";
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']);
|
||||
echo " Created: {$pName}\n";
|
||||
$created++;
|
||||
} catch (\Exception $e) {
|
||||
$msg = $e->getMessage();
|
||||
if (str_contains($msg, '403')) {
|
||||
echo " Skipped (needs Pro/paid plan): {$pName}\n";
|
||||
$skipped++;
|
||||
} else {
|
||||
echo " Failed: {$pName} — {$msg}\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat('-', 50) . "\n";
|
||||
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
|
||||
return $failed > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $config->getString(
|
||||
$adapter->getPlatformName() . '.organization',
|
||||
'mokoconsulting-tech'
|
||||
);
|
||||
|
||||
$platformName = $adapter->getPlatformName();
|
||||
$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private'];
|
||||
|
||||
// ── Protection rules (platform-agnostic format) ─────────────────────────
|
||||
// On GitHub → rulesets API. On Gitea → branch_protections API.
|
||||
$PROTECTIONS = [
|
||||
[
|
||||
'name' => 'MAIN — protect default branch',
|
||||
'branch' => 'main',
|
||||
'rules' => [
|
||||
'required_reviews' => 1,
|
||||
'dismiss_stale' => true,
|
||||
'enforce_admins' => true,
|
||||
'block_on_rejected' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'VERSION — immutable snapshots',
|
||||
'branch' => 'version/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'DEV — prevent branch deletion',
|
||||
'branch' => 'dev/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'RC — prevent branch deletion',
|
||||
'branch' => 'rc/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
'whitelist_actions_user' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// ── Build repo list ─────────────────────────────────────────────────────
|
||||
$repos = [];
|
||||
if ($repoName) {
|
||||
$repos = [$repoName];
|
||||
} else {
|
||||
echo "Fetching repositories from {$org} ({$platformName})...\n";
|
||||
$allRepos = $adapter->listOrgRepos($org, true); // skip archived
|
||||
foreach ($allRepos as $r) {
|
||||
if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) {
|
||||
$repos[] = $r['name'];
|
||||
}
|
||||
}
|
||||
sort($repos);
|
||||
echo "Found " . count($repos) . " repositories\n\n";
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
echo "Processing {$repo}...\n";
|
||||
|
||||
// Check existing protections
|
||||
$existing = $adapter->listBranchProtections($org, $repo);
|
||||
$existingNames = [];
|
||||
if (is_array($existing)) {
|
||||
foreach ($existing as $bp) {
|
||||
$bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? '';
|
||||
$bpId = $bp['id'] ?? null;
|
||||
if ($bpName !== '') {
|
||||
$existingNames[$bpName] = $bpId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($PROTECTIONS as $protection) {
|
||||
$pName = $protection['name'];
|
||||
|
||||
if ($deleteOld && isset($existingNames[$pName])) {
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
// Platform-specific deletion via raw API
|
||||
$adapter->getApiClient()->delete(
|
||||
"/repos/{$org}/{$repo}/" .
|
||||
($platformName === 'github' ? 'rulesets' : 'branch_protections') .
|
||||
"/{$existingNames[$pName]}"
|
||||
);
|
||||
} catch (\Exception $e) { /* ignore delete errors */ }
|
||||
}
|
||||
echo " Deleted: {$pName}\n";
|
||||
unset($existingNames[$pName]);
|
||||
}
|
||||
|
||||
if (isset($existingNames[$pName])) {
|
||||
echo " Exists: {$pName}\n";
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
echo " (dry-run) would create: {$pName}\n";
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']);
|
||||
echo " Created: {$pName}\n";
|
||||
$created++;
|
||||
} catch (\Exception $e) {
|
||||
$msg = $e->getMessage();
|
||||
if (str_contains($msg, '403')) {
|
||||
echo " Skipped (needs Pro/paid plan): {$pName}\n";
|
||||
$skipped++;
|
||||
} else {
|
||||
echo " Failed: {$pName} — {$msg}\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo str_repeat('-', 50) . "\n";
|
||||
echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n";
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
$app = new SyncRulesetsCli();
|
||||
exit($app->execute());
|
||||
|
||||
+166
-187
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,201 +10,179 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/theme_lint.php
|
||||
* BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs
|
||||
*
|
||||
* Usage:
|
||||
* php theme_lint.php --path /repo
|
||||
* php theme_lint.php --path /repo --max-image-kb 500
|
||||
* php theme_lint.php --path /repo --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --max-image-kb Maximum image file size in KB (default: 500)
|
||||
* --github-output Export results to $GITHUB_OUTPUT
|
||||
* --strict Exit 1 on any warning (default: only on errors)
|
||||
* BRIEF: Lint theme files -- CSS syntax, image sizes, hardcoded URLs
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$maxImageKb = 500;
|
||||
$ghOutput = false;
|
||||
$strict = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
if ($arg === '--strict') $strict = true;
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
// ── Find source directory ───────────────────────────────────────────────
|
||||
$srcDir = null;
|
||||
foreach (['src', 'htdocs'] as $d) {
|
||||
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
|
||||
}
|
||||
if ($srcDir === null) {
|
||||
fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
// ── Check 1: CSS syntax validation ──────────────────────────────────────
|
||||
echo "--- CSS Syntax ---\n";
|
||||
$cssFiles = findFiles($srcDir, '*.css');
|
||||
$cssMinFiles = findFiles($srcDir, '*.min.css');
|
||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||
|
||||
if (empty($cssToCheck)) {
|
||||
echo " No CSS files to check\n";
|
||||
} else {
|
||||
foreach ($cssToCheck as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
// Check for unmatched braces
|
||||
$openBraces = substr_count($content, '{');
|
||||
$closeBraces = substr_count($content, '}');
|
||||
if ($openBraces !== $closeBraces) {
|
||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||
$errors++;
|
||||
}
|
||||
|
||||
// Check for empty rules
|
||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||
$count = count($m[0]);
|
||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||
$warnings++;
|
||||
}
|
||||
|
||||
// Check for !important abuse (more than 10 in one file)
|
||||
$importantCount = substr_count($content, '!important');
|
||||
if ($importantCount > 10) {
|
||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors === 0) {
|
||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Image file sizes ───────────────────────────────────────────
|
||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||
$images = [];
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles($srcDir, $ext));
|
||||
}
|
||||
// Also check root images/ directory
|
||||
if (is_dir("{$root}/images")) {
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, findFiles("{$root}/images", $ext));
|
||||
}
|
||||
}
|
||||
|
||||
$oversized = 0;
|
||||
$totalSize = 0;
|
||||
foreach ($images as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$sizeKb = round($size / 1024);
|
||||
|
||||
if ($sizeKb > $maxImageKb) {
|
||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||
$oversized++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||
if ($oversized > 0) {
|
||||
echo ", {$oversized} oversized";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// ── Check 3: Hardcoded URLs in CSS/JS ───────────────────────────────────
|
||||
echo "\n--- Hardcoded URLs ---\n";
|
||||
$codeFiles = array_merge(
|
||||
findFiles($srcDir, '*.css'),
|
||||
findFiles($srcDir, '*.js')
|
||||
);
|
||||
// Exclude minified files
|
||||
$codeFiles = array_filter($codeFiles, function($f) {
|
||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||
});
|
||||
|
||||
$urlPatterns = [
|
||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||
'/https?:\/\/localhost/' => 'localhost reference',
|
||||
];
|
||||
|
||||
$urlIssues = 0;
|
||||
foreach ($codeFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
|
||||
foreach ($urlPatterns as $pattern => $desc) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
$count = count($matches[0]);
|
||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||
$urlIssues++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($urlIssues === 0) {
|
||||
echo " OK: No hardcoded URLs found\n";
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Errors: {$errors}\n";
|
||||
echo "Warnings: {$warnings}\n";
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
if ($strict && $warnings > 0) {
|
||||
exit(1);
|
||||
}
|
||||
exit(0);
|
||||
|
||||
// ── Helper: recursively find files matching a glob pattern ──────────────
|
||||
function findFiles(string $dir, string $pattern): array
|
||||
class ThemeLintCli extends CliFramework
|
||||
{
|
||||
$results = [];
|
||||
if (!is_dir($dir)) return $results;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
|
||||
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
|
||||
$this->addArgument('--strict', 'Exit 1 on any warning', false);
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$maxImageKb = (int) $this->getArgument('--max-image-kb');
|
||||
$ghOutput = (bool) $this->getArgument('--github-output');
|
||||
$strict = (bool) $this->getArgument('--strict');
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (fnmatch($pattern, $file->getFilename())) {
|
||||
$results[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
return $results;
|
||||
$srcDir = SourceResolver::resolveAbsolute($root);
|
||||
if ($srcDir === null) {
|
||||
$this->log('ERROR', "No source/ or src/ directory in {$root}");
|
||||
return 1;
|
||||
}
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
|
||||
echo "Theme Lint: {$srcDir}\n\n";
|
||||
|
||||
echo "--- CSS Syntax ---\n";
|
||||
$cssFiles = $this->findFiles($srcDir, '*.css');
|
||||
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
|
||||
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
|
||||
|
||||
if (empty($cssToCheck)) {
|
||||
echo " No CSS files to check\n";
|
||||
} else {
|
||||
foreach ($cssToCheck as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$openBraces = substr_count($content, '{');
|
||||
$closeBraces = substr_count($content, '}');
|
||||
if ($openBraces !== $closeBraces) {
|
||||
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
|
||||
$errors++;
|
||||
}
|
||||
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
|
||||
$count = count($m[0]);
|
||||
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
|
||||
$warnings++;
|
||||
}
|
||||
$importantCount = substr_count($content, '!important');
|
||||
if ($importantCount > 10) {
|
||||
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
if ($errors === 0) {
|
||||
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
|
||||
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
|
||||
$images = [];
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, $this->findFiles($srcDir, $ext));
|
||||
}
|
||||
if (is_dir("{$root}/images")) {
|
||||
foreach ($imageExts as $ext) {
|
||||
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
|
||||
}
|
||||
}
|
||||
|
||||
$oversized = 0;
|
||||
$totalSize = 0;
|
||||
foreach ($images as $file) {
|
||||
$size = filesize($file);
|
||||
$totalSize += $size;
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$sizeKb = round($size / 1024);
|
||||
if ($sizeKb > $maxImageKb) {
|
||||
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
|
||||
$oversized++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalMb = round($totalSize / 1024 / 1024, 1);
|
||||
echo " " . count($images) . " image(s), {$totalMb}MB total";
|
||||
if ($oversized > 0) {
|
||||
echo ", {$oversized} oversized";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
echo "\n--- Hardcoded URLs ---\n";
|
||||
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
|
||||
$codeFiles = array_filter($codeFiles, function ($f) {
|
||||
return !preg_match('/\.min\.(css|js)$/', $f);
|
||||
});
|
||||
$urlPatterns = [
|
||||
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
|
||||
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
|
||||
'/https?:\/\/localhost/' => 'localhost reference',
|
||||
];
|
||||
$urlIssues = 0;
|
||||
foreach ($codeFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
foreach ($urlPatterns as $pattern => $desc) {
|
||||
if (preg_match_all($pattern, $content, $matches)) {
|
||||
$count = count($matches[0]);
|
||||
echo " WARN: {$relPath}: {$count} {$desc}\n";
|
||||
$urlIssues++;
|
||||
$warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($urlIssues === 0) {
|
||||
echo " OK: No hardcoded URLs found\n";
|
||||
}
|
||||
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Errors: {$errors}\n";
|
||||
echo "Warnings: {$warnings}\n";
|
||||
|
||||
if ($ghOutput) {
|
||||
$ghFile = getenv('GITHUB_OUTPUT');
|
||||
if ($ghFile) {
|
||||
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
|
||||
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
return 1;
|
||||
}
|
||||
if ($strict && $warnings > 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function findFiles(string $dir, string $pattern): array
|
||||
{
|
||||
$results = [];
|
||||
if (!is_dir($dir)) {
|
||||
return $results;
|
||||
}
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (fnmatch($pattern, $file->getFilename())) {
|
||||
$results[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ThemeLintCli();
|
||||
exit($app->execute());
|
||||
|
||||
+402
-415
@@ -11,444 +11,362 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/updates_xml_build.php
|
||||
* BRIEF: Generate Joomla updates.xml from extension manifest metadata
|
||||
*
|
||||
* Usage:
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256
|
||||
* php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (default: .)
|
||||
* --version Version string (required)
|
||||
* --stability One of: stable, rc, beta, alpha, development (default: stable)
|
||||
* --sha SHA-256 hash of the ZIP package (optional)
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
|
||||
* --org Organization (default: env GITEA_ORG)
|
||||
* --repo Repository name (default: env GITEA_REPO)
|
||||
* --output Output file path (default: updates.xml in --path)
|
||||
* --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// -- Argument parsing ---------------------------------------------------------
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$stability = 'stable';
|
||||
$sha = null;
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
$outputFile = null;
|
||||
$githubOutput = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) {
|
||||
$version = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) {
|
||||
$stability = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--sha' && isset($argv[$i + 1])) {
|
||||
$sha = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) {
|
||||
$giteaUrl = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) {
|
||||
$org = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
||||
$repo = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--output' && isset($argv[$i + 1])) {
|
||||
$outputFile = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--github-output') {
|
||||
$githubOutput = true;
|
||||
}
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n");
|
||||
exit(1);
|
||||
}
|
||||
class UpdatesXmlBuildCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Generate Joomla updates.xml from extension manifest metadata');
|
||||
$this->addArgument('--path', 'Repository root (default: .)', '.');
|
||||
$this->addArgument('--version', 'Version string (required)', '');
|
||||
$this->addArgument('--stability', 'One of: stable, rc, beta, alpha, development (default: stable)', 'stable');
|
||||
$this->addArgument('--sha', 'SHA-256 hash of the ZIP package', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea instance URL', '');
|
||||
$this->addArgument('--org', 'Organization', '');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
$this->addArgument('--output', 'Output file path (default: updates.xml in --path)', '');
|
||||
$this->addArgument('--github-output', 'Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT', false);
|
||||
}
|
||||
|
||||
// Strip any existing stability suffix from version (e.g. 01.02.20-dev → 01.02.20)
|
||||
// so per-channel suffixes are applied cleanly without doubling
|
||||
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$stability = $this->getArgument('--stability');
|
||||
$sha = $this->getArgument('--sha') ?: null;
|
||||
$giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech');
|
||||
$org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: '');
|
||||
$repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: '');
|
||||
$outputFile = $this->getArgument('--output') ?: null;
|
||||
$githubOutput = $this->getArgument('--github-output');
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- Read platform from .mokogitea/manifest.xml --------------------------------
|
||||
$detectedPlatform = 'joomla'; // default for backward compat
|
||||
$detectedName = $repo;
|
||||
$detectedPackageType = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||
if ($mokoXml !== false) {
|
||||
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||
if ($rawPlatform !== '') {
|
||||
$detectedPlatform = match ($rawPlatform) {
|
||||
'waas-component' => 'joomla',
|
||||
'crm-module' => 'dolibarr',
|
||||
default => $rawPlatform,
|
||||
};
|
||||
if ($version === '') {
|
||||
$this->log('ERROR', 'Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]');
|
||||
return 1;
|
||||
}
|
||||
$detectedName = (string)($mokoXml->identity->name ?? $repo);
|
||||
// <display-name> is the human-friendly name for releases and updates.xml
|
||||
$detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? '');
|
||||
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
// Strip suffix — stability is applied via --stability parameter
|
||||
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
|
||||
|
||||
// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root
|
||||
$candidates = glob("{$root}/src/pkg_*.xml") ?: [];
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
if ($manifest === null) {
|
||||
$searchDirs = ["{$root}/src", "{$root}"];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
// -- Read platform from .mokogitea/manifest.xml --------------------------------
|
||||
$detectedPlatform = 'joomla';
|
||||
$detectedName = $repo;
|
||||
$detectedPackageType = '';
|
||||
$detectedDisplayName = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoXml = @simplexml_load_file($mokoManifest);
|
||||
if ($mokoXml !== false) {
|
||||
$rawPlatform = (string)($mokoXml->governance->platform ?? '');
|
||||
if ($rawPlatform !== '') {
|
||||
$detectedPlatform = match ($rawPlatform) {
|
||||
'waas-component' => 'joomla',
|
||||
'crm-module' => 'dolibarr',
|
||||
default => $rawPlatform,
|
||||
};
|
||||
}
|
||||
$detectedName = (string)($mokoXml->identity->name ?? $repo);
|
||||
$detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? '');
|
||||
$detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? '');
|
||||
|
||||
if (empty($org)) {
|
||||
$manifestOrg = (string)($mokoXml->identity->org ?? '');
|
||||
if ($manifestOrg !== '') {
|
||||
$org = $manifestOrg;
|
||||
}
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$manifestName = (string)($mokoXml->identity->name ?? '');
|
||||
if ($manifestName !== '') {
|
||||
$repo = $manifestName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
|
||||
// -- Fallback: detect org/repo from git remote --------------------------------
|
||||
if (empty($org) || empty($repo)) {
|
||||
$remoteUrl = trim(shell_exec("git -C " . escapeshellarg($root) . " remote get-url origin 2>/dev/null") ?? '');
|
||||
if (preg_match('#[/:]([^/:]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
|
||||
if (empty($org)) {
|
||||
$org = $m[1];
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$repo = $m[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
|
||||
$candidates = SourceResolver::globSource($root, 'pkg_*.xml');
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest === null && $detectedPlatform === 'joomla') {
|
||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
if ($manifest === null) {
|
||||
$searchDirs = ["{$root}/src", "{$root}"];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$extName = '';
|
||||
$extType = '';
|
||||
$extElement = '';
|
||||
$extClient = '';
|
||||
$extFolder = '';
|
||||
$targetPlatform = '';
|
||||
$phpMinimum = '';
|
||||
if ($manifest === null && $detectedPlatform === 'joomla') {
|
||||
$this->log('ERROR', "No Joomla XML manifest found in {$root}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
// Joomla manifest found — parse extension metadata from it
|
||||
$xml = file_get_contents($manifest);
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$extName = '';
|
||||
$extType = '';
|
||||
$extElement = '';
|
||||
$extClient = '';
|
||||
$extFolder = '';
|
||||
$targetPlatform = '';
|
||||
$phpMinimum = '';
|
||||
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
|
||||
$extName = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) {
|
||||
$extName = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) {
|
||||
$extType = $m[1];
|
||||
}
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) {
|
||||
$extElement = $m[1];
|
||||
}
|
||||
if (empty($extElement)) {
|
||||
$fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME));
|
||||
if (in_array($fname, ['templatedetails', 'manifest'])) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root)));
|
||||
} else {
|
||||
$extElement = $fname;
|
||||
}
|
||||
}
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
|
||||
$extClient = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
|
||||
$targetPlatform = $m[1];
|
||||
}
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||
}
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
||||
$phpMinimum = $m[1];
|
||||
}
|
||||
} else {
|
||||
$extElement = $fname;
|
||||
$extName = $detectedName ?: ($repo ?: basename($root));
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
|
||||
$extType = $detectedPackageType ?: 'generic';
|
||||
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
|
||||
}
|
||||
}
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) {
|
||||
$extClient = $m[1];
|
||||
}
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) {
|
||||
$extFolder = $m[1];
|
||||
}
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) {
|
||||
$targetPlatform = $m[1];
|
||||
}
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||
}
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
||||
$phpMinimum = $m[1];
|
||||
}
|
||||
} else {
|
||||
// Non-Joomla platform — derive metadata from .mokogitea/manifest.xml
|
||||
$extName = $detectedName ?: ($repo ?: basename($root));
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
|
||||
$extType = $detectedPackageType ?: 'generic';
|
||||
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
|
||||
}
|
||||
|
||||
// Display name resolution moved to manifest.xml <display-name> (below)
|
||||
|
||||
// Fallbacks
|
||||
if (empty($extName)) {
|
||||
$extName = $repo ?: basename($root);
|
||||
}
|
||||
if (empty($extType)) {
|
||||
$extType = 'component';
|
||||
}
|
||||
|
||||
// Display name: use <display-name> from manifest.xml if available
|
||||
// This is the canonical human-friendly name — no type prefix added
|
||||
if (!empty($detectedDisplayName)) {
|
||||
$displayName = $detectedDisplayName;
|
||||
} elseif (!empty($detectedName)) {
|
||||
$displayName = $detectedName;
|
||||
} else {
|
||||
$displayName = $extName;
|
||||
}
|
||||
|
||||
// -- Build type prefix --------------------------------------------------------
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"ext_element={$extElement}",
|
||||
"ext_name={$extName}",
|
||||
"ext_type={$extType}",
|
||||
"ext_folder={$extFolder}",
|
||||
"type_prefix={$typePrefix}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
if (empty($extName)) {
|
||||
$extName = $repo ?: basename($root);
|
||||
}
|
||||
if (empty($extType)) {
|
||||
$extType = 'component';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
];
|
||||
if (!empty($detectedDisplayName)) {
|
||||
$displayName = $detectedDisplayName;
|
||||
} elseif (!empty($detectedName)) {
|
||||
$displayName = $detectedName;
|
||||
} else {
|
||||
$displayName = $extName;
|
||||
}
|
||||
|
||||
// Joomla <tags><tag> values — maps to Joomla's stabilityTagToInteger()
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
'dev' => 'dev',
|
||||
];
|
||||
// -- Build type prefix --------------------------------------------------------
|
||||
$typePrefix = '';
|
||||
switch ($extType) {
|
||||
case 'plugin':
|
||||
$typePrefix = "plg_{$extFolder}_";
|
||||
break;
|
||||
case 'module':
|
||||
$typePrefix = 'mod_';
|
||||
break;
|
||||
case 'component':
|
||||
$typePrefix = 'com_';
|
||||
break;
|
||||
case 'template':
|
||||
$typePrefix = 'tpl_';
|
||||
break;
|
||||
case 'library':
|
||||
$typePrefix = 'lib_';
|
||||
break;
|
||||
case 'package':
|
||||
$typePrefix = 'pkg_';
|
||||
break;
|
||||
}
|
||||
|
||||
// Gitea release tag names (used in download/info URLs)
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
'dev' => 'development',
|
||||
];
|
||||
|
||||
// -- Build update entries -----------------------------------------------------
|
||||
// For the primary entry: apply suffix if not stable
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
|
||||
// Build client tag — Joomla requires <client>site</client> to match updates
|
||||
// to installed extensions. Without it, extension_id=0 in #__updates.
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} else {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
// Build folder tag
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
|
||||
// PHP minimum tag
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
|
||||
// SHA tag
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single <update> entry for a given stability tag
|
||||
*/
|
||||
function buildEntry(
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $displayName,
|
||||
string $stabilityLabel,
|
||||
string $extElement,
|
||||
string $extType,
|
||||
string $clientTag,
|
||||
string $folderTag,
|
||||
string $infoUrl,
|
||||
string $targetPlatform,
|
||||
string $phpTag,
|
||||
string $shaTag,
|
||||
string $changelogUrl = ''
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = ' <update>';
|
||||
$lines[] = " <name>{$displayName}</name>";
|
||||
$lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>";
|
||||
// Element in updates.xml must match what Joomla stores in #__extensions.
|
||||
// Plugins and templates are stored as bare element (no prefix).
|
||||
// Other types need their prefix: mod_, com_, pkg_, lib_.
|
||||
$prefixMap = [
|
||||
'package' => 'pkg_',
|
||||
'module' => 'mod_',
|
||||
'component' => 'com_',
|
||||
'library' => 'lib_',
|
||||
];
|
||||
$dbElement = isset($prefixMap[$extType]) ? $prefixMap[$extType] . $extElement : $extElement;
|
||||
$lines[] = " <element>{$dbElement}</element>";
|
||||
$lines[] = " <type>{$extType}</type>";
|
||||
$lines[] = $clientTag;
|
||||
$lines[] = " <version>{$entryVersion}</version>";
|
||||
$lines[] = " <creationDate>" . date('Y-m-d') . "</creationDate>";
|
||||
if (!empty($folderTag)) {
|
||||
$lines[] = $folderTag;
|
||||
}
|
||||
$lines[] = " <infourl title='{$displayName}'>{$infoUrl}</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type='full' format='zip'>{$entryDownloadUrl}</downloadurl>";
|
||||
$lines[] = ' </downloads>';
|
||||
if (!empty($shaTag)) {
|
||||
$lines[] = $shaTag;
|
||||
}
|
||||
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
|
||||
if (!empty($changelogUrl)) {
|
||||
$lines[] = " <changelogurl>{$changelogUrl}</changelogurl>";
|
||||
}
|
||||
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
|
||||
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
|
||||
$lines[] = " {$targetPlatform}";
|
||||
if (!empty($phpTag)) {
|
||||
$lines[] = $phpTag;
|
||||
}
|
||||
$lines[] = ' </update>';
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
// -- Write ONLY the single channel being released --------------------------------
|
||||
// No cascading. Each update stream is independent.
|
||||
// When dev releases, only the dev entry is written/updated.
|
||||
// When stable releases, only the stable entry is written/updated.
|
||||
// All other channel entries are preserved exactly as-is.
|
||||
$entries = [];
|
||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
|
||||
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$displayName,
|
||||
$stability,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag,
|
||||
$changelogUrl
|
||||
);
|
||||
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
$preservedEntries = [];
|
||||
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
// Only the channel we're writing gets replaced — everything else is preserved
|
||||
$writtenTag = $joomlaTag;
|
||||
// Also match legacy alternate (e.g. 'development' = 'dev')
|
||||
$writtenAliases = [$writtenTag];
|
||||
if ($writtenTag === 'dev') $writtenAliases[] = 'development';
|
||||
if ($writtenTag === 'development') $writtenAliases[] = 'dev';
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
$existingTag = '';
|
||||
if (isset($existingUpdate->tags->tag)) {
|
||||
$existingTag = (string) $existingUpdate->tags->tag;
|
||||
}
|
||||
// Keep ALL entries except the one channel we're overwriting
|
||||
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
|
||||
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||
// -- Export to GITHUB_OUTPUT if requested -------------------------------------
|
||||
if ($githubOutput) {
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
$lines = [
|
||||
"ext_element={$extElement}",
|
||||
"ext_name={$extName}",
|
||||
"ext_type={$extType}",
|
||||
"ext_folder={$extFolder}",
|
||||
"type_prefix={$typePrefix}",
|
||||
];
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
|
||||
$this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT");
|
||||
} else {
|
||||
foreach ($lines as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write updates.xml --------------------------------------------------------
|
||||
$year = date('Y');
|
||||
$output = <<<XML
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
];
|
||||
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
'dev' => 'dev',
|
||||
];
|
||||
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
'dev' => 'development',
|
||||
];
|
||||
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} else {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
|
||||
// -- Write ONLY the single channel being released --------------------------------
|
||||
$entries = [];
|
||||
$giteaTag = $releaseTagMap[$stability] ?? $stability;
|
||||
$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? '');
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}";
|
||||
$joomlaTag = $stabilityTagMap[$stability] ?? $stability;
|
||||
$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md";
|
||||
|
||||
$entries[] = $this->buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$displayName,
|
||||
$stability,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag,
|
||||
$changelogUrl
|
||||
);
|
||||
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
$preservedEntries = [];
|
||||
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
$writtenTag = $joomlaTag;
|
||||
$writtenAliases = [$writtenTag];
|
||||
if ($writtenTag === 'dev') {
|
||||
$writtenAliases[] = 'development';
|
||||
}
|
||||
if ($writtenTag === 'development') {
|
||||
$writtenAliases[] = 'dev';
|
||||
}
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
$existingTag = '';
|
||||
if (isset($existingUpdate->tags->tag)) {
|
||||
$existingTag = (string) $existingUpdate->tags->tag;
|
||||
}
|
||||
if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) {
|
||||
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write updates.xml --------------------------------------------------------
|
||||
$year = date('Y');
|
||||
$output = <<<XML
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) {$year} Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -457,13 +375,82 @@ $output = <<<XML
|
||||
|
||||
<updates>
|
||||
XML;
|
||||
$allEntries = array_merge($preservedEntries, $entries);
|
||||
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
||||
$allEntries = array_merge($preservedEntries, $entries);
|
||||
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
file_put_contents($dest, $output);
|
||||
$stabilityOrder = ['dev' => 0, 'development' => 0, 'alpha' => 1, 'beta' => 2, 'rc' => 3, 'stable' => 4];
|
||||
usort($allEntries, function ($a, $b) use ($stabilityOrder) {
|
||||
preg_match('/<tag>([^<]+)<\/tag>/', $a, $ma);
|
||||
preg_match('/<tag>([^<]+)<\/tag>/', $b, $mb);
|
||||
return ($stabilityOrder[$ma[1] ?? ''] ?? 99) - ($stabilityOrder[$mb[1] ?? ''] ?? 99);
|
||||
});
|
||||
|
||||
$channelCount = count($entries);
|
||||
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
|
||||
echo "Output: {$dest}\n";
|
||||
exit(0);
|
||||
$output .= "\n" . implode("\n", $allEntries) . "\n</updates>\n";
|
||||
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
file_put_contents($dest, $output);
|
||||
|
||||
$channelCount = count($entries);
|
||||
echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n";
|
||||
echo "Output: {$dest}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function buildEntry(
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $displayName,
|
||||
string $stabilityLabel,
|
||||
string $extElement,
|
||||
string $extType,
|
||||
string $clientTag,
|
||||
string $folderTag,
|
||||
string $infoUrl,
|
||||
string $targetPlatform,
|
||||
string $phpTag,
|
||||
string $shaTag,
|
||||
string $changelogUrl = ''
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = ' <update>';
|
||||
$lines[] = " <name>{$displayName}</name>";
|
||||
$lines[] = " <description>{$displayName} {$stabilityLabel} build.</description>";
|
||||
$prefixMap = [
|
||||
'package' => 'pkg_',
|
||||
'module' => 'mod_',
|
||||
'component' => 'com_',
|
||||
'library' => 'lib_',
|
||||
];
|
||||
$dbElement = isset($prefixMap[$extType]) ? $prefixMap[$extType] . $extElement : $extElement;
|
||||
$lines[] = " <element>{$dbElement}</element>";
|
||||
$lines[] = " <type>{$extType}</type>";
|
||||
$lines[] = $clientTag;
|
||||
$lines[] = " <version>{$entryVersion}</version>";
|
||||
$lines[] = " <creationDate>" . date('Y-m-d') . "</creationDate>";
|
||||
if (!empty($folderTag)) {
|
||||
$lines[] = $folderTag;
|
||||
}
|
||||
$lines[] = " <infourl title='{$displayName}'>{$infoUrl}</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type='full' format='zip'>{$entryDownloadUrl}</downloadurl>";
|
||||
$lines[] = ' </downloads>';
|
||||
if (!empty($shaTag)) {
|
||||
$lines[] = $shaTag;
|
||||
}
|
||||
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
|
||||
if (!empty($changelogUrl)) {
|
||||
$lines[] = " <changelogurl>{$changelogUrl}</changelogurl>";
|
||||
}
|
||||
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
|
||||
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
|
||||
$lines[] = " {$targetPlatform}";
|
||||
if (!empty($phpTag)) {
|
||||
$lines[] = $phpTag;
|
||||
}
|
||||
$lines[] = ' </update>';
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new UpdatesXmlBuildCli();
|
||||
exit($app->execute());
|
||||
|
||||
+204
-177
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,193 +10,219 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/updates_xml_sync.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Sync updates.xml to target branches via Gitea API
|
||||
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
||||
* is modified on the current branch. Pushes the file to other branches
|
||||
* without requiring a git checkout (avoids merge conflicts).
|
||||
*
|
||||
* Usage:
|
||||
* php updates_xml_sync.php --path /repo --branches main,dev --current dev
|
||||
* php updates_xml_sync.php --path /repo --all --current dev --version 02.01.27
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root containing updates.xml (default: .)
|
||||
* --branches Comma-separated target branches to sync to (default: main,dev)
|
||||
* --all Auto-discover all branches via Gitea API (overrides --branches)
|
||||
* --current Current branch to skip (required)
|
||||
* --version Version string for commit message (optional)
|
||||
* --token Gitea API token (default: env MOKOGITEA_TOKEN)
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech)
|
||||
* --org Organization (default: env GITEA_ORG)
|
||||
* --repo Repository name (default: env GITEA_REPO)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ────────────────────────────────────────────────────
|
||||
$path = '.';
|
||||
$branches = 'main,dev';
|
||||
$current = '';
|
||||
$version = '';
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: '';
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
$discoverAll = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branches' && isset($argv[$i + 1])) $branches = $argv[$i + 1];
|
||||
if ($arg === '--all') $discoverAll = true;
|
||||
if ($arg === '--current' && isset($argv[$i + 1])) $current = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1];
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
}
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
if ($current === '') {
|
||||
fwrite(STDERR, "Error: --current is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "Error: --token or MOKOGITEA_TOKEN env is required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($org === '' || $repo === '') {
|
||||
fwrite(STDERR, "Error: --org and --repo (or GITEA_ORG/GITEA_REPO env) are required\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Auto-discover branches if --all flag is set
|
||||
if ($discoverAll) {
|
||||
$apiUrl = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}/branches?limit=50";
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Accept: application/json'],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$branchList = json_decode($response ?: '[]', true) ?: [];
|
||||
$discovered = [];
|
||||
foreach ($branchList as $b) {
|
||||
$name = $b['name'] ?? '';
|
||||
if ($name !== '' && $name !== $current
|
||||
&& !str_starts_with($name, 'version/')
|
||||
&& !str_starts_with($name, 'feature/')
|
||||
&& !str_starts_with($name, 'patch/')
|
||||
) {
|
||||
$discovered[] = $name;
|
||||
}
|
||||
}
|
||||
if (!empty($discovered)) {
|
||||
$branches = implode(',', $discovered);
|
||||
echo "Discovered branches: {$branches}\n";
|
||||
}
|
||||
}
|
||||
|
||||
$updatesFile = rtrim($path, '/') . '/updates.xml';
|
||||
if (!file_exists($updatesFile)) {
|
||||
fwrite(STDERR, "No updates.xml found at {$updatesFile}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$content = file_get_contents($updatesFile);
|
||||
$encoded = base64_encode($content);
|
||||
$giteaUrl = rtrim($giteaUrl, '/');
|
||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||
$vLabel = $version !== '' ? " {$version}" : '';
|
||||
|
||||
$targets = array_filter(
|
||||
array_map('trim', explode(',', $branches)),
|
||||
fn($b) => $b !== '' && $b !== $current
|
||||
);
|
||||
|
||||
if (empty($targets)) {
|
||||
fwrite(STDERR, "No target branches to sync to (current: {$current})\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($targets as $branch) {
|
||||
fwrite(STDERR, "Syncing updates.xml -> {$branch}...\n");
|
||||
|
||||
$sha = getFileSha($apiBase, $token, $branch);
|
||||
|
||||
if ($sha === null) {
|
||||
fwrite(STDERR, " WARNING: could not get SHA from {$branch}\n");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = putFile($apiBase, $token, $branch, $encoded, $sha,
|
||||
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]");
|
||||
|
||||
if ($ok) {
|
||||
fwrite(STDERR, " Synced to {$branch}\n");
|
||||
$synced++;
|
||||
} else {
|
||||
fwrite(STDERR, " WARNING: push to {$branch} failed\n");
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDERR, "Done: {$synced} synced, {$failed} failed\n");
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getFileSha(string $apiBase, string $token, string $branch): ?string
|
||||
class UpdatesXmlSyncCli extends CliFramework
|
||||
{
|
||||
$resp = apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
|
||||
return $resp['sha'] ?? null;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Sync updates.xml to target branches via Gitea API');
|
||||
$this->addArgument('--path', 'Repository root containing updates.xml', '.');
|
||||
$this->addArgument('--branches', 'Comma-separated target branches to sync to', 'main,dev');
|
||||
$this->addArgument('--all', 'Auto-discover all branches via Gitea API', false);
|
||||
$this->addArgument('--current', 'Current branch to skip (required)', '');
|
||||
$this->addArgument('--version', 'Version string for commit message', '');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--gitea-url', 'Gitea instance URL', '');
|
||||
$this->addArgument('--org', 'Organization', '');
|
||||
$this->addArgument('--repo', 'Repository name', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$branches = $this->getArgument('--branches');
|
||||
$discoverAll = $this->getArgument('--all');
|
||||
$current = $this->getArgument('--current');
|
||||
$version = $this->getArgument('--version');
|
||||
$token = $this->getArgument('--token');
|
||||
$giteaUrl = $this->getArgument('--gitea-url');
|
||||
$org = $this->getArgument('--org');
|
||||
$repo = $this->getArgument('--repo');
|
||||
|
||||
// Fall back to environment variables
|
||||
if ($token === '') {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: '';
|
||||
}
|
||||
if ($giteaUrl === '') {
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
}
|
||||
if ($org === '') {
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
}
|
||||
if ($repo === '') {
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
}
|
||||
|
||||
if ($current === '') {
|
||||
$this->log('ERROR', '--current is required');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
$this->log('ERROR', '--token or MOKOGITEA_TOKEN env is required');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($org === '' || $repo === '') {
|
||||
$this->log('ERROR', '--org and --repo (or GITEA_ORG/GITEA_REPO env) are required');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Auto-discover branches if --all flag is set
|
||||
if ($discoverAll) {
|
||||
$apiUrl = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}/branches?limit=50";
|
||||
$ch = curl_init($apiUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Accept: application/json'],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$branchList = json_decode($response ?: '[]', true) ?: [];
|
||||
$discovered = [];
|
||||
foreach ($branchList as $b) {
|
||||
$name = $b['name'] ?? '';
|
||||
if (
|
||||
$name !== '' && $name !== $current
|
||||
&& !str_starts_with($name, 'version/')
|
||||
&& !str_starts_with($name, 'feature/')
|
||||
&& !str_starts_with($name, 'patch/')
|
||||
) {
|
||||
$discovered[] = $name;
|
||||
}
|
||||
}
|
||||
if (!empty($discovered)) {
|
||||
$branches = implode(',', $discovered);
|
||||
echo "Discovered branches: {$branches}\n";
|
||||
}
|
||||
}
|
||||
|
||||
$updatesFile = rtrim($path, '/') . '/updates.xml';
|
||||
if (!file_exists($updatesFile)) {
|
||||
$this->log('ERROR', "No updates.xml found at {$updatesFile}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$content = file_get_contents($updatesFile);
|
||||
$encoded = base64_encode($content);
|
||||
$giteaUrl = rtrim($giteaUrl, '/');
|
||||
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
|
||||
$vLabel = $version !== '' ? " {$version}" : '';
|
||||
|
||||
$targets = array_filter(
|
||||
array_map('trim', explode(',', $branches)),
|
||||
fn($b) => $b !== '' && $b !== $current
|
||||
);
|
||||
|
||||
if (empty($targets)) {
|
||||
$this->log('ERROR', "No target branches to sync to (current: {$current})");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($targets as $branch) {
|
||||
$this->log('INFO', "Syncing updates.xml -> {$branch}...");
|
||||
|
||||
$sha = $this->getFileSha($apiBase, $token, $branch);
|
||||
|
||||
if ($sha === null) {
|
||||
$this->warning("could not get SHA from {$branch}");
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $this->putFile(
|
||||
$apiBase,
|
||||
$token,
|
||||
$branch,
|
||||
$encoded,
|
||||
$sha,
|
||||
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
$this->log('INFO', "Synced to {$branch}");
|
||||
$synced++;
|
||||
} else {
|
||||
$this->warning("push to {$branch} failed");
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('INFO', "Done: {$synced} synced, {$failed} failed");
|
||||
return $failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function getFileSha(string $apiBase, string $token, string $branch): ?string
|
||||
{
|
||||
$resp = $this->apiCall('GET', "{$apiBase}/contents/updates.xml?ref={$branch}", $token);
|
||||
return $resp['sha'] ?? null;
|
||||
}
|
||||
|
||||
private function putFile(
|
||||
string $apiBase,
|
||||
string $token,
|
||||
string $branch,
|
||||
string $encoded,
|
||||
string $sha,
|
||||
string $msg
|
||||
): bool {
|
||||
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
|
||||
'content' => $encoded,
|
||||
'sha' => $sha,
|
||||
'message' => $msg,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
return $resp !== null;
|
||||
}
|
||||
|
||||
private function apiCall(string $method, string $url, string $token, ?array $data = null): ?array
|
||||
{
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
if ($data !== null) {
|
||||
curl_setopt(
|
||||
$ch,
|
||||
CURLOPT_POSTFIELDS,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return ($code >= 200 && $code < 300)
|
||||
? (json_decode($body, true) ?: [])
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function putFile(string $apiBase, string $token, string $branch,
|
||||
string $encoded, string $sha, string $msg): bool
|
||||
{
|
||||
$resp = apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
|
||||
'content' => $encoded,
|
||||
'sha' => $sha,
|
||||
'message' => $msg,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
return $resp !== null;
|
||||
}
|
||||
|
||||
function apiCall(string $method, string $url, string $token, ?array $data = null): ?array
|
||||
{
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
if ($data !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return ($code >= 200 && $code < 300)
|
||||
? (json_decode($body, true) ?: [])
|
||||
: null;
|
||||
}
|
||||
$app = new UpdatesXmlSyncCli();
|
||||
exit($app->execute());
|
||||
|
||||
+190
-148
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,165 +10,206 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_auto_bump.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
||||
*
|
||||
* Usage:
|
||||
* php version_auto_bump.php --path . --branch dev
|
||||
* php version_auto_bump.php --path . --branch feature/my-feature --token TOKEN --repo-url URL
|
||||
* php version_auto_bump.php --path . --branch alpha --dry-run
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$branch = null;
|
||||
$token = '';
|
||||
$repoUrl = '';
|
||||
$dryRun = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$watchPath = '';
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1];
|
||||
if ($arg === '--watch-path' && isset($argv[$i + 1])) $watchPath = $argv[$i + 1];
|
||||
if ($arg === '--dry-run') $dryRun = true;
|
||||
}
|
||||
|
||||
// Auto-detect branch from git or CI env
|
||||
if ($branch === null) {
|
||||
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
if (empty($branch) || $branch === 'HEAD') {
|
||||
fwrite(STDERR, "Cannot detect branch — pass --branch\n");
|
||||
exit(1);
|
||||
class VersionAutoBumpCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Auto patch-bump, set stability suffix, and commit');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--branch', 'Git branch name', '');
|
||||
$this->addArgument('--token', 'API token for push', '');
|
||||
$this->addArgument('--repo-url', 'Repository URL for git remote', '');
|
||||
$this->addArgument('--watch-path', 'Path to watch for changes', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Map branch to stability suffix
|
||||
$stabilityMap = [
|
||||
'dev' => 'dev',
|
||||
'alpha' => 'alpha',
|
||||
'beta' => 'beta',
|
||||
'rc' => 'rc',
|
||||
];
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$token = $this->getArgument('--token');
|
||||
$repoUrl = $this->getArgument('--repo-url');
|
||||
$watchPath = $this->getArgument('--watch-path');
|
||||
|
||||
if (array_key_exists($branch, $stabilityMap)) {
|
||||
$stability = $stabilityMap[$branch];
|
||||
} elseif (str_starts_with($branch, 'feature/') || str_starts_with($branch, 'patch/')) {
|
||||
$stability = 'dev';
|
||||
} else {
|
||||
$stability = 'dev';
|
||||
}
|
||||
|
||||
$cli = __DIR__;
|
||||
$php = '"' . PHP_BINARY . '"';
|
||||
|
||||
// Auto-detect watch path from manifest.xml if not provided
|
||||
if (empty($watchPath)) {
|
||||
$manifestFile = realpath($path) . '/.mokogitea/manifest.xml';
|
||||
if (file_exists($manifestFile)) {
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
if ($xml && isset($xml->build->{'entry-point'})) {
|
||||
$watchPath = (string) $xml->build->{'entry-point'};
|
||||
// Auto-detect branch from git or CI env
|
||||
if ($branch === '') {
|
||||
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
if (empty($branch) || $branch === 'HEAD') {
|
||||
$this->log('ERROR', 'Cannot detect branch — pass --branch');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Map branch to stability suffix
|
||||
$stabilityMap = [
|
||||
'dev' => 'dev',
|
||||
'alpha' => 'alpha',
|
||||
'beta' => 'beta',
|
||||
'rc' => 'rc',
|
||||
];
|
||||
|
||||
if (array_key_exists($branch, $stabilityMap)) {
|
||||
$stability = $stabilityMap[$branch];
|
||||
} elseif (str_starts_with($branch, 'feature/') || str_starts_with($branch, 'patch/')) {
|
||||
$stability = 'dev';
|
||||
} else {
|
||||
$stability = 'dev';
|
||||
}
|
||||
|
||||
$cli = __DIR__;
|
||||
$php = '"' . PHP_BINARY . '"';
|
||||
|
||||
// Auto-detect watch path from manifest.xml if not provided
|
||||
if (empty($watchPath)) {
|
||||
$manifestFile = realpath($path) . '/.mokogitea/manifest.xml';
|
||||
if (file_exists($manifestFile)) {
|
||||
$xml = @simplexml_load_file($manifestFile);
|
||||
if ($xml && isset($xml->build->{'entry-point'})) {
|
||||
$watchPath = (string) $xml->build->{'entry-point'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if code files actually changed (skip bump for docs/config-only changes)
|
||||
$shouldBump = true;
|
||||
if (!empty($watchPath)) {
|
||||
$root = realpath($path) ?: $path;
|
||||
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$diffOutput = trim((string) @shell_exec(
|
||||
$cdCmd . escapeshellarg($root)
|
||||
. " && git diff --name-only HEAD~1 HEAD -- "
|
||||
. escapeshellarg($watchPath) . " 2>/dev/null"
|
||||
));
|
||||
if (empty($diffOutput)) {
|
||||
echo "No changes in {$watchPath} — skipping version bump\n";
|
||||
$shouldBump = false;
|
||||
} else {
|
||||
echo "Changes detected in {$watchPath}:\n{$diffOutput}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!$shouldBump) {
|
||||
echo "No code changes — nothing to do\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 1: Patch bump
|
||||
$bumpOutput = [];
|
||||
exec("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " 2>&1", $bumpOutput, $bumpRc);
|
||||
foreach ($bumpOutput as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
|
||||
// Step 2: Read version (--quiet suppresses banner so only the version is output)
|
||||
$versionOutput = [];
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " --quiet 2>&1", $versionOutput, $versionRc);
|
||||
// Take the last non-empty line — the version is always the final output
|
||||
$version = '';
|
||||
foreach (array_reverse($versionOutput) as $line) {
|
||||
$line = trim($line);
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}/', $line)) {
|
||||
$version = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($version)) {
|
||||
echo "No version found — skipping\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
echo "Version: {$version} | Branch: {$branch} | Stability: {$stability}\n";
|
||||
|
||||
// Step 3: Set platform version with stability suffix
|
||||
$setPlatOutput = [];
|
||||
exec("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($version)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>&1", $setPlatOutput);
|
||||
foreach ($setPlatOutput as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
|
||||
// Step 4: Version consistency check and fix
|
||||
exec("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>&1", $checkOutput);
|
||||
|
||||
// Re-read version (now includes suffix from version_set_platform)
|
||||
$suffixMap = [
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
];
|
||||
$displayVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version) . ($suffixMap[$stability] ?? '');
|
||||
|
||||
if ($this->dryRun) {
|
||||
echo "[DRY-RUN] Would commit and push {$displayVersion} to {$branch}\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 5: Git commit and push
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Check if anything changed
|
||||
$cdPrefix = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$diffStatus = trim((string) @shell_exec(
|
||||
$cdPrefix . escapeshellarg($root)
|
||||
. " && git diff --quiet && git diff --cached --quiet"
|
||||
. " 2>&1 && echo clean || echo dirty"
|
||||
));
|
||||
if ($diffStatus === 'clean') {
|
||||
echo "No version changes to commit\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Configure git
|
||||
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
|
||||
$cdRoot = $cd . escapeshellarg($root);
|
||||
@shell_exec(
|
||||
$cdRoot . " && git config --local user.email"
|
||||
. " \"gitea-actions[bot]@mokoconsulting.tech\""
|
||||
);
|
||||
@shell_exec(
|
||||
$cdRoot . " && git config --local user.name"
|
||||
. " \"gitea-actions[bot]\""
|
||||
);
|
||||
|
||||
if (!empty($repoUrl)) {
|
||||
@shell_exec(
|
||||
$cdRoot . " && git remote set-url origin "
|
||||
. escapeshellarg($repoUrl)
|
||||
);
|
||||
}
|
||||
|
||||
@shell_exec($cdRoot . " && git add -A");
|
||||
$commitMsg = $shouldBump
|
||||
? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
|
||||
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
|
||||
@shell_exec(
|
||||
$cdRoot . " && git commit -m " . escapeshellarg($commitMsg)
|
||||
. " --author=\"gitea-actions[bot]"
|
||||
. " <gitea-actions[bot]@mokoconsulting.tech>\""
|
||||
);
|
||||
|
||||
$pushResult = @shell_exec(
|
||||
$cdRoot . " && git push origin "
|
||||
. escapeshellarg($branch) . " 2>&1"
|
||||
);
|
||||
echo $pushResult ?? '';
|
||||
|
||||
echo "Bumped to {$displayVersion}\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if code files actually changed (skip bump for docs/config-only changes)
|
||||
$shouldBump = true;
|
||||
if (!empty($watchPath)) {
|
||||
$root = realpath($path) ?: $path;
|
||||
$diffOutput = trim((string) @shell_exec(
|
||||
(PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --name-only HEAD~1 HEAD -- " . escapeshellarg($watchPath) . " 2>/dev/null"
|
||||
));
|
||||
if (empty($diffOutput)) {
|
||||
echo "No changes in {$watchPath} — skipping version bump\n";
|
||||
$shouldBump = false;
|
||||
} else {
|
||||
echo "Changes detected in {$watchPath}:\n{$diffOutput}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!$shouldBump) {
|
||||
echo "No code changes — nothing to do\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Step 1: Patch bump
|
||||
$bumpOutput = [];
|
||||
exec("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " 2>&1", $bumpOutput, $bumpRc);
|
||||
foreach ($bumpOutput as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
|
||||
// Step 2: Read version
|
||||
$versionOutput = [];
|
||||
exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($path) . " 2>&1", $versionOutput, $versionRc);
|
||||
$version = trim($versionOutput[0] ?? '');
|
||||
|
||||
if (empty($version)) {
|
||||
echo "No version found — skipping\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Version: {$version} | Branch: {$branch} | Stability: {$stability}\n";
|
||||
|
||||
// Step 3: Set platform version with stability suffix
|
||||
exec("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
|
||||
. " --version " . escapeshellarg($version)
|
||||
. " --branch " . escapeshellarg($branch)
|
||||
. " --stability " . escapeshellarg($stability) . " 2>&1", $setPlatOutput);
|
||||
foreach ($setPlatOutput as $line) {
|
||||
echo "{$line}\n";
|
||||
}
|
||||
|
||||
// Step 4: Version consistency check and fix
|
||||
exec("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>&1", $checkOutput);
|
||||
|
||||
// Re-read version (now includes suffix from version_set_platform)
|
||||
$suffixMap = [
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
];
|
||||
$displayVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version) . ($suffixMap[$stability] ?? '');
|
||||
|
||||
if ($dryRun) {
|
||||
echo "[DRY-RUN] Would commit and push {$displayVersion} to {$branch}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Step 5: Git commit and push
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Check if anything changed
|
||||
$diffStatus = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
|
||||
if ($diffStatus === 'clean') {
|
||||
echo "No version changes to commit\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Configure git
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
|
||||
|
||||
if (!empty($repoUrl)) {
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
|
||||
}
|
||||
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
|
||||
$commitMsg = $shouldBump
|
||||
? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
|
||||
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
|
||||
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg($commitMsg)
|
||||
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
|
||||
|
||||
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
|
||||
echo $pushResult ?? '';
|
||||
|
||||
echo "Bumped to {$displayVersion}\n";
|
||||
exit(0);
|
||||
$app = new VersionAutoBumpCli();
|
||||
exit($app->execute());
|
||||
|
||||
+307
-258
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,276 +10,324 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump.php
|
||||
* BRIEF: Auto-increment version — manifest.xml is canonical, cascades to all XML and MD files
|
||||
* BRIEF: Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$type = 'patch'; // patch | minor | major
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--minor') $type = 'minor';
|
||||
if ($arg === '--major') $type = 'major';
|
||||
}
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
// -- 1. Read version from .mokogitea/manifest.xml (canonical) --
|
||||
$mokoVersion = null;
|
||||
$mokoSuffix = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
$mokoContent = '';
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoContent = file_get_contents($mokoManifest);
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) {
|
||||
$mokoVersion = $m[1];
|
||||
$mokoSuffix = isset($m[2]) ? $m[2] : '';
|
||||
class VersionBumpCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--minor', 'Bump minor version', false);
|
||||
$this->addArgument('--major', 'Bump major version', false);
|
||||
}
|
||||
}
|
||||
|
||||
// -- 2. Fallback: README.md --
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
$readmeContent = '';
|
||||
if (file_exists($readme)) {
|
||||
$readmeContent = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestSuffix = '';
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
// Preserve the suffix from the manifest (e.g. dev, rc) — strip leading dash
|
||||
$manifestSuffix = ltrim($xm[2] ?? '', '-');
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$type = 'patch';
|
||||
if ($this->getArgument('--minor')) {
|
||||
$type = 'minor';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Use the highest version as base --
|
||||
$baseVersion = null;
|
||||
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
||||
foreach ($candidates as $v) {
|
||||
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
|
||||
$baseVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseVersion === null) {
|
||||
fwrite(STDERR, "No version found in manifest.xml, README.md, or Joomla XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Parse and bump --
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
|
||||
fwrite(STDERR, "Invalid version format: {$baseVersion}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
switch ($type) {
|
||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor': $minor++; $patch = 0; break;
|
||||
default:
|
||||
$patch++;
|
||||
if ($patch > 99) { $minor++; $patch = 0; }
|
||||
if ($minor > 99) { $major++; $minor = 0; }
|
||||
break;
|
||||
}
|
||||
|
||||
$new = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
|
||||
// -- Write clean version (no suffix) ------------------------------------------
|
||||
// Suffixes (-dev, -alpha, -beta, -rc) are managed by version_set_platform.php
|
||||
// called from CI workflows with the appropriate --stability flag. version_bump
|
||||
// always writes a clean base version so the suffix layer stays consistent.
|
||||
$newFull = $new;
|
||||
|
||||
// -- Update .mokogitea/manifest.xml (canonical — preserves suffix) --
|
||||
if (file_exists($mokoManifest) && !empty($mokoContent)) {
|
||||
$updated = preg_replace(
|
||||
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#',
|
||||
"<version>{$newFull}</version>",
|
||||
$mokoContent,
|
||||
1
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($mokoManifest, $updated);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Update README.md --
|
||||
if (file_exists($readme) && !empty($readmeContent)) {
|
||||
$updated = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m',
|
||||
'${1}' . $newFull,
|
||||
$readmeContent,
|
||||
1
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($readme, $updated);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Cascade to ALL Joomla extension XML manifests --
|
||||
$xmlPatterns = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
$updatedFiles = [];
|
||||
foreach ($xmlPatterns as $pattern) {
|
||||
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
// Only update files that have an <extension> tag (Joomla manifests)
|
||||
if (strpos($content, '<extension') === false) {
|
||||
continue;
|
||||
if ($this->getArgument('--major')) {
|
||||
$type = 'major';
|
||||
}
|
||||
$newContent = preg_replace(
|
||||
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#',
|
||||
"<version>{$newFull}</version>",
|
||||
$content
|
||||
$root = realpath($path) ?: $path;
|
||||
$mokoVersion = null;
|
||||
$existingSuffix = '';
|
||||
$versionPrefix = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
$mokoContent = '';
|
||||
if (file_exists($mokoManifest)) {
|
||||
$mokoContent = file_get_contents($mokoManifest);
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $mokoContent, $m)) {
|
||||
$mokoVersion = $m[1];
|
||||
$existingSuffix = $m[2] ?? '';
|
||||
}
|
||||
// Read version_prefix from manifest.xml (supports nested and flat structure)
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$prefix = (string)($xml->identity->version_prefix ?? '');
|
||||
if ($prefix === '') {
|
||||
$prefix = (string)($xml->version_prefix ?? '');
|
||||
}
|
||||
$versionPrefix = $prefix;
|
||||
}
|
||||
}
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
$readmeContent = '';
|
||||
if (file_exists($readme)) {
|
||||
$readmeContent = file_get_contents($readme);
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware README scan
|
||||
$prefixPattern = preg_quote($versionPrefix, '/');
|
||||
if (preg_match('/' . $prefixPattern . '(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
if ($readmeVersion === null && preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
$manifestVersion = null;
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$manifestFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/mokowaas.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
if ($newContent !== null && $newContent !== $content) {
|
||||
file_put_contents($xmlFile, $newContent);
|
||||
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware: look for <version>prefix + XX.YY.ZZ</version>
|
||||
$prefixPattern = preg_quote($versionPrefix, '#');
|
||||
if (preg_match('#<version>' . $prefixPattern . '(\d{2}\.\d{2}\.\d{2})</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
$baseVersion = null;
|
||||
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
|
||||
foreach ($candidates as $v) {
|
||||
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
|
||||
$baseVersion = $v;
|
||||
}
|
||||
}
|
||||
if ($baseVersion === null) {
|
||||
$this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML");
|
||||
return 1;
|
||||
}
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
|
||||
$this->log('ERROR', "Invalid version format: {$baseVersion}");
|
||||
return 1;
|
||||
}
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
switch ($type) {
|
||||
case 'major':
|
||||
$major++;
|
||||
$minor = 0;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
$minor++;
|
||||
$patch = 0;
|
||||
break;
|
||||
default:
|
||||
$patch++;
|
||||
if ($patch > 99) {
|
||||
$minor++;
|
||||
$patch = 0;
|
||||
} if ($minor > 99) {
|
||||
$major++;
|
||||
$minor = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
$newBase = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
$newFull = $newBase . $existingSuffix;
|
||||
if (file_exists($mokoManifest) && !empty($mokoContent)) {
|
||||
$pattern = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||
$updated = preg_replace(
|
||||
$pattern,
|
||||
"<version>{$newFull}</version>",
|
||||
$mokoContent,
|
||||
1
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($mokoManifest, $updated);
|
||||
}
|
||||
}
|
||||
if (file_exists($readme) && !empty($readmeContent)) {
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware README replacement: preserve prefix, replace only version part
|
||||
$prefixPattern = preg_quote($versionPrefix, '/');
|
||||
$updated = preg_replace('/(' . $prefixPattern . ')\d{2}\.\d{2}\.\d{2}/m', '${1}' . $newBase, $readmeContent, 1);
|
||||
} else {
|
||||
$updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newBase, $readmeContent, 1);
|
||||
}
|
||||
if ($updated !== null) {
|
||||
file_put_contents($readme, $updated);
|
||||
}
|
||||
}
|
||||
$updatedFiles = [];
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
foreach (["{$root}/{$srcName}/pkg_*.xml", "{$root}/{$srcName}/*.xml", "{$root}/{$srcName}/packages/*/*.xml", "{$root}/*.xml"] as $pattern) {
|
||||
foreach (glob($pattern) ?: [] as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') === false) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware: preserve prefix, replace only the Moko version part
|
||||
$prefixPattern = preg_quote($versionPrefix, '#');
|
||||
$xmlPattern = '#(<version>' . $prefixPattern . ')\d{2}\.\d{2}\.\d{2}</version>#';
|
||||
$newContent = preg_replace(
|
||||
$xmlPattern,
|
||||
'${1}' . $newBase . '</version>',
|
||||
$content
|
||||
);
|
||||
} else {
|
||||
$xmlPattern = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||
$newContent = preg_replace(
|
||||
$xmlPattern,
|
||||
"<version>{$newFull}</version>",
|
||||
$content
|
||||
);
|
||||
}
|
||||
if ($newContent !== null && $newContent !== $content) {
|
||||
file_put_contents($xmlFile, $newContent);
|
||||
$updatedFiles[] = substr($xmlFile, strlen($root) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($updatedFiles)) {
|
||||
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
|
||||
}
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgContent = file_get_contents($packageJsonFile);
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware package.json replacement
|
||||
$prefixPattern = preg_quote($versionPrefix, '/');
|
||||
$pkgPattern = '/("version"\s*:\s*")' . $prefixPattern . '\d{2}\.\d{2}\.\d{2}(")/m';
|
||||
$updatedPkg = preg_replace(
|
||||
$pkgPattern,
|
||||
'${1}' . $versionPrefix . $newBase . '${2}',
|
||||
$pkgContent
|
||||
);
|
||||
} else {
|
||||
$pkgPattern = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||
$updatedPkg = preg_replace(
|
||||
$pkgPattern,
|
||||
'${1}' . $newFull . '${2}',
|
||||
$pkgContent
|
||||
);
|
||||
}
|
||||
if ($updatedPkg !== $pkgContent) {
|
||||
file_put_contents($packageJsonFile, $updatedPkg);
|
||||
fwrite(STDERR, "Updated package.json\n");
|
||||
}
|
||||
}
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware pyproject.toml replacement
|
||||
$prefixPattern = preg_quote($versionPrefix, '/');
|
||||
$pyPattern = '/^(version\s*=\s*")' . $prefixPattern . '\d{2}\.\d{2}\.\d{2}(")/m';
|
||||
$updatedPy = preg_replace(
|
||||
$pyPattern,
|
||||
'${1}' . $versionPrefix . $newBase . '${2}',
|
||||
$pyContent
|
||||
);
|
||||
} else {
|
||||
$pyPattern = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||
$updatedPy = preg_replace(
|
||||
$pyPattern,
|
||||
'${1}' . $newFull . '${2}',
|
||||
$pyContent
|
||||
);
|
||||
}
|
||||
if ($updatedPy !== $pyContent) {
|
||||
file_put_contents($pyprojectFile, $updatedPy);
|
||||
fwrite(STDERR, "Updated pyproject.toml\n");
|
||||
}
|
||||
}
|
||||
$changelogFile = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($changelogFile)) {
|
||||
$clContent = file_get_contents($changelogFile);
|
||||
$updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newBase, $clContent);
|
||||
if ($updatedCl !== null && $updatedCl !== $clContent) {
|
||||
file_put_contents($changelogFile, $updatedCl);
|
||||
fwrite(STDERR, "Updated CHANGELOG.md\n");
|
||||
}
|
||||
}
|
||||
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
|
||||
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
|
||||
// Build the generic VERSION: pattern — prefix-aware if configured
|
||||
if (!empty($versionPrefix)) {
|
||||
$prefixPatternGeneric = preg_quote($versionPrefix, '/');
|
||||
$versionPattern = '/(' . $prefixPatternGeneric . ')\d{2}\.\d{2}\.\d{2}/m';
|
||||
} else {
|
||||
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m';
|
||||
}
|
||||
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) {
|
||||
if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) {
|
||||
return false;
|
||||
} return true;
|
||||
});
|
||||
$iterator = new RecursiveIteratorIterator($filter);
|
||||
$genericUpdated = [];
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if ($fileInfo->isDir()) {
|
||||
continue;
|
||||
}
|
||||
$ext = strtolower($fileInfo->getExtension());
|
||||
if (!in_array($ext, $scanExtensions, true)) {
|
||||
continue;
|
||||
}
|
||||
$filePath = $fileInfo->getPathname();
|
||||
$relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
|
||||
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($relPath, $updatedFiles ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) {
|
||||
continue;
|
||||
}
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) {
|
||||
continue;
|
||||
}
|
||||
$updated = preg_replace($versionPattern, '${1}' . $newBase, $content);
|
||||
if ($updated !== null && $updated !== $content) {
|
||||
file_put_contents($filePath, $updated);
|
||||
$genericUpdated[] = $relPath;
|
||||
}
|
||||
}
|
||||
if (!empty($genericUpdated)) {
|
||||
fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n");
|
||||
}
|
||||
echo "{$old} -> {$newFull}\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($updatedFiles)) {
|
||||
fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n");
|
||||
}
|
||||
|
||||
// -- Update package.json (Node.js / MCP) --
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgContent = file_get_contents($packageJsonFile);
|
||||
$updatedPkg = preg_replace(
|
||||
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m',
|
||||
'${1}' . $newFull . '${2}',
|
||||
$pkgContent
|
||||
);
|
||||
if ($updatedPkg !== $pkgContent) {
|
||||
file_put_contents($packageJsonFile, $updatedPkg);
|
||||
fwrite(STDERR, "Updated package.json\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Update pyproject.toml (Python) --
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
$updatedPy = preg_replace(
|
||||
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m',
|
||||
'${1}' . $newFull . '${2}',
|
||||
$pyContent
|
||||
);
|
||||
if ($updatedPy !== $pyContent) {
|
||||
file_put_contents($pyprojectFile, $updatedPy);
|
||||
fwrite(STDERR, "Updated pyproject.toml\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Update CHANGELOG.md --
|
||||
$changelogFile = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($changelogFile)) {
|
||||
$clContent = file_get_contents($changelogFile);
|
||||
$updatedCl = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m',
|
||||
'${1}' . $newFull,
|
||||
$clContent
|
||||
);
|
||||
if ($updatedCl !== null && $updatedCl !== $clContent) {
|
||||
file_put_contents($changelogFile, $updatedCl);
|
||||
fwrite(STDERR, "Updated CHANGELOG.md\n");
|
||||
}
|
||||
}
|
||||
|
||||
// -- Generic VERSION: pattern scan across all text files --
|
||||
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
|
||||
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
|
||||
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
||||
|
||||
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) {
|
||||
if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
$iterator = new RecursiveIteratorIterator($filter);
|
||||
|
||||
$genericUpdated = [];
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if ($fileInfo->isDir()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower($fileInfo->getExtension());
|
||||
if (!in_array($ext, $scanExtensions, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $fileInfo->getPathname();
|
||||
|
||||
// Skip files already handled above
|
||||
$relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
|
||||
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($relPath, $updatedFiles ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip synced files — they have their own version managed by their source repo
|
||||
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content);
|
||||
if ($updated !== null && $updated !== $content) {
|
||||
file_put_contents($filePath, $updated);
|
||||
$genericUpdated[] = $relPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($genericUpdated)) {
|
||||
fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n");
|
||||
}
|
||||
|
||||
echo "{$old} -> {$newFull}\n";
|
||||
exit(0);
|
||||
$app = new VersionBumpCli();
|
||||
exit($app->execute());
|
||||
|
||||
+183
-211
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,224 +11,195 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump_remote.php
|
||||
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump patch --token TOKEN --api-base URL
|
||||
* php version_bump_remote.php --path . --branch dev --bump minor --no-changelog --token TOKEN --api-base URL
|
||||
*
|
||||
* Options:
|
||||
* --path Repository root (reads current version from local manifest)
|
||||
* --branch Target branch to bump (required, e.g. dev)
|
||||
* --bump Bump type: patch | minor | major (default: minor)
|
||||
* --token Gitea API token (or MOKOGITEA_TOKEN env var)
|
||||
* --api-base Gitea API base URL for the repo
|
||||
* --no-changelog Skip CHANGELOG.md bump
|
||||
* --repo Repository path (owner/repo) for API base construction
|
||||
* --gitea-url Gitea instance URL (default: env GITEA_URL)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$branch = null;
|
||||
$bumpType = 'minor';
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$noChangelog = false;
|
||||
$repo = null;
|
||||
$giteaUrl = null;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1];
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1];
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1];
|
||||
if ($arg === '--no-changelog') $noChangelog = true;
|
||||
if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1];
|
||||
if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1];
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
|
||||
if ($apiBase === null && $repo !== null) {
|
||||
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
|
||||
}
|
||||
|
||||
if ($branch === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]\n");
|
||||
fwrite(STDERR, " or: version_bump_remote.php --branch BRANCH --token TOKEN --repo owner/repo\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read current version from local manifest ────────────────────────────
|
||||
$version = null;
|
||||
$manifestFile = null;
|
||||
|
||||
$searchDirs = ["{$root}/src", $root];
|
||||
foreach ($searchDirs as $dir) {
|
||||
if (!is_dir($dir)) continue;
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||
$version = $m[1];
|
||||
$manifestFile = basename($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compute next version ────────────────────────────────────────────────
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
||||
fwrite(STDERR, "Invalid version format: {$version}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
|
||||
switch ($bumpType) {
|
||||
case 'major': $major++; $minor = 0; $patch = 0; break;
|
||||
case 'minor': $minor++; $patch = 0; break;
|
||||
default: $patch++; break;
|
||||
}
|
||||
|
||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||
|
||||
// ── Helper: Gitea API request ───────────────────────────────────────────
|
||||
function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||
class VersionBumpRemoteCli extends CliFramework
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--branch', 'Target branch to bump (required)', null);
|
||||
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
|
||||
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
|
||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
|
||||
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
|
||||
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
|
||||
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
|
||||
}
|
||||
|
||||
if ($httpCode >= 400 || $response === false) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$bumpType = $this->getArgument('--bump');
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$noChangelog = (bool) $this->getArgument('--no-changelog');
|
||||
$repo = $this->getArgument('--repo');
|
||||
$giteaUrl = $this->getArgument('--gitea-url');
|
||||
if ($token === null) {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
if ($giteaUrl === null) {
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
}
|
||||
if ($apiBase === null && $repo !== null) {
|
||||
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
|
||||
}
|
||||
if ($branch === null || $token === null || $apiBase === null) {
|
||||
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
|
||||
return 1;
|
||||
}
|
||||
$root = realpath($path) ?: $path;
|
||||
$version = null;
|
||||
$manifestFile = null;
|
||||
foreach (["{$root}/src", $root] as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
|
||||
$xml = file_get_contents($f);
|
||||
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
|
||||
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) {
|
||||
if ($version === null || version_compare($m[1], $version, '>')) {
|
||||
$version = $m[1];
|
||||
$manifestFile = basename($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($version === null) {
|
||||
$this->log('ERROR', "No version found in manifest XML");
|
||||
return 1;
|
||||
}
|
||||
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) {
|
||||
$this->log('ERROR', "Invalid version format: {$version}");
|
||||
return 1;
|
||||
}
|
||||
$major = (int)$parts[1];
|
||||
$minor = (int)$parts[2];
|
||||
$patch = (int)$parts[3];
|
||||
switch ($bumpType) {
|
||||
case 'major':
|
||||
$major++;
|
||||
$minor = 0;
|
||||
$patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
$minor++;
|
||||
$patch = 0;
|
||||
break;
|
||||
default:
|
||||
$patch++;
|
||||
break;
|
||||
}
|
||||
$nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
|
||||
echo "{$version} -> {$nextVersion} ({$branch})\n";
|
||||
|
||||
// Try both source/ and src/ paths for backwards compatibility with remote repos
|
||||
$manifestPaths = [];
|
||||
foreach (['source', 'src'] as $srcPrefix) {
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "{$srcPrefix}/{$manifestFile}";
|
||||
}
|
||||
$manifestPaths[] = "{$srcPrefix}/templateDetails.xml";
|
||||
$manifestPaths[] = "{$srcPrefix}/manifest.xml";
|
||||
}
|
||||
$manifestUpdated = false;
|
||||
foreach ($manifestPaths as $mPath) {
|
||||
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
|
||||
return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
|
||||
}, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
|
||||
if ($result) {
|
||||
$manifestUpdated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$manifestUpdated) {
|
||||
$this->log('WARN', "could not update manifest on {$branch}");
|
||||
}
|
||||
if (!$noChangelog) {
|
||||
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
|
||||
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
|
||||
$marker = "## [{$version}]";
|
||||
if (strpos($content, $marker) !== false) {
|
||||
$header = "## [{$nextVersion}] - Unreleased\n\n"
|
||||
. "### Added\n\n### Changed\n\n"
|
||||
. "### Fixed\n\n";
|
||||
$content = str_replace(
|
||||
$marker,
|
||||
$header . $marker,
|
||||
$content
|
||||
);
|
||||
}
|
||||
}
|
||||
return $content;
|
||||
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($httpCode >= 400 || $response === false) {
|
||||
return null;
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
private function updateRemoteFile(
|
||||
string $apiBase,
|
||||
string $token,
|
||||
string $filePath,
|
||||
string $branch,
|
||||
callable $transform,
|
||||
string $commitMessage
|
||||
): bool {
|
||||
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
|
||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||
return false;
|
||||
}
|
||||
$content = base64_decode($file['content']);
|
||||
$newContent = $transform($content);
|
||||
if ($newContent === $content) {
|
||||
$this->log('INFO', "{$filePath}: no changes needed");
|
||||
return true;
|
||||
}
|
||||
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
|
||||
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||
if ($result === null) {
|
||||
$this->log('ERROR', "{$filePath}: failed to update");
|
||||
return false;
|
||||
}
|
||||
echo " {$filePath}: updated on {$branch}\n";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: Update a file on a remote branch ────────────────────────────
|
||||
function updateRemoteFile(
|
||||
string $apiBase,
|
||||
string $token,
|
||||
string $filePath,
|
||||
string $branch,
|
||||
callable $transform,
|
||||
string $commitMessage
|
||||
): bool {
|
||||
$url = "{$apiBase}/contents/{$filePath}?ref={$branch}";
|
||||
$file = giteaApi('GET', $url, $token);
|
||||
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = base64_decode($file['content']);
|
||||
$newContent = $transform($content);
|
||||
|
||||
if ($newContent === $content) {
|
||||
fwrite(STDERR, " {$filePath}: no changes needed\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => base64_encode($newContent),
|
||||
'sha' => $file['sha'],
|
||||
'message' => $commitMessage,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$result = giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
|
||||
if ($result === null) {
|
||||
fwrite(STDERR, " {$filePath}: failed to update\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
echo " {$filePath}: updated on {$branch}\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Update manifest XML on the remote branch ────────────────────────────
|
||||
$manifestPaths = [];
|
||||
if ($manifestFile !== null) {
|
||||
$manifestPaths[] = "src/{$manifestFile}";
|
||||
}
|
||||
$manifestPaths = array_merge($manifestPaths, [
|
||||
'src/templateDetails.xml',
|
||||
'src/manifest.xml',
|
||||
]);
|
||||
|
||||
$manifestUpdated = false;
|
||||
foreach ($manifestPaths as $mPath) {
|
||||
$result = updateRemoteFile(
|
||||
$apiBase, $token, $mPath, $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
return str_replace(
|
||||
"<version>{$version}</version>",
|
||||
"<version>{$nextVersion}</version>",
|
||||
$content
|
||||
);
|
||||
},
|
||||
"chore(version): bump {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
if ($result) {
|
||||
$manifestUpdated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$manifestUpdated) {
|
||||
fwrite(STDERR, "WARNING: could not update manifest on {$branch}\n");
|
||||
}
|
||||
|
||||
// ── Update CHANGELOG.md on the remote branch ────────────────────────────
|
||||
if (!$noChangelog) {
|
||||
updateRemoteFile(
|
||||
$apiBase, $token, 'CHANGELOG.md', $branch,
|
||||
function (string $content) use ($version, $nextVersion): string {
|
||||
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
|
||||
|
||||
if (strpos($content, '[Unreleased]') === false
|
||||
&& strpos($content, "## [{$nextVersion}]") === false
|
||||
) {
|
||||
$marker = "## [{$version}]";
|
||||
if (strpos($content, $marker) !== false) {
|
||||
$unreleased = "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n";
|
||||
$content = str_replace($marker, $unreleased . $marker, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
},
|
||||
"chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"
|
||||
);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new VersionBumpRemoteCli();
|
||||
exit($app->execute());
|
||||
|
||||
+168
-202
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,230 +10,195 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_check.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*
|
||||
* Usage:
|
||||
* php version_check.php --path /repo
|
||||
* php version_check.php --path /repo --strict # exit 1 on mismatch
|
||||
* php version_check.php --path /repo --fix # fix mismatches to highest version
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$strict = false;
|
||||
$fix = false;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--strict') $strict = true;
|
||||
if ($arg === '--fix') $fix = true;
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$versions = [];
|
||||
class VersionCheckCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
|
||||
$this->addArgument('--path', 'Repository root', '.');
|
||||
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
|
||||
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
|
||||
}
|
||||
|
||||
// ── Read .mokogitea/manifest.xml (canonical) ────────────────────────────────
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$v = (string)($xml->identity->version ?? '');
|
||||
$base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
|
||||
$versions['.mokogitea/manifest.xml'] = $base;
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$strict = (bool) $this->getArgument('--strict');
|
||||
$fix = (bool) $this->getArgument('--fix');
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$versions = [];
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$v = (string)($xml->identity->version ?? '');
|
||||
$base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
|
||||
$versions['.mokogitea/manifest.xml'] = $base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read README.md version ───────────────────────────────────────────────────
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['README.md'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read CHANGELOG.md version ───────────────────────────────────────────────
|
||||
$changelog = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($changelog)) {
|
||||
$content = file_get_contents($changelog);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['CHANGELOG.md'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read package.json version ───────────────────────────────────────────────
|
||||
$packageJson = "{$root}/package.json";
|
||||
if (file_exists($packageJson)) {
|
||||
$pkg = json_decode(file_get_contents($packageJson), true);
|
||||
if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
|
||||
$versions['package.json'] = $pkg['version'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read pyproject.toml version ─────────────────────────────────────────────
|
||||
$pyproject = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyproject)) {
|
||||
$content = file_get_contents($pyproject);
|
||||
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) {
|
||||
$versions['pyproject.toml'] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read manifest XML versions ───────────────────────────────────────────────
|
||||
$xmlGlobs = [
|
||||
"{$root}/src/pkg_*.xml",
|
||||
"{$root}/src/*.xml",
|
||||
"{$root}/src/packages/*/*.xml",
|
||||
"{$root}/*.xml",
|
||||
];
|
||||
|
||||
foreach ($xmlGlobs as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
// Skip updates.xml
|
||||
if (basename($file) === 'updates.xml') continue;
|
||||
|
||||
$xmlContent = file_get_contents($file);
|
||||
if (strpos($xmlContent, '<extension') === false) continue;
|
||||
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
$relPath = str_replace($root . '\\', '', $relPath);
|
||||
$versions[$relPath] = $xm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
fwrite(STDERR, "No version sources found\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Compare versions ─────────────────────────────────────────────────────────
|
||||
$uniqueVersions = array_unique(array_values($versions));
|
||||
$highestVersion = '00.00.00';
|
||||
foreach ($versions as $v) {
|
||||
if (version_compare($v, $highestVersion, '>')) {
|
||||
$highestVersion = $v;
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== Version Consistency Check ===\n";
|
||||
foreach ($versions as $source => $ver) {
|
||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||
if ($status === 'MISMATCH') $errors++;
|
||||
echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||
}
|
||||
|
||||
if (count($uniqueVersions) === 1) {
|
||||
echo "\nAll {$ver} — consistent.\n";
|
||||
} else {
|
||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||
|
||||
if ($fix) {
|
||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||
|
||||
// Fix README.md
|
||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
$updated = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $highestVersion,
|
||||
$content,
|
||||
1
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($readme, $updated);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['README.md'] = $m[1];
|
||||
}
|
||||
echo " Fixed: README.md -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix .mokogitea/manifest.xml
|
||||
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) {
|
||||
$content = file_get_contents($mokoManifest);
|
||||
$updated = preg_replace(
|
||||
'#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#',
|
||||
"<version>{$highestVersion}</version>",
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($mokoManifest, $updated);
|
||||
}
|
||||
echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix CHANGELOG.md
|
||||
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) {
|
||||
$changelog = "{$root}/CHANGELOG.md";
|
||||
if (file_exists($changelog)) {
|
||||
$content = file_get_contents($changelog);
|
||||
$updated = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m',
|
||||
'${1}' . $highestVersion,
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($changelog, $updated);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$versions['CHANGELOG.md'] = $m[1];
|
||||
}
|
||||
echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix package.json
|
||||
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) {
|
||||
$content = file_get_contents($packageJson);
|
||||
$updated = preg_replace(
|
||||
'/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m',
|
||||
'${1}' . $highestVersion . '${2}',
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($packageJson, $updated);
|
||||
$packageJson = "{$root}/package.json";
|
||||
if (file_exists($packageJson)) {
|
||||
$pkg = json_decode(file_get_contents($packageJson), true);
|
||||
if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
|
||||
$versions['package.json'] = $pkg['version'];
|
||||
}
|
||||
echo " Fixed: package.json -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix pyproject.toml
|
||||
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) {
|
||||
$pyproject = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyproject)) {
|
||||
$content = file_get_contents($pyproject);
|
||||
$updated = preg_replace(
|
||||
'/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m',
|
||||
'${1}' . $highestVersion . '${2}',
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($pyproject, $updated);
|
||||
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) {
|
||||
$versions['pyproject.toml'] = $m[1];
|
||||
}
|
||||
echo " Fixed: pyproject.toml -> {$highestVersion}\n";
|
||||
}
|
||||
|
||||
// Fix XML manifests
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
foreach (["{$root}/{$srcName}/pkg_*.xml", "{$root}/{$srcName}/*.xml", "{$root}/{$srcName}/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
|
||||
foreach (glob($glob) ?: [] as $file) {
|
||||
if (basename($file) === 'updates.xml') {
|
||||
continue;
|
||||
}
|
||||
$xmlContent = file_get_contents($file);
|
||||
if (strpos($xmlContent, '<extension') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
|
||||
$relPath = str_replace([$root . '/', $root . '\\'], '', $file);
|
||||
$versions[$relPath] = $xm[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($versions)) {
|
||||
$this->log('ERROR', "No version sources found");
|
||||
return 1;
|
||||
}
|
||||
$uniqueVersions = array_unique(array_values($versions));
|
||||
$highestVersion = '00.00.00';
|
||||
foreach ($versions as $v) {
|
||||
if (version_compare($v, $highestVersion, '>')) {
|
||||
$highestVersion = $v;
|
||||
}
|
||||
}
|
||||
echo "=== Version Consistency Check ===\n";
|
||||
foreach ($versions as $source => $ver) {
|
||||
if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) continue;
|
||||
if ($ver === $highestVersion) continue;
|
||||
|
||||
$file = "{$root}/{$source}";
|
||||
if (!file_exists($file)) continue;
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$updated = preg_replace(
|
||||
'#<version>[^<]*</version>#',
|
||||
"<version>{$highestVersion}</version>",
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($file, $updated);
|
||||
}
|
||||
echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||
$status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH';
|
||||
if ($status === 'MISMATCH') {
|
||||
$errors++;
|
||||
} echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})");
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
if (count($uniqueVersions) === 1) {
|
||||
echo "\nAll {$ver} -- consistent.\n";
|
||||
} else {
|
||||
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n";
|
||||
if ($fix) {
|
||||
echo "\n=== Fixing mismatches to {$highestVersion} ===\n";
|
||||
if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) {
|
||||
$content = file_get_contents($readme);
|
||||
$updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($readme, $updated);
|
||||
} echo " Fixed: README.md -> {$highestVersion}\n";
|
||||
}
|
||||
if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) {
|
||||
$content = file_get_contents($mokoManifest);
|
||||
$vPat = '#<version>\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
|
||||
$updated = preg_replace(
|
||||
$vPat,
|
||||
"<version>{$highestVersion}</version>",
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($mokoManifest, $updated);
|
||||
} echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n";
|
||||
}
|
||||
if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) {
|
||||
$content = file_get_contents($changelog);
|
||||
$clPat = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
|
||||
$updated = preg_replace(
|
||||
$clPat,
|
||||
'${1}' . $highestVersion,
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($changelog, $updated);
|
||||
} echo " Fixed: CHANGELOG.md -> {$highestVersion}\n";
|
||||
}
|
||||
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) {
|
||||
$content = file_get_contents($packageJson);
|
||||
$pkPat = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||
$updated = preg_replace(
|
||||
$pkPat,
|
||||
'${1}' . $highestVersion . '${2}',
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($packageJson, $updated);
|
||||
} echo " Fixed: package.json -> {$highestVersion}\n";
|
||||
}
|
||||
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) {
|
||||
$content = file_get_contents($pyproject);
|
||||
$pyPat = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}'
|
||||
. '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m';
|
||||
$updated = preg_replace(
|
||||
$pyPat,
|
||||
'${1}' . $highestVersion . '${2}',
|
||||
$content
|
||||
);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($pyproject, $updated);
|
||||
} echo " Fixed: pyproject.toml -> {$highestVersion}\n";
|
||||
}
|
||||
foreach ($versions as $source => $ver) {
|
||||
if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) {
|
||||
continue;
|
||||
} if ($ver === $highestVersion) {
|
||||
continue;
|
||||
} $file = "{$root}/{$source}";
|
||||
if (!file_exists($file)) {
|
||||
continue;
|
||||
} $content = file_get_contents($file);
|
||||
$updated = preg_replace('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content);
|
||||
if ($updated !== null) {
|
||||
file_put_contents($file, $updated);
|
||||
} echo " Fixed: {$source} -> {$highestVersion}\n";
|
||||
}
|
||||
echo "Done.\n";
|
||||
}
|
||||
}
|
||||
if ($strict && $errors > 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($strict && $errors > 0) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new VersionCheckCli();
|
||||
exit($app->execute());
|
||||
|
||||
+157
-117
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -14,131 +15,170 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionReadCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Read version — manifest.xml is canonical, falls back to README.md and Joomla XML');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
}
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
|
||||
$mokoVersion = null;
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$v = (string)($xml->identity->version ?? '');
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?$/', $v)) {
|
||||
$mokoVersion = $v;
|
||||
// -- 1. Read from .mokogitea/manifest.xml (canonical source) --
|
||||
$mokoVersion = null;
|
||||
$versionPrefix = '';
|
||||
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($mokoManifest)) {
|
||||
$xml = @simplexml_load_file($mokoManifest);
|
||||
if ($xml !== false) {
|
||||
$v = (string)($xml->identity->version ?? '');
|
||||
if (preg_match('/^\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?$/', $v)) {
|
||||
$mokoVersion = $v;
|
||||
}
|
||||
// Read version_prefix (supports both nested and flat structure)
|
||||
$prefix = (string)($xml->identity->version_prefix ?? '');
|
||||
if ($prefix === '') {
|
||||
$prefix = (string)($xml->version_prefix ?? '');
|
||||
}
|
||||
$versionPrefix = $prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If manifest.xml has a version, that is authoritative
|
||||
if ($mokoVersion !== null) {
|
||||
echo $mokoVersion . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// -- 2. Fallback: README.md --
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
glob("{$root}/src/pkg_*.xml") ?: [],
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?)</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
$candidateBase = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $candidate);
|
||||
$currentBase = $manifestVersion ? preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $manifestVersion) : null;
|
||||
if ($currentBase === null || version_compare($candidateBase, $currentBase, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
// If manifest.xml has a version, that is authoritative
|
||||
if ($mokoVersion !== null) {
|
||||
echo $mokoVersion . "\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4. Fallback: package.json (Node.js / MCP) --
|
||||
$packageJsonVersion = null;
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgData = json_decode(file_get_contents($packageJsonFile), true);
|
||||
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
|
||||
$packageJsonVersion = $pkgData['version'];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 5. Fallback: pyproject.toml (Python) --
|
||||
$pyprojectVersion = null;
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
|
||||
$pyprojectVersion = $pm[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- Output the higher version --
|
||||
$candidates = array_filter([
|
||||
$readmeVersion,
|
||||
$manifestVersion,
|
||||
$packageJsonVersion,
|
||||
$pyprojectVersion,
|
||||
]);
|
||||
|
||||
$version = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($version === null || version_compare($candidate, $version, '>')) {
|
||||
$version = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// -- Backfill: if manifest.xml exists but lacks <version>, insert it --
|
||||
if (file_exists($mokoManifest)) {
|
||||
$content = file_get_contents($mokoManifest);
|
||||
if (!preg_match('#<version>\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?</version>#', $content)) {
|
||||
if (strpos($content, '<license') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(\s*<license)|',
|
||||
"\n <version>{$version}</version>\$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
} elseif (strpos($content, '</identity>') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(</identity>)|',
|
||||
" <version>{$version}</version>\n \$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
// -- 2. Fallback: README.md --
|
||||
$readmeVersion = null;
|
||||
$readme = "{$root}/README.md";
|
||||
if (file_exists($readme)) {
|
||||
$content = file_get_contents($readme);
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware: search for prefix followed by version
|
||||
$prefixPattern = preg_quote($versionPrefix, '/');
|
||||
if (preg_match('/' . $prefixPattern . '(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
if ($readmeVersion === null && preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
||||
$readmeVersion = $m[1];
|
||||
}
|
||||
}
|
||||
file_put_contents($mokoManifest, $content);
|
||||
fwrite(STDERR, "Backfilled manifest.xml with version {$version}\n");
|
||||
|
||||
// -- 3. Fallback: Joomla manifest XML --
|
||||
$manifestVersion = null;
|
||||
$manifestFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
SourceResolver::globSource($root, 'packages/*/*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
foreach ($manifestFiles as $xmlFile) {
|
||||
$xmlContent = file_get_contents($xmlFile);
|
||||
if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($versionPrefix)) {
|
||||
// Prefix-aware: look for <version>prefix + XX.YY.ZZ</version>
|
||||
$prefixPattern = preg_quote($versionPrefix, '#');
|
||||
if (preg_match('#<version>' . $prefixPattern . '(\d{2}\.\d{2}\.\d{2})</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
$currentBase = $manifestVersion ? preg_replace('/(-(?:dev|alpha|beta|rc))+$/', '', $manifestVersion) : null;
|
||||
if ($currentBase === null || version_compare($candidate, $currentBase, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?)</version>#', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
$candidateBase = preg_replace('/(-(?:dev|alpha|beta|rc))+$/', '', $candidate);
|
||||
$currentBase = $manifestVersion ? preg_replace('/(-(?:dev|alpha|beta|rc))+$/', '', $manifestVersion) : null;
|
||||
if ($currentBase === null || version_compare($candidateBase, $currentBase, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4. Fallback: package.json (Node.js / MCP) --
|
||||
$packageJsonVersion = null;
|
||||
$packageJsonFile = "{$root}/package.json";
|
||||
if (file_exists($packageJsonFile)) {
|
||||
$pkgData = json_decode(file_get_contents($packageJsonFile), true);
|
||||
if (isset($pkgData['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkgData['version'])) {
|
||||
$packageJsonVersion = $pkgData['version'];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 5. Fallback: pyproject.toml (Python) --
|
||||
$pyprojectVersion = null;
|
||||
$pyprojectFile = "{$root}/pyproject.toml";
|
||||
if (file_exists($pyprojectFile)) {
|
||||
$pyContent = file_get_contents($pyprojectFile);
|
||||
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $pyContent, $pm)) {
|
||||
$pyprojectVersion = $pm[1];
|
||||
}
|
||||
}
|
||||
|
||||
// -- Output the higher version --
|
||||
$candidates = array_filter([
|
||||
$readmeVersion,
|
||||
$manifestVersion,
|
||||
$packageJsonVersion,
|
||||
$pyprojectVersion,
|
||||
]);
|
||||
|
||||
$version = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($version === null || version_compare($candidate, $version, '>')) {
|
||||
$version = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
$this->log('ERROR', 'No version found in manifest.xml, README.md, Joomla XML, package.json, or pyproject.toml');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// -- Backfill: if manifest.xml exists but lacks <version>, insert it --
|
||||
if (file_exists($mokoManifest)) {
|
||||
$content = file_get_contents($mokoManifest);
|
||||
if (!preg_match('#<version>\d{2}\.\d{2}\.\d{2}((?:-(?:dev|alpha|beta|rc))+)?</version>#', $content)) {
|
||||
if (strpos($content, '<license') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(\s*<license)|',
|
||||
"\n <version>{$version}</version>\$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
} elseif (strpos($content, '</identity>') !== false) {
|
||||
$content = preg_replace(
|
||||
'|(</identity>)|',
|
||||
" <version>{$version}</version>\n \$1",
|
||||
$content,
|
||||
1
|
||||
);
|
||||
}
|
||||
file_put_contents($mokoManifest, $content);
|
||||
$this->log('ERROR', "Backfilled manifest.xml with version {$version}");
|
||||
}
|
||||
}
|
||||
|
||||
echo $version . "\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
echo $version . "\n";
|
||||
exit(0);
|
||||
$app = new VersionReadCli();
|
||||
exit($app->execute());
|
||||
|
||||
+201
-269
@@ -11,309 +11,241 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_reset_dev.php
|
||||
* BRIEF: Reset platform version to 'development' on a branch via Gitea API
|
||||
*
|
||||
* Usage:
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --branch dev
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --platform dolibarr
|
||||
* php version_reset_dev.php --token TOKEN --api-base URL --path /repo/root
|
||||
*
|
||||
* This replaces the inline curl+python3+sed block previously used in
|
||||
* auto-release.yml to reset Dolibarr's $this->version on the dev branch
|
||||
* after a stable release.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// ── Argument parsing ─────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
$branch = 'dev';
|
||||
$platform = null;
|
||||
$path = null;
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--token' && isset($argv[$i + 1])) {
|
||||
$token = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--api-base' && isset($argv[$i + 1])) {
|
||||
$apiBase = rtrim($argv[$i + 1], '/');
|
||||
}
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) {
|
||||
$branch = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--platform' && isset($argv[$i + 1])) {
|
||||
$platform = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) {
|
||||
$path = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--help' || $arg === '-h') {
|
||||
printUsage();
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$envToken = getenv('MOKOGITEA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
if ($token === null) {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
if ($token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Error: --token and --api-base are required.\n\n");
|
||||
printUsage();
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
if ($platform === null && $path !== null) {
|
||||
$platform = detectPlatform($path);
|
||||
if ($platform !== null) {
|
||||
echo "Detected platform: {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($platform === null) {
|
||||
fwrite(STDERR, "Error: could not determine platform. Use --platform or --path.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Dispatch by platform ─────────────────────────────────────────────────────
|
||||
|
||||
$changed = 0;
|
||||
|
||||
if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
|
||||
$changed = resetDolibarrVersion($apiBase, $token, $branch);
|
||||
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
|
||||
echo "Joomla version reset is not yet implemented — skipping.\n";
|
||||
} else {
|
||||
echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
|
||||
}
|
||||
|
||||
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
|
||||
exit(0);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Helper functions
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Print usage information to stdout.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function printUsage(): void
|
||||
class VersionResetDevCli extends CliFramework
|
||||
{
|
||||
echo <<<'USAGE'
|
||||
Reset platform version to 'development' on a branch via Gitea API.
|
||||
|
||||
Usage:
|
||||
php version_reset_dev.php --token TOKEN --api-base URL [options]
|
||||
|
||||
Required:
|
||||
--token TOKEN Gitea API token (also reads MOKOGITEA_TOKEN / GITEA_TOKEN env)
|
||||
--api-base URL Gitea API base URL for the repo
|
||||
e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo
|
||||
|
||||
Options:
|
||||
--branch BRANCH Target branch (default: dev)
|
||||
--platform TYPE Platform type: dolibarr, crm-module, joomla, waas-component
|
||||
--path DIR Repo root for auto-detecting platform from manifest.xml
|
||||
--help Show this help
|
||||
|
||||
USAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the platform type from a repo's .mokogitea/manifest.xml file.
|
||||
*
|
||||
* @param string $repoPath Path to the repository root
|
||||
* @return string|null The detected platform, or null if detection fails
|
||||
*/
|
||||
function detectPlatform(string $repoPath): ?string
|
||||
{
|
||||
$root = realpath($repoPath) ?: $repoPath;
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestXml)) {
|
||||
return null;
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Reset platform version to development on a branch via Gitea API');
|
||||
$this->addArgument('--token', 'Gitea API token (also reads MOKOGITEA_TOKEN / GITEA_TOKEN env)', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL for the repo', '');
|
||||
$this->addArgument('--branch', 'Target branch (default: dev)', 'dev');
|
||||
$this->addArgument('--platform', 'Platform type: dolibarr, crm-module, joomla, waas-component', '');
|
||||
$this->addArgument('--path', 'Repo root for auto-detecting platform from manifest.xml', '');
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($manifestXml);
|
||||
if ($xml === false) {
|
||||
return null;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$token = $this->getArgument('--token');
|
||||
$apiBase = $this->getArgument('--api-base');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$platform = $this->getArgument('--platform');
|
||||
$path = $this->getArgument('--path');
|
||||
|
||||
if (isset($xml->governance->platform)) {
|
||||
$platform = (string) $xml->governance->platform;
|
||||
if ($platform !== '') {
|
||||
return $platform;
|
||||
// Allow token from environment
|
||||
if ($token === '') {
|
||||
$envToken = getenv('MOKOGITEA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
if ($token === '') {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if ($token === '' || $apiBase === '') {
|
||||
$this->log('ERROR', '--token and --api-base are required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a Gitea API call and return the decoded JSON response.
|
||||
*
|
||||
* @param string $url Full API URL
|
||||
* @param string $token Gitea API token
|
||||
* @param string $method HTTP method (GET, PUT, POST, DELETE)
|
||||
* @param string|null $body JSON request body, or null for bodiless requests
|
||||
* @return array<string, mixed>|null Decoded JSON response, or null on failure
|
||||
*/
|
||||
function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
fwrite(STDERR, "Error: curl_init() failed for {$url}\n");
|
||||
return null;
|
||||
}
|
||||
$apiBase = rtrim($apiBase, '/');
|
||||
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/json',
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
if ($platform === '' && $path !== '') {
|
||||
$platform = $this->detectPlatform($path) ?? '';
|
||||
if ($platform !== '') {
|
||||
echo "Detected platform: {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
if ($platform === '') {
|
||||
$this->log('ERROR', 'Could not determine platform. Use --platform or --path.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
// ── Dispatch by platform ─────────────────────────────────────────────────────
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||
return null;
|
||||
}
|
||||
$changed = 0;
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
if (in_array($platform, ['dolibarr', 'crm-module'], true)) {
|
||||
$changed = $this->resetDolibarrVersion($apiBase, $token, $branch);
|
||||
} elseif (in_array($platform, ['joomla', 'waas-component'], true)) {
|
||||
echo "Joomla version reset is not yet implemented — skipping.\n";
|
||||
} else {
|
||||
echo "Platform '{$platform}' has no version-reset logic — skipping.\n";
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Dolibarr module version to 'development' on the target branch.
|
||||
*
|
||||
* Searches the repository tree for mod*.class.php files that contain
|
||||
* `extends DolibarrModules`, then replaces `$this->version = '...'`
|
||||
* with `$this->version = 'development'` via the Gitea file contents API.
|
||||
*
|
||||
* @param string $apiBase Gitea API base URL for the repo
|
||||
* @param string $token Gitea API token
|
||||
* @param string $branch Target branch name
|
||||
* @return int Number of files modified
|
||||
*/
|
||||
function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
|
||||
{
|
||||
// Search the repo tree for mod*.class.php files
|
||||
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
|
||||
$tree = giteaApiCall($treeUrl, $token);
|
||||
|
||||
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
|
||||
fwrite(STDERR, "Error: could not read repository tree for branch '{$branch}'.\n");
|
||||
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find candidate files: mod*.class.php anywhere in the tree
|
||||
$candidates = [];
|
||||
foreach ($tree['tree'] as $entry) {
|
||||
if (!isset($entry['path']) || !is_string($entry['path'])) {
|
||||
continue;
|
||||
private function detectPlatform(string $repoPath): ?string
|
||||
{
|
||||
$root = realpath($repoPath) ?: $repoPath;
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestXml)) {
|
||||
return null;
|
||||
}
|
||||
$basename = basename($entry['path']);
|
||||
if (preg_match('/^mod[A-Za-z0-9_]+\.class\.php$/', $basename)) {
|
||||
$candidates[] = $entry['path'];
|
||||
|
||||
$xml = @simplexml_load_file($manifestXml);
|
||||
if ($xml === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($xml->governance->platform)) {
|
||||
$platform = (string) $xml->governance->platform;
|
||||
if ($platform !== '') {
|
||||
return $platform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
echo "No mod*.class.php files found on branch '{$branch}'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($candidates as $filePath) {
|
||||
// GET file contents via API
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
|
||||
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
|
||||
$fileData = giteaApiCall($fileUrl, $token);
|
||||
|
||||
if ($fileData === null || !isset($fileData['content'])) {
|
||||
echo "Skipping {$filePath}: could not fetch contents.\n";
|
||||
continue;
|
||||
private function giteaApiCall(string $url, string $token, string $method = 'GET', ?string $body = null): ?array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
$this->log('ERROR', "curl_init() failed for {$url}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
$rawContent = is_string($fileData['content']) ? $fileData['content'] : '';
|
||||
$content = base64_decode($rawContent, true);
|
||||
if ($content === false) {
|
||||
echo "Skipping {$filePath}: could not decode content.\n";
|
||||
continue;
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/json',
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
|
||||
// Verify this file extends DolibarrModules
|
||||
if (!str_contains($content, 'extends DolibarrModules')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace $this->version = '...' with $this->version = 'development'
|
||||
$updated = preg_replace(
|
||||
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||
"\${1}'development'",
|
||||
$content
|
||||
);
|
||||
|
||||
if ($updated === null || $updated === $content) {
|
||||
echo "Skipping {$filePath}: no version change needed.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// PUT updated content back via API
|
||||
$sha = $fileData['sha'] ?? '';
|
||||
$putBody = json_encode([
|
||||
'content' => base64_encode($updated),
|
||||
'message' => 'chore(version): reset dev version [skip ci]',
|
||||
'branch' => $branch,
|
||||
'sha' => $sha,
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
|
||||
$putUrl = "{$apiBase}/contents/{$encodedPath}";
|
||||
$result = giteaApiCall($putUrl, $token, 'PUT', $putBody);
|
||||
|
||||
if ($result !== null) {
|
||||
echo "Reset: {$filePath} -> \$this->version = 'development'\n";
|
||||
$changed++;
|
||||
} else {
|
||||
fwrite(STDERR, "Error: failed to update {$filePath} on branch '{$branch}'.\n");
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
return $changed;
|
||||
private function resetDolibarrVersion(string $apiBase, string $token, string $branch): int
|
||||
{
|
||||
// Search the repo tree for mod*.class.php files
|
||||
$treeUrl = "{$apiBase}/git/trees/{$branch}?recursive=true";
|
||||
$tree = $this->giteaApiCall($treeUrl, $token);
|
||||
|
||||
if ($tree === null || !isset($tree['tree']) || !is_array($tree['tree'])) {
|
||||
$this->log('ERROR', "Could not read repository tree for branch '{$branch}'.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find candidate files: mod*.class.php anywhere in the tree
|
||||
$candidates = [];
|
||||
foreach ($tree['tree'] as $entry) {
|
||||
if (!isset($entry['path']) || !is_string($entry['path'])) {
|
||||
continue;
|
||||
}
|
||||
$basename = basename($entry['path']);
|
||||
if (preg_match('/^mod[A-Za-z0-9_]+\.class\.php$/', $basename)) {
|
||||
$candidates[] = $entry['path'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
echo "No mod*.class.php files found on branch '{$branch}'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($candidates as $filePath) {
|
||||
// GET file contents via API
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath)));
|
||||
$fileUrl = "{$apiBase}/contents/{$encodedPath}?ref={$branch}";
|
||||
$fileData = $this->giteaApiCall($fileUrl, $token);
|
||||
|
||||
if ($fileData === null || !isset($fileData['content'])) {
|
||||
echo "Skipping {$filePath}: could not fetch contents.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode base64 content
|
||||
$rawContent = is_string($fileData['content']) ? $fileData['content'] : '';
|
||||
$content = base64_decode($rawContent, true);
|
||||
if ($content === false) {
|
||||
echo "Skipping {$filePath}: could not decode content.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify this file extends DolibarrModules
|
||||
if (!str_contains($content, 'extends DolibarrModules')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace $this->version = '...' with $this->version = 'development'
|
||||
$updated = preg_replace(
|
||||
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||
"\${1}'development'",
|
||||
$content
|
||||
);
|
||||
|
||||
if ($updated === null || $updated === $content) {
|
||||
echo "Skipping {$filePath}: no version change needed.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// PUT updated content back via API
|
||||
$sha = $fileData['sha'] ?? '';
|
||||
$putBody = json_encode([
|
||||
'content' => base64_encode($updated),
|
||||
'message' => 'chore(version): reset dev version [skip ci]',
|
||||
'branch' => $branch,
|
||||
'sha' => $sha,
|
||||
]);
|
||||
|
||||
$putUrl = "{$apiBase}/contents/{$encodedPath}";
|
||||
$result = $this->giteaApiCall($putUrl, $token, 'PUT', $putBody);
|
||||
|
||||
if ($result !== null) {
|
||||
echo "Reset: {$filePath} -> \$this->version = 'development'\n";
|
||||
$changed++;
|
||||
} else {
|
||||
$this->log('ERROR', "Failed to update {$filePath} on branch '{$branch}'.");
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new VersionResetDevCli();
|
||||
exit($app->execute());
|
||||
|
||||
+161
-142
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -10,163 +11,181 @@
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_set_platform.php
|
||||
* BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)
|
||||
*
|
||||
* Usage:
|
||||
* php version_set_platform.php --path . --version 04.01.00
|
||||
* php version_set_platform.php --path . --version 04.01.00 --stability alpha
|
||||
*
|
||||
* When --stability is set to anything other than "stable", the suffix is
|
||||
* appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$version = null;
|
||||
$branch = null;
|
||||
$stability = 'stable';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1];
|
||||
if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1];
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1];
|
||||
}
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
// Auto-detect branch from git or GitHub env
|
||||
if ($branch === null) {
|
||||
$branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
if (empty($branch) || $branch === 'HEAD') {
|
||||
$branch = getenv('GITHUB_REF_NAME') ?: 'main';
|
||||
class VersionSetPlatformCli extends CliFramework
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Set version in platform-specific files (Dolibarr $this->version, Joomla <version>)');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Version string XX.YY.ZZ', '');
|
||||
$this->addArgument('--branch', 'Git branch name', '');
|
||||
$this->addArgument('--stability', 'Stability level (stable, dev, alpha, beta, rc)', 'stable');
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n");
|
||||
exit(1);
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$version = $this->getArgument('--version');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$stability = $this->getArgument('--stability');
|
||||
|
||||
// Strip any existing suffix(es) before applying the correct one
|
||||
$version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||
|
||||
// Append stability suffix for non-stable releases
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
];
|
||||
$suffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
if ($suffix !== '' && !str_ends_with($version, $suffix)) {
|
||||
$version .= $suffix;
|
||||
echo "Version with stability suffix: {$version}\n";
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Detect platform — check manifest.xml first, then legacy .mokostandards
|
||||
$platform = '';
|
||||
|
||||
// New format: .mokogitea/manifest.xml (XML with <platform> tag)
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$xml = @simplexml_load_file($manifestXml);
|
||||
if ($xml && isset($xml->governance->platform)) {
|
||||
$platform = (string) $xml->governance->platform;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: .mokostandards YAML file
|
||||
if (empty($platform)) {
|
||||
$mokoStandards = "{$root}/.github/.mokostandards";
|
||||
if (!file_exists($mokoStandards)) {
|
||||
$mokoStandards = "{$root}/.mokogitea/.mokostandards";
|
||||
}
|
||||
if (!file_exists($mokoStandards)) {
|
||||
$mokoStandards = "{$root}/.mokostandards";
|
||||
}
|
||||
if (file_exists($mokoStandards)) {
|
||||
$content = file_get_contents($mokoStandards);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$platform = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
// Dolibarr: $this->version + $this->url_last_version in mod*.class.php
|
||||
if ($platform === 'crm-module') {
|
||||
$pattern = "{$root}/src/core/modules/mod*.class.php";
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$content = file_get_contents($file);
|
||||
|
||||
// Set $this->version
|
||||
$updated = preg_replace(
|
||||
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||
"\${1}'{$version}'",
|
||||
$content
|
||||
);
|
||||
|
||||
// Rewrite $this->url_last_version to point to current branch
|
||||
if (preg_match('/\$this->url_last_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $updated, $urlMatch)) {
|
||||
$oldUrl = $urlMatch[1];
|
||||
// Replace the branch segment: .../BRANCH/update.txt
|
||||
$newUrl = preg_replace(
|
||||
'#(raw\.githubusercontent\.com/[^/]+/[^/]+/)[^/]+(/update\.json)#',
|
||||
"\${1}{$branch}\${2}",
|
||||
$oldUrl
|
||||
);
|
||||
if ($newUrl !== $oldUrl) {
|
||||
$updated = str_replace($oldUrl, $newUrl, $updated);
|
||||
echo "Dolibarr: url_last_version → {$branch}/update.txt\n";
|
||||
// Auto-detect branch from git or GitHub env
|
||||
if ($branch === '') {
|
||||
$branch = trim((string) @shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null'));
|
||||
if (empty($branch) || $branch === 'HEAD') {
|
||||
$branch = getenv('GITHUB_REF_NAME') ?: 'main';
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
echo "Dolibarr: " . basename($file) . " → version={$version}, branch={$branch}\n";
|
||||
$changed++;
|
||||
if ($version === '') {
|
||||
$this->log('ERROR', 'Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
||||
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||
$xmlFiles = array_merge(
|
||||
glob("{$root}/src/*.xml") ?: [],
|
||||
glob("{$root}/src/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
if (empty($xmlFiles)) {
|
||||
$xmlFiles = glob("{$root}/*.xml") ?: [];
|
||||
}
|
||||
foreach ($xmlFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if (!str_contains($content, '<extension')) continue;
|
||||
$updated = preg_replace(
|
||||
'|<version>[^<]*</version>|',
|
||||
"<version>{$version}</version>",
|
||||
$content
|
||||
);
|
||||
if ($updated !== null && $updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
echo "Joomla: {$relPath} → {$version}\n";
|
||||
$changed++;
|
||||
// Strip any existing suffix(es) before applying the correct one
|
||||
$version = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version);
|
||||
|
||||
// Validate version format — must be XX.YY.ZZ to prevent XML corruption
|
||||
if (!preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $version)) {
|
||||
$this->log('ERROR', "Invalid version format: '{$version}' — expected XX.YY.ZZ");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append stability suffix for non-stable releases
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
'alpha' => '-alpha',
|
||||
'beta' => '-beta',
|
||||
'rc' => '-rc',
|
||||
'release-candidate' => '-rc',
|
||||
];
|
||||
$suffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
if ($suffix !== '' && !str_ends_with($version, $suffix)) {
|
||||
$version .= $suffix;
|
||||
echo "Version with stability suffix: {$version}\n";
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// Detect platform — check manifest.xml first, then legacy .mokostandards
|
||||
$platform = '';
|
||||
|
||||
// New format: .mokogitea/manifest.xml (XML with <platform> tag)
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
if (file_exists($manifestXml)) {
|
||||
$xml = @simplexml_load_file($manifestXml);
|
||||
if ($xml && isset($xml->governance->platform)) {
|
||||
$platform = (string) $xml->governance->platform;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: .mokostandards YAML file
|
||||
if (empty($platform)) {
|
||||
$mokoStandards = "{$root}/.github/.mokostandards";
|
||||
if (!file_exists($mokoStandards)) {
|
||||
$mokoStandards = "{$root}/.mokogitea/manifest.xml";
|
||||
}
|
||||
if (!file_exists($mokoStandards)) {
|
||||
$mokoStandards = "{$root}/.mokostandards";
|
||||
}
|
||||
if (file_exists($mokoStandards)) {
|
||||
$content = file_get_contents($mokoStandards);
|
||||
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
|
||||
$platform = trim($m[1], " \t\n\r\"'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
// Dolibarr: $this->version + $this->url_last_version in mod*.class.php
|
||||
if ($platform === 'crm-module') {
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
$pattern = "{$root}/{$srcName}/core/modules/mod*.class.php";
|
||||
foreach (glob($pattern) ?: [] as $file) {
|
||||
$content = file_get_contents($file);
|
||||
|
||||
// Set $this->version
|
||||
$updated = preg_replace(
|
||||
'/(\$this->version\s*=\s*)[\'"][^\'"]*[\'"]/',
|
||||
"\${1}'{$version}'",
|
||||
$content
|
||||
);
|
||||
|
||||
// Rewrite $this->url_last_version to point to current branch
|
||||
if (preg_match('/\$this->url_last_version\s*=\s*[\'"]([^\'"]+)[\'"]/', $updated, $urlMatch)) {
|
||||
$oldUrl = $urlMatch[1];
|
||||
// Replace the branch segment: .../BRANCH/update.txt
|
||||
$newUrl = preg_replace(
|
||||
'#(raw\.githubusercontent\.com/[^/]+/[^/]+/)[^/]+(/update\.json)#',
|
||||
"\${1}{$branch}\${2}",
|
||||
$oldUrl
|
||||
);
|
||||
if ($newUrl !== $oldUrl) {
|
||||
$updated = str_replace($oldUrl, $newUrl, $updated);
|
||||
echo "Dolibarr: url_last_version → {$branch}/update.txt\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
echo "Dolibarr: " . basename($file) . " → version={$version}, branch={$branch}\n";
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Joomla: <version> in XML manifests (top-level + sub-packages)
|
||||
if (in_array($platform, ['waas-component', 'joomla'], true)) {
|
||||
$srcName = SourceResolver::resolve($root);
|
||||
$xmlFiles = array_merge(
|
||||
glob("{$root}/{$srcName}/*.xml") ?: [],
|
||||
glob("{$root}/{$srcName}/packages/*/*.xml") ?: [],
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
if (empty($xmlFiles)) {
|
||||
$xmlFiles = glob("{$root}/*.xml") ?: [];
|
||||
}
|
||||
foreach ($xmlFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if (!str_contains($content, '<extension')) {
|
||||
continue;
|
||||
}
|
||||
$updated = preg_replace(
|
||||
'|<version>[^<]*</version>|',
|
||||
"<version>{$version}</version>",
|
||||
$content
|
||||
);
|
||||
if ($updated !== null && $updated !== $content) {
|
||||
file_put_contents($file, $updated);
|
||||
$relPath = str_replace($root . '/', '', $file);
|
||||
echo "Joomla: {$relPath} → {$version}\n";
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed === 0) {
|
||||
if (empty($platform)) {
|
||||
echo "No manifest.xml file — skipping platform version set\n";
|
||||
} else {
|
||||
echo "No platform-specific version files found for {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed === 0) {
|
||||
if (empty($platform)) {
|
||||
echo "No .mokostandards file — skipping platform version set\n";
|
||||
} else {
|
||||
echo "No platform-specific version files found for {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
$app = new VersionSetPlatformCli();
|
||||
exit($app->execute());
|
||||
|
||||
+60
-95
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@@ -9,13 +10,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/wiki_sync.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Sync select wiki pages from moko-platform to all template repos
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class WikiSync
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class WikiSyncCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
@@ -23,27 +28,52 @@ final class WikiSync
|
||||
private string $sourceRepo = 'moko-platform';
|
||||
private array $targetRepos = [];
|
||||
private array $pages = [];
|
||||
private bool $dryRun = false;
|
||||
private bool $allTemplates = false;
|
||||
private bool $allStandards = false;
|
||||
|
||||
private int $synced = 0;
|
||||
private int $created = 0;
|
||||
private int $skipped = 0;
|
||||
private int $errors = 0;
|
||||
|
||||
public function run(): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->parseArgs();
|
||||
$this->setDescription('Sync wiki pages from moko-platform to template repos');
|
||||
$this->addArgument('--token', 'Gitea API token (required)', '');
|
||||
$this->addArgument('--org', 'Organization (default: MokoConsulting)', 'MokoConsulting');
|
||||
$this->addArgument('--source', 'Source repo (default: moko-platform)', 'moko-platform');
|
||||
$this->addArgument('--target', 'Target repo (can repeat)', '');
|
||||
$this->addArgument('--page', 'Page to sync (can repeat)', '');
|
||||
$this->addArgument('--all-standards', 'Sync all UPPERCASE standards pages', false);
|
||||
$this->addArgument('--all-templates', 'Target all Template-* repos', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$this->token = $this->getArgument('--token');
|
||||
$this->org = $this->getArgument('--org');
|
||||
$this->sourceRepo = $this->getArgument('--source');
|
||||
$this->allStandards = (bool) $this->getArgument('--all-standards');
|
||||
$this->allTemplates = (bool) $this->getArgument('--all-templates');
|
||||
|
||||
// Handle repeatable args from raw argv
|
||||
global $argv;
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--target' && isset($argv[$i + 1])) {
|
||||
$this->targetRepos[] = $argv[$i + 1];
|
||||
}
|
||||
if ($arg === '--page' && isset($argv[$i + 1])) {
|
||||
$this->pages[] = $argv[$i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token is required.');
|
||||
$this->printUsage();
|
||||
$this->log('ERROR', '--token is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (empty($this->pages) && !$this->allTemplates) {
|
||||
$this->log('ERROR: --page or --all-standards is required.');
|
||||
$this->printUsage();
|
||||
if (empty($this->pages) && !$this->allStandards) {
|
||||
$this->log('ERROR', '--page or --all-standards is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -53,7 +83,7 @@ final class WikiSync
|
||||
}
|
||||
|
||||
if (empty($this->targetRepos)) {
|
||||
$this->log('No target repos found.');
|
||||
$this->log('INFO', 'No target repos found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -62,16 +92,16 @@ final class WikiSync
|
||||
$this->pages = $this->getStandardsPages();
|
||||
}
|
||||
|
||||
$this->log("Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
|
||||
$this->log('INFO', "Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
|
||||
if ($this->dryRun) {
|
||||
$this->log("[DRY RUN] No changes will be made.\n");
|
||||
$this->log('INFO', "[DRY RUN] No changes will be made.\n");
|
||||
}
|
||||
|
||||
foreach ($this->pages as $pageName) {
|
||||
$this->log("\n--- Page: {$pageName} ---");
|
||||
$this->log('INFO', "\n--- Page: {$pageName} ---");
|
||||
$sourceContent = $this->getWikiPage($this->sourceRepo, $pageName);
|
||||
if ($sourceContent === null) {
|
||||
$this->log(" WARNING: page not found in {$this->sourceRepo}");
|
||||
$this->log('WARNING', "page not found in {$this->sourceRepo}");
|
||||
$this->errors++;
|
||||
continue;
|
||||
}
|
||||
@@ -79,30 +109,30 @@ final class WikiSync
|
||||
foreach ($this->targetRepos as $repo) {
|
||||
$existing = $this->getWikiPage($repo, $pageName);
|
||||
if ($existing !== null && $existing === $sourceContent) {
|
||||
$this->log(" {$repo}: IDENTICAL (skipped)");
|
||||
$this->log('INFO', " {$repo}: IDENTICAL (skipped)");
|
||||
$this->skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
|
||||
$this->log(" {$repo}: {$action}");
|
||||
$this->log('INFO', " {$repo}: {$action}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing !== null) {
|
||||
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
|
||||
$this->log(" {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
|
||||
$this->log('INFO', " {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
|
||||
$ok ? $this->synced++ : $this->errors++;
|
||||
} else {
|
||||
$ok = $this->createWikiPage($repo, $pageName, $sourceContent);
|
||||
$this->log(" {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
|
||||
$this->log('INFO', " {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
|
||||
$ok ? $this->created++ : $this->errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->log("\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
|
||||
$this->log('INFO', "\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -116,7 +146,7 @@ final class WikiSync
|
||||
}
|
||||
}
|
||||
sort($templates);
|
||||
$this->log("Found template repos: " . implode(', ', $templates));
|
||||
$this->log('INFO', "Found template repos: " . implode(', ', $templates));
|
||||
return $templates;
|
||||
}
|
||||
|
||||
@@ -132,7 +162,7 @@ final class WikiSync
|
||||
}
|
||||
}
|
||||
sort($standards);
|
||||
$this->log("Found " . count($standards) . " standards pages: " . implode(', ', $standards));
|
||||
$this->log('INFO', "Found " . count($standards) . " standards pages: " . implode(', ', $standards));
|
||||
return $standards;
|
||||
}
|
||||
|
||||
@@ -175,7 +205,9 @@ final class WikiSync
|
||||
];
|
||||
$ctx = stream_context_create($opts);
|
||||
$result = @file_get_contents($url, false, $ctx);
|
||||
if ($result === false) return null;
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($result, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
@@ -203,80 +235,13 @@ final class WikiSync
|
||||
];
|
||||
$ctx = stream_context_create($opts);
|
||||
$result = @file_get_contents($url, false, $ctx);
|
||||
if ($result === false) return null;
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($result, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
global $argv;
|
||||
$args = $argv;
|
||||
for ($i = 1; $i < count($args); $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--token':
|
||||
$this->token = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--org':
|
||||
$this->org = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--source':
|
||||
$this->sourceRepo = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--target':
|
||||
$this->targetRepos[] = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--page':
|
||||
$this->pages[] = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--all-standards':
|
||||
$this->pages = []; // will be populated from source wiki
|
||||
$this->allTemplates = true;
|
||||
break;
|
||||
case '--all-templates':
|
||||
$this->allTemplates = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: wiki_sync.php --token <token> [options]');
|
||||
$this->log('');
|
||||
$this->log('Sync wiki pages from moko-platform to template repos.');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --token <token> Gitea API token (required)');
|
||||
$this->log(' --org <org> Organization (default: MokoConsulting)');
|
||||
$this->log(' --source <repo> Source repo (default: moko-platform)');
|
||||
$this->log(' --target <repo> Target repo (can repeat; default: all Template-* repos)');
|
||||
$this->log(' --page <name> Page to sync (can repeat)');
|
||||
$this->log(' --all-standards Sync all UPPERCASE standards pages');
|
||||
$this->log(' --all-templates Target all Template-* repos');
|
||||
$this->log(' --dry-run Show what would be done');
|
||||
$this->log(' --help, -h Show this help');
|
||||
$this->log('');
|
||||
$this->log('Examples:');
|
||||
$this->log(' php wiki_sync.php --token xxx --page MANIFEST_STANDARD --all-templates');
|
||||
$this->log(' php wiki_sync.php --token xxx --all-standards --all-templates --dry-run');
|
||||
$this->log(' php wiki_sync.php --token xxx --page WORKFLOW_STANDARDS --target Template-Joomla');
|
||||
}
|
||||
|
||||
private function log(string $msg): void
|
||||
{
|
||||
fwrite(STDERR, $msg . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
(new WikiSync())->run();
|
||||
$app = new WikiSyncCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: moko-platform.CLI
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/workflow_sync.php
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class WorkflowSyncCli extends CliFramework
|
||||
{
|
||||
private const PLATFORM_TEMPLATES = [
|
||||
'joomla' => 'Template-Joomla',
|
||||
'dolibarr' => 'Template-Dolibarr',
|
||||
'go' => 'Template-Go',
|
||||
'mcp' => 'Template-MCP',
|
||||
'platform' => 'Template-Generic',
|
||||
'generic' => 'Template-Generic',
|
||||
];
|
||||
|
||||
private const DEFAULT_TEMPLATE = 'Template-Generic';
|
||||
private const GENERIC_TEMPLATE = 'Template-Generic';
|
||||
|
||||
private int $updated = 0;
|
||||
private int $created = 0;
|
||||
private int $skipped = 0;
|
||||
private int $errors = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Sync workflows from Generic → platform templates → live repos based on manifest.platform');
|
||||
$this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--org', 'Target organization', '');
|
||||
$this->addArgument('--branch', 'Target branch (default: main)', 'main');
|
||||
$this->addArgument('--phase', 'Phase to run: all, templates, repos (default: all)', 'all');
|
||||
$this->addArgument('--platform-filter', 'Only sync repos matching this platform', '');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
|
||||
$token = $this->getArgument('--token');
|
||||
$org = $this->getArgument('--org');
|
||||
$branch = $this->getArgument('--branch');
|
||||
$phase = $this->getArgument('--phase');
|
||||
$platformFilter = $this->getArgument('--platform-filter');
|
||||
|
||||
if ($token === '') {
|
||||
$this->log('ERROR', '--token is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($org === '') {
|
||||
$this->log('ERROR', '--org is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!in_array($phase, ['all', 'templates', 'repos'], true)) {
|
||||
$this->log('ERROR', "--phase must be one of: all, templates, repos (got: {$phase})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', "Workflow Sync — org: {$org}, branch: {$branch}, phase: {$phase}");
|
||||
|
||||
if ($platformFilter !== '') {
|
||||
$this->log('INFO', "Platform filter: {$platformFilter}");
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', '[DRY RUN] No changes will be made.');
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Phase 1: Sync Generic → Platform Templates
|
||||
if ($phase === 'all' || $phase === 'templates') {
|
||||
$result = $this->syncGenericToTemplates($giteaUrl, $token, $org, $branch, $platformFilter);
|
||||
|
||||
if ($result !== 0) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Sync Platform Templates → Live Repos
|
||||
if ($phase === 'all' || $phase === 'repos') {
|
||||
$result = $this->syncTemplatesToRepos($giteaUrl, $token, $org, $branch, $platformFilter);
|
||||
|
||||
if ($result !== 0) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
$this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, "
|
||||
. "{$this->skipped} skipped, {$this->errors} error(s).");
|
||||
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Push all Generic workflows to each platform template repo.
|
||||
* Skips platform-specific overrides (files that exist in the platform template but NOT in Generic).
|
||||
*/
|
||||
private function syncGenericToTemplates(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $branch,
|
||||
string $platformFilter
|
||||
): int {
|
||||
$this->log('INFO', '=== Phase 1: Sync Generic → Platform Templates ===');
|
||||
echo "\n";
|
||||
|
||||
// Get all workflow files from Template-Generic
|
||||
$genericWorkflows = $this->listWorkflows($giteaUrl, $token, $org, self::GENERIC_TEMPLATE, $branch);
|
||||
|
||||
if ($genericWorkflows === null) {
|
||||
$this->log('ERROR', 'Could not list workflows from ' . self::GENERIC_TEMPLATE);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (count($genericWorkflows) === 0) {
|
||||
$this->log('WARN', 'No workflows found in ' . self::GENERIC_TEMPLATE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->log('INFO', 'Found ' . count($genericWorkflows) . ' workflow(s) in ' . self::GENERIC_TEMPLATE);
|
||||
echo "\n";
|
||||
|
||||
// Get unique platform templates (exclude Generic itself)
|
||||
$platformTemplates = array_unique(array_filter(
|
||||
array_values(self::PLATFORM_TEMPLATES),
|
||||
fn(string $t) => $t !== self::GENERIC_TEMPLATE
|
||||
));
|
||||
|
||||
// If platform-filter is set, only sync to the matching template
|
||||
if ($platformFilter !== '') {
|
||||
$targetTemplate = self::PLATFORM_TEMPLATES[$platformFilter] ?? null;
|
||||
|
||||
if ($targetTemplate === null || $targetTemplate === self::GENERIC_TEMPLATE) {
|
||||
$this->log('INFO', "Platform filter '{$platformFilter}' does not map to a non-generic template, skipping Phase 1.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$platformTemplates = [$targetTemplate];
|
||||
}
|
||||
|
||||
fprintf(STDERR, "%-45s | %s\n", 'Template / File', 'Status');
|
||||
fprintf(STDERR, "%s\n", str_repeat('-', 70));
|
||||
|
||||
foreach ($platformTemplates as $templateRepo) {
|
||||
foreach ($genericWorkflows as $workflow) {
|
||||
$filename = $workflow['name'];
|
||||
$destPath = '.mokogitea/workflows/' . $filename;
|
||||
$label = "{$templateRepo}/{$filename}";
|
||||
|
||||
// Get file content from Generic
|
||||
$sourceContent = $this->getFileContent(
|
||||
$giteaUrl, $token, $org,
|
||||
self::GENERIC_TEMPLATE, $destPath, $branch
|
||||
);
|
||||
|
||||
if ($sourceContent === null) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)');
|
||||
$this->errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$commitMsg = "chore: sync {$filename} from " . self::GENERIC_TEMPLATE . " [skip ci]";
|
||||
|
||||
$this->pushFile(
|
||||
$giteaUrl, $token, $org, $templateRepo,
|
||||
$destPath, $sourceContent, $branch, $commitMsg, $label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Sync platform template workflows to live repos based on manifest.platform.
|
||||
*/
|
||||
private function syncTemplatesToRepos(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $branch,
|
||||
string $platformFilter
|
||||
): int {
|
||||
$this->log('INFO', '=== Phase 2: Sync Platform Templates → Live Repos ===');
|
||||
echo "\n";
|
||||
|
||||
$repos = $this->fetchOrgRepos($giteaUrl, $token, $org);
|
||||
|
||||
if ($repos === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('INFO', 'Found ' . count($repos) . " repo(s) in \"{$org}\".");
|
||||
echo "\n";
|
||||
|
||||
fprintf(STDERR, "%-45s | %s\n", 'Repo / File', 'Status');
|
||||
fprintf(STDERR, "%s\n", str_repeat('-', 70));
|
||||
|
||||
// Cache template workflows to avoid repeated API calls
|
||||
$templateWorkflowCache = [];
|
||||
|
||||
foreach ($repos as $repoFullName) {
|
||||
[, $repoName] = explode('/', $repoFullName, 2);
|
||||
|
||||
// Skip template repos
|
||||
if (str_starts_with($repoName, 'Template-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read manifest.platform
|
||||
$platform = $this->getRepoPlatform($giteaUrl, $token, $org, $repoName, $branch);
|
||||
|
||||
// Apply platform filter
|
||||
if ($platformFilter !== '' && $platform !== $platformFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve template
|
||||
$templateRepo = self::PLATFORM_TEMPLATES[$platform] ?? self::DEFAULT_TEMPLATE;
|
||||
|
||||
// Get workflows from the template (cached)
|
||||
if (!isset($templateWorkflowCache[$templateRepo])) {
|
||||
$workflows = $this->listWorkflows($giteaUrl, $token, $org, $templateRepo, $branch);
|
||||
|
||||
if ($workflows === null) {
|
||||
$this->log('WARN', "Could not list workflows from {$templateRepo}, falling back to " . self::GENERIC_TEMPLATE);
|
||||
$workflows = $this->listWorkflows($giteaUrl, $token, $org, self::GENERIC_TEMPLATE, $branch);
|
||||
}
|
||||
|
||||
$templateWorkflowCache[$templateRepo] = $workflows ?? [];
|
||||
}
|
||||
|
||||
$workflows = $templateWorkflowCache[$templateRepo];
|
||||
|
||||
if (count($workflows) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($workflows as $workflow) {
|
||||
$filename = $workflow['name'];
|
||||
$destPath = '.mokogitea/workflows/' . $filename;
|
||||
$label = "{$repoFullName}/{$filename}";
|
||||
|
||||
// Get source content from template
|
||||
$sourceContent = $this->getFileContent(
|
||||
$giteaUrl, $token, $org,
|
||||
$templateRepo, $destPath, $branch
|
||||
);
|
||||
|
||||
if ($sourceContent === null) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)');
|
||||
$this->errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$commitMsg = "chore: sync {$filename} from {$templateRepo} [skip ci]";
|
||||
|
||||
$this->pushFile(
|
||||
$giteaUrl, $token, $org, $repoName,
|
||||
$destPath, $sourceContent, $branch, $commitMsg, $label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a file to a repo — create or update, skip if identical.
|
||||
*/
|
||||
private function pushFile(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $repoName,
|
||||
string $destPath,
|
||||
string $localContent,
|
||||
string $branch,
|
||||
string $commitMsg,
|
||||
string $label
|
||||
): void {
|
||||
$existing = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/"
|
||||
. "{$destPath}?ref={$branch}"
|
||||
);
|
||||
|
||||
$encodedContent = base64_encode($localContent);
|
||||
|
||||
if ($existing['code'] === 200) {
|
||||
$data = json_decode($existing['body'], true);
|
||||
$remoteSha = $data['sha'] ?? '';
|
||||
$remoteContent = base64_decode($data['content'] ?? '');
|
||||
|
||||
if ($remoteContent === $localContent) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'IDENTICAL (skipped)');
|
||||
$this->skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD UPDATE');
|
||||
$this->updated++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'sha' => $remoteSha,
|
||||
'message' => $commitMsg,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'PUT',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 200) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'UPDATED');
|
||||
$this->updated++;
|
||||
} else {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
} elseif ($existing['code'] === 404) {
|
||||
if ($this->dryRun) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD CREATE');
|
||||
$this->created++;
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'content' => $encodedContent,
|
||||
'message' => $commitMsg,
|
||||
'branch' => $branch,
|
||||
]);
|
||||
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'POST',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath,
|
||||
$payload
|
||||
);
|
||||
|
||||
if ($response['code'] === 201) {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, 'CREATED');
|
||||
$this->created++;
|
||||
} else {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
} else {
|
||||
fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$existing['code']})");
|
||||
$this->errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List workflow files in a repo's .mokogitea/workflows/ directory.
|
||||
*/
|
||||
private function listWorkflows(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $repoName,
|
||||
string $branch
|
||||
): ?array {
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/.mokogitea/workflows?ref={$branch}"
|
||||
);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only files (not directories)
|
||||
return array_values(array_filter($data, fn($item) => ($item['type'] ?? '') === 'file'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content from a repo as a raw string.
|
||||
*/
|
||||
private function getFileContent(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $repoName,
|
||||
string $filePath,
|
||||
string $branch
|
||||
): ?string {
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}"
|
||||
);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || !isset($data['content'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base64_decode($data['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a repo's manifest.xml and extract the platform value.
|
||||
* Returns 'generic' if the manifest is missing or has no platform field.
|
||||
*/
|
||||
private function getRepoPlatform(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $org,
|
||||
string $repoName,
|
||||
string $branch
|
||||
): string {
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/repos/{$org}/{$repoName}/contents/.mokogitea/manifest.xml?ref={$branch}"
|
||||
);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || !isset($data['content'])) {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
$xmlContent = base64_decode($data['content']);
|
||||
|
||||
if ($xmlContent === false || $xmlContent === '') {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
// Suppress XML warnings for malformed manifests
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($xmlContent);
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
if ($xml === false) {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
// Try <governance><platform> (standard location)
|
||||
$platform = '';
|
||||
|
||||
// Register namespace if present
|
||||
$namespaces = $xml->getNamespaces(true);
|
||||
|
||||
if (!empty($namespaces)) {
|
||||
$ns = reset($namespaces);
|
||||
$xml->registerXPathNamespace('mp', $ns);
|
||||
|
||||
$nodes = $xml->xpath('//mp:governance/mp:platform');
|
||||
|
||||
if (!empty($nodes)) {
|
||||
$platform = trim((string) $nodes[0]);
|
||||
}
|
||||
|
||||
// Fallback: <identity><platform>
|
||||
if ($platform === '') {
|
||||
$nodes = $xml->xpath('//mp:identity/mp:platform');
|
||||
|
||||
if (!empty($nodes)) {
|
||||
$platform = trim((string) $nodes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: top-level <platform>
|
||||
if ($platform === '') {
|
||||
$nodes = $xml->xpath('//mp:platform');
|
||||
|
||||
if (!empty($nodes)) {
|
||||
$platform = trim((string) $nodes[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No namespace
|
||||
if (isset($xml->governance->platform)) {
|
||||
$platform = trim((string) $xml->governance->platform);
|
||||
} elseif (isset($xml->identity->platform)) {
|
||||
$platform = trim((string) $xml->identity->platform);
|
||||
} elseif (isset($xml->platform)) {
|
||||
$platform = trim((string) $xml->platform);
|
||||
}
|
||||
}
|
||||
|
||||
if ($platform === '') {
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
return strtolower($platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all non-archived repos in an org (paginated).
|
||||
*/
|
||||
private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array
|
||||
{
|
||||
$this->log('INFO', "Fetching repos from org: {$org}");
|
||||
|
||||
$page = 1;
|
||||
$repos = [];
|
||||
|
||||
while (true) {
|
||||
$response = $this->apiRequest(
|
||||
$giteaUrl,
|
||||
$token,
|
||||
'GET',
|
||||
"/api/v1/orgs/{$org}/repos?"
|
||||
. "limit=50&page={$page}"
|
||||
);
|
||||
|
||||
if ($response['code'] < 200 || $response['code'] >= 300) {
|
||||
if ($page === 1) {
|
||||
$this->log('ERROR', "Could not fetch repos "
|
||||
. "(HTTP {$response['code']}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (!is_array($data) || count($data) === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($data as $repo) {
|
||||
if (!empty($repo['archived'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullName = $repo['full_name'] ?? '';
|
||||
|
||||
if ($fullName !== '') {
|
||||
$repos[] = $fullName;
|
||||
}
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
return $repos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the Gitea API.
|
||||
*/
|
||||
private function apiRequest(
|
||||
string $giteaUrl,
|
||||
string $token,
|
||||
string $method,
|
||||
string $endpoint,
|
||||
?string $body = null
|
||||
): array {
|
||||
$url = $giteaUrl . $endpoint;
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
"Authorization: token {$token}",
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo(
|
||||
$ch,
|
||||
CURLINFO_HTTP_CODE
|
||||
);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'body' => "cURL error: {$error}",
|
||||
];
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return ['code' => $httpCode, 'body' => $responseBody];
|
||||
}
|
||||
}
|
||||
|
||||
$app = new WorkflowSyncCli();
|
||||
exit($app->execute());
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "mokoconsulting-tech/enterprise",
|
||||
"description": "MokoStandards Enterprise API \u2014 PHP implementation",
|
||||
"description": "moko-platform Enterprise API \u2014 PHP implementation",
|
||||
"type": "library",
|
||||
"version": "09.01.00",
|
||||
"version": "09.23.00",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
+147
-164
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -7,206 +8,188 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Scripts.Deploy
|
||||
* INGROUP: MokoStandards
|
||||
* DEFGROUP: MokoPlatform.Scripts.Deploy
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/backup-before-deploy.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class BackupBeforeDeploy
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class BackupBeforeDeployCli extends CliFramework
|
||||
{
|
||||
private bool $verbose = false;
|
||||
private string $configPath = '';
|
||||
private string $outputDir = '';
|
||||
private const JOOMLA_DIRS = [
|
||||
'administrator/components',
|
||||
'administrator/language',
|
||||
'administrator/modules',
|
||||
'administrator/templates',
|
||||
'components',
|
||||
'language',
|
||||
'layouts',
|
||||
'libraries',
|
||||
'media',
|
||||
'modules',
|
||||
'plugins',
|
||||
'templates',
|
||||
];
|
||||
|
||||
private const JOOMLA_DIRS = [
|
||||
'administrator/components',
|
||||
'administrator/language',
|
||||
'administrator/modules',
|
||||
'administrator/templates',
|
||||
'components',
|
||||
'language',
|
||||
'layouts',
|
||||
'libraries',
|
||||
'media',
|
||||
'modules',
|
||||
'plugins',
|
||||
'templates',
|
||||
];
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
|
||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||
$this->addArgument('--output', 'Local output directory for snapshot', '');
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
protected function run(): int
|
||||
{
|
||||
$configPath = $this->getArgument('--config');
|
||||
$outputDir = $this->getArgument('--output');
|
||||
|
||||
if ($this->configPath === '') {
|
||||
$this->log('Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
|
||||
return 1;
|
||||
}
|
||||
if ($configPath === '') {
|
||||
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->outputDir === '') {
|
||||
$this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
|
||||
}
|
||||
if ($outputDir === '') {
|
||||
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
|
||||
}
|
||||
|
||||
$config = $this->loadConfig($this->configPath);
|
||||
if ($config === null) {
|
||||
return 1;
|
||||
}
|
||||
$config = $this->loadConfig($configPath);
|
||||
if ($config === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$host = $config['host'] ?? '';
|
||||
$user = $config['user'] ?? '';
|
||||
$port = (int) ($config['port'] ?? 22);
|
||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||
$sshKey = $config['ssh_key_file'] ?? '';
|
||||
$host = $config['host'] ?? '';
|
||||
$user = $config['user'] ?? '';
|
||||
$port = (int) ($config['port'] ?? 22);
|
||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||
$sshKey = $config['ssh_key_file'] ?? '';
|
||||
|
||||
if ($host === '' || $user === '' || $remotePath === '') {
|
||||
$this->log('ERROR: Config must contain host, user, and remote_path.');
|
||||
return 1;
|
||||
}
|
||||
if ($host === '' || $user === '' || $remotePath === '') {
|
||||
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
if (!is_dir($this->outputDir)) {
|
||||
if (!mkdir($this->outputDir, 0755, true)) {
|
||||
$this->log("ERROR: Could not create output directory: {$this->outputDir}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
// Create output directory
|
||||
if (!is_dir($outputDir)) {
|
||||
if (!mkdir($outputDir, 0755, true)) {
|
||||
$this->log('ERROR', "Could not create output directory: {$outputDir}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('Starting pre-deploy snapshot...');
|
||||
$this->log("Source: {$user}@{$host}:{$remotePath}");
|
||||
$this->log("Output: {$this->outputDir}");
|
||||
$this->log('INFO', 'Starting pre-deploy snapshot...');
|
||||
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
|
||||
$this->log('INFO', "Output: {$outputDir}");
|
||||
|
||||
$failed = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach (self::JOOMLA_DIRS as $dir) {
|
||||
$remoteSource = "{$remotePath}/{$dir}/";
|
||||
$localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/';
|
||||
foreach (self::JOOMLA_DIRS as $dir) {
|
||||
$remoteSource = "{$remotePath}/{$dir}/";
|
||||
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
|
||||
|
||||
// Ensure local subdirectory exists
|
||||
if (!is_dir($localTarget)) {
|
||||
mkdir($localTarget, 0755, true);
|
||||
}
|
||||
// Ensure local subdirectory exists
|
||||
if (!is_dir($localTarget)) {
|
||||
mkdir($localTarget, 0755, true);
|
||||
}
|
||||
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
|
||||
$cmd = $this->buildRsyncCommand(
|
||||
$sshCmd,
|
||||
"{$user}@{$host}:{$remoteSource}",
|
||||
$localTarget
|
||||
);
|
||||
$cmd = $this->buildRsyncCommand(
|
||||
$sshCmd,
|
||||
"{$user}@{$host}:{$remoteSource}",
|
||||
$localTarget
|
||||
);
|
||||
|
||||
$this->log("Downloading: {$dir}");
|
||||
if ($this->verbose) {
|
||||
$this->log("CMD: {$cmd}");
|
||||
}
|
||||
$this->log('INFO', "Downloading: {$dir}");
|
||||
if ($this->verbose) {
|
||||
$this->log('INFO', "CMD: {$cmd}");
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
$failed++;
|
||||
} else {
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($exitCode !== 0) {
|
||||
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log('ERROR', " {$line}");
|
||||
}
|
||||
$failed++;
|
||||
} else {
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log('INFO', " {$line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->log("Snapshot completed with {$failed} error(s).");
|
||||
return 1;
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$this->log('ERROR', "Snapshot completed with {$failed} error(s).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
$this->log('Snapshot completed successfully.');
|
||||
$this->log("SNAPSHOT_PATH={$this->outputDir}");
|
||||
$this->log('');
|
||||
$this->log('To rollback, run:');
|
||||
$this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}");
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', 'Snapshot completed successfully.');
|
||||
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', 'To rollback, run:');
|
||||
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
private function loadConfig(string $path): ?array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$this->log('ERROR', "Config file not found: {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--config':
|
||||
$this->configPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--output':
|
||||
$this->outputDir = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--verbose':
|
||||
$this->verbose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
$this->log('ERROR', "Could not read config file: {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private function loadConfig(string $path): ?array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$this->log("ERROR: Config file not found: {$path}");
|
||||
return null;
|
||||
}
|
||||
// Strip // comments (sftp-config.json style)
|
||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||
$config = json_decode($cleaned, true);
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
$this->log("ERROR: Could not read config file: {$path}");
|
||||
return null;
|
||||
}
|
||||
if (!is_array($config)) {
|
||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip // comments (sftp-config.json style)
|
||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||
$config = json_decode($cleaned, true);
|
||||
return $config;
|
||||
}
|
||||
|
||||
if (!is_array($config)) {
|
||||
$this->log('ERROR: Invalid JSON in config file.');
|
||||
return null;
|
||||
}
|
||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
||||
{
|
||||
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
|
||||
|
||||
return $config;
|
||||
}
|
||||
if ($this->verbose) {
|
||||
$parts[] = '-v';
|
||||
}
|
||||
|
||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
|
||||
{
|
||||
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
|
||||
$parts[] = '-e';
|
||||
$parts[] = escapeshellarg($sshCmd);
|
||||
$parts[] = escapeshellarg($source);
|
||||
$parts[] = escapeshellarg($dest);
|
||||
|
||||
if ($this->verbose) {
|
||||
$parts[] = '-v';
|
||||
}
|
||||
|
||||
$parts[] = '-e';
|
||||
$parts[] = escapeshellarg($sshCmd);
|
||||
$parts[] = escapeshellarg($source);
|
||||
$parts[] = escapeshellarg($dest);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
|
||||
}
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new BackupBeforeDeploy();
|
||||
exit($app->run());
|
||||
$app = new BackupBeforeDeployCli();
|
||||
exit($app->execute());
|
||||
|
||||
+214
-233
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
@@ -7,295 +8,275 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Scripts.Deploy
|
||||
* INGROUP: MokoStandards
|
||||
* DEFGROUP: MokoPlatform.Scripts.Deploy
|
||||
* INGROUP: MokoPlatform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /deploy/deploy-dolibarr.php
|
||||
* VERSION: 09.21.00
|
||||
* VERSION: 09.25.04
|
||||
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class DeployDolibarr
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class DeployDolibarrCli extends CliFramework
|
||||
{
|
||||
private bool $verbose = false;
|
||||
private bool $dryRun = false;
|
||||
private string $configPath = '';
|
||||
private string $source = '';
|
||||
private string $source = '';
|
||||
|
||||
private const MODULE_DIRS = [
|
||||
'core/modules',
|
||||
'class',
|
||||
'lib',
|
||||
'sql',
|
||||
'langs',
|
||||
'css',
|
||||
'js',
|
||||
'img',
|
||||
];
|
||||
private const MODULE_DIRS = [
|
||||
'core/modules',
|
||||
'class',
|
||||
'lib',
|
||||
'sql',
|
||||
'langs',
|
||||
'css',
|
||||
'js',
|
||||
'img',
|
||||
];
|
||||
|
||||
private const EXCLUDES = [
|
||||
'.git/',
|
||||
'vendor/',
|
||||
'tests/',
|
||||
'node_modules/',
|
||||
];
|
||||
private const EXCLUDES = [
|
||||
'.git/',
|
||||
'vendor/',
|
||||
'tests/',
|
||||
'node_modules/',
|
||||
];
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
|
||||
$this->addArgument('--source', 'Local source directory', '');
|
||||
$this->addArgument('--config', 'Path to sftp-config.json', '');
|
||||
}
|
||||
|
||||
if ($this->configPath === '' || $this->source === '') {
|
||||
$this->log('Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
|
||||
return 1;
|
||||
}
|
||||
protected function run(): int
|
||||
{
|
||||
$configPath = $this->getArgument('--config');
|
||||
$this->source = $this->getArgument('--source');
|
||||
|
||||
if (!is_dir($this->source)) {
|
||||
$this->log("ERROR: Source directory does not exist: {$this->source}");
|
||||
return 1;
|
||||
}
|
||||
if ($configPath === '' || $this->source === '') {
|
||||
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$moduleName = $this->detectModuleName();
|
||||
if ($moduleName === null) {
|
||||
$this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php');
|
||||
return 1;
|
||||
}
|
||||
if (!is_dir($this->source)) {
|
||||
$this->log('ERROR', "Source directory does not exist: {$this->source}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$config = $this->loadConfig($this->configPath);
|
||||
if ($config === null) {
|
||||
return 1;
|
||||
}
|
||||
$moduleName = $this->detectModuleName();
|
||||
if ($moduleName === null) {
|
||||
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$host = $config['host'] ?? '';
|
||||
$user = $config['user'] ?? '';
|
||||
$port = (int) ($config['port'] ?? 22);
|
||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||
$sshKey = $config['ssh_key_file'] ?? '';
|
||||
$config = $this->loadConfig($configPath);
|
||||
if ($config === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($host === '' || $user === '' || $remotePath === '') {
|
||||
$this->log('ERROR: Config must contain host, user, and remote_path.');
|
||||
return 1;
|
||||
}
|
||||
$host = $config['host'] ?? '';
|
||||
$user = $config['user'] ?? '';
|
||||
$port = (int) ($config['port'] ?? 22);
|
||||
$remotePath = rtrim($config['remote_path'] ?? '', '/');
|
||||
$sshKey = $config['ssh_key_file'] ?? '';
|
||||
|
||||
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
|
||||
if ($host === '' || $user === '' || $remotePath === '') {
|
||||
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Deploying Dolibarr module: {$moduleName}");
|
||||
$this->log("Source: {$this->source}");
|
||||
$this->log("Target: {$user}@{$host}:{$remoteBase}");
|
||||
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('*** DRY RUN — no changes will be made ***');
|
||||
}
|
||||
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
|
||||
$this->log('INFO', "Source: {$this->source}");
|
||||
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
|
||||
|
||||
$failed = 0;
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
|
||||
}
|
||||
|
||||
// Deploy subdirectories
|
||||
foreach (self::MODULE_DIRS as $dir) {
|
||||
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
|
||||
$failed = 0;
|
||||
|
||||
if (!is_dir($localDir)) {
|
||||
if ($this->verbose) {
|
||||
$this->log("SKIP: {$dir} (not present in source)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Deploy subdirectories
|
||||
foreach (self::MODULE_DIRS as $dir) {
|
||||
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
|
||||
|
||||
$remoteTarget = "{$remoteBase}/{$dir}/";
|
||||
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
|
||||
if (!is_dir($localDir)) {
|
||||
if ($this->verbose) {
|
||||
$this->log('INFO', "SKIP: {$dir} (not present in source)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
$remoteTarget = "{$remoteBase}/{$dir}/";
|
||||
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
|
||||
|
||||
// Deploy root PHP files
|
||||
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
|
||||
if (!empty($rootPhpFiles)) {
|
||||
$this->log('Syncing root PHP files...');
|
||||
$sourceRoot = rtrim($this->source, '/\\') . '/';
|
||||
$remoteTarget = "{$remoteBase}/";
|
||||
if (!$result) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
// Deploy root PHP files
|
||||
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
|
||||
if (!empty($rootPhpFiles)) {
|
||||
$this->log('INFO', 'Syncing root PHP files...');
|
||||
$sourceRoot = rtrim($this->source, '/\\') . '/';
|
||||
$remoteTarget = "{$remoteBase}/";
|
||||
|
||||
$cmd = $this->buildRsyncCommand(
|
||||
$sshCmd,
|
||||
$sourceRoot,
|
||||
"{$user}@{$host}:{$remoteTarget}",
|
||||
['--include=*.php', '--exclude=*/', '--exclude=.*']
|
||||
);
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
|
||||
if ($this->verbose) {
|
||||
$this->log("CMD: {$cmd}");
|
||||
}
|
||||
$cmd = $this->buildRsyncCommand(
|
||||
$sshCmd,
|
||||
$sourceRoot,
|
||||
"{$user}@{$host}:{$remoteTarget}",
|
||||
['--include=*.php', '--exclude=*/', '--exclude=.*']
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($this->verbose) {
|
||||
$this->log('INFO', "CMD: {$cmd}");
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
$failed++;
|
||||
} else {
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->log("Deployment completed with {$failed} error(s).");
|
||||
return 1;
|
||||
}
|
||||
if ($exitCode !== 0) {
|
||||
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log('ERROR', " {$line}");
|
||||
}
|
||||
$failed++;
|
||||
} else {
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log('INFO', " {$line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('Deployment completed successfully.');
|
||||
return 0;
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$this->log('ERROR', "Deployment completed with {$failed} error(s).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
$this->log('INFO', 'Deployment completed successfully.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
switch ($args[$i]) {
|
||||
case '--source':
|
||||
$this->source = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--config':
|
||||
$this->configPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--dry-run':
|
||||
$this->dryRun = true;
|
||||
break;
|
||||
case '--verbose':
|
||||
$this->verbose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private function detectModuleName(): ?string
|
||||
{
|
||||
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
|
||||
$matches = glob($pattern);
|
||||
|
||||
private function detectModuleName(): ?string
|
||||
{
|
||||
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
|
||||
$matches = glob($pattern);
|
||||
if (empty($matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($matches)) {
|
||||
return null;
|
||||
}
|
||||
$filename = basename($matches[0]);
|
||||
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it
|
||||
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
|
||||
return strtolower($m[1]);
|
||||
}
|
||||
|
||||
$filename = basename($matches[0]);
|
||||
// mod{ModuleName}.class.php → extract ModuleName, lowercase it
|
||||
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
|
||||
return strtolower($m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
private function loadConfig(string $path): ?array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$this->log('ERROR', "Config file not found: {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private function loadConfig(string $path): ?array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
$this->log("ERROR: Config file not found: {$path}");
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
$this->log('ERROR', "Could not read config file: {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
$this->log("ERROR: Could not read config file: {$path}");
|
||||
return null;
|
||||
}
|
||||
// Strip // comments (sftp-config.json style)
|
||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||
$config = json_decode($cleaned, true);
|
||||
|
||||
// Strip // comments (sftp-config.json style)
|
||||
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
|
||||
$config = json_decode($cleaned, true);
|
||||
if (!is_array($config)) {
|
||||
$this->log('ERROR', 'Invalid JSON in config file.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_array($config)) {
|
||||
$this->log('ERROR: Invalid JSON in config file.');
|
||||
return null;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
|
||||
{
|
||||
$dirName = basename(rtrim($localDir, '/'));
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
|
||||
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
|
||||
{
|
||||
$dirName = basename(rtrim($localDir, '/'));
|
||||
$sshCmd = "ssh -p {$port}";
|
||||
if ($sshKey !== '') {
|
||||
$sshCmd .= " -i " . escapeshellarg($sshKey);
|
||||
}
|
||||
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
||||
|
||||
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
|
||||
$this->log('INFO', "Syncing: {$dirName}");
|
||||
if ($this->verbose) {
|
||||
$this->log('INFO', "CMD: {$cmd}");
|
||||
}
|
||||
|
||||
$this->log("Syncing: {$dirName}");
|
||||
if ($this->verbose) {
|
||||
$this->log("CMD: {$cmd}");
|
||||
}
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log('ERROR', " {$line}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})");
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log('INFO', " {$line}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->verbose) {
|
||||
foreach ($output as $line) {
|
||||
$this->log(" {$line}");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
|
||||
{
|
||||
$parts = ['rsync', '-rlptz', '--delete'];
|
||||
|
||||
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
|
||||
{
|
||||
$parts = ['rsync', '-rlptz', '--delete'];
|
||||
foreach (self::EXCLUDES as $exclude) {
|
||||
$parts[] = '--exclude=' . $exclude;
|
||||
}
|
||||
|
||||
foreach (self::EXCLUDES as $exclude) {
|
||||
$parts[] = '--exclude=' . $exclude;
|
||||
}
|
||||
foreach ($extraArgs as $arg) {
|
||||
$parts[] = $arg;
|
||||
}
|
||||
|
||||
foreach ($extraArgs as $arg) {
|
||||
$parts[] = $arg;
|
||||
}
|
||||
if ($this->dryRun) {
|
||||
$parts[] = '--dry-run';
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$parts[] = '--dry-run';
|
||||
}
|
||||
if ($this->verbose) {
|
||||
$parts[] = '-v';
|
||||
}
|
||||
|
||||
if ($this->verbose) {
|
||||
$parts[] = '-v';
|
||||
}
|
||||
$parts[] = '-e';
|
||||
$parts[] = escapeshellarg($sshCmd);
|
||||
$parts[] = escapeshellarg($source);
|
||||
$parts[] = escapeshellarg($dest);
|
||||
|
||||
$parts[] = '-e';
|
||||
$parts[] = escapeshellarg($sshCmd);
|
||||
$parts[] = escapeshellarg($source);
|
||||
$parts[] = escapeshellarg($dest);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
|
||||
}
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new DeployDolibarr();
|
||||
exit($app->run());
|
||||
$app = new DeployDolibarrCli();
|
||||
exit($app->execute());
|
||||
|
||||
+459
-469
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user