Public Access
Compare commits
328 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e8ae7e5736 | |||
| e3eb639cdf | |||
| 346ae7bdaf | |||
| f36598b3c3 | |||
| e05ce7db14 | |||
| 9f0d122fbf | |||
| faab8e9e63 | |||
| 864e4ab017 | |||
| 9b49704294 | |||
| 4962f0f05f | |||
| 48b2ac9346 | |||
| 44087c86e9 | |||
| fa77e4fa13 | |||
| 3bccb43c83 | |||
| fd98eba436 | |||
| d563b45962 | |||
| 9954436905 | |||
| 4d2fef3e82 | |||
| 0a79a99256 | |||
| a83b2f8d07 | |||
| 8887cd858c | |||
| 2b3e7e631e | |||
| c573bc9816 | |||
| 8a7acca1b7 | |||
| 8b9e852fd6 | |||
| 7522e08f37 | |||
| ddf17bf5d5 | |||
| 4142550c3b | |||
| bc8f9830f2 | |||
| 7f2f41d5c4 | |||
| 1a3fcc7abd | |||
| d5bccfefb6 | |||
| 2e9eff967c | |||
| 1fec9f5165 | |||
| 6832d301a7 | |||
| b786bb2b8e | |||
| 4e7a253d1c | |||
| f903619b2e | |||
| 7c0a587df3 | |||
| 7c76f2c8c1 | |||
| 3a96abdb00 | |||
| 0b3f2465ed | |||
| 2b5e394eec | |||
| 334bb15184 | |||
| 3ec49bd8cc | |||
| f768db495b | |||
| 5ab9c00618 | |||
| a3a7555b5c | |||
| fe59923886 | |||
| 983b06d434 | |||
| c24050c7e3 | |||
| 6fe7597da0 | |||
| a97ea66aa8 | |||
| 376a59eb9b | |||
| 2f0ddd9ef6 | |||
| e477760b33 | |||
| f7a6e0bae7 | |||
| b7cbf861f0 | |||
| f7bb0edf3a | |||
| a864a4a0f0 | |||
| c3e6471d87 | |||
| 929b69fbd4 | |||
| 56deb8b923 | |||
| 4a098e6c15 | |||
| a704031bca | |||
| e330b59df5 | |||
| e3f297d5c5 | |||
| 7ee2ee4360 | |||
| bc067032ff | |||
| 6d5b374515 | |||
| 647277c528 | |||
| 3530485060 | |||
| 1f57a19860 | |||
| ead8d13114 | |||
| db06dc31cc | |||
| b76b3d5337 | |||
| dea9bb3577 | |||
| 10aa0b9716 | |||
| c76b9fd07b | |||
| c5cb67f542 | |||
| 09d4e531ee | |||
| 169473e9d8 | |||
| 789155fdd8 | |||
| 71bbb6ccb5 | |||
| a37b863ba8 | |||
| 50bb4d2def | |||
| 25d51c1ec0 | |||
| c4bfbae348 | |||
| d332eaf80a | |||
| 10d0a809e9 | |||
| c665a7a31d | |||
| 81df267401 | |||
| 7941e875a6 | |||
| 5d3de97843 | |||
| 6333189313 | |||
| 49a00185c9 | |||
| e5e32c4cb7 | |||
| b0f29cefa3 | |||
| f2be11a5c3 | |||
| 048d1f4914 | |||
| 1cbdaa0c37 | |||
| 32e34fc936 | |||
| d7e679b7b2 | |||
| 23e777d50d | |||
| 14e177f862 | |||
| a8a9e1d31f | |||
| 83bb3fd084 | |||
| 6703935d80 | |||
| 28a9e05cb7 | |||
| 20293f5f5c | |||
| ec07a75097 | |||
| 6c4db0c64c | |||
| 327d4b4e85 | |||
| d886a83c9f | |||
| 508d33df63 | |||
| a625806870 | |||
| 391ce25256 | |||
| 170deab90f | |||
| bd13cb9a5e | |||
| 5b16c394dd | |||
| d8b76be9aa | |||
| e5d6936a94 | |||
| 2c79f527c4 | |||
| a4009aff2f | |||
| cf15759c3a | |||
| a9181197d1 | |||
| 7bd5b4cd87 | |||
| d33b6e37b2 | |||
| b56768c9e6 | |||
| 475d2e33ef | |||
| eaa97e6406 | |||
| beed1e628a | |||
| d11ca3d85b | |||
| d7fdd99f68 | |||
| 405261a123 | |||
| a93794f1ba | |||
| 0a095f9a6b | |||
| fe3644204a | |||
| 5a8bb1bd6e | |||
| 34ef05bd6e | |||
| c893f5ac33 | |||
| e177971462 | |||
| a28236d120 | |||
| 51e599acef | |||
| c73675234b | |||
| 16ff7e611e | |||
| 030f057ab4 | |||
| fec5464c17 | |||
| 2723fbf0e7 | |||
| e9dcd48e44 | |||
| 1bd170c77f | |||
| 1e18d6bcb8 | |||
| 35befccf06 | |||
| d012bb900b | |||
| 2ab332161d | |||
| a9acb2d27c | |||
| e609a4e205 | |||
| dbf4cdef9a | |||
| 24d5238b64 | |||
| 4ccefec2dc | |||
| 5a8b18ea8d | |||
| 9dbf790e1a | |||
| a07d93b6fc | |||
| 415e58d06c | |||
| d8ec7b5ba0 | |||
| e882425f04 | |||
| 3171fb3ef0 | |||
| 6cd46f0b7f | |||
| 48ae7c1e88 | |||
| 63a2640254 | |||
| 6ec2202c6e | |||
| 8f7cce051b | |||
| f426f21f2e | |||
| 2ee5a55ec5 | |||
| a04040533c | |||
| d5541abf22 | |||
| 8ae829ad89 | |||
| 5815ad040f | |||
| 96b6db73a9 | |||
| 8eb3e310cf | |||
| eca475c6e3 | |||
| 92822303ef | |||
| 9649fb55cf | |||
| 8f39017b59 | |||
| bd18642045 | |||
| 820e968e1a | |||
| a5cd566dea | |||
| b5599579a7 | |||
| 61a232dfc6 | |||
| a45bf42335 | |||
| 77a1ae3977 | |||
| fb5461b661 | |||
| e15421699e | |||
| 48d574e225 | |||
| 1dba0c37b9 | |||
| 07ea171af9 | |||
| 420b4f5f3c | |||
| f8c28f055b | |||
| a7df4d49b9 | |||
| 320b2c57be | |||
| d323ca52af | |||
| c5e4b41100 | |||
| 335fcd0382 | |||
| c1c820bb5c | |||
| f441a8a51f | |||
| 005eb5cf39 | |||
| 21acb19fed | |||
| 1fe4f83e73 | |||
| 7e5c322792 | |||
| b010677d75 | |||
| 9275e581c2 | |||
| 3f3b1f79a0 | |||
| 83842c50ad | |||
| fbedd5966c | |||
| eca2c13018 | |||
| 48d000107d | |||
| 7ceb9528cc | |||
| 5fabaec477 | |||
| e40b799101 | |||
| 7e9784e723 | |||
| 209dee14fd | |||
| 81351f45fd | |||
| fd451b4b73 | |||
| d0dbd1dceb | |||
| 3e2e291819 | |||
| 5975ea38d8 | |||
| 8ad548f4a3 | |||
| cbb4d73df5 | |||
| 47cb47ebdb | |||
| 22b0f8af7e | |||
| 08ca1429ae | |||
| e8da1a30ff | |||
| fb754b1a07 | |||
| 9a2c164207 | |||
| 78c1329a83 | |||
| 05f43ed88f | |||
| 05e4f39e7d | |||
| 3dcb3b6d3a | |||
| db4e6f5c6b | |||
| aa7fc45a67 | |||
| 03fe66238f | |||
| a5ae616a94 | |||
| ff7924de7d |
@@ -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
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
# | BRANCH PROTECTION SETUP |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Applies protection rules for: main, dev, rc/*, beta/*, alpha/* |
|
||||
# | Applies protection rules for: main, dev, rc, beta, alpha |
|
||||
# | |
|
||||
# | main — Require PR, block rejected reviews, no force push |
|
||||
# | dev — Allow push, no force push, no delete |
|
||||
# | rc/* — Allow push, no force push, no delete |
|
||||
# | beta/* — Allow push, no force push, no delete |
|
||||
# | alpha/* — Allow push, no force push, no delete |
|
||||
# | rc — Allow push, no force push, no delete |
|
||||
# | beta — Allow push, no force push, no delete |
|
||||
# | alpha — Allow push, no force push, no delete |
|
||||
# | |
|
||||
# | jmiller has override authority on all branches. |
|
||||
# | |
|
||||
@@ -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
|
||||
@@ -116,17 +116,18 @@ jobs:
|
||||
SKIPPED=0
|
||||
|
||||
# ── Rule definitions ──────────────────────────────────────
|
||||
# Each rule: NAME|JSON_BODY
|
||||
# jmiller has override (force push + push whitelist) on all branches
|
||||
# Only the CI bot (jmiller token) can push directly.
|
||||
# All human contributors must use PRs.
|
||||
# Force push disabled on all branches.
|
||||
|
||||
RULE_MAIN='{
|
||||
"rule_name": "main",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": true,
|
||||
@@ -138,10 +139,11 @@ jobs:
|
||||
RULE_DEV='{
|
||||
"rule_name": "dev",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
@@ -149,12 +151,13 @@ jobs:
|
||||
}'
|
||||
|
||||
RULE_RC='{
|
||||
"rule_name": "rc/*",
|
||||
"rule_name": "rc",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
@@ -162,12 +165,13 @@ jobs:
|
||||
}'
|
||||
|
||||
RULE_BETA='{
|
||||
"rule_name": "beta/*",
|
||||
"rule_name": "beta",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
@@ -175,12 +179,13 @@ jobs:
|
||||
}'
|
||||
|
||||
RULE_ALPHA='{
|
||||
"rule_name": "alpha/*",
|
||||
"rule_name": "alpha",
|
||||
"enable_push": true,
|
||||
"enable_push_whitelist": false,
|
||||
"enable_force_push": true,
|
||||
"enable_force_push_allowlist": true,
|
||||
"force_push_allowlist_usernames": ["jmiller"],
|
||||
"enable_push_whitelist": true,
|
||||
"push_whitelist_usernames": ["jmiller"],
|
||||
"enable_force_push": false,
|
||||
"enable_force_push_allowlist": false,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"enable_merge_whitelist": false,
|
||||
"required_approvals": 0,
|
||||
"block_on_rejected_reviews": false,
|
||||
@@ -188,7 +193,7 @@ jobs:
|
||||
}'
|
||||
|
||||
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
|
||||
RULE_NAMES=("main" "dev" "rc/*" "beta/*" "alpha/*")
|
||||
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
|
||||
|
||||
# ── Apply rules to each repo ──────────────────────────────
|
||||
for REPO in $REPOS; do
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
moko-platform Repository Manifest
|
||||
Auto-generated by cleanup script.
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
||||
<identity>
|
||||
<name>moko-platform</name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Enterprise automation, validation, sync, and governance engine for all Moko Consulting repositories</description>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
<platform>generic</platform>
|
||||
<standards-version>05.00.00</standards-version>
|
||||
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
|
||||
<last-synced>2026-05-10T19:51:08+00:00</last-synced>
|
||||
</governance>
|
||||
<build>
|
||||
<language>HCL</language>
|
||||
<package-type>generic</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoPlatform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /.mokogitea/workflows/branch-cleanup.yml
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Delete feature branches after PR merge
|
||||
|
||||
name: "Branch Cleanup"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete merged branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref != 'dev' &&
|
||||
github.event.pull_request.head.ref != 'main'
|
||||
|
||||
steps:
|
||||
- name: Delete source branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||
|
||||
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "$STATUS" = "404" ]; then
|
||||
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
|
||||
fi
|
||||
@@ -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.GA_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 ${GA_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.GA_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 ${GA_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 ${GA_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 ${GA_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 ${GA_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 ${GA_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"
|
||||
@@ -124,16 +124,16 @@ jobs:
|
||||
echo "### PHPCS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PSR-12 compliance: passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "PHPStan (Level 2)"
|
||||
continue-on-error: true
|
||||
- name: "PHPStan (Level 6)"
|
||||
run: |
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --error-format=github 2>&1 || {
|
||||
echo "::warning::PHPStan found type errors (advisory)"
|
||||
vendor/bin/phpstan analyse -c phpstan.neon --no-progress --memory-limit=512M --error-format=github 2>&1 || {
|
||||
echo "::error::PHPStan found type errors"
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis errors detected. Run \`composer phpstan\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPStan" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis: advisory (level 0)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Static analysis (level 6): passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Psalm"
|
||||
continue-on-error: true
|
||||
@@ -177,11 +177,14 @@ jobs:
|
||||
|
||||
- name: "PHPUnit (PHP ${{ matrix.php }})"
|
||||
run: |
|
||||
vendor/bin/phpunit --testdox 2>&1
|
||||
{
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})"
|
||||
echo "All tests passed."
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
vendor/bin/phpunit --testdox 2>&1 || {
|
||||
echo "::error::PHPUnit tests failed"
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Run \`vendor/bin/phpunit --testdox\` locally." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
}
|
||||
echo "### PHPUnit (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Gate 3 — Self-Health (Dogfood)
|
||||
@@ -267,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"
|
||||
@@ -286,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"
|
||||
@@ -409,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: |
|
||||
{
|
||||
@@ -434,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"
|
||||
@@ -33,17 +33,17 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
|
||||
- name: Delete merged branches
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Merged Branch Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
# List branches via API
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||
|
||||
DELETED=0
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
# Check if branch is merged into main
|
||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||
echo " Deleting merged branch: ${BRANCH}"
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
fi
|
||||
@@ -66,20 +66,20 @@ jobs:
|
||||
|
||||
- name: Clean old workflow runs
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
echo "=== Workflow Run Cleanup ==="
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Get old completed runs
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
||||
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/actions/runs?status=completed&limit=50" | \
|
||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||
|
||||
DELETED=0
|
||||
for RUN_ID in $RUNS; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
||||
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||
DELETED=$((DELETED + 1))
|
||||
done
|
||||
|
||||
@@ -1,139 +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.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform-api" ] && [ -f "/tmp/moko-platform-api/composer.json" ]; then
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/moko-platform-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/moko-platform-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/moko-platform-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/moko-platform-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
|
||||
- name: Post-deploy health check
|
||||
if: success() && steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
if [ -f "deploy/health-check.php" ]; then
|
||||
SITE_URL="${{ vars.DEV_SITE_URL }}"
|
||||
if [ -n "$SITE_URL" ]; then
|
||||
php deploy/health-check.php --url "$SITE_URL" --checks http --timeout 30 || echo "::warning::Health check failed after deploy"
|
||||
else
|
||||
echo "DEV_SITE_URL not configured, skipping health check"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -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: 01.00.00
|
||||
# VERSION: 09.25.03
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create branch and comment
|
||||
run: |
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
ISSUE_TITLE="${{ github.event.issue.title }}"
|
||||
|
||||
@@ -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
-214
@@ -1,214 +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
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
alpha/*|beta/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc/*)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# 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: 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.GA_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 ${GA_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,11 +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:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- 'fix/**'
|
||||
- 'patch/**'
|
||||
- 'hotfix/**'
|
||||
- 'bugfix/**'
|
||||
- 'chore/**'
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -35,56 +46,62 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Setup tools
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
# 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
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
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
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
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
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
# 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 }}"
|
||||
# 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" ;;
|
||||
@@ -93,60 +110,50 @@ jobs:
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
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 }}" 2>/dev/null || true
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
|
||||
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
|
||||
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
EXT_ELEMENT=""
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
# Auto-detect element via manifest_element.php
|
||||
php ${MOKO_CLI}/manifest_element.php \
|
||||
--path . --version "$VERSION" --stability "$STABILITY" \
|
||||
--repo "${GITEA_REPO}" --github-output
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
# Read back element outputs
|
||||
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
@@ -154,204 +161,74 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
- name: Create release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
# 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
|
||||
|
||||
# Map stability to XML tag name
|
||||
case "$STABILITY" in
|
||||
development) XML_TAG="development" ;;
|
||||
alpha) XML_TAG="alpha" ;;
|
||||
beta) XML_TAG="beta" ;;
|
||||
release-candidate) XML_TAG="rc" ;;
|
||||
*) XML_TAG="$STABILITY" ;;
|
||||
esac
|
||||
# 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)
|
||||
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
|
||||
|
||||
# Use PHP to update the channel in updates.xml
|
||||
php -r '
|
||||
$xml_tag = $argv[1];
|
||||
$version = $argv[2];
|
||||
$sha256 = $argv[3];
|
||||
$url = $argv[4];
|
||||
$date = date("Y-m-d");
|
||||
|
||||
$content = file_get_contents("updates.xml");
|
||||
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
|
||||
|
||||
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
|
||||
$block = $m[0];
|
||||
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
|
||||
if (strpos($block, "<sha256>") !== false) {
|
||||
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
|
||||
} else {
|
||||
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
|
||||
}
|
||||
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
|
||||
return $block;
|
||||
}, $content);
|
||||
|
||||
file_put_contents("updates.xml", $content);
|
||||
echo "Updated {$xml_tag} channel: version={$version}\n";
|
||||
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
|
||||
|
||||
# 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"
|
||||
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: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
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}" -- . 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
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
@@ -364,7 +241,7 @@ jobs:
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
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,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": [
|
||||
|
||||
+26
-153
@@ -10,163 +10,36 @@ BRIEF: Release changelog
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version format: `XX.YY.ZZ` (zero-padded semver).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [07.00.00] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- `cli/client_provision.php` — end-to-end client onboarding from JSON config (closes #4)
|
||||
- `cli/client_dashboard.php` — unified HTML dashboard: health, SSL, uptime, releases (closes #3)
|
||||
- `cli/client_health_check.php`, `cli/joomla_compat_check.php`, `cli/theme_lint.php` — new CLI tools
|
||||
- `lib/Enterprise/ConfigValidator.php` — JSON schema validator for plugin configs (closes #105)
|
||||
- PHPUnit test infrastructure: `phpunit.xml` + 19 tests (closes #102)
|
||||
- `bin/moko list` — auto-grouped command list with 45 commands, plugin command dispatcher (closes #104)
|
||||
- `templates/client-provision-example.json` — example config for client provisioning
|
||||
- `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
|
||||
|
||||
### 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.25.00] --- 2026-06-04
|
||||
|
||||
## [09.23] --- 2026-05-31
|
||||
|
||||
## [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
|
||||
- `bin/moko` COMMAND_MAP: all paths pointed to non-existent `api/` directory (closes #100)
|
||||
- `release_cascade.php`: accept `release-candidate` as stability value (was silently skipping)
|
||||
- `package_build.php`: fix 0-byte ZIP for Joomla packages — correct structure, no double prefix (closes #92)
|
||||
- PHPStan: level 0 to 2, 67 type errors fixed, 0 exclusions
|
||||
- `ApiClient::delete()`: accept optional body parameter for Gitea Contents API
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
- Migrated all 7 CLIApp scripts to CliFramework (closes #101)
|
||||
- Updated CLAUDE.md with current architecture, CLI patterns, code quality (closes #103)
|
||||
- Wiki CLI_AUTOMATION page updated with all tools
|
||||
## [09.21] --- 2026-05-30
|
||||
|
||||
## [06.00.00] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- `cli/bulk_workflow_push.php` — push a workflow file to all governed repos via Gitea Contents API (closes #52)
|
||||
- `cli/grafana_dashboard.php` — manage Grafana dashboards: push, delete, list, export (closes #53)
|
||||
- Wiki CLI_AUTOMATION page — comprehensive reference for all 30 CLI tools (closes #66)
|
||||
|
||||
### Fixed
|
||||
- `version_read.php` / `version_bump.php`: handle suffixed versions in XML manifests (e.g. `01.00.00-dev`)
|
||||
- `version_read.php` / `version_bump.php`: match `VERSION:` inside HTML comments (`<!-- VERSION: ... -->`)
|
||||
- Pre-release RC builds now work after a development pre-release has been built
|
||||
- auto-release workflow: switch trigger from `pull_request closed` to `push` on main (closes #54)
|
||||
- CI Gate 1: add ondrej/php PPA + composer package for PHP 8.2 on runners
|
||||
- CI repo-health: use `.mokogitea/workflows/` instead of `.gitea/workflows/`
|
||||
- PHPCS: fix all 7,539 PSR-12 violations across 74 files (0 errors remaining)
|
||||
- PHPStan: fix deprecated config options, mark as advisory until errors addressed
|
||||
- Branch protection: update check names from `MokoStandards CI` to `moko-platform CI`
|
||||
- Runner-03: fix Docker image label (`moko/runner-images` → self-hosted `git.mokoconsulting.tech/mokoconsulting/runner-image`)
|
||||
- Runbook 08: update with 3-runner fleet overview, per-runner configs, troubleshooting
|
||||
|
||||
### Changed
|
||||
- Rename MokoStandards references to moko-platform in config files
|
||||
|
||||
## [05.00.00] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- `server-autoheal.sh` — boot-check, split system/content backups, self-installing with cron + systemd hook
|
||||
- Grafana library panels: legend (list, right) and multi-tooltip options on all 14 panels
|
||||
- Prometheus targets volume mount in monitoring Docker Compose
|
||||
|
||||
### Fixed
|
||||
- MokoWaaS dashboard: remove `v_hidden` column — use explicit `filterFieldsByName` regex instead of broken `excludeByName`
|
||||
- MokoWaaS dashboard: simplify probe queries (remove redundant `and on(site_name)` joins)
|
||||
|
||||
### Changed
|
||||
- Rename `gitea-server-setup` → `.mokogitea-private` in workflow EXCLUDE lists
|
||||
- Dolibarr Module ID Registry moved to MokoDolibarr wiki (moko-platform page is now a redirect)
|
||||
|
||||
## [04.09.00] - 2026-05-12
|
||||
|
||||
### Added
|
||||
- `<deploy>` section support in `.manifest.xml` schema: `source-dir`, `remote-subdir`, `excludes`, `dev-host`, `demo-host`
|
||||
- `manifest_read.php` now parses all deploy fields for CI consumption
|
||||
|
||||
### Changed
|
||||
- Deploy workflows can now read deploy paths from manifest instead of guessing from directory structure
|
||||
|
||||
## [04.08.00] - 2026-05-12
|
||||
|
||||
### Added
|
||||
- `cli/manifest_read.php` -- full `.manifest.xml` parser for CI consumption
|
||||
- Supports `--field`, `--all`, `--json`, and `--github-output` modes
|
||||
- Backward-compatible with `.moko-platform` (XML) and `.mokostandards` (YAML) formats
|
||||
- Replaces inline `sed` detection blocks in workflows
|
||||
|
||||
### Changed
|
||||
- Workflows (`auto-release`, `pre-release`, `pr-check`) now use `manifest_read.php` for platform detection
|
||||
- `entry-point` field from manifest replaces `find` tree scan for mod file discovery
|
||||
- Platform detection outputs all manifest fields to `GITHUB_OUTPUT` (name, org, language, package-type, etc.)
|
||||
|
||||
|
||||
|
||||
## [05.00.00] - 2026-05-11
|
||||
|
||||
### Added
|
||||
- Centralized MokoWaaS Grafana dashboard for all Joomla sites (2-column layout)
|
||||
- MokoStandards MCP server with 24 governance tools
|
||||
- Wiki health check and GitHub wiki mirror sync
|
||||
- Daily wiki sync workflow — mirrors all Gitea wikis to GitHub
|
||||
- CHANGELOG `[Unreleased]` section check in repo health (5 pts)
|
||||
- Client platform type with detection and structure definition
|
||||
- PHPStan, Gitleaks, and Renovate — templates, workflows, and docs
|
||||
- Cascade and branch protection workflow documentation
|
||||
- Branch protection setup workflow
|
||||
- Client-site definition
|
||||
- Pre-release workflow for manual dev/alpha/beta/rc builds
|
||||
- PR-check, security-audit, notify, cleanup workflow definitions
|
||||
- Expanded workflow suite (10 workflows from MokoOnyx)
|
||||
- `.gitea/workflows` definitions to Joomla structure defs
|
||||
- Joomla workflow templates from MokoOnyx
|
||||
- Cleanup script to remove `.claude/` and `.mcp.json` from repos
|
||||
- Auto-discover all repos with wikis across all orgs
|
||||
- CLAUDE.md to repo health check, flag unwanted files
|
||||
- `.moko-platform` manifest (replaces `.mokostandards`)
|
||||
- PR branch policy check workflow
|
||||
|
||||
### Changed
|
||||
- Major version bump: `04.05.00` → `05.00.00` across all definitions, templates, and wiki
|
||||
- Grafana endpoint dashboards: 2 columns per row (reduced congestion)
|
||||
- Sync engine clones template repos at runtime for workflows
|
||||
- Simplified platform types across definitions and sync engine
|
||||
- Removed `templates/github` — all CI/templates now in `.gitea/`
|
||||
- Removed `templates/workflows` — canonical source is now template repos
|
||||
- Updated mokostandards xmlns to point to MokoStandards-API repo
|
||||
- Comprehensive repo health check updates
|
||||
|
||||
### Fixed
|
||||
- Remove gitea-actions[bot] from push whitelist (not a real user)
|
||||
- Delete-then-create branch protection rules to avoid 422
|
||||
- Patch version bump in pre-release workflow
|
||||
- Always emit `<client>` tag in UpdateXmlGenerator
|
||||
- Rewrite `updates.xml.template` with 5 stability channels
|
||||
- Migrate `.mokostandards` from `.github/` to `.gitea/` on Gitea
|
||||
|
||||
## [04.05.00] - 2026-03-15
|
||||
|
||||
### Added
|
||||
- Dual-platform support (Gitea + GitHub) and Joomla template tooling
|
||||
- Templates, CLI dirs, docs, and Gitea-first platform config
|
||||
- Sync to all branches, listBranches, ext-zip
|
||||
- All templates from MokoStandards
|
||||
|
||||
### Changed
|
||||
- Migrated to Gitea-only workflows and API
|
||||
- Converted all gh CLI calls to Gitea API curl across workflow templates
|
||||
- Gitea-primary tokens: GA_TOKEN for Gitea API, GH_TOKEN for GitHub mirror
|
||||
- Updated all references to MokoConsulting org and Gitea URLs
|
||||
|
||||
### Fixed
|
||||
- Guzzle base_uri resolution for Gitea API paths
|
||||
- Replace all hardcoded GitHub API URLs with platform adapter pattern
|
||||
- Split repoRoot into apiRoot + standardsRoot
|
||||
- Auto-release template: use Gitea API for main sync, auth push URL
|
||||
- Bulk_sync: resolve label names to IDs, fix username
|
||||
- Remove sha256: prefix from update XML templates
|
||||
|
||||
## [04.00.00] - 2026-01-01
|
||||
|
||||
- Initial release: MokoStandards Enterprise API extracted from MokoStandards
|
||||
## [09.20] --- 2026-05-30
|
||||
|
||||
@@ -1,103 +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** | 06.00.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) |
|
||||
| `definitions/` | Repository structure definitions (HCL format) |
|
||||
| `templates/` | Workflow templates, config templates, docs templates |
|
||||
| `.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
-30
@@ -1,30 +1,161 @@
|
||||
# Contributing to moko-platform
|
||||
|
||||
Thank you for your interest in contributing to the Moko Consulting platform.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork** the repository
|
||||
2. Create a **feature branch** from `dev` (e.g., `feature/my-feature`)
|
||||
3. Make your changes following [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
|
||||
4. Submit a **Pull Request** targeting `dev`
|
||||
|
||||
## Branch Policy
|
||||
|
||||
- `feature/*`, `fix/*` branches target `dev`
|
||||
- `hotfix/*` branches may target `dev` or `main`
|
||||
- `dev` merges to `main` for releases
|
||||
|
||||
## Code Standards
|
||||
|
||||
- PHP: follow PSR-12, use tabs for indentation
|
||||
- All files must include the Moko copyright header and SPDX identifier
|
||||
- Scripts must be self-contained (no external dependencies unless via composer)
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Use the [issue tracker](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/issues) 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,16 +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.25.03
|
||||
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,10 +50,9 @@ 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 AuditLogger $logger;
|
||||
private Config $config;
|
||||
|
||||
protected function configure(): void
|
||||
@@ -85,7 +84,6 @@ class BulkJoomlaTemplate extends CliFramework
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('joomla_template');
|
||||
$org = $this->getArgument('--org', self::DEFAULT_ORG);
|
||||
$platform = $this->adapter->getPlatformName();
|
||||
$this->log("Platform: {$platform} | Organization: {$org}", 'INFO');
|
||||
@@ -320,7 +318,7 @@ class BulkJoomlaTemplate extends CliFramework
|
||||
$name,
|
||||
$path,
|
||||
$content,
|
||||
"chore: update {$path} from MokoStandards",
|
||||
"chore: update {$path} from moko-platform",
|
||||
$existingSha,
|
||||
$branch
|
||||
);
|
||||
|
||||
+48
-49
@@ -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;
|
||||
@@ -66,9 +66,6 @@ class BulkSync extends CliFramework
|
||||
private AuditLogger $logger;
|
||||
private CheckpointManager $checkpoints;
|
||||
private MetricsCollector $metrics;
|
||||
private SecurityValidator $security;
|
||||
private PluginFactory $pluginFactory;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
private Config $config;
|
||||
|
||||
/** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */
|
||||
@@ -98,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()) {
|
||||
@@ -159,6 +156,11 @@ class BulkSync extends CliFramework
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sync universal workflows from Template-Generic → other templates first
|
||||
$this->log("📋 Syncing universal workflows to template repos...", 'INFO');
|
||||
$templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org);
|
||||
$this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO');
|
||||
|
||||
// Execute synchronization
|
||||
$this->log("🔄 Starting synchronization...", 'INFO');
|
||||
$results = $this->executeSynchronization($org, $repositories, $alreadyProcessed);
|
||||
@@ -178,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
|
||||
@@ -204,7 +206,6 @@ class BulkSync extends CliFramework
|
||||
$this->logger = new AuditLogger('bulk_sync');
|
||||
$this->metrics = new MetricsCollector();
|
||||
$this->checkpoints = new CheckpointManager('.checkpoints');
|
||||
$this->security = new SecurityValidator();
|
||||
$this->synchronizer = new RepositorySynchronizer(
|
||||
$this->api,
|
||||
$this->logger,
|
||||
@@ -215,8 +216,6 @@ class BulkSync extends CliFramework
|
||||
);
|
||||
|
||||
// Initialize plugin system
|
||||
$this->pluginFactory = new PluginFactory($this->logger, $this->metrics);
|
||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||
|
||||
$this->log("✓ Enterprise components initialized for platform: {$platform}", 'INFO');
|
||||
return true;
|
||||
@@ -245,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
|
||||
{
|
||||
@@ -288,7 +287,7 @@ class BulkSync extends CliFramework
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_merge($priority, $rest));
|
||||
return array_merge($priority, $rest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,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');
|
||||
@@ -838,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
|
||||
@@ -873,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'],
|
||||
@@ -913,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'],
|
||||
@@ -926,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
|
||||
}
|
||||
@@ -1025,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';
|
||||
@@ -1056,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) {
|
||||
@@ -1077,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.
|
||||
*/
|
||||
/**
|
||||
@@ -1120,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 |
|
||||
|-------|-------|
|
||||
@@ -1145,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 {
|
||||
@@ -1214,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
|
||||
{
|
||||
@@ -1233,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);
|
||||
@@ -1282,7 +1281,7 @@ class BulkSync extends CliFramework
|
||||
: "|---|---|---|---|";
|
||||
|
||||
$body = <<<MD
|
||||
## MokoStandards Bulk Sync Report
|
||||
## moko-platform Bulk Sync Report
|
||||
|
||||
**Organisation:** `{$org}`
|
||||
**Triggered:** {$now}{$force}
|
||||
@@ -1302,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,
|
||||
@@ -1310,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'])) {
|
||||
@@ -1320,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');
|
||||
@@ -1343,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.
|
||||
*/
|
||||
@@ -1389,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,
|
||||
@@ -1404,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']]
|
||||
|
||||
+48
-65
@@ -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
|
||||
@@ -26,7 +26,6 @@ use MokoEnterprise\{
|
||||
AuditLogger,
|
||||
CliFramework,
|
||||
Config,
|
||||
DefinitionParser,
|
||||
GitPlatformAdapter,
|
||||
MetricsCollector,
|
||||
PlatformAdapterFactory,
|
||||
@@ -36,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
|
||||
@@ -54,12 +53,11 @@ 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;
|
||||
private AuditLogger $logger;
|
||||
private DefinitionParser $defParser;
|
||||
private ProjectTypeDetector $typeDetector;
|
||||
|
||||
/**
|
||||
@@ -83,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;
|
||||
@@ -154,7 +152,6 @@ class PushFiles extends CliFramework
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
$this->logger = new AuditLogger('push_files');
|
||||
$this->defParser = new DefinitionParser();
|
||||
$this->typeDetector = new ProjectTypeDetector($this->logger);
|
||||
|
||||
$platform = $this->adapter->getPlatformName();
|
||||
@@ -198,43 +195,24 @@ class PushFiles extends CliFramework
|
||||
$platform = $this->detectRepoPlatform($org, $repo);
|
||||
$this->log(" {$repo}: platform = {$platform}", 'INFO');
|
||||
|
||||
// Build a destination→source lookup from the definition
|
||||
$defEntries = $this->defParser->parseForPlatform($platform, $repoRoot);
|
||||
$destToSource = [];
|
||||
foreach ($defEntries as $entry) {
|
||||
$destToSource[$entry['destination']] = $entry['source'];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($files as $fileSpec) {
|
||||
if (str_contains($fileSpec, ':')) {
|
||||
// Raw source:destination pair
|
||||
[$src, $dest] = explode(':', $fileSpec, 2);
|
||||
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
||||
if (!file_exists($srcAbs)) {
|
||||
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
||||
$this->log(" ✓ {$dest} (raw: {$src})", 'INFO');
|
||||
} else {
|
||||
// Destination path — look up in definition
|
||||
$dest = ltrim($fileSpec, '/');
|
||||
if (isset($destToSource[$dest])) {
|
||||
$src = $destToSource[$dest];
|
||||
$srcAbs = str_starts_with($src, '/')
|
||||
? $src
|
||||
: rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
||||
if (!file_exists($srcAbs)) {
|
||||
$this->log(" ⚠️ Template not found for {$repo}: {$src}", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
||||
$this->log(" ✓ {$dest}", 'INFO');
|
||||
} else {
|
||||
$this->log(" ⚠️ {$dest} not found in {$platform} definition for {$repo}", 'WARN');
|
||||
}
|
||||
// Same path as source and destination
|
||||
$src = $fileSpec;
|
||||
$dest = $fileSpec;
|
||||
}
|
||||
$dest = ltrim($dest, '/');
|
||||
$srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/');
|
||||
if (!file_exists($srcAbs)) {
|
||||
$this->log(" ⚠️ Source not found for {$repo}: {$src}", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$resolved[] = ['source' => $srcAbs, 'destination' => $dest];
|
||||
$this->log(" ✓ {$dest}", 'INFO');
|
||||
}
|
||||
|
||||
if (!empty($resolved)) {
|
||||
@@ -246,24 +224,29 @@ class PushFiles extends CliFramework
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform for a repo by checking its sync def file, falling back
|
||||
* to the live GitHub API detection used by bulk_sync.
|
||||
* Detect platform for a repo via manifest or live detection.
|
||||
*/
|
||||
private function detectRepoPlatform(string $org, string $repo): string
|
||||
{
|
||||
// Check local sync def first — fastest path
|
||||
$defDir = dirname(__DIR__) . '/definitions/sync';
|
||||
$defFile = "{$defDir}/{$repo}.def.tf";
|
||||
if (file_exists($defFile)) {
|
||||
$content = file_get_contents($defFile) ?: '';
|
||||
if (preg_match('/detected_platform\s*=\s*"([^"]+)"/', $content, $m)) {
|
||||
return $m[1];
|
||||
// Read platform from repo's .mokogitea/manifest.xml via API
|
||||
try {
|
||||
$fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main');
|
||||
$manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : '';
|
||||
if (!empty($manifestData)) {
|
||||
$xml = @simplexml_load_string($manifestData);
|
||||
if ($xml !== false) {
|
||||
$platform = (string)($xml->governance->platform ?? '');
|
||||
if (!empty($platform)) {
|
||||
return $platform;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fall through to local detection
|
||||
}
|
||||
|
||||
// Fall back to live detection
|
||||
try {
|
||||
$repoData = $this->api->get("/repos/{$org}/{$repo}");
|
||||
$result = $this->typeDetector->detect('.');
|
||||
return $result['type'] ?? 'default';
|
||||
} catch (\Exception $e) {
|
||||
@@ -354,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,
|
||||
@@ -431,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;
|
||||
@@ -474,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) . ") |"
|
||||
@@ -488,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 |
|
||||
|-------|-------|
|
||||
@@ -504,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", [
|
||||
@@ -567,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
|
||||
@@ -615,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,
|
||||
@@ -630,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');
|
||||
@@ -655,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
-16
@@ -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',
|
||||
@@ -58,8 +58,6 @@ class RepoCleanup extends CliFramework
|
||||
|
||||
private ApiClient $api;
|
||||
private GitPlatformAdapter $adapter;
|
||||
private AuditLogger $logger;
|
||||
private MetricsCollector $metrics;
|
||||
protected bool $dryRun = false;
|
||||
private float $startTime;
|
||||
|
||||
@@ -99,10 +97,8 @@ class RepoCleanup extends CliFramework
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->logger = new AuditLogger('repo_cleanup');
|
||||
$this->metrics = new MetricsCollector('repo_cleanup');
|
||||
|
||||
$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) {
|
||||
@@ -229,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 ──────────────────────────────────────────────
|
||||
@@ -467,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();
|
||||
}
|
||||
@@ -483,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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
@@ -18,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: .)
|
||||
@@ -87,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',
|
||||
@@ -107,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,38 +134,94 @@ const COMMAND_MAP = [
|
||||
'release:notes' => 'cli/release_notes.php',
|
||||
'release:validate' => 'cli/release_validate.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',
|
||||
'changelog:prune' => 'cli/changelog_prune.php',
|
||||
|
||||
// Version management
|
||||
'version:read' => 'cli/version_read.php',
|
||||
'version:bump' => 'cli/version_bump.php',
|
||||
'version:check' => 'cli/version_check.php',
|
||||
'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',
|
||||
@@ -185,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);
|
||||
}
|
||||
@@ -256,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 {
|
||||
@@ -265,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
-130
@@ -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,146 +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: Remove sync definition ──────────────────────────────────────
|
||||
echo "Step 5: Removing sync definition...\n";
|
||||
$defFile = "{$repoRoot}/definitions/sync/{$repoName}.def.tf";
|
||||
if (file_exists($defFile)) {
|
||||
if (!$dryRun) {
|
||||
unlink($defFile);
|
||||
echo " Removed: {$defFile}\n";
|
||||
} else {
|
||||
echo " (dry-run) would remove {$defFile}\n";
|
||||
}
|
||||
} else {
|
||||
echo " No sync definition found\n";
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/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/branch_rename.php
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class BranchRenameCli extends CliFramework
|
||||
{
|
||||
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', '');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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) ?: [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$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: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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());
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/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/changelog_prune.php
|
||||
* BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$keep = (int) $this->getArgument('--keep');
|
||||
|
||||
$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);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ChangelogPruneCli();
|
||||
exit($app->execute());
|
||||
+36
-79
@@ -12,13 +12,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_dashboard.php
|
||||
* VERSION: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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('GA_TOKEN') ?: '';
|
||||
$this->token = getenv('MOKOGITEA_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
if ($this->token === '') {
|
||||
$this->log('ERROR: --token or GA_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 GA_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());
|
||||
|
||||
+198
-188
@@ -1,188 +1,198 @@
|
||||
#!/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/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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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);
|
||||
#!/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/client_health_check.php
|
||||
* BRIEF: Verify a client site's update server, installed version, and release availability
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$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: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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());
|
||||
|
||||
+69
-112
@@ -12,13 +12,17 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/client_provision.php
|
||||
* VERSION: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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,12 +61,12 @@ 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;
|
||||
}
|
||||
|
||||
$this->giteaToken = $this->config['gitea_token']
|
||||
?? getenv('GA_TOKEN') ?: '';
|
||||
?? getenv('MOKOGITEA_TOKEN') ?: '';
|
||||
$this->grafanaUrl = $this->config['grafana_url']
|
||||
?? getenv('GRAFANA_URL') ?: '';
|
||||
$this->grafanaToken = $this->config['grafana_token']
|
||||
@@ -65,7 +75,7 @@ final class ClientProvision
|
||||
?? $this->giteaUrl;
|
||||
|
||||
if ($this->giteaToken === '') {
|
||||
$this->log('ERROR: gitea_token or GA_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(' GA_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('GA_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: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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: 05.00.01
|
||||
* VERSION: 09.25.03
|
||||
* 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());
|
||||
|
||||
+144
-136
@@ -1,136 +1,144 @@
|
||||
#!/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/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;
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1];
|
||||
if ($arg === '--github-output') $ghOutput = true;
|
||||
}
|
||||
|
||||
$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);
|
||||
#!/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/joomla_compat_check.php
|
||||
* BRIEF: Check if extension targetplatform regex matches the latest Joomla version
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new JoomlaCompatCheckCli();
|
||||
exit($app->execute());
|
||||
|
||||
+72
-32
@@ -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,12 +25,20 @@ 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
|
||||
*
|
||||
* Creates and manages Joomla extension releases on Gitea, including
|
||||
* package building, asset upload, and update stream management.
|
||||
*
|
||||
* @since 04.06.00
|
||||
*/
|
||||
class JoomlaRelease extends CliFramework
|
||||
{
|
||||
private const VERSION = '04.06.00';
|
||||
private const ORG = 'mokoconsulting-tech';
|
||||
private const VERSION = '09.23.00';
|
||||
private const ORG = 'MokoConsulting';
|
||||
|
||||
private const STABILITY_TAGS = [
|
||||
'development' => 'development',
|
||||
@@ -47,18 +56,17 @@ class JoomlaRelease extends CliFramework
|
||||
'stable' => '',
|
||||
];
|
||||
|
||||
private ApiClient $api;
|
||||
private AuditLogger $logger;
|
||||
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
|
||||
@@ -68,7 +76,7 @@ class JoomlaRelease extends CliFramework
|
||||
$stability = (string) $this->getArgument('--stability');
|
||||
$dryRun = (bool) $this->getArgument('--dry-run');
|
||||
|
||||
if (!isset(self::STABILITY_TAGS[$stability])) {
|
||||
if (!array_key_exists($stability, self::STABILITY_TAGS)) {
|
||||
$this->log('ERROR', "Invalid stability: {$stability}. Use: " . implode(', ', array_keys(self::STABILITY_TAGS)));
|
||||
return 1;
|
||||
}
|
||||
@@ -76,11 +84,12 @@ class JoomlaRelease extends CliFramework
|
||||
$config = Config::load();
|
||||
$this->adapter = PlatformAdapterFactory::create($config);
|
||||
$this->api = $this->adapter->getApiClient();
|
||||
$this->logger = new AuditLogger('joomla_release');
|
||||
|
||||
if ($repo !== '') {
|
||||
$path = $this->cloneRepo($repo);
|
||||
if ($path === null) { return 1; }
|
||||
if ($path === null) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$path = rtrim($path, '/\\');
|
||||
|
||||
@@ -112,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";
|
||||
@@ -143,10 +153,11 @@ class JoomlaRelease extends CliFramework
|
||||
}
|
||||
|
||||
// ── Step 4: Upload to GitHub Release ──────────────────────────
|
||||
$repoFullName = self::ORG . '/' . ($repo ?: basename($path));
|
||||
$repoFullName = self::ORG . '/' . ($repo ?: basename(realpath($path) ?: $path));
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->ensureRelease($repoFullName, $releaseTag, $displayVersion, $stability);
|
||||
$packageName = "{$prefix}{$meta['element']}-{$displayVersion}";
|
||||
$this->ensureRelease($repoFullName, $releaseTag, $displayVersion, $stability, $meta['name'], $packageName);
|
||||
$this->uploadAsset($repoFullName, $releaseTag, $zipPath, $zipName);
|
||||
$this->uploadAsset($repoFullName, $releaseTag, $tarPath, $tarName);
|
||||
$this->log('SUCCESS', "Uploaded to release: {$releaseTag}");
|
||||
@@ -184,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;
|
||||
@@ -207,7 +220,9 @@ class JoomlaRelease extends CliFramework
|
||||
|
||||
// Templates don't have <element> — derive from <name>
|
||||
if ($element === '') {
|
||||
$element = strtolower(str_replace(' ', '', $name));
|
||||
// Strip type prefix (e.g. "Template - ") before deriving element
|
||||
$baseName = preg_replace('/^(Package|Plugin|Module|Component|Template|Library|File)\s*-\s*/i', '', $name);
|
||||
$element = strtolower(str_replace([' ', '-'], '', $baseName));
|
||||
}
|
||||
|
||||
$tp = '';
|
||||
@@ -226,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];
|
||||
}
|
||||
@@ -292,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}");
|
||||
@@ -312,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
|
||||
@@ -333,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();
|
||||
@@ -350,24 +375,39 @@ 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): 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})");
|
||||
try {
|
||||
$this->api->get("/repos/{$repo}/releases/tags/{$tag}");
|
||||
} catch (\Exception $e) {
|
||||
$this->api->post("/repos/{$repo}/releases", [
|
||||
'tag_name' => $tag,
|
||||
'name' => ($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})",
|
||||
'body' => "## {$version}\n\nCreated by MokoStandards release pipeline.",
|
||||
'name' => $releaseName,
|
||||
'body' => "## {$version}\n\nCreated by moko-platform release pipeline.",
|
||||
'prerelease' => ($stability !== 'stable'),
|
||||
]);
|
||||
}
|
||||
@@ -380,7 +420,7 @@ class JoomlaRelease extends CliFramework
|
||||
|
||||
foreach ($release['assets'] ?? [] as $asset) {
|
||||
if ($asset['name'] === $fileName) {
|
||||
$this->api->delete("/repos/{$repo}/releases/assets/{$asset['id']}");
|
||||
$this->api->delete("/repos/{$repo}/releases/{$release['id']}/assets/{$asset['id']}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +469,7 @@ class JoomlaRelease extends CliFramework
|
||||
$lines[] = ' <tags>';
|
||||
$lines[] = " <tag>{$stability}</tag>";
|
||||
$lines[] = ' </tags>';
|
||||
$lines[] = " <infourl title=\"{$meta['name']}\">https://github.com/" . self::ORG . "</infourl>";
|
||||
$lines[] = " <infourl title=\"{$meta['name']}\">https://git.mokoconsulting.tech/" . self::ORG . "</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$zipUrl}</downloadurl>";
|
||||
$lines[] = " <downloadurl type=\"full\" format=\"tar.gz\">{$tarUrl}</downloadurl>";
|
||||
|
||||
@@ -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());
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/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_element.php
|
||||
* BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
$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.03
|
||||
* 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
-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
|
||||
@@ -9,165 +10,161 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/manifest_read.php
|
||||
* VERSION: 04.09.00
|
||||
* VERSION: 09.25.03
|
||||
* 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 ?? ''),
|
||||
'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"} ?? ''),
|
||||
'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.03
|
||||
* 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}/manifest",
|
||||
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('GA_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());
|
||||
|
||||
+20
-99
@@ -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,109 +10,29 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/release_cascade.php
|
||||
* BRIEF: Delete lesser pre-release channels from Gitea when promoting stability
|
||||
*
|
||||
* Usage:
|
||||
* php release_cascade.php --stability stable --token TOKEN --api-base URL
|
||||
* php release_cascade.php --stability rc --token TOKEN --api-base URL
|
||||
*
|
||||
* Cascade rules:
|
||||
* stable -> deletes development, alpha, beta, release-candidate
|
||||
* rc -> deletes development, alpha, beta
|
||||
* beta -> deletes development, alpha
|
||||
* alpha -> deletes development
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$stability = null;
|
||||
$token = null;
|
||||
$apiBase = null;
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
foreach ($argv as $i => $arg) {
|
||||
if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === null) {
|
||||
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
|
||||
}
|
||||
|
||||
if ($stability === null || $token === null || $apiBase === null) {
|
||||
fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n");
|
||||
fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n");
|
||||
fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Define cascade hierarchy
|
||||
$cascadeMap = [
|
||||
'stable' => ['development', 'alpha', 'beta', 'release-candidate'],
|
||||
'release-candidate' => ['development', 'alpha', 'beta'],
|
||||
'rc' => ['development', 'alpha', 'beta'],
|
||||
'beta' => ['development', 'alpha'],
|
||||
'alpha' => ['development'],
|
||||
];
|
||||
|
||||
if (!isset($cascadeMap[$stability])) {
|
||||
fwrite(STDERR, "Unknown stability level: {$stability}\n");
|
||||
fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$tagsToDelete = $cascadeMap[$stability];
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($tagsToDelete as $tag) {
|
||||
// Get release by tag
|
||||
$ch = curl_init("{$apiBase}/releases/tags/{$tag}");
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$releaseId = $data['id'] ?? null;
|
||||
|
||||
if ($releaseId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete release
|
||||
$ch = curl_init("{$apiBase}/releases/{$releaseId}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Delete tag
|
||||
$ch = curl_init("{$apiBase}/tags/{$tag}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ["Authorization: token {$token}"],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
echo "Deleted: {$tag} (release id: {$releaseId})\n";
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
echo "Cleaned up {$deleted} pre-release channel(s)\n";
|
||||
exit(0);
|
||||
$app = new ReleaseCascadeCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/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/release_create.php
|
||||
* BRIEF: Create or overwrite a Gitea release with proper naming
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleaseCreateCli extends CliFramework
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === '') {
|
||||
$envToken = getenv('MOKOGITEA_TOKEN');
|
||||
if ($envToken === false || $envToken === '') {
|
||||
$envToken = getenv('GITEA_TOKEN');
|
||||
}
|
||||
if ($envToken !== false && $envToken !== '') {
|
||||
$token = $envToken;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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]);
|
||||
}
|
||||
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(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$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 GA_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('GA_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());
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/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/release_mirror.php
|
||||
* BRIEF: Mirror a Gitea release (with assets) to a GitHub repository
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class ReleaseMirrorCli extends CliFramework
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$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());
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
#!/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/release_package.php
|
||||
* BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleasePackageCli extends CliFramework
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private array $excludePatterns = [
|
||||
'sftp-config*',
|
||||
'.ftpignore',
|
||||
'*.ppk',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'.env*',
|
||||
'*.local',
|
||||
'.build-trigger',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release');
|
||||
$this->addArgument('--path', 'Repository root path', '.');
|
||||
$this->addArgument('--version', 'Release version', '');
|
||||
$this->addArgument('--tag', 'Release tag name', '');
|
||||
$this->addArgument('--token', 'Gitea API token', '');
|
||||
$this->addArgument('--api-base', 'Gitea API base URL', '');
|
||||
$this->addArgument('--repo', 'Repo name for element detection fallback', '');
|
||||
$this->addArgument('--output', 'Output directory for built packages', '');
|
||||
}
|
||||
|
||||
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');
|
||||
$repoName = $this->getArgument('--repo');
|
||||
$outputDir = $this->getArgument('--output');
|
||||
|
||||
if ($outputDir === '' || $outputDir === null) {
|
||||
$outputDir = sys_get_temp_dir();
|
||||
}
|
||||
|
||||
// Allow token from environment
|
||||
if ($token === '' || $token === null) {
|
||||
$token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: '');
|
||||
}
|
||||
|
||||
if ($version === '' || $tag === '' || $token === '' || $apiBase === '') {
|
||||
$this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL");
|
||||
$this->log('ERROR', " --repo REPO Repo name for element detection fallback");
|
||||
$this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())");
|
||||
$this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
// ── Read platform from .mokogitea/manifest.xml ───────────────────────
|
||||
$detectedPlatform = 'generic';
|
||||
$detectedEntryPoint = '';
|
||||
$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,
|
||||
};
|
||||
}
|
||||
$detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detect element metadata from manifest XML ────────────────────────
|
||||
$extElement = '';
|
||||
$extType = '';
|
||||
$extFolder = '';
|
||||
$typePrefix = '';
|
||||
|
||||
SourceResolver::warnIfLegacy($root);
|
||||
$manifestFiles = array_merge(
|
||||
SourceResolver::globSource($root, 'pkg_*.xml'),
|
||||
SourceResolver::globSource($root, '*.xml'),
|
||||
glob("{$root}/*.xml") ?: []
|
||||
);
|
||||
|
||||
$extManifest = null;
|
||||
foreach ($manifestFiles as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if ($content !== false && strpos($content, '<extension') !== false) {
|
||||
$extManifest = $file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($extManifest !== null) {
|
||||
$xml = file_get_contents($extManifest);
|
||||
if ($xml === false) {
|
||||
$xml = '';
|
||||
}
|
||||
|
||||
// Extension type and folder
|
||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||
$extType = $tm[1];
|
||||
}
|
||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||
$extFolder = $gm[1];
|
||||
}
|
||||
|
||||
// Element name
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
|
||||
$extElement = $em[1];
|
||||
}
|
||||
if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) {
|
||||
$extElement = $mm[1];
|
||||
}
|
||||
if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) {
|
||||
$extElement = $pm[1];
|
||||
}
|
||||
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/packagename>/', $xml, $pn)) {
|
||||
$extElement = $pn[1];
|
||||
}
|
||||
if ($extElement === '') {
|
||||
$extElement = strtolower(basename($extManifest, '.xml'));
|
||||
if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to repo name
|
||||
if ($extElement === '') {
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root)));
|
||||
}
|
||||
|
||||
// Strip existing type prefix to prevent duplication
|
||||
$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $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;
|
||||
}
|
||||
|
||||
echo "Element: {$typePrefix}{$extElement}\n";
|
||||
echo "Type: {$extType}\n";
|
||||
|
||||
// ── Compute filenames ────────────────────────────────────────────────
|
||||
$baseName = "{$typePrefix}{$extElement}-{$version}";
|
||||
$zipFile = "{$outputDir}/{$baseName}.zip";
|
||||
$tarFile = "{$outputDir}/{$baseName}.tar.gz";
|
||||
|
||||
echo "ZIP: {$baseName}.zip\n";
|
||||
echo "TAR: {$baseName}.tar.gz\n";
|
||||
|
||||
// ── Find source directory ────────────────────────────────────────────
|
||||
$sourceDir = null;
|
||||
|
||||
if ($detectedEntryPoint !== '') {
|
||||
$entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/');
|
||||
if (is_dir("{$root}/{$entryDir}")) {
|
||||
$sourceDir = "{$root}/{$entryDir}";
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
$sourceDir = SourceResolver::resolveAbsolute($root);
|
||||
}
|
||||
|
||||
if ($sourceDir === null) {
|
||||
echo "No source/ or src/ directory found — skipping package build\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
echo "Source: {$sourceDir}\n";
|
||||
|
||||
// ── Build packages ───────────────────────────────────────────────────
|
||||
$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages"));
|
||||
|
||||
if ($isJoomlaPackage) {
|
||||
echo "Building Joomla package (sub-extensions)...\n";
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: [];
|
||||
foreach ($packageDirs as $pkgDir) {
|
||||
$subName = basename($pkgDir);
|
||||
$subZipPath = "{$outputDir}/{$subName}.zip";
|
||||
|
||||
// If sub-package is a full repo checkout (e.g. git submodule),
|
||||
// look for a source/ or src/ subdirectory containing a Joomla manifest XML
|
||||
// and zip that instead of the repo root.
|
||||
$subSourceDir = $pkgDir;
|
||||
$subSrcAbs = SourceResolver::resolveAbsolute($pkgDir);
|
||||
if ($subSrcAbs !== null) {
|
||||
$srcManifests = array_merge(
|
||||
glob("{$subSrcAbs}/*.xml") ?: [],
|
||||
glob("{$subSrcAbs}/pkg_*.xml") ?: []
|
||||
);
|
||||
foreach ($srcManifests as $mf) {
|
||||
if (strpos(file_get_contents($mf) ?: '', '<extension') !== false) {
|
||||
$subSourceDir = $subSrcAbs;
|
||||
$subSrcName = SourceResolver::resolve($pkgDir);
|
||||
echo " Sub-package {$subName}: using {$subSrcName}/ entry-point\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$subZip = new \ZipArchive();
|
||||
if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}");
|
||||
continue;
|
||||
}
|
||||
$this->addDirToZip($subZip, $subSourceDir, '', $this->excludePatterns);
|
||||
$subZip->close();
|
||||
|
||||
$zip->addFile($subZipPath, "packages/{$subName}.zip");
|
||||
echo " Sub-package: {$subName}.zip\n";
|
||||
}
|
||||
|
||||
$pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: [];
|
||||
foreach ($pkgManifests as $pkgXml) {
|
||||
$pkgContent = file_get_contents($pkgXml);
|
||||
if (strpos($pkgContent, '<files>') !== false && strpos($pkgContent, 'folder="packages"') === false) {
|
||||
$pkgContent = str_replace('<files>', '<files folder="packages">', $pkgContent);
|
||||
file_put_contents($pkgXml, $pkgContent);
|
||||
echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
$topLevelFiles = array_merge(
|
||||
glob("{$sourceDir}/*.xml") ?: [],
|
||||
glob("{$sourceDir}/*.php") ?: []
|
||||
);
|
||||
foreach ($topLevelFiles as $tlFile) {
|
||||
if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) {
|
||||
$zip->addFile($tlFile, basename($tlFile));
|
||||
}
|
||||
}
|
||||
|
||||
$topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: [];
|
||||
foreach ($topLevelDirs as $tlDir) {
|
||||
$dirName = basename($tlDir);
|
||||
if ($dirName === 'packages') {
|
||||
continue;
|
||||
}
|
||||
$this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns);
|
||||
echo " Included dir: {$dirName}/\n";
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
echo "ZIP created: {$zipFile}\n";
|
||||
} else {
|
||||
echo "Building standard extension ZIP...\n";
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
$this->log('ERROR', "Failed to create ZIP: {$zipFile}");
|
||||
return 1;
|
||||
}
|
||||
$this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns);
|
||||
$zip->close();
|
||||
echo "ZIP created: {$zipFile}\n";
|
||||
}
|
||||
|
||||
// ── Build tar.gz ─────────────────────────────────────────────────────
|
||||
$tarExcludeArgs = [];
|
||||
foreach ($this->excludePatterns as $pattern) {
|
||||
$tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern);
|
||||
}
|
||||
|
||||
$tarCommand = sprintf(
|
||||
'tar -czf %s -C %s %s .',
|
||||
escapeshellarg($tarFile),
|
||||
escapeshellarg($sourceDir),
|
||||
implode(' ', $tarExcludeArgs)
|
||||
);
|
||||
|
||||
$tarReturnCode = 0;
|
||||
$tarOutputLines = [];
|
||||
exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode);
|
||||
|
||||
if (!file_exists($tarFile)) {
|
||||
$this->log('ERROR', "Failed to create tar.gz: {$tarFile}");
|
||||
if ($tarOutputLines !== []) {
|
||||
$this->log('ERROR', implode("\n", $tarOutputLines));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
echo "TAR created: {$tarFile}\n";
|
||||
|
||||
// ── Compute SHA-256 checksums ────────────────────────────────────────
|
||||
$zipHash = hash_file('sha256', $zipFile);
|
||||
$tarHash = hash_file('sha256', $tarFile);
|
||||
|
||||
if ($zipHash === false || $tarHash === false) {
|
||||
$this->log('ERROR', "Failed to compute SHA-256 checksums");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$zipSha = "{$zipFile}.sha256";
|
||||
$tarSha = "{$tarFile}.sha256";
|
||||
|
||||
file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n");
|
||||
file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n");
|
||||
|
||||
echo "SHA-256 (ZIP): {$zipHash}\n";
|
||||
echo "SHA-256 (TAR): {$tarHash}\n";
|
||||
echo "sha256_zip={$zipHash}\n";
|
||||
echo "zip_name={$baseName}.zip\n";
|
||||
|
||||
// Write to GITHUB_OUTPUT if available
|
||||
$ghOutput = getenv('GITHUB_OUTPUT');
|
||||
if ($ghOutput) {
|
||||
file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
// ── Get release ID from tag ──────────────────────────────────────────
|
||||
$result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token);
|
||||
if ($result['data'] === null || !isset($result['data']['id'])) {
|
||||
$this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$releaseId = (int) $result['data']['id'];
|
||||
echo "Release ID: {$releaseId} (tag: {$tag})\n";
|
||||
|
||||
// ── Delete existing assets with same names ───────────────────────────
|
||||
$assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token);
|
||||
$existingAssets = $assetsResult['data'] ?? [];
|
||||
|
||||
$uploadNames = [
|
||||
"{$baseName}.zip",
|
||||
"{$baseName}.tar.gz",
|
||||
"{$baseName}.zip.sha256",
|
||||
"{$baseName}.tar.gz.sha256",
|
||||
];
|
||||
|
||||
foreach ($existingAssets as $asset) {
|
||||
if (!is_array($asset)) {
|
||||
continue;
|
||||
}
|
||||
$assetName = $asset['name'] ?? '';
|
||||
$assetId = $asset['id'] ?? 0;
|
||||
if (in_array($assetName, $uploadNames, true) && $assetId > 0) {
|
||||
$this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE');
|
||||
echo "Deleted existing asset: {$assetName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload assets ────────────────────────────────────────────────────
|
||||
$filesToUpload = [
|
||||
"{$baseName}.zip" => $zipFile,
|
||||
"{$baseName}.tar.gz" => $tarFile,
|
||||
"{$baseName}.zip.sha256" => $zipSha,
|
||||
"{$baseName}.tar.gz.sha256" => $tarSha,
|
||||
];
|
||||
|
||||
$uploaded = 0;
|
||||
foreach ($filesToUpload as $name => $localPath) {
|
||||
if (!file_exists($localPath)) {
|
||||
$this->log('ERROR', "File not found, skipping: {$localPath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name);
|
||||
$httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath);
|
||||
$status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})";
|
||||
echo "Upload: {$name} — {$status}\n";
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────
|
||||
echo "\n";
|
||||
echo "Package build complete\n";
|
||||
echo " Element: {$typePrefix}{$extElement}\n";
|
||||
echo " Version: {$version}\n";
|
||||
echo " Tag: {$tag}\n";
|
||||
echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n";
|
||||
|
||||
return $uploaded === count($filesToUpload) ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a Gitea API request.
|
||||
*
|
||||
* @return array{data: array<string, mixed>|null, code: int}
|
||||
*/
|
||||
private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return ['data' => null, 'code' => 0];
|
||||
}
|
||||
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 = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') {
|
||||
return ['data' => null, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file as a release asset.
|
||||
*/
|
||||
private function giteaUploadAsset(string $url, string $token, string $filePath): int
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return 0;
|
||||
}
|
||||
$fileContent = file_get_contents($filePath);
|
||||
if ($fileContent === false) {
|
||||
return 0;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: token {$token}",
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename matches any exclusion pattern.
|
||||
*/
|
||||
private function isExcluded(string $filename, array $patterns): bool
|
||||
{
|
||||
$basename = basename($filename);
|
||||
foreach ($patterns as $pattern) {
|
||||
if (fnmatch($pattern, $basename)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add files from a directory to a ZipArchive.
|
||||
*/
|
||||
private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file instanceof \SplFileInfo || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$realPath = $file->getRealPath();
|
||||
if ($realPath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isExcluded($file->getFilename(), $excludes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($realPath, strlen($sourceDir) + 1);
|
||||
// Normalise to forward slashes for ZIP compatibility
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
$archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath;
|
||||
$zip->addFile($realPath, $archivePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ReleasePackageCli();
|
||||
exit($app->execute());
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/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/release_promote.php
|
||||
* BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleasePromoteCli extends CliFramework
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ReleasePromoteCli();
|
||||
exit($app->execute());
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/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/release_publish.php
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
if (empty($stability) || empty($token)) {
|
||||
$this->log('ERROR', "Usage: release_publish.php --stability <dev|alpha|beta|rc|stable> --token TOKEN [options]");
|
||||
return 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
$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"
|
||||
));
|
||||
}
|
||||
|
||||
$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) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new ReleasePublishCli();
|
||||
exit($app->execute());
|
||||
+222
-160
@@ -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,170 +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 = 'joomla';
|
||||
$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 === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1];
|
||||
if ($arg === '--output-summary') $outputSummary = true;
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ReleaseValidateCli extends CliFramework
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$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';
|
||||
}
|
||||
}
|
||||
$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 {
|
||||
$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"
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$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. 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;
|
||||
foreach (glob("{$dir}/*.xml") as $xmlFile) {
|
||||
$content = file_get_contents($xmlFile);
|
||||
if (strpos($content, '<extension') !== false) {
|
||||
$manifest = $xmlFile;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`");
|
||||
}
|
||||
} else {
|
||||
addResult('Manifest version', 'FAIL', 'No <version> tag in manifest');
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 Validation\n\n{$table}\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: 01.00.00
|
||||
* VERSION: 09.25.03
|
||||
* 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
-150
@@ -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,166 +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,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'VERSION — immutable snapshots',
|
||||
'branch' => 'version/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'DEV — prevent branch deletion',
|
||||
'branch' => 'dev/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => true,
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'RC — prevent branch deletion',
|
||||
'branch' => 'rc/*',
|
||||
'rules' => [
|
||||
'required_reviews' => 0,
|
||||
'enforce_admins' => 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());
|
||||
|
||||
+188
-209
@@ -1,209 +1,188 @@
|
||||
#!/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/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)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$path = '.';
|
||||
$maxImageKb = 500;
|
||||
$ghOutput = false;
|
||||
$strict = false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$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;
|
||||
}
|
||||
#!/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/theme_lint.php
|
||||
* BRIEF: Lint theme files -- CSS syntax, image sizes, hardcoded URLs
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class ThemeLintCli extends CliFramework
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
$errors = 0;
|
||||
$warnings = 0;
|
||||
|
||||
$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());
|
||||
|
||||
+395
-306
@@ -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,343 +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);
|
||||
}
|
||||
|
||||
$root = realpath($path) ?: $path;
|
||||
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');
|
||||
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
if ($version === '') {
|
||||
$this->log('ERROR', 'Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Strip suffix — stability is applied via --stability parameter
|
||||
$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$root = realpath($path) ?: $path;
|
||||
|
||||
if ($manifest === null) {
|
||||
fwrite(STDERR, "No Joomla XML manifest found in {$root}\n");
|
||||
exit(1);
|
||||
}
|
||||
// -- 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"} ?? '');
|
||||
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$xml = file_get_contents($manifest);
|
||||
if (empty($org)) {
|
||||
$manifestOrg = (string)($mokoXml->identity->org ?? '');
|
||||
if ($manifestOrg !== '') {
|
||||
$org = $manifestOrg;
|
||||
}
|
||||
}
|
||||
if (empty($repo)) {
|
||||
$manifestName = (string)($mokoXml->identity->name ?? '');
|
||||
if ($manifestName !== '') {
|
||||
$repo = $manifestName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract fields via regex (more portable than SimpleXML for malformed manifests)
|
||||
$extName = '';
|
||||
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $m)) $extName = $m[1];
|
||||
// -- 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$extType = '';
|
||||
if (preg_match('/<extension[^>]*type="([^"]+)"/', $xml, $m)) $extType = $m[1];
|
||||
// -- Locate Joomla manifest ---------------------------------------------------
|
||||
$manifest = null;
|
||||
|
||||
$extElement = '';
|
||||
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $m)) $extElement = $m[1];
|
||||
// For packages, prefer <packagename> to avoid pkg_pkg_ duplication
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas)
|
||||
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement);
|
||||
$candidates = SourceResolver::globSource($root, 'pkg_*.xml');
|
||||
foreach ($candidates as $f) {
|
||||
if (strpos(file_get_contents($f), '<extension') !== false) {
|
||||
$manifest = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$extClient = '';
|
||||
if (preg_match('/<extension[^>]*client="([^"]+)"/', $xml, $m)) $extClient = $m[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$extFolder = '';
|
||||
if (preg_match('/<extension[^>]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1];
|
||||
if ($manifest === null && $detectedPlatform === 'joomla') {
|
||||
$this->log('ERROR', "No Joomla XML manifest found in {$root}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$targetPlatform = '';
|
||||
if (preg_match('/(<targetplatform[^\/]*\/>)/', $xml, $m)) $targetPlatform = $m[1];
|
||||
if (empty($targetPlatform)) {
|
||||
$targetPlatform = '<targetplatform name="joomla" version="(5|6)\..*" />';
|
||||
}
|
||||
// -- Parse extension metadata -------------------------------------------------
|
||||
$extName = '';
|
||||
$extType = '';
|
||||
$extElement = '';
|
||||
$extClient = '';
|
||||
$extFolder = '';
|
||||
$targetPlatform = '';
|
||||
$phpMinimum = '';
|
||||
|
||||
$phpMinimum = '';
|
||||
if (preg_match('/<php_minimum>([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1];
|
||||
if ($manifest !== null) {
|
||||
$xml = file_get_contents($manifest);
|
||||
|
||||
// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS)
|
||||
if (preg_match('/^[A-Z_]+$/', $extName)) {
|
||||
$iniFiles = [];
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (preg_match('/\.sys\.ini$/i', $file->getFilename())) {
|
||||
$iniFiles[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
foreach ($iniFiles as $ini) {
|
||||
$content = file_get_contents($ini);
|
||||
if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) {
|
||||
$extName = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
// Fallbacks
|
||||
if (empty($extName)) $extName = $repo ?: basename($root);
|
||||
if (empty($extType)) $extType = 'component';
|
||||
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 {
|
||||
$extName = $detectedName ?: ($repo ?: basename($root));
|
||||
$extElement = strtolower(str_replace([' ', '-'], '', $extName));
|
||||
$extType = $detectedPackageType ?: 'generic';
|
||||
$targetPlatform = "<targetplatform name=\"{$detectedPlatform}\" version=\".*\" />";
|
||||
}
|
||||
|
||||
// -- 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;
|
||||
}
|
||||
if (empty($extName)) {
|
||||
$extName = $repo ?: basename($root);
|
||||
}
|
||||
if (empty($extType)) {
|
||||
$extType = 'component';
|
||||
}
|
||||
|
||||
// -- 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($detectedDisplayName)) {
|
||||
$displayName = $detectedDisplayName;
|
||||
} elseif (!empty($detectedName)) {
|
||||
$displayName = $detectedName;
|
||||
} else {
|
||||
$displayName = $extName;
|
||||
}
|
||||
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-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;
|
||||
}
|
||||
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
];
|
||||
// -- 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Build update entries -----------------------------------------------------
|
||||
$releaseTag = $stabilityTagMap[$stability] ?? $stability;
|
||||
// -- Stability suffix map -----------------------------------------------------
|
||||
$stabilitySuffixMap = [
|
||||
'stable' => '',
|
||||
'rc' => '-rc',
|
||||
'beta' => '-beta',
|
||||
'alpha' => '-alpha',
|
||||
'development' => '-dev',
|
||||
'dev' => '-dev',
|
||||
];
|
||||
|
||||
// For the primary entry: apply suffix if not stable
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
$stabilityTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'rc',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'dev',
|
||||
'dev' => 'dev',
|
||||
];
|
||||
|
||||
$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip";
|
||||
$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}";
|
||||
$releaseTagMap = [
|
||||
'stable' => 'stable',
|
||||
'rc' => 'release-candidate',
|
||||
'beta' => 'beta',
|
||||
'alpha' => 'alpha',
|
||||
'development' => 'development',
|
||||
'dev' => 'development',
|
||||
];
|
||||
|
||||
// Build client tag
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} elseif ($extType === 'module' || $extType === 'plugin') {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
$primarySuffix = $stabilitySuffixMap[$stability] ?? '';
|
||||
$primaryVersion = $version . $primarySuffix;
|
||||
|
||||
// Build folder tag
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
$clientTag = '';
|
||||
if (!empty($extClient)) {
|
||||
$clientTag = " <client>{$extClient}</client>";
|
||||
} else {
|
||||
$clientTag = ' <client>site</client>';
|
||||
}
|
||||
|
||||
// PHP minimum tag
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
$folderTag = '';
|
||||
if (!empty($extFolder) && $extType === 'plugin') {
|
||||
$folderTag = " <folder>{$extFolder}</folder>";
|
||||
}
|
||||
|
||||
// SHA tag
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
$phpTag = '';
|
||||
if (!empty($phpMinimum)) {
|
||||
$phpTag = " <php_minimum>{$phpMinimum}</php_minimum>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single <update> entry for a given stability tag
|
||||
*/
|
||||
function buildEntry(
|
||||
string $tagName,
|
||||
string $entryVersion,
|
||||
string $entryDownloadUrl,
|
||||
string $extName,
|
||||
string $extElement,
|
||||
string $extType,
|
||||
string $clientTag,
|
||||
string $folderTag,
|
||||
string $infoUrl,
|
||||
string $targetPlatform,
|
||||
string $phpTag,
|
||||
string $shaTag
|
||||
): string {
|
||||
$lines = [];
|
||||
$lines[] = ' <update>';
|
||||
$lines[] = " <name>{$extName}</name>";
|
||||
$lines[] = " <description>{$extName} update</description>";
|
||||
// Element in updates.xml must match what Joomla stores in #__extensions
|
||||
// For packages: pkg_elementname. For plugins: elementname (folder handles grouping).
|
||||
$dbElement = ($extType === 'package') ? "pkg_{$extElement}" : $extElement;
|
||||
$lines[] = " <element>{$dbElement}</element>";
|
||||
$lines[] = " <type>{$extType}</type>";
|
||||
$lines[] = " <version>{$entryVersion}</version>";
|
||||
if (!empty($clientTag)) $lines[] = $clientTag;
|
||||
if (!empty($folderTag)) $lines[] = $folderTag;
|
||||
$lines[] = " <tags><tag>{$tagName}</tag></tags>";
|
||||
$lines[] = " <infourl title=\"{$extName}\">{$infoUrl}</infourl>";
|
||||
$lines[] = ' <downloads>';
|
||||
$lines[] = " <downloadurl type=\"full\" format=\"zip\">{$entryDownloadUrl}</downloadurl>";
|
||||
$lines[] = ' </downloads>';
|
||||
if (!empty($shaTag)) $lines[] = $shaTag;
|
||||
$lines[] = " {$targetPlatform}";
|
||||
if (!empty($phpTag)) $lines[] = $phpTag;
|
||||
$lines[] = ' <maintainer>Moko Consulting</maintainer>';
|
||||
$lines[] = ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>';
|
||||
$lines[] = ' </update>';
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
$shaTag = '';
|
||||
if (!empty($sha)) {
|
||||
$shaTag = " <sha256>{$sha}</sha256>";
|
||||
}
|
||||
|
||||
// -- Determine which channels to write ----------------------------------------
|
||||
// Stable cascades to all channels; pre-releases only write their level and below
|
||||
// Each channel gets its own suffixed version:
|
||||
// development -> 04.01.00-dev
|
||||
// alpha -> 04.01.00-alpha
|
||||
// beta -> 04.01.00-beta
|
||||
// rc -> 04.01.00-rc
|
||||
// stable -> 04.01.00
|
||||
$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable'];
|
||||
$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels);
|
||||
if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable
|
||||
// -- 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";
|
||||
|
||||
// Write only the current channel entry (not cascade)
|
||||
// Each channel release only creates its own entry; preserved entries handle other channels
|
||||
$entries = [];
|
||||
$channelName = $allChannels[$stabilityIndex];
|
||||
$channelSuffix = $stabilitySuffixMap[$channelName] ?? '';
|
||||
$channelVersion = $version . $channelSuffix;
|
||||
$channelTag = $stabilityTagMap[$channelName] ?? $channelName;
|
||||
$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip";
|
||||
$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}";
|
||||
$entries[] = $this->buildEntry(
|
||||
$joomlaTag,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$displayName,
|
||||
$stability,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag,
|
||||
$changelogUrl
|
||||
);
|
||||
|
||||
$entries[] = buildEntry(
|
||||
$channelName,
|
||||
$channelVersion,
|
||||
$channelDownloadUrl,
|
||||
$extName,
|
||||
$extElement,
|
||||
$extType,
|
||||
$clientTag,
|
||||
$folderTag,
|
||||
$channelInfoUrl,
|
||||
$targetPlatform,
|
||||
$phpTag,
|
||||
$shaTag
|
||||
);
|
||||
// -- Preserve existing entries for channels not being updated -----------------
|
||||
$dest = $outputFile ?? "{$root}/updates.xml";
|
||||
$preservedEntries = [];
|
||||
|
||||
// -- 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';
|
||||
}
|
||||
|
||||
if (file_exists($dest)) {
|
||||
$existingXml = @simplexml_load_file($dest);
|
||||
if ($existingXml) {
|
||||
// Channels we're writing — don't preserve these
|
||||
$writtenChannels = [];
|
||||
for ($i = 0; $i <= $stabilityIndex; $i++) {
|
||||
$writtenChannels[] = $allChannels[$i];
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingXml->update as $existingUpdate) {
|
||||
$existingTag = '';
|
||||
if (isset($existingUpdate->tags->tag)) {
|
||||
$existingTag = (string) $existingUpdate->tags->tag;
|
||||
}
|
||||
// Keep entries for channels we're NOT overwriting
|
||||
if (!empty($existingTag) && !in_array($existingTag, $writtenChannels, true)) {
|
||||
$preservedEntries[] = ' ' . trim($existingUpdate->asXML());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write updates.xml --------------------------------------------------------
|
||||
$year = date('Y');
|
||||
$output = <<<XML
|
||||
// -- 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
|
||||
@@ -355,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
-145
@@ -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,161 +10,219 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/updates_xml_sync.php
|
||||
* VERSION: 05.00.01
|
||||
* VERSION: 09.25.03
|
||||
* 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 --branches main --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)
|
||||
* --current Current branch to skip (required)
|
||||
* --version Version string for commit message (optional)
|
||||
* --token Gitea API token (default: env GA_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('GA_TOKEN') ?: '';
|
||||
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
|
||||
$org = getenv('GITEA_ORG') ?: '';
|
||||
$repo = getenv('GITEA_REPO') ?: '';
|
||||
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 === '--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 GA_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);
|
||||
}
|
||||
|
||||
$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());
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/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/version_auto_bump.php
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
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', '');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new VersionAutoBumpCli();
|
||||
exit($app->execute());
|
||||
+310
-91
@@ -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,106 +10,324 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_bump.php
|
||||
* BRIEF: Auto-increment patch version — checks both README.md and manifest XML, uses the higher version as base
|
||||
* 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};
|
||||
|
||||
// ── Read version from 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];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read version from Joomla manifest XML ────────────────────────────────────
|
||||
$manifestVersion = null;
|
||||
|
||||
// Check package manifest first (pkg_*.xml), then sub-extension manifests
|
||||
$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})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
protected function run(): int
|
||||
{
|
||||
$path = $this->getArgument('--path');
|
||||
$type = 'patch';
|
||||
if ($this->getArgument('--minor')) {
|
||||
$type = 'minor';
|
||||
}
|
||||
if ($this->getArgument('--major')) {
|
||||
$type = 'major';
|
||||
}
|
||||
$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") ?: []
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Use the higher version as base ───────────────────────────────────────────
|
||||
$baseVersion = null;
|
||||
|
||||
if ($readmeVersion !== null && $manifestVersion !== null) {
|
||||
$baseVersion = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
|
||||
} elseif ($manifestVersion !== null) {
|
||||
$baseVersion = $manifestVersion;
|
||||
} elseif ($readmeVersion !== null) {
|
||||
$baseVersion = $readmeVersion;
|
||||
}
|
||||
|
||||
if ($baseVersion === null) {
|
||||
fwrite(STDERR, "No version found in README.md or manifest 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);
|
||||
|
||||
// ── Update README.md ─────────────────────────────────────────────────────────
|
||||
if (file_exists($readme) && !empty($readmeContent)) {
|
||||
$updated = preg_replace(
|
||||
'/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
|
||||
'${1}' . $new,
|
||||
$readmeContent,
|
||||
1
|
||||
);
|
||||
file_put_contents($readme, $updated);
|
||||
}
|
||||
|
||||
echo "{$old} → {$new}\n";
|
||||
exit(0);
|
||||
$app = new VersionBumpCli();
|
||||
exit($app->execute());
|
||||
|
||||
+205
-233
@@ -1,233 +1,205 @@
|
||||
#!/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/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 GA_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;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
if ($token === null) $token = getenv('GA_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
|
||||
{
|
||||
$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) ?: [];
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
#!/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/version_bump_remote.php
|
||||
* BRIEF: Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
class VersionBumpRemoteCli extends CliFramework
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new VersionBumpRemoteCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/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/version_check.php
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\{CliFramework, SourceResolver};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
$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];
|
||||
}
|
||||
}
|
||||
$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];
|
||||
}
|
||||
}
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
$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];
|
||||
}
|
||||
}
|
||||
$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) {
|
||||
$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";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new VersionCheckCli();
|
||||
exit($app->execute());
|
||||
+162
-52
@@ -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,66 +10,175 @@
|
||||
* INGROUP: moko-platform
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /cli/version_read.php
|
||||
* BRIEF: Read version from README.md or manifest XML — outputs the higher of the two
|
||||
* BRIEF: Read version — manifest.xml is canonical, falls back to README.md and Joomla XML
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
// ── Read from 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];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read from 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})(?:-[a-z]+)?</version>|', $xmlContent, $xm)) {
|
||||
$candidate = $xm[1];
|
||||
if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
|
||||
$manifestVersion = $candidate;
|
||||
// -- 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";
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -- 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];
|
||||
}
|
||||
}
|
||||
|
||||
// -- 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output the higher version ────────────────────────────────────────────────
|
||||
$version = null;
|
||||
if ($readmeVersion !== null && $manifestVersion !== null) {
|
||||
$version = version_compare($manifestVersion, $readmeVersion, '>') ? $manifestVersion : $readmeVersion;
|
||||
} elseif ($manifestVersion !== null) {
|
||||
$version = $manifestVersion;
|
||||
} elseif ($readmeVersion !== null) {
|
||||
$version = $readmeVersion;
|
||||
}
|
||||
|
||||
if ($version === null) {
|
||||
fwrite(STDERR, "No version found in README.md or manifest XML\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo $version . "\n";
|
||||
exit(0);
|
||||
$app = new VersionReadCli();
|
||||
exit($app->execute());
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/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/version_reset_dev.php
|
||||
* BRIEF: Reset platform version to 'development' on a branch via Gitea API
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class VersionResetDevCli extends CliFramework
|
||||
{
|
||||
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', '');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
if ($token === '' || $apiBase === '') {
|
||||
$this->log('ERROR', '--token and --api-base are required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$apiBase = rtrim($apiBase, '/');
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
|
||||
if ($platform === '' && $path !== '') {
|
||||
$platform = $this->detectPlatform($path) ?? '';
|
||||
if ($platform !== '') {
|
||||
echo "Detected platform: {$platform}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($platform === '') {
|
||||
$this->log('ERROR', 'Could not determine platform. Use --platform or --path.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Dispatch by platform ─────────────────────────────────────────────────────
|
||||
|
||||
$changed = 0;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
echo "Reset {$changed} file(s) to 'development' on branch '{$branch}'.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function detectPlatform(string $repoPath): ?string
|
||||
{
|
||||
$root = realpath($repoPath) ?: $repoPath;
|
||||
$manifestXml = "{$root}/.mokogitea/manifest.xml";
|
||||
|
||||
if (!file_exists($manifestXml)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
"Authorization: token {$token}",
|
||||
'Accept: application/json',
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
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 || !is_string($response) || $response === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
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
-139
@@ -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,160 +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');
|
||||
|
||||
// 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 !== $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());
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/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/wiki_sync.php
|
||||
* VERSION: 09.25.03
|
||||
* BRIEF: Sync select wiki pages from moko-platform to all template repos
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoEnterprise\CliFramework;
|
||||
|
||||
class WikiSyncCli extends CliFramework
|
||||
{
|
||||
private string $giteaUrl = 'https://git.mokoconsulting.tech';
|
||||
private string $token = '';
|
||||
private string $org = 'MokoConsulting';
|
||||
private string $sourceRepo = 'moko-platform';
|
||||
private array $targetRepos = [];
|
||||
private array $pages = [];
|
||||
private bool $allTemplates = false;
|
||||
private bool $allStandards = false;
|
||||
|
||||
private int $synced = 0;
|
||||
private int $created = 0;
|
||||
private int $skipped = 0;
|
||||
private int $errors = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$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.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (empty($this->pages) && !$this->allStandards) {
|
||||
$this->log('ERROR', '--page or --all-standards is required.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Discover template repos if --all-templates
|
||||
if ($this->allTemplates || empty($this->targetRepos)) {
|
||||
$this->targetRepos = $this->discoverTemplateRepos();
|
||||
}
|
||||
|
||||
if (empty($this->targetRepos)) {
|
||||
$this->log('INFO', 'No target repos found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If --all-standards, get all pages that start with uppercase
|
||||
if (empty($this->pages)) {
|
||||
$this->pages = $this->getStandardsPages();
|
||||
}
|
||||
|
||||
$this->log('INFO', "Syncing " . count($this->pages) . " page(s) to " . count($this->targetRepos) . " repo(s)");
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', "[DRY RUN] No changes will be made.\n");
|
||||
}
|
||||
|
||||
foreach ($this->pages as $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->errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->targetRepos as $repo) {
|
||||
$existing = $this->getWikiPage($repo, $pageName);
|
||||
if ($existing !== null && $existing === $sourceContent) {
|
||||
$this->log('INFO', " {$repo}: IDENTICAL (skipped)");
|
||||
$this->skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$action = $existing !== null ? 'WOULD UPDATE' : 'WOULD CREATE';
|
||||
$this->log('INFO', " {$repo}: {$action}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($existing !== null) {
|
||||
$ok = $this->updateWikiPage($repo, $pageName, $sourceContent);
|
||||
$this->log('INFO', " {$repo}: " . ($ok ? 'UPDATED' : 'ERROR'));
|
||||
$ok ? $this->synced++ : $this->errors++;
|
||||
} else {
|
||||
$ok = $this->createWikiPage($repo, $pageName, $sourceContent);
|
||||
$this->log('INFO', " {$repo}: " . ($ok ? 'CREATED' : 'ERROR'));
|
||||
$ok ? $this->created++ : $this->errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('INFO', "\nDone: {$this->synced} updated, {$this->created} created, {$this->skipped} skipped, {$this->errors} error(s)");
|
||||
return $this->errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function discoverTemplateRepos(): array
|
||||
{
|
||||
$repos = $this->apiGet("/orgs/{$this->org}/repos?limit=100");
|
||||
$templates = [];
|
||||
foreach ($repos as $repo) {
|
||||
if (str_starts_with($repo['name'], 'Template-') && !($repo['archived'] ?? false)) {
|
||||
$templates[] = $repo['name'];
|
||||
}
|
||||
}
|
||||
sort($templates);
|
||||
$this->log('INFO', "Found template repos: " . implode(', ', $templates));
|
||||
return $templates;
|
||||
}
|
||||
|
||||
private function getStandardsPages(): array
|
||||
{
|
||||
$pages = $this->apiGet("/repos/{$this->org}/{$this->sourceRepo}/wiki/pages");
|
||||
$standards = [];
|
||||
foreach ($pages as $page) {
|
||||
$title = $page['title'] ?? '';
|
||||
// Sync pages that are all-caps with underscores (standards pages)
|
||||
if (preg_match('/^[A-Z][A-Z0-9_-]+$/', $title)) {
|
||||
$standards[] = $title;
|
||||
}
|
||||
}
|
||||
sort($standards);
|
||||
$this->log('INFO', "Found " . count($standards) . " standards pages: " . implode(', ', $standards));
|
||||
return $standards;
|
||||
}
|
||||
|
||||
private function getWikiPage(string $repo, string $pageName): ?string
|
||||
{
|
||||
$data = $this->apiGet("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}");
|
||||
if ($data === null || !isset($data['content_base64'])) {
|
||||
return null;
|
||||
}
|
||||
return base64_decode($data['content_base64']);
|
||||
}
|
||||
|
||||
private function createWikiPage(string $repo, string $pageName, string $content): bool
|
||||
{
|
||||
$payload = json_encode([
|
||||
'title' => $pageName,
|
||||
'content_base64' => base64_encode($content),
|
||||
]);
|
||||
return $this->apiPost("/repos/{$this->org}/{$repo}/wiki/new", $payload) !== null;
|
||||
}
|
||||
|
||||
private function updateWikiPage(string $repo, string $pageName, string $content): bool
|
||||
{
|
||||
$payload = json_encode([
|
||||
'title' => $pageName,
|
||||
'content_base64' => base64_encode($content),
|
||||
]);
|
||||
return $this->apiPatch("/repos/{$this->org}/{$repo}/wiki/page/{$pageName}", $payload) !== null;
|
||||
}
|
||||
|
||||
private function apiGet(string $endpoint): ?array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => "Authorization: token {$this->token}\r\nAccept: application/json\r\n",
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
];
|
||||
$ctx = stream_context_create($opts);
|
||||
$result = @file_get_contents($url, false, $ctx);
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($result, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function apiPost(string $endpoint, string $payload): ?array
|
||||
{
|
||||
return $this->apiWrite('POST', $endpoint, $payload);
|
||||
}
|
||||
|
||||
private function apiPatch(string $endpoint, string $payload): ?array
|
||||
{
|
||||
return $this->apiWrite('PATCH', $endpoint, $payload);
|
||||
}
|
||||
|
||||
private function apiWrite(string $method, string $endpoint, string $payload): ?array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1{$endpoint}";
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'header' => "Authorization: token {$this->token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
|
||||
'content' => $payload,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
];
|
||||
$ctx = stream_context_create($opts);
|
||||
$result = @file_get_contents($url, false, $ctx);
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($result, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
|
||||
$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.03
|
||||
* 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": "07.00.00",
|
||||
"version": "09.23.00",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Client Repository Structure Definition
|
||||
* Standard repository structure for managed Joomla client sites (WaaS)
|
||||
*
|
||||
* This is NOT a Joomla extension — it's a full managed client site with
|
||||
* deployment configs, monitoring, SFTP settings, and sync workflows.
|
||||
* The src/ directory mirrors the Joomla site's public_html.
|
||||
*
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* Schema Version: 1.0
|
||||
*/
|
||||
|
||||
locals {
|
||||
repository_structure = {
|
||||
metadata = {
|
||||
name = "Client Site"
|
||||
description = "Managed Joomla client site — full site structure, not an extension"
|
||||
repository_type = "client"
|
||||
platform = "client"
|
||||
last_updated = "2026-05-09T00:00:00Z"
|
||||
maintainer = "Moko Consulting"
|
||||
version = "01.00.00"
|
||||
schema_version = "1.0"
|
||||
}
|
||||
|
||||
detection_hints = [
|
||||
"scripts/sftp-config/",
|
||||
"scripts/sync-dev-to-live.sh",
|
||||
"monitoring/grafana/",
|
||||
"src/administrator/",
|
||||
"src/components/",
|
||||
"src/plugins/",
|
||||
"src/templates/",
|
||||
"src/media/templates/site/mokoonyx/"
|
||||
]
|
||||
|
||||
root_files = [
|
||||
{
|
||||
name = "README.md"
|
||||
extension = "md"
|
||||
description = "Client site overview and deployment info"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
protected = true
|
||||
},
|
||||
{
|
||||
name = "CHANGELOG.md"
|
||||
extension = "md"
|
||||
description = "Release history"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "LICENSE"
|
||||
extension = ""
|
||||
description = "GPL-3.0-or-later license file"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/docs/required/LICENSE"
|
||||
},
|
||||
{
|
||||
name = "Makefile"
|
||||
extension = ""
|
||||
description = "Build and deployment targets (includes minify)"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "composer.json"
|
||||
extension = "json"
|
||||
description = "PHP dependencies"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = ".gitignore"
|
||||
extension = ""
|
||||
description = "Git ignore rules (must include *.min.css, *.min.js, TODO.md)"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
}
|
||||
]
|
||||
|
||||
directories = [
|
||||
{
|
||||
name = "src"
|
||||
path = "src"
|
||||
description = "Joomla site public_html mirror — deployed via SFTP"
|
||||
required = true
|
||||
purpose = "Contains the full Joomla site directory structure"
|
||||
subdirectories = [
|
||||
{ name = "administrator", path = "src/administrator", description = "Joomla admin", required = true },
|
||||
{ name = "components", path = "src/components", description = "Frontend components", required = true },
|
||||
{ name = "plugins", path = "src/plugins", description = "Plugins", required = true },
|
||||
{ name = "modules", path = "src/modules", description = "Modules", required = true },
|
||||
{ name = "templates", path = "src/templates", description = "Templates", required = true },
|
||||
{ name = "media", path = "src/media", description = "Media assets", required = true },
|
||||
{ name = "images", path = "src/images", description = "Site images", required = false },
|
||||
{ name = "language", path = "src/language", description = "Language files", required = false },
|
||||
{ name = "libraries", path = "src/libraries", description = "Libraries", required = false },
|
||||
{ name = "layouts", path = "src/layouts", description = "Layouts", required = false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "scripts"
|
||||
path = "scripts"
|
||||
description = "Deployment, sync, and monitoring scripts"
|
||||
required = true
|
||||
purpose = "Contains SFTP configs, sync scripts, and monitoring"
|
||||
subdirectories = [
|
||||
{
|
||||
name = "sftp-config"
|
||||
path = "scripts/sftp-config"
|
||||
description = "SFTP connection configs (dev + live)"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "sftp-config.dev.json"
|
||||
extension = "json"
|
||||
description = "Dev server SFTP connection"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
},
|
||||
{
|
||||
name = "sftp-config.rs.json"
|
||||
extension = "json"
|
||||
description = "Live/release server SFTP connection"
|
||||
required = true
|
||||
always_overwrite = false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = "monitoring"
|
||||
path = "monitoring"
|
||||
description = "Grafana dashboard templates"
|
||||
required = true
|
||||
purpose = "Contains Panopticon-style Grafana dashboard JSON"
|
||||
subdirectories = [
|
||||
{
|
||||
name = "grafana"
|
||||
path = "monitoring/grafana"
|
||||
description = "Grafana dashboard JSON templates"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "client-joomla-dashboard.json"
|
||||
extension = "json"
|
||||
description = "Panopticon-style Grafana dashboard template"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
template = "templates/monitoring/client-joomla-dashboard.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name = ".gitea"
|
||||
path = ".gitea"
|
||||
description = "Gitea configuration"
|
||||
required = true
|
||||
purpose = "Contains Gitea Actions workflows"
|
||||
subdirectories = [
|
||||
{
|
||||
name = "workflows"
|
||||
path = ".gitea/workflows"
|
||||
description = "Gitea Actions CI/CD workflows"
|
||||
required = true
|
||||
files = [
|
||||
{
|
||||
name = "auto-release.yml"
|
||||
extension = "yml"
|
||||
description = "Auto-release on merge to main"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
},
|
||||
{
|
||||
name = "deploy.yml"
|
||||
extension = "yml"
|
||||
description = "Deploy src/ to servers via SFTP"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
},
|
||||
{
|
||||
name = "add-endpoint.yml"
|
||||
extension = "yml"
|
||||
description = "Add monitoring endpoint to sites.json"
|
||||
required = true
|
||||
always_overwrite = true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
output "client_structure" {
|
||||
description = "Client site repository structure definition"
|
||||
value = local.repository_structure
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"metadata": {
|
||||
"name": "Default Repository Structure",
|
||||
"description": "Default repository structure applicable to all repository types with minimal requirements",
|
||||
"repositoryType": "library",
|
||||
"platform": "multi-platform",
|
||||
"lastUpdated": "2026-01-16T00:00:00Z",
|
||||
"maintainer": "Moko Consulting"
|
||||
},
|
||||
"structure": {
|
||||
"rootFiles": [
|
||||
{
|
||||
"name": "README.md",
|
||||
"extension": "md",
|
||||
"description": "Project overview and documentation",
|
||||
"requirementStatus": "required",
|
||||
"audience": "general",
|
||||
"template": "templates/docs/required/template-README.md"
|
||||
},
|
||||
{
|
||||
"name": "LICENSE",
|
||||
"extension": "",
|
||||
"description": "License file (GPL-3.0-or-later)",
|
||||
"requirementStatus": "required",
|
||||
"audience": "general",
|
||||
"template": "templates/licenses/GPL-3.0"
|
||||
},
|
||||
{
|
||||
"name": "CHANGELOG.md",
|
||||
"extension": "md",
|
||||
"description": "Version history and changes",
|
||||
"requirementStatus": "required",
|
||||
"audience": "general",
|
||||
"template": "templates/docs/required/template-CHANGELOG.md"
|
||||
},
|
||||
{
|
||||
"name": "CONTRIBUTING.md",
|
||||
"extension": "md",
|
||||
"description": "Contribution guidelines",
|
||||
"requirementStatus": "required",
|
||||
"audience": "contributor",
|
||||
"template": "templates/docs/required/template-CONTRIBUTING.md"
|
||||
},
|
||||
{
|
||||
"name": "SECURITY.md",
|
||||
"extension": "md",
|
||||
"description": "Security policy and vulnerability reporting",
|
||||
"requirementStatus": "required",
|
||||
"audience": "general",
|
||||
"template": "templates/docs/required/template-SECURITY.md"
|
||||
},
|
||||
{
|
||||
"name": "CODE_OF_CONDUCT.md",
|
||||
"extension": "md",
|
||||
"description": "Community code of conduct",
|
||||
"requirementStatus": "suggested",
|
||||
"audience": "contributor",
|
||||
"template": "templates/docs/extra/template-CODE_OF_CONDUCT.md"
|
||||
},
|
||||
{
|
||||
"name": ".gitignore",
|
||||
"extension": "gitignore",
|
||||
"description": "Git ignore patterns",
|
||||
"requirementStatus": "required",
|
||||
"alwaysOverwrite": false,
|
||||
"audience": "developer"
|
||||
},
|
||||
{
|
||||
"name": ".gitattributes",
|
||||
"extension": "gitattributes",
|
||||
"description": "Git attributes configuration",
|
||||
"requirementStatus": "required",
|
||||
"audience": "developer"
|
||||
},
|
||||
{
|
||||
"name": ".editorconfig",
|
||||
"extension": "editorconfig",
|
||||
"description": "Editor configuration for consistent coding style",
|
||||
"requirementStatus": "required",
|
||||
"alwaysOverwrite": false,
|
||||
"audience": "developer"
|
||||
},
|
||||
{
|
||||
"name": "Makefile",
|
||||
"description": "Build automation",
|
||||
"requirementStatus": "suggested",
|
||||
"audience": "developer"
|
||||
},
|
||||
{
|
||||
"name": "renovate.json",
|
||||
"extension": "json",
|
||||
"description": "Renovate dependency management configuration",
|
||||
"requirementStatus": "required",
|
||||
"alwaysOverwrite": false,
|
||||
"audience": "developer",
|
||||
"template": "templates/configs/renovate.json"
|
||||
}
|
||||
],
|
||||
"directories": [
|
||||
{
|
||||
"name": "docs",
|
||||
"path": "docs",
|
||||
"description": "Documentation directory",
|
||||
"requirementStatus": "required",
|
||||
"purpose": "Contains comprehensive project documentation",
|
||||
"files": [
|
||||
{
|
||||
"name": "index.md",
|
||||
"extension": "md",
|
||||
"description": "Documentation index",
|
||||
"requirementStatus": "suggested"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "scripts",
|
||||
"path": "scripts",
|
||||
"description": "Build and automation scripts",
|
||||
"requirementStatus": "required",
|
||||
"purpose": "Contains scripts for building, testing, and deploying"
|
||||
},
|
||||
{
|
||||
"name": "src",
|
||||
"path": "src",
|
||||
"description": "Source code directory",
|
||||
"requirementStatus": "required",
|
||||
"purpose": "Contains application source code"
|
||||
},
|
||||
{
|
||||
"name": "tests",
|
||||
"path": "tests",
|
||||
"description": "Test files",
|
||||
"requirementStatus": "suggested",
|
||||
"purpose": "Contains unit tests, integration tests, and test fixtures",
|
||||
"subdirectories": [
|
||||
{
|
||||
"name": "unit",
|
||||
"path": "tests/unit",
|
||||
"description": "Unit tests",
|
||||
"requirementStatus": "suggested"
|
||||
},
|
||||
{
|
||||
"name": "integration",
|
||||
"path": "tests/integration",
|
||||
"description": "Integration tests",
|
||||
"requirementStatus": "optional"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": ".github",
|
||||
"path": ".github",
|
||||
"description": "Gitea/GitHub Actions configuration (Gitea reads .github/workflows natively)",
|
||||
"requirementStatus": "required",
|
||||
"purpose": "Contains CI/CD workflows and repository configuration. Gitea is the primary platform; GitHub is backup only.",
|
||||
"subdirectories": [
|
||||
{
|
||||
"name": "workflows",
|
||||
"path": ".github/workflows",
|
||||
"description": "CI/CD workflows (Gitea-primary, GitHub-compatible)",
|
||||
"requirementStatus": "required",
|
||||
"requiredFiles": [
|
||||
"auto-assign.yml",
|
||||
"auto-dev-issue.yml",
|
||||
"auto-release.yml",
|
||||
"branch-freeze.yml",
|
||||
"changelog-validation.yml",
|
||||
"repository-cleanup.yml",
|
||||
"sync-version-on-merge.yml",
|
||||
"cascade-dev.yml",
|
||||
"gitleaks.yml"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "node_modules",
|
||||
"path": "node_modules",
|
||||
"description": "Node.js dependencies (generated)",
|
||||
"requirementStatus": "not-allowed",
|
||||
"purpose": "Generated directory that should not be committed"
|
||||
},
|
||||
{
|
||||
"name": "vendor",
|
||||
"path": "vendor",
|
||||
"description": "PHP dependencies (generated)",
|
||||
"requirementStatus": "not-allowed",
|
||||
"purpose": "Generated directory that should not be committed"
|
||||
},
|
||||
{
|
||||
"name": "build",
|
||||
"path": "build",
|
||||
"description": "Build artifacts (generated)",
|
||||
"requirementStatus": "not-allowed",
|
||||
"purpose": "Generated directory that should not be committed"
|
||||
},
|
||||
{
|
||||
"name": "dist",
|
||||
"path": "dist",
|
||||
"description": "Distribution files (generated)",
|
||||
"requirementStatus": "not-allowed",
|
||||
"purpose": "Generated directory that should not be committed"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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